{
  "schema_version": "1.0.0",
  "bot_id": "4.6",
  "bot_name": "SocialSentiment",
  "slug": "socialsentiment",
  "layer": "Intelligence",
  "layer_key": "intel",
  "bot_class": "Signal Service",
  "authority": [
    "Read-only"
  ],
  "status": "planned",
  "readiness": "Spec started",
  "flagship": false,
  "is_reference": false,
  "public_export": false,
  "identity": {
    "layer": "Intelligence",
    "bot_class": "Signal Service",
    "authority": "Read-only",
    "runs_before": "",
    "runs_after": "",
    "applies_to": "",
    "default_mode": "shadow_only",
    "user_visible": "Advanced details only",
    "developer_owner": "Polytraders core"
  },
  "purpose": "Lightweight, secondary social-sentiment input \u2014 never primary trigger.",
  "why_it_matters": [
    {
      "failure": "Strategy uses sentiment as a primary trigger",
      "consequence": "Social sentiment is noisy, gameable, and easily manipulated by bot networks. Treated as a primary signal it produces churn and drawdowns; treated as a secondary input \u2014 'is the prior consistent with public mood?' \u2014 it adds a small but real filter.",
      "worked_example": {
        "setup": "A strategy reads `sentiment_score=+0.81` on a political market and treats it as a buy trigger. The +0.81 is driven by 14k posts in the last 30 minutes, 9k of which come from 12 wallets posting at 8 Hz.",
        "without_bot": "The strategy enters at 0.74. Within 12 minutes the bot-driven posts stop, sentiment normalises to +0.05, and the price drifts back to 0.61. The strategy churns out for a 13% loss it never had a real thesis behind.",
        "with_bot": "SocialSentiment emits `{score: +0.81, provenance: 'twitter-v2', concentration: 0.73, primary_trigger_allowed: false}`. The strategy ignores it as a primary trigger and only widens its existing fundamental thesis. No entry occurs on the bot-driven spike."
      }
    },
    {
      "failure": "No freshness or provenance on sentiment numbers",
      "consequence": "A sentiment score with no source string and no timestamp cannot be audited or replayed. Every emission must carry both, or downstream consumers have no way to reason about the data they are reading."
    },
    {
      "failure": "Sentiment signal duplicated across teams",
      "consequence": "Strategy authors otherwise build one-off sentiment fetchers each. Centralising the read gives a single rate-limited source, a single cache, and a single place to swap providers when one degrades."
    }
  ],
  "polymarket_inputs": [
    {
      "input": "Social post feed per market slug",
      "source": "ws_sports",
      "required": true,
      "use": "Primary sentiment scoring data source."
    },
    {
      "input": "Market slug and condition_id mapping",
      "source": "data",
      "required": true,
      "use": "Resolve market slug to condition_id for ObservationReport output."
    }
  ],
  "internal_inputs": [
    {
      "input": "KillSwitch active flag",
      "source": "KillSwitch",
      "required": true,
      "use": "Suppress all sentiment emissions when KillSwitch is active."
    }
  ],
  "raw_params": [
    "weight_in_signal \u00b7 0\u20130.3 (capped)",
    "decay_half_life_min \u00b7 int",
    "per_topic_rate_cap \u00b7 int",
    "require_secondary_only \u00b7 bool (locked true)"
  ],
  "parameters": [
    {
      "name": "min_post_count",
      "default": 10,
      "warning": 5,
      "hard": 1,
      "controls": "Minimum number of posts in the sampling window required to emit a sentiment signal.",
      "why_default_matters": "10 posts provides a statistically meaningful sample; fewer posts produce unreliable scores.",
      "threshold_logic": [
        {
          "condition": "posts >= 10",
          "action": "Normal \u2014 emit ObservationReport"
        },
        {
          "condition": "5\u20139 posts",
          "action": "WARN \u2014 low sample; emit with SOCIALSENTIMENT_LOW_SAMPLE warning"
        },
        {
          "condition": "< 1 post",
          "action": "Reject \u2014 skip emission entirely"
        }
      ],
      "dev_check": "if (p.min_post_count < p.hard) return;",
      "user_facing": "Sentiment signals require a minimum number of recent posts to be reliable."
    },
    {
      "name": "poll_interval_s",
      "default": 60,
      "warning": 300,
      "hard": 600,
      "controls": "Seconds between social feed polls per market slug.",
      "why_default_matters": "60 s provides near-real-time sentiment without overloading the social feed API.",
      "threshold_logic": [
        {
          "condition": "interval <= 60 s",
          "action": "Normal"
        },
        {
          "condition": "60\u2013300 s",
          "action": "WARN \u2014 reduced signal freshness"
        },
        {
          "condition": "> 600 s",
          "action": "Reject \u2014 PARAMETER_CHANGE_REQUIRES_APPROVAL"
        }
      ],
      "dev_check": "if (p.poll_interval_s > p.hard) throw ConfigError('PARAMETER_CHANGE_REQUIRES_APPROVAL');",
      "user_facing": "Social feed is polled regularly to ensure sentiment signals are fresh."
    }
  ],
  "default_config": {
    "bot_id": "intel.socialsentiment",
    "version": "0.1.0",
    "mode": "planned",
    "defaults": {
      "min_post_count": 10,
      "poll_interval_s": 60,
      "sample_rate": 10
    },
    "locked": {
      "min_post_count": {
        "min": 1
      },
      "poll_interval_s": {
        "max": 600
      }
    }
  },
  "implementation_flow": [],
  "decision_logic": {
    "approve": "",
    "reshape_required": "",
    "reject": "",
    "warning_only": ""
  },
  "decision_output_schema": "RiskVote",
  "decision_output_example": {
    "report_id": "rep_ss_btc100k_1746703000000",
    "trace_id": "trc_0xbeef0102030405060708",
    "bot_id": "intel.socialsentiment",
    "kind": "ObservationReport",
    "market_slug": "btc-price-above-100k-dec-2026",
    "sentiment_score": 0.62,
    "post_count": 42,
    "window_s": 3600,
    "warnings": [],
    "emitted_at_ms": 1746703010000
  },
  "developer_log": {
    "bot_id": "intel.socialsentiment",
    "market_slug": "btc-price-above-100k-dec-2026",
    "post_count": 42,
    "sentiment_score": 0.62,
    "low_sample_skips": 0,
    "killswitch_active": false,
    "emitted_at_ms": 1746703010000
  },
  "user_explanations": [
    {
      "situation": "Strategy adjusted position size based on positive sentiment signal",
      "message": "Recent social discussion about this market shows a positive trend. The system used this as one factor in its assessment, alongside price and liquidity data."
    },
    {
      "situation": "No sentiment signal emitted for a market",
      "message": "There was not enough recent discussion about this market to generate a reliable sentiment signal. Signals require a minimum volume of posts."
    }
  ],
  "failure_modes": {
    "main_failure_mode": "Social feed API outage during high-activity periods causes SocialSentiment to miss sentiment spikes, leaving downstream strategies without fresh signal data.",
    "false_positive_risk": "A coordinated posting burst by a small group of accounts inflates the sentiment score for a market without reflecting genuine market opinion.",
    "false_negative_risk": "Genuine sentiment shift occurs outside the polling window or below the min_post_count threshold, causing the signal to be suppressed.",
    "safe_fallback": "If feed returns an error or zero posts for > 5 min, emit STALE_DATA WARN and suppress further emissions until feed recovers.",
    "required_dependencies": [
      "ws_sports social feed",
      "KillSwitch active flag",
      "Data API for slug resolution"
    ]
  },
  "acceptance_tests": {
    "unit": [
      {
        "test": "High-volume market emits ObservationReport after poll",
        "setup": "42 posts, sentiment_score=0.62, poll_interval_s=60",
        "expected": "ObservationReport emitted with sentiment_score=0.62, post_count=42"
      },
      {
        "test": "Low sample below hard floor skips emission",
        "setup": "0 posts in window, min_post_count hard=1",
        "expected": "No ObservationReport; low_sample_skips counter incremented"
      },
      {
        "test": "KillSwitch suppresses emission",
        "setup": "killswitch.active=true; 50 posts available",
        "expected": "No ObservationReport; KILL_SWITCH_ACTIVE logged"
      }
    ],
    "integration": [
      {
        "test": "Full lifecycle: feed poll \u2192 sentiment score \u2192 ObservationReport consumed by strategy",
        "expected": "Strategy receives ObservationReport with correct sentiment_score and market_slug"
      },
      {
        "test": "Feed outage: STALE_DATA emitted after 5 min of feed errors",
        "expected": "STALE_DATA WARN logged; no ObservationReports during outage"
      }
    ],
    "property": [
      {
        "property": "SocialSentiment never submits or signs orders",
        "required": "Always true"
      },
      {
        "property": "No ObservationReport emitted when KillSwitch is active",
        "required": "Always true"
      }
    ]
  },
  "checklist_overrides": {},
  "legacy_goal": "Lightweight, secondary social-sentiment input \u2014 never primary trigger.",
  "legacy_pm_signals": [],
  "legacy_external_feeds": [
    "Public social streams (rate-capped, decay-weighted)",
    "Sarcasm-aware classifier (local)"
  ],
  "reporting_groups": [
    "pretrade_intel"
  ],
  "network": [
    "polygon"
  ],
  "api_surface": [
    "ws_sports",
    "data",
    "internal"
  ],
  "version": {
    "spec": "2.0.0",
    "implementation": "0.1.0",
    "schema": "2",
    "released": null,
    "planned_release": "Q3-2026"
  },
  "migration_history": [
    {
      "date": "2026-04-28",
      "from": "n/a",
      "to": "v2-spec",
      "reason": "Spec drafted post-CLOB-V2 cutover; bot not yet implemented",
      "action_taken": "Designed against V2 schema (pUSD, builder codes, V2 EIP-712 domain)"
    }
  ],
  "polymarket_v2_compat": {
    "clob_version": "v2",
    "collateral": "pUSD",
    "eip712_domain_version": "2",
    "builder_code_aware": false,
    "negrisk_aware": false,
    "multichain_ready": false,
    "sdk_used": "py-clob-client-v2",
    "settlement_contract": "CTFExchangeV2",
    "notes": "Read-only signal service; references Polymarket Gamma market slugs and pUSD volumes. No order signing."
  },
  "reference_implementation": {
    "pseudocode": "FUNCTION pollSentiment(market_slug, window_s):\n  // 0. KillSwitch check\n  IF FETCH internal.killswitch.status == ACTIVE:\n    RETURN\n\n  // 1. Fetch recent social posts for market slug\n  posts = FETCH ws_sports.social_feed(slug=market_slug, since=now()-window_s)\n  IF len(posts) < min_post_count:\n    EMIT WARN 'SOCIALSENTIMENT_LOW_SAMPLE'\n    RETURN\n\n  // 2. Score each post\n  scores = [scoreSentiment(p) for p in posts]\n  agg = mean(scores)\n\n  // 3. Staleness check\n  IF now() - posts[0].ts > staleness_threshold_s:\n    EMIT WARN 'STALE_DATA'\n    RETURN\n\n  // 4. Sample gate (high-volume)\n  IF sample_counter % sample_rate != 0:\n    sample_counter++\n    RETURN\n\n  // 5. Emit ObservationReport\n  EMIT ObservationReport {\n    report_id: gen_id(),\n    kind: 'ObservationReport',\n    market_slug: market_slug,\n    sentiment_score: agg,\n    post_count: len(posts),\n    window_s: window_s,\n    emitted_at_ms: now_ms()\n  }\n  sample_counter = 0",
    "sdk_calls": [
      "ws_sports.social_feed(slug, since)",
      "data.GET('/markets?slug=<slug>')",
      "internal.killswitch.status"
    ],
    "complexity": "O(P) per poll where P = posts in window"
  },
  "wire_examples": {
    "input": {
      "label": "Social feed poll for a market slug",
      "source": "ws_sports",
      "payload": {
        "market_slug": "btc-price-above-100k-dec-2026",
        "window_s": 3600,
        "post_count": 42,
        "timestamp_ms": 1746703000000
      }
    },
    "output": {
      "label": "ObservationReport \u2014 positive sentiment signal",
      "payload": {
        "report_id": "rep_ss_btc100k_1746703000000",
        "trace_id": "trc_0xbeef0102030405060708",
        "bot_id": "intel.socialsentiment",
        "kind": "ObservationReport",
        "market_slug": "btc-price-above-100k-dec-2026",
        "sentiment_score": 0.62,
        "post_count": 42,
        "window_s": 3600,
        "emitted_at_ms": 1746703010000
      }
    }
  },
  "reason_codes": [
    {
      "code": "SOCIALSENTIMENT_LOW_SAMPLE",
      "severity": "WARN",
      "meaning": "Fewer posts than min_post_count in the sampling window; sentiment score unreliable.",
      "action": "Skip emission for this poll cycle.",
      "user_message": "Not enough recent discussion to generate a reliable sentiment signal."
    },
    {
      "code": "STALE_DATA",
      "severity": "WARN",
      "meaning": "Most recent post is older than staleness_threshold_s; feed may be lagging.",
      "action": "Suppress emission; alert on-call if condition persists > 5 min.",
      "user_message": ""
    },
    {
      "code": "KILL_SWITCH_ACTIVE",
      "severity": "HARD_REJECT",
      "meaning": "KillSwitch is active; all sentiment emissions suppressed.",
      "action": "Continue polling but suppress all ObservationReport emissions.",
      "user_message": "Sentiment signals paused while trading is suspended system-wide."
    },
    {
      "code": "SOCIALSENTIMENT_FEED_ERROR",
      "severity": "WARN",
      "meaning": "Social feed returned a non-200 status or empty payload unexpectedly.",
      "action": "Log WARN; skip this poll cycle; retry on next interval.",
      "user_message": ""
    },
    {
      "code": "PARAMETER_CHANGE_REQUIRES_APPROVAL",
      "severity": "HARD_REJECT",
      "meaning": "A parameter violates a locked bound (e.g. sample_rate < 1).",
      "action": "Reject config change; do not apply.",
      "user_message": ""
    }
  ],
  "metrics": {
    "emitted": [
      {
        "name": "polytraders_intel_socialsentiment_observations_emitted_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "market_slug"
        ],
        "meaning": "ObservationReports emitted per market slug."
      },
      {
        "name": "polytraders_intel_socialsentiment_low_sample_skips_total",
        "type": "counter",
        "unit": "count",
        "labels": [],
        "meaning": "Poll cycles skipped due to insufficient post count."
      },
      {
        "name": "polytraders_intel_socialsentiment_feed_latency_ms",
        "type": "histogram",
        "unit": "ms",
        "labels": [],
        "meaning": "Latency of social feed fetch per poll cycle."
      },
      {
        "name": "polytraders_intel_socialsentiment_sentiment_score",
        "type": "gauge",
        "unit": "score",
        "labels": [
          "market_slug"
        ],
        "meaning": "Latest aggregated sentiment score per market slug (-1 to +1)."
      }
    ],
    "alerts": [
      {
        "name": "SocialSentimentFeedStale",
        "condition": "rate(polytraders_intel_socialsentiment_observations_emitted_total[10m]) == 0",
        "severity": "warn",
        "runbook": "#runbook-socialsentiment-feed-stale"
      },
      {
        "name": "SocialSentimentHighSkipRate",
        "condition": "rate(polytraders_intel_socialsentiment_low_sample_skips_total[10m]) > 5",
        "severity": "warn",
        "runbook": "#runbook-socialsentiment-high-skip-rate"
      }
    ],
    "dashboards": [
      "Grafana \u2014 Intelligence / SocialSentiment emission rate per market",
      "Grafana \u2014 Intelligence / SocialSentiment score distribution"
    ],
    "log_level": "info"
  },
  "state": {
    "store": "redis",
    "shape": "Per-slug: last_emitted_at_ms, last_sentiment_score, sample_counter. TTL-keyed cache of recent scores.",
    "ttl": "Per-slug state expires after 6 h of no activity",
    "recovery": "On cold start, state re-initialised from next poll cycle; no historical backfill required.",
    "size_estimate": "~500 bytes per tracked market slug"
  },
  "concurrency": {
    "execution_model": "async poll loop per market slug",
    "max_in_flight": 20,
    "idempotency_key": "market_slug + window_start_ms",
    "timeout_ms": 8000,
    "backpressure": "drop-after-buffer \u2014 skip slugs that have not drained within 2x poll interval",
    "locking": "Redis SETNX on slug + window_start_ms to prevent duplicate emissions"
  },
  "dependencies": {
    "depends_on": [
      {
        "bot_id": "risk.kill_switch",
        "why": "Suppress emissions when KillSwitch is active."
      }
    ],
    "emits_to": [
      {
        "bot_id": "strat.sentiment_aware_strategies",
        "what": "ObservationReport with sentiment_score and post_count for a market slug"
      }
    ],
    "sibling": [],
    "external": [
      {
        "service": "Polymarket social/sports feed",
        "endpoint": "ws_sports",
        "sla": "99.5% / 1 s p99",
        "fallback": "Skip poll cycle on feed error; emit STALE_DATA if no data for > 5 min"
      }
    ]
  },
  "security_surfaces": {
    "signs_orders": false,
    "private_key_access": "none",
    "abuse_vectors": [
      "Adversary floods social feed with synthetic posts to artificially inflate sentiment score",
      "Feed provider returns manipulated scores for targeted market slugs"
    ],
    "mitigations": [
      "min_post_count gate prevents low-signal spikes from small coordinated posting bursts",
      "Scores are informational only \u2014 downstream strategies independently validate before acting",
      "sample_rate throttle limits emission frequency to prevent signal flooding"
    ]
  },
  "failure_injection": [
    {
      "scenario": "FEED_OUTAGE",
      "how_to_inject": "Return 503 from ws_sports social feed for 5 min",
      "expected_behaviour": "SOCIALSENTIMENT_FEED_ERROR WARN logged; no emissions; SocialSentimentFeedStale alert fires after 10 min",
      "recovery": "Automatic on feed recovery; next successful poll emits normally"
    },
    {
      "scenario": "LOW_SAMPLE_FLOOD",
      "how_to_inject": "Return 1 post per poll for all slugs for 30 min",
      "expected_behaviour": "All cycles skipped; SOCIALSENTIMENT_LOW_SAMPLE logged; SocialSentimentHighSkipRate alert fires",
      "recovery": "Automatic when post volume normalises"
    },
    {
      "scenario": "KILL_SWITCH_ON",
      "how_to_inject": "Set killswitch.active=true during active polling",
      "expected_behaviour": "All emissions suppressed; KILL_SWITCH_ACTIVE logged; polling loop continues",
      "recovery": "Emissions resume automatically on KillSwitch reset"
    }
  ],
  "runbook": {
    "summary": "SocialSentiment incidents are most commonly feed outages or low-volume conditions. Neither blocks trading. Monitor for extended stale periods.",
    "oncall_actions": [
      {
        "alert": "SocialSentimentFeedStale",
        "first_step": "Check ws_sports feed health endpoint and last_emitted_at_ms. Verify feed credentials are valid.",
        "diagnosis": "",
        "mitigation": "",
        "escalation": "Intelligence pod lead if feed is down > 15 min"
      },
      {
        "alert": "SocialSentimentHighSkipRate",
        "first_step": "Check post volume for affected market slugs. If genuinely low, reduce min_post_count via config.",
        "diagnosis": "",
        "mitigation": "",
        "escalation": "Intelligence pod lead if all slugs affected simultaneously"
      }
    ],
    "manual_overrides": [
      {
        "command": "force_low_sample_bypass",
        "effect": "Set config.min_post_count=1 temporarily in staging to verify emission pipeline \u2014 Debugging emission pipeline in staging environment only"
      }
    ],
    "healthcheck": "Endpoint: /internal/health/socialsentiment | Green: Last emission < 2x poll_interval_s ago AND Redis reachable AND feed returning 200 | Red: No emission for > 10 min on any active slug OR Redis unreachable"
  },
  "promotion_gates": {
    "to_shadow": [
      {
        "gate": "Unit tests pass for low-sample gate, KillSwitch suppression, and sample rate",
        "how_measured": "CI test run",
        "threshold": "100% pass"
      }
    ],
    "to_limited_live": [
      {
        "gate": "Feed latency p99 < 2 s over 24 h on staging",
        "how_measured": "polytraders_intel_socialsentiment_feed_latency_ms histogram",
        "threshold": "p99 < 2000 ms"
      }
    ],
    "to_general_live": [
      {
        "gate": "Zero spurious emissions when KillSwitch active during 7-day soak",
        "how_measured": "Integration test log audit",
        "threshold": "0 emissions during KillSwitch active period"
      }
    ]
  },
  "reporting": {
    "emits_kinds": [
      "ObservationReport"
    ],
    "topics": [
      "polytraders.reports.observation"
    ],
    "cadence": "every-N",
    "retention_class": "30d",
    "retention_notes": "Full fidelity for 30 d; rolled-up summary retained for 1 y",
    "sampling_rule": "sample-1/10",
    "bus_failure_action": "drop-after-buffer",
    "user_visible": "no",
    "consumes_kinds": []
  },
  "capital_impact": "Indirect",
  "v3_status": {
    "phase": 2,
    "phase_name": "Data normalisation",
    "docs": {
      "done": 27,
      "total": 27,
      "state": "done"
    },
    "impl": {
      "done": 0,
      "total": 15,
      "state": "pending"
    },
    "runtime": {
      "done": 0,
      "total": 8,
      "state": "pending"
    },
    "overall": "pending"
  }
}