openapi: 3.1.0

# Hand-authored on purpose. The app serves no generated spec: every router is
# registered include_in_schema=False and /openapi.json is disabled outright in
# production (app/main.py), so route names like wipe-stash can never leak
# through a spec. This file describes the ONE endpoint that is meant to be
# public — the ingest endpoint — and nothing else.
#
# FastAPI could not generate this anyway: POST /in/{token} reads the raw body
# and dispatches on Content-Type, so there is no Pydantic request model to
# introspect and the generated requestBody would be empty.
#
# Because it is hand-authored it can drift. tests/api/test_openapi_spec.py
# asserts the columns, synonyms, limits, and tag regex here match the parser
# constants, so ./dev test fails if they diverge.

info:
  title: trdstash ingest API
  version: "1.0.0"
  summary: Write-only endpoint for POSTing trade executions.
  # Replaced with doc/INGEST.md when the spec is served — see
  # app/api/docs.py:_spec_text. The prose lives in a real markdown file so it
  # stays reviewable in a diff instead of being a 200-line YAML string, and
  # there is still only one published surface: this document.
  description: |
    Your stash has one write-only URL. POST executions to it. trdstash dedupes them,
    FIFO-matches them into round-trip trades, and renders them on the dashboard.

    There is no file upload UI. That is deliberate — this product is for traders who
    already produce structured data.

    **This page is the whole manual.** The reference below is generated from the
    spec and covers every field, type, and status code. What's here is the part a
    schema can't express: what makes two rows the same row, what a re-POST
    overwrites, and which of the counters is worth alerting on.

    Two facts to build on, before anything else:

    **Retries are safe.** Dedupe is the core promise — re-POST the same batch, the
    same file, the same five years of history, and the second POST inserts nothing.
    Build error handling around retrying, not around tracking what you already sent.

    **A 200 does not mean every row landed.** Rows are validated one at a time and a
    bad one is rejected on its own, so the status says the request was accepted, not
    that the data was. Read the counters. **Reading the response** below is the part
    worth getting right.

    ## Get your URL

    Sign in, open **Settings → Ingest endpoint**, and copy the URL. It looks like:

    ```
    https://trdstash.com/in/8f2a91c4e7b3d05a
    ```

    The path segment after `/in/` is your secret. Anyone holding it can write to
    your stash. It cannot read your stash — the URL is POST-only, and there is no
    GET handler on it. Rotate it from the same screen if it leaks; the old token
    stops working immediately.

    Treat it like any other write credential: read it from an environment variable,
    and don't inline it in the integration, log it, or print it in output the user
    might paste somewhere public.

    ## The smallest thing that works

    Four columns. Symbol, quantity, price, side.

    ```csv
    Symbol,Qty,Price,Side
    SPY,100,510.22,Buy
    SPY,100,511.50,Sell
    ```

    ```bash
    curl -X POST 'https://trdstash.com/in/YOUR_TOKEN' \
      -H 'Content-Type: text/csv' \
      -H 'User-Agent: my-trading-bot/1.0' \
      --data-binary @trades.csv
    ```

    ```json
    {"inserted": 2, "updated": 0, "skipped": 0, "rejected": 0,
     "accounts_created": [], "summary": "Stashed 2."}
    ```

    Two executions in, one round-trip trade on the dashboard.

    Set a `User-Agent`. Cloudflare blocks Python's default urllib string.

    Every column, synonym, and type is in the reference below. A header row is
    optional only if your columns are in the canonical order shown there; any other
    order needs one. Header matching ignores case and surrounding whitespace.

    ## Tags

    Tags are optional. Everything above works without them, and so does the
    dashboard. Add them and they become how you slice it — `key:value`, and yours
    to define.

    ```
    strategy:breakout|regime:bull-trend|confidence:A+|session:morning
    ```

    Pipe-separated in one CSV cell; an array of strings in JSON.

    - Keys are lowercase. Start with `a-z`, then `a-z0-9_-`.
    - Values start with a letter or digit, then `A-Za-z0-9_+.-`. Case is preserved.
    - No whitespace. Slug it: `Privat Live` → `Privat_Live`.
    - 64 characters per tag.

    Those rules are enforced, and a tag that breaks them rejects **the whole row** —
    the execution never lands, and `errors` carries the reason. Slug values before
    you send them rather than hoping; `Privat Live` costs you the fill, not just
    the tag.

    On the dashboard, different keys are AND'd and values within one key are OR'd.
    So `strategy:breakout|strategy:pullback|regime:bull` reads as "breakout or
    pullback, in a bull regime."

    Tags are dumb strings by design. trdstash does not validate that
    `strategy:breakout` means anything. That vocabulary is yours.

    Which leaves you to invent one. **Naming things** below is the vocabulary we'd
    pick, and the reasons — none of it enforced, all of it expensive to change
    later.

    ## Before you backfill

    A two-row curl is forgiving. A five-year history is not, and the difference is
    worth two minutes.

    **Run it as a dry run first.** Add `?validate=1` (or send `X-Dry-Run: 1`) and
    the batch runs for real — accounts resolved, dedupe evaluated, quota checked —
    then rolls back. You get the identical response with `"dry_run": true`, and
    nothing is stored.

    ```bash
    curl -X POST 'https://trdstash.com/in/YOUR_TOKEN?validate=1' \
      -H 'Content-Type: text/csv' --data-binary @backfill.csv
    ```

    The counters are real, not estimated. Two fields in that response tell you
    whether the backfill will land where you think:

    **`accounts_created`** should be empty, unless this genuinely is a new account.
    A name here means your `Account` value doesn't match what's already stored — and
    because `Account` is part of a row's identity, the backfill is about to insert a
    second copy of history you already have, under a second account. See
    **Dedupe** below for the rule.

    **`cross_account_ids`** should be zero. It counts rows whose `ID` already exists
    under a different account — the same fork, seen from the other side.

    Neither blocks the write. We report, we don't adjudicate your data. But on a
    dry run they cost nothing to check, and afterwards they are the only signal
    you get: a fork produces `inserted: 633, rejected: 0`, which reads exactly like
    a correct first import.

    If one does fire after the fact, it's repairable without deleting anything —
    see **If something looks wrong**.

    ## Dedupe

    POST the same file twice and the second POST inserts zero rows. This is the core
    promise, so you can re-run a backfill without thinking about it.

    An execution is identified by **you, your account, and the row's key**:

    1. Your `ID` column, when you send one. **Send it.**
    2. Otherwise a sha256 of account, timestamp, symbol, side, qty, price,
       commission, trans fee, and ECN fee.

    Without an ID, two genuinely separate fills of 100 SPY at the same price, in the
    same second, in the same account collapse into one. Rare, but real.

    ### `Account` is part of the identity

    Your `ID` is unique *within an account*, not across your stash. The same `ID`
    arriving under a different `Account` value is a different execution, and it is
    inserted rather than deduped.

    ```csv
    Account,Symbol,Qty,Price,Side,ID
    TR Live,SPY,100,510.22,Buy,alpaca:8f2a91      ← inserted
    Privat Live,SPY,100,510.22,Buy,alpaca:8f2a91  ← ALSO inserted, same ID
    ```

    So a typo, a stale display name in your config, or a renamed account on your
    side splits your history in two. Treat the `Account` value as a stable key
    rather than a label — derive it from the broker account id, not from something a
    human edits. If you want a prettier name, rename the account in Settings; that
    changes the display only and leaves ingest matching alone.

    ### What a re-POST changes

    Tags and the risk columns are the only mutable fields on a duplicate. Everything
    else is frozen. A re-POST with a corrected stop fixes the trade, and the
    response counts it under `updated` rather than `inserted`.

    Tags update by **namespace**, and a row only speaks for the namespaces it
    mentions:

    | What you send | What happens |
    |---|---|
    | No `Tags` column at all | Nothing. Stored tags are left alone. |
    | `Tags` present, with values | Each key you send replaces that key's stored tags. Keys you don't send are untouched. |
    | `Tags` present but empty | You're asserting the row has no tags. All of them are cleared. |

    So a row arriving with `strategy:reversal` replaces `strategy:breakout` and
    leaves `note:clean_setup` exactly where it was.

    The first and third rows are opposite instructions. "I didn't mention tags" and
    "this row has no tags" used to be the same thing; they aren't. Only the explicit
    empty cell clears.

    In JSON the same three cases are: no `tags` key, `"tags": [...]`, and
    `"tags": []`.

    ## Naming things

    Everything below is a recommendation, not a rule. trdstash accepts any non-empty
    string for `Account`, any `key:value` pair that matches the grammar above, and
    any `ID` — none of these conventions are validated, and none of them ever cause
    a rejection.

    **If you're generating an integration, follow them by default and don't write
    code that enforces them.** Rejecting a user's existing account names or tag
    vocabulary at the client is worse than accepting a value we'd have named
    differently. The reason to default to these is that `Account` values and tag
    vocabularies are cheap to choose now and expensive to change after a year of
    history.

    ### Accounts

    **Use `<broker>-<env>-<broker_account_id>`.** Recommended, not enforced.

    ```
    alpaca-live-PA3XYZ9K
    alpaca-paper-PA7QW21B
    ibkr-live-U1234567
    ```

    Three properties earn their keep:

    **Derived, never typed.** The account id comes from the broker's API — on
    Alpaca, `GET /v2/account` → `account_number`. Nothing a human edits appears
    anywhere in the value. Since `Account` is part of a row's identity, a stale
    label in a config file doesn't produce an error; it produces a second copy of
    your history under a second account.

    **Environment in the key, not in a tag.** Matching is FIFO per symbol *and
    account*, so if paper and live share one account a paper fill will close a live
    position, and the round trip you get back is fiction. A tag can't prevent that.
    Only the account boundary can.

    **Ugly on purpose.** The readable name lives in Settings, where renaming
    changes the display and leaves ingest matching alone. The value you POST never
    has to be pretty — and the moment it is, someone will want to edit it.

    ### Strategy tags

    **Use one key per dimension, with lowercase values.** Recommended, not
    enforced — the only hard rule is the `key:value` grammar above.

    ```
    strategy:orb|variant:tight-stop|regime:bull-trend|session:premarket
    ```

    rather than:

    ```
    strategy:orb-tight-stop-bull-premarket
    ```

    The second is one opaque value. There is no `strategy:orb` to select, because
    every ORB trade carries a different composite — so "how does ORB do overall",
    "does the tight stop help", and "does this only work in trends" all become
    unanswerable at once. Keys are AND'd and values within a key are OR'd, so
    splitting costs nothing at write time and buys four filters that compose.

    Two conventions inside the value:

    **Lowercase it.** Case survives ingest, so `strategy:ORB` and `strategy:orb`
    are two different strategies on the dashboard.

    **Name variants, don't number them.** `variant:tight-stop` tells you what you
    were comparing; `variant:3` sends you looking for a changelog you never wrote.
    Name the trait rather than the edit — `tight-stop` stands alone, where
    `removed-gap-filter` describes a diff from whatever came before and stops
    identifying anything by the fourth variant. You give up the ordering a number
    carries, but executions are timestamped, so chronology is already in the data.

    Whatever you pick, keep it stable. Renaming `momo` to `momentum` halfway
    through leaves you with two strategies forever — accounts can be merged in
    Settings, tags cannot.

    ### Execution IDs

    **Use `<broker>:<the broker's own id for that fill>` — one id per fill.**
    Recommended, not enforced, but the per-fill half is the closest thing on this
    page to a real rule.

    ```
    alpaca:20260824000000000::a4f9c2e1
    ```

    The namespace keeps another broker's ids from ever colliding with these. The
    per-fill part matters more: a partially filled order produces several
    executions sharing one order id, so keying on the order silently collapses
    real fills into one and reports them as duplicates. Nothing errors. The
    history is just quietly wrong.

    If your broker lets you stamp your own id on an order — Alpaca's
    `client_order_id` — that field is the place to carry strategy out with the
    order, so the tags come back attached to the fills instead of being
    reconstructed afterwards. `integrations/alpaca.py` has a worked version.

    ## Reading the response

    A 200 does not mean every row landed. Rows are validated individually — check
    `rejected` and read `errors`, which carries a row number and a reason for each
    failure, capped at 50.

    **`skipped` is computed as `rows − inserted − updated`.** It is not a tally of
    anything, so `inserted + updated + skipped + rejected` always equals the rows
    you sent, arithmetically, whatever the server did. It cannot detect a problem,
    so it isn't worth alerting on.

    Use the precise fields instead. `skipped == unchanged + duplicate_in_batch`, and
    those two want opposite reactions: `unchanged` is the healthy steady state,
    `duplicate_in_batch` means your source emitted the same execution twice inside
    one request.

    For a scheduled sync, the two fields worth alerting on are `accounts_created`
    and `cross_account_ids` — both empty when things are healthy. A third check
    worth building yourself: a high `inserted` ratio for an account you've synced
    before is the signature of rows landing under a new identity.

    `detail` breaks the counters down by the id you sent, or the derived `hash:...`
    key for rows with no `ID`. Sampled to the first 50 per bucket, with `truncated`
    saying whether anything was cut. Add `?detail=full` for the complete mapping —
    worth it when verifying a large one-off backfill, which is exactly when a sample
    is useless.

    ### Writing the sync loop

    **Retry the whole batch.** Dedupe makes it free. Don't build a cursor that
    tracks what you've already sent — it will be wrong at exactly the moment that
    matters, and it's solving a problem the server already solved.

    **Don't retry a `rejected` row unchanged.** It failed validation and will fail
    the same way forever. Surface the `errors` entries — each carries a row number
    and a reason — and let a human fix the source.

    **On 429, back off and read which kind.** Either you're over the per-token rate
    limit, where retrying after a minute works, or over the monthly quota, where it
    won't help until the cycle turns. Current ceilings are in the reference below.

    **On 413, split the file.** There's a per-POST row cap; batch under it rather
    than retrying the same body.

    **Alert on `accounts_created` and `cross_account_ids`.** Not on `skipped`. A
    steady-state sync leaves both empty forever, so a non-empty one is the only
    signal you get that your history has started forking.

    ## If something looks wrong

    **Trades aren't appearing.** Check `inserted`. If it's 0 and `skipped` is high,
    you already sent them.

    **An account you didn't expect showed up, holding trades you already had.** Your
    `Account` value changed between runs. Fix it at the source so the next sync
    stops splitting, then in **Settings → Accounts** pick **merge into…** on the
    stray account and choose the real one.

    Merging moves the executions across, drops the ones already there as the
    duplicates they are, and re-runs the matcher — so a trade whose entry landed
    under one label and exit under the other is paired back into one round trip.
    Nothing else is lost. Renaming is not the same thing: it changes the display
    label only, so the history stays split.

    **Everything re-inserted after I changed something.** The only fields you can
    change on a stored row are tags and the risk columns. Change `Account`,
    timestamp, symbol, side, qty, price, or any fee on a row with no `ID`, and it's
    a different execution by definition.

    **One trade split into several.** Matching is FIFO per symbol and account. A
    partial close opens a new trade for the remainder.

    **Timestamps are off by hours.** Times are UTC. trdstash does not convert from
    your local zone.

    **No R-multiples on the dashboard.** Risk must be on the **opening** row of the
    position, not the closing one. The Edge panel shows its own coverage — "R on 142
    of 1,284" — so you can see how much of your book carries risk data.

    **A trade shows no R even though you sent a stop.** Your stop was on the wrong
    side of entry. A stop there is a data error; trdstash flags the trade and
    records no risk rather than inverting the number.

