diff --git a/CHANGELOG.md b/CHANGELOG.md index d08c4ee5..15a86b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **The canary runs against real venues (closes the `canary-roundtrip` + epic).** Venue identity oracle: our store-recorded fills == the + venue-reported fills (exact triples), per-asset balance deltas == the + fills math (fee-denomination-aware — never an assumed zero), position + flat, cost bounded. `--mode testnet` wired (Binance sandbox; Kraken + refused — no testnet); `--mode live` implemented behind the existing + `live_enabled` gate + typed confirmation. `doc/dev/09-go-live.md` names + the live canary as the road-to-1.0 #1 validation vehicle. **Proven on + real Binance testnet: 16/16 checks PASS** (real cancel accepted, venue + dedup on the duplicate client-order-id, cost 0.00511 USDT). (#223) +- **`Fill.fee_asset`** — the venue may charge the fee in a non-quote asset + (Binance charges a market buy's commission in BASE; found by the canary's + identity oracle refusing an unexplained −8E-8 BTC). Additive domain field + (`None` = quote, the historic meaning), threaded through the Binance + adapter, the store (migration) and the oracle's fills math. Also: + Binance HTTP-400 `{code,msg}` rejections now map to the same domain + errors as in-band bodies (they escaped as transport errors); + `BrokerConfig.symbols` threads the per-symbol trade-history scope Binance + requires for `fills()`. (#223) - **`trading-bot canary`** — the platform self-test as a one-liner (paper by default): self-contained factory engine funded with `--budget`, quantity sized to the venue's REAL minimums (public-endpoint resolver + real last diff --git a/doc/dev/03-decisions.md b/doc/dev/03-decisions.md index 3d6d2e0a..2830c082 100644 --- a/doc/dev/03-decisions.md +++ b/doc/dev/03-decisions.md @@ -6,6 +6,28 @@ rejected approaches as tombstones. --- +### 2026-07-12 The venue oracle is an accounting identity; fees carry their asset (PR #223) [accepted] +- **Choice**: on a real venue the canary asserts the **identity** — our + fills == venue-reported fills, per-asset balance deltas == the fills math + — never a precomputed PnL. Supporting discoveries hardened the domain: + `Fill.fee_asset` (Binance charges market-buy commissions in BASE; `None` + keeps the historic quote meaning), Binance HTTP-400 rejections mapped to + domain errors, `BrokerConfig.symbols` for Binance's per-symbol + trade-history scope, and a 15 % venue probe offset (the venue's + `PERCENT_PRICE_BY_SIDE` band rejects 50 %). A settle loop attributes the + venue's async executions to our client-order-ids and re-emits them on the + bus — venue truth flows through the ordinary fill plumbing. +- **Why**: spread and drift make absolute expectations flaky exactly where + correctness matters most; the identity is exact on any venue. The two + intermediate testnet failures WERE the canary working — each became a + domain fix with offline tests before the final green run. PnL folding + still values fees at face value when fee_asset ≠ quote (documented + approximation); the balance identity itself is exact. +- **Rejected alternatives**: absolute-PnL venue oracle (nondeterministic); + ignoring sub-1e-7 balance dust (it was the fee-denomination bug, not + dust); adapter-specific oracle code (the broker port suffices — the + oracle stays venue-agnostic). + ### 2026-07-12 The canary: two oracles, probes before money, resting limits in strict paper (PR #221) [accepted] - **Choice**: the canary runs probes FIRST (cancel, idempotency — both free) and only then risks the round-trip; paper mode gets an EXACT oracle diff --git a/doc/dev/06-status.md b/doc/dev/06-status.md index 7706070f..984f2e30 100644 --- a/doc/dev/06-status.md +++ b/doc/dev/06-status.md @@ -161,8 +161,20 @@ and `CHANGELOG.md` for what shipped. value / unrealised, shared expandable rows (SSE-surviving), orders with Filled % / Avg fill / Value and click-to-expand fills (standalone fills table retired from the detail page), honest last→next bar chip, timezone - label, filtered-vs-empty states. Known follow-up: `reject_reason` is - rendered defensively but not yet serialized by `/api/orders`. + label, filtered-vs-empty states. The `reject_reason` follow-up shipped in + #219. +- **`canary-roundtrip` (2026-07-12, PRs #221–#223)**: `trading-bot canary` — + the deterministic platform self-test. Probes first (real cancel = the + kill-switch path; duplicate client-order-id = the idempotency invariant), + then a sequential round-trip; paper oracle exact (PnL == −Σ fees, flat, + persisted terminals, balance deltas), venue oracle = the accounting + identity (fills/balances == venue-reported, fee-denomination-aware) + + bounded cost. **Proven on real Binance testnet: 16/16 PASS** (cost + 0.00511 USDT). Two real findings hardened the domain: `Fill.fee_asset` + (Binance charges market-buy fees in BASE) and Binance HTTP-400 → domain + error mapping. `09-go-live.md` names the live canary as the road-to-1.0 + #1 validation vehicle. Cadence: paper per release, testnet at will, live + once per venue at go-live. ## Pending diff --git a/doc/dev/07-roadmap.md b/doc/dev/07-roadmap.md index 682c1f85..23eaefec 100644 --- a/doc/dev/07-roadmap.md +++ b/doc/dev/07-roadmap.md @@ -97,43 +97,33 @@ order — none started yet, each is a `/pick-task` candidate: 200-row tail + server timestamps on snapshot endpoints, then the public contract is declared stable — 0.x-style breaking removals end at 1.0. -## UI consistency & accounting integrity (2026-07-11) +## UI consistency & accounting integrity (2026-07-11) — SHIPPED A dashboard-driven audit (position ≠ Σ orders on `alloc1-binance`) uncovered two -engine bugs and a set of API/UX gaps. Shipped 2026-07-11 (plan trees archived): -the `paper-integrity` epic (durable paper ids, order↔fill lifecycle sync, -persisted orphan-closes — PRs #190–#194, v0.12.0; restart the daemon to heal -the live books), the `accounting-guardrail` epic (pure invariant checker, -startup + 60 s-TTL wiring with SSE alerts, per-strategy health surface incl. -the kill-switch fold — the *status* half of road-to-1.0 #3 — and the dashboard -health pill; PRs #197–#201), and the `venue-minimums` epic (keyless cached -spec resolver, round-up-or-skip order prep with the spot-sell cap, strict -factory paper — PRs #203–#206; sub-minimum orders are structurally -impossible on the portfolio path), and the `api-completeness` epic (per-engine -mark cache + bar-close marks with mandatory as-of on position rows, value / -unrealised / `fee_ccy`, server-side display currency, `GET /api/balances`, -`last_asof_ts` surfaced, additive-only contract sweep — PRs #209–#214; ready -for the road-to-1.0 #5 freeze), and the `dashboard-tables-ux` epic -(asset-first positions with as-of marks + shared expandable rows, orders with -Filled %/Value + fills as expanded detail, the honest last→next bar chip, -timezone label + filtered empty states — PRs #215–#218). In dependency order: - -6. [ ] **`canary-roundtrip` — deterministic self-test strategy.** A minimal - round-trip canary run as its own strategy unit with its own tiny ledger - (~10 USDT): **sequential** market buy *x* then sell *x* (never simultaneous - — self-trade prevention would make it non-deterministic), plus two free - probes: a far-off-limit **cancel** (the real kill-switch path) and a - client-order-id **idempotency** re-submit. Two oracles: **paper** = exact - Decimal equality against the precomputed expectation (PnL = −2·fees, flat - position, terminal orders — CI-runnable, per release); **live** = exact - *accounting identity* (our fills/balances == venue-reported) + **bounded** - total cost (≤ 1–2 EUR) — never a precomputed absolute PnL (spread/drift are - not deterministic). Cadence: paper per release; testnet at will; live once - per venue at go-live + after any broker-adapter change. Depends on #1 - (order lifecycle) + #2 (health surface for the pass/fail report) + #3 - (venue minimums; Binance BNB-fee discount must be off or modeled). The live - canary is the validation vehicle for **Road to v1.0.0 #1** (real-key - enablement: venue idempotency, real cancel, balance reconciliation). +engine bugs and a set of API/UX gaps. **All six epics shipped 2026-07-11/12** +(plan trees archived; details in `06-status.md`, decisions in `03-decisions.md`): + +1. `paper-integrity` — durable paper ids, order↔fill lifecycle sync, persisted + orphan-closes (PRs #190–#194, **v0.12.0**). +2. `accounting-guardrail` — pure invariant checker, startup + 60 s-TTL wiring + with SSE alerts, per-strategy health surface incl. the kill-switch fold + (the *status* half of road-to-1.0 #3), dashboard health pill + (PRs #197–#201, **v0.13.0**). +3. `venue-minimums` — keyless cached spec resolver, round-up-or-skip order + prep with the spot-sell cap, strict factory paper (PRs #203–#206, + **v0.13.0**). +4. `api-completeness` — mark cache + bar-close marks with mandatory as-of, + value/unrealised/`fee_ccy` per position, server-side display currency, + `GET /api/balances`, additive-only contract sweep — ready for the + road-to-1.0 #5 freeze (PRs #209–#214). +5. `dashboard-tables-ux` — asset-first positions, shared expandable rows, + orders with Filled %/Value + fills as expanded detail, honest bar-timing + chip, timezone + filtered empty states (PRs #215–#219). +6. `canary-roundtrip` — deterministic self-test (`trading-bot canary`): + probes + round-trip, exact paper oracle, venue identity oracle **proven + 16/16 on real Binance testnet**; the live canary is the named validation + vehicle for road-to-1.0 #1 (PRs #221–#223; found `Fill.fee_asset` and the + Binance HTTP-400 mapping along the way). ## Not gating 1.0 (post-1.0 candidates) diff --git a/doc/dev/09-go-live.md b/doc/dev/09-go-live.md index ac9be47c..b1c4a863 100644 --- a/doc/dev/09-go-live.md +++ b/doc/dev/09-go-live.md @@ -79,12 +79,12 @@ missing any one refuses with a non-zero exit / a raised ruff check trading_bot/ mypy trading_bot/ ``` -5. **Validate against a real-key sandbox.** This is the one remaining - prerequisite and is **not done in-repo**: before any real order, exercise the - private endpoints (AddOrder / OpenOrders / balances / fills) against a real - Kraken key (ideally a low-limit, throwaway key) and confirm the - venue-reported state matches what the engine requested — see *Proven vs - pending*. **Do not skip this.** +5. **Validate against the real venue — run the canary.** Before any real order, + exercise the private endpoints (place / cancel / open orders / balances / + fills) and confirm the venue-reported state matches what the engine + requested. The **canary** (`trading-bot canary`) is the named in-repo + vehicle for exactly this — see *The canary — the validation vehicle* under + *Proven vs pending*. **Do not skip this.** Only after all five does a live `run` proceed to wire the live adapter. @@ -133,6 +133,42 @@ The left column is what the offline suite demonstrates today. The right column i the one remaining bridge to live — it requires a real key and is **out of scope for this repository's automated tests** (which never hit a real venue). +### The canary — the validation vehicle + +The pending column is exactly what the **canary** +([`application/canary.py`](../../trading_bot/application/canary.py), the +`trading-bot canary` command) proves against a real venue, in one gated run — a +minimum-size sequential round-trip plus two free probes, with an +expectation-vs-observation evidence table and a non-zero exit on the first +violation: + +```bash +trading-bot canary --exchange binance --mode testnet # sandbox: fake money, real API +trading-bot canary --exchange --mode live -c cfg.yaml # the go-live act (all gates + typed ack) +``` + +- **Venue idempotency** — the probe re-submits the *same* client-order-id and + asserts, from **venue reads** (`open_orders` / `fills`), that no duplicate + order was created. +- **Real cancel (the kill path)** — the probe places a far-below-market limit + and cancels it **on the venue**: a real cancel accepted, no fill, no balance + move, `CANCELLED` persisted. +- **Balance reconciliation** — the venue **identity oracle**: our recorded + fills == the venue-reported fills (`fills()` re-fetch; ids/qtys/prices, + exact), the venue's per-asset balance deltas == what our fills imply (the + fills math), position flat, and the total cost (the venue's quote delta) + bounded by `--max-cost`. Never a precomputed absolute PnL — a real venue's + spread/drift are not deterministic. + +`--mode testnet` runs the factory's sandbox path (hard-pinned testnet URL, +`BINANCE_TESTNET_*` keys, no `live_enabled` needed — Kraken has no spot testnet +and is refused). `--mode live` is the operator's **go-live act** for road-to-1.0 +#1: it demands the full gate stack above (`--config` with `live_enabled: true`, +credentials, all risk limits) **plus a typed confirmation** (the dashboard's +go-live phrase), and only then trades the smallest venue-legal size. Cadence: +paper per release, testnet at will, live once per venue at go-live and after +any adapter change. + --- ## Running LS1 (the first real portfolio strategy) diff --git a/doc/dev/plans/canary-roundtrip/00-plan.md b/doc/dev/plans/canary-roundtrip/00-plan.md deleted file mode 100644 index af3d69b3..00000000 --- a/doc/dev/plans/canary-roundtrip/00-plan.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -plan: canary-roundtrip -kind: global -status: planning -roadmap: "6. [ ] **`canary-roundtrip` — deterministic self-test strategy.** (UI consistency & accounting integrity, 2026-07-11)" -release_on_done: false ---- - -# canary-roundtrip — the platform proves itself with a known trade - -## Goal - -A deterministic self-test the operator can run against any venue mode: a -tiny **sequential round-trip** (market buy *x* → confirm the fill → market -sell *x* → confirm) plus two **free probes** (a far-off-limit cancel — the -real kill-switch path — and a client-order-id idempotency re-submit), with -an **oracle computed in advance**. Maintainer design, 2026-07-11; the live -canary is the validation vehicle for road-to-1.0 #1 (real-key enablement). - -## Pinned design (implement, do not re-decide) - -- **Sequential, never simultaneous** — self-trade prevention would make a - crossed pair non-deterministic. -- **Two oracles**: - - *paper*: EXACT Decimal equality — realised PnL == −Σ fees, position - flat, both orders `FILLED` with `filled_qty == qty`, store rows - consistent, broker balances delta == −Σ fees (quote) / 0 (base); - - *venue (testnet/live)*: the **accounting identity**, exact — OUR fills == - venue-reported fills, OUR balances delta == venue-reported balances - delta, position flat — plus a **bounded total cost** (`max_cost`, - config, default 2 quote units). NEVER a precomputed absolute PnL - (spread/drift are not deterministic). -- **Probes** (both modes): cancel — place a limit buy far below market - (config `probe_offset_pct`, default 50 % below), cancel it, assert no - fill, no balance change, `CANCELLED` persisted; idempotency — re-submit - the SAME client-order-id, assert no duplicate order was created. -- **Funding**: paper brokers start unfunded (epic D finding) — the canary - engine is built with explicit `starting_balances` (quote budget, config - `budget`, default 100 quote units paper / the real balance on venues). -- **Gates**: live mode requires the existing `live_enabled` opt-in + - credentials — the canary NEVER weakens a gate; testnet uses the testnet - endpoints/keys (`BINANCE_TESTNET_*`). Cost/size: qty sized from - `budget` and the venue minimums (the epic-C resolver) — the smallest - venue-legal size, never more than `max_cost` at risk. -- **Evidence report**: every step's expectation vs observation (exact - strings), machine-readable + human-printable; the run FAILS on the first - violated expectation but still reports everything it measured. -- Cadence (documented, not automated here): paper per release; testnet at - will; live once per venue at go-live + after adapter changes. - -## Decomposition - -1. `01-scenario-and-paper-oracle` — `application/canary.py`: the scenario - runner (round-trip + probes) over a real `Engine`, the report dataclass, - the exact paper oracle; E2E test green in the default suite. -2. `02-canary-cli` — `trading-bot canary` (Typer): builds the canary - engine/config, runs the scenario, prints the evidence table, exit code - 0/1; paper by default. -3. `03-venue-oracle-and-testnet` — the venue identity oracle + cost bound, - testnet/live wiring behind the existing gates, go-live doc linkage; - verified for real against Binance testnet (fake money, real API). - -## Leaf checklist - -- [x] 01 scenario-and-paper-oracle — feat/canary-scenario — high (#221) -- [x] 02 canary-cli — feat/canary-cli — medium (#222) -- [ ] 03 venue-oracle-and-testnet — feat/canary-venue — high - -## Dependencies - -Serial: 01 → 02 → 03. - -## Done criteria - -- `trading-bot canary --mode paper` runs green end-to-end with the exact - oracle, on a fresh engine, in seconds. -- The venue path runs against Binance testnet for real: identity oracle - holds (our fills/balances == venue-reported), probes pass, cost bounded. -- `doc/dev/09-go-live.md` names the live canary as the road-to-1.0 #1 - validation vehicle. -- Roadmap line removed by the last leaf. diff --git a/doc/dev/plans/canary-roundtrip/03-venue-oracle-and-testnet.md b/doc/dev/plans/canary-roundtrip/03-venue-oracle-and-testnet.md deleted file mode 100644 index b4e6fc3d..00000000 --- a/doc/dev/plans/canary-roundtrip/03-venue-oracle-and-testnet.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -plan: canary-roundtrip/03-venue-oracle-and-testnet -kind: leaf -status: planned -complexity: high -depends: [02] -parallel: false -branch: feat/canary-venue -pr: "" ---- - -# 03 — the venue oracle, testnet for real - -## Goal - -The canary against a REAL venue: same scenario, different oracle (pinned in -`00-plan.md`): - -- **identity oracle** (exact): OUR recorded fills == the venue-reported - fills (ids, qtys, prices — via `broker.fills(...)` re-fetch); OUR - balances delta == the venue-reported before/after delta (per asset, - exact); position flat at the end (base delta == 0 within the venue's - dust rounding — READ how reconcile treats venue balance precision and - mirror; if the venue reports base dust after the round trip, the check - reports it exactly and compares against the fills math, not against - zero); -- **bounded cost**: total quote cost (balance delta) ≤ `--max-cost`; - reported exactly; -- probes run against the real venue too (far-off limit cancel = a REAL - cancel — the road-to-1.0 #1 kill-switch validation; idempotency - re-submit = the venue-side client-order-id dedup validation). - -Wiring: - -- `--mode testnet`: builds the engine against the venue's testnet adapter - (READ how `_build_broker` selects testnet — `testnet: true` config — and - which venues support it: Binance yes, Kraken no); keys from the - environment (`BINANCE_TESTNET_*`), never logged; -- `--mode live`: behind the EXISTING `live_enabled` gate + the CLI asks a - typed confirmation (mirror the dashboard's go-live confirmation - pattern); this leaf implements the path but its real-data verification - is testnet-only — the live run is the operator's go-live act - (road-to-1.0 #1), not CI; -- `doc/dev/09-go-live.md`: add the canary as the named validation vehicle - for the "Proven vs pending" items (venue idempotency, real cancel, - balance reconciliation) — a short section, linking the CLI invocation. - -## Files to change - -- `trading_bot/application/canary.py` — the venue oracle + the - before/after venue snapshots (balances/fills re-fetch through the - broker port; no adapter-specific code here). -- `trading_bot/interfaces/cli/main.py` — `--mode testnet|live` wiring + - the live confirmation. -- `doc/dev/09-go-live.md` — the section (docs-of-record EXCEPTION: this - leaf may edit 09-go-live.md — it is the leaf's deliverable, not - /finish-task bookkeeping). -- Tests: venue-oracle unit tests over a fake broker (identity holds / - broken fills mismatch / balance mismatch / cost bound breach); testnet - path smoke behind `@pytest.mark.network`. - -## Steps - -1. READ `_build_broker`'s testnet branch, the `live_enabled` gate, the - broker port's `fills`/`balances` signatures, and reconcile's - venue-truth patterns. -2. Implement; all three gates green (network tests deselected by default). - -## Tests - -Above; the fake-broker suite must cover every oracle branch. - -## Verification on real data (MANDATORY — Binance testnet, real API, fake money) - -Run `trading-bot canary --exchange binance --mode testnet` FOR REAL with -the `BINANCE_TESTNET_*` keys from the repo's `.env` (they exist — memory: -validated read-only previously; testnet is fake money by construction). -Paste the full evidence table: the real testnet fills, the balance deltas, -the identity checks, the probe results (real cancel accepted, duplicate -client-order-id deduped venue-side), the bounded cost. If the testnet is -unreachable/out of funds, report exactly what happened and how far the run -got — do NOT fake it, and do NOT touch mainnet keys or `--mode live`. -No `var/`, no port 8000. - -## Closeout - -- CHANGELOG `[Unreleased] > Added`: "the canary runs against real venues — - identity oracle (fills/balances == venue-reported) + bounded cost, - testnet wired, live behind the existing gates; go-live doc names it as - the road-to-1.0 #1 vehicle (#XX)." -- ADR: the identity-not-absolute-PnL oracle for venues; testnet as the - proving ground. -- `doc/dev/06-status.md`: epic shipped note. -- Last leaf: remove the `canary-roundtrip` roadmap line, set the global - done, archive the tree per `/finish-task`. diff --git a/trading_bot/application/__init__.py b/trading_bot/application/__init__.py index 8a0c9137..16e3a46d 100644 --- a/trading_bot/application/__init__.py +++ b/trading_bot/application/__init__.py @@ -103,13 +103,16 @@ :class:`~trading_bot.application.portfolio_runner.RebalanceResult` reports submitted-vs-failed). * canary — :func:`~trading_bot.application.canary.run_canary` (with - :func:`~trading_bot.application.canary.paper_oracle` and the + :func:`~trading_bot.application.canary.paper_oracle`, + :func:`~trading_bot.application.canary.venue_oracle` and the :class:`~trading_bot.application.canary.CanaryReport` / :class:`~trading_bot.application.canary.CanaryCheck` evidence types), the deterministic self-test: a sequential round-trip plus free cancel/idempotency probes over a **dedicated**, explicitly-funded engine, every expectation vs observation recorded exactly — paper's oracle is exact Decimal equality - (realised PnL == −Σ fees, flat, balances moved by fees only). + (realised PnL == −Σ fees, flat, balances moved by fees only); the venue + oracle is the accounting identity (our fills/balances == venue-reported) + plus a bounded cost. * run_app — :func:`~trading_bot.application.run_app.run_app` (and :func:`~trading_bot.application.run_app.build_runners`), the **triptych entrypoint**: one :class:`~trading_bot.application.config.AppConfig` → @@ -130,6 +133,7 @@ CanaryReport, paper_oracle, run_canary, + venue_oracle, ) from trading_bot.application.capital_service import CapitalPolicy, CapitalService from trading_bot.application.config import ( @@ -261,6 +265,7 @@ # canary (deterministic self-test) "run_canary", "paper_oracle", + "venue_oracle", "CanaryReport", "CanaryCheck", # entrypoint diff --git a/trading_bot/application/canary.py b/trading_bot/application/canary.py index 61b7735e..c655f8f7 100644 --- a/trading_bot/application/canary.py +++ b/trading_bot/application/canary.py @@ -31,12 +31,27 @@ row. This is the invariant that makes a retry safe with real money. * **Two oracles.** The *paper* oracle (:func:`paper_oracle`, this module) is **EXACT** — Decimal equality, no tolerance — because the simulator is fully - deterministic. The *venue* oracle (leaf 03) can never precompute an absolute - PnL (spread and drift are not deterministic); it asserts the **accounting - identity** (our fills == venue-reported fills, our balance deltas == - venue-reported deltas, flat) plus a **bounded total cost**. Keeping the two - oracles distinct is what lets paper be exact without pretending a live venue - is. + deterministic. The *venue* oracle (:func:`venue_oracle`, this module) can + never precompute an absolute PnL (spread and drift are not deterministic); + it asserts the **accounting identity** (our recorded fills == the + venue-reported fills re-fetched through ``broker.fills(...)``, the + venue-reported balance deltas == the deltas *our fills imply*, flat) plus a + **bounded total cost** (the quote balance delta, capped by ``max_cost``). + Keeping the two oracles distinct is what lets paper be exact without + pretending a live venue is. +* **Venue legs settle by polling the broker port.** A real (REST) venue fills + a market order asynchronously and reports the execution only through + :meth:`~trading_bot.brokers.base.Broker.fills` — and it keys those fills by + its **own** order reference, not our client-order-id (Binance ``myTrades`` + carries the venue ``orderId``). After submitting a market leg the canary + therefore polls ``broker.fills(...)``, attributes every **new** fill on the + leg's instrument and side to the leg (the canary engine is dedicated, and + the round-trip is sequential, so within the run's own fill-id window that + attribution is unambiguous), and re-emits each one on the engine bus with + the leg's client-order-id — so the ordinary fill plumbing (fill sync → + tracker/performance/store) applies venue truth exactly as it would a + simulator fill. On paper the broker fills synchronously and the settle loop + never runs. No adapter-specific code: only the broker port is read. * **Sizing / cost philosophy.** The canary trades the **smallest venue-legal size**: the caller sizes ``qty`` from the venue's real minimums (``min_qty`` / ``min_notional`` via the instrument-spec resolver) so the @@ -68,12 +83,15 @@ from __future__ import annotations # Built-in +import asyncio +import time import uuid -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from decimal import ROUND_UP, localcontext from typing import TYPE_CHECKING # Local +from trading_bot.application.events import FillEvent from trading_bot.domain.errors import ( BrokerError, MissingOrder, @@ -87,6 +105,7 @@ from collections.abc import Callable from trading_bot.application.service_factory import Engine + from trading_bot.domain.fill import Fill from trading_bot.domain.instrument import Instrument __all__ = [ @@ -95,6 +114,7 @@ "paper_oracle", "run_canary", "size_for_minimums", + "venue_oracle", ] #: Percent denominator for ``probe_offset_pct``. @@ -112,6 +132,21 @@ #: failed check (the canary reports, it does not crash), never an escape. _ORDER_ERRORS = (BrokerError, OrderError, MissingOrder, RiskLimitBreached) +#: How long a market leg may take to settle on a real venue (seconds): the poll +#: loop that re-fetches ``broker.fills(...)`` and pumps the leg's executions +#: onto the bus gives up after this long — the leg's check then fails with +#: exactly what was observed. Paper fills synchronously and never waits. +_VENUE_SETTLE_TIMEOUT_S: float = 20.0 + +#: Delay between two settle polls of ``broker.fills(...)`` (seconds). +_VENUE_SETTLE_POLL_S: float = 0.5 + +#: The order statuses a submitted market leg may still settle from (a venue +#: fill can only apply to a live order — mirrors ``OrderFillSync``'s rule). +_LEG_SETTLING: frozenset[OrderStatus] = frozenset( + {OrderStatus.OPEN, OrderStatus.PARTIALLY_FILLED} +) + @dataclass(frozen=True, slots=True) class CanaryCheck: @@ -155,7 +190,10 @@ class CanaryReport: exchange : str The broker's venue key (``"paper"``, ``"binance"``, ...). mode : str - The engine's configured execution mode (``"paper"`` / ``"live"``). + The run's execution mode label (``"paper"`` / ``"testnet"`` / + ``"live"``) — the engine's configured mode, or the caller's + ``mode_label`` override (a testnet engine is ``mode: live`` + + ``testnet: true`` config-side; the report says ``"testnet"``). instrument : Instrument The instrument the canary traded. qty : Decimal @@ -171,6 +209,13 @@ class CanaryReport: initial_balances, final_balances : dict of str to Decimal The broker-reported balance snapshots taken before the first order and after the last (exact ``Decimal``, keyed by canonical asset code). + venue_fills : list of Fill + The **venue-reported** fills this run produced: the final + ``broker.fills(...)`` re-fetch, minus every fill id the venue already + reported before the first order. Exactly as the venue rendered them + (ids, quantities, prices — the venue's own order reference, not our + client-order-ids). :func:`venue_oracle` compares our recorded fills + against these; empty until the run's final snapshot. """ @@ -185,6 +230,7 @@ class CanaryReport: cost: Money | None = None initial_balances: dict[str, Money] = field(default_factory=dict) final_balances: dict[str, Money] = field(default_factory=dict) + venue_fills: list[Fill] = field(default_factory=list) @property def passed(self) -> bool: @@ -492,6 +538,200 @@ def paper_oracle( return checks +def _fmt_fill_lines(triples: list[tuple[str, Money, Money]]) -> str: + """Render sorted ``(fill_id, qty, price)`` triples as one exact, stable line.""" + if not triples: + return "(none)" + return " ".join( + f"[id={fid} qty={qty} price={price}]" for fid, qty, price in triples + ) + + +def venue_oracle( + max_cost: Money, +) -> Callable[[Engine, CanaryReport], list[CanaryCheck]]: + """Build the venue **identity** oracle for a real-venue (testnet/live) run. + + A real venue's absolute PnL is not precomputable (spread and drift are not + deterministic), so — unlike :func:`paper_oracle` — this oracle asserts the + **accounting identity** plus a **bounded cost**, all on exact ``Decimal`` + values (see the module docstring's "Two oracles"): + + * ``oracle.fills_match_venue`` — OUR recorded fills (the store's persisted + fills for the two round-trip client-order-ids) == the **venue-reported** + fills (:attr:`CanaryReport.venue_fills`, the run's final + ``broker.fills(...)`` re-fetch minus the pre-run baseline), compared as + sorted ``(fill_id, qty, price)`` triples — ids, quantities and prices, + exact. Client-order-ids are deliberately **not** compared: a venue keys + its fill reports by its own order reference (Binance ``myTrades`` + carries the venue ``orderId``), and the run's settle step is what + attributed each venue fill to its leg. + * ``oracle.position_flat`` — the tracker's net position is ``0``. + * ``oracle.base_delta_matches_fills`` / ``oracle.quote_delta_matches_fills`` + — the venue-reported balance deltas (final − initial snapshots, per + asset) equal the deltas **our recorded fills imply** — the fills math, + never a hardcoded zero, **fee-denomination-aware** (each fill's fee is + charged against its :attr:`~trading_bot.domain.fill.Fill.fee_asset`; + ``None`` means quote). Base: ``Σ buy qty − Σ sell qty − Σ base-fees`` + (Binance charges a market buy's commission in the base asset — observed + live on the testnet as exactly this base dust). Quote: ``Σ sell notional + − Σ buy notional − Σ quote-fees``. If the venue reports base dust after + the round trip, the check reports it exactly and compares it against the + fills math, not against zero — mirroring reconcile's rule that the + broker's fills are the truth balances must be explained by. + * ``oracle.fee_delta_matches_fills[ASSET]`` — one extra check per fee + denominated in an asset that is **neither** base nor quote (e.g. a BNB + fee discount): that asset's venue-reported delta must equal ``−Σ`` of + those fees. Emitted only when such fees exist, so the common runs keep + their fixed five checks. + * ``oracle.cost_bounded`` — the run's total cost, ``-(venue quote balance + delta)``, is at most ``max_cost``; reported exactly. Fees charged in + other assets are visible in their own identity lines above (converting + them to quote would need a price the run does not have — never + fabricated). + + Parameters + ---------- + max_cost : Decimal + The cost bound (quote units): the most the whole round-trip may cost + (fees + spread + drift), as measured by the venue's own quote balance + delta. + + Returns + ------- + callable + An ``oracle(engine, report) -> list[CanaryCheck]`` suitable for + :func:`run_canary`'s ``oracle`` parameter. + + Raises + ------ + ValueError + (From the returned oracle.) If ``engine`` has no store — our recorded + fills are read from the persisted rows, exactly like + :func:`paper_oracle`. + + """ + max_cost = money(max_cost) + + def _venue_oracle(engine: Engine, report: CanaryReport) -> list[CanaryCheck]: + store = engine.store + if store is None: + raise ValueError( + "venue_oracle requires an engine with its own store: our " + "recorded fills are read from persisted rows, not memory" + ) + store.flush() + checks: list[CanaryCheck] = [] + zero = money("0") + + # OUR recorded fills: the store's persisted rows for the two legs. + roundtrip_cids = {report.buy_cid, report.sell_cid} + ours = [ + fill for fill in store.fills() if fill.client_order_id in roundtrip_cids + ] + + # 1. Identity: our recorded fills == the venue-reported fills, compared + # as sorted (fill_id, qty, price) triples — exact Decimal equality. + ours_triples = sorted((f.fill_id, f.qty, f.price) for f in ours) + venue_triples = sorted((f.fill_id, f.qty, f.price) for f in report.venue_fills) + checks.append( + CanaryCheck( + name="oracle.fills_match_venue", + expected=f"venue fills: {_fmt_fill_lines(venue_triples)}", + observed=f"our fills: {_fmt_fill_lines(ours_triples)}", + passed=ours_triples == venue_triples, + ) + ) + + # 2. Flat position (a never-touched instrument counts as flat). + position = engine.tracker.position(report.instrument) + net_qty = position.net_qty if position is not None else zero + checks.append( + CanaryCheck( + name="oracle.position_flat", + expected="net_qty=0", + observed=f"net_qty={net_qty}", + passed=net_qty == 0, + ) + ) + + # 3./4. Balance deltas: the venue's reported movement must equal what + # our fills imply — the fills math, never a hardcoded zero. Each + # fee is charged against the asset it is denominated in (a fill's + # fee_asset; None means quote — Binance charges a market buy's + # commission in the BASE asset, observed live on the testnet). + base = report.instrument.symbol.base + quote = report.instrument.symbol.quote + base_delta = report.final_balances.get( + base, zero + ) - report.initial_balances.get(base, zero) + quote_delta = report.final_balances.get( + quote, zero + ) - report.initial_balances.get(quote, zero) + bought = sum((f.qty for f in ours if f.side is OrderSide.BUY), zero) + sold = sum((f.qty for f in ours if f.side is OrderSide.SELL), zero) + buy_notional = sum( + (f.qty * f.price for f in ours if f.side is OrderSide.BUY), zero + ) + sell_notional = sum( + (f.qty * f.price for f in ours if f.side is OrderSide.SELL), zero + ) + fees_by_asset: dict[str, Money] = {} + for fill in ours: + asset = fill.fee_asset if fill.fee_asset is not None else quote + fees_by_asset[asset] = fees_by_asset.get(asset, zero) + fill.fee + expected_base = bought - sold - fees_by_asset.get(base, zero) + expected_quote = sell_notional - buy_notional - fees_by_asset.get(quote, zero) + checks.append( + CanaryCheck( + name="oracle.base_delta_matches_fills", + expected=f"base_delta={expected_base}", + observed=f"base_delta={base_delta}", + passed=base_delta == expected_base, + ) + ) + checks.append( + CanaryCheck( + name="oracle.quote_delta_matches_fills", + expected=f"quote_delta={expected_quote}", + observed=f"quote_delta={quote_delta}", + passed=quote_delta == expected_quote, + ) + ) + # One extra identity line per fee denominated in a third asset (e.g. a + # BNB fee discount): its venue delta must equal -(those fees) exactly. + for asset in sorted(fees_by_asset): + if asset in (base, quote): + continue + asset_fees = fees_by_asset[asset] + asset_delta = report.final_balances.get( + asset, zero + ) - report.initial_balances.get(asset, zero) + checks.append( + CanaryCheck( + name=f"oracle.fee_delta_matches_fills[{asset}]", + expected=f"{asset}_delta={-asset_fees}", + observed=f"{asset}_delta={asset_delta}", + passed=asset_delta == -asset_fees, + ) + ) + + # 5. Bounded cost: what the round-trip actually cost, by the venue's + # own quote balance delta — never a precomputed absolute PnL. + cost = -quote_delta + checks.append( + CanaryCheck( + name="oracle.cost_bounded", + expected=f"cost<={max_cost}", + observed=f"cost={cost}", + passed=cost <= max_cost, + ) + ) + return checks + + return _venue_oracle + + async def run_canary( engine: Engine, instrument: Instrument, @@ -500,6 +740,7 @@ async def run_canary( probe_offset_pct: Money = _DEFAULT_PROBE_OFFSET_PCT, run_id: str | None = None, oracle: Callable[[Engine, CanaryReport], list[CanaryCheck]] | None = paper_oracle, + mode_label: str | None = None, ) -> CanaryReport: """Run the canary scenario on a ready, dedicated ``engine``. @@ -541,8 +782,13 @@ async def run_canary( The mode's oracle, called as ``oracle(engine, report)`` **only when every scenario check passed** (an aborted run's oracle expectations were never measured) and its checks appended to the report. Defaults - to :func:`paper_oracle`; leaf 03's venue oracle replaces it for - testnet/live runs. ``None`` skips the oracle. + to :func:`paper_oracle`; a real-venue (testnet/live) run passes + :func:`venue_oracle`'s built oracle instead. ``None`` skips the oracle. + mode_label : str, optional + The mode the report should carry (``report.mode``). Defaults to the + engine's ``config.mode`` — pass ``"testnet"`` for a testnet engine, + whose config is ``mode: live`` + a ``testnet: true`` broker (the + factory's sandbox path), so the evidence says what actually ran. Returns ------- @@ -582,7 +828,7 @@ async def run_canary( token = run_id if run_id is not None else uuid.uuid4().hex[:8] report = CanaryReport( exchange=broker.name, - mode=engine.config.mode, + mode=mode_label if mode_label is not None else engine.config.mode, instrument=instrument, qty=qty, probe_cid=f"canary-{token}-probe", @@ -593,6 +839,13 @@ async def run_canary( # --- initial snapshot (before any order) --------------------------------- # mark = await broker.ticker(instrument) report.initial_balances = dict(await broker.balances()) + # One venue-truth fill snapshot before any order. Its size baselines the + # cancel probe's no-fill check; its id set is the run's fill baseline — + # everything the venue reports beyond it is this run's doing (the market + # legs' settle attribution, and the final ``report.venue_fills`` re-fetch). + initial_fills = await broker.fills() + fill_ids_before: frozenset[str] = frozenset(f.fill_id for f in initial_fills) + attributed: set[str] = set(fill_ids_before) ok = True # --- cancel probe: the free kill-path rehearsal --------------------------- # @@ -605,7 +858,7 @@ async def run_canary( f"offset {probe_offset_pct}%): not a placeable limit price" ) probe_qty = _probe_qty(instrument, qty, probe_price) - fills_before = len(await broker.fills()) + fills_before = len(initial_fills) tracked_probe: Order | None = None try: tracked_probe = await router.submit( @@ -766,7 +1019,9 @@ async def run_canary( # --- round-trip: market buy qty, confirm; market sell qty, confirm; flat -- # if ok: - ok = await _market_leg(engine, report, report.buy_cid, OrderSide.BUY) + ok = await _market_leg( + engine, report, report.buy_cid, OrderSide.BUY, attributed + ) if ok: position = engine.tracker.position(instrument) net_qty = position.net_qty if position is not None else money("0") @@ -781,7 +1036,9 @@ async def run_canary( ) ok = ok and in_tracker if ok: - ok = await _market_leg(engine, report, report.sell_cid, OrderSide.SELL) + ok = await _market_leg( + engine, report, report.sell_cid, OrderSide.SELL, attributed + ) if ok: position = engine.tracker.position(instrument) net_qty = position.net_qty if position is not None else money("0") @@ -809,25 +1066,98 @@ async def run_canary( pass # the report already fails; cleanup is best-effort store.flush() report.final_balances = dict(await broker.balances()) + # Re-fetch the venue's fills one last time: everything beyond the pre-run + # baseline is this run's venue-reported truth (the venue oracle's side of + # the identity). On paper these are simply the round-trip's own two fills. + final_fills = await broker.fills() + report.venue_fills = [ + fill for fill in final_fills if fill.fill_id not in fill_ids_before + ] report.cost = -engine.perf.realised_pnl() if ok and oracle is not None: report.checks.extend(oracle(engine, report)) return report +async def _settle_market_leg( + engine: Engine, + report: CanaryReport, + tracked: Order, + side: OrderSide, + attributed: set[str], +) -> None: + """Wait for a real venue to report the leg's executions; pump them onto the bus. + + A REST venue fills a market order asynchronously and reports the execution + only through :meth:`~trading_bot.brokers.base.Broker.fills` — keyed by its + **own** order reference, not our client-order-id (see the module + docstring). This loop polls the broker port, attributes every **new** fill + (id not in ``attributed``) on the leg's instrument and side to the leg, + and re-emits each one on the engine bus with the leg's client-order-id and + spec-carrying instrument — so the ordinary fill plumbing (fill sync → + tracker / performance / store) applies venue truth exactly as it would a + simulator fill. Returns once ``tracked`` leaves its live statuses (the + fill sync drove it to ``FILLED``) or after :data:`_VENUE_SETTLE_TIMEOUT_S` + — the caller's checks then record exactly what was (not) observed. + + Parameters + ---------- + engine : Engine + The canary engine (broker port + bus). + report : CanaryReport + The run's report — supplies the instrument the fills must match. + tracked : Order + The just-submitted, still-live tracked market order. + side : OrderSide + The leg's side; a venue fill on the other side is never attributed. + attributed : set of str + Every fill id already accounted for (the pre-run baseline plus fills + attributed by earlier legs). Mutated in place as fills are attributed. + + """ + deadline = time.monotonic() + _VENUE_SETTLE_TIMEOUT_S + while tracked.status in _LEG_SETTLING: + for fill in await engine.broker.fills(): + if fill.fill_id in attributed: + continue + if fill.instrument.symbol != report.instrument.symbol: + continue + if fill.side is not side: + continue + attributed.add(fill.fill_id) + # Attribute the venue fill to the leg: same id/qty/price/fee/ts, + # our client-order-id and spec-carrying instrument — the exact + # correlation this sequential, dedicated run just established. + engine.bus.emit( + FillEvent( + replace( + fill, + client_order_id=tracked.client_order_id, + instrument=report.instrument, + ) + ) + ) + if tracked.status not in _LEG_SETTLING or time.monotonic() >= deadline: + break + await asyncio.sleep(_VENUE_SETTLE_POLL_S) + + async def _market_leg( engine: Engine, report: CanaryReport, cid: str, side: OrderSide, + attributed: set[str], ) -> bool: """Place one market leg of the round-trip and confirm it filled + persisted. - Submits a MARKET order for ``report.qty`` on ``side`` under ``cid``, then - appends two checks: the tracked order is ``FILLED`` with - ``filled_qty == qty``, and the **store** row agrees. Returns whether both - held (the caller stops placing on ``False``). A submission error becomes a - failed check, never an exception out of the scenario. + Submits a MARKET order for ``report.qty`` on ``side`` under ``cid``, lets a + real venue's executions settle (:func:`_settle_market_leg` — a no-op on + paper, whose broker fills synchronously), then appends two checks: the + tracked order is ``FILLED`` with ``filled_qty == qty``, and the **store** + row agrees. Returns whether both held (the caller stops placing on + ``False``). A submission error becomes a failed check, never an exception + out of the scenario. """ leg = "buy" if side is OrderSide.BUY else "sell" tracked: Order | None = None @@ -851,6 +1181,10 @@ async def _market_leg( ) ) return False + if tracked.status in _LEG_SETTLING: + # A real venue reports the market execution asynchronously via + # broker.fills(); paper never gets here (it filled synchronously). + await _settle_market_leg(engine, report, tracked, side, attributed) filled = tracked.status is OrderStatus.FILLED and tracked.filled_qty == report.qty report.checks.append( CanaryCheck( diff --git a/trading_bot/application/config.py b/trading_bot/application/config.py index f217c3f9..53743853 100644 --- a/trading_bot/application/config.py +++ b/trading_bot/application/config.py @@ -100,12 +100,23 @@ class BrokerConfig(BaseModel): endpoint — it cannot reach mainnet — so it does **not** require the ``live_enabled`` opt-in (it still needs testnet credentials). Ignored in paper mode (the simulator). A venue with no testnet (``"kraken"``) raises. + symbols : list of str, optional + The pairs this venue's **per-symbol** private endpoints are queried + for. Binance has no account-wide trade history (``myTrades`` is + per-symbol), so its adapter serves + :meth:`~trading_bot.brokers.base.Broker.fills` only for an explicit + symbol set — without one, ``fills()`` (and so reconciliation and the + canary's venue oracle) raises. Entries are parsed by the factory with + the venue's own parser (``"BTC/USDT"``, ``"BTCUSDT"``, ...); an + unparseable entry refuses at build time. Ignored by venues with + account-wide fills (Kraken) and by paper mode. Empty by default. """ name: str exchange: str testnet: bool = False + symbols: list[str] = Field(default_factory=list) @field_validator("name", "exchange") @classmethod @@ -115,6 +126,15 @@ def _non_empty(cls, v: str) -> str: raise ValueError("must be a non-empty string") return v + @field_validator("symbols") + @classmethod + def _non_empty_symbols(cls, v: list[str]) -> list[str]: + """Reject blank ``symbols`` entries (whitespace-only too).""" + for entry in v: + if not entry or not entry.strip(): + raise ValueError("symbols entries must be non-empty strings") + return v + class DataSourceConfig(BaseModel): """Where a strategy's bars come from — a dccd OHLC dataset. diff --git a/trading_bot/application/service_factory.py b/trading_bot/application/service_factory.py index 46c1a518..0c430757 100644 --- a/trading_bot/application/service_factory.py +++ b/trading_bot/application/service_factory.py @@ -69,6 +69,11 @@ from trading_bot.brokers.kraken import KrakenBroker from trading_bot.brokers.paper import PaperBroker from trading_bot.domain.errors import BrokerError, LiveTradingNotEnabled +from trading_bot.domain.instrument import ( + Symbol, + parse_binance_symbol, + parse_kraken_pair, +) from trading_bot.domain.money import Money from trading_bot.storage.sqlite_store import SqliteStore @@ -388,7 +393,7 @@ def _build_broker(config: AppConfig, bus: EventBus) -> Broker: # broker is what opts in; a venue with no testnet raises. first: BrokerConfig | None = config.brokers[0] if config.brokers else None if first is not None and first.testnet: - broker = _build_testnet_venue(venue) + broker = _build_testnet_venue(venue, symbols=_broker_symbols(first, venue)) if not broker.has_credentials: raise BrokerError( f"testnet for venue {venue!r} requires credentials; set the " @@ -412,7 +417,7 @@ def _build_broker(config: AppConfig, bus: EventBus) -> Broker: # Opt-in is set: build the real adapter, but only if it can actually trade. # Never silently downgrade to paper. if venue in _LIVE_VENUES: - broker = _build_live_venue(venue) + broker = _build_live_venue(venue, symbols=_broker_symbols(first, venue)) if not broker.has_credentials: raise BrokerError( f"live mode requires credentials for venue {venue!r}; " @@ -480,8 +485,39 @@ def _selected_venue(config: AppConfig) -> str: return first.exchange.lower() -def _build_live_venue(venue: str) -> _LiveBroker: - """Construct the live adapter for ``venue`` (reads credentials from env).""" +def _broker_symbols(first: BrokerConfig | None, venue: str) -> tuple[Symbol, ...]: + """Parse the selected broker's declared ``symbols`` into canonical pairs. + + ``BrokerConfig.symbols`` entries are free-form venue pair strings + (``"BTC/USDT"``, ``"BTCUSDT"``, a Kraken legacy pair, ...); each is parsed + with the venue's own parser so the adapter receives canonical + :class:`~trading_bot.domain.instrument.Symbol` objects. An unparseable + entry refuses at build time with a clear :class:`BrokerError` — a broker + whose per-symbol endpoints would silently query nothing must not build. + """ + if first is None or not first.symbols: + return () + parse = parse_kraken_pair if venue == "kraken" else parse_binance_symbol + parsed: list[Symbol] = [] + for raw in first.symbols: + try: + parsed.append(parse(raw)) + except ValueError as exc: + raise BrokerError( + f"broker {first.name!r}: cannot parse symbols entry {raw!r} " + f"for venue {venue!r}: {exc}" + ) from exc + return tuple(parsed) + + +def _build_live_venue(venue: str, *, symbols: tuple[Symbol, ...] = ()) -> _LiveBroker: + """Construct the live adapter for ``venue`` (reads credentials from env). + + ``symbols`` (from ``BrokerConfig.symbols``, parsed by + :func:`_broker_symbols`) is threaded to venues whose fills are + **per-symbol** (Binance ``myTrades``); Kraken's trade history is + account-wide, so its adapter takes none. + """ if venue == "kraken": # KrakenBroker reads KRAKEN_API_KEY / KRAKEN_API_SECRET from the # environment; ``has_credentials`` reports whether both are present. @@ -490,7 +526,7 @@ def _build_live_venue(venue: str) -> _LiveBroker: # BinanceBroker reads BINANCE_API_KEY / BINANCE_API_SECRET (and the # optional BINANCE_API_BASE testnet toggle) from the environment; # ``has_credentials`` reports whether both key + secret are present. - return BinanceBroker() + return BinanceBroker(symbols=symbols) # Unreachable: callers gate on ``_LIVE_VENUES`` first. Defensive only. raise BrokerError(f"no live adapter for venue {venue!r}") @@ -514,7 +550,9 @@ def _binance_testnet_credentials() -> tuple[str, str]: return key, secret -def _build_testnet_venue(venue: str) -> _LiveBroker: +def _build_testnet_venue( + venue: str, *, symbols: tuple[Symbol, ...] = () +) -> _LiveBroker: """Construct a venue's **testnet** adapter, hard-pinned to its sandbox URL. Only venues in :data:`_TESTNET_VENUES` have a testnet. The base URL is forced @@ -522,8 +560,9 @@ def _build_testnet_venue(venue: str) -> _LiveBroker: env value is overridden) — the adapter can therefore never reach mainnet, which is why the caller skips the ``live_enabled`` opt-in for it. Credentials are the venue's **testnet** keys (``BINANCE_TESTNET_*``, falling back to ``BINANCE_*``); - see :func:`_binance_testnet_credentials`. A venue with no testnet (e.g. Kraken, - which has no public spot sandbox) raises. + see :func:`_binance_testnet_credentials`. ``symbols`` is threaded exactly as in + :func:`_build_live_venue` (Binance fills are per-symbol). A venue with no + testnet (e.g. Kraken, which has no public spot sandbox) raises. """ if venue == "binance": # Hard-pin the testnet base URL (explicit arg overrides the env default), @@ -531,7 +570,12 @@ def _build_testnet_venue(venue: str) -> _LiveBroker: # feed it the *testnet* credentials (not the mainnet key, which testnet # rejects with -2015). key, secret = _binance_testnet_credentials() - return BinanceBroker(api_key=key, api_secret=secret, base_url=TESTNET_API_BASE) + return BinanceBroker( + api_key=key, + api_secret=secret, + base_url=TESTNET_API_BASE, + symbols=symbols, + ) raise BrokerError( f"venue {venue!r} has no testnet/sandbox; testnet is available for " f"{sorted(_TESTNET_VENUES)!r} only (Kraken has no public spot testnet — " diff --git a/trading_bot/brokers/binance.py b/trading_bot/brokers/binance.py index 4521fe89..1529742f 100644 --- a/trading_bot/brokers/binance.py +++ b/trading_bot/brokers/binance.py @@ -75,6 +75,7 @@ import hashlib import hmac +import json import os import re import time @@ -98,7 +99,7 @@ ) from trading_bot.domain.money import Money, money from trading_bot.domain.order import Order, OrderSide, OrderType -from trading_bot.transport.http import AsyncHTTPClient +from trading_bot.transport.http import AsyncHTTPClient, HTTPError from trading_bot.transport.ratelimit import RateLimiter if TYPE_CHECKING: @@ -216,6 +217,51 @@ def _map_binance_error(code: int, msg: str, *, context: str) -> BrokerError: return BrokerError(detail) +def _map_http_error(exc: HTTPError, *, context: str) -> BrokerError: + """Map a **definitive** venue rejection carried by a transport error. + + Binance delivers request rejections as an HTTP **4xx** whose JSON body + carries the same ``{"code", "msg"}`` shape as its in-band (200) errors — + so on the real venue a rejection (a filter failure, a duplicate id, an + insufficient balance) surfaces from the transport as + :class:`~trading_bot.transport.http.HTTPError`, never as a parsed payload + reaching :meth:`BinanceBroker._raise_on_error`. This helper recovers the + body and reuses :func:`_map_binance_error`, so venue rejections become the + same specific domain errors either way; a body that is not the Binance + error shape degrades to a generic :class:`BrokerError` carrying the + transport's (secret-redacted) message. + + Only **definitive** failures reach here: the transport raises the distinct + :class:`~trading_bot.transport.http.AmbiguousRequestError` for + unknown-outcome failures (a 5xx / drop on a non-retryable POST), and that + type propagates unchanged — the reconcile-don't-retry discipline is + untouched. + + Parameters + ---------- + exc : HTTPError + The transport error (carries ``status`` and the raw response body). + context : str + The endpoint name, for the error message. + + Returns + ------- + BrokerError + The most specific mapped domain error. + + """ + error: tuple[int, str] | None = None + if exc.body: + try: + error = BinanceBroker._find_error(json.loads(exc.body)) + except ValueError: + error = None + if error is not None: + code, msg = error + return _map_binance_error(code, msg, context=context) + return BrokerError(f"Binance {context}: {exc}") + + def _sign(query: str, secret: str) -> str: """Compute Binance's HMAC-SHA256 ``signature`` for a signed request. @@ -390,6 +436,15 @@ def is_testnet(self) -> bool: """Whether this adapter is pinned to Binance's spot **testnet**.""" return self._base_url == TESTNET_API_BASE + @property + def symbols(self) -> tuple[Symbol, ...]: + """The symbol set :meth:`fills` queries (``myTrades`` is per-symbol). + + Read-only introspection — empty means :meth:`fills` refuses (Binance + has no account-wide trade history to fall back to). + """ + return self._symbols + def _require_credentials(self) -> None: """Raise :class:`BrokerError` if a private call lacks credentials.""" if not self.has_credentials: @@ -466,12 +521,20 @@ def _weight_for(endpoint: str) -> int: return _ENDPOINT_WEIGHTS.get(endpoint, _DEFAULT_ENDPOINT_WEIGHT) async def _public_get(self, endpoint: str, params: Mapping[str, Any]) -> Any: - """GET a public endpoint and return its parsed JSON (or raise).""" + """GET a public endpoint and return its parsed JSON (or raise). + + A definitive HTTP-level rejection (Binance sends its ``{"code","msg"}`` + errors with a 4xx status) is mapped to the same specific domain errors + as an in-band error body — see :func:`_map_http_error`. + """ url = f"{self._base_url}{_API_PREFIX}/{endpoint}" async with self._http as client: - payload = await client.get( - url, params=dict(params), weight=self._weight_for(endpoint) - ) + try: + payload = await client.get( + url, params=dict(params), weight=self._weight_for(endpoint) + ) + except HTTPError as exc: + raise _map_http_error(exc, context=endpoint) from exc return self._raise_on_error(payload, context=endpoint) async def _signed_request( @@ -518,13 +581,21 @@ async def _signed_request( headers = {"X-MBX-APIKEY": self._api_key} async with self._http as client: - payload = await client.request( - method, - url, - headers=headers, - retry=retry, - weight=self._weight_for(endpoint), - ) + try: + payload = await client.request( + method, + url, + headers=headers, + retry=retry, + weight=self._weight_for(endpoint), + ) + except HTTPError as exc: + # A definitive venue rejection (Binance sends its + # ``{"code","msg"}`` errors with a 4xx status): map it to the + # same specific domain errors as an in-band error body. The + # ambiguous-failure path (AmbiguousRequestError) is a distinct + # type and propagates unchanged — never blind-retried here. + raise _map_http_error(exc, context=endpoint) from exc return self._raise_on_error(payload, context=endpoint) # --- public endpoints -------------------------------------------------- # @@ -927,8 +998,19 @@ async def fills(self, since_ms: int | None = None) -> list[Fill]: @staticmethod def _build_fill(symbol: Symbol, info: Mapping[str, Any]) -> Fill: - """Build a domain :class:`Fill` from a Binance ``myTrades`` entry.""" + """Build a domain :class:`Fill` from a Binance ``myTrades`` entry. + + Binance denominates the commission in ``commissionAsset`` — the + **base** asset on a market buy (observed live: a BTC/USDT buy is + charged in BTC), the quote on a sell, or BNB under the fee discount — + so the asset is carried onto :attr:`~trading_bot.domain.fill.Fill. + fee_asset` (normalised; ``None`` when Binance omits it) rather than + silently re-labelled as quote. Venue-truth accounting (reconciliation, + the canary's identity oracle) needs the real denomination to explain + the venue's per-asset balance movement exactly. + """ side = OrderSide.BUY if info.get("isBuyer") else OrderSide.SELL + commission_asset = info.get("commissionAsset") return Fill( fill_id=str(info.get("id")), client_order_id=str(info.get("orderId")), @@ -938,6 +1020,7 @@ def _build_fill(symbol: Symbol, info: Mapping[str, Any]) -> Fill: price=money(str(info.get("price", "0"))), fee=money(str(info.get("commission", "0"))), ts=int(info.get("time", 0)), + fee_asset=(normalise(str(commission_asset)) if commission_asset else None), ) # --- composite venue id ------------------------------------------------- # diff --git a/trading_bot/domain/fill.py b/trading_bot/domain/fill.py index 1a23d381..ef9f15e1 100644 --- a/trading_bot/domain/fill.py +++ b/trading_bot/domain/fill.py @@ -63,12 +63,26 @@ class Fill: The execution price (in quote units per base unit). Must be strictly positive. fee : Decimal - The fee charged for this execution, in quote units. Must be - non-negative (zero is allowed for rebated / fee-free fills). + The fee charged for this execution, denominated in ``fee_asset`` + (the quote currency when ``fee_asset`` is ``None`` — the historic + assumption). Must be non-negative (zero is allowed for rebated / + fee-free fills). ts : int Execution timestamp as **milliseconds since the Unix epoch (UTC)**. Must be non-negative. Fills are folded in caller-supplied order; ``ts`` is carried for record-keeping and tie-breaking, not used to re-sort. + fee_asset : str or None, optional + The canonical asset code the ``fee`` is denominated in. ``None`` + (default) means the instrument's **quote** currency — the historic + assumption, and what the simulator and Kraken report. Binance charges + a market *buy*'s commission in the **base** asset (``commissionAsset``) + — carrying the denomination is what lets venue-truth accounting (the + canary's identity oracle) explain the venue's per-asset balance + movement exactly. NOTE: the PnL fold (:class:`~trading_bot.domain. + position.Position`) still treats ``fee`` as quote units at face value — + a documented approximation for non-quote fees (an exact conversion + needs a fee-asset price), acceptable because such fees are commission + dust; the balance identity above is exact regardless. Examples -------- @@ -96,6 +110,7 @@ class Fill: price: Money fee: Money ts: int + fee_asset: str | None = None def __post_init__(self) -> None: """Validate construction invariants (ids non-empty, amounts in range). @@ -137,6 +152,12 @@ def __post_init__(self) -> None: raise OrderError( self.client_order_id, f"fill ts must be non-negative, got {self.ts}" ) + if self.fee_asset is not None and not self.fee_asset.strip(): + raise OrderError( + self.client_order_id, + "fill fee_asset must be a non-empty asset code when given " + "(None means the quote currency)", + ) @property def signed_qty(self) -> Money: diff --git a/trading_bot/interfaces/cli/main.py b/trading_bot/interfaces/cli/main.py index 021583c4..42a52f69 100644 --- a/trading_bot/interfaces/cli/main.py +++ b/trading_bot/interfaces/cli/main.py @@ -62,8 +62,13 @@ from rich.console import Console from trading_bot import __version__ -from trading_bot.application.canary import CanaryReport, run_canary, size_for_minimums -from trading_bot.application.config import AppConfig, StrategyConfig +from trading_bot.application.canary import ( + CanaryReport, + run_canary, + size_for_minimums, + venue_oracle, +) +from trading_bot.application.config import AppConfig, BrokerConfig, StrategyConfig from trading_bot.application.data_feed import BARS_SCHEMA, InMemoryFeed from trading_bot.application.instrument_specs import InstrumentSpecResolver from trading_bot.application.performance_service import PerformanceService @@ -107,6 +112,12 @@ #: The go-live runbook the ``--live`` refusals point the user at. _RUNBOOK = "doc/dev/09-go-live.md" +#: The exact phrase the operator must type to run a **live** (real-money) canary +#: — the CLI's typed confirmation, mirroring the dashboard's server-side go-live +#: acknowledgement (:data:`trading_bot.interfaces.api.app._LIVE_ACK_PHRASE`, +#: kept equal by a test; not imported here so the CLI never pulls FastAPI in). +_LIVE_ACK_PHRASE = "I UNDERSTAND" + #: The host values treated as loopback (local-only). Binding any *other* host makes #: the dashboard reachable off the box, so the serve paths that expose the control #: surface require a token there. Kept in sync with the config-layer guard @@ -613,6 +624,18 @@ def _resolve_kpi_capital( #: canary refuses rather than guess one. _CANARY_EXCHANGES = ("kraken", "binance") +#: Default cancel-probe offset on **paper** (percent below the mark): maximally +#: far-off, exactly reproducible — the simulator has no price band. +_PAPER_PROBE_OFFSET_PCT: Money = money("50") + +#: Default cancel-probe offset on a **real venue** (testnet/live). Real venues +#: reject a resting limit too far from the mark — Binance spot's +#: PERCENT_PRICE_BY_SIDE filter bounds a buy limit to roughly 20% below the +#: 5-minute weighted average (a 50%-below probe is rejected with ``-1013``, +#: observed on the real testnet). 15% below sits inside the band with margin +#: and still never fills within a seconds-long canary run. +_VENUE_PROBE_OFFSET_PCT: Money = money("15") + def _canary_price_source(exchange: str) -> KrakenBroker | BinanceBroker: """A fresh, keyless adapter for ``exchange``'s public endpoints only. @@ -738,6 +761,222 @@ async def _build_and_run(store_path: pathlib.Path) -> CanaryReport: return await _build_and_run(pathlib.Path(tmp_dir) / "canary.sqlite") +def _confirm_live_canary(config_path: pathlib.Path | None) -> AppConfig: + """Gate ``canary --mode live``: config opt-in first, then a typed confirmation. + + Live is real money, so the canary re-uses the EXISTING gates rather than + minting its own: the operator's ``--config`` must carry the off-by-default + ``live_enabled: true`` opt-in (the same gate ``run --live`` and the factory + enforce — the canary never sets it itself), and the operator must then + **type** the exact acknowledgement phrase (:data:`_LIVE_ACK_PHRASE` — the + dashboard's go-live confirmation pattern, enforced verbatim). Credentials + and the mandatory risk limits are re-checked one layer down by + :func:`~trading_bot.application.service_factory.build_engine`. Any missing + gate refuses with a non-zero exit **before an engine is ever built** — no + order placed. + + Returns + ------- + AppConfig + The operator's loaded config (the venue canary derives its engine + config from it — see :func:`_canary_venue_config`). + + """ + if config_path is None: + _console.print( + "[red]refusing to run canary --mode live[/red] without --config: " + "the live gates (live_enabled, risk limits, the venue's broker " + f"entry) live in the operator's config. See {_RUNBOOK}. " + "(no order was placed)" + ) + raise typer.Exit(code=1) + config = AppConfig.from_yaml(config_path) + if not config.live_enabled: + _console.print( + "[red]refusing to run canary --mode live[/red]: live is off by " + f"default. Set live_enabled: true in the config and read {_RUNBOOK} " + "first (no order was placed)." + ) + raise typer.Exit(code=1) + ack = typer.prompt( + "The LIVE canary trades REAL money on the real venue (a minimum-size " + f'round-trip + a real cancel). Type "{_LIVE_ACK_PHRASE}" to proceed', + default="", + show_default=False, + ) + if ack != _LIVE_ACK_PHRASE: + _console.print( + "[red]refusing to run canary --mode live[/red] without the exact " + f"typed acknowledgement {_LIVE_ACK_PHRASE!r} (no order was placed). " + f"See {_RUNBOOK}." + ) + raise typer.Exit(code=1) + return config + + +def _canary_venue_config( + exchange: str, symbol: str, mode: str, live_base: AppConfig | None +) -> AppConfig: + """Build the engine config for a **venue** (testnet/live) canary run. + + * ``testnet`` — a self-contained config: ``mode: live`` plus one broker + with ``testnet: true``, exactly the factory's sandbox path (hard-pinned + testnet URL, ``BINANCE_TESTNET_*`` credentials, **no** ``live_enabled`` + needed — the adapter structurally cannot reach mainnet). A venue with no + testnet (Kraken) is refused by the factory with a clear error. + * ``live`` — derived from the operator's own config (``live_base``, loaded + by :func:`_confirm_live_canary`): only the venue's matching broker + entries are kept (forced off testnet), strategies/portfolios are cleared + (the canary trades nothing but its own round-trip), and ``live_enabled`` + / ``risk`` are carried **unchanged** so the factory's existing gates do + their job — the canary never weakens a gate. + + In both cases the canary's ``symbol`` is added to the broker's ``symbols`` + so per-symbol fill endpoints (Binance ``myTrades``) serve + ``broker.fills(...)`` — the venue oracle's re-fetch channel. + """ + if mode == "testnet": + return AppConfig( + mode="live", + brokers=[ + BrokerConfig( + name=f"canary-{exchange}-testnet", + exchange=exchange, + testnet=True, + symbols=[symbol], + ) + ], + ) + assert live_base is not None # gated by _confirm_live_canary + matching = [ + broker for broker in live_base.brokers if broker.exchange.lower() == exchange + ] + if not matching: + raise ValueError( + f"canary --mode live: no broker for exchange {exchange!r} in the " + f"config (have {[b.exchange for b in live_base.brokers]!r}); add a " + "brokers entry for that venue" + ) + brokers = [ + broker.model_copy( + update={ + "testnet": False, + "symbols": sorted({*broker.symbols, symbol}), + } + ) + for broker in matching + ] + return live_base.model_copy( + update={ + "mode": "live", + "brokers": brokers, + "strategies": [], + "portfolios": [], + } + ) + + +async def _resolve_venue_sizing( + engine: Engine, exchange: str, symbol: str +) -> tuple[Instrument, Money, Money]: + """Resolve ``(instrument, mark, qty)`` against the venue the engine trades. + + The venue-run twin of :func:`_resolve_canary_sizing`, with one crucial + difference: the spec and the mark are read off the **engine's own broker** + (the testnet-pinned or live adapter), not a fresh mainnet public adapter — + a sandbox's filters and prices can differ from production, and the sizing + must be legal on the venue that will actually see the orders. + + Raises + ------ + ValueError + If the resolver's fetch degraded to a bare, unquantized instrument — + the canary never guesses a size from an unresolved spec. + """ + broker = engine.broker + resolver = InstrumentSpecResolver( + kraken=broker if isinstance(broker, KrakenBroker) else None, + binance=broker if isinstance(broker, BinanceBroker) else None, + ) + parsed_symbol = ( + parse_kraken_pair(symbol) + if exchange == "kraken" + else parse_binance_symbol(symbol) + ) + instrument = await resolver.resolve(exchange, parsed_symbol) + if exchange in resolver.degraded: + raise ValueError( + f"the instrument-spec resolver could not fetch {exchange}'s real " + f"minimums for {parsed_symbol} (endpoint degraded); refusing to " + "guess a venue-legal size — retry, or check connectivity" + ) + mark = await broker.ticker(instrument) + qty = size_for_minimums(instrument, mark) + return instrument, mark, qty + + +async def _run_venue_canary( + *, + exchange: str, + symbol: str, + mode: str, + max_cost: Money, + probe_offset_pct: Money, + db_path: pathlib.Path | None, + live_base: AppConfig | None = None, +) -> CanaryReport: + """Build a dedicated **venue** (testnet/live) engine and run the canary on it. + + Builds the engine through the factory (:func:`build_engine` — the single + wiring point, whose testnet/live gates all apply: sandbox availability, + credentials, ``live_enabled``, mandatory live risk limits), sizes the + round-trip against the venue's **own** spec and last price + (:func:`_resolve_venue_sizing`), and runs the scenario with the venue + **identity** oracle (:func:`~trading_bot.application.canary.venue_oracle`, + bounded by ``max_cost``) instead of the exact paper oracle. Always the + canary's own store (``--db``, or a scratch temp store) — never a shared + book. The account's real balances fund the run; ``--budget`` plays no role + here. + """ + exchange = exchange.lower() + if exchange not in _CANARY_EXCHANGES: + raise ValueError( + f"canary --exchange {exchange!r} has no venue adapter " + f"(supported: {', '.join(_CANARY_EXCHANGES)})" + ) + config = _canary_venue_config(exchange, symbol, mode, live_base) + + async def _build_and_run(store_path: pathlib.Path) -> CanaryReport: + # The factory raises before any I/O when a gate is missing (no testnet + # for the venue, no credentials, live not enabled, risk limits unset). + engine = build_engine(config, db_path=store_path) + try: + if engine.store is not None: + # Tag the evidence trail with the real deployment context so a + # persisted (--db) canary store reads truthfully — testnet and + # live are different money and must never commingle with paper. + engine.store.set_context(mode=mode, venue=exchange) + instrument, _mark, qty = await _resolve_venue_sizing( + engine, exchange, symbol + ) + return await run_canary( + engine, + instrument, + qty=qty, + probe_offset_pct=probe_offset_pct, + oracle=venue_oracle(max_cost), + mode_label=mode, + ) + finally: + if engine.store is not None: + engine.store.close() + + if db_path is not None: + return await _build_and_run(db_path) + with tempfile.TemporaryDirectory(prefix="trading-bot-canary-") as tmp_dir: + return await _build_and_run(pathlib.Path(tmp_dir) / "canary.sqlite") + + @app.command() def canary( exchange: str = typer.Option( @@ -753,30 +992,39 @@ def canary( mode: str = typer.Option( "paper", "--mode", - help="paper (default, self-contained, no venue/no key) | testnet | " - "live — testnet/live arrive with the next leaf " - "(venue-oracle-and-testnet); the flag is accepted now so scripts " - "stay stable.", + help="paper (default, self-contained, no venue/no key) | testnet " + "(the venue's sandbox — fake money, real API; needs the venue's " + "testnet credentials, e.g. BINANCE_TESTNET_API_KEY/_SECRET; no " + "live_enabled needed — the adapter is hard-pinned to the sandbox) | " + "live (REAL money — requires --config with live_enabled: true, " + "credentials, all risk limits set, and a typed confirmation).", ), budget: float = typer.Option( 100.0, "--budget", help="Quote-currency balance to fund the canary's own dedicated " "paper engine with (AppConfig.paper_starting_balances) — always a " - "fresh engine, never a shared book.", + "fresh engine, never a shared book. Paper only: on testnet/live the " + "account's real balances fund the run.", ), max_cost: float = typer.Option( 2.0, "--max-cost", - help="Refuse to start (before any order) if the round-trip's cost " - "would exceed this many quote units. Paper cost is exact: " - "2 x qty x mark x fee_bps/10000 (two legs, at the paper " - "simulator's default 10bps fee).", + help="The round-trip's cost bound, in quote units. Paper: refuse to " + "start (before any order) if the exact implied cost " + "(2 x qty x mark x fee_bps/10000, at the simulator's default 10bps " + "fee) exceeds it. Testnet/live: the venue oracle's bounded-cost " + "check — the venue-reported quote balance delta must not cost more " + "than this.", ), - probe_offset_pct: float = typer.Option( - 50.0, + probe_offset_pct: float | None = typer.Option( + None, "--probe-offset-pct", - help="How far below the mark (percent) the free cancel probe is priced.", + help="How far below the mark (percent) the free cancel probe is " + "priced. Default: 50 on paper; 15 on testnet/live — real venues " + "reject a limit too far off the mark (Binance's " + "PERCENT_PRICE_BY_SIDE band is roughly +/-20%), and 15% below still " + "never fills within a seconds-long run.", ), db_path: pathlib.Path | None = typer.Option( None, @@ -784,46 +1032,83 @@ def canary( help="Persist the canary's evidence trail to this SqliteStore path. " "Defaults to a scratch temp store, discarded when the run ends.", ), + config_path: pathlib.Path | None = typer.Option( + None, + "--config", + "-c", + help="Operator AppConfig YAML — required for --mode live (its " + "live_enabled opt-in, risk limits and the venue's brokers entry gate " + "the run; the canary never sets live_enabled itself). Unused by " + "paper/testnet.", + ), ) -> None: """Run the platform's deterministic self-test and print the evidence table. A one-liner that makes the canary (:mod:`trading_bot.application.canary`) runnable without wiring anything by hand: it builds the canary's own - **dedicated** paper engine (never a shared book), sizes the round-trip to - the venue's real smallest legal quantity, runs the sequential round-trip - plus the two free probes, and prints one line per check - (PASS/FAIL, name, expected, observed) followed by the total cost and the - verdict. Exits ``0`` when every check passed, ``1`` otherwise — so a CI job - or a release checklist can gate on it directly. - - ``--mode testnet`` / ``--mode live`` are **not implemented in this leaf** - (arrive with ``venue-oracle-and-testnet``) — the flags are accepted (so a - script naming them today keeps working unchanged) but refuse immediately - with a clear, non-zero exit and place no order. + **dedicated** engine (never a shared book), sizes the round-trip to the + venue's real smallest legal quantity, runs the sequential round-trip plus + the two free probes, and prints one line per check (PASS/FAIL, name, + expected, observed) followed by the total cost and the verdict. Exits + ``0`` when every check passed, ``1`` otherwise — so a CI job or a release + checklist can gate on it directly. + + Three modes share the one scenario, with the mode's oracle: + + * ``--mode paper`` (default) — a fresh strict-paper engine funded with + ``--budget``; the **exact** paper oracle. No venue, no key. + * ``--mode testnet`` — the venue's sandbox (fake money, real API): the + factory's testnet path (hard-pinned sandbox URL, testnet credentials), + the venue **identity** oracle (our fills/balances == venue-reported, + cost bounded by ``--max-cost``). Kraken has no public spot testnet and + is refused with a clear error. + * ``--mode live`` — REAL money, the operator's go-live act: gated by the + existing ``live_enabled`` config opt-in (``--config``), venue + credentials, the mandatory live risk limits, **and** a typed + confirmation (the dashboard's go-live pattern). Same identity oracle. """ if mode not in ("paper", "testnet", "live"): raise typer.BadParameter( f"--mode must be one of paper, testnet, live; got {mode!r}" ) - if mode != "paper": - _console.print( - f"[red]canary --mode {mode}[/red] is not implemented yet; it " - "arrives with the venue-oracle-and-testnet leaf. Only " - "--mode paper runs today (no order was placed)." - ) - raise typer.Exit(code=1) + live_base: AppConfig | None = None + if mode == "live": + # Every gate is checked (and the confirmation typed) BEFORE anything + # is built or sized — a refused live canary never touches the venue. + live_base = _confirm_live_canary(config_path) + + # The probe offset's default is per-mode: 50% below on paper (maximally + # far-off, exactly reproducible), 15% below on a real venue — inside the + # venue's limit-price band (see the option help), still never filling. + if probe_offset_pct is not None: + offset = money(str(Decimal(str(probe_offset_pct)))) + else: + offset = _PAPER_PROBE_OFFSET_PCT if mode == "paper" else _VENUE_PROBE_OFFSET_PCT try: - report = asyncio.run( - _run_paper_canary( - exchange=exchange, - symbol=symbol, - budget=money(str(Decimal(str(budget)))), - max_cost=money(str(Decimal(str(max_cost)))), - probe_offset_pct=money(str(Decimal(str(probe_offset_pct)))), - db_path=db_path, + if mode == "paper": + report = asyncio.run( + _run_paper_canary( + exchange=exchange, + symbol=symbol, + budget=money(str(Decimal(str(budget)))), + max_cost=money(str(Decimal(str(max_cost)))), + probe_offset_pct=offset, + db_path=db_path, + ) + ) + else: + report = asyncio.run( + _run_venue_canary( + exchange=exchange, + symbol=symbol, + mode=mode, + max_cost=money(str(Decimal(str(max_cost)))), + probe_offset_pct=offset, + db_path=db_path, + live_base=live_base, + ) ) - ) except Exception as exc: # noqa: BLE001 - surface any refusal/build failure cleanly _console.print(f"[red]refusing to run canary:[/red] {exc}") raise typer.Exit(code=1) from exc diff --git a/trading_bot/storage/sqlite_store.py b/trading_bot/storage/sqlite_store.py index eb8d7a08..efec5a5d 100644 --- a/trading_bot/storage/sqlite_store.py +++ b/trading_bot/storage/sqlite_store.py @@ -123,6 +123,10 @@ qty TEXT NOT NULL, price TEXT NOT NULL, fee TEXT NOT NULL, + -- The asset ``fee`` is denominated in; NULL means the quote currency (the + -- historic assumption — pre-migration rows backfill to NULL and read back + -- unchanged). Binance charges a market buy's commission in the BASE asset. + fee_asset TEXT, ts INTEGER NOT NULL, mode TEXT NOT NULL DEFAULT 'paper', venue TEXT NOT NULL DEFAULT '', @@ -447,8 +451,8 @@ def _record_fill_tagged(self, fill: Fill, mode: str, venue: str) -> None: """ INSERT OR IGNORE INTO fills ( fill_id, client_order_id, instrument, side, qty, price, - fee, ts, mode, venue - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + fee, fee_asset, ts, mode, venue + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( fill.fill_id, @@ -458,6 +462,7 @@ def _record_fill_tagged(self, fill: Fill, mode: str, venue: str) -> None: str(fill.qty), str(fill.price), str(fill.fee), + fill.fee_asset, fill.ts, mode, venue, @@ -899,7 +904,13 @@ def _row_to_order(row: sqlite3.Row) -> Order: def _row_to_fill(row: sqlite3.Row) -> Fill: - """Rebuild a :class:`Fill` from a stored ``fills`` row (exact Decimal).""" + """Rebuild a :class:`Fill` from a stored ``fills`` row (exact Decimal). + + ``fee_asset`` reads back ``None`` for a pre-migration (backfilled-NULL) + row — the quote-denominated historic assumption, exactly what those rows + meant when written. + """ + fee_asset = row["fee_asset"] if "fee_asset" in row.keys() else None return Fill( fill_id=str(row["fill_id"]), client_order_id=str(row["client_order_id"]), @@ -909,6 +920,7 @@ def _row_to_fill(row: sqlite3.Row) -> Fill: price=money(str(row["price"])), fee=money(str(row["fee"])), ts=int(row["ts"]), + fee_asset=None if fee_asset is None else str(fee_asset), ) @@ -941,16 +953,17 @@ def _row_to_capital_event(row: sqlite3.Row) -> CapitalEvent: def _migrate_fills_tags(conn: sqlite3.Connection) -> None: - """Add the ``mode`` / ``venue`` columns to a pre-existing ``fills`` table. + """Add the ``mode`` / ``venue`` / ``fee_asset`` columns to an old ``fills`` table. A lightweight, idempotent forward migration: ``CREATE TABLE IF NOT EXISTS`` (in :data:`_SCHEMA`) already gives a *fresh* database the tagged columns, but a database created before this leaf has the old ``fills`` shape. This inspects the live columns and ``ALTER TABLE ... ADD COLUMN`` for whichever tag is missing — SQLite backfills every existing row with the column ``DEFAULT`` - (``mode="paper"`` / ``venue=""``), so no row is lost or corrupted and the - money columns are untouched. A no-op once both columns exist (a fresh DB, or a - second open of a migrated one). + (``mode="paper"`` / ``venue=""``; ``fee_asset`` is nullable and backfills to + ``NULL`` — the quote-denominated historic meaning of those rows), so no row is + lost or corrupted and the money columns are untouched. A no-op once every + column exists (a fresh DB, or a second open of a migrated one). """ columns = {row["name"] for row in conn.execute("PRAGMA table_info(fills)")} if "mode" not in columns: @@ -959,6 +972,8 @@ def _migrate_fills_tags(conn: sqlite3.Connection) -> None: ) if "venue" not in columns: conn.execute("ALTER TABLE fills ADD COLUMN venue TEXT NOT NULL DEFAULT ''") + if "fee_asset" not in columns: + conn.execute("ALTER TABLE fills ADD COLUMN fee_asset TEXT") #: The composite primary key the current ``fills`` table carries — a fill's @@ -1006,6 +1021,7 @@ def _migrate_fills_pk(conn: sqlite3.Connection) -> None: qty TEXT NOT NULL, price TEXT NOT NULL, fee TEXT NOT NULL, + fee_asset TEXT, ts INTEGER NOT NULL, mode TEXT NOT NULL DEFAULT 'paper', venue TEXT NOT NULL DEFAULT '', @@ -1013,10 +1029,10 @@ def _migrate_fills_pk(conn: sqlite3.Connection) -> None: ); INSERT OR IGNORE INTO fills_new ( fill_id, client_order_id, instrument, side, qty, price, - fee, ts, mode, venue + fee, fee_asset, ts, mode, venue ) SELECT fill_id, client_order_id, instrument, side, qty, price, - fee, ts, mode, venue + fee, fee_asset, ts, mode, venue FROM fills ORDER BY rowid; DROP TABLE fills; diff --git a/trading_bot/tests/application/test_canary_venue.py b/trading_bot/tests/application/test_canary_venue.py new file mode 100644 index 00000000..aad2a11b --- /dev/null +++ b/trading_bot/tests/application/test_canary_venue.py @@ -0,0 +1,638 @@ +"""Tests for the canary's **venue** path: the identity oracle + the settle loop. + +The venue oracle (:func:`trading_bot.application.canary.venue_oracle`) asserts +the accounting identity — our recorded fills == the venue-reported fills, the +venue's balance deltas == what our fills imply (the fills math, never a +hardcoded zero), flat — plus a bounded cost. A real (REST) venue also fills +market orders *asynchronously* and keys its fill reports by its **own** order +reference, so :func:`~trading_bot.application.canary.run_canary` settles each +market leg by polling ``broker.fills(...)`` and attributing new fills to the +leg (see the module docstring's "Venue legs settle by polling"). + +These tests drive both against :class:`_RestVenueBroker`, a deliberately +REST-shaped fake: market orders execute **venue-side only** (recorded in its +``fills()`` view, balances mutated — nothing emitted on the bus), limit orders +rest, and every fill's ``client_order_id`` is the venue's own numeric order id +(never ours) — exactly the Binance ``myTrades`` shape the settle loop must +handle. Covered: + +* the whole scenario is green end to end over the REST venue: legs settle by + poll, the store's fills carry OUR client-order-ids while + ``report.venue_fills`` carries the venue's, and every identity/cost check + holds; +* every oracle branch turns red on the seeded violation it guards: a dropped + store fill (fills mismatch), a tampered quote balance (quote delta), venue + base dust our fills do not explain (base delta — expected rendered from the + fills math), a tiny ``max_cost`` (cost bound); +* the base-delta expectation really is the **fills math, not zero**: a venue + over-delivery within the domain fill tolerance leaves base dust that the + check *passes* because our recorded fills imply exactly that dust; +* a venue that never reports the market execution times the settle loop out — + the leg's check fails with what was observed and the abort cleanup cancels + the resting order; +* opt-in (``-m network``): the REAL Binance testnet run — the CLI's testnet + path end to end with the ``BINANCE_TESTNET_*`` keys, fake money, real API. + +Async tests run un-decorated (``asyncio_mode = "auto"``). +""" + +from __future__ import annotations + +# Built-in +import os +import pathlib +import sqlite3 +from dataclasses import replace + +# Third-party +import pytest + +# Local +from trading_bot.application import ( + AppConfig, + CanaryReport, + EventBus, + OrderRouter, + PerformanceService, + PositionTracker, + RiskConfig, + RiskManager, + run_canary, + venue_oracle, +) +from trading_bot.application import canary as canary_mod +from trading_bot.application.events import FillEvent +from trading_bot.application.order_fill_sync import OrderFillSync +from trading_bot.application.service_factory import Engine +from trading_bot.brokers.base import Capability +from trading_bot.domain import Instrument, Order, OrderStatus, Symbol, money +from trading_bot.domain.fill import Fill +from trading_bot.domain.money import Money +from trading_bot.domain.order import OrderSide, OrderType +from trading_bot.storage.sqlite_store import SqliteStore + +#: The same Binance-BTC/USDT-shaped venue spec the paper canary tests use. +INSTRUMENT = Instrument( + Symbol("BTC", "USDT"), + price_precision=2, + qty_precision=5, + min_qty=money("0.00001"), + min_notional=money("5"), +) +MARK = money("50000") +QTY = money("0.001") +MAX_COST = money("2") + +#: Per-leg venue fee at the fake's default 10 bps: 0.001 * 50000 * 0.001 = 0.05. +LEG_FEE = money("0.05") + + +class _RestVenueBroker: + """A REST-shaped fake venue: async fills, venue-keyed fill reports. + + Market orders execute immediately **venue-side**: the execution lands in + the ``fills()`` view and moves the balances, but nothing is emitted on any + bus — exactly like a real REST venue, which only *reports* executions when + polled. Limit orders rest until cancelled. Every fill's + ``client_order_id`` is the venue's own numeric order id (the Binance + ``myTrades`` shape), never the caller's — the canary's settle attribution + must not rely on client-order-id matching. + + Parameters + ---------- + balances : dict of str to Decimal + Starting free balances. + fee_bps : Decimal, optional + Fee rate in basis points, charged in **quote** on each fill. + fill_market : bool, optional + ``False`` makes market orders rest instead of executing — the + "venue never reports the execution" failure mode. + buy_overfill : Decimal, optional + Extra base quantity a market BUY delivers beyond the requested size + (venue lot rounding) — the within-tolerance dust case. Balances move + by the *delivered* quantity. + fee_in_base_on_buys : bool, optional + Charge a market BUY's commission in the **base** asset (taken out of + the delivered quantity), exactly like the real Binance venue — the + base-dust identity case observed live on the testnet. Sells stay + quote-fee'd. + + """ + + name = "restvenue" + + def __init__( + self, + *, + balances: dict[str, Money], + fee_bps: Money = money("10"), + fill_market: bool = True, + buy_overfill: Money = money("0"), + fee_in_base_on_buys: bool = False, + ) -> None: + self._balances: dict[str, Money] = { + asset: money(amount) for asset, amount in balances.items() + } + self._fee_bps = money(fee_bps) + self._fill_market = fill_market + self._buy_overfill = money(buy_overfill) + self._fee_in_base_on_buys = fee_in_base_on_buys + self._prices: dict[Symbol, Money] = {INSTRUMENT.symbol: MARK} + # venue_order_id -> the resting order's rebuild ingredients. + self._open: dict[str, Order] = {} + self._fills: list[Fill] = [] + self._seq = 0 + + def capabilities(self) -> set[Capability]: + return { + Capability.PLACE_ORDER, + Capability.CANCEL, + Capability.OPEN_ORDERS, + Capability.BALANCES, + Capability.FILLS, + Capability.TICKER, + } + + async def ticker(self, instrument: Instrument) -> Money: + return self._prices[instrument.symbol] + + async def balances(self) -> dict[str, Money]: + return { + asset: amount for asset, amount in self._balances.items() if amount != 0 + } + + async def open_orders(self) -> list[Order]: + return list(self._open.values()) + + async def fills(self, since_ms: int | None = None) -> list[Fill]: + return list(self._fills) + + async def place_order(self, order: Order) -> str: + self._seq += 1 + venue_num = str(1000 + self._seq) + venue_id = f"BTCUSDT:{venue_num}" + if order.type is OrderType.MARKET and self._fill_market: + price = self._prices[order.instrument.symbol] + base = order.instrument.symbol.base + quote = order.instrument.symbol.quote + zero = money("0") + qty = order.qty + if order.side is OrderSide.BUY: + qty += self._buy_overfill + base_fee = order.side is OrderSide.BUY and self._fee_in_base_on_buys + # Base-denominated buy commission (the real Binance shape) is a + # cut of the delivered base; otherwise the fee is quote on the + # traded notional. + fee = ( + qty * self._fee_bps / money("10000") + if base_fee + else qty * price * self._fee_bps / money("10000") + ) + self._fills.append( + Fill( + fill_id=f"V{self._seq}", + # The venue's own order reference — NOT the caller's cid. + client_order_id=venue_num, + # Bare instrument, like a real adapter's fill rebuild. + instrument=Instrument(order.instrument.symbol), + side=order.side, + qty=qty, + price=price, + fee=fee, + ts=self._seq, + fee_asset=base if base_fee else None, + ) + ) + if order.side is OrderSide.BUY: + self._balances[quote] = self._balances.get(quote, zero) - ( + qty * price + (zero if base_fee else fee) + ) + self._balances[base] = self._balances.get(base, zero) + ( + qty - (fee if base_fee else zero) + ) + else: + self._balances[base] = self._balances.get(base, zero) - qty + self._balances[quote] = ( + self._balances.get(quote, zero) + qty * price - fee + ) + else: + self._open[venue_id] = order + return venue_id + + async def cancel_order(self, venue_order_id: str) -> None: + self._open.pop(venue_order_id, None) + + +def _venue_engine(tmp_path: pathlib.Path, broker: _RestVenueBroker) -> Engine: + """A hand-wired engine (the factory's exact shape) over the REST fake. + + Mirrors ``build_engine``'s wiring order — tracker/perf subscribed, a + risk-gated router, the fill sync before the store attaches — so the settle + loop's re-emitted fills flow through the very plumbing a real venue run + uses. + """ + bus = EventBus() + tracker = PositionTracker(event_bus=bus) + perf = PerformanceService(event_bus=bus) + risk = RiskManager( + RiskConfig(), + position_tracker=tracker, + daily_pnl_provider=perf.realised_pnl_since, + ) + router = OrderRouter(broker, bus, risk_manager=risk) + fill_sync = OrderFillSync(router, bus) + store = SqliteStore(tmp_path / "canary.sqlite") + store.attach(bus) + return Engine( + config=AppConfig(), + bus=bus, + broker=broker, + router=router, + fill_sync=fill_sync, + tracker=tracker, + perf=perf, + risk=risk, + store=store, + ) + + +async def _green_run( + tmp_path: pathlib.Path, +) -> tuple[Engine, CanaryReport]: + """One full, green venue-canary run over the REST fake (asserted green).""" + engine = _venue_engine(tmp_path, _RestVenueBroker(balances={"USDT": money("1000")})) + report = await run_canary( + engine, + INSTRUMENT, + qty=QTY, + oracle=venue_oracle(MAX_COST), + mode_label="testnet", + ) + assert report.passed, report.to_text() + return engine, report + + +# --- the full scenario, green end to end over a REST-shaped venue ------------ # + + +async def test_venue_scenario_identity_holds(tmp_path: pathlib.Path) -> None: + """The whole canary passes over a REST venue: settle-by-poll + identity oracle. + + The venue reports fills asynchronously and keys them by its own order id; + the run must still confirm both legs, persist them under OUR ids, and the + identity oracle must hold with the exact venue-reported ids/qtys/prices. + """ + engine, report = await _green_run(tmp_path) + + assert report.mode == "testnet" + assert [check.name for check in report.checks] == [ + "cancel_probe.resting", + "cancel_probe.cancelled", + "cancel_probe.balances_unmoved", + "cancel_probe.cancelled_persisted", + "idempotency.deduped", + "roundtrip.buy_filled", + "roundtrip.buy_in_store", + "roundtrip.buy_in_tracker", + "roundtrip.sell_filled", + "roundtrip.sell_in_store", + "roundtrip.flat", + "oracle.fills_match_venue", + "oracle.position_flat", + "oracle.base_delta_matches_fills", + "oracle.quote_delta_matches_fills", + "oracle.cost_bounded", + ] + + # The venue reported the two round-trip fills under ITS OWN references... + assert len(report.venue_fills) == 2 + assert {fill.client_order_id for fill in report.venue_fills} == {"1002", "1003"} + # ...while the store recorded the same executions under OUR leg ids — the + # settle loop's attribution — with identical fill ids. + store = engine.store + assert store is not None + store.flush() + ours = [ + fill + for fill in store.fills() + if fill.client_order_id in {report.buy_cid, report.sell_cid} + ] + assert {fill.fill_id for fill in ours} == { + fill.fill_id for fill in report.venue_fills + } + assert store.get_order(report.buy_cid).status is OrderStatus.FILLED + assert store.get_order(report.sell_cid).status is OrderStatus.FILLED + + # Exact venue accounting: both legs at the same mark, so the cost is the + # two fees; balances moved by exactly what the fills imply. + assert report.cost == 2 * LEG_FEE + assert report.initial_balances == {"USDT": money("1000")} + assert report.final_balances == {"USDT": money("999.9")} + store.close() + + +async def test_venue_scenario_green_with_base_denominated_buy_fee( + tmp_path: pathlib.Path, +) -> None: + """The identity holds when the venue charges the BUY fee in the base asset. + + Exactly the shape the real Binance testnet exhibits (a BTC/USDT market buy + is commissioned in BTC): the venue's base balance ends with a fee-sized + dust the fills math must explain via the fill's ``fee_asset`` — expected + from the fills, not from zero — while the position stays flat (fees never + fold into the position quantity). + """ + broker = _RestVenueBroker( + balances={"USDT": money("1000")}, fee_in_base_on_buys=True + ) + engine = _venue_engine(tmp_path, broker) + + report = await run_canary( + engine, + INSTRUMENT, + qty=QTY, + oracle=venue_oracle(MAX_COST), + mode_label="testnet", + ) + + assert report.passed, report.to_text() + checks = {c.name: c for c in report.checks} + # Buy fee: 0.001 * 10bps = 0.000001 BTC, taken from the delivered base. + base_check = checks["oracle.base_delta_matches_fills"] + assert base_check.expected == "base_delta=-0.000001" + assert base_check.observed == "base_delta=-0.000001" + # Quote moved by the sell fee only (the buy fee never touched quote). + quote_check = checks["oracle.quote_delta_matches_fills"] + assert quote_check.expected == "quote_delta=-0.050" + assert report.cost is not None + if engine.store is not None: + engine.store.close() + + +# --- every oracle branch turns red on its seeded violation ------------------- # + + +async def test_oracle_detects_fills_mismatch(tmp_path: pathlib.Path) -> None: + """Dropping one persisted fill breaks the fills identity, exactly reported.""" + engine, report = await _green_run(tmp_path) + store = engine.store + assert store is not None + store.flush() + conn = sqlite3.connect(tmp_path / "canary.sqlite") + try: + fill_id = conn.execute("SELECT fill_id FROM fills LIMIT 1").fetchone()[0] + conn.execute("DELETE FROM fills WHERE fill_id = ?", (fill_id,)) + conn.commit() + finally: + conn.close() + + checks = {c.name: c for c in venue_oracle(MAX_COST)(engine, report)} + + assert not checks["oracle.fills_match_venue"].passed + # The venue side still lists both executions; ours only one. + assert checks["oracle.fills_match_venue"].expected.count("[id=") == 2 + assert checks["oracle.fills_match_venue"].observed.count("[id=") == 1 + store.close() + + +async def test_oracle_detects_quote_balance_mismatch( + tmp_path: pathlib.Path, +) -> None: + """A venue quote delta our fills do not explain fails the quote identity.""" + engine, report = await _green_run(tmp_path) + + report.final_balances["USDT"] = report.final_balances["USDT"] + money("0.01") + checks = {c.name: c for c in venue_oracle(MAX_COST)(engine, report)} + + quote_check = checks["oracle.quote_delta_matches_fills"] + assert not quote_check.passed + assert quote_check.expected == "quote_delta=-0.100" + assert quote_check.observed == "quote_delta=-0.090" + if engine.store is not None: + engine.store.close() + + +async def test_oracle_reports_unexplained_base_dust_exactly( + tmp_path: pathlib.Path, +) -> None: + """Venue base dust our fills do NOT imply fails — against the fills math. + + The expected side is computed from our recorded fills (buy qty − sell qty + == 0 here), never assumed: the venue reporting a stray base remainder (a + base-denominated fee, say) is an exact, reported identity violation. + """ + engine, report = await _green_run(tmp_path) + + report.final_balances["BTC"] = money("-0.00000001") + checks = {c.name: c for c in venue_oracle(MAX_COST)(engine, report)} + + base_check = checks["oracle.base_delta_matches_fills"] + assert not base_check.passed + assert base_check.expected == "base_delta=0.000" # the fills math, exact + assert base_check.observed == "base_delta=-1E-8" + if engine.store is not None: + engine.store.close() + + +async def test_oracle_cost_bound_breached(tmp_path: pathlib.Path) -> None: + """A run whose venue-measured cost exceeds ``max_cost`` fails the bound.""" + engine, report = await _green_run(tmp_path) + + checks = {c.name: c for c in venue_oracle(money("0.05"))(engine, report)} + + cost_check = checks["oracle.cost_bounded"] + assert not cost_check.passed + assert cost_check.expected == "cost<=0.05" + assert cost_check.observed == "cost=0.100" # 2 legs x 0.05 fee + if engine.store is not None: + engine.store.close() + + +# --- base dust: the expectation is the fills math, not zero ------------------ # + + +async def test_scenario_reports_venue_overfill_dust_and_stops( + tmp_path: pathlib.Path, +) -> None: + """A venue over-delivery within fill tolerance is reported exactly, and the + run stops before selling. + + The buy leg reads FILLED (the domain clamps within-tolerance over-fill + dust), but the tracker folds the *delivered* quantity — so the in-tracker + check records the venue's real behaviour exactly and the scenario refuses + to continue on a book that does not hold what it requested. + """ + engine = _venue_engine( + tmp_path, + _RestVenueBroker( + balances={"USDT": money("1000")}, + # 1e-8 over 0.001: excess fraction 1e-5, far inside the 0.1% + # domain fill tolerance — dust, not a material over-fill. + buy_overfill=money("0.00000001"), + ), + ) + report = await run_canary( + engine, INSTRUMENT, qty=QTY, oracle=None, mode_label="testnet" + ) + + assert not report.passed + by_name = {c.name: c for c in report.checks} + assert by_name["roundtrip.buy_filled"].passed + assert not by_name["roundtrip.buy_in_tracker"].passed + assert "0.00100001" in by_name["roundtrip.buy_in_tracker"].observed + # The sell was never placed: the run stopped on the dusted book. + assert engine.router.get(report.sell_cid) is None + if engine.store is not None: + engine.store.close() + + +async def test_base_dust_explained_by_fills_math_passes( + tmp_path: pathlib.Path, +) -> None: + """Venue base dust that our fills imply PASSES the base-delta identity. + + The pinned "compare against the fills math, not against zero" case, over + directly-seeded evidence of a *completed* round trip whose recorded fills + imply a base remainder (the buy delivered a within-tolerance hair more + than the sell returned): the venue balances reporting exactly that dust + must pass the base-delta check, whose expectation is computed from the + fills — a hardcoded-zero oracle would wrongly fail it. + """ + engine = _venue_engine(tmp_path, _RestVenueBroker(balances={"USDT": money("1000")})) + buy_qty = money("0.00100001") + sell_qty = money("0.001") + dust = buy_qty - sell_qty + report = CanaryReport( + exchange="restvenue", + mode="testnet", + instrument=INSTRUMENT, + qty=QTY, + probe_cid="canary-dust-probe", + buy_cid="canary-dust-buy", + sell_cid="canary-dust-sell", + ) + ours = [ + Fill( + fill_id="V1", + client_order_id=report.buy_cid, + instrument=INSTRUMENT, + side=OrderSide.BUY, + qty=buy_qty, + price=MARK, + fee=money("0"), + ts=1, + ), + Fill( + fill_id="V2", + client_order_id=report.sell_cid, + instrument=INSTRUMENT, + side=OrderSide.SELL, + qty=sell_qty, + price=MARK, + fee=money("0"), + ts=2, + ), + ] + for fill in ours: + engine.bus.emit(FillEvent(fill)) # store + tracker record our fills + report.initial_balances = {"USDT": money("1000")} + # The venue reports exactly what those fills imply: the base dust left + # over, and the quote moved by the notional difference. + report.final_balances = { + "USDT": money("1000") - dust * MARK, + "BTC": dust, + } + # Venue-side, the same executions under the venue's own references. + report.venue_fills = [ + replace(ours[0], client_order_id="9001"), + replace(ours[1], client_order_id="9002"), + ] + + checks = {c.name: c for c in venue_oracle(MAX_COST)(engine, report)} + + base_check = checks["oracle.base_delta_matches_fills"] + assert dust != 0 + assert base_check.passed + assert base_check.expected == f"base_delta={dust}" # the fills math, not 0 + assert base_check.observed == f"base_delta={dust}" + assert checks["oracle.fills_match_venue"].passed + assert checks["oracle.quote_delta_matches_fills"].passed + # The dusted book is still reported honestly where it IS a violation: + # the position is not flat. + assert not checks["oracle.position_flat"].passed + if engine.store is not None: + engine.store.close() + + +# --- settle timeout: a venue that never reports the execution ---------------- # + + +async def test_settle_timeout_fails_leg_with_evidence( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A market order the venue never reports filled times out and fails the leg. + + The settle loop gives up after the timeout; the leg's check records the + still-open order exactly, the run stops placing, and the abort cleanup + cancels the resting order on the venue. + """ + monkeypatch.setattr(canary_mod, "_VENUE_SETTLE_TIMEOUT_S", 0.05) + monkeypatch.setattr(canary_mod, "_VENUE_SETTLE_POLL_S", 0.01) + broker = _RestVenueBroker(balances={"USDT": money("1000")}, fill_market=False) + engine = _venue_engine(tmp_path, broker) + + report = await run_canary( + engine, INSTRUMENT, qty=QTY, oracle=venue_oracle(MAX_COST) + ) + + assert not report.passed + assert report.checks[-1].name == "roundtrip.buy_filled" + assert not report.checks[-1].passed + assert "status=open filled_qty=0" in report.checks[-1].observed + # The sell never ran, and the cleanup cancelled the resting buy venue-side. + assert engine.router.get(report.sell_cid) is None + assert await broker.open_orders() == [] + if engine.store is not None: + engine.store.close() + + +# --- the REAL Binance testnet run (opt-in: ``-m network``, key-gated) -------- # + + +@pytest.mark.network +async def test_real_binance_testnet_canary() -> None: + """The canary against the REAL Binance spot testnet — fake money, real API. + + Skips without testnet credentials (``BINANCE_TESTNET_API_KEY`` / + ``BINANCE_TESTNET_API_SECRET``, falling back to ``BINANCE_API_KEY`` / + ``BINANCE_API_SECRET`` — testnet.binance.vision rejects a mainnet key with + ``-2015``). Runs the CLI's own testnet path — the factory-built, + testnet-pinned engine, venue-side sizing, the full scenario, the identity + oracle — and asserts the whole evidence table is green. This is the + road-to-1.0 #1 validation vehicle: a real cancel, venue-checked + idempotency, and balance reconciliation against venue-reported fills. + """ + from trading_bot.interfaces.cli.main import _run_venue_canary + + key = os.environ.get("BINANCE_TESTNET_API_KEY") or os.environ.get("BINANCE_API_KEY") + secret = os.environ.get("BINANCE_TESTNET_API_SECRET") or os.environ.get( + "BINANCE_API_SECRET" + ) + if not key or not secret: + pytest.skip("no Binance testnet credentials in the environment") + + report = await _run_venue_canary( + exchange="binance", + symbol="BTC/USDT", + mode="testnet", + max_cost=MAX_COST, + # The CLI's venue default: inside Binance's PERCENT_PRICE_BY_SIDE + # price band (a 50%-below probe is rejected with -1013 on the venue). + probe_offset_pct=money("15"), + db_path=None, + ) + + assert report.exchange == "binance" + assert report.mode == "testnet" + assert report.passed, report.to_text() + assert len(report.venue_fills) >= 2 # both legs venue-confirmed diff --git a/trading_bot/tests/application/test_service_factory.py b/trading_bot/tests/application/test_service_factory.py index 3aa73ee8..e6a8ae4e 100644 --- a/trading_bot/tests/application/test_service_factory.py +++ b/trading_bot/tests/application/test_service_factory.py @@ -19,7 +19,7 @@ import pytest -from trading_bot.application.config import AppConfig, BrokerConfig +from trading_bot.application.config import AppConfig, BrokerConfig, RiskConfig from trading_bot.application.events import EventBus, FillEvent from trading_bot.application.mark_cache import MarkCache from trading_bot.application.order_router import OrderRouter @@ -620,6 +620,67 @@ def test_brokerconfig_testnet_defaults_false() -> None: assert BrokerConfig(name="bn", exchange="binance").testnet is False +# --- BrokerConfig.symbols → the adapter's per-symbol fill endpoints ---------- # + + +def test_testnet_threads_symbols_to_binance_adapter(monkeypatch) -> None: + """``BrokerConfig.symbols`` reaches the testnet adapter, parsed canonically. + + Binance has no account-wide trade history (``myTrades`` is per-symbol), so + ``broker.fills()`` — reconciliation's and the canary venue oracle's + re-fetch channel — needs the symbol set threaded from the config. + """ + monkeypatch.setenv("BINANCE_API_KEY", "tk") + monkeypatch.setenv("BINANCE_API_SECRET", "ts") + cfg = AppConfig( + mode="live", + brokers=[ + BrokerConfig( + name="bn", + exchange="binance", + testnet=True, + symbols=["BTC/USDT", "ETHUSDT"], + ) + ], + ) + engine = build_engine(cfg) + assert isinstance(engine.broker, BinanceBroker) + assert engine.broker.symbols == (Symbol("BTC", "USDT"), Symbol("ETH", "USDT")) + + +def test_live_threads_symbols_to_binance_adapter(monkeypatch) -> None: + """The live path threads ``symbols`` too (same seam as testnet).""" + monkeypatch.setenv("BINANCE_API_KEY", "tk") + monkeypatch.setenv("BINANCE_API_SECRET", "ts") + cfg = AppConfig( + mode="live", + live_enabled=True, + risk=RiskConfig( + max_order=money("0.01"), + max_position=money("0.01"), + max_daily_loss=money("10"), + ), + brokers=[BrokerConfig(name="bn", exchange="binance", symbols=["BTC/USDT"])], + ) + engine = build_engine(cfg) + assert isinstance(engine.broker, BinanceBroker) + assert engine.broker.symbols == (Symbol("BTC", "USDT"),) + + +def test_unparseable_symbols_entry_refuses_at_build(monkeypatch) -> None: + """An unparseable ``symbols`` entry refuses with a clear ``BrokerError``.""" + monkeypatch.setenv("BINANCE_API_KEY", "tk") + monkeypatch.setenv("BINANCE_API_SECRET", "ts") + cfg = AppConfig( + mode="live", + brokers=[ + BrokerConfig(name="bn", exchange="binance", testnet=True, symbols=["???"]) + ], + ) + with pytest.raises(BrokerError, match="cannot parse symbols entry"): + build_engine(cfg) + + # --- the daily-loss circuit breaker is wired to live PnL ------------------- # diff --git a/trading_bot/tests/brokers/test_binance_rest.py b/trading_bot/tests/brokers/test_binance_rest.py index e827f147..0712b17b 100644 --- a/trading_bot/tests/brokers/test_binance_rest.py +++ b/trading_bot/tests/brokers/test_binance_rest.py @@ -762,6 +762,77 @@ async def test_binance_error_body_raises_broker_error( await broker.place_order(order) +# --- HTTP-4xx rejections map to the same domain errors (real-venue shape) - # + + +async def test_http_400_rejection_maps_to_specific_domain_error( + httpx_mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """A venue rejection sent as HTTP 400 + JSON body maps like an in-band error. + + On the real venue Binance delivers its ``{"code","msg"}`` rejections with + a **4xx status** (observed live on the testnet: a filter failure arrives + as HTTP 400), which the transport surfaces as ``HTTPError`` — the adapter + must recover the body and raise the same specific domain error the 200 + shape would have produced, never leak a transport exception. + """ + httpx_mock.add_response( + status_code=400, + json={"code": -2010, "msg": "Account has insufficient balance."}, + ) + broker = _broker(monkeypatch) + order = Order( + client_order_id="strat-400", + instrument=BTC_USDT, + side=OrderSide.BUY, + qty=money("1"), + type=OrderType.MARKET, + ) + + with pytest.raises(InsufficientBalance, match="insufficient balance"): + await broker.place_order(order) + + +async def test_http_400_filter_failure_maps_to_broker_error( + httpx_mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """The real testnet's -1013 filter rejection surfaces as a ``BrokerError``. + + The exact shape the live canary hit: ``HTTP 400`` + + ``{"code":-1013,"msg":"Filter failure: PERCENT_PRICE_BY_SIDE"}`` on the + probe's far-below limit. It must arrive as a mapped ``BrokerError`` + carrying the venue's diagnostic (so the canary records it as a failed + check instead of crashing out of the scenario). + """ + httpx_mock.add_response( + status_code=400, + json={"code": -1013, "msg": "Filter failure: PERCENT_PRICE_BY_SIDE"}, + ) + broker = _broker(monkeypatch) + order = Order( + client_order_id="strat-1013", + instrument=BTC_USDT, + side=OrderSide.BUY, + qty=money("1"), + type=OrderType.LIMIT, + limit_price=money("30000"), + ) + + with pytest.raises(BrokerError, match="PERCENT_PRICE_BY_SIDE"): + await broker.place_order(order) + + +async def test_http_400_non_binance_body_degrades_to_generic_broker_error( + httpx_mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """A 4xx whose body is not the Binance error shape still maps (generic).""" + httpx_mock.add_response(status_code=404, text="not found") + broker = _broker(monkeypatch) + + with pytest.raises(BrokerError, match="account"): + await broker.balances() + + # --- B-7: array-wrapped Binance errors are detected & mapped -------------- # diff --git a/trading_bot/tests/interfaces/test_cli_commands.py b/trading_bot/tests/interfaces/test_cli_commands.py index 5859d674..6d3b7d94 100644 --- a/trading_bot/tests/interfaces/test_cli_commands.py +++ b/trading_bot/tests/interfaces/test_cli_commands.py @@ -533,24 +533,155 @@ async def _fake_run_canary(engine, instrument, *, qty, probe_offset_pct): assert "result: FAIL" in result.output -def test_canary_mode_testnet_not_implemented(monkeypatch: pytest.MonkeyPatch) -> None: - """`--mode testnet` refuses with a clear error and a non-zero exit. +def test_canary_testnet_kraken_refused_no_sandbox( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`--mode testnet --exchange kraken` refuses clearly: no public spot testnet. - Reserved for the next leaf (venue-oracle-and-testnet) — no sizing/engine - call happens: fail loudly if the CLI ever reaches ``_resolve_canary_sizing`` - on this path. + The factory's ``_build_testnet_venue`` raises before any I/O (constructing + an adapter sends nothing), so this runs fully offline and places no order. """ + result = runner.invoke(app, ["canary", "--mode", "testnet", "--exchange", "kraken"]) + + assert result.exit_code != 0 + assert "refusing to run canary" in result.output + assert "no testnet" in result.output - async def _must_not_be_called(exchange: str, symbol: str): - raise AssertionError("--mode testnet must refuse before any sizing call") - monkeypatch.setattr(cli_main, "_resolve_canary_sizing", _must_not_be_called) +def test_canary_testnet_without_credentials_refused( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`--mode testnet` without testnet credentials refuses before any order. + + With every Binance key stripped from the environment, the factory's + credential gate raises (the adapter is built keyless, no request goes + out), and the CLI surfaces it as a clean non-zero exit — offline. + """ + for var in ( + "BINANCE_TESTNET_API_KEY", + "BINANCE_TESTNET_API_SECRET", + "BINANCE_API_KEY", + "BINANCE_API_SECRET", + ): + monkeypatch.delenv(var, raising=False) result = runner.invoke(app, ["canary", "--mode", "testnet"]) assert result.exit_code != 0 - assert "not implemented" in result.output - assert "venue-oracle-and-testnet" in result.output + assert "refusing to run canary" in result.output + assert "credentials" in result.output + + +def test_canary_live_without_config_refused(monkeypatch: pytest.MonkeyPatch) -> None: + """`--mode live` without --config refuses before any prompt or build.""" + + def _must_not_build(*args, **kwargs): + raise AssertionError("live canary must refuse before building an engine") + + monkeypatch.setattr(cli_main, "build_engine", _must_not_build) + + result = runner.invoke(app, ["canary", "--mode", "live"]) + + assert result.exit_code != 0 + assert "--config" in result.output + assert "no order was placed" in result.output + + +def test_canary_live_without_live_enabled_refused( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`--mode live` with a config missing live_enabled refuses, pre-prompt. + + Mirrors the ``run --live`` gate: the off-by-default ``live_enabled`` opt-in + must come from the operator's config — the canary never sets it. Refused + before the typed confirmation is even asked and before any engine exists. + """ + + def _must_not_build(*args, **kwargs): + raise AssertionError("live canary must refuse before building an engine") + + monkeypatch.setattr(cli_main, "build_engine", _must_not_build) + cfg = tmp_path / "live.yaml" + cfg.write_text("mode: live\nbrokers:\n - {name: kraken, exchange: kraken}\n") + + result = runner.invoke(app, ["canary", "--mode", "live", "--config", str(cfg)]) + + assert result.exit_code != 0 + assert "live is off by default" in result.output + assert "live_enabled" in result.output + assert "no order was placed" in result.output + + +def test_canary_live_confirmation_mismatch_refused( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`--mode live` with the gates open still refuses without the typed phrase. + + The typed confirmation is the dashboard's go-live pattern: the exact + acknowledgement phrase, verbatim — anything else refuses with no engine + built and no order placed. + """ + + def _must_not_build(*args, **kwargs): + raise AssertionError("live canary must refuse before building an engine") + + monkeypatch.setattr(cli_main, "build_engine", _must_not_build) + cfg = tmp_path / "live.yaml" + cfg.write_text( + "mode: live\nlive_enabled: true\n" + "brokers:\n - {name: kraken, exchange: kraken}\n" + ) + + result = runner.invoke( + app, ["canary", "--mode", "live", "--config", str(cfg)], input="nope\n" + ) + + assert result.exit_code != 0 + assert "typed acknowledgement" in result.output + assert "no order was placed" in result.output + + +def test_canary_live_confirmed_reaches_existing_factory_gates( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A correctly-typed live confirmation proceeds — into the factory's gates. + + With the opt-in set and the exact phrase typed, the CLI goes on to build + the engine, where the EXISTING factory gates take over: with every Kraken + key stripped, the credential gate raises and the run refuses cleanly — + still offline, still no order. (Proves the confirmation path is wired to + the same guarded factory, not around it.) + """ + monkeypatch.delenv("KRAKEN_API_KEY", raising=False) + monkeypatch.delenv("KRAKEN_API_SECRET", raising=False) + cfg = tmp_path / "live.yaml" + cfg.write_text( + "mode: live\nlive_enabled: true\n" + "brokers:\n - {name: kraken, exchange: kraken}\n" + "risk: {max_order: '0.01', max_position: '0.01', max_daily_loss: '10'}\n" + ) + + result = runner.invoke( + app, + ["canary", "--mode", "live", "--exchange", "kraken", "--config", str(cfg)], + input="I UNDERSTAND\n", + ) + + assert result.exit_code != 0 + assert "refusing to run canary" in result.output + assert "credentials" in result.output + + +def test_canary_live_ack_phrase_mirrors_dashboard() -> None: + """The CLI's typed phrase is the dashboard's go-live phrase, verbatim. + + The CLI deliberately does not import the API module (FastAPI stays out of + the CLI's import graph), so this test is what keeps the two constants from + drifting apart. + """ + from trading_bot.interfaces.api import app as api_app + + assert cli_main._LIVE_ACK_PHRASE == api_app._LIVE_ACK_PHRASE def test_canary_max_cost_refuses_before_any_order( diff --git a/trading_bot/tests/storage/test_sqlite_store.py b/trading_bot/tests/storage/test_sqlite_store.py index f68a25f5..a78a15fc 100644 --- a/trading_bot/tests/storage/test_sqlite_store.py +++ b/trading_bot/tests/storage/test_sqlite_store.py @@ -680,6 +680,69 @@ def test_migration_is_idempotent_and_preserves_new_schema(tmp_path) -> None: assert (rec.mode, rec.venue) == ("paper", "kraken") +def test_fee_asset_round_trips_and_backfills_none(tmp_path) -> None: + """``Fill.fee_asset`` persists exactly; absent/legacy rows read back ``None``. + + A venue may denominate a commission in the base asset (Binance charges a + market buy's fee in BTC on BTC/USDT) — the denomination must survive the + store round trip for venue-truth accounting (the canary's identity + oracle). A fill recorded without one (the historic quote assumption) and + a pre-migration row (the column backfills NULL) both read back ``None``. + """ + db = tmp_path / "fees.db" + store = SqliteStore(db) + base_fee_fill = Fill( + fill_id="BF1", + client_order_id="cid-bf", + instrument=BTC_USD, + side=OrderSide.BUY, + qty=money("0.001"), + price=money("50000"), + fee=money("0.000001"), + ts=1_700_000_000_000, + fee_asset="BTC", + ) + store.record_fill(base_fee_fill) + store.record_fill(_fill(fill_id="QF1")) # no fee_asset: quote-denominated + + by_id = {f.fill_id: f for f in store.fills()} + assert by_id["BF1"].fee_asset == "BTC" + assert by_id["BF1"].fee == money("0.000001") + assert by_id["QF1"].fee_asset is None + + # A legacy (pre-fee_asset) database: the migration adds the column and the + # old row reads back fee_asset=None — exactly what it meant when written. + legacy_db = tmp_path / "legacy_fee.db" + legacy = sqlite3.connect(str(legacy_db)) + try: + legacy.executescript( + """ + CREATE TABLE fills ( + fill_id TEXT PRIMARY KEY, + client_order_id TEXT NOT NULL, + instrument TEXT NOT NULL, + side TEXT NOT NULL, + qty TEXT NOT NULL, + price TEXT NOT NULL, + fee TEXT NOT NULL, + ts INTEGER NOT NULL + ); + """ + ) + legacy.execute( + "INSERT INTO fills VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ("OLD-FA", "c1", "BTC/USD", "buy", "1", "30000", "3", 1_700), + ) + legacy.commit() + finally: + legacy.close() + migrated = SqliteStore(legacy_db) + [old_fill] = migrated.fills() + assert old_fill.fill_id == "OLD-FA" + assert old_fill.fee_asset is None + assert old_fill.fee == money("3") + + # --- orders: schema migration (add ts/reject_reason/fill_tolerance) --------- #