{
  "openapi": "3.0.1",
  "info": {
    "title": "OutcomeX API",
    "description": "Public REST + WebSocket API for the OutcomeX prediction market.\n\n**Auth.** Bearer tokens issued by `POST /login` (ASP.NET Identity); cookies for browser flows. Admin-only endpoints require the `Admin` role.\n\n**Rate limits.** 300 req/min global per-IP, 10 req/min on credential endpoints, 180 req/min on trading. See `/docs` for a getting-started walkthrough.\n\n**Settlement.** Win-side fee at `Features:SettlementFeeBps` basis points (default 4%). WebSocket book/trade feed at `/ws/markets/{id}` (or multiplexed at `/ws/markets?ids=…`).",
    "contact": {
      "name": "Developer docs",
      "url": "/docs"
    },
    "version": "v1"
  },
  "paths": {
    "/api/v1/account/balance": {
      "get": {
        "tags": [
          "Account"
        ],
        "summary": "The caller's current OXC balance. The order-entry form re-syncs to this\r\nshortly after placing a trade because async fills move the balance AFTER\r\nthe place response returns — complete-set redemption credits cash back on\r\na sell, and an over-reserved buy refunds the difference. Without a re-sync\r\nthe optimistic figure (reserve only) stays wrong until a full page load.",
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/account/trades.csv": {
      "get": {
        "tags": [
          "Account"
        ],
        "summary": "Streams the caller's trade history as a CSV. RFC 4180 quoting for any field\r\ncontaining commas, quotes, or newlines.",
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/account/ledger.csv": {
      "get": {
        "tags": [
          "Account"
        ],
        "summary": "Streams the caller's ledger entries as a CSV with a running balance column,\r\noldest-first so the balance progression is easy to reconcile.",
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/account/positions.csv": {
      "get": {
        "tags": [
          "Account"
        ],
        "summary": "Streams the caller's open positions (non-zero share count on either side) as a CSV.\r\nCompanion to /trades.csv for users who want a quick snapshot of what they're holding.",
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/account/orders.csv": {
      "get": {
        "tags": [
          "Account"
        ],
        "summary": "Streams the caller's full order history (open + filled + cancelled) as a CSV.\r\nUses UserSide/UserPrice so BuyNo orders show up as BuyNo, not the engine-normalized\r\nSellYes equivalent.",
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/account/demo-deposit": {
      "post": {
        "tags": [
          "Account"
        ],
        "summary": "Play-money faucet. Credits OutcomeX.Web.Controllers.AccountController.DemoDepositAmount OXC to\r\nthe caller's balance and writes a `demo_deposit` LedgerEntry,\r\nonce per 24 hours per user. The cooldown is enforced by reading the\r\nmost recent `demo_deposit` entry's CreatedAt rather than a\r\ndedicated field on OutcomeX.Web.Domain.ApplicationUser — the ledger is\r\nthe source of truth for balance moves so the cooldown invariant\r\ncan't drift away from the actual credit history.",
        "description": "\nGated by OutcomeX.Web.Services.IPaymentModeAccessor: when the venue\r\n            is in OutcomeX.Web.Domain.PaymentMode.RealMoney mode, every call returns\r\n            503 instead of opening the wallet door — the demo faucet is a\r\n            play-money primitive and has no place once real money is flowing.\r\n            Legacy `Features:PlayMoneyMode=false` still works via the\r\n            accessor's backwards-compat path.\r\n\n429 response shape exposes `nextAvailableAt` and\r\n            `secondsUntilNext` so the client can render a countdown\r\n            without a clock-skew round-trip.",
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/account/deposit": {
      "post": {
        "tags": [
          "Account"
        ],
        "summary": "Real-money deposit stub. Returns 503 Service Unavailable with a\r\nmode-aware reason so the JS deposit modal can render a sensible\r\nnext-step message instead of always reading \"Real-money deposits\r\nare not yet available\" — which is confusing when the venue is in\r\nPlayMoney mode and the user's actual funding rail is the demo\r\nfaucet.",
        "description": "\nThe 503 body carries a structured `reason` code so the\r\n            client can branch on it without parsing the error string:\r\n            <list type=\"bullet\"><item>`play_money_use_demo_faucet` — PlayMoney mode; the\r\n                    client should point the user at `POST /demo-deposit`\r\n                    instead. The `hint` field names that endpoint\r\n                    explicitly so a curl-using developer also gets the\r\n                    pointer without reading docs.</item><item>`provider_not_configured` — RealMoney mode; no\r\n                    payment provider wired yet (Stripe / Moonpay / crypto\r\n                    custodian). Same return shape so the client can render\r\n                    \"coming soon\" once a provider is wired and this branch\r\n                    returns 200.</item></list>",
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/account/withdraw": {
      "post": {
        "tags": [
          "Account"
        ],
        "summary": "Real-money withdrawal stub. Mode check fires first — in\r\nPlayMoney mode there's no possible withdrawal flow, KYC or\r\notherwise, so prompting \"verify identity to withdraw\" would be\r\na bug. Past the mode gate, gates on KYC verification: a\r\nnon-verified user gets `reason = \"kyc_required\"` regardless\r\nof provider state. Verified users (currently none — the flag is\r\nadmin-flipped only) fall through to the provider-stub 503.",
        "description": "\nCheck order:\r\n            <list type=\"number\"><item>PaymentMode == PlayMoney → 503 with\r\n                    `reason = \"play_money_no_withdrawal\"`. Play money has\r\n                    no off-ramp by definition; KYC + provider don't apply.</item><item>!user.KycVerified → 503 with `reason = \"kyc_required\"`.\r\n                    Actionable for the user (the \"verify identity\" CTA points\r\n                    them somewhere they can act).</item><item>Otherwise → 503 with `reason = \"provider_not_configured\"`.\r\n                    Operational state; the user can't act on it.</item></list>\r\n\nPutting the mode check before KYC is important: a PlayMoney\r\n            venue would otherwise tell a user \"verify identity to withdraw\"\r\n            when there is no withdrawal to enable. Putting KYC before\r\n            provider keeps the actionable branch ahead of the operational\r\n            one.",
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/markets/{marketId}/comments": {
      "get": {
        "tags": [
          "Comments"
        ],
        "summary": "Anonymous; paginated newest-first. Soft-deleted rows\r\n            remain in the response with OutcomeX.Web.Domain.Comment.Body blanked\r\n            (DeletedAt is non-null so clients render the \"[deleted by author]\"\r\n            affordance). EditedAt is surfaced for the \"(edited)\" badge.",
        "parameters": [
          {
            "name": "marketId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "format": "int32",
              "default": 1
            }
          },
          {
            "name": "pageSize",
            "in": "query",
            "schema": {
              "type": "integer",
              "format": "int32",
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      },
      "post": {
        "tags": [
          "Comments"
        ],
        "summary": "Append a new comment to a market's thread. Body is\r\n            trimmed and length-validated (1–OutcomeX.Web.Domain.Comment.MaxBodyLength chars)\r\n            both via the DataAnnotations on OutcomeX.Web.Controllers.AddCommentRequest and an\r\n            explicit post-trim check (so whitespace-only inputs reject as 400\r\n            instead of inserting an empty row that the listing would later\r\n            filter).",
        "parameters": [
          {
            "name": "marketId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AddCommentRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/AddCommentRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/AddCommentRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/markets/{marketId}/comments/{commentId}": {
      "delete": {
        "tags": [
          "Comments"
        ],
        "summary": "Author-only soft-delete. Stamps\r\n            OutcomeX.Web.Domain.Comment.DeletedAt with UTC now; the row stays in\r\n            the DB so the 5-minute M:OutcomeX.Web.Controllers.CommentsController.Undelete(System.Int32,System.Int64,System.Threading.CancellationToken) window can restore\r\n            it. 404 if the comment doesn't exist on this market; 403 if it's\r\n            someone else's. Idempotent in effect — re-deleting an already-\r\n            deleted comment just re-stamps DeletedAt (acceptable; the row was\r\n            already invisible). Returns 204 No Content.",
        "parameters": [
          {
            "name": "marketId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "commentId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      },
      "put": {
        "tags": [
          "Comments"
        ],
        "summary": "Edit your own comment body. Same body validation as Add.",
        "parameters": [
          {
            "name": "marketId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "commentId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AddCommentRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/AddCommentRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/AddCommentRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/markets/{marketId}/comments/{commentId}/undelete": {
      "post": {
        "tags": [
          "Comments"
        ],
        "summary": "Author-only undelete within 5 minutes of the original delete. Outside\r\n            the window, returns 410 Gone — the row stays soft-deleted permanently.\r\n            Admin-deleted comments (OutcomeX.Web.Domain.Comment.DeletedByAdminId non-null)\r\n            can NOT be undeleted by the author at all — only an admin restore via\r\n            `Admin/CommentsController.Restore` can revive them. Returns 403\r\n            to make the gate visible to the client.",
        "parameters": [
          {
            "name": "marketId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "commentId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/admin/comments/{id}/delete": {
      "post": {
        "tags": [
          "Comments"
        ],
        "summary": "Admin-delete a comment. Sets OutcomeX.Web.Domain.Comment.DeletedAt,\r\n            OutcomeX.Web.Domain.Comment.DeletedByAdminId, and\r\n            OutcomeX.Web.Domain.Comment.DeletedReason. Idempotent in effect — re-deleting\r\n            an already-deleted comment re-stamps the audit fields with the new\r\n            admin / reason.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DeleteCommentRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/DeleteCommentRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/DeleteCommentRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/admin/comments/{id}/restore": {
      "post": {
        "tags": [
          "Comments"
        ],
        "summary": "Restore an admin-deleted comment. Clears all three\r\n            admin-delete fields (`DeletedAt`, `DeletedByAdminId`,\r\n            `DeletedReason`). 400 if the comment wasn't admin-deleted\r\n            (an author-soft-deleted comment isn't this endpoint's concern —\r\n            the author has their own undelete path).",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/account/crypto/address": {
      "post": {
        "tags": [
          "CryptoRail"
        ],
        "summary": "Get (or first-issue) this user's USDC deposit address.",
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/account/crypto/simulate-deposit": {
      "post": {
        "tags": [
          "CryptoRail"
        ],
        "summary": "Testnet only: simulate a confirmed inbound deposit, which a\r\n            real custodian would deliver via webhook. Refused once a real rail is\r\n            wired (i.e. when the rail is not the simulated one), so it can never\r\n            mint against nothing on a production custodian.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SimulateDepositRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/SimulateDepositRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/SimulateDepositRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/account/crypto/withdraw": {
      "post": {
        "tags": [
          "CryptoRail"
        ],
        "summary": "Withdraw USDC: KYC-gated, balance-checked. Burns OXC 1:1 and\r\n            asks the rail to send.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WithdrawRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/WithdrawRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/WithdrawRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/market-groups": {
      "get": {
        "tags": [
          "MarketGroups"
        ],
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "open"
            }
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "format": "int32",
              "default": 1
            }
          },
          {
            "name": "pageSize",
            "in": "query",
            "schema": {
              "type": "integer",
              "format": "int32",
              "default": 20
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/MarketGroupListResponseDto"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketGroupListResponseDto"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketGroupListResponseDto"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/market-groups/{id}": {
      "get": {
        "tags": [
          "MarketGroups"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/MarketGroupDetailDto"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketGroupDetailDto"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketGroupDetailDto"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/admin/market-groups": {
      "post": {
        "tags": [
          "MarketGroups"
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateMarketGroupRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateMarketGroupRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/CreateMarketGroupRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/MarketGroupResponse"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketGroupResponse"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketGroupResponse"
                }
              }
            }
          }
        }
      },
      "get": {
        "tags": [
          "MarketGroups"
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/MarketGroupResponse"
                  }
                }
              },
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/MarketGroupResponse"
                  }
                }
              },
              "text/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/MarketGroupResponse"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/admin/market-groups/{id}": {
      "get": {
        "tags": [
          "MarketGroups"
        ],
        "summary": "Group detail + child market summaries. Each child carries\r\n            a `BestAskProbability` derived from the lowest open SellYes\r\n            price — this is what the admin UI's \"current implied probability\"\r\n            column reads. Same DB-derived top-of-book pattern as the public\r\n            market detail endpoint: stateless + horizontally scalable, at the\r\n            cost of being a cold snapshot rather than live engine state.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/MarketGroupDetailResponse"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketGroupDetailResponse"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketGroupDetailResponse"
                }
              }
            }
          }
        }
      },
      "put": {
        "tags": [
          "MarketGroups"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateMarketGroupRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateMarketGroupRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateMarketGroupRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/MarketGroupResponse"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketGroupResponse"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketGroupResponse"
                }
              }
            }
          }
        }
      },
      "delete": {
        "tags": [
          "MarketGroups"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/admin/market-groups/{id}/markets/{marketId}": {
      "post": {
        "tags": [
          "MarketGroups"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "marketId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      },
      "delete": {
        "tags": [
          "MarketGroups"
        ],
        "summary": "Detach a market from a group. Unlike most endpoints\r\n            here this has NO ResolvedAt gate — see class doc.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "marketId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/markets": {
      "get": {
        "tags": [
          "Markets"
        ],
        "summary": "List markets with optional status filter + pagination.\r\n            Each list item carries `LastPrice` derived from the most\r\n            recent OutcomeX.Web.Domain.Trade row — a per-market subquery rather than\r\n            a join because Postgres optimises this shape well and an empty\r\n            market just returns null.",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "open"
            }
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "format": "int32",
              "default": 1
            }
          },
          {
            "name": "pageSize",
            "in": "query",
            "schema": {
              "type": "integer",
              "format": "int32",
              "default": 20
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/MarketListResponseDto"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketListResponseDto"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketListResponseDto"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/markets/search": {
      "get": {
        "tags": [
          "Markets"
        ],
        "summary": "Lightweight autocomplete: returns up to `limit` open markets whose Question\r\nmatches the query (case-insensitive LIKE). Designed for type-ahead — minimal payload,\r\nno auth required.",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "schema": {
              "type": "string",
              "default": ""
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "format": "int32",
              "default": 8
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/markets/{id}/trades": {
      "get": {
        "tags": [
          "Markets"
        ],
        "summary": "Time-bucketed trade history for the price chart. Range\r\n            values come from OutcomeX.Web.Controllers.MarketsController.TradeHistoryRanges; the default is\r\n            OutcomeX.Web.Controllers.MarketsController.DefaultTradeHistoryRange. Returns Unix-second\r\n            timestamps + price (cents) — the chart code avoids parsing ISO\r\n            strings on the hot redraw path. Sorted ascending by time so the\r\n            chart can append without sorting.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "range",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "1d"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/TradeHistoryDto"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TradeHistoryDto"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/TradeHistoryDto"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/markets/{id}": {
      "get": {
        "tags": [
          "Markets"
        ],
        "summary": "Market detail: question + status + top-of-book + last 20\r\n            trades. Top-of-book is computed from the Orders table (best bid =\r\n            highest BuyYes price, best ask = lowest SellYes price) rather than\r\n            the engine's in-memory book — see class doc for the rationale.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/MarketDetailDto"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketDetailDto"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketDetailDto"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/admin/markets": {
      "post": {
        "tags": [
          "Markets"
        ],
        "summary": "Create a new Open market. Trims Question / Description /\r\n            ResolutionSource; coerces ClosesAt to UTC; rejects ClosesAt <=\r\n            now as 400. Returns 201 + Location: /api/v1/admin/markets/{id}.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateMarketRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateMarketRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/CreateMarketRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/MarketResponse"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketResponse"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketResponse"
                }
              }
            }
          }
        }
      },
      "get": {
        "tags": [
          "Markets"
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/MarketResponse"
                  }
                }
              },
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/MarketResponse"
                  }
                }
              },
              "text/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/MarketResponse"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/admin/markets/{id}": {
      "get": {
        "tags": [
          "Markets"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/MarketResponse"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketResponse"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketResponse"
                }
              }
            }
          }
        }
      },
      "put": {
        "tags": [
          "Markets"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateMarketRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateMarketRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateMarketRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/MarketResponse"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketResponse"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/admin/markets/{id}/resolve/preview": {
      "get": {
        "tags": [
          "Markets"
        ],
        "summary": "Read-only preview of what a resolve action would\r\n            produce: the gross payout total across the winning side, the\r\n            settlement-fee total that would be debited from those winners,\r\n            the net total credited to user balances, and the count of\r\n            distinct winning users. Lets an operator (or a confirmation\r\n            dialog) see the economics BEFORE clicking Resolve — closes the\r\n            admin-side transparency loop the order-entry fee preview opened\r\n            on the user side.",
        "description": "\nMirrors `ResolutionService.SettleMarketAsync`'s\r\n            payout math (gross = sum of winning shares × 1 OXC; fee =\r\n            gross × bps / 10000 with the same [0, MaxSettlementFeeBps]\r\n            clamp). The endpoint touches no DB writes — pure read.\r\n\nReturns 400 when the outcome is unparseable or the market\r\n            isn't Open (a non-Open market can't be resolved, so the preview\r\n            would be meaningless).",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "outcome",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/ResolvePreviewResponse"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ResolvePreviewResponse"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/ResolvePreviewResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/admin/markets/{id}/resolve": {
      "post": {
        "tags": [
          "Markets"
        ],
        "summary": "Resolve an Open market to Yes or No. Delegates to\r\n            M:OutcomeX.Web.Services.IResolutionService.ResolveAsync(System.Int32,OutcomeX.Web.Domain.Outcome,System.Threading.CancellationToken), then reloads the\r\n            market entity so the response reflects post-resolve state\r\n            (Status=Resolved, ResolvedOutcome set). Returns 400 if the market\r\n            isn't Open or the outcome string isn't parseable. Idempotency for\r\n            \"already resolved\" lives in the service — the endpoint here just\r\n            pre-checks Status to give a clearer 400.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "outcome",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ResolveMarketRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/ResolveMarketRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/ResolveMarketRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/MarketResponse"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketResponse"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/admin/markets/{id}/cancel/preview": {
      "get": {
        "tags": [
          "Markets"
        ],
        "summary": "Read-only preview of what a cancel action would\r\n            produce: the total OXC that would be refunded across every open\r\n            order on the market, and the count of those orders. Mirrors\r\n            M:OutcomeX.Web.Controllers.Admin.MarketsController.Cancel(System.Int32,System.Threading.CancellationToken)'s refund pass (proportional unfilled\r\n            margin), so an admin sees the cash impact BEFORE clicking\r\n            Cancel. Returns 400 if the market isn't Open.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/CancelPreviewResponse"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CancelPreviewResponse"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/CancelPreviewResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/admin/markets/{id}/cancel": {
      "post": {
        "tags": [
          "Markets"
        ],
        "summary": "Cancel an Open market: flip Status to Cancelled and\r\n            refund every open order at its proportional unfilled portion. No\r\n            payout pass (cancel ≠ resolve — there's no winning side). The\r\n            ledger reason written is `market_resolved_cancel`, shared\r\n            with OutcomeX.Web.Services.IResolutionService's refund pass for\r\n            admin-reporting consistency.",
        "description": "This is the only resolution-style operation that lives in a\r\ncontroller rather than the service; the logic is small enough\r\n(no payouts, no cascade) that extracting it didn't earn its\r\nkeep. If a future change introduces partial-cancellation rules\r\n(e.g. preserve some orders), promote it to\r\n`IResolutionService`.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/MarketResponse"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketResponse"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/orders": {
      "post": {
        "tags": [
          "Orders"
        ],
        "summary": "Place a new limit order. Validates the request, normalizes\r\nBuyNo/SellNo to the YES book, debits the user's balance for the\r\nworst-case fill cost, writes an `order_reserve` LedgerEntry,\r\nthen enqueues a Submit command for the engine.",
        "description": "Reserve formula is asymmetric by side: BuyYes reserves\r\n`price/100 × size` (cash to pay sellers); SellYes reserves\r\n`(100−price)/100 × size` (cash to pay the complementary buyer\r\nif you have to deliver). A Market order reserves the same way off a\r\nreference (top-of-book) price — that becomes the caller's cash\r\nbudget. The engine crosses any level for a market taker but caps the\r\nsweep at that budget and refunds whatever it doesn't spend\r\n(`market_reserve_refund`), so a market order never strands cash\r\nor rests a leftover at the reference price.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlaceOrderRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/PlaceOrderRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/PlaceOrderRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/orders": {
      "post": {
        "tags": [
          "Orders"
        ],
        "summary": "Place a new limit order. Validates the request, normalizes\r\nBuyNo/SellNo to the YES book, debits the user's balance for the\r\nworst-case fill cost, writes an `order_reserve` LedgerEntry,\r\nthen enqueues a Submit command for the engine.",
        "description": "Reserve formula is asymmetric by side: BuyYes reserves\r\n`price/100 × size` (cash to pay sellers); SellYes reserves\r\n`(100−price)/100 × size` (cash to pay the complementary buyer\r\nif you have to deliver). A Market order reserves the same way off a\r\nreference (top-of-book) price — that becomes the caller's cash\r\nbudget. The engine crosses any level for a market taker but caps the\r\nsweep at that budget and refunds whatever it doesn't spend\r\n(`market_reserve_refund`), so a market order never strands cash\r\nor rests a leftover at the reference price.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlaceOrderRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/PlaceOrderRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/PlaceOrderRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/orders/close-position/{marketId}": {
      "post": {
        "tags": [
          "Orders"
        ],
        "summary": "Close the caller's position on a market at the best opposing price.\r\nYES position: SellYes at the best YES bid. NO position: SellNo at the best NO bid\r\n(engine-normalized to BuyYes at the best YES ask). If both sides are held, closes\r\nthe larger side. Returns 404 if no position, 409 if no opposing liquidity.",
        "parameters": [
          {
            "name": "marketId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/orders/close-position/{marketId}": {
      "post": {
        "tags": [
          "Orders"
        ],
        "summary": "Close the caller's position on a market at the best opposing price.\r\nYES position: SellYes at the best YES bid. NO position: SellNo at the best NO bid\r\n(engine-normalized to BuyYes at the best YES ask). If both sides are held, closes\r\nthe larger side. Returns 404 if no position, 409 if no opposing liquidity.",
        "parameters": [
          {
            "name": "marketId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/orders/cancel-mine": {
      "post": {
        "tags": [
          "Orders"
        ],
        "summary": "Cancels every open or partially-filled order for the caller across all markets.\r\nUseful before a market event or for cleaning up forgotten orders. Each cancel\r\nruns through OrderCancellationService (refund + ledger + engine notification).",
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/orders/cancel-mine": {
      "post": {
        "tags": [
          "Orders"
        ],
        "summary": "Cancels every open or partially-filled order for the caller across all markets.\r\nUseful before a market event or for cleaning up forgotten orders. Each cancel\r\nruns through OrderCancellationService (refund + ledger + engine notification).",
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/orders/{id}": {
      "delete": {
        "tags": [
          "Orders"
        ],
        "summary": "Cancel one of the caller's orders. Thin wrapper around\r\nOutcomeX.Web.Services.OrderCancellationService; maps\r\nOutcomeX.Web.Services.CancelOutcome values to HTTP status codes:\r\nCancelled→200, NotFound→404, Forbidden→403, NotCancellable→400.\r\nRefund + ledger + engine notification all happen inside the\r\nservice.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      },
      "get": {
        "tags": [
          "Orders"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/orders/{id}": {
      "delete": {
        "tags": [
          "Orders"
        ],
        "summary": "Cancel one of the caller's orders. Thin wrapper around\r\nOutcomeX.Web.Services.OrderCancellationService; maps\r\nOutcomeX.Web.Services.CancelOutcome values to HTTP status codes:\r\nCancelled→200, NotFound→404, Forbidden→403, NotCancellable→400.\r\nRefund + ledger + engine notification all happen inside the\r\nservice.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      },
      "get": {
        "tags": [
          "Orders"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/orders/mine": {
      "get": {
        "tags": [
          "Orders"
        ],
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "all"
            }
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "format": "int32",
              "default": 1
            }
          },
          {
            "name": "pageSize",
            "in": "query",
            "schema": {
              "type": "integer",
              "format": "int32",
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/orders/mine": {
      "get": {
        "tags": [
          "Orders"
        ],
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "all"
            }
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "format": "int32",
              "default": 1
            }
          },
          {
            "name": "pageSize",
            "in": "query",
            "schema": {
              "type": "integer",
              "format": "int32",
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/health": {
      "get": {
        "tags": [
          "OutcomeX.Web"
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/health/ready": {
      "get": {
        "tags": [
          "OutcomeX.Web"
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/sitemap.xml": {
      "get": {
        "tags": [
          "OutcomeX.Web"
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/robots.txt": {
      "get": {
        "tags": [
          "OutcomeX.Web"
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/me": {
      "get": {
        "tags": [
          "OutcomeX.Web"
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/spot": {
      "get": {
        "tags": [
          "OutcomeX.Web"
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/register": {
      "post": {
        "tags": [
          "OutcomeX.Web"
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RegisterRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "OK"
          },
          "400": {
            "description": "Bad Request",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/HttpValidationProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/login": {
      "post": {
        "tags": [
          "OutcomeX.Web"
        ],
        "parameters": [
          {
            "name": "useCookies",
            "in": "query",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "useSessionCookies",
            "in": "query",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LoginRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AccessTokenResponse"
                }
              }
            }
          }
        }
      }
    },
    "/refresh": {
      "post": {
        "tags": [
          "OutcomeX.Web"
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RefreshRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AccessTokenResponse"
                }
              }
            }
          }
        }
      }
    },
    "/confirmEmail": {
      "get": {
        "tags": [
          "OutcomeX.Web"
        ],
        "operationId": "MapIdentityApi-/confirmEmail",
        "parameters": [
          {
            "name": "userId",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "code",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "changedEmail",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/resendConfirmationEmail": {
      "post": {
        "tags": [
          "OutcomeX.Web"
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ResendConfirmationEmailRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/forgotPassword": {
      "post": {
        "tags": [
          "OutcomeX.Web"
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ForgotPasswordRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "OK"
          },
          "400": {
            "description": "Bad Request",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/HttpValidationProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/resetPassword": {
      "post": {
        "tags": [
          "OutcomeX.Web"
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ResetPasswordRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "OK"
          },
          "400": {
            "description": "Bad Request",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/HttpValidationProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/manage/2fa": {
      "post": {
        "tags": [
          "OutcomeX.Web"
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TwoFactorRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TwoFactorResponse"
                }
              }
            }
          },
          "400": {
            "description": "Bad Request",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/HttpValidationProblemDetails"
                }
              }
            }
          },
          "404": {
            "description": "Not Found"
          }
        }
      }
    },
    "/manage/info": {
      "get": {
        "tags": [
          "OutcomeX.Web"
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InfoResponse"
                }
              }
            }
          },
          "400": {
            "description": "Bad Request",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/HttpValidationProblemDetails"
                }
              }
            }
          },
          "404": {
            "description": "Not Found"
          }
        }
      },
      "post": {
        "tags": [
          "OutcomeX.Web"
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/InfoRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InfoResponse"
                }
              }
            }
          },
          "400": {
            "description": "Bad Request",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/HttpValidationProblemDetails"
                }
              }
            }
          },
          "404": {
            "description": "Not Found"
          }
        }
      }
    },
    "/api/v1/markets/{marketId}/resolution-suggestions": {
      "post": {
        "tags": [
          "ResolutionSuggestions"
        ],
        "parameters": [
          {
            "name": "marketId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SuggestRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/SuggestRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/SuggestRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/admin/users/{id}/ban": {
      "post": {
        "tags": [
          "Users"
        ],
        "summary": "Ban a user. Idempotent in effect — re-banning an already-\r\n            banned user re-stamps OutcomeX.Web.Domain.ApplicationUser.BannedAt and\r\n            rewrites OutcomeX.Web.Domain.ApplicationUser.BannedReason with the new\r\n            reason. Returns 404 for an unknown user id.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BanUserRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/BanUserRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/BanUserRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/admin/users/{id}/unban": {
      "post": {
        "tags": [
          "Users"
        ],
        "summary": "Unban a user. Clears OutcomeX.Web.Domain.ApplicationUser.IsBanned,\r\n            OutcomeX.Web.Domain.ApplicationUser.BannedAt, and\r\n            OutcomeX.Web.Domain.ApplicationUser.BannedReason. Idempotent —\r\n            unbanning an already-unbanned user is a no-op (still 200).",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/admin/users/{id}/kyc/verify": {
      "post": {
        "tags": [
          "Users"
        ],
        "summary": "Mark a user as KYC-verified. Idempotent — re-verifying\r\n            an already-verified user re-stamps\r\n            OutcomeX.Web.Domain.ApplicationUser.KycVerifiedAt with the new clear\r\n            time (audit shows the re-verification). Returns 404 for an\r\n            unknown user id. Manual KYC flips exist for the dev/beta period\r\n            before a vendor (Persona / Jumio / Sumsub) is wired; a future\r\n            vendor-callback would write via this same path or a sibling.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/admin/users/{id}/kyc/revoke": {
      "post": {
        "tags": [
          "Users"
        ],
        "summary": "Revoke a user's KYC verification. Clears\r\n            OutcomeX.Web.Domain.ApplicationUser.KycVerified but PRESERVES\r\n            OutcomeX.Web.Domain.ApplicationUser.KycVerifiedAt — the latter is\r\n            \"last cleared at\" not \"currently verified\", so keeping it lets\r\n            admin reports show \"previously verified on X, revoked since\".\r\n            A subsequent re-verify call overwrites it with the new instant.\r\n            Idempotent — revoking an already-unverified user is a no-op (still 200).",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/admin/users/{id}/confirm-email": {
      "post": {
        "tags": [
          "Users"
        ],
        "summary": "Mark a user's email as confirmed without sending a\r\n            confirmation link. Manual override for the launch trap where\r\n            `Features:RequireEmailConfirmation=true` + NoOpEmailSender\r\n            means signups can never confirm via the normal link flow (the\r\n            /Admin readiness banner already warns about this combo). Also\r\n            useful in the dev/beta period for unblocking a single user who\r\n            can't receive the confirmation email for any reason. Idempotent\r\n            — confirming an already-confirmed user is a no-op (still 200).\r\n            Returns 404 for an unknown user id.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/admin/users/{id}/balance-adjust": {
      "post": {
        "tags": [
          "Users"
        ],
        "summary": "Adjust a user's balance by a signed delta. Writes a\r\n            single OutcomeX.Web.Domain.LedgerEntry with reason\r\n            `admin_balance_adjust` and the operator's note. Zero delta\r\n            is rejected (no-op) so empty corrections don't pollute the audit\r\n            trail. Negative deltas that would push balance below zero are\r\n            allowed — admins explicitly have the power to do this and\r\n            occasionally need it (e.g. clawback for fraud) — but the action\r\n            is logged at warning level.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BalanceAdjustRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/BalanceAdjustRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/BalanceAdjustRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/watchlist": {
      "get": {
        "tags": [
          "Watchlist"
        ],
        "summary": "List the caller's watchlist, newest-first. Lean payload —\r\n            just MarketId + CreatedAt — because the UI joins to market data\r\n            from a separate cache. Anonymous callers get 401.",
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/watchlist/{marketId}": {
      "post": {
        "tags": [
          "Watchlist"
        ],
        "summary": "Add the market to the caller's watchlist. Idempotent: a\r\n            second call for the same (user, market) is a no-op and still\r\n            returns `{ watching: true }`. Returns 404 only if the market\r\n            itself doesn't exist — bookmarking a deleted market would orphan\r\n            the row.",
        "parameters": [
          {
            "name": "marketId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      },
      "delete": {
        "tags": [
          "Watchlist"
        ],
        "summary": "Remove the market from the caller's watchlist.\r\n            Idempotent: removing a market the user isn't watching returns\r\n            `{ watching: false }` with 200, not 404 — the toggle UI\r\n            treats this as the success path.",
        "parameters": [
          {
            "name": "marketId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "AccessTokenResponse": {
        "required": [
          "accessToken",
          "expiresIn",
          "refreshToken"
        ],
        "type": "object",
        "properties": {
          "tokenType": {
            "type": "string",
            "nullable": true,
            "readOnly": true
          },
          "accessToken": {
            "type": "string",
            "nullable": true
          },
          "expiresIn": {
            "type": "integer",
            "format": "int64"
          },
          "refreshToken": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "AddCommentRequest": {
        "type": "object",
        "properties": {
          "body": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "BalanceAdjustRequest": {
        "type": "object",
        "properties": {
          "delta": {
            "type": "number",
            "format": "double"
          },
          "reason": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "BanUserRequest": {
        "type": "object",
        "properties": {
          "reason": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "CancelPreviewResponse": {
        "type": "object",
        "properties": {
          "marketId": {
            "type": "integer",
            "format": "int32"
          },
          "totalRefundOxc": {
            "type": "number",
            "format": "double"
          },
          "openOrderCount": {
            "type": "integer",
            "format": "int32"
          }
        },
        "additionalProperties": false
      },
      "CreateMarketGroupRequest": {
        "type": "object",
        "properties": {
          "title": {
            "type": "string",
            "nullable": true
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "type": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "CreateMarketRequest": {
        "type": "object",
        "properties": {
          "question": {
            "type": "string",
            "nullable": true
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "closesAt": {
            "type": "string",
            "format": "date-time"
          },
          "resolutionSource": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "DeleteCommentRequest": {
        "type": "object",
        "properties": {
          "reason": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "ForgotPasswordRequest": {
        "required": [
          "email"
        ],
        "type": "object",
        "properties": {
          "email": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "HttpValidationProblemDetails": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "nullable": true
          },
          "title": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "detail": {
            "type": "string",
            "nullable": true
          },
          "instance": {
            "type": "string",
            "nullable": true
          },
          "errors": {
            "type": "object",
            "additionalProperties": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "nullable": true
          }
        },
        "additionalProperties": { }
      },
      "InfoRequest": {
        "type": "object",
        "properties": {
          "newEmail": {
            "type": "string",
            "nullable": true
          },
          "newPassword": {
            "type": "string",
            "nullable": true
          },
          "oldPassword": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "InfoResponse": {
        "required": [
          "email",
          "isEmailConfirmed"
        ],
        "type": "object",
        "properties": {
          "email": {
            "type": "string",
            "nullable": true
          },
          "isEmailConfirmed": {
            "type": "boolean"
          }
        },
        "additionalProperties": false
      },
      "LoginRequest": {
        "required": [
          "email",
          "password"
        ],
        "type": "object",
        "properties": {
          "email": {
            "type": "string",
            "nullable": true
          },
          "password": {
            "type": "string",
            "nullable": true
          },
          "twoFactorCode": {
            "type": "string",
            "nullable": true
          },
          "twoFactorRecoveryCode": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "MarketDetailDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int32"
          },
          "question": {
            "type": "string",
            "nullable": true
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "closesAt": {
            "type": "string",
            "format": "date-time"
          },
          "status": {
            "type": "string",
            "nullable": true
          },
          "resolvedOutcome": {
            "type": "string",
            "nullable": true
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "topOfBook": {
            "$ref": "#/components/schemas/TopOfBookDto"
          },
          "lastPrice": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "recentTrades": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TradeDto"
            },
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "MarketGroupChildDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int32"
          },
          "question": {
            "type": "string",
            "nullable": true
          },
          "closesAt": {
            "type": "string",
            "format": "date-time"
          },
          "status": {
            "type": "string",
            "nullable": true
          },
          "resolvedOutcome": {
            "type": "string",
            "nullable": true
          },
          "topOfBook": {
            "$ref": "#/components/schemas/TopOfBookDto"
          },
          "lastPrice": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "MarketGroupChildSummary": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int32"
          },
          "question": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "nullable": true
          },
          "resolvedOutcome": {
            "type": "string",
            "nullable": true
          },
          "bestAskProbability": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "MarketGroupDetailDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int32"
          },
          "title": {
            "type": "string",
            "nullable": true
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "type": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "nullable": true
          },
          "resolvedChildMarketId": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "resolvedAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "markets": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MarketGroupChildDto"
            },
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "MarketGroupDetailResponse": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int32"
          },
          "title": {
            "type": "string",
            "nullable": true
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "type": {
            "type": "string",
            "nullable": true
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          },
          "resolvedChildMarketId": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "resolvedAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "markets": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MarketGroupChildSummary"
            },
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "MarketGroupListItemDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int32"
          },
          "title": {
            "type": "string",
            "nullable": true
          },
          "type": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "nullable": true
          },
          "childCount": {
            "type": "integer",
            "format": "int32"
          },
          "totalVolume": {
            "type": "number",
            "format": "double"
          }
        },
        "additionalProperties": false
      },
      "MarketGroupListResponseDto": {
        "type": "object",
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MarketGroupListItemDto"
            },
            "nullable": true
          },
          "page": {
            "type": "integer",
            "format": "int32"
          },
          "pageSize": {
            "type": "integer",
            "format": "int32"
          },
          "total": {
            "type": "integer",
            "format": "int32"
          }
        },
        "additionalProperties": false
      },
      "MarketGroupResponse": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int32"
          },
          "title": {
            "type": "string",
            "nullable": true
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "type": {
            "type": "string",
            "nullable": true
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          },
          "resolvedChildMarketId": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "resolvedAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "childCount": {
            "type": "integer",
            "format": "int32"
          }
        },
        "additionalProperties": false
      },
      "MarketListItemDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int32"
          },
          "question": {
            "type": "string",
            "nullable": true
          },
          "closesAt": {
            "type": "string",
            "format": "date-time"
          },
          "status": {
            "type": "string",
            "nullable": true
          },
          "resolvedOutcome": {
            "type": "string",
            "nullable": true
          },
          "lastPrice": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "MarketListResponseDto": {
        "type": "object",
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MarketListItemDto"
            },
            "nullable": true
          },
          "page": {
            "type": "integer",
            "format": "int32"
          },
          "pageSize": {
            "type": "integer",
            "format": "int32"
          },
          "total": {
            "type": "integer",
            "format": "int32"
          }
        },
        "additionalProperties": false
      },
      "MarketResponse": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int32"
          },
          "question": {
            "type": "string",
            "nullable": true
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "closesAt": {
            "type": "string",
            "format": "date-time"
          },
          "status": {
            "type": "string",
            "nullable": true
          },
          "resolvedOutcome": {
            "type": "string",
            "nullable": true
          },
          "resolutionSource": {
            "type": "string",
            "nullable": true
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        },
        "additionalProperties": false
      },
      "PlaceOrderRequest": {
        "type": "object",
        "properties": {
          "marketId": {
            "type": "integer",
            "format": "int32"
          },
          "side": {
            "type": "string",
            "nullable": true
          },
          "type": {
            "type": "string",
            "nullable": true
          },
          "price": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "size": {
            "type": "number",
            "format": "double"
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "POST /api/v1/orders body. `Side` and `Type` are\r\n            case-insensitive enum strings (\"BuyYes\" / \"SellYes\" / \"BuyNo\" / \"SellNo\";\r\n            \"Limit\" / \"Market\"). `Price` is required for Limit and rejected at\r\n            the controller for Market (see `OrdersController.Place`).\r\n            `Size` is decimal — fractional shares are valid. `ExpiresAt`,\r\n            if given, is coerced to UTC and must be in the future."
      },
      "RefreshRequest": {
        "required": [
          "refreshToken"
        ],
        "type": "object",
        "properties": {
          "refreshToken": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "RegisterRequest": {
        "required": [
          "email",
          "password"
        ],
        "type": "object",
        "properties": {
          "email": {
            "type": "string",
            "nullable": true
          },
          "password": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "ResendConfirmationEmailRequest": {
        "required": [
          "email"
        ],
        "type": "object",
        "properties": {
          "email": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "ResetPasswordRequest": {
        "required": [
          "email",
          "newPassword",
          "resetCode"
        ],
        "type": "object",
        "properties": {
          "email": {
            "type": "string",
            "nullable": true
          },
          "resetCode": {
            "type": "string",
            "nullable": true
          },
          "newPassword": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "ResolveMarketRequest": {
        "type": "object",
        "properties": {
          "outcome": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "ResolvePreviewResponse": {
        "type": "object",
        "properties": {
          "marketId": {
            "type": "integer",
            "format": "int32"
          },
          "outcome": {
            "type": "string",
            "nullable": true
          },
          "totalGrossPayoutOxc": {
            "type": "number",
            "format": "double"
          },
          "totalSettlementFeeOxc": {
            "type": "number",
            "format": "double"
          },
          "totalNetPayoutOxc": {
            "type": "number",
            "format": "double"
          },
          "winnerCount": {
            "type": "integer",
            "format": "int32"
          }
        },
        "additionalProperties": false
      },
      "SimulateDepositRequest": {
        "type": "object",
        "properties": {
          "amount": {
            "type": "number",
            "format": "double"
          },
          "txHash": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "SuggestRequest": {
        "type": "object",
        "properties": {
          "outcome": {
            "type": "string",
            "nullable": true
          },
          "sourceUrl": {
            "type": "string",
            "nullable": true
          },
          "note": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "TopOfBookDto": {
        "type": "object",
        "properties": {
          "bestBid": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "bestAsk": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "Best bid + best ask for a market's YES book. Either side\r\n            is null when no Open / PartiallyFilled order exists on that side.\r\n            Read DB-derived (not from engine state) — see\r\n            `MarketsController` class doc for the rationale."
      },
      "TradeDto": {
        "type": "object",
        "properties": {
          "price": {
            "type": "integer",
            "format": "int32"
          },
          "size": {
            "type": "number",
            "format": "double"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        },
        "additionalProperties": false,
        "description": "Single fill row in the recent-trades window. `Price` is\r\n            cents (0–100 on the YES book); `Size` is decimal shares."
      },
      "TradeHistoryDto": {
        "type": "object",
        "properties": {
          "range": {
            "type": "string",
            "nullable": true
          },
          "points": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TradePoint"
            },
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "TradePoint": {
        "type": "object",
        "properties": {
          "t": {
            "type": "integer",
            "format": "int64"
          },
          "p": {
            "type": "integer",
            "format": "int32"
          }
        },
        "additionalProperties": false,
        "description": "Compact time-series point for the price chart. `T` is\r\n            Unix seconds, `P` is YES price in cents. Two-char field names\r\n            keep the JSON payload small on the chart's hot fetch path."
      },
      "TwoFactorRequest": {
        "type": "object",
        "properties": {
          "enable": {
            "type": "boolean",
            "nullable": true
          },
          "twoFactorCode": {
            "type": "string",
            "nullable": true
          },
          "resetSharedKey": {
            "type": "boolean"
          },
          "resetRecoveryCodes": {
            "type": "boolean"
          },
          "forgetMachine": {
            "type": "boolean"
          }
        },
        "additionalProperties": false
      },
      "TwoFactorResponse": {
        "required": [
          "isMachineRemembered",
          "isTwoFactorEnabled",
          "recoveryCodesLeft",
          "sharedKey"
        ],
        "type": "object",
        "properties": {
          "sharedKey": {
            "type": "string",
            "nullable": true
          },
          "recoveryCodesLeft": {
            "type": "integer",
            "format": "int32"
          },
          "recoveryCodes": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "nullable": true
          },
          "isTwoFactorEnabled": {
            "type": "boolean"
          },
          "isMachineRemembered": {
            "type": "boolean"
          }
        },
        "additionalProperties": false
      },
      "UpdateMarketGroupRequest": {
        "type": "object",
        "properties": {
          "title": {
            "type": "string",
            "nullable": true
          },
          "description": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "UpdateMarketRequest": {
        "type": "object",
        "properties": {
          "question": {
            "type": "string",
            "nullable": true
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "closesAt": {
            "type": "string",
            "format": "date-time"
          },
          "resolutionSource": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "WithdrawRequest": {
        "type": "object",
        "properties": {
          "toAddress": {
            "type": "string",
            "nullable": true
          },
          "amount": {
            "type": "number",
            "format": "double"
          }
        },
        "additionalProperties": false
      }
    }
  },
  "tags": [
    {
      "name": "Account",
      "description": "Per-user account endpoints: CSV exports (trades / ledger / positions /\r\norders) for self-service data portability + the demo deposit faucet.\r\nEvery endpoint scopes by the JWT's NameIdentifier — there's no admin\r\nsurface here, just the caller's own data."
    },
    {
      "name": "Comments",
      "description": "Per-market comment thread CRUD. Routed under\r\n`/api/v1/markets/{marketId}/comments` so the market is always\r\nscope-of-record. List is anonymous; Add / Edit / Delete / Undelete\r\nrequire auth and enforce author-only ownership at the action level."
    },
    {
      "name": "CryptoRail",
      "description": "Phase-1 crypto deposit rail (testnet). Lets a user get a USDC deposit\r\naddress, simulate an inbound deposit (the testnet stand-in for an\r\non-chain webhook), and withdraw — proving the full\r\ndeposit→credit→withdraw→reconcile loop end-to-end with no real funds."
    },
    {
      "name": "MarketGroups",
      "description": "Public read-only API for OutcomeX.Web.Domain.MarketGroup aggregations\r\n(categorical or bundled families of related markets — e.g. \"Who will\r\nwin the 2028 election?\" with one child market per candidate). No\r\n`[Authorize]`; same shape as OutcomeX.Web.Controllers.MarketsController.\r\nGroup lifecycle (create / resolve / cascade) lives on\r\n`Admin/MarketGroupsController`, not here."
    },
    {
      "name": "Markets",
      "description": "Public read-only market data API. No `[Authorize]` — anonymous\r\ncallers can list markets, search them, and pull top-of-book + recent\r\ntrades. Mutations (create/resolve/cancel) live on\r\n`Admin/MarketsController`; user-owned orders live on\r\nOutcomeX.Web.Controllers.OrdersController."
    },
    {
      "name": "Orders",
      "description": "REST endpoints for user-owned orders: place, cancel one, cancel all,\r\nclose a position at market, list, fetch. Sits in front of\r\nOutcomeX.Web.Engine.IOrderEngine + OutcomeX.Web.Services.OrderCancellationService —\r\nauthorization, validation, ledger accounting, and DTO shaping happen\r\nhere; matching happens in the engine."
    },
    {
      "name": "ResolutionSuggestions",
      "description": "Community resolution suggestions. A signed-in user proposes how an Open\r\nmarket should resolve (an OutcomeX.Web.Domain.Outcome plus optional evidence link\r\nand rationale). This is ADVISORY ONLY — it never resolves anything; an admin\r\nmakes the binding call through the normal resolve endpoint, now informed by\r\nthe suggestions surfaced on the market's Resolution tab + the admin view."
    },
    {
      "name": "Watchlist",
      "description": "Per-user market bookmarks. CRUD over a single composite-keyed table\r\n(OutcomeX.Web.Domain.WatchlistEntry, PK = UserId + MarketId). Idempotent\r\nby design — Add and Remove both return the post-state shape\r\n`{ watching: bool }` regardless of whether they actually mutated,\r\nso the toggle UI can fire-and-forget without observing 4xx for \"already\r\nthere\" or \"already gone\" cases."
    },
    {
      "name": "Users",
      "description": "Admin moderation surface for user accounts: ban / unban + balance\r\nadjustments. Gated by `[Authorize(Roles = AdminSeeder.AdminRole)]`\r\nat the class level — there is no public counterpart."
    }
  ]
}