servers:
  - url: https://trdstash.com
    description: Production
  - url: http://localhost:8000
    description: Local dev (./dev up)

tags:
  - name: ingest
    description: The write-only endpoint.

paths:
  /in/{token}:
    post:
      tags: [ingest]
      operationId: ingestExecutions
      # Renderers use `summary` as the sidebar label, so no trailing period.
      summary: POST executions to your stash
      # No OpenAPI security scheme applies: the credential is the token in the
      # path, which the type system can't express. Empty rather than absent —
      # absent inherits any root-level requirement and renders as "no
      # authentication selected", which reads like an unfinished spec.
      security: []
      description: |
        Send CSV, TSV, or JSON. The response reports what landed.

        A 200 does not mean every row landed — rows are validated individually.
        Always check `rejected` and read `errors`.

        There is intentionally no `GET` on this path. Keeping it POST-only
        preserves the write-only contract and removes the 200-vs-404 oracle a
        GET handler would hand an attacker probing for valid tokens.
      parameters:
        - name: token
          in: path
          required: true
          description: |
            Your ingest token. **This path segment IS your secret** — anyone
            holding it can write to your stash. Rotate it from Settings if it
            leaks; the old token stops working immediately.
          schema:
            type: string
          example: 8f2a91c4e7b3d05a
        - name: User-Agent
          in: header
          required: false
          description: |
            Set a non-default value. Cloudflare blocks Python's default urllib
            User-Agent string.
          schema:
            type: string
          example: my-trading-bot/1.0
        - name: detail
          in: query
          required: false
          description: |
            `full` lifts the 50-id-per-bucket sample cap on `detail` and returns
            the complete mapping. Worth it when verifying a large one-off
            backfill; wasteful otherwise.
          schema:
            type: string
            enum: [full]
        - name: validate
          in: query
          required: false
          description: |
            `1` runs the batch for real — accounts resolved, dedupe evaluated,
            quota checked — then rolls it back. Identical response with
            `dry_run: true`, nothing stored. The counters are real, not
            estimated. `X-Dry-Run: 1` does the same thing.
          schema:
            type: string
            enum: ['1']
        - name: X-Dry-Run
          in: header
          required: false
          description: Header form of `?validate=1`.
          schema:
            type: string
      requestBody:
        required: true
        content:
          text/csv:
            schema:
              type: string
            examples:
              minimum:
                summary: The four required columns
                value: |
                  Symbol,Qty,Price,Side
                  SPY,100,510.22,Buy
                  SPY,100,511.50,Sell
              withHeader:
                summary: Header row, any column order
                value: |
                  Account,Date,Time,Symbol,Qty,Price,Side,ID,Tags,stop
                  Live Q,2026-04-29,14:08:00,SPY,100,510.22,Buy,ib_a1,strategy:breakout,508.00
                  Live Q,2026-04-29,14:30:00,SPY,100,511.50,Sell,ib_a2,,
              headerless:
                summary: No header row — columns must be in canonical order
                value: |
                  Live Q,2026-04-29,14:08:00,SPY,100,524.45,Sell,e8f2
          text/tab-separated-values:
            schema:
              type: string
            example: "Symbol\tQty\tPrice\tSide\nSPY\t100\t510.22\tBuy\n"
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/Execution'
                - type: array
                  items:
                    $ref: '#/components/schemas/Execution'
            examples:
              single:
                summary: One fill
                value:
                  account: Live Q
                  datetime: '2026-04-29T14:08:00Z'
                  symbol: SPY
                  side: Buy
                  qty: 100
                  price: 510.22
                  commission: 0.5
                  id: ib_8f2a91
                  tags: [strategy:breakout, regime:bull, confidence:A+]
              roundTrip:
                summary: An array — entry carries the stop, exit closes it
                value:
                  - account: Live Q
                    datetime: '2026-04-29T14:08:00Z'
                    symbol: SPY
                    side: Buy
                    qty: 100
                    price: 510.22
                    stop_price: 508.00
                    id: ib_a1
                  - account: Live Q
                    datetime: '2026-04-29T14:30:00Z'
                    symbol: SPY
                    side: Sell
                    qty: 100
                    price: 511.50
                    id: ib_a2
      responses:
        '200':
          description: |
            Batch parsed. Rows may still have been rejected individually —
            check `rejected` and `errors`.
          headers:
            Link:
              $ref: '#/components/headers/SchemaLink'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IngestResult'
              examples:
                clean:
                  summary: Everything landed
                  value:
                    inserted: 2
                    updated: 0
                    skipped: 0
                    rejected: 0
                    errors: []
                    unchanged: 0
                    duplicate_in_batch: 0
                    detail:
                      inserted: [ib_a1, ib_a2]
                      updated: []
                      unchanged: []
                      duplicate_in_batch: []
                      truncated: false
                    accounts_created: []
                    summary: Stashed 2.
                    schema_url: https://trdstash.com/api/ingest-schema
                forkedAccount:
                  summary: >-
                    Healthy-looking counters, but the account value drifted —
                    these rows just duplicated an existing account's history
                  value:
                    inserted: 633
                    updated: 0
                    skipped: 0
                    rejected: 0
                    errors: []
                    unchanged: 0
                    duplicate_in_batch: 0
                    detail:
                      inserted: ["alpaca:8f2a91"]
                      updated: []
                      unchanged: []
                      duplicate_in_batch: []
                      truncated: true
                    accounts_created: ["Privat Live"]
                    summary: 'Stashed 633. New account: "Privat Live".'
                    schema_url: https://trdstash.com/api/ingest-schema
                partial:
                  summary: A re-run of a backfill, with two bad rows
                  value:
                    inserted: 1282
                    updated: 0
                    skipped: 142
                    rejected: 2
                    errors:
                      - row: 47
                        reason: Tag "Strategy:Breakout" isn't valid — expected key:value.
                      - row: 981
                        reason: Quantity is zero.
                    unchanged: 142
                    duplicate_in_batch: 0
                    detail:
                      inserted: ["alpaca:1", "alpaca:2"]
                      updated: []
                      unchanged: ["alpaca:900"]
                      duplicate_in_batch: []
                      truncated: true
                    accounts_created: []
                    summary: >-
                      Stashed 1,282. Skipped 142 — already in your stash.
                      Couldn't read 2 rows.
                    schema_url: https://trdstash.com/api/ingest-schema
        '404':
          description: Token unknown. Check for a truncated copy-paste.
          headers:
            Link:
              $ref: '#/components/headers/SchemaLink'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                reason: This ingest URL isn't valid.
        '413':
          description: More than 5,000 rows in one POST. Split the file.
          headers:
            Link:
              $ref: '#/components/headers/SchemaLink'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                reason: Too many rows in one POST.
        '415':
          description: Unsupported Content-Type.
          headers:
            Link:
              $ref: '#/components/headers/SchemaLink'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                reason: >-
                  Send Content-Type text/csv, text/tab-separated-values, or
                  application/json.
        '429':
          description: |
            Over the per-token rate limit (60/min), over the per-IP cap, or over
            the monthly quota (500 inserted rows on the free plan). Duplicates
            are free — the quota charges real inserts only.
          headers:
            Link:
              $ref: '#/components/headers/SchemaLink'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                rateLimited:
                  value:
                    reason: Slow down — too many ingest requests in a short window.
                overQuota:
                  value:
                    reason: You've hit your monthly stash limit.

  /api/ingest-schema:
    get:
      tags: [ingest]
      operationId: getIngestSchema
      summary: The generated ingest contract
      description: |
        Returns the same contract this file describes, but generated from the
        parser constants at runtime — so it reflects exactly what the deployed
        build accepts. Use it when you want to be certain; use this spec when
        you want tooling.

        Session-gated. Anonymous callers are redirected through Google OAuth
        with `?next=/api/ingest-schema`, so this is not usable from a bot.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: The contract, as a bespoke (non-OpenAPI) JSON document.
          content:
            application/json:
              schema:
                type: object
        '303':
          description: Not signed in — redirected to Google OAuth.

