{
  "schema_version": "1.0.0",
  "bot_id": "1.19",
  "bot_name": "SelfTradeWashGuard",
  "slug": "self_trade_wash_guard",
  "layer": "Risk",
  "layer_key": "risk",
  "bot_class": "Guardrail",
  "authority": [
    "Reject",
    "Downsize"
  ],
  "status": "planned",
  "readiness": "Spec ready",
  "flagship": false,
  "is_reference": false,
  "public_export": false,
  "identity": {
    "layer": "Risk",
    "bot_class": "Guardrail",
    "authority": "Reject, Downsize",
    "runs_before": "exec.smart_router",
    "runs_after": "exec.order_lifecycle_manager",
    "applies_to": "Per OrderIntent",
    "default_mode": "shadow",
    "user_visible": "Yes",
    "developer_owner": "Risk pod"
  },
  "purpose": "Prevents Polytraders from trading against itself. If an outgoing OrderIntent would cross with one of our own resting orders on the same market and outcome, SelfTradeWashGuard rejects it (full overlap) or downsizes it (partial overlap). This protects against wash-trade exposure and inadvertent self-fills.",
  "why_it_matters": [
    {
      "failure": "Wash-trade liability",
      "consequence": "Crossing your own orders is treated as wash trading by most regulators and by Polymarket's own terms; even unintentional self-trades are a compliance risk."
    },
    {
      "failure": "Inventory churn",
      "consequence": "Self-fills move money from one account to another (or from one strategy book to another) while paying maker+taker fees both sides \u2014 pure deadweight loss."
    },
    {
      "failure": "Strategy interference",
      "consequence": "Two Polytraders strategies disagreeing on direction should not silently fund each other's positions through self-fills."
    }
  ],
  "polymarket_inputs": [
    {
      "input": "Outgoing OrderIntent (price/size/side/market_id/outcome)",
      "source": "Strategy bot",
      "required": true,
      "use": "The order under review."
    },
    {
      "input": "Resting orders snapshot (ours)",
      "source": "OrderLifecycleManager",
      "required": true,
      "use": "All currently-resting Polytraders orders, indexed by (market_id, outcome_id, side)."
    }
  ],
  "internal_inputs": [
    {
      "input": "OrderLifecycleManager state",
      "source": "exec.order_lifecycle_manager",
      "required": true,
      "use": "Source of truth for our own resting book."
    }
  ],
  "raw_params": [
    "mode \u00b7 reject|downsize",
    "tolerance_bps \u00b7 0\u2013100"
  ],
  "parameters": [
    {
      "name": "mode",
      "default": "downsize",
      "warning": "\u2014",
      "hard": "\u2014",
      "controls": "How to handle a detected self-cross: REJECT outright or DOWNSIZE to the non-overlapping remainder.",
      "why_default_matters": "Downsize is preferred \u2014 it preserves the legitimate part of the order while eliminating the self-trade.",
      "threshold_logic": [
        {
          "condition": "reject",
          "action": "Whole order rejected on any overlap"
        },
        {
          "condition": "downsize",
          "action": "Order shrunk to non-overlapping size; rejected if remainder is below the bot's min_size_usd"
        }
      ],
      "dev_check": "if (mode == 'reject' && overlap > 0) reject(); else if (overlap > 0) downsize(intent.size - overlap);",
      "user_facing": "We trimmed your order so it would not trade against another order from this account."
    },
    {
      "name": "tolerance_bps",
      "default": 0,
      "warning": "5",
      "hard": "10",
      "controls": "Tick-rounding tolerance when comparing prices (in basis points).",
      "why_default_matters": "Polymarket V2 uses fixed tick sizes (0.001), so 0 bps tolerance is correct. Non-zero only useful for legacy markets.",
      "threshold_logic": [
        {
          "condition": "0 bps",
          "action": "Exact price match required"
        }
      ],
      "dev_check": "if (priceMatchWithin(p.tolerance_bps, mine.price, intent.price)) recordOverlap();",
      "user_facing": "(Internal \u2014 not shown to users.)"
    }
  ],
  "default_config": {
    "mode": "downsize",
    "tolerance_bps": 0
  },
  "flow": "Receive OrderIntent \u2192 look up our resting orders matching (market_id, outcome_id, opposite side) at compatible price \u2192 compute total resting size at crossable prices \u2192 if overlap == intent.size: REJECT; if 0 < overlap < intent.size: DOWNSIZE to (intent.size - overlap); if overlap == 0: PASS.",
  "decision_logic": {
    "approve": "Find resting orders on opposite side for same (market_id, outcome_id). Filter to prices that would actually cross with intent.price + intent.side. Sum overlapping size in USD.",
    "reshape_required": "This bot does not reshape orders.",
    "reject": "Apply mode (reject vs downsize) and minimum-remainder check.",
    "warning_only": "No warn-only path defined."
  },
  "decision_output_example": {
    "vote": "DOWNSIZE",
    "reason_code": "RISK_SELF_TRADE",
    "suggested_size_usd": 60,
    "overlap_usd": 40,
    "explain": "Crossing $40 of our own resting orders; downsized to $60 remainder."
  },
  "developer_log": "Per decision: intent_id, market_id, outcome_id, side, intent.price, intent.size_usd, overlap_usd, vote, mode, suggested_size_usd.",
  "user_explanations": [
    {
      "situation": "When this bot acts",
      "message": "We trimmed (or rejected) your order so it would not trade against another order from this account."
    }
  ],
  "failure_modes": {
    "main_failure_mode": "Stale resting-orders view misses an overlap that just got placed.",
    "false_positive_risk": "Resting view includes orders that are about to cancel; mitigation: only count orders whose status is OPEN or PARTIALLY_FILLED at the moment of the check.",
    "false_negative_risk": "A new resting order placed in the same millisecond as the OrderIntent is not yet visible; mitigation: treat the OrderLifecycleManager's view as eventually consistent and rely on the exchange-side self-trade prevention as a backstop.",
    "safe_fallback": "If the resting-orders view is unavailable, REJECT \u2014 never assume there is no overlap."
  },
  "acceptance_tests": {
    "unit": [
      {
        "test": "Full overlap \u2192 REJECT.",
        "setup": "Synthetic fixture per template.",
        "expected": "Behaviour matches the rule described in the test name."
      },
      {
        "test": "50% overlap \u2192 DOWNSIZE to 50%.",
        "setup": "Synthetic fixture per template.",
        "expected": "Behaviour matches the rule described in the test name."
      },
      {
        "test": "Zero overlap \u2192 PASS.",
        "setup": "Synthetic fixture per template.",
        "expected": "Behaviour matches the rule described in the test name."
      },
      {
        "test": "Overlap > intent.size \u2192 REJECT (not negative size).",
        "setup": "Synthetic fixture per template.",
        "expected": "Behaviour matches the rule described in the test name."
      }
    ],
    "integration": [
      {
        "test": "Place a resting BUY at 0.55, then submit a SELL at 0.55 \u2192 DOWNSIZE.",
        "expected": "End-to-end behaviour matches the spec without manual intervention."
      }
    ],
    "property": [
      {
        "property": "For any (intent.size, overlap), the suggested_size_usd is in [0, intent.size].",
        "required": "Always true across all generated inputs."
      }
    ]
  },
  "reference_implementation": {
    "language": "pseudocode",
    "pseudocode": "ours = lifecycle.resting_orders(intent.market_id, intent.outcome_id, opposite(intent.side))\noverlap = sum(o.size for o in ours if would_cross(o.price, intent.price, intent.side))\nif overlap >= intent.size: return reject('RISK_SELF_TRADE')\nif overlap > 0:\n  if p.mode == 'reject': return reject('RISK_SELF_TRADE')\n  return downsize(intent.size - overlap, 'RISK_SELF_TRADE')\nreturn pass_()"
  },
  "wire_examples": {
    "input": {
      "intent_id": "intent_003",
      "market_id": "0xabc",
      "outcome_id": "YES",
      "side": "SELL",
      "size_usd": 100,
      "price": 0.55
    },
    "output": {
      "vote": "DOWNSIZE",
      "reason_code": "RISK_SELF_TRADE",
      "suggested_size_usd": 60,
      "overlap_usd": 40
    }
  },
  "reason_codes": [
    {
      "code": "RISK_SELF_TRADE",
      "severity": "P1",
      "meaning": "Risk Self Trade",
      "action": "See decision output and developer log for context.",
      "user_message": "We trimmed (or rejected) your order so it would not trade against another order from this account."
    },
    {
      "code": "RISK_SELF_TRADE_DOWNSIZED",
      "severity": "P1",
      "meaning": "Risk Self Trade Downsized",
      "action": "See decision output and developer log for context.",
      "user_message": "We trimmed (or rejected) your order so it would not trade against another order from this account."
    }
  ],
  "metrics": {
    "emitted": [
      {
        "name": "self_trade_rejects_total",
        "type": "counter",
        "unit": "event",
        "labels": [
          "market_id",
          "reason_code"
        ],
        "meaning": "Self trade rejects total."
      },
      {
        "name": "self_trade_downsizes_total",
        "type": "counter",
        "unit": "event",
        "labels": [
          "bot_id"
        ],
        "meaning": "Self trade downsizes total."
      },
      {
        "name": "self_trade_overlap_usd_histogram",
        "type": "counter",
        "unit": "event",
        "labels": [
          "bot_id"
        ],
        "meaning": "Self trade overlap usd histogram."
      }
    ],
    "alerts": [],
    "dashboards": [
      "1.19 overview dashboard"
    ]
  },
  "state": {
    "summary": "Stateless within the bot; reads OrderLifecycleManager state.",
    "stores": [
      {
        "name": "self_trade_wash_guard_state",
        "kind": "in-memory + fast KV mirror",
        "key": "bot_id",
        "value": "Stateless within the bot; reads OrderLifecycleManager state.",
        "ttl": "24h",
        "durability": "crash-safe via KV mirror"
      }
    ],
    "recovery": "Cold-start hydrates from fast KV; missing keys default to safe fallback.",
    "on_restart": "All in-flight decisions are re-evaluated; no bot decision is trusted across restart without re-emit."
  },
  "concurrency": {
    "execution_model": "Read-after-write hazard with very-recent OrderLifecycleManager updates; bot accepts eventual consistency.",
    "max_in_flight": 32,
    "idempotency_key": "order_intent_id",
    "replay_safe": true,
    "deduplication": "By idempotency_key within a 60s window.",
    "ordering_guarantees": "Per-market_id FIFO; cross-market unordered.",
    "timeout_ms": 250,
    "backpressure": "Bounded queue; oldest-dropped with metric increment when full.",
    "locking": "Per-market_id mutex; no global locks."
  },
  "dependencies": {
    "depends_on": [
      "exec.order_lifecycle_manager"
    ],
    "emits_to": [
      "exec.smart_router"
    ]
  },
  "graph": {
    "requires": [
      "exec.order_lifecycle_manager"
    ],
    "required_before": [
      "exec.smart_router"
    ],
    "consumes": [
      "OrderIntent",
      "RestingOrdersView"
    ],
    "emits": [
      "RiskVote"
    ],
    "blocks": true
  },
  "mode_support": [
    "off",
    "shadow",
    "advisory",
    "enforced",
    "quarantine"
  ],
  "latency_budget_ms": {
    "p50": 3,
    "p99": 12
  },
  "data_freshness": {
    "max_market_data_age_ms": 2000,
    "max_orderbook_age_ms": 2000,
    "on_stale_data": "REJECT."
  },
  "ownership": {
    "owner": "Risk pod",
    "on_call": "risk-oncall",
    "channel": "#polytraders-risk",
    "escalation": "Head of Risk",
    "severity_class": "P1"
  },
  "human_override": {
    "allowed": false,
    "who": "\u2014",
    "log_event": "\u2014",
    "time_bound": "\u2014",
    "scope": "\u2014",
    "second_approval": false
  },
  "security_surfaces": {
    "summary": "Internal-only. No external endpoints.",
    "signing": "None \u2014 bot does not sign or submit.",
    "secrets": [],
    "contract_calls": [],
    "abuse_vectors": [],
    "mitigations": [
      "Rate-limit per source",
      "Audit-log every override",
      "Require role-based authz on admin paths"
    ]
  },
  "polymarket_v2_compat": {
    "clob_version": "V2",
    "collateral": "pUSD",
    "eip712_domain_version": "2",
    "builder_code_aware": true,
    "negrisk_aware": true,
    "multichain_ready": true,
    "sdk_used": "Polymarket CLOB V2 SDK",
    "settlement_contract": "CTFExchangeV2",
    "notes": "Operates on V2 OrderIntent and resting-orders state. Polymarket V2 also enforces server-side self-trade prevention as a backstop."
  },
  "version": {
    "current": "0.1.0",
    "contract_version": "1.0.0",
    "last_breaking_change": "none",
    "deprecation_window_days": 30
  },
  "migration_history": [],
  "runbook": {
    "summary": "If downsize rate is high, multiple strategies are likely fighting on the same market \u2014 review strategy allocation, do not silence the guard.",
    "oncall_actions": [
      {
        "alert": "1.19_anomaly",
        "first_step": "Open the bot's reporting page and confirm the alert is real (not a metric hiccup).",
        "diagnosis": "Inspect developer log entries for the affected market_id over the last 30 minutes.",
        "mitigation": "Force-clear via Admin UI if the rule is clearly stale; otherwise leave engaged and notify owner.",
        "escalation": "Risk pod"
      }
    ],
    "manual_overrides": [
      {
        "command": "polytraders bot pause 1.19",
        "effect": "Disables the bot's enforcement layer; downstream consumers fall back to safe defaults."
      }
    ],
    "healthcheck": "GET /healthz/self_trade_wash_guard \u2192 200 if last successful evaluation < 60s ago."
  },
  "promotion_gates": {
    "to_shadow": [
      {
        "gate": "Stub",
        "how_measured": "deterministic against synthetic resting state.",
        "threshold": "Documented threshold met for the full window."
      }
    ],
    "to_limited_live": [
      {
        "gate": "Shadow",
        "how_measured": "14 days; downsize/reject events tracked but not enforced.",
        "threshold": "Documented threshold met for the full window."
      },
      {
        "gate": "Advisory",
        "how_measured": "7 days.",
        "threshold": "Documented threshold met for the full window."
      }
    ],
    "to_general_live": [
      {
        "gate": "Enforced",
        "how_measured": "Risk Lead sign-off + compliance review.",
        "threshold": "Documented threshold met for the full window."
      }
    ]
  },
  "failure_injection": [
    {
      "scenario": "Inject 50% overlap and assert DOWNSIZE",
      "how_to_inject": "Inject 50% overlap and assert DOWNSIZE.",
      "expected_behavior": "Bot detects within its latency budget and emits the corresponding reason code.",
      "recovery": "Remove the injected fault; bot returns to healthy state within one debounce window."
    },
    {
      "scenario": "Disconnect OrderLifecycleManager view and assert all OrderIntents are REJECTED",
      "how_to_inject": "Disconnect OrderLifecycleManager view and assert all OrderIntents are REJECTED.",
      "expected_behavior": "Bot detects within its latency budget and emits the corresponding reason code.",
      "recovery": "Remove the injected fault; bot returns to healthy state within one debounce window."
    },
    {
      "scenario": "Race condition: submit two intents within 1ms and assert at most one self-fill",
      "how_to_inject": "Race condition: submit two intents within 1ms and assert at most one self-fill.",
      "expected_behavior": "Bot detects within its latency budget and emits the corresponding reason code.",
      "recovery": "Remove the injected fault; bot returns to healthy state within one debounce window."
    }
  ],
  "capital_impact": "Direct",
  "v3_status": {
    "phase": 4,
    "phase_name": "Core risk",
    "docs": {
      "done": 27,
      "total": 27,
      "state": "done"
    },
    "impl": {
      "done": 0,
      "total": 15,
      "state": "pending"
    },
    "runtime": {
      "done": 0,
      "total": 8,
      "state": "pending"
    },
    "overall": "pending"
  }
}