components:
  headers:
    SchemaLink:
      description: |
        RFC 8288 link to the generated contract. Present on every response from
        the ingest endpoint, including errors, so the schema is discoverable
        from the very first probe. Postman, Insomnia, Bruno, and `curl -i` all
        surface it.
      schema:
        type: string
      example: '<https://trdstash.com/api/ingest-schema>; rel="describedby"'

  securitySchemes:
    # There is deliberately no scheme for the ingest token. It's a path segment,
    # and OpenAPI's apiKey type only allows query, header, or cookie — so any
    # declaration would be a fiction, and renderers would draw an input box
    # teaching people to send the token the wrong way. The `token` path
    # parameter carries the documentation instead, and the operation declares
    # `security: []` so tooling reports "none required" rather than "none
    # selected".
    sessionCookie:
      type: apiKey
      in: cookie
      name: trdstash_session
      description: Set by Google OAuth sign-in. Browser-only.

  schemas:
    Execution:
      type: object
      description: |
        One fill. Risk fields belong on the row that OPENS a position; the
        matcher reads them from the entry lot and ignores them elsewhere.
      required: [symbol, side, qty, price]
      properties:
        symbol:
          type: string
          example: SPY
        side:
          type: string
          description: |
            Only the first character matters, case-insensitive. `B` opens long,
            `S` closes/opens short.
          examples: [Buy, Sell, BOT, SLD, B, S, Short]
        qty:
          type: number
          exclusiveMinimum: 0
          example: 100
        price:
          type: number
          example: 510.22
        account:
          type: string
          default: default
          description: |
            Free text, auto-created on first sight. Part of the dedupe identity
            — see `id`. Treat it as a stable key, not a label; renaming an
            account in Settings changes only its display name and leaves ingest
            matching alone.
          example: Live Q
        datetime:
          type: string
          format: date-time
          description: ISO-8601 with a timezone. Alternative to `date` + `time`.
          example: '2026-04-29T14:08:00Z'
        date:
          type: string
          description: Use with `time` instead of `datetime`. Defaults to today (UTC).
          example: '2026-04-29'
        time:
          type: string
          description: UTC. Omit and 12:00:00 is used.
          example: '14:08:00'
        id:
          type: string
          description: |
            Your broker's execution ID, and the preferred dedupe key. Send it —
            without one, two identical fills in the same second collapse into
            one row.

            **Scoped to the account, not global.** An execution is unique on
            (user, account, id). The same id arriving under a different
            `account` value is a different execution and will be inserted, with
            nothing in the response flagging it. Derive `account` from something
            stable like the broker account id, never from a human-editable
            display name.
          example: ib_8f2a91
        commission:
          type: number
          default: 0
        trans_fee:
          type: number
          default: 0
        ecn_fee:
          type: number
          default: 0
        tags:
          type: array
          description: |
            `key:value` strings. Different keys AND together on the dashboard;
            values within one key OR together.

            **On a repeat POST, tags merge by namespace.** Omit the key entirely
            and stored tags are left alone. Send it with values and each key you
            send replaces that key's stored tags, leaving keys you don't send
            untouched — `strategy:x` replaces `strategy:*` but not `note:*`.
            Send `[]` to assert the row has no tags and clear them all.

            Omitting the key and sending `[]` are opposite intentions. Only `[]`
            clears.
          items:
            type: string
            pattern: '^[a-z][a-z0-9_-]*:[A-Za-z0-9][A-Za-z0-9_+.-]*$'
            maxLength: 64
          example: [strategy:breakout, regime:bull-trend, confidence:A+]
        stop_price:
          type: number
          description: |
            Planned stop, on the OPENING fill. Risk = |entry − stop| × qty. A
            stop on the wrong side of entry is flagged as a data error and
            yields no R — it is never inverted to make it positive.
          example: 508.00
        risk_amount:
          type: number
          description: |
            Currency risked. Wins over `stop_price` and `risk_per_share` when
            more than one is supplied.
          example: 200
        risk_per_share:
          type: number
          description: Currency risked per unit. Risk = value × qty.
          example: 2.00

    IngestResult:
      type: object
      required:
        [inserted, updated, skipped, rejected, errors, unchanged,
         duplicate_in_batch, cross_account_ids, detail, dry_run,
         accounts_created, summary, schema_url]
      properties:
        inserted:
          type: integer
          description: New executions written this batch.
        updated:
          type: integer
          description: |
            Existing executions whose tags or risk fields changed. Those are the
            only mutable fields on a duplicate.
        skipped:
          type: integer
          description: |
            **Derived, not counted** — `rows - inserted - updated`. So
            `inserted + updated + skipped + rejected` always equals the rows you
            sent, arithmetically, whatever the server did. It cannot detect a
            problem; don't alert on it.

            Kept unchanged for existing clients. Use `unchanged` and
            `duplicate_in_batch` instead —
            `skipped == unchanged + duplicate_in_batch`.
        unchanged:
          type: integer
          description: Already stored, identical. Nothing to do. The healthy steady state.
        duplicate_in_batch:
          type: integer
          description: |
            Lost to a later copy of itself **in this request** — not "already in
            your stash". Your source emitted the same execution twice in one
            POST. Last one wins.
        dry_run:
          type: boolean
          description: |
            Whether this batch was validated and rolled back rather than
            written. See the `validate` parameter.
        cross_account_ids:
          type: integer
          description: |
            Rows whose `id` already exists under a **different** account. Not an
            error and never rejected — but on a steady-state sync it means your
            history is forking. Threshold on it. See the `id` field for why.
        detail:
          allOf:
            - $ref: '#/components/schemas/IngestDetail'
          description: |
            Which rows landed in which bucket. Sampled to 50 per bucket; pass
            `?detail=full` for the complete mapping.
        rejected:
          type: integer
          description: Rows that failed validation.
        errors:
          type: array
          description: Row-level failures, capped at 50.
          items:
            $ref: '#/components/schemas/RowError'
        accounts_created:
          type: array
          description: |
            Accounts this POST created, because the `account` value had never
            been seen before. Expected on a first import. **On a steady-state
            sync it should always be empty** — a name appearing here means your
            `account` value moved, and since account is part of the dedupe
            identity (see `id`), your history has just forked into two accounts
            holding the same trades. Alert on it.

            Contains only names you just sent, never your full account list —
            the ingest token stays write-only.
          items:
            type: string
          example: ["Privat Live"]
        summary:
          type: string
          description: Human-readable summary, in the product's voice.
          example: Stashed 1,282. Skipped 142 — already in your stash.
        schema_url:
          type: string
          format: uri
          description: |
            Same URL as the `Link: rel="describedby"` header, repeated in the
            body for JSON-only clients that don't read headers.

    IngestDetail:
      type: object
      description: |
        Per-bucket ids, reported as the `ID` you sent — or the derived
        `hash:...` key for rows that carried none.
      properties:
        inserted:
          type: array
          items: {type: string}
        updated:
          type: array
          items: {type: string}
        unchanged:
          type: array
          items: {type: string}
        duplicate_in_batch:
          type: array
          items: {type: string}
        cross_account_ids:
          type: array
          items: {type: string}
        truncated:
          type: boolean
          description: Whether any bucket was cut to the 50-id sample.

    RowError:
      type: object
      required: [row, reason]
      properties:
        row:
          type: integer
          description: 1-indexed row number within the submitted batch.
        reason:
          type: string
          example: Quantity is zero.

    Error:
      type: object
      required: [reason]
      properties:
        reason:
          type: string
          description: Short, specific, no jargon.
