From ec5d68f3c1b15fa3606e04bd18cd0559aaeae463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nahim=20Rodr=C3=ADguez?= Date: Sun, 30 Aug 2026 17:20:23 -0600 Subject: [PATCH 01/27] =?UTF-8?q?fix(exit):=20stop=20strangling=20winners?= =?UTF-8?q?=20=E2=80=94=20trailing=20floor,=20executable=20marks,=20async?= =?UTF-8?q?=20sells?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trailing distance now floors at max(min_trail_ticks, spread, pct of gain) so the stop can never sit inside one tick; the lock arms at +30 percent and take_profit ships disabled so the trail is the primary exit. Marks come only from depth-qualified bids (no mid fallback), peaks update from the book sampler, and live sells run off the event loop with an in-flight registry so settlement/reconciliation cannot double-close. --- CHANGELOG.md | 80 +++ openpoly/api/main.py | 5 + openpoly/markets/manager.py | 16 + openpoly/runtime/closing_registry.py | 56 ++ openpoly/runtime/exit_monitor.py | 345 +++++++++-- openpoly/runtime/orchestrator.py | 15 +- openpoly/runtime/reconciliation_monitor.py | 7 + openpoly/runtime/settlement_monitor.py | 7 + openpoly/sections/exit/threshold_v0.py | 137 ++++- tests/conftest.py | 13 + tests/test_closing_registry.py | 27 + tests/test_exit_monitor.py | 640 +++++++++++++++++++-- tests/test_market_book_sampling.py | 46 ++ tests/test_orchestrator.py | 47 ++ tests/test_reconciliation_monitor.py | 25 + tests/test_section_exit_threshold.py | 244 +++++++- tests/test_settlement_monitor.py | 28 + 17 files changed, 1605 insertions(+), 133 deletions(-) create mode 100644 openpoly/runtime/closing_registry.py create mode 100644 tests/test_closing_registry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4068451..f9ac3e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,86 @@ Dates are US-style (MM/DD/YYYY). --- +## 08/30/2026 — Exit policy v2: the trailing stop stops eating the trade + +Live behavior exposed a defect in exit policy v1 (05/24): winners were being +closed on the *first downtick* of a move, at barely above entry. The +compounding causes, all now fixed: + +**The trailing distance was measured in the wrong unit.** `peak_drawdown_pct` +was applied to the banked gain — retrace 12% *of (peak − entry)*. That +distance is tightest exactly when a move is youngest: on a $10 position that +had just armed, 12% of the gain worked out to less than one Polymarket tick +(0.01), so the very next quote closed the position. The rule now compares the +retrace against an absolute price distance: + + max(min_trail_ticks × tick_size, current_spread, peak_drawdown_pct × (peak − entry)) + +Never tighter than two ticks, never tighter than the book's own spread, and +widening as the move grows. New knobs: `min_trail_ticks` (default 2) and +`tick_size` (default 0.01). + +**It armed far too early.** `peak_meaningful_floor_pct` was 1% of cost basis, +so on a small position the $1 USD floor did all the work and the lock engaged +after roughly a +10% move — a range where a retrace is quote noise, not +given-back profit. The floor is now 30% of cost basis: the trailing lock only +ever protects a gain worth protecting. + +**Precedence made take-profit dead code.** With `peak_drawdown` evaluated +before `take_profit`, almost every winner large enough to reach +20% had +already retraced enough to close as a drawdown first — the ceiling nominally +existed but essentially never fired. Precedence is now **stop-loss → +take-profit → peak-drawdown**. + +**Take-profit now ships off.** The new `take_profit_enabled` flag defaults to +`false`. This is forced by the two changes above: the trailing lock arms at ++30% of cost basis, so a +20% ceiling would close every winner *before* the +lock could ever engage and the whole trailing redesign would be inert. Under +the shipped defaults a position is therefore exited by the trailing lock once +it has run +30% or more, or by the stop-loss at -15%; `take_profit_pct` (still +0.20) is kept as an opt-in hard cap for anyone who wants one. The trade-off is +explicit and worth stating: **between entry and +30% there is no profit-taking +rule at all — a position in that band is protected only by the stop-loss**, so +a +25% gain can round-trip back to -15% without the section closing it. + +**Marks now require depth.** The mark was the raw level-1 bid, whatever its +size. A single minimum-size resting order sitting away from fair value was +enough to print a loss the position never had and fire the stop. The monitor +now marks at the first bid level carrying at least `min_mark_bid_size` shares +(default 5 — above the venue's $1 minimum order at the prices traded here) and +at nothing else: there is no mid fallback, because both executors sell into the +book's raw level-1 bid, so a mid mark would evaluate take-profit and the +trailing lock against a price the position can never realize (0.40 bid / 0.72 +ask marks at 0.56 and "takes profit" into a 0.40 fill). When no bid level +qualifies the position is held, counted as *blocked*, and logged once as +`no_executable_bid` so the gap is visible instead of silent. A stop-loss can no +longer fire off a bid nobody is standing behind, and a take-profit can no +longer fire off a price nobody is bidding. + +**Peaks track the book, not the tick.** The peak was only sampled by the 120s +exit tick, so a run-up that happened and reversed between two ticks left no +trace and the stop trailed a peak that never existed. The order-book sampler +(60s) now pushes every observed book into the monitor's peak tracker. This is +still polling, not a live quote stream — the runtime has no push book feed — +so the limit is the sampler's interval, which is the smallest honest change +available today. + +**Closing a position is now single-writer.** The exit monitor's `execute_sell` +moved onto a worker thread (`asyncio.to_thread`) so a seconds-long on-chain +sell no longer stalls the WS reconnects and market polls — but that hands the +event loop back mid-sell, and the settlement and reconciliation monitors could +then close the same position id first, leaving the real fill unpersistable. A +process-local in-flight registry (`runtime/closing_registry.py`) now holds the +id for the duration of the sell; the other two loops skip it and reconsider on +their next sweep. For the same reason `stop()` no longer just cancels the tick +loop: a sell already handed to a worker thread cannot be cancelled, so the sell +and its bookkeeping run as their own task, which shutdown drains (30s cap) +before reporting stopped. + +On a synthetic path from 0.50 to 0.80 with single-tick noise, the old rules +gave the trade back at ~0.55; the shipped defaults hold through every dip and +exit at 0.76 on the trailing lock, keeping ~87% of the move. + ## 06/01/2026 — The strategy canvas becomes the operating surface The canvas page was promoted from a configuration sketchpad to the actual diff --git a/openpoly/api/main.py b/openpoly/api/main.py index 3922f29..913fb4d 100644 --- a/openpoly/api/main.py +++ b/openpoly/api/main.py @@ -166,6 +166,10 @@ async def _held_condition_sides() -> set[tuple[str, str]]: # cached vectors so a restart skips the cold recompute. await embedding_manager.start(session_factory=get_session_factory()) market_source_manager.set_book_persist(database_manager.enqueue_order_book) + # Feed every sampled book to the exit monitor's peak tracker. The sampler + # runs finer than the exit tick, so this is what keeps the trailing stop + # trailing a peak that actually happened. + market_source_manager.set_book_observer(exit_monitor.observe_book) market_source_manager.set_portfolio_store(PortfolioStore(get_session_factory())) news_source_manager.set_news_persist(database_manager.enqueue_news) # Auto-start both sources so a fresh process is already streaming — no @@ -177,6 +181,7 @@ async def _held_condition_sides() -> set[tuple[str, str]]: # Shutdown (reverse order): drain orchestrator first so it doesn't try # to enqueue against a torn-down manager, then stop the WS source. market_source_manager.set_book_persist(None) + market_source_manager.set_book_observer(None) market_source_manager.set_portfolio_store(None) news_source_manager.set_news_persist(None) if _recon_mod.reconciliation_monitor is not None: diff --git a/openpoly/markets/manager.py b/openpoly/markets/manager.py index a562f4c..f2b6b43 100644 --- a/openpoly/markets/manager.py +++ b/openpoly/markets/manager.py @@ -145,6 +145,7 @@ def __init__( self._poll_count: int = 0 self._last_error: str | None = None self._book_persist: Callable[[OrderBook], None] | None = None + self._book_observer: Callable[[OrderBook], None] | None = None # ---------- lifecycle ---------- @@ -192,6 +193,15 @@ def set_book_persist(self, persist: Callable[[OrderBook], None] | None) -> None: writer's ``enqueue``. Wired by the FastAPI lifespan; ``None`` in tests.""" self._book_persist = persist + def set_book_observer(self, observer: Callable[[OrderBook], None] | None) -> None: + """Install / clear the order-book observer hook — the exit monitor's + ``observe_book``. It is the only push path the runtime has for prices: + the exit tick runs every 120s while this sampler runs every 60s, so a + run-up that happens and reverses between two ticks would otherwise + never reach the trailing-stop peak. Wired by the FastAPI lifespan; + ``None`` in tests.""" + self._book_observer = observer + def set_portfolio_store(self, store: Any | None) -> None: """Install / clear the portfolio_store reference — wired by the FastAPI lifespan after PortfolioStore is constructed. ``None`` in tests that @@ -336,6 +346,12 @@ async def _one(token_id: str) -> OrderBook | None: if self._book_persist is not None: for book in books: self._book_persist(book) + if self._book_observer is not None: + for book in books: + try: + self._book_observer(book) + except Exception as exc: # noqa: BLE001 — an observer must never stall sampling + logger.warning("order book observer failed for %s: %s", book.token_id, exc) return len(books) async def _run_book_loop(self) -> None: diff --git a/openpoly/runtime/closing_registry.py b/openpoly/runtime/closing_registry.py new file mode 100644 index 0000000..6e05269 --- /dev/null +++ b/openpoly/runtime/closing_registry.py @@ -0,0 +1,56 @@ +"""In-flight close registry — which position ids the exit monitor is selling. + +Three runtime loops can close the same position: the exit monitor (threshold +sell), the settlement monitor (resolved market) and the reconciliation monitor +(flat on-chain). They used to be effectively serialized by the event loop, +because the exit monitor's ``execute_sell`` ran inline. It no longer does: the +live sell is offloaded with ``asyncio.to_thread``, which hands the loop back +for the seconds the on-chain order takes. In that window the other two loops +can run, see a position the wallet has already emptied, and call +``close_position`` on it first. The exit monitor then fails to persist the real +fill (``ValueError: position N is closed``, five retries deep) and the actual +exit price and realized PnL are lost. + +The fix is deliberately small: the exit monitor registers a position id here +for exactly as long as its sell is in flight, and the other two monitors skip +any id in the set for that tick. They are periodic sweeps — skipping is free, +they simply reconsider the position on the next tick, by which time the exit +monitor has either persisted its close or left the position open on purpose. + +Process-local and single-threaded by construction: every mutation happens on +the event loop thread (the sell itself runs in a worker, the registry calls +around it do not), so a plain ``set`` needs no lock. +""" + +from __future__ import annotations + +_closing: set[int] = set() + + +def mark_closing(position_id: int) -> None: + """Register ``position_id`` as being sold by the exit monitor.""" + _closing.add(position_id) + + +def clear_closing(position_id: int) -> None: + """Deregister ``position_id`` — always from a ``finally``, so a failed sell + can never leave a position permanently unreconcilable.""" + _closing.discard(position_id) + + +def is_closing(position_id: int) -> bool: + """True while the exit monitor has a sell in flight for ``position_id``.""" + return position_id in _closing + + +def closing_ids() -> frozenset[int]: + """Snapshot of the ids currently being sold (diagnostics / tests).""" + return frozenset(_closing) + + +def reset_for_tests() -> None: + """Drop every registered id — the set is process-global, so a test that + leaves one behind (a cancelled sell, a fake executor that raised) would + make the other monitors skip that position for the rest of the session. + Called by an autouse fixture in ``tests/conftest.py``; never in runtime.""" + _closing.clear() diff --git a/openpoly/runtime/exit_monitor.py b/openpoly/runtime/exit_monitor.py index 764d378..2920f49 100644 --- a/openpoly/runtime/exit_monitor.py +++ b/openpoly/runtime/exit_monitor.py @@ -3,9 +3,10 @@ The news pipeline (orchestrator) is event-driven; closing a position is position-driven + periodic. ``ExitMonitor`` runs a tick loop: every ``tick_interval_seconds`` it walks every open position, marks it with the held -side's current price (level-1 bid of the held token's order book), runs the -``exit`` section, and — when the section returns a ``CloseIntent`` — routes it -to ``executor.execute_sell``. Each evaluation is recorded in ``exit_log``. +side's current price (a depth-guarded bid from the held token's order book), +runs the ``exit`` section, and — when the section returns a ``CloseIntent`` — +routes it to ``executor.execute_sell``. Each evaluation is recorded in +``exit_log``. It shares the one module-level ``executor`` with the orchestrator — entry buys and exit sells go through the same fill path. The ``PortfolioStore`` is @@ -15,9 +16,21 @@ the monitor logs a ``skip`` and leaves the position open. Settlement-close is a separate concern, out of scope. -The tick does only sync work (DB read/write, in-memory book lookup, the pure -exit section) — all sub-millisecond — so it runs inline; the loop yields -cooperatively between ticks (docs/architecture/05). +Marking is depth-guarded (v0.3.0). A resting level-1 bid can be a single +minimum-size probe order sitting far from fair value; marking there produced +false stop-outs. The mark is the first bid level carrying at least +``min_mark_bid_size`` shares — and nothing else. There is deliberately no mid +fallback: the executors sell into the book's raw level-1 bid, so a mid mark +would evaluate take-profit and peak-drawdown against a price the position can +never realize, closing a "winner" into a dust bid at a loss. When no bid level +qualifies the position is reported *blocked*, held, and logged once as +``no_executable_bid`` so the gap is visible rather than silent. + +The tick's own work is sub-millisecond (DB read/write, in-memory book lookup, +the pure exit section) so it runs inline, but ``execute_sell`` is a live +network call that sleeps for seconds (CTF cache polling, close-persist +retries), so it is offloaded with ``asyncio.to_thread`` — same pattern the +orchestrator uses for its blocking section calls (docs/architecture/05). """ from __future__ import annotations @@ -36,8 +49,10 @@ from openpoly.execution import ExecResult from openpoly.execution import executor as _executor_singleton from openpoly.markets.manager import manager as market_source_manager +from openpoly.markets.models import OrderBook from openpoly.markets.store import MarketStore from openpoly.portfolio import HeldPosition, PortfolioStore +from openpoly.runtime.closing_registry import clear_closing, mark_closing from openpoly.runtime.section_log import ExitDecision, exit_log from openpoly.sections._base import SectionInput, SectionOutput from openpoly.sections.exit.threshold_v0 import ( @@ -51,9 +66,42 @@ DEFAULT_TICK_INTERVAL_SECONDS = 120 # v8 §10.1 "hard" tick +# Minimum resting size (in shares) for a bid level to be accepted as the mark. +# Polymarket's minimum order notional is $1, which at the 0.20–0.80 prices this +# system trades is 1.25–5 shares — so any level below ~5 shares can be a single +# minimum-size probe or dust order rather than a price anyone is committed to. +# Requiring 5 shares means the mark is always backed by at least one order +# larger than the venue minimum, which is what makes a stop-loss fired off it +# an executable price rather than an artifact. +DEFAULT_MIN_MARK_BID_SIZE = 5.0 + +# How long ``stop()`` waits for an in-flight sell to finish recording itself. +# The live sell runs in a worker thread and cannot be cancelled, so the choice +# is between waiting for its bookkeeping and losing the record of a close that +# already happened on-chain. 30s covers the live executor's own retry budget. +INFLIGHT_DRAIN_TIMEOUT_SECONDS = 30.0 + State = Literal["stopped", "running"] +def _mark_from_levels( + bids: list[tuple[float, float]], + min_bid_size: float, +) -> float | None: + """Depth-guarded mark for one book — an *executable* price or nothing. + + The first bid level carrying ``min_bid_size`` or more is where the position + could actually be sold, so that is the mark. Returns ``None`` when no bid + level qualifies: the book has no real bid side, and every alternative (the + mid, the dust bid) is a price the position cannot be sold at, which is + exactly what makes a trigger fired off it a fabricated exit. + """ + for price, size in bids: + if size >= min_bid_size: + return price + return None + + class _ExitSection(Protocol): """Minimal exit-section shape used by the monitor.""" @@ -82,25 +130,37 @@ def __init__( exit_section: _ExitSection, executor: _Executor, tick_interval_seconds: int = DEFAULT_TICK_INTERVAL_SECONDS, + min_mark_bid_size: float = DEFAULT_MIN_MARK_BID_SIZE, ) -> None: self._exit = exit_section self._executor = executor self._tick_interval = tick_interval_seconds + self._min_mark_bid_size = min_mark_bid_size self._portfolio: PortfolioStore | None = None self._stop = asyncio.Event() self._task: asyncio.Task[None] | None = None + # The sell currently in flight, if any. ``execute_sell`` runs in a + # worker thread and cannot be cancelled, so the sell + its bookkeeping + # live in their own task: cancelling the tick loop must not strand a + # close that already happened on-chain (see ``stop``). + self._inflight: asyncio.Task[None] | None = None self._state: State = "stopped" # canvas-sync v2: atomic swap lock — same model as orchestrator's # _sections_lock. _tick_once reads self._exit; replace happens between # ticks (or between in-flight section.run calls within a tick — Python # GC keeps the old instance alive for any caller already holding it). self._exit_lock = asyncio.Lock() - # Per-position peak of the held side's current_price across this - # process's lifetime. Rebuilt at startup by ``bootstrap_peaks`` from - # the order_book_snapshot table; updated every tick; dropped on close. - # Process-restart loses anything not in that table — accepted trade-off - # for keeping runtime state out of the database schema. + # Per-position peak of the held side's mark across this process's + # lifetime. Rebuilt at startup by ``bootstrap_peaks`` from the + # order_book_snapshot table; updated every tick and on every observed + # book (see ``observe_price``); dropped on close. Process-restart loses + # anything not in that table — accepted trade-off for keeping runtime + # state out of the database schema. self._peak: dict[int, float] = {} + # token_id → position_ids, rebuilt each sweep. Lets ``observe_price`` + # update peaks from the book sampler without touching the DB on what is + # a per-book hot path. + self._watch: dict[str, list[int]] = {} # Tick telemetry (v18) — the "is the monitor working" heartbeat, # surfaced via /api/exit/log so the canvas badge / Closes tab can show # liveness without flooding exit_log with a skip entry per position per @@ -110,6 +170,14 @@ def __init__( self._last_tick_at: float | None = None self._last_tick_open: int = 0 self._last_tick_blocked: int = 0 + # Positions already logged as ``no_executable_bid``. A position whose + # book has no depth-qualified bid cannot be evaluated at all — its + # stop-loss can't fire — so that has to be visible in exit_log, but a + # row per position per tick would evict the ok / error closes from the + # ring. One row per occurrence: the id is dropped again as soon as the + # position becomes markable (or closes), so a book that goes thin twice + # is logged twice. + self._unmarkable: set[int] = set() @property def state(self) -> State: @@ -128,7 +196,8 @@ def open_positions(self) -> int: @property def blocked(self) -> int: """Positions on the last sweep that could not be evaluated (no order - book — market resolved or data gap; their stop-loss can't fire).""" + book, or no book level deep enough to mark against — their stop-loss + can't fire).""" return self._last_tick_blocked def configure(self, portfolio: PortfolioStore) -> None: @@ -140,10 +209,11 @@ def bootstrap_peaks(self, session_factory: sessionmaker[Session]) -> None: """Rebuild per-position peaks from persisted order-book snapshots. For each open position, scan ``order_book_snapshot`` rows where - ``token_id == position.token_id AND recorded_at >= opened_at`` and - take the max of ``bids[0][0]`` (the held-side best bid — same value - the live tick uses). Falls back to ``avg_entry_price`` when no - snapshot exists yet. Called once at startup, before ``start()``. + ``token_id == position.token_id AND recorded_at >= opened_at`` and take + the max of the same depth-guarded mark the live tick uses — a dust bid + recorded in a snapshot must not seed a peak the position can never live + up to. Falls back to ``avg_entry_price`` when no snapshot exists yet. + Called once at startup, before ``start()``. """ if self._portfolio is None: return @@ -158,14 +228,10 @@ def bootstrap_peaks(self, session_factory: sessionmaker[Session]) -> None: ) peak = held.avg_entry_price for (bids_json,) in session.execute(stmt): - try: - bids = json.loads(bids_json) - except (TypeError, ValueError): - continue - if bids: - bid = float(bids[0][0]) - if bid > peak: - peak = bid + bids = _parse_levels(bids_json) + mark = _mark_from_levels(bids, self._min_mark_bid_size) + if mark is not None and mark > peak: + peak = mark self._peak[held.position_id] = peak logger.info("exit monitor: bootstrap_peaks loaded %d positions", len(self._peak)) @@ -181,25 +247,47 @@ async def start(self) -> None: self._task = asyncio.create_task(self._tick_loop()) async def stop(self) -> None: - if self._task is None: - self._state = "stopped" + if self._task is not None: + self._stop.set() + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + finally: + self._task = None + # Cancelling the loop does not cancel a sell already handed to a worker + # thread: it completes on-chain and in the DB regardless. Wait for its + # task so the exit_log entry and the peak cleanup that follow the await + # actually run. + await self._drain_inflight() + self._state = "stopped" + + async def _drain_inflight(self) -> None: + """Wait (bounded) for the in-flight sell task to finish.""" + task = self._inflight + self._inflight = None + if task is None or task.done(): return - self._stop.set() - self._task.cancel() try: - await self._task - except asyncio.CancelledError: - pass - finally: - self._task = None - self._state = "stopped" + await asyncio.wait_for(asyncio.shield(task), timeout=INFLIGHT_DRAIN_TIMEOUT_SECONDS) + except asyncio.TimeoutError: + logger.error( + "exit monitor: in-flight sell still running after %.0fs at shutdown — " + "its close may not be recorded", + INFLIGHT_DRAIN_TIMEOUT_SECONDS, + ) + except Exception: # noqa: BLE001 — shutdown must not raise on a failed sell + logger.exception("exit monitor: in-flight sell failed during shutdown") # ---------- loop ---------- async def _tick_loop(self) -> None: while not self._stop.is_set(): try: - self._tick_once() + await self._tick_once() + except asyncio.CancelledError: + raise except Exception: # noqa: BLE001 — the loop must survive any tick error logger.exception("exit monitor: tick failed") # Cooperative yield, then sleep the interval — waking early on stop. @@ -207,22 +295,74 @@ async def _tick_loop(self) -> None: with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for(self._stop.wait(), timeout=self._tick_interval) + # ---------- price observation ---------- + + def _mark(self, book: OrderBook) -> float | None: + """Depth-guarded mark for ``book`` (None when it cannot be marked).""" + return _mark_from_levels(book.bids, self._min_mark_bid_size) + + def observe_price(self, token_id: str, bid: float) -> None: + """Push hook — record a fresh mark for ``token_id`` into the peaks. + + The exit tick runs every ``DEFAULT_TICK_INTERVAL_SECONDS`` (120s) but + the order-book sampler refreshes far more often; without this hook a + run-up that happens and reverses between two ticks is invisible and the + trailing lock trails a peak that never existed. Only tokens held by a + position seen on the last sweep are tracked, so this stays a dict + lookup — a position opened between sweeps starts being observed after + the next tick, which is harmless (its peak seeds at that tick's mark). + """ + for position_id in self._watch.get(token_id, ()): + prev = self._peak.get(position_id) + if prev is None or bid > prev: + self._peak[position_id] = bid + + def _unwatch(self, token_id: str, position_id: int) -> None: + """Stop observing one position's token (called when it closes).""" + watchers = self._watch.get(token_id) + if watchers is None: + return + if position_id in watchers: + watchers.remove(position_id) + if not watchers: + self._watch.pop(token_id, None) + + def observe_book(self, book: OrderBook) -> None: + """``observe_price`` adapter for order-book deliveries. + + Wired to the market-source book sampler by the FastAPI lifespan. It + applies the same depth guard as the tick so peaks and marks come from + one price series. Note the limitation: the runtime has no push/WS book + feed, so "fresh" here means the sampler's poll interval (60s by + default) rather than every quote update. + """ + mark = self._mark(book) + if mark is not None: + self.observe_price(book.token_id, mark) + # ---------- tick ---------- - def _tick_once(self) -> None: - """One sweep — evaluate every open position. Sync; tests drive it - directly. Records tick telemetry (open / blocked counts + timestamp); - within-threshold + no-order-book holds no longer write a log entry — - only ok / error closes land in exit_log.""" + async def _tick_once(self) -> None: + """One sweep — evaluate every open position. Records tick telemetry + (open / blocked counts + timestamp); within-threshold + unmarkable + holds no longer write a log entry — only ok / error closes land in + exit_log.""" if self._portfolio is None: return ts = time.time() catalog = market_source_manager.store opens = self._portfolio.get_open_positions() + watch: dict[str, list[int]] = {} + for held in opens: + watch.setdefault(held.token_id, []).append(held.position_id) + self._watch = watch + # Drop the unmarkable marker for anything no longer open, so the set + # can't grow across the process lifetime. + self._unmarkable &= {held.position_id for held in opens} blocked = 0 for held in opens: try: - if self._evaluate(held, catalog, ts): + if await self._evaluate(held, catalog, ts): blocked += 1 except Exception as exc: # noqa: BLE001 — one bad position must not abort the sweep logger.exception("exit monitor: position %d failed", held.position_id) @@ -231,17 +371,29 @@ def _tick_once(self) -> None: self._last_tick_open = len(opens) self._last_tick_blocked = blocked - def _evaluate(self, held: HeldPosition, catalog: MarketStore, ts: float) -> bool: + async def _evaluate(self, held: HeldPosition, catalog: MarketStore, ts: float) -> bool: """Evaluate one position. Returns True when it could not be evaluated - (no order book — counted as ``blocked``); False when held within - thresholds or closed. ok / error closes are logged; within-threshold - and no-order-book holds are not (see tick telemetry).""" + (no order book, or no level deep enough to mark against — counted as + ``blocked``); False when held within thresholds or closed. ok / error + closes are logged; within-threshold and unmarkable holds are not (see + tick telemetry).""" book = catalog.get_order_book(held.token_id) if book is None or not book.bids: return True - current_price = book.bids[0][0] + current_price = self._mark(book) + if current_price is None: + # The book has bids but none deep enough to sell into: the position + # is unevaluable, not cheap. Log the first tick of each occurrence + # (see ``_unmarkable``) so it doesn't fail silently. + if held.position_id not in self._unmarkable: + self._unmarkable.add(held.position_id) + self._log(held, ts, verdict="skip", reason="no_executable_bid") + return True + self._unmarkable.discard(held.position_id) + spread = book.asks[0][0] - book.bids[0][0] if book.asks else None # Monotone-increasing per-position peak. New open positions seed at - # current_price; bootstrap_peaks may have seeded a higher one already. + # current_price; bootstrap_peaks / observe_price may have seeded a + # higher one already. prev_peak = self._peak.get(held.position_id, current_price) peak_price = max(prev_peak, current_price) self._peak[held.position_id] = peak_price @@ -253,6 +405,10 @@ def _evaluate(self, held: HeldPosition, catalog: MarketStore, ts: float) -> bool qty=held.qty, current_price=current_price, peak_price=peak_price, + spread=spread, + # Polymarket exposes no per-market tick size through Gamma or the + # book endpoint, so the section falls back to its configured tick. + tick_size=None, ) out = self._exit.run(SectionInput(tick_type="hard", payload=marked)) return_pct = out.signals.get("return_pct") @@ -263,24 +419,86 @@ def _evaluate(self, held: HeldPosition, catalog: MarketStore, ts: float) -> bool return False intent = out.payload - result = self._executor.execute_sell( - held, close_reason=intent.trigger, ts=ts, trigger=intent.trigger - ) + # The sweep's open-position list was read once, at the top of the tick, + # and every sell since then handed the loop back for seconds. In that + # window the settlement or reconciliation monitor can have closed *this* + # position. Selling it from the stale snapshot means an execute_sell + # against an already-closed row: a spurious ``error`` entry on paper, a + # real on-chain sell that can never be persisted on live. Re-read the + # row and skip it instead. There is deliberately no ``await`` between + # this check and the claim below, so nothing can close it in between. + if not self._still_open(held.position_id): + self._log(held, ts, verdict="skip", reason="position_no_longer_open") + return False + # Claim the position before yielding the loop: from here until the sell + # has been persisted, the settlement and reconciliation monitors must + # not close this id underneath us (see closing_registry). + mark_closing(held.position_id) + task = asyncio.create_task(self._close(held, intent.trigger, ts, return_pct, peak_price)) + self._inflight = task + try: + # Shielded: if the tick loop is cancelled mid-sell, this await is + # cancelled but the task keeps running and stop() drains it. The + # sell itself is already uncancellable — only its bookkeeping was + # at risk. + await asyncio.shield(task) + finally: + if task.done(): + self._inflight = None + return False + + def _still_open(self, position_id: int) -> bool: + """Fresh read of one position's status — synchronous by design, so the + caller can claim the position without yielding the loop in between.""" + portfolio = self._portfolio + if portfolio is None: + return False + record = portfolio.get_position(position_id) + return record is not None and record.status == "open" + + async def _close( + self, + held: HeldPosition, + trigger: str, + ts: float, + return_pct: float | None, + peak_price: float, + ) -> None: + """Sell one position and record the outcome. + + Runs as its own task so a cancelled tick loop cannot strand the + bookkeeping; clears the in-flight claim on every exit path. + """ + try: + # execute_sell blocks for seconds on the live path — keep the event + # loop free (same offload the orchestrator does for its sections). + result = await asyncio.to_thread( + self._executor.execute_sell, + held, + close_reason=trigger, + ts=ts, + trigger=trigger, + ) + finally: + clear_closing(held.position_id) if result.filled and result.price is not None: realized = (result.price - held.avg_entry_price) * held.qty # Position is closed; drop its peak so a future re-entry on the - # same position_id (shouldn't happen, but be safe) starts fresh. + # same position_id (shouldn't happen, but be safe) starts fresh, + # and stop observing its token so a book delivered before the next + # sweep cannot resurrect the entry. self._peak.pop(held.position_id, None) + self._unwatch(held.token_id, held.position_id) self._log( held, ts, verdict="ok", - trigger=intent.trigger, + trigger=trigger, return_pct=return_pct, peak_price=peak_price, fill_price=result.price, realized_pnl=realized, - reason=intent.trigger, + reason=trigger, ) else: # The section decided to close but the fill did not land — a @@ -289,12 +507,11 @@ def _evaluate(self, held: HeldPosition, catalog: MarketStore, ts: float) -> bool held, ts, verdict="error", - trigger=intent.trigger, + trigger=trigger, return_pct=return_pct, peak_price=peak_price, error=f"sell not filled: {result.skip_reason}", ) - return False def _log( self, @@ -328,6 +545,24 @@ def _log( ) +def _parse_levels(raw: str | None) -> list[tuple[float, float]]: + """Parse a persisted ``bids_json`` / ``asks_json`` ladder. Malformed rows + yield an empty ladder rather than aborting the bootstrap scan.""" + try: + levels = json.loads(raw) # type: ignore[arg-type] + except (TypeError, ValueError): + return [] + if not isinstance(levels, list): + return [] + out: list[tuple[float, float]] = [] + for level in levels: + try: + out.append((float(level[0]), float(level[1]))) + except (TypeError, ValueError, IndexError, KeyError): + continue + return out + + # Module-level singleton — the FastAPI lifespan injects its PortfolioStore via # configure() and start()s it. Shares the one executor with the orchestrator. exit_monitor = ExitMonitor( diff --git a/openpoly/runtime/orchestrator.py b/openpoly/runtime/orchestrator.py index 6ddc889..b0863ee 100644 --- a/openpoly/runtime/orchestrator.py +++ b/openpoly/runtime/orchestrator.py @@ -286,9 +286,9 @@ async def _run_analyzer( async def _run_entry(self, item: NewsItem, ar: AnalysisResult) -> None: """Stage 3 — entry decision + execution. - The entry section may do a blocking HTTP fetch (the late-buy veto), so - its ``run()`` is offloaded to a worker thread; the executor's DB write - stays inline (sub-millisecond — see PF5 risk notes). + The entry section may do a blocking HTTP fetch (the late-buy veto) and + the live executor blocks on network + sleeps, so both are offloaded to + worker threads; only the log append stays inline. """ ts = time.time() start = time.monotonic() @@ -318,7 +318,14 @@ async def _run_entry(self, item: NewsItem, ar: AnalysisResult) -> None: position_id: int | None = None if intent is not None: try: - result = self._executor.execute_buy(intent, news_id=item.id, ts=ts) + # The live executor sleeps for seconds inside execute_buy + # (balance-allowance refresh, CTF polling after a lost + # response), so it is offloaded like the section calls above — + # the event loop must stay free for the WS reconnect / market + # poll tasks. + result = await asyncio.to_thread( + self._executor.execute_buy, intent, news_id=item.id, ts=ts + ) if result.filled: fill_status = "filled" fill_price = result.price diff --git a/openpoly/runtime/reconciliation_monitor.py b/openpoly/runtime/reconciliation_monitor.py index acddc50..a816e8e 100644 --- a/openpoly/runtime/reconciliation_monitor.py +++ b/openpoly/runtime/reconciliation_monitor.py @@ -37,6 +37,7 @@ from typing import Awaitable, Callable, Literal from openpoly.portfolio import PortfolioStore +from openpoly.runtime.closing_registry import is_closing from openpoly.runtime.section_log import SettlementDecision, settlement_log logger = logging.getLogger(__name__) @@ -177,6 +178,12 @@ async def _tick_once(self) -> None: continue if (pos.condition_id, pos.side) in held: continue + if is_closing(pos.position_id): + # The exit monitor is mid-sell on this id: the wallet can + # already read flat while its fill is still being persisted. + # Closing it here would destroy that fill — reconsider next + # tick, when the sell has landed one way or the other. + continue # Flat on-chain but open in the DB → exited outside the ledger. try: self._portfolio.close_position( diff --git a/openpoly/runtime/settlement_monitor.py b/openpoly/runtime/settlement_monitor.py index 385ff14..f0874cb 100644 --- a/openpoly/runtime/settlement_monitor.py +++ b/openpoly/runtime/settlement_monitor.py @@ -29,6 +29,7 @@ from openpoly.markets.models import normalize_gamma_market from openpoly.markets.polymarket_api import fetch_markets_by_condition_id from openpoly.portfolio import HeldPosition, PortfolioStore +from openpoly.runtime.closing_registry import is_closing from openpoly.runtime.section_log import SettlementDecision, settlement_log logger = logging.getLogger(__name__) @@ -213,6 +214,12 @@ def _process_market(self, raw: dict, held_positions: list[HeldPosition]) -> None return for held in held_positions: + if is_closing(held.position_id): + # The exit monitor has an on-chain sell in flight for this + # position; settling it now would make that fill unpersistable. + # Skip one tick — settlement is not latency-sensitive. + self._log(held, ts, verdict="skip", reason="exit_in_flight") + continue final_price = _settlement_price_for_side(market.outcome_prices, held.side) if final_price is None: self._log( diff --git a/openpoly/sections/exit/threshold_v0.py b/openpoly/sections/exit/threshold_v0.py index b07182a..5e75318 100644 --- a/openpoly/sections/exit/threshold_v0.py +++ b/openpoly/sections/exit/threshold_v0.py @@ -11,9 +11,30 @@ position before each call. Peak tracking itself lives in ``ExitMonitor`` — the section never holds state across ticks. -Trigger precedence is ``stop_loss → peak_drawdown → take_profit`` (a prior project's -v6 ordering): the absolute-loss circuit fires first, then the trailing lock -on banked gains, with the absolute take-profit ceiling as the final fallback. +Trigger precedence is ``stop_loss → take_profit → peak_drawdown``: the +absolute-loss circuit fires first, then the absolute take-profit ceiling, and +the trailing lock on banked gains last. + +Two properties of the trailing lock are worth stating explicitly, because the +naive form of the rule gives the whole edge back (v0.3.0): + +* The retrace is compared against an *absolute price distance*, not against a + fraction of ``peak - entry``. ``peak_drawdown_pct × (peak - entry)`` shrinks + to nothing right after the position arms — on a $10 position it can land + below one Polymarket tick (0.01), which closes every winner on its first + downtick. The effective distance is therefore + ``max(min_trail_ticks × tick_size, spread, peak_drawdown_pct × (peak - entry))``: + never tighter than a couple of ticks, never tighter than the book's own + spread, and widening with the size of the move. +* It arms late. ``peak_meaningful_floor_pct`` defaults to 30% of cost basis so + the lock only engages on a move large enough that giving part of it back is + a real loss of banked profit, rather than noise around entry. + +Because the lock arms at +30%, ``take_profit_enabled`` ships **off**: a +20% +ceiling would close every winner before the lock could ever engage, leaving the +trailing behaviour dead code. The shipped trade-off is explicit — between entry +and +30% a position is protected by the stop-loss alone; above +30% the +trailing lock takes over and take-profit is an opt-in cap. """ from __future__ import annotations @@ -37,6 +58,11 @@ class MarkedPosition: (Polymarket token price in 0..1), so return math is side-agnostic. The monitor injects ``peak_price`` from its per-position max — see ``ExitMonitor`` — and the section uses it for peak-drawdown only. + + ``spread`` and ``tick_size`` are optional book context added in v0.3.0 for + the trailing-distance floor. Both default to ``None`` so any caller written + against the older five-field shape keeps working; the section falls back to + ``ThresholdExitConfig.tick_size`` and a zero spread when they are absent. """ market_id: str @@ -45,6 +71,8 @@ class MarkedPosition: qty: float current_price: float peak_price: float + spread: float | None = None # best_ask - best_bid at mark time + tick_size: float | None = None # venue tick, when the book exposes one @dataclass(frozen=True) @@ -64,7 +92,20 @@ class ThresholdExitConfig(BaseModel): default=0.20, ge=0.0, le=10.0, - description="Close the position when its return reaches this fraction (0.20 = +20%).", + description=( + "Take-profit ceiling, as a fraction of entry (0.20 = +20%). It caps every " + "winner at this return, so it is OFF by default (see take_profit_enabled) " + "and only applies once you switch it on: at +20% the trailing lock has not " + "armed yet, so leaving it on means no position ever reaches the lock." + ), + ) + take_profit_enabled: bool = Field( + default=False, + description=( + "Whether the take-profit ceiling is active. Off by default: the trailing " + "peak-drawdown lock is the primary exit for winners, with the stop-loss " + "underneath. Turn it on to cap every winner at take_profit_pct instead." + ), ) stop_loss_pct: float = Field( default=0.15, @@ -76,7 +117,31 @@ class ThresholdExitConfig(BaseModel): default=0.12, ge=0.0, le=1.0, - description="Close when the gain has retraced this fraction from peak (0.12 = 12%).", + description=( + "Trailing lock: close when the price has retraced this fraction of the " + "banked gain (peak - entry) from the peak. Only ever widens the trailing " + "distance — the min_trail_ticks and spread floors below set its minimum." + ), + ) + min_trail_ticks: int = Field( + default=2, + ge=0, + le=100, + description=( + "Floor on the trailing distance, in price ticks. A percentage-only trail is " + "tightest right after the position arms, where it can fall below a single " + "tick and close on ordinary quote noise; two ticks is the smallest distance " + "a real move can be distinguished from that noise." + ), + ) + tick_size: float = Field( + default=0.01, + gt=0.0, + le=1.0, + description=( + "Price tick used for the min_trail_ticks floor (Polymarket CLOB is 0.01). " + "A tick size carried on the marked position overrides this." + ), ) peak_meaningful_floor_usd: float = Field( default=1.0, @@ -84,22 +149,36 @@ class ThresholdExitConfig(BaseModel): description="Skip peak_drawdown unless the peak gain in USD exceeds this floor.", ) peak_meaningful_floor_pct: float = Field( - default=0.01, + default=0.30, ge=0.0, le=1.0, - description="Skip peak_drawdown unless the peak gain exceeds this fraction of cost basis.", + description=( + "Skip peak_drawdown unless the peak gain exceeds this fraction of cost basis. " + "Defaults to 30%: at grain-scale stakes the USD floor alone arms the trailing " + "lock after a ~+10% move, where a retrace is noise rather than given-back " + "profit. Arming at +30% means the lock only ever protects a real gain." + ), ) class ThresholdExitV0: SECTION_TYPE = "exit" - SECTION_VERSION = "0.2.0" + SECTION_VERSION = "0.3.0" REQUIRES = ["market_data", "portfolio"] Config = ThresholdExitConfig def __init__(self, config: ThresholdExitConfig) -> None: self.config = config + def _trail_distance(self, pos: MarkedPosition) -> float: + """Effective trailing distance in price. The percentage trail is a + floor-of-last-resort: the tick floor and the live spread both override + it while the banked gain is still small.""" + tick = pos.tick_size if pos.tick_size and pos.tick_size > 0 else self.config.tick_size + spread = pos.spread if pos.spread is not None and pos.spread > 0 else 0.0 + pct_trail = self.config.peak_drawdown_pct * (pos.peak_price - pos.avg_entry_price) + return max(self.config.min_trail_ticks * tick, spread, pct_trail) + def run(self, input: SectionInput) -> SectionOutput: pos = input.payload if not isinstance(pos, MarkedPosition): @@ -116,18 +195,20 @@ def run(self, input: SectionInput) -> SectionOutput: self.config.peak_meaningful_floor_pct * cost_basis, ) peak_meaningful = pos.peak_price > pos.avg_entry_price and peak_gain_usd >= floor + retrace = pos.peak_price - pos.current_price + trail_distance = self._trail_distance(pos) if peak_meaningful: - peak_dd = (pos.peak_price - pos.current_price) / (pos.peak_price - pos.avg_entry_price) + peak_dd = retrace / (pos.peak_price - pos.avg_entry_price) else: peak_dd = 0.0 trigger: Trigger | None if return_pct <= -self.config.stop_loss_pct: trigger = "stop_loss" - elif peak_meaningful and peak_dd >= self.config.peak_drawdown_pct: - trigger = "peak_drawdown" - elif return_pct >= self.config.take_profit_pct: + elif self.config.take_profit_enabled and return_pct >= self.config.take_profit_pct: trigger = "take_profit" + elif peak_meaningful and retrace >= trail_distance: + trigger = "peak_drawdown" else: trigger = None @@ -136,6 +217,7 @@ def run(self, input: SectionInput) -> SectionOutput: "peak_price": round(pos.peak_price, 4), "peak_dd": round(peak_dd, 4) if peak_meaningful else None, "peak_meaningful": peak_meaningful, + "trail_distance": round(trail_distance, 4), } if trigger is None: @@ -179,6 +261,9 @@ def CONTRACT_TEST() -> None: out_hold = inst.run(SectionInput(tick_type="hard", payload=hold)) assert out_hold.verdict == "skip" + # take_profit is opt-in (off by default), so the ceiling gets its own + # instance here. + capped = ThresholdExitV0(ThresholdExitConfig(take_profit_enabled=True)) win = MarkedPosition( market_id="m1", side="yes", @@ -187,7 +272,7 @@ def CONTRACT_TEST() -> None: current_price=0.65, peak_price=0.65, ) - out_tp = inst.run(SectionInput(tick_type="hard", payload=win)) + out_tp = capped.run(SectionInput(tick_type="hard", payload=win)) assert out_tp.verdict == "ok" assert isinstance(out_tp.payload, CloseIntent) assert out_tp.payload.trigger == "take_profit" @@ -204,17 +289,31 @@ def CONTRACT_TEST() -> None: assert out_sl.verdict == "ok" assert out_sl.payload.trigger == "stop_loss" - # Peak drawdown: ran up to 0.62 (+24% peak gain $2.40 ≥ floor), - # now back to 0.58 → retrace 4/12 = 33% > 12% threshold; not yet - # at take_profit (+16% < 20%). + # Peak drawdown: ran up to 0.70 (peak gain $4.00 ≥ the $3.00 arming + # floor), now back to 0.65 → retrace 0.05 ≥ the trailing distance + # max(2 × 0.01, 0.12 × 0.20) = 0.024. The default config already has + # take_profit off, so the +30% return doesn't take precedence. + trailing = inst retrace = MarkedPosition( market_id="m1", side="yes", avg_entry_price=0.50, qty=20.0, - current_price=0.58, - peak_price=0.62, + current_price=0.65, + peak_price=0.70, ) - out_pd = inst.run(SectionInput(tick_type="hard", payload=retrace)) + out_pd = trailing.run(SectionInput(tick_type="hard", payload=retrace)) assert out_pd.verdict == "ok" assert out_pd.payload.trigger == "peak_drawdown" + + # A single tick down from the same peak must NOT close — this is the + # regression the trailing floor exists to prevent. + one_tick = MarkedPosition( + market_id="m1", + side="yes", + avg_entry_price=0.50, + qty=20.0, + current_price=0.69, + peak_price=0.70, + ) + assert trailing.run(SectionInput(tick_type="hard", payload=one_tick)).verdict == "skip" diff --git a/tests/conftest.py b/tests/conftest.py index d2d58b4..7dbea93 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -37,3 +37,16 @@ def _reset_orchestrator() -> Iterator[None]: _reset_singleton_for_tests() yield _reset_singleton_for_tests() + + +@pytest.fixture(autouse=True) +def _reset_closing_registry() -> Iterator[None]: + """Clear the process-global in-flight close registry before every test. + + An id left behind by one test makes the settlement and reconciliation + monitors skip that position in every later test of the session. + """ + from openpoly.runtime.closing_registry import reset_for_tests + + reset_for_tests() + yield diff --git a/tests/test_closing_registry.py b/tests/test_closing_registry.py new file mode 100644 index 0000000..85f6fec --- /dev/null +++ b/tests/test_closing_registry.py @@ -0,0 +1,27 @@ +"""Tests for the in-flight close registry. + +The registry is a process-global set. An id left behind by one test makes the +settlement and reconciliation monitors silently skip that position in every +later test in the session, so it must be resettable — and the reset has to run +automatically, not by remembering to call it. +""" + +from __future__ import annotations + +from openpoly.runtime.closing_registry import closing_ids, mark_closing, reset_for_tests + + +def test_reset_for_tests_clears_every_registered_id() -> None: + mark_closing(1) + mark_closing(2) + reset_for_tests() + assert closing_ids() == frozenset() + + +def test_a_leaked_id_does_not_reach_the_next_test() -> None: + assert closing_ids() == frozenset() + mark_closing(99) # deliberately leaked — the next test proves it is cleared + + +def test_the_registry_starts_empty() -> None: + assert closing_ids() == frozenset() diff --git a/tests/test_exit_monitor.py b/tests/test_exit_monitor.py index a8a3a50..a3194a5 100644 --- a/tests/test_exit_monitor.py +++ b/tests/test_exit_monitor.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio +import contextlib import json import pytest @@ -19,7 +20,10 @@ from openpoly.markets.models import OrderBook from openpoly.markets.store import MarketStore from openpoly.portfolio import HeldPosition, PortfolioStore +from openpoly.portfolio.models import PositionRecord +from openpoly.runtime.closing_registry import is_closing from openpoly.runtime.exit_monitor import ExitMonitor +from openpoly.runtime.reconciliation_monitor import ReconciliationMonitor from openpoly.runtime.section_log import exit_log from openpoly.sections.exit.threshold_v0 import ( ThresholdExitConfig, @@ -74,6 +78,27 @@ def __init__(self, positions: list[HeldPosition]) -> None: def get_open_positions(self) -> list[HeldPosition]: return list(self._positions) + def get_position(self, position_id: int) -> PositionRecord | None: + """The monitor re-reads a position's status before claiming it for + sale — every position handed to this fake stays open.""" + for p in self._positions: + if p.position_id == position_id: + return PositionRecord( + id=p.position_id, + market_id=p.market_id, + side=p.side, + token_id=p.token_id, + condition_id=p.condition_id, + qty=p.qty, + avg_entry_price=p.avg_entry_price, + status="open", + opened_at=p.opened_at, + closed_at=None, + close_reason=None, + realized_pnl=None, + ) + return None + class _FakeExecutor: """Records execute_sell calls; returns a canned ExecResult or raises.""" @@ -111,8 +136,11 @@ def execute_sell( def _monitor(portfolio: _FakePortfolio, executor: _FakeExecutor) -> ExitMonitor: + # take_profit ships off (the trailing lock is the primary exit path), but + # these tests drive the monitor's own plumbing — mark → run → route → log — + # through a take-profit close, so they opt the ceiling back in explicitly. m = ExitMonitor( - exit_section=ThresholdExitV0(ThresholdExitConfig()), + exit_section=ThresholdExitV0(ThresholdExitConfig(take_profit_enabled=True)), executor=executor, tick_interval_seconds=3600, ) @@ -123,10 +151,10 @@ def _monitor(portfolio: _FakePortfolio, executor: _FakeExecutor) -> ExitMonitor: # ---------- close paths ---------- -def test_take_profit_triggers_execute_sell() -> None: +async def test_take_profit_triggers_execute_sell() -> None: market_source_manager.store.set_order_books([_book("t1", bid=0.55)]) ex = _FakeExecutor() # default result: ExecResult.ok(price=0.55) - _monitor(_FakePortfolio([_held(1, "t1", avg=0.40)]), ex)._tick_once() + await _monitor(_FakePortfolio([_held(1, "t1", avg=0.40)]), ex)._tick_once() # (0.55 - 0.40) / 0.40 = 0.375 ≥ 0.20 → take_profit assert ex.calls == [ { @@ -142,22 +170,22 @@ def test_take_profit_triggers_execute_sell() -> None: assert e.realized_pnl == pytest.approx((0.55 - 0.40) * 20.0) -def test_stop_loss_triggers_execute_sell() -> None: +async def test_stop_loss_triggers_execute_sell() -> None: market_source_manager.store.set_order_books([_book("t1", bid=0.30)]) ex = _FakeExecutor(result=ExecResult.ok(price=0.30, qty=20.0, position_id=1)) - _monitor(_FakePortfolio([_held(1, "t1", avg=0.40)]), ex)._tick_once() + await _monitor(_FakePortfolio([_held(1, "t1", avg=0.40)]), ex)._tick_once() # (0.30 - 0.40) / 0.40 = -0.25 ≤ -0.15 → stop_loss assert ex.calls[0]["close_reason"] == "stop_loss" assert exit_log.entries()[0].trigger == "stop_loss" -def test_within_thresholds_holds() -> None: +async def test_within_thresholds_holds() -> None: # v18: a within-threshold hold writes NO log entry (the ring keeps only # ok / error closes); tick telemetry records the position was evaluated. market_source_manager.store.set_order_books([_book("t1", bid=0.41)]) ex = _FakeExecutor() m = _monitor(_FakePortfolio([_held(1, "t1", avg=0.40)]), ex) - m._tick_once() + await m._tick_once() assert ex.calls == [] # nothing closed assert exit_log.entries() == [] # no skip entry assert m.open_positions == 1 @@ -165,37 +193,37 @@ def test_within_thresholds_holds() -> None: assert m.last_tick_at is not None -def test_no_order_book_blocked() -> None: +async def test_no_order_book_blocked() -> None: # v18: no order book → can't evaluate → counted as blocked, no log entry. ex = _FakeExecutor() m = _monitor(_FakePortfolio([_held(1, "t-missing")]), ex) - m._tick_once() + await m._tick_once() assert ex.calls == [] assert exit_log.entries() == [] assert m.open_positions == 1 assert m.blocked == 1 -def test_empty_bids_blocked() -> None: +async def test_empty_bids_blocked() -> None: market_source_manager.store.set_order_books( [OrderBook(token_id="t1", ts=1.0, bids=[], asks=[(0.5, 100.0)])] ) ex = _FakeExecutor() m = _monitor(_FakePortfolio([_held(1, "t1")]), ex) - m._tick_once() + await m._tick_once() assert ex.calls == [] assert exit_log.entries() == [] assert m.blocked == 1 -def test_tick_telemetry_open_and_blocked() -> None: +async def test_tick_telemetry_open_and_blocked() -> None: # One evaluable (held within thresholds) + one blocked (no order book) → # open=2, blocked=1, and still zero log entries. market_source_manager.store.set_order_books([_book("t1", bid=0.41)]) ex = _FakeExecutor() positions = [_held(1, "t1", avg=0.40), _held(2, "t-missing", market_id="m2")] m = _monitor(_FakePortfolio(positions), ex) - m._tick_once() + await m._tick_once() assert m.open_positions == 2 assert m.blocked == 1 assert exit_log.entries() == [] @@ -205,14 +233,14 @@ def test_tick_telemetry_open_and_blocked() -> None: # ---------- error handling ---------- -def test_execute_sell_raises_logged_as_error_sweep_continues() -> None: +async def test_execute_sell_raises_logged_as_error_sweep_continues() -> None: market_source_manager.store.set_order_books([_book("t1", bid=0.55), _book("t2", bid=0.55)]) ex = _FakeExecutor(exc=ValueError("position already closed")) positions = [ _held(1, "t1", avg=0.40), _held(2, "t2", avg=0.40, market_id="m2"), ] - _monitor(_FakePortfolio(positions), ex)._tick_once() + await _monitor(_FakePortfolio(positions), ex)._tick_once() # Both TP-trigger → execute_sell raises on both → both logged error; # the first error did not abort the sweep. entries = exit_log.entries() @@ -220,10 +248,10 @@ def test_execute_sell_raises_logged_as_error_sweep_continues() -> None: assert {e.position_id for e in entries} == {1, 2} -def test_execute_sell_not_filled_logged_as_error() -> None: +async def test_execute_sell_not_filled_logged_as_error() -> None: market_source_manager.store.set_order_books([_book("t1", bid=0.55)]) ex = _FakeExecutor(result=ExecResult.skip("no_bid_liquidity")) - _monitor(_FakePortfolio([_held(1, "t1", avg=0.40)]), ex)._tick_once() + await _monitor(_FakePortfolio([_held(1, "t1", avg=0.40)]), ex)._tick_once() e = exit_log.entries()[0] assert e.verdict == "error" assert e.error is not None @@ -233,19 +261,19 @@ def test_execute_sell_not_filled_logged_as_error() -> None: # ---------- no-op paths ---------- -def test_empty_positions_noop() -> None: +async def test_empty_positions_noop() -> None: ex = _FakeExecutor() - _monitor(_FakePortfolio([]), ex)._tick_once() + await _monitor(_FakePortfolio([]), ex)._tick_once() assert exit_log.entries() == [] assert ex.calls == [] -def test_not_configured_noop() -> None: +async def test_not_configured_noop() -> None: m = ExitMonitor( exit_section=ThresholdExitV0(ThresholdExitConfig()), executor=_FakeExecutor(), ) - m._tick_once() # no configure() — portfolio is None + await m._tick_once() # no configure() — portfolio is None assert exit_log.entries() == [] @@ -271,9 +299,10 @@ async def test_stop_before_start_is_safe() -> None: # ---------- peak tracking ---------- -def test_peak_persists_across_ticks_and_triggers_drawdown() -> None: +async def test_peak_persists_across_ticks_and_triggers_drawdown() -> None: # TP set very high so peak_drawdown is the *only* close trigger that can - # fire over the price path 0.46 → 0.50 → 0.47 with entry 0.40. + # fire over the price path 0.46 → 0.56 → 0.50 with entry 0.40. The peak + # has to reach 0.52 to arm the trailing lock (30% of the $8 cost basis). monitor = ExitMonitor( exit_section=ThresholdExitV0( ThresholdExitConfig( @@ -292,40 +321,41 @@ def test_peak_persists_across_ticks_and_triggers_drawdown() -> None: # Tick 1: bid 0.46 → +15%, no trigger; peak seeded at 0.46. # v18: a hold writes no log entry — assert via the peak instead. store.set_order_books([_book("t1", bid=0.46)]) - monitor._tick_once() + await monitor._tick_once() assert monitor._peak[1] == pytest.approx(0.46) assert exit_log.entries() == [] - # Tick 2: bid climbs to 0.50; peak follows. - store.set_order_books([_book("t1", bid=0.50)]) - monitor._tick_once() - assert monitor._peak[1] == pytest.approx(0.50) + # Tick 2: bid climbs to 0.56; peak follows and the lock arms + # (peak gain 0.16 × 20 = $3.20 ≥ the $2.40 floor). + store.set_order_books([_book("t1", bid=0.56)]) + await monitor._tick_once() + assert monitor._peak[1] == pytest.approx(0.56) assert exit_log.entries() == [] - # Tick 3: bid retreats to 0.47; peak stays at 0.50. - # peak_dd = (0.50 - 0.47) / (0.50 - 0.40) = 0.30 ≥ 0.12 → close. - store.set_order_books([_book("t1", bid=0.47)]) - monitor._tick_once() + # Tick 3: bid retreats to 0.50; peak stays at 0.56. Retrace 0.06 clears the + # trailing distance max(2 ticks, 0.02 spread, 0.12 × 0.16) = 0.02 → close. + store.set_order_books([_book("t1", bid=0.50)]) + await monitor._tick_once() last = exit_log.entries()[-1] assert last.verdict == "ok" assert last.trigger == "peak_drawdown" - assert last.peak_price == pytest.approx(0.50) + assert last.peak_price == pytest.approx(0.56) # On a successful close the peak entry is dropped. assert 1 not in monitor._peak -def test_peak_tracked_on_hold() -> None: +async def test_peak_tracked_on_hold() -> None: # v18: held within thresholds writes no entry, but the peak is still # tracked in-memory for the drawdown trigger. market_source_manager.store.set_order_books([_book("t1", bid=0.41)]) ex = _FakeExecutor() m = _monitor(_FakePortfolio([_held(1, "t1", avg=0.40)]), ex) - m._tick_once() + await m._tick_once() assert exit_log.entries() == [] assert m._peak[1] == pytest.approx(0.41) -def test_bootstrap_peaks_rebuilds_from_snapshots(tmp_path) -> None: +async def test_bootstrap_peaks_rebuilds_from_snapshots(tmp_path) -> None: db_path = tmp_path / "openpoly_peak.db" engine = make_engine(f"sqlite:///{db_path}") init_db(engine) @@ -386,7 +416,7 @@ def test_bootstrap_peaks_rebuilds_from_snapshots(tmp_path) -> None: assert monitor._peak[held.position_id] == pytest.approx(0.55) -def test_bootstrap_peaks_no_snapshot_falls_back_to_entry(tmp_path) -> None: +async def test_bootstrap_peaks_no_snapshot_falls_back_to_entry(tmp_path) -> None: db_path = tmp_path / "openpoly_peak2.db" engine = make_engine(f"sqlite:///{db_path}") init_db(engine) @@ -413,3 +443,539 @@ def test_bootstrap_peaks_no_snapshot_falls_back_to_entry(tmp_path) -> None: monitor.bootstrap_peaks(sf) # No snapshots after opened_at → peak defaults to avg_entry_price (0.40). assert monitor._peak[held.position_id] == pytest.approx(0.40) + + +# ---------- mark sanity (depth-guarded bid) ---------- + + +async def test_thin_l1_bid_blocks_the_position_and_never_marks_at_the_mid() -> None: + # A 1-share resting bid at 0.40 is a dust order, not a price — and the mid + # is not a price the position can be sold at either: both executors sell + # into the raw level-1 bid. Entry 0.45 with a 0.40 dust bid and a far 0.72 + # ask has a 0.56 mid, i.e. +24% — marking there would fire take_profit and + # sell at 0.40, a loss. The position is blocked instead. + market_source_manager.store.set_order_books( + [OrderBook(token_id="t1", ts=1.0, bids=[(0.40, 1.0)], asks=[(0.72, 50.0)])] + ) + ex = _FakeExecutor() + m = _monitor(_FakePortfolio([_held(1, "t1", avg=0.45)]), ex) + await m._tick_once() + assert ex.calls == [] + assert m.blocked == 1 + assert 1 not in m._peak + + +async def test_unmarkable_position_is_logged_once_until_the_state_changes() -> None: + # A blocked position is invisible in the tick counters alone (they only + # carry the last sweep), so the first tick that cannot mark it writes one + # exit_log row. Repeat ticks in the same state stay silent — the ring must + # not evict the ok / error closes. + thin = OrderBook(token_id="t1", ts=1.0, bids=[(0.40, 1.0)], asks=[(0.72, 50.0)]) + market_source_manager.store.set_order_books([thin]) + ex = _FakeExecutor() + m = _monitor(_FakePortfolio([_held(1, "t1", avg=0.45)]), ex) + + await m._tick_once() + entries = exit_log.entries() + assert len(entries) == 1 + assert entries[0].verdict == "skip" + assert entries[0].reason == "no_executable_bid" + assert entries[0].position_id == 1 + + await m._tick_once() + assert len(exit_log.entries()) == 1 # same state → no second row + + # The book recovers, the position is marked again, then goes thin once + # more: that is a new occurrence and is logged again. + market_source_manager.store.set_order_books([_book("t1", bid=0.46)]) + await m._tick_once() + assert len(exit_log.entries()) == 1 + market_source_manager.store.set_order_books([thin]) + await m._tick_once() + assert len(exit_log.entries()) == 2 + assert exit_log.entries()[-1].reason == "no_executable_bid" + + +async def test_mark_walks_to_first_bid_level_meeting_min_size() -> None: + # L1 is a 1-share probe; the first level with real depth is 0.54, which is + # where the position could actually be sold. + market_source_manager.store.set_order_books( + [ + OrderBook( + token_id="t1", + ts=1.0, + bids=[(0.55, 1.0), (0.54, 50.0)], + asks=[(0.60, 100.0)], + ) + ] + ) + ex = _FakeExecutor() + m = _monitor(_FakePortfolio([_held(1, "t1", avg=0.50)]), ex) + await m._tick_once() + assert ex.calls == [] # +8% on a 0.50 entry — held + assert m._peak[1] == pytest.approx(0.54) + + +async def test_deep_l1_bid_is_used_as_is() -> None: + market_source_manager.store.set_order_books([_book("t1", bid=0.41)]) + ex = _FakeExecutor() + m = _monitor(_FakePortfolio([_held(1, "t1", avg=0.40)]), ex) + await m._tick_once() + assert m._peak[1] == pytest.approx(0.41) + + +async def test_thin_bid_with_no_ask_counts_as_blocked() -> None: + # No depth-qualified bid → the position cannot be marked at a price it + # could actually be sold at, so it is reported blocked rather than closed. + market_source_manager.store.set_order_books( + [OrderBook(token_id="t1", ts=1.0, bids=[(0.40, 1.0)], asks=[])] + ) + ex = _FakeExecutor() + m = _monitor(_FakePortfolio([_held(1, "t1", avg=0.50)]), ex) + await m._tick_once() + assert ex.calls == [] + assert m.blocked == 1 + assert exit_log.entries()[-1].reason == "no_executable_bid" + + +async def test_min_mark_bid_size_is_configurable() -> None: + market_source_manager.store.set_order_books( + [OrderBook(token_id="t1", ts=1.0, bids=[(0.30, 1.0)], asks=[(0.52, 50.0)])] + ) + ex = _FakeExecutor() + m = ExitMonitor( + exit_section=ThresholdExitV0(ThresholdExitConfig()), + executor=ex, + tick_interval_seconds=3600, + min_mark_bid_size=1.0, # accept the 1-share bid + ) + m.configure(_FakePortfolio([_held(1, "t1", avg=0.40)])) # type: ignore[arg-type] + await m._tick_once() + assert ex.calls[0]["close_reason"] == "stop_loss" + + +async def test_marked_position_carries_spread_from_the_book() -> None: + captured: list[object] = [] + + class _Recorder: + def run(self, input): # noqa: ANN001, ANN201 + captured.append(input.payload) + return ThresholdExitV0(ThresholdExitConfig()).run(input) + + market_source_manager.store.set_order_books( + [ + OrderBook( + token_id="t1", + ts=1.0, + bids=[(0.50, 100.0)], + asks=[(0.53, 100.0)], + ) + ] + ) + m = ExitMonitor( + exit_section=_Recorder(), + executor=_FakeExecutor(), + tick_interval_seconds=3600, + ) + m.configure(_FakePortfolio([_held(1, "t1", avg=0.48)])) # type: ignore[arg-type] + await m._tick_once() + marked = captured[0] + assert marked.spread == pytest.approx(0.03) + assert marked.tick_size is None + + +async def test_bootstrap_peaks_ignores_thin_snapshot_bids(tmp_path) -> None: + db_path = tmp_path / "openpoly_peak_thin.db" + engine = make_engine(f"sqlite:///{db_path}") + init_db(engine) + sf = make_session_factory(engine) + + pf = PortfolioStore(sf) + held = pf.open_position( + market_id="m1", + side="yes", + token_id="t1", + condition_id="0xm1", + qty=20.0, + price=0.40, + ts=100.0, + news_id="n1", + ) + with sf() as session: + session.add_all( + [ + OrderBookSnapshot( + token_id="t1", + recorded_at=110.0, + bids_json=json.dumps([[0.50, 100]]), + asks_json=json.dumps([[0.51, 100]]), + ), + # A 1-share spike bid over a real 0.60 book: must not inflate + # the peak, or the very first live tick looks like a drawdown. + OrderBookSnapshot( + token_id="t1", + recorded_at=120.0, + bids_json=json.dumps([[0.90, 1], [0.60, 100]]), + asks_json=json.dumps([[0.91, 100]]), + ), + ] + ) + session.commit() + + monitor = ExitMonitor( + exit_section=ThresholdExitV0(ThresholdExitConfig()), + executor=_FakeExecutor(), + tick_interval_seconds=3600, + ) + monitor.configure(pf) + monitor.bootstrap_peaks(sf) + # The 0.90 dust bid is rejected and the walk lands on the 0.60 level that + # actually had depth, so that — not 0.90 — becomes the peak. + assert monitor._peak[held.position_id] == pytest.approx(0.60) + + +# ---------- observe_price push hook ---------- + + +async def test_observe_price_lifts_peak_between_ticks() -> None: + market_source_manager.store.set_order_books([_book("t1", bid=0.46)]) + ex = _FakeExecutor() + monitor = ExitMonitor( + exit_section=ThresholdExitV0( + ThresholdExitConfig(take_profit_enabled=False, stop_loss_pct=0.50) + ), + executor=ex, + tick_interval_seconds=3600, + ) + monitor.configure(_FakePortfolio([_held(1, "t1", avg=0.40)])) # type: ignore[arg-type] + await monitor._tick_once() + assert monitor._peak[1] == pytest.approx(0.46) + + # Between ticks the book sampler observes a spike the 120s tick would miss. + monitor.observe_price("t1", 0.56) + assert monitor._peak[1] == pytest.approx(0.56) + # A lower observation never lowers the peak. + monitor.observe_price("t1", 0.52) + assert monitor._peak[1] == pytest.approx(0.56) + + # Next tick sees 0.50 and closes against the observed peak, not 0.46. + market_source_manager.store.set_order_books([_book("t1", bid=0.50)]) + await monitor._tick_once() + assert exit_log.entries()[-1].trigger == "peak_drawdown" + assert exit_log.entries()[-1].peak_price == pytest.approx(0.56) + + +async def test_observe_price_ignores_unwatched_tokens() -> None: + ex = _FakeExecutor() + m = _monitor(_FakePortfolio([_held(1, "t1", avg=0.40)]), ex) + m.observe_price("t-unknown", 0.99) + assert m._peak == {} + + +async def test_observe_book_applies_the_depth_guard() -> None: + market_source_manager.store.set_order_books([_book("t1", bid=0.46)]) + ex = _FakeExecutor() + m = _monitor(_FakePortfolio([_held(1, "t1", avg=0.40)]), ex) + await m._tick_once() + # A 1-share spike bid at 0.90 over a real 0.60 book must not become the + # peak — the walk lands on the first level with depth. + m.observe_book( + OrderBook( + token_id="t1", + ts=2.0, + bids=[(0.90, 1.0), (0.60, 100.0)], + asks=[(0.91, 100.0)], + ) + ) + assert m._peak[1] == pytest.approx(0.60) + m.observe_book(OrderBook(token_id="t1", ts=3.0, bids=[(0.95, 50.0)], asks=[(0.96, 100.0)])) + assert m._peak[1] == pytest.approx(0.95) + + +async def test_observe_book_thin_ladder_with_wide_ask_does_not_raise_the_peak() -> None: + # Every bid level is dust and the ask is far away, so the mid (0.66) is way + # above anything the position could be sold at. A peak raised to a + # non-executable price makes the trailing lock measure a retrace from a + # price that never existed — the observation must be dropped entirely. + market_source_manager.store.set_order_books([_book("t1", bid=0.46)]) + m = _monitor(_FakePortfolio([_held(1, "t1", avg=0.40)]), _FakeExecutor()) + await m._tick_once() + m.observe_book( + OrderBook( + token_id="t1", + ts=2.0, + bids=[(0.44, 1.0), (0.43, 2.0)], + asks=[(0.88, 100.0)], + ) + ) + assert m._peak[1] == pytest.approx(0.46) + + +async def test_observe_book_with_no_bids_is_a_noop() -> None: + market_source_manager.store.set_order_books([_book("t1", bid=0.46)]) + m = _monitor(_FakePortfolio([_held(1, "t1", avg=0.40)]), _FakeExecutor()) + await m._tick_once() + m.observe_book(OrderBook(token_id="t1", ts=2.0, bids=[], asks=[])) + assert m._peak[1] == pytest.approx(0.46) + + +# ---------- blocking I/O must not stall the event loop ---------- + + +async def test_execute_sell_runs_off_the_event_loop() -> None: + """The live executor sleeps seconds inside execute_sell (CTF cache polling + + close-persist retries). It must run in a worker thread, or every other + runtime task — WS reconnects, market polls — stalls behind it.""" + import time as _time + + class _SlowExecutor(_FakeExecutor): + def execute_sell(self, position, *, close_reason, ts, trigger): # noqa: ANN001, ANN201 + _time.sleep(0.3) + return super().execute_sell(position, close_reason=close_reason, ts=ts, trigger=trigger) + + market_source_manager.store.set_order_books([_book("t1", bid=0.55)]) + m = _monitor(_FakePortfolio([_held(1, "t1", avg=0.40)]), _SlowExecutor()) + + beats = 0 + + async def _heartbeat() -> None: + nonlocal beats + while True: + await asyncio.sleep(0.01) + beats += 1 + + hb = asyncio.create_task(_heartbeat()) + try: + await m._tick_once() + finally: + hb.cancel() + with __import__("contextlib").suppress(asyncio.CancelledError): + await hb + # ~30 beats are possible in 0.3s; anything above a handful proves the loop + # kept running while execute_sell slept. + assert beats >= 10, f"event loop stalled: only {beats} heartbeats" + + +async def test_closed_position_stops_being_observed() -> None: + market_source_manager.store.set_order_books([_book("t1", bid=0.55)]) + ex = _FakeExecutor() + m = _monitor(_FakePortfolio([_held(1, "t1", avg=0.40)]), ex) + await m._tick_once() # +37.5% → take_profit → closed + assert ex.calls + assert m._peak == {} + # A book delivered before the next sweep must not resurrect the peak. + m.observe_book(_book("t1", bid=0.60)) + assert m._peak == {} + + +# ---------- in-flight sell guard ---------- + + +class _SleepingExecutor(_FakeExecutor): + """Executor whose sell blocks the worker thread long enough for another + monitor's tick to interleave on the event loop.""" + + def __init__(self, seconds: float = 0.3, **kwargs) -> None: # noqa: ANN003 + super().__init__(**kwargs) + self._seconds = seconds + + def execute_sell(self, position, *, close_reason, ts, trigger): # noqa: ANN001, ANN201 + import time as _time + + _time.sleep(self._seconds) + return super().execute_sell(position, close_reason=close_reason, ts=ts, trigger=trigger) + + +def _portfolio_with_open_position(tmp_path, name: str): # noqa: ANN001, ANN201 + engine = make_engine(f"sqlite:///{tmp_path}/{name}.db") + init_db(engine) + pf = PortfolioStore(make_session_factory(engine)) + held = pf.open_position( + market_id="m1", + side="yes", + token_id="t1", + condition_id="0xm1", + qty=20.0, + price=0.40, + ts=100.0, + news_id="n1", + ) + return pf, held + + +async def test_position_is_registered_as_closing_while_the_sell_is_in_flight(tmp_path) -> None: + # execute_sell runs in a worker thread, so the event loop is free while the + # on-chain sell is still open. Any other monitor that closes positions must + # be able to see that this one is mid-flight. + market_source_manager.store.set_order_books([_book("t1", bid=0.55)]) + pf, held = _portfolio_with_open_position(tmp_path, "closing_flag") + m = ExitMonitor( + exit_section=ThresholdExitV0(ThresholdExitConfig(take_profit_enabled=True)), + executor=_SleepingExecutor(0.3), + tick_interval_seconds=3600, + ) + m.configure(pf) + + tick = asyncio.create_task(m._tick_once()) + await asyncio.sleep(0.05) + assert is_closing(held.position_id) + await tick + assert not is_closing(held.position_id) + + +async def test_closing_flag_is_cleared_when_the_sell_raises(tmp_path) -> None: + market_source_manager.store.set_order_books([_book("t1", bid=0.55)]) + pf, held = _portfolio_with_open_position(tmp_path, "closing_raise") + m = ExitMonitor( + exit_section=ThresholdExitV0(ThresholdExitConfig(take_profit_enabled=True)), + executor=_SleepingExecutor(0.05, exc=ValueError("boom")), + tick_interval_seconds=3600, + ) + m.configure(pf) + await m._tick_once() + assert exit_log.entries()[-1].verdict == "error" + assert not is_closing(held.position_id) + + +async def test_reconciliation_does_not_close_a_position_being_sold(tmp_path) -> None: + # The race this prevents: the exit monitor yields the loop inside + # to_thread, reconciliation sees the (empty) on-chain holdings, closes the + # same position id, and the real fill can no longer be persisted. + market_source_manager.store.set_order_books([_book("t1", bid=0.55)]) + pf, held = _portfolio_with_open_position(tmp_path, "recon_race") + m = ExitMonitor( + exit_section=ThresholdExitV0(ThresholdExitConfig(take_profit_enabled=True)), + executor=_SleepingExecutor(0.3), + tick_interval_seconds=3600, + ) + m.configure(pf) + + async def _no_holdings() -> set[tuple[str, str]]: + return set() + + rm = ReconciliationMonitor(holdings_fetcher=_no_holdings, grace_seconds=0) + rm.configure(pf) + + tick = asyncio.create_task(m._tick_once()) + await asyncio.sleep(0.05) + await rm._tick_once() + record = pf.get_position(held.position_id) + assert record is not None + assert record.status == "open" # reconciliation deferred to the next tick + await tick + + +async def test_stop_waits_for_an_in_flight_sell_to_record_its_close() -> None: + """stop() cancels the tick loop, but a sell already handed to a worker + thread cannot be cancelled — it completes on-chain and in the DB. If the + bookkeeping after the await is dropped, that close leaves no exit_log entry + and the position keeps a stale peak. stop() must drain it.""" + market_source_manager.store.set_order_books([_book("t1", bid=0.55)]) + m = ExitMonitor( + exit_section=ThresholdExitV0(ThresholdExitConfig(take_profit_enabled=True)), + executor=_SleepingExecutor(0.2), + tick_interval_seconds=3600, + ) + m.configure(_FakePortfolio([_held(1, "t1", avg=0.40)])) # type: ignore[arg-type] + await m.start() + await asyncio.sleep(0.05) # the tick is inside the sell + assert is_closing(1) + + await m.stop() + assert m.state == "stopped" + entries = exit_log.entries() + assert [e.verdict for e in entries] == ["ok"] + assert entries[0].trigger == "take_profit" + assert entries[0].fill_price == 0.55 + assert m._peak == {} # peak cleanup ran + assert not is_closing(1) + + +async def test_stop_is_safe_when_no_sell_is_in_flight() -> None: + m = _monitor(_FakePortfolio([]), _FakeExecutor()) + await m.start() + await asyncio.sleep(0) + await m.stop() + assert m.state == "stopped" + + +# ---------- stale-snapshot guard ---------- + + +def _portfolio_with_two_open_positions(tmp_path, name: str): # noqa: ANN001, ANN201 + engine = make_engine(f"sqlite:///{tmp_path}/{name}.db") + init_db(engine) + pf = PortfolioStore(make_session_factory(engine)) + first = pf.open_position( + market_id="m1", + side="yes", + token_id="t1", + condition_id="0xm1", + qty=20.0, + price=0.40, + ts=100.0, + news_id="n1", + ) + second = pf.open_position( + market_id="m2", + side="yes", + token_id="t2", + condition_id="0xm2", + qty=20.0, + price=0.40, + ts=100.0, + news_id="n2", + ) + return pf, first, second + + +async def test_position_closed_mid_sweep_is_not_sold_from_the_stale_snapshot(tmp_path) -> None: + """The sweep's open-position list is read once, then the first sell hands + the loop back for seconds. Another monitor can close a *different* position + in that window; selling it from the stale snapshot hits an already-closed + row (paper: ValueError → a spurious ``error`` row; live: a real on-chain + sell that can never be persisted). Re-read the row before claiming it.""" + import threading + import time as _time + + market_source_manager.store.set_order_books([_book("t1", bid=0.55), _book("t2", bid=0.55)]) + pf, first, second = _portfolio_with_two_open_positions(tmp_path, "stale_snapshot") + + selling = threading.Event() + + class _SignallingExecutor(_FakeExecutor): + """Blocks the worker thread on the first position's sell and tells the + event loop it is in flight, so the race is deterministic.""" + + def execute_sell(self, position, *, close_reason, ts, trigger): # noqa: ANN001, ANN201 + if position.position_id == first.position_id: + selling.set() + _time.sleep(0.3) + return super().execute_sell(position, close_reason=close_reason, ts=ts, trigger=trigger) + + ex = _SignallingExecutor() + m = ExitMonitor( + exit_section=ThresholdExitV0(ThresholdExitConfig(take_profit_enabled=True)), + executor=ex, + tick_interval_seconds=3600, + ) + m.configure(pf) + + async def _close_the_other_position() -> None: + while not selling.is_set(): + await asyncio.sleep(0.01) + pf.close_position(second.position_id, sell_price=0.50, ts=200.0, close_reason="settlement") + + closer = asyncio.create_task(_close_the_other_position()) + try: + await asyncio.wait_for(m._tick_once(), timeout=5.0) + finally: + closer.cancel() + with contextlib.suppress(asyncio.CancelledError): + await closer + + assert [c["position_id"] for c in ex.calls] == [first.position_id] + skips = [e for e in exit_log.entries() if e.verdict == "skip"] + assert [(e.position_id, e.reason) for e in skips] == [ + (second.position_id, "position_no_longer_open") + ] diff --git a/tests/test_market_book_sampling.py b/tests/test_market_book_sampling.py index 3a9e41f..ee09658 100644 --- a/tests/test_market_book_sampling.py +++ b/tests/test_market_book_sampling.py @@ -169,3 +169,49 @@ async def test_stop_cancels_both_loops(): assert mgr._tasks == [] # second stop is a safe no-op assert (await mgr.stop()).state == "stopped" + + +# ---------- book observer hook ---------- + + +async def test_sample_books_notifies_observer(): + seen: list[str] = [] + mgr = MarketSourceManager( + fetcher=_fetcher([_raw_pair("a")]), + book_fetcher=_book_fetcher, + ) + mgr._config = MarketSourceConfig() + mgr.set_book_observer(lambda book: seen.append(book.token_id)) + await mgr._poll_once() + await mgr._sample_books_once() + assert sorted(seen) == ["no-a", "yes-a"] + + +async def test_book_observer_exception_does_not_break_the_cycle(): + def _boom(book: OrderBook) -> None: + raise RuntimeError("observer blew up") + + mgr = MarketSourceManager( + fetcher=_fetcher([_raw_pair("a")]), + book_fetcher=_book_fetcher, + ) + mgr._config = MarketSourceConfig() + mgr.set_book_observer(_boom) + await mgr._poll_once() + count = await mgr._sample_books_once() + assert count == 2 + assert mgr.store.order_book_count == 2 + + +async def test_book_observer_can_be_cleared(): + seen: list[str] = [] + mgr = MarketSourceManager( + fetcher=_fetcher([_raw_pair("a")]), + book_fetcher=_book_fetcher, + ) + mgr._config = MarketSourceConfig() + mgr.set_book_observer(lambda book: seen.append(book.token_id)) + mgr.set_book_observer(None) + await mgr._poll_once() + await mgr._sample_books_once() + assert seen == [] diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py index 7c4408f..ba7dd32 100644 --- a/tests/test_orchestrator.py +++ b/tests/test_orchestrator.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio +import contextlib from typing import Any from openpoly.embedding.models import MarketCandidate, MarketCandidates @@ -540,3 +541,49 @@ def run(self, input: SectionInput) -> SectionOutput: # Entry never called assert entry.call_count == 0 assert len(e_log.entries()) == 0 + + +# ---------- blocking I/O must not stall the event loop ---------- + + +async def test_execute_buy_runs_off_the_event_loop() -> None: + """The live executor sleeps seconds inside execute_buy (CTF balance polling + on a lost response). It must run in a worker thread, or the WS reconnect / + market-poll tasks stall behind every fill.""" + import time as _time + + class _SlowExecutor(FakeExecutor): + def execute_buy(self, intent: OrderIntent, *, news_id: str | None, ts: float): + _time.sleep(0.3) + return super().execute_buy(intent, news_id=news_id, ts=ts) + + ex = _SlowExecutor() + orch, _, _, e_log = make_orchestrator(executor=ex) + + beats = 0 + + async def _heartbeat() -> None: + nonlocal beats + while True: + await asyncio.sleep(0.01) + beats += 1 + + hb = asyncio.create_task(_heartbeat()) + await orch.start() + try: + orch.enqueue(_item()) + + async def _wait_for_fill() -> None: + while not e_log.entries(): + await asyncio.sleep(0.01) + + await asyncio.wait_for(_wait_for_fill(), timeout=5.0) + finally: + await orch.stop() + hb.cancel() + with contextlib.suppress(asyncio.CancelledError): + await hb + + assert ex.call_count == 1 + assert e_log.entries()[0].fill_status == "filled" + assert beats >= 10, f"event loop stalled: only {beats} heartbeats" diff --git a/tests/test_reconciliation_monitor.py b/tests/test_reconciliation_monitor.py index 7229841..7281616 100644 --- a/tests/test_reconciliation_monitor.py +++ b/tests/test_reconciliation_monitor.py @@ -16,6 +16,7 @@ from openpoly.db.engine import init_db, make_engine, make_session_factory from openpoly.portfolio import PortfolioStore +from openpoly.runtime.closing_registry import clear_closing, mark_closing from openpoly.runtime.section_log import settlement_log from openpoly.runtime.reconciliation_monitor import ReconciliationMonitor @@ -172,3 +173,27 @@ async def test_tracked_holding_does_not_alert(store) -> None: rm.configure(store) await rm._tick_once() assert settlement_log.entries() == [] # ledger knows it — quiet + + +async def test_position_with_an_exit_sell_in_flight_is_not_reconciled(store) -> None: + """A position mid-sell is legitimately absent from... nothing: the wallet + may already be flat while the exit monitor is still persisting the fill. + Closing it here loses that fill, so reconciliation defers one tick.""" + pid = _open_position(store, condition_id="0xcid", side="yes") + rm = ReconciliationMonitor(holdings_fetcher=_holdings(set()), grace_seconds=0) + rm.configure(store) + mark_closing(pid) + try: + await rm._tick_once() + finally: + clear_closing(pid) + rec = store.get_position(pid) + assert rec is not None + assert rec.status == "open" + + # Sell finished without closing the position → next tick reconciles it. + await rm._tick_once() + rec = store.get_position(pid) + assert rec is not None + assert rec.status == "closed" + assert rec.close_reason == "reconciled" diff --git a/tests/test_section_exit_threshold.py b/tests/test_section_exit_threshold.py index a207fc7..91d07b5 100644 --- a/tests/test_section_exit_threshold.py +++ b/tests/test_section_exit_threshold.py @@ -16,6 +16,8 @@ def _pos( *, peak_price: float | None = None, qty: float = 20.0, + spread: float | None = None, + tick_size: float | None = None, ) -> MarkedPosition: return MarkedPosition( market_id="m1", @@ -24,6 +26,8 @@ def _pos( qty=qty, current_price=current_price, peak_price=current_price if peak_price is None else peak_price, + spread=spread, + tick_size=tick_size, ) @@ -50,7 +54,7 @@ def test_within_thresholds_holds() -> None: def test_take_profit_closes() -> None: - inst = ThresholdExitV0(ThresholdExitConfig()) + inst = ThresholdExitV0(ThresholdExitConfig(take_profit_enabled=True)) out = inst.run(SectionInput(tick_type="hard", payload=_pos(0.65))) assert out.verdict == "ok" assert isinstance(out.payload, CloseIntent) @@ -79,7 +83,9 @@ def test_invalid_entry_price_skips() -> None: def test_custom_thresholds() -> None: - inst = ThresholdExitV0(ThresholdExitConfig(take_profit_pct=0.05, stop_loss_pct=0.05)) + inst = ThresholdExitV0( + ThresholdExitConfig(take_profit_enabled=True, take_profit_pct=0.05, stop_loss_pct=0.05) + ) # +6% return → take-profit at the lowered 5% threshold tp = inst.run(SectionInput(tick_type="hard", payload=_pos(0.53))) assert tp.verdict == "ok" @@ -93,7 +99,7 @@ def test_custom_thresholds() -> None: def test_no_side_position_return_is_side_agnostic() -> None: - inst = ThresholdExitV0(ThresholdExitConfig()) + inst = ThresholdExitV0(ThresholdExitConfig(take_profit_enabled=True)) pos = MarkedPosition( market_id="m2", side="no", @@ -114,15 +120,19 @@ def test_no_side_position_return_is_side_agnostic() -> None: def test_peak_drawdown_triggers_when_meaningful_retrace() -> None: - inst = ThresholdExitV0(ThresholdExitConfig()) - # Peak 0.62 (+24%, peak_gain = $2.40 ≥ floor); now 0.58 (+16% so under TP). - # peak_dd = (0.62 - 0.58) / (0.62 - 0.50) = 0.04 / 0.12 = 0.333 ≥ 0.12. - out = inst.run(SectionInput(tick_type="hard", payload=_pos(0.58, peak_price=0.62))) + # take_profit disabled so the trailing lock is the only trigger that can + # fire — with it enabled, +30% would take profit before the retrace. + inst = ThresholdExitV0(ThresholdExitConfig(take_profit_enabled=False)) + # Peak 0.70 (+40%, peak_gain = $4.00 >= the 30%-of-cost-basis floor $3.00); + # now 0.65. Retrace 0.05 >= trail distance max(2 ticks = 0.02, + # 0.12 * (0.70 - 0.50) = 0.024) = 0.024 -> close. + out = inst.run(SectionInput(tick_type="hard", payload=_pos(0.65, peak_price=0.70))) assert out.verdict == "ok" assert isinstance(out.payload, CloseIntent) assert out.payload.trigger == "peak_drawdown" assert out.signals["peak_meaningful"] is True - assert out.signals["peak_dd"] == 0.3333 + assert out.signals["peak_dd"] == 0.25 + assert out.signals["trail_distance"] == 0.024 def test_peak_drawdown_skipped_when_peak_below_usd_floor() -> None: @@ -136,9 +146,8 @@ def test_peak_drawdown_skipped_when_peak_below_usd_floor() -> None: def test_peak_drawdown_skipped_when_peak_below_pct_floor() -> None: - # qty 200 → cost basis $100. peak +0.5pt = $1 bank, but 1% floor = $1 too, - # so peak_gain = 1.0 not strictly > floor → meaningful=True at exactly 1.0. - # Bump qty to 1000 (cost $500): 1% floor = $5, peak +0.4pt = $4 < $5 → skip. + # qty 1000 → cost basis $500 → 30% floor = $150. A +0.4pt peak banks only + # $4, far below the floor, so the trailing lock stays disarmed. inst = ThresholdExitV0(ThresholdExitConfig()) out = inst.run(SectionInput(tick_type="hard", payload=_pos(0.51, peak_price=0.504, qty=1000.0))) assert out.verdict == "skip" @@ -155,15 +164,17 @@ def test_stop_loss_beats_peak_drawdown() -> None: assert out.payload.trigger == "stop_loss" -def test_peak_drawdown_beats_take_profit() -> None: - inst = ThresholdExitV0(ThresholdExitConfig()) - # Peak 0.80 (+60%), now 0.60 (+20%, exactly at TP). peak_dd 20/30 = 67%. - # Without peak tracking this would close as TP; with it, the trailing - # retrace wins. - out = inst.run(SectionInput(tick_type="hard", payload=_pos(0.60, peak_price=0.80))) +def test_take_profit_beats_peak_drawdown() -> None: + inst = ThresholdExitV0(ThresholdExitConfig(take_profit_enabled=True)) + # Peak 0.80 (+60%), now 0.62 (+24%, past TP) with a 0.18 retrace that is + # far outside the 0.036 trailing distance. Both the take-profit ceiling and + # the trailing lock qualify; precedence now puts take_profit second (right + # after stop_loss) so the position books its target gain instead of + # reporting a drawdown close at the very same price. + out = inst.run(SectionInput(tick_type="hard", payload=_pos(0.62, peak_price=0.80))) assert out.verdict == "ok" assert isinstance(out.payload, CloseIntent) - assert out.payload.trigger == "peak_drawdown" + assert out.payload.trigger == "take_profit" def test_peak_below_entry_does_not_trigger_peak_dd() -> None: @@ -174,3 +185,200 @@ def test_peak_below_entry_does_not_trigger_peak_dd() -> None: assert out.verdict == "skip" assert out.signals["peak_meaningful"] is False assert out.signals["peak_dd"] is None + + +# ---------- trailing distance floor ---------- + + +def test_single_tick_dip_holds_at_default_min_trail_ticks() -> None: + # Entry 0.50, peak 0.70 (+$4.00 peak gain, armed), one tick down to 0.69. + # Percentage trail alone would be 0.12 * 0.20 = 0.024, but the retrace is + # only 0.01 — under both that and the 2-tick floor, so the position holds. + inst = ThresholdExitV0(ThresholdExitConfig(take_profit_enabled=False)) + out = inst.run(SectionInput(tick_type="hard", payload=_pos(0.69, peak_price=0.70))) + assert out.verdict == "skip" + assert out.reason == "within thresholds" + + +def test_min_trail_ticks_floor_dominates_small_percentage_trail() -> None: + # Peak barely above the arming floor: peak 0.66, entry 0.50, qty 20 → + # peak gain $3.20 ≥ $3.00 floor. Percentage trail = 0.12 * 0.16 = 0.0192, + # i.e. under two ticks; the floor lifts it to 0.02 so a single 0.01 dip + # (which the raw percentage rule would close on) is held. + inst = ThresholdExitV0(ThresholdExitConfig(take_profit_enabled=False)) + out = inst.run(SectionInput(tick_type="hard", payload=_pos(0.65, peak_price=0.66))) + assert out.verdict == "skip" + assert out.signals["peak_meaningful"] is True + assert out.signals["trail_distance"] == 0.02 + # Two ticks down does clear the floor. + out2 = inst.run(SectionInput(tick_type="hard", payload=_pos(0.64, peak_price=0.66))) + assert out2.verdict == "ok" + assert isinstance(out2.payload, CloseIntent) + assert out2.payload.trigger == "peak_drawdown" + + +def test_spread_widens_the_trail_distance() -> None: + # Same peak/current as the holding case above, but a 0.06 spread: the mark + # is a bid inside a wide book, so a 0.05 retrace is inside the noise the + # spread itself implies → hold. + inst = ThresholdExitV0(ThresholdExitConfig(take_profit_enabled=False)) + out = inst.run(SectionInput(tick_type="hard", payload=_pos(0.65, peak_price=0.70, spread=0.06))) + assert out.verdict == "skip" + assert out.signals["trail_distance"] == 0.06 + + +def test_position_tick_size_overrides_config_tick_size() -> None: + # A venue tick of 0.05 makes the 2-tick floor 0.10, so a 0.05 retrace holds. + inst = ThresholdExitV0(ThresholdExitConfig(take_profit_enabled=False)) + out = inst.run( + SectionInput(tick_type="hard", payload=_pos(0.65, peak_price=0.70, tick_size=0.05)) + ) + assert out.verdict == "skip" + assert out.signals["trail_distance"] == 0.10 + + +def test_min_trail_ticks_zero_restores_pure_percentage_trail() -> None: + inst = ThresholdExitV0(ThresholdExitConfig(take_profit_enabled=False, min_trail_ticks=0)) + # Trail = 0.12 * 0.16 = 0.0192; a single 0.01 dip is still inside it, but + # 0.02 clears it — the floor is what the config removed, nothing else. + out = inst.run(SectionInput(tick_type="hard", payload=_pos(0.64, peak_price=0.66))) + assert out.verdict == "ok" + assert out.signals["trail_distance"] == 0.0192 + + +# ---------- arming floor ---------- + + +def test_arming_floor_defaults_to_30_pct_of_cost_basis() -> None: + cfg = ThresholdExitConfig() + assert cfg.peak_meaningful_floor_pct == 0.30 + assert cfg.peak_meaningful_floor_usd == 1.0 + # Entry 0.50 × qty 20 = $10 cost basis → floor $3.00 → arms at peak 0.65. + inst = ThresholdExitV0(ThresholdExitConfig(take_profit_enabled=False)) + below = inst.run(SectionInput(tick_type="hard", payload=_pos(0.55, peak_price=0.64))) + assert below.signals["peak_meaningful"] is False + at = inst.run(SectionInput(tick_type="hard", payload=_pos(0.60, peak_price=0.65))) + assert at.signals["peak_meaningful"] is True + + +# ---------- take profit switch ---------- + + +def test_take_profit_is_off_by_default() -> None: + cfg = ThresholdExitConfig() + assert cfg.take_profit_enabled is False + # The pct is kept as an opt-in cap for callers that want a hard ceiling. + assert cfg.take_profit_pct == 0.20 + # +30% and the peak is the current price: with the ceiling off the position + # keeps running toward the trailing lock's +30% arming floor instead of + # being closed before the lock can ever engage. + out = ThresholdExitV0(cfg).run(SectionInput(tick_type="hard", payload=_pos(0.65))) + assert out.verdict == "skip" + assert out.reason == "within thresholds" + + +def test_take_profit_can_be_disabled() -> None: + inst = ThresholdExitV0(ThresholdExitConfig(take_profit_enabled=False)) + # +30% with the peak at the current price: nothing to retrace, and the + # take-profit ceiling is off → the position stays open to keep running. + out = inst.run(SectionInput(tick_type="hard", payload=_pos(0.65))) + assert out.verdict == "skip" + assert out.reason == "within thresholds" + + +def test_take_profit_disabled_still_stops_out() -> None: + inst = ThresholdExitV0(ThresholdExitConfig(take_profit_enabled=False)) + out = inst.run(SectionInput(tick_type="hard", payload=_pos(0.40))) + assert out.verdict == "ok" + assert isinstance(out.payload, CloseIntent) + assert out.payload.trigger == "stop_loss" + + +# ---------- config schema (canvas-facing) ---------- + + +def test_all_config_fields_carry_descriptions() -> None: + props = ThresholdExitConfig.model_json_schema()["properties"] + for name in ( + "take_profit_pct", + "take_profit_enabled", + "stop_loss_pct", + "peak_drawdown_pct", + "min_trail_ticks", + "tick_size", + "peak_meaningful_floor_usd", + "peak_meaningful_floor_pct", + ): + assert name in props, name + assert props[name].get("description"), name + + +# ---------- synthetic price path regression ---------- + + +def _rising_then_retracing_path() -> list[float]: + """0.50 → 0.80 in +2/-1 tick steps (single-tick noise on every leg), + then a clean retrace back down to 0.55.""" + prices: list[float] = [] + price = 0.50 + while price < 0.80 - 1e-9: + price = round(price + 0.02, 2) + prices.append(price) + prices.append(round(price - 0.01, 2)) + prices.append(0.80) + down = 0.80 + while down > 0.55 + 1e-9: + down = round(down - 0.01, 2) + prices.append(down) + return prices + + +def _walk(config: ThresholdExitConfig, path: list[float]) -> tuple[float, float] | None: + """Replay ``path`` through the section the way ExitMonitor does (monotone + peak, 0.01 spread). Returns (exit_price, peak_at_exit) or None if the + position was never closed.""" + inst = ThresholdExitV0(config) + peak = path[0] + for price in path: + peak = max(peak, price) + out = inst.run( + SectionInput( + tick_type="hard", + payload=_pos(price, peak_price=peak, spread=0.01), + ) + ) + if out.verdict == "ok": + return price, peak + return None + + +def test_old_defaults_close_on_the_first_downtick() -> None: + # The pre-fix configuration: 1%-of-cost-basis arming floor, no trailing + # floor, peak_drawdown ahead of take_profit. It gives the move back at + # ~0.55 — barely above entry — because the trailing distance at that peak + # is 0.12 × 0.06 = 0.007, i.e. under one Polymarket tick. + old = ThresholdExitConfig( + peak_meaningful_floor_pct=0.01, + min_trail_ticks=0, + take_profit_enabled=False, + ) + result = _walk(old, _rising_then_retracing_path()) + assert result is not None + exit_price, _ = result + assert 0.54 <= exit_price <= 0.55 + + +def test_new_defaults_hold_through_noise_and_capture_most_of_the_peak() -> None: + # Bare defaults — the configuration the runtime actually ships with. The + # trailing lock is the primary exit path; take-profit is off, so nothing + # closes this move before the lock arms. + new = ThresholdExitConfig() + path = _rising_then_retracing_path() + result = _walk(new, path) + assert result is not None + exit_price, peak = result + assert peak == 0.80 + captured = (exit_price - 0.50) / (peak - 0.50) + assert captured >= 0.50, f"captured only {captured:.2%} at {exit_price}" + # It must have survived every single-tick dip on the way up. + assert exit_price > 0.75 diff --git a/tests/test_settlement_monitor.py b/tests/test_settlement_monitor.py index f9cd0f7..dc6a34f 100644 --- a/tests/test_settlement_monitor.py +++ b/tests/test_settlement_monitor.py @@ -17,6 +17,7 @@ from openpoly.db.engine import init_db, make_engine, make_session_factory from openpoly.portfolio import PortfolioStore +from openpoly.runtime.closing_registry import clear_closing, mark_closing from openpoly.runtime.section_log import settlement_log from openpoly.runtime.settlement_monitor import ( SettlementMonitor, @@ -292,3 +293,30 @@ async def test_not_configured_is_noop() -> None: sm = SettlementMonitor(fetcher=_fetcher_returning([])) await sm._tick_once() assert settlement_log.entries() == [] + + +async def test_position_with_an_exit_sell_in_flight_is_skipped(store) -> None: + """The exit monitor's execute_sell runs in a worker thread, so this loop + can run while an on-chain sell for the same position is still open. + Closing it here would make the real fill unpersistable — defer one tick.""" + pid = _open_position(store, condition_id="0xcid", side="yes", avg=0.40, qty=10.0) + raw = [_raw_market(condition_id="0xcid", closed=True, outcome_prices=["1", "0"])] + sm = SettlementMonitor(fetcher=_fetcher_returning(raw)) + sm.configure(store) + mark_closing(pid) + try: + await sm._tick_once() + finally: + clear_closing(pid) + rec = store.get_position(pid) + assert rec is not None + assert rec.status == "open" + entries = settlement_log.entries() + assert entries[0].verdict == "skip" + assert entries[0].reason == "exit_in_flight" + + # Once the sell is done the next tick settles it as usual. + await sm._tick_once() + rec = store.get_position(pid) + assert rec is not None + assert rec.status == "closed" From 3f50ca83aba80c9b01c771180061bfe4dc241016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nahim=20Rodr=C3=ADguez?= Date: Sun, 30 Aug 2026 17:20:23 -0600 Subject: [PATCH 02/27] fix(execution): venue-rule sizing, dust stays open, calibration groundwork quantize_size follows the venue size-decimals rule and never zeroes a whole-share position at 3-decimal prices; sub-share remainders skip and stay open for settlement instead of being written off at zero. Paper and live share one sizing helper, buys abort when the CTF baseline read fails, the write-behind writer counts drops and drains on stop, clob_patch gets tests, manual closes respect the closing registry, and entries persist p_model/confidence/edge behind a calibration report with an edge-sizing knob that ships disabled. --- CHANGELOG.md | 64 ++++ openpoly/analytics/__init__.py | 6 + openpoly/analytics/calibration.py | 125 ++++++++ openpoly/api/portfolio_routes.py | 73 ++++- openpoly/db/manager.py | 24 ++ openpoly/db/tables.py | 9 + openpoly/db/writer.py | 119 +++++++- openpoly/execution/clob_patch.py | 8 + openpoly/execution/executor.py | 59 +++- openpoly/execution/live_executor.py | 87 +++--- openpoly/execution/sizing.py | 116 +++++++ openpoly/portfolio/models.py | 10 + openpoly/portfolio/store.py | 75 ++++- openpoly/runtime/exit_monitor.py | 34 ++- openpoly/sections/entry/edge_threshold_v0.py | 93 +++++- tests/test_analytics_calibration.py | 179 +++++++++++ tests/test_api_portfolio.py | 53 ++++ tests/test_api_portfolio_close.py | 85 ++++++ tests/test_clob_patch.py | 213 +++++++++++++ tests/test_db_engine.py | 43 +++ tests/test_db_manager.py | 1 + tests/test_db_portfolio_store.py | 89 ++++++ tests/test_db_writer.py | 119 ++++++++ tests/test_execution_sizing.py | 304 +++++++++++++++++++ tests/test_executor.py | 32 ++ tests/test_exit_monitor.py | 61 +++- tests/test_live_executor.py | 148 ++++++++- tests/test_section_entry_edge.py | 141 +++++++++ 28 files changed, 2273 insertions(+), 97 deletions(-) create mode 100644 openpoly/analytics/__init__.py create mode 100644 openpoly/analytics/calibration.py create mode 100644 openpoly/execution/sizing.py create mode 100644 tests/test_analytics_calibration.py create mode 100644 tests/test_clob_patch.py create mode 100644 tests/test_execution_sizing.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f9ac3e5..902dceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,70 @@ Dates are US-style (MM/DD/YYYY). --- +## 08/30/2026 — Execution integrity, and sizing that has to earn the right + +Three beliefs changed about the gap between what the system *records* and what +actually happened at the venue, and a fourth about what has to be true before +the size of a bet is allowed to vary at all. + +**A remainder that cannot be sold is still worth its resolution price.** A +partial sell can leave less than one share open, which is not a placeable order +at the venue — every later exit attempt skips. The remainder is not worthless, +though: the settlement monitor closes open rows at the resolution price, so +those tokens pay out 1.0 on the winning side. Closing them at 0.0 would book a +loss that never happened and orphan tokens still sitting in the wallet, so the +sell now skips (`dust_remainder`, warned once per position) and the row stays +open until settlement. The tradeoff is accepted deliberately: the remainder +keeps counting toward the open-position list and `heat_cap_usd` until the +market resolves, which is a bounded, honest cost — a fabricated realized loss +is not. Sizing was also the reason most of that dust existed: orders were +floored to *whole shares* and additionally required `qty × price` to land on +clean cents. The venue asks for neither. Its SDK allows two size decimals at +every tick size and rounds the order amount itself, so the cent rule was +inventing rejections — at a three-decimal price (0.999, 0.993: exactly the +tick regime a winner exits through) no whole-share quantity aligns, so a +perfectly sellable nine-share winner quantized to zero and was treated as +unsellable. Sizing now floors to two decimals and nothing else. + +**Paper has to be a rehearsal of live, not a friendlier version of it.** The +two fill models had drifted apart: paper accepted orders down to $1.00 that +live rejects below $1.10, never quantized the size at all, and — worst — sold +the *entire* position into the level-1 bid regardless of that bid's depth, +reporting an exit price live could never have realized. Both executors now size +through one module (`execution/sizing.py`), and the paper sell caps at bid +depth and leaves the unsold remainder open, exactly as the live path does. +Paper P&L is now a lower-bound rehearsal rather than an optimistic one. + +**An order that cannot be confirmed is not worth placing.** The venue SDK +offers no client-supplied order id, so the only way to tell "lost response" from +"real fill" is the wallet's CTF balance before and after. When that pre-order +read fails there is no recovery signal at all, and a fill that did land would +become an untracked on-chain position. The buy path now refuses to place the +order (`ctf_balance_unavailable`) rather than trade blind. The manual close and +close-all routes were the other hole: they sold positions the exit monitor +already had in flight (the row stays `open` for the seconds the on-chain order +takes), which is a second sell of tokens already gone. Both now consult and +hold the same in-flight claim as the monitors — 409 `exit_in_flight` for a +single close, skipped-and-reported for close-all. + +**Sizing may scale with edge, but only once calibration says so.** New knob +`size_edge_multiplier_max`, defaulting to **1.0 — off**: at the default, +sizing is byte-for-byte what it was, `order_size_usd / held_price`, ignoring +edge entirely. Above 1.0 the notional becomes +`order_size_usd × clamp(edge / min_edge, 1.0, max)`. The reason it ships off is +that "edge" is `p_model − held_price`, and nothing so far has established that +`p_model` means what it says; betting more on a bigger number derived from an +uncalibrated probability just loses faster. So the evidence comes first: every +entry now freezes its `p_model`, `confidence` and `edge` onto the position row +(the analyzer log ring evicts a call long before the position it opened +closes), and `GET /api/analytics/calibration` buckets closed positions by the +model's probability for the side actually held, with each bucket's win rate and +mean realized return. The knob should be raised only when a bucket's win rate +sits near its own midpoint with n ≥ 100 behind it. `heat_cap_usd` still bounds +the result: the extra size a multiplier grants is trimmed to the headroom left +over open exposure — never below the base order, so the default path is +unchanged. + ## 08/30/2026 — Exit policy v2: the trailing stop stops eating the trade Live behavior exposed a defect in exit policy v1 (05/24): winners were being diff --git a/openpoly/analytics/__init__.py b/openpoly/analytics/__init__.py new file mode 100644 index 0000000..395ba86 --- /dev/null +++ b/openpoly/analytics/__init__.py @@ -0,0 +1,6 @@ +"""Analytics — pure, read-only derivations over the position ledger. + +Nothing here writes, schedules, or touches the network: each function takes +already-loaded records and returns a value object, so the same code backs an +HTTP route, a test, and an offline analysis. +""" diff --git a/openpoly/analytics/calibration.py b/openpoly/analytics/calibration.py new file mode 100644 index 0000000..872139f --- /dev/null +++ b/openpoly/analytics/calibration.py @@ -0,0 +1,125 @@ +"""Calibration — is the analyzer's stated probability worth anything? + +The entry section sizes and gates off ``p_model``. Whether that number is +*calibrated* — whether the trades it opened at "70%" actually won about 70% of +the time — is the question that has to be answered before ``p_model`` is +allowed to influence position size at all (see +``EdgeThresholdConfig.size_edge_multiplier_max``). It cannot be answered from +the analyzer log: that ring evicts a call within a few hundred news events, +long before the position it opened closes. It is answered here, from the +``entry_p_model`` frozen onto the position row at open time. + +The report is a plain bucketing, deliberately: no smoothing, no fitted curve, +no confidence intervals. A bucket whose win rate sits near its own midpoint, +with enough closed positions behind it to mean anything, is the whole signal. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from dataclasses import dataclass + +from openpoly.portfolio import PositionRecord + +# Bucket boundaries over the *held side's* probability, which is always ≥ 0.5 +# by construction (the entry section picks the side p_model favours). +BUCKET_EDGES: tuple[float, ...] = (0.5, 0.6, 0.7, 0.8, 0.9, 1.0) + + +@dataclass(frozen=True) +class CalibrationBucket: + """One probability bucket ``[lower, upper)`` — the top one includes 1.0. + + ``win_rate`` is the fraction of the bucket's closed positions with positive + realized PnL; compare it against the bucket midpoint. ``mean_return`` is the + average of ``realized_pnl / cost_basis``, where the cost basis is what was + paid to *open* the position — it answers a different question (is the edge + worth trading) and can disagree with the win rate. + Both are None for an empty bucket rather than a misleading 0.0. + """ + + lower: float + upper: float + count: int + win_rate: float | None + mean_return: float | None + + +def _held_side_probability(record: PositionRecord) -> float | None: + """The model's probability for the side actually held. + + A NO position on ``p_model=0.2`` is a 0.8 bet on the outcome it bought, so + bucketing the raw 0.2 would drop it out of the report entirely. Returns + None when the value falls outside [0.5, 1.0], which the entry section + cannot produce — such a row is a data defect and is left out rather than + quietly clamped into a bucket it does not belong to. + """ + p_model = record.entry_p_model + if p_model is None: + return None + held = p_model if record.side == "yes" else 1.0 - p_model + if held < BUCKET_EDGES[0] or held > BUCKET_EDGES[-1]: + return None + return held + + +def _bucket_index(probability: float) -> int: + """Index of the bucket owning ``probability``; boundaries belong to the + upper bucket and 1.0 belongs to the last one.""" + for index in range(len(BUCKET_EDGES) - 2, -1, -1): + if probability >= BUCKET_EDGES[index]: + return index + return 0 + + +def calibration_report( + positions: Iterable[PositionRecord], + cost_basis: Mapping[int, float] | None = None, +) -> list[CalibrationBucket]: + """Bucket closed, labelled positions by held-side probability. + + Excluded: still-open positions (no outcome yet) and positions without an + ``entry_p_model`` (opened manually, by reconciliation, or before the column + existed) — counting either would bias the very number being measured. + + ``cost_basis`` maps position id → what was paid to open it, from + ``PortfolioStore.buy_cost_basis``. It is what ``mean_return`` divides by, + and it has to come from the fill ledger: ``record_sell`` decrements + ``PositionRecord.qty`` on a partial sell while ``realized_pnl`` keeps + accruing on the whole position, so ``avg_entry_price * qty`` measures the + full gain against a residual sliver of the stake (buy 10 @ 0.40, sell 9.4 + @ 0.55, close 0.6 → +488% reported for a +29% trade). A position missing + from the mapping falls back to ``avg_entry_price * qty``, which is exact + for any position that was never partially sold. + + Always returns one bucket per ``BUCKET_EDGES`` pair, in order, including + empty ones: a gap in the coverage is itself worth seeing. + """ + basis_by_id: Mapping[int, float] = cost_basis or {} + wins: list[int] = [0] * (len(BUCKET_EDGES) - 1) + counts: list[int] = [0] * (len(BUCKET_EDGES) - 1) + returns: list[float] = [0.0] * (len(BUCKET_EDGES) - 1) + + for record in positions: + if record.status != "closed" or record.realized_pnl is None: + continue + probability = _held_side_probability(record) + if probability is None: + continue + index = _bucket_index(probability) + counts[index] += 1 + if record.realized_pnl > 0: + wins[index] += 1 + basis = basis_by_id.get(record.id, record.avg_entry_price * record.qty) + returns[index] += record.realized_pnl / basis if basis > 0 else 0.0 + + return [ + CalibrationBucket( + lower=BUCKET_EDGES[i], + upper=BUCKET_EDGES[i + 1], + count=counts[i], + win_rate=(wins[i] / counts[i]) if counts[i] else None, + mean_return=(returns[i] / counts[i]) if counts[i] else None, + ) + for i in range(len(counts)) + ] diff --git a/openpoly/api/portfolio_routes.py b/openpoly/api/portfolio_routes.py index 12cef32..8e5f6f0 100644 --- a/openpoly/api/portfolio_routes.py +++ b/openpoly/api/portfolio_routes.py @@ -4,7 +4,9 @@ The ``fill`` ledger is the source of truth; ``position`` is its materialized projection. Reads are newest-first, bounded by ``limit``. The manual close routes one open position through ``executor.execute_sell`` (close_reason -``manual``) — the same fill path the ExitMonitor uses. +``manual``) — the same fill path the ExitMonitor uses, and therefore under the +same single-writer discipline: both close routes consult (and hold) the +in-flight claim in ``runtime.closing_registry``. """ from __future__ import annotations @@ -17,15 +19,21 @@ from sqlalchemy.orm import Session, sessionmaker +from openpoly.analytics.calibration import calibration_report from openpoly.db.engine import get_session_factory from openpoly.execution import executor from openpoly.portfolio import PortfolioStore from openpoly.portfolio.equity import build_equity_curve +from openpoly.runtime.closing_registry import clear_closing, is_closing, mark_closing router = APIRouter(prefix="/api", tags=["portfolio"]) LIMIT_DEFAULT = 100 LIMIT_MAX = 500 +# Calibration wants the whole trading history, not a page of it — the report is +# only meaningful at n ≥ 100 per bucket. Still bounded: this is a per-request +# scan of the position projection, not a cursor. +CALIBRATION_LIMIT = 5000 def get_portfolio_store() -> PortfolioStore: @@ -80,13 +88,22 @@ async def close_position( store: PortfolioStore = Depends(get_portfolio_store), ) -> dict[str, Any]: """Manually close one open position at the level-1 bid (close_reason - ``manual``). 404 if no such position; 409 if it is already closed. The - response body is the ``ExecResult`` — ``filled`` is False (with a - ``skip_reason``) when the order book has no bid liquidity right now. + ``manual``). 404 if no such position; 409 if it is already closed, or if + the exit monitor already has a sell in flight for it. The response body is + the ``ExecResult`` — ``filled`` is False (with a ``skip_reason``) when the + order book has no bid liquidity right now. Async, and it never awaits between the open-position lookup and the synchronous ``execute_sell`` — so the close is atomic with respect to the - ExitMonitor tick on the same event loop (no double-close race). + ExitMonitor tick on the same event loop. + + "Still open in the DB" is not by itself proof that nobody is selling this + position: the exit monitor's ``execute_sell`` runs in a worker thread and + the row stays ``open`` for the seconds the on-chain order takes. Closing it + from here in that window is a second on-chain sell of tokens that are + already gone, so an in-flight claim is a 409 — and this route takes the + same claim for the duration of its own sell, so the settlement and + reconciliation monitors leave it alone too. """ held = next( (p for p in store.get_open_positions() if p.position_id == position_id), @@ -100,7 +117,13 @@ async def close_position( status_code=409, detail=f"position {position_id} is {record.status}, not open", ) - result = executor.execute_sell(held, close_reason="manual", ts=time.time(), trigger=None) + if is_closing(position_id): + raise HTTPException(status_code=409, detail="exit_in_flight") + mark_closing(position_id) + try: + result = executor.execute_sell(held, close_reason="manual", ts=time.time(), trigger=None) + finally: + clear_closing(position_id) return asdict(result) @@ -116,7 +139,10 @@ async def close_all_positions( Same atomicity story as ``close_position``: the open snapshot is taken once at the top and each ``execute_sell`` is synchronous; no await - interleaves between them and the ExitMonitor tick. + interleaves between them and the ExitMonitor tick. Positions the exit + monitor is already selling (see ``closing_registry``) are skipped with + ``exit_in_flight`` and reported in ``details`` rather than sold twice; each + position this route does sell is claimed for the duration. """ opens = store.get_open_positions() if not opens: @@ -131,6 +157,13 @@ async def close_all_positions( "market_id": held.market_id, "side": held.side, } + if is_closing(held.position_id): + entry["ok"] = False + entry["skip_reason"] = "exit_in_flight" + skipped += 1 + details.append(entry) + continue + mark_closing(held.position_id) try: result = executor.execute_sell(held, close_reason="manual", ts=now, trigger=None) except Exception as exc: # noqa: BLE001 — isolate per-position failure @@ -147,6 +180,8 @@ async def close_all_positions( entry["ok"] = False entry["skip_reason"] = result.skip_reason skipped += 1 + finally: + clear_closing(held.position_id) details.append(entry) return { "attempted": len(opens), @@ -234,6 +269,30 @@ def _lookup_analyzer_decisions(news_id: str | None) -> list[dict[str, Any]]: return matches +@router.get("/analytics/calibration") +def get_calibration( + store: PortfolioStore = Depends(get_portfolio_store), +) -> dict[str, Any]: + """Is ``p_model`` calibrated? Closed positions bucketed by the model's + probability for the side they held, with each bucket's win rate and mean + realized return (see ``openpoly.analytics.calibration``). + + Read-only and derived per request — nothing here is persisted. Read it as: + a bucket whose ``win_rate`` sits near its own midpoint, with ``count`` of + at least ~100, is a bucket whose probability means something. That is the + precondition for turning ``size_edge_multiplier_max`` above 1.0. + """ + positions = store.list_positions(CALIBRATION_LIMIT) + buckets = calibration_report( + positions, + store.buy_cost_basis([p.id for p in positions]), + ) + return { + "buckets": [asdict(b) for b in buckets], + "sample_size": sum(b.count for b in buckets), + } + + @router.get("/portfolio/equity") def get_equity_curve( factory: sessionmaker[Session] = Depends(get_session_factory), diff --git a/openpoly/db/manager.py b/openpoly/db/manager.py index af1a83c..5707d7c 100644 --- a/openpoly/db/manager.py +++ b/openpoly/db/manager.py @@ -48,6 +48,26 @@ def _ensure_fill_live_columns(engine: Engine) -> None: logger.info("migration: added fill.tx_hash") +def _ensure_position_entry_columns(engine: Engine) -> None: + """Idempotent migration: add the entry-signal columns to the position table + if they are missing (older DBs predate calibration). New DBs get them via + init_db()'s create_all and skip this entirely. + + Same hand-rolled PRAGMA-then-ALTER shape as ``_ensure_fill_live_columns``: + SQLite's ALTER TABLE ADD COLUMN only fails if the column exists, so we + check first instead of catching.""" + with engine.begin() as conn: + existing = {r[1] for r in conn.execute(text("PRAGMA table_info(position)")).fetchall()} + for column, sql_type in ( + ("entry_p_model", "FLOAT"), + ("entry_confidence", "VARCHAR"), + ("entry_edge", "FLOAT"), + ): + if column not in existing: + conn.execute(text(f"ALTER TABLE position ADD COLUMN {column} {sql_type}")) + logger.info("migration: added position.%s", column) + + class DatabaseConfig(BaseModel): """Config for the ``database`` section. @@ -78,6 +98,7 @@ async def start(self, engine: Engine | None = None) -> None: self._engine = engine or get_engine() init_db(self._engine) _ensure_fill_live_columns(self._engine) + _ensure_position_entry_columns(self._engine) factory = make_session_factory(self._engine) self._book_writer = WriteBehindWriter(make_order_book_sink(factory)) self._news_writer = WriteBehindWriter(make_news_sink(factory)) @@ -146,6 +167,9 @@ def _writer_stats(writer: WriteBehindWriter | None) -> dict[str, int] | None: return { "written": writer.written, "dropped": writer.dropped, + # Sink failures: a non-zero count means batches were lost to a + # persistence outage, which is otherwise invisible from outside. + "errors": writer.errors, "pending": writer.pending, } diff --git a/openpoly/db/tables.py b/openpoly/db/tables.py index 8615e37..188934c 100644 --- a/openpoly/db/tables.py +++ b/openpoly/db/tables.py @@ -134,3 +134,12 @@ class PositionRow(Base): closed_at: Mapped[float | None] close_reason: Mapped[str | None] realized_pnl: Mapped[float | None] + # Entry-time analyzer/section signals, denormalized onto the position so an + # outcome can be joined back to the belief that opened it. Without them a + # closed position says what happened but not what we predicted, and the + # calibration question ("is p_model 0.7 actually right 70% of the time?") + # is unanswerable after the fact — the analyzer_log ring has long evicted + # the call. Nullable: hand-opened / reconciled positions have no signal. + entry_p_model: Mapped[float | None] = mapped_column(default=None) + entry_confidence: Mapped[str | None] = mapped_column(default=None) + entry_edge: Mapped[float | None] = mapped_column(default=None) diff --git a/openpoly/db/writer.py b/openpoly/db/writer.py index cd67c5a..a954445 100644 --- a/openpoly/db/writer.py +++ b/openpoly/db/writer.py @@ -6,8 +6,19 @@ batch off the event loop. The actual persist call is an injected ``sink``, so the buffering logic is fully testable without a database. -Overflow drops the newest row (and counts it) rather than blocking a producer — -the same discipline as the pipeline orchestrator's queue. +Overflow drops the newest row rather than blocking a producer — the same +discipline as the pipeline orchestrator's queue. Both failure modes (an +overflow drop, a sink error) are *counted* and reported at WARNING rather than +swallowed: a dropped row is a lost order-book or news sample, and a failing +sink is a persistence outage, and neither used to leave any trace outside a +counter nobody read. The reporting is rate-limited to one message per failure +kind per ``WARN_INTERVAL_SECONDS``, carrying the cumulative count — a saturated +queue must not turn its own diagnosis into the flood. + +``stop`` waits for the write already in flight instead of cancelling out from +under it: the worker thread behind ``asyncio.to_thread`` runs to completion +regardless, so cancelling only threw away the bookkeeping for a batch that did +get written. """ from __future__ import annotations @@ -15,6 +26,7 @@ import asyncio import contextlib import logging +import time from collections.abc import Callable from typing import Any @@ -23,6 +35,13 @@ DEFAULT_QUEUE_MAXSIZE = 5000 DEFAULT_BATCH_SIZE = 200 +# One WARNING per failure kind per window, carrying the cumulative count. +WARN_INTERVAL_SECONDS = 60.0 +# How long ``stop`` waits for the in-flight batch. The sink runs in a worker +# thread and cannot be cancelled, so the choice is between waiting for its +# bookkeeping and shutting down while it writes. +STOP_DRAIN_TIMEOUT_SECONDS = 10.0 + # Persists one batch of rows. Sync — runs in a worker thread, off the loop. Sink = Callable[[list[Any]], None] @@ -31,7 +50,8 @@ class WriteBehindWriter: """Bounded queue + a single drain task. ``enqueue`` is sync and non-blocking. ``start`` launches the drain loop; - ``stop`` cancels it and flushes whatever is still queued. + ``stop`` drains the in-flight write, cancels the loop, and flushes whatever + is still queued. """ def __init__( @@ -45,8 +65,14 @@ def __init__( self._batch_size = batch_size self._queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=queue_maxsize) self._task: asyncio.Task[None] | None = None + # The batch currently being written, if any. It lives in its own task + # so ``stop`` can wait for it after cancelling the loop (see _write). + self._inflight: asyncio.Task[None] | None = None self._dropped = 0 self._written = 0 + self._errors = 0 + # kind -> monotonic timestamp of the last WARNING emitted for it. + self._last_warn_at: dict[str, float] = {} @property def dropped(self) -> int: @@ -56,6 +82,11 @@ def dropped(self) -> int: def written(self) -> int: return self._written + @property + def errors(self) -> int: + """Sink failures since start — each one is a batch that was lost.""" + return self._errors + @property def pending(self) -> int: return self._queue.qsize() @@ -68,6 +99,12 @@ def enqueue(self, row: Any) -> bool: return True except asyncio.QueueFull: self._dropped += 1 + self._warn( + "drop", + "write-behind queue full (maxsize %d): dropped %d rows since start", + self._queue.maxsize, + self._dropped, + ) return False async def start(self) -> None: @@ -76,16 +113,59 @@ async def start(self) -> None: self._task = asyncio.create_task(self._drain_loop()) async def stop(self) -> None: - """Cancel the drain loop, then flush whatever is still queued.""" + """Cancel the drain loop, wait for the write already in flight, then + flush whatever is still queued. + + Cancel comes first on purpose: the in-flight write is shielded, so + cancelling stops the loop from starting *another* batch without + touching the one already running. Draining before cancelling would + leave exactly that window open — the loop is free to pick up a new + batch while we wait — and the cancel would then land mid-write after + all. The wait itself is what matters: the sink runs in a worker thread + that completes regardless, so cancelling around it only threw away the + record of a write that did happen.""" if self._task is not None: self._task.cancel() with contextlib.suppress(asyncio.CancelledError): await self._task self._task = None + await self._drain_inflight() await self._flush() # ---------- internals ---------- + def _warn(self, kind: str, message: str, *args: Any) -> None: + """Emit at most one WARNING per ``kind`` per ``WARN_INTERVAL_SECONDS``. + + The counts in ``message`` are cumulative, so a suppressed burst is + still fully accounted for by the next message that does get through. + """ + now = time.monotonic() + last = self._last_warn_at.get(kind) + if last is not None and now - last < WARN_INTERVAL_SECONDS: + return + self._last_warn_at[kind] = now + logger.warning(message, *args) + + async def _drain_inflight(self) -> None: + """Wait (bounded) for the batch currently being persisted.""" + task = self._inflight + if task is None or task.done(): + self._inflight = None + return + try: + await asyncio.wait_for(asyncio.shield(task), timeout=STOP_DRAIN_TIMEOUT_SECONDS) + except asyncio.TimeoutError: + logger.error( + "write-behind: sink still running after %.0fs at shutdown — " + "that batch may not be recorded", + STOP_DRAIN_TIMEOUT_SECONDS, + ) + return # keep the reference so the task is not garbage-collected + except Exception: # noqa: BLE001 — _write already handles sink errors + logger.exception("write-behind: in-flight batch failed during shutdown") + self._inflight = None + async def _drain_loop(self) -> None: while True: batch = [await self._queue.get()] @@ -94,7 +174,7 @@ async def _drain_loop(self) -> None: batch.append(self._queue.get_nowait()) except asyncio.QueueEmpty: break - await self._write(batch) + await self._write_tracked(batch) async def _flush(self) -> None: """Drain everything still queued in one final pass.""" @@ -107,11 +187,34 @@ async def _flush(self) -> None: if batch: await self._write(batch) + async def _write_tracked(self, batch: list[Any]) -> None: + """Run one ``_write`` as a task ``stop`` can wait on. + + Shielded: cancelling the drain loop mid-write cancels this await, but + the task (and the worker thread under it) keeps going, and ``stop`` + drains it. + """ + task = asyncio.create_task(self._write(batch)) + self._inflight = task + try: + await asyncio.shield(task) + finally: + if task.done(): + self._inflight = None + async def _write(self, batch: list[Any]) -> None: """Persist a batch via the sink, off the event loop. Sink errors are - logged and swallowed — a bad write must not kill the drain loop.""" + counted and reported, never raised — a bad write must not kill the + drain loop, but it must not be invisible either.""" try: await asyncio.to_thread(self._sink, batch) self._written += len(batch) - except Exception: # noqa: BLE001 — drain loop must survive sink errors - logger.exception("write-behind sink failed for %d rows", len(batch)) + except Exception as exc: # noqa: BLE001 — drain loop must survive sink errors + self._errors += 1 + self._warn( + "sink", + "write-behind sink failed for %d rows (%d sink errors since start): %r", + len(batch), + self._errors, + exc, + ) diff --git a/openpoly/execution/clob_patch.py b/openpoly/execution/clob_patch.py index f1f4648..7e71644 100644 --- a/openpoly/execution/clob_patch.py +++ b/openpoly/execution/clob_patch.py @@ -7,6 +7,14 @@ must be in place before any SDK module wires its own reference to the helper. Re-exports the SDK symbols callers need so a single import covers both the patch and the client surface. + +Known limitation (observed 08/30/2026, not changed): only ``Origin`` and +``Referer`` reach the wire. The SDK's own ``_overload_headers`` runs inside +``request`` and *assigns* ``User-Agent = "py_clob_client_v2"``, overwriting the +browser UA this patch sets beforehand. Live has been working against Cloudflare +on Origin/Referer alone, and the pattern above is the one verified in +production, so this is documented rather than "fixed" blind — forcing the UA +after ``_overload_headers`` would need a live re-test to justify. """ from __future__ import annotations diff --git a/openpoly/execution/executor.py b/openpoly/execution/executor.py index e016e6c..93ab999 100644 --- a/openpoly/execution/executor.py +++ b/openpoly/execution/executor.py @@ -3,10 +3,17 @@ A fixed system service (not a pluggable section): it turns an entry ``OrderIntent`` or an exit close decision into an actual fill, recorded through ``PortfolioStore``. The fill model is deliberately crude — it takes the order -book's level-1 price (BUY at the best ask, SELL at the best bid) and caps a buy -by that level's depth. No walk-book, no slippage model, no fees (zero-fee -rule). At micro-stakes ($5-$50) an order rarely walks past level 1, so this is -not worth more. +book's level-1 price (BUY at the best ask, SELL at the best bid) and caps the +fill by that level's depth, on **both** sides. No walk-book, no slippage model, +no fees (zero-fee rule). At micro-stakes ($5-$50) an order rarely walks past +level 1, so this is not worth more. + +Paper is the simulation of live, so the two must not disagree about *whether* +an order is fillable or *how much* of it fills: order size and the minimum +notional come from ``openpoly.execution.sizing``, the same module the live +executor sizes through, and a partial sell leaves the remainder open exactly +as the live path does. What stays paper-only is the price model (level-1, no +venue round-trip). Entry and exit share this one executor so their accounting is symmetric. It reads the live ``MarketStore`` singleton directly (same pattern as the @@ -18,6 +25,7 @@ import logging +from openpoly.execution.sizing import MIN_NOTIONAL_USD, dust_remainder_skip, quantize_size from openpoly.execution.types import ExecResult from openpoly.markets.manager import manager as market_source_manager from openpoly.portfolio import CloseReason, HeldPosition, PortfolioStore @@ -25,9 +33,6 @@ logger = logging.getLogger(__name__) -# A fill below this notional (USD) is not worth recording. -MIN_FILL_USD = 1.0 - class PaperExecutor: """Level-1 paper fill service. Routed to by ExecutorDispatcher when @@ -73,8 +78,10 @@ def execute_buy(self, intent: OrderIntent, *, news_id: str | None, ts: float) -> return ExecResult.skip("position_exists") ask_price, ask_size = book.asks[0] - qty = min(intent.qty, ask_size) - if qty * ask_price < MIN_FILL_USD: + # Depth cap, then the venue's whole-share + min-notional rules — the + # same two gates the live executor applies before it signs an order. + qty = quantize_size(min(intent.qty, ask_size), ask_price) + if qty * ask_price < MIN_NOTIONAL_USD: return ExecResult.skip("dust") held = self._store.open_position( @@ -86,6 +93,9 @@ def execute_buy(self, intent: OrderIntent, *, news_id: str | None, ts: float) -> qty=qty, ts=ts, news_id=news_id, + entry_p_model=intent.p_model, + entry_confidence=intent.confidence, + entry_edge=intent.edge, ) logger.info( "buy filled: %s %s qty=%.4f @ %.4f (position %d)", @@ -108,8 +118,17 @@ def execute_sell( """Close a held position at the level-1 bid of its own token's book. Reads ``position.token_id`` directly, so a close never depends on the - market still being in the live catalog. Closes the full quantity - (one-shot model). Skips when the order book / bid liquidity is missing. + market still being in the live catalog. The fill is capped by the + level-1 bid's depth — a book with 4 shares bid cannot absorb a 10-share + exit — and a capped fill leaves the remainder OPEN for the next tick, + the same shape ``LiveExecutor`` records through ``record_sell``. + Selling the whole position into a bid that could not hold it was the + one place paper reported an exit price live could never have realized. + + Skips when the order book / bid liquidity is missing, and when what is + left is below one share — that remainder is not a placeable order but + is still worth its resolution price, so it stays open (see + ``dust_remainder_skip``). """ book = market_source_manager.store.get_order_book(position.token_id) if book is None: @@ -117,9 +136,19 @@ def execute_sell( if not book.bids: return ExecResult.skip("no_bid_liquidity") - bid_price = book.bids[0][0] - self._store.close_position( + bid_price, bid_size = book.bids[0] + qty = quantize_size(min(position.qty, bid_size), bid_price) + if qty <= 0: + # Either the position itself is a sub-share remainder (not + # placeable — leave it open for settlement) or the book's bid is: + # hold and retry on the next tick. + if quantize_size(position.qty, bid_price) <= 0: + return dust_remainder_skip(position) + return ExecResult.skip("bid_depth_below_min_size") + + self._store.record_sell( position.position_id, + sold_qty=qty, sell_price=bid_price, ts=ts, close_reason=close_reason, @@ -129,13 +158,13 @@ def execute_sell( "sell filled: %s %s qty=%.4f @ %.4f (position %d, %s)", position.market_id, position.side, - position.qty, + qty, bid_price, position.position_id, close_reason, ) return ExecResult.ok( price=bid_price, - qty=position.qty, + qty=qty, position_id=position.position_id, ) diff --git a/openpoly/execution/live_executor.py b/openpoly/execution/live_executor.py index 645bf9b..4af96bb 100644 --- a/openpoly/execution/live_executor.py +++ b/openpoly/execution/live_executor.py @@ -21,6 +21,19 @@ The ``_ClobClient`` Protocol lets tests pass a fake without instantiating the real v2 client (which would hit the network at init). + +**No client-side order idempotency.** py-clob-client-v2 exposes no client +order id: ``OrderArgsV2`` carries only ``builder_code`` and a bytes32 +``metadata`` field (neither is queryable), and the read side filters orders by +the *server-assigned* id only (``OpenOrderParams.id`` / ``get_order(order_id)``) +— an id we learn from the very response a lost order loses. There is therefore +no way to ask "did the order I just sent land?" by an id we chose. The only +signal available is the wallet's CTF balance delta, so a pre-order balance read +is not an optimisation here, it is the entire recovery mechanism: without it a +lost response after a real fill becomes an untracked on-chain position. Both +order paths consequently refuse to place an order they could not confirm — +BUY skips with ``ctf_balance_unavailable``, SELL with ``ctf_cache_not_synced``. +Revisit if the SDK ever gains a client-supplied order id. """ from __future__ import annotations @@ -41,6 +54,7 @@ PartialCreateOrderOptions, Side, ) +from openpoly.execution.sizing import MIN_NOTIONAL_USD, dust_remainder_skip, quantize_size from openpoly.execution.types import ExecResult from openpoly.markets.manager import manager as market_source_manager from openpoly.portfolio import CloseReason, HeldPosition, PortfolioStore @@ -52,12 +66,8 @@ POLYGON_CHAIN_ID = 137 SIGTYPE_POLY_1271 = 3 -# Server-side rules verified by live smoke 2026-05-24: -# - marketable BUY: maker amount max 2 decimals (cents); min size $1.00 -# - SELL : taker amount max 4 decimals -# We give a $0.10 buffer over the $1.00 floor so price/rounding wiggle won't -# trip server rejection at the edge. -_MIN_NOTIONAL_PUSD = 1.10 +# Order size + min-notional live in ``openpoly.execution.sizing`` so the paper +# executor obeys exactly the same venue rules (see that module). _CTF_DECIMALS = 6 # CTF / Polymarket shares are 1e6 base units _CTF_POLL_ATTEMPTS = 5 # SELL right after BUY can hit cache lag; ~5s total _CTF_POLL_SLEEP = 1.0 @@ -68,29 +78,6 @@ _CLOSE_PERSIST_SLEEP = 0.5 -def _quantize_size(qty: float, price: float) -> float: - """Floor qty so ``qty * price`` yields a clean ≤2-decimal maker amount. - - Most Polymarket prices are 2-decimal-aligned (cents), in which case - integer qty is sufficient. For 3+ decimal prices we walk down to find - the largest integer qty whose maker amount lands on clean cents. - Returns 0.0 only if no qty in [1, floor(qty)] satisfies the rule, - which the caller handles via the min-notional floor. - """ - base = int(qty) - if base <= 0: - return 0.0 - # Common case: price is 2-dec-aligned → any integer qty is clean. - if abs(price * 100 - round(price * 100)) < 1e-9: - return float(base) - # Rare case: finer price → search. - for candidate in range(base, 0, -1): - maker_cents = candidate * price * 100 - if abs(maker_cents - round(maker_cents)) < 1e-6: - return float(candidate) - return 0.0 - - class _ClobClient(Protocol): def create_and_post_order( self, @@ -208,9 +195,9 @@ def execute_buy(self, intent: OrderIntent, *, news_id: str | None, ts: float) -> # Quantize qty + check min notional against server rules verified # 2026-05-24. Both are pre-flight: cheaper to skip locally than to # eat a 400 round-trip + clutter logs with rejections. - size = _quantize_size(intent.qty, intent.price) + size = quantize_size(intent.qty, intent.price) notional = size * intent.price - if notional < _MIN_NOTIONAL_PUSD: + if notional < MIN_NOTIONAL_USD: return ExecResult.skip("min_notional_below_floor") # Refresh CLOB collateral allowance cache before signing (a prior project pattern). @@ -224,8 +211,20 @@ def execute_buy(self, intent: OrderIntent, *, news_id: str | None, ts: float) -> logger.warning("update_balance_allowance(COLLATERAL) failed: %s", exc) # Pre-order CTF balance — the baseline for lost-response confirmation - # below. None = read failed; confirmation then unavailable. + # below, and the ONLY one available: the SDK has no client order id to + # query a lost order by (see module header). Without the baseline a + # lost response after a real fill becomes an untracked on-chain + # position, so we refuse to place the order rather than trade blind. pre_raw = self._read_ctf_balance_raw(token_id) + if pre_raw is None: + logger.error( + "buy aborted for %s %s: CTF balance unreadable, so a lost order " + "response could not be confirmed — refusing to place an " + "unconfirmable order", + intent.market_id, + intent.side, + ) + return ExecResult.skip("ctf_balance_unavailable") # GTC + crossing the spread acts like an aggressive market order. We # use GTC (not FAK) because a prior project's production verified it end-to-end @@ -245,11 +244,7 @@ def execute_buy(self, intent: OrderIntent, *, news_id: str | None, ts: float) -> except Exception as exc: # noqa: BLE001 # The order may have filled despite the lost response — confirm via # the balance before declaring failure (R5 at-least-once). - got = ( - self._confirm_lost_order_qty(token_id, pre_raw, "rise") - if pre_raw is not None - else 0.0 - ) + got = self._confirm_lost_order_qty(token_id, pre_raw, "rise") if got > 0: actual_qty = min(got, size) # Response (and with it the real fill price) is lost; record at @@ -272,6 +267,9 @@ def execute_buy(self, intent: OrderIntent, *, news_id: str | None, ts: float) -> news_id=news_id, order_id=None, tx_hash=None, + entry_p_model=intent.p_model, + entry_confidence=intent.confidence, + entry_edge=intent.edge, ) return ExecResult.ok( price=intent.price, @@ -310,6 +308,9 @@ def execute_buy(self, intent: OrderIntent, *, news_id: str | None, ts: float) -> news_id=news_id, order_id=order_id, tx_hash=tx_hash, + entry_p_model=intent.p_model, + entry_confidence=intent.confidence, + entry_edge=intent.edge, ) logger.info( "live buy filled: %s %s qty=%.4f @ %.4f order=%s tx=%s", @@ -340,12 +341,14 @@ def execute_sell( return ExecResult.skip("no_bid_liquidity") bid_price = book.bids[0][0] - # Quantize SELL size symmetrically with BUY so taker (size * price) - # stays within server precision. Most likely a no-op since BUY also - # quantized, but defensive for partial-fill positions or hand-opened. - size = _quantize_size(position.qty, bid_price) + # Quantize SELL size symmetrically with BUY so the size stays within + # server precision. + size = quantize_size(position.qty, bid_price) if size <= 0: - return ExecResult.skip("min_notional_below_floor") + # Below one share: not a placeable order. The remainder still + # settles at the resolution price, so leave the row open rather + # than writing it off at 0. + return dust_remainder_skip(position) # Poll CTF balance — handles cache lag when SELL fires shortly after # BUY (live smoke testing saw ~3-5s lag). update_balance_allowance is diff --git a/openpoly/execution/sizing.py b/openpoly/execution/sizing.py new file mode 100644 index 0000000..54dfca9 --- /dev/null +++ b/openpoly/execution/sizing.py @@ -0,0 +1,116 @@ +"""Order sizing — the one venue rule both executors obey. + +``PaperExecutor`` and ``LiveExecutor`` used to carry independent fill models: +paper floored a fill at $1.00 and never quantized, live floored at $1.10 and +floored qty to whole shares. Paper is the simulation of live, so a paper fill +that live would have rejected is a lie about the strategy's realized behavior. +Both now size through this module. + +The rules are the Polymarket V2 CLOB rules, read off the SDK's own +``ROUNDING_CONFIG`` (``py_clob_client_v2.order_builder.builder``): + +* **Two size decimals.** Every tick size in that table — 0.1, 0.01, 0.001, + 0.0001 — carries ``size=2``, and the builder itself rounds the derived + maker/taker *amount* to its own ``amount`` decimals (4–6). So the client only + has to floor the size to 2 decimals; it must not additionally demand that + ``qty * price`` land on clean cents. That extra demand was the bug: at a + 3-decimal price (0.999, 0.993 — the 0.001-tick regime above 0.96 where + winners exit) no whole-share qty aligns to a cent, so a perfectly sellable + 9-share position quantized to 0 and was treated as unsellable dust. +* **One share minimum.** A remainder below one share is not worth placing — + it cannot clear the venue's $1 minimum at any price ≤ 1.0. It quantizes to + 0, and the sell side leaves that remainder OPEN (see + ``dust_remainder_skip``) rather than writing it off. +* **Minimum notional.** The server minimum order is $1.00. We floor at $1.10 + so price/rounding wiggle at the edge cannot trip a server rejection. +""" + +from __future__ import annotations + +import logging +import math +from typing import TYPE_CHECKING + +from openpoly.execution.types import ExecResult + +if TYPE_CHECKING: + from openpoly.portfolio import HeldPosition + +logger = logging.getLogger(__name__) + +# $1.00 server minimum + a $0.10 buffer for price/rounding wiggle. +MIN_NOTIONAL_USD = 1.10 + +# ``RoundConfig.size`` is 2 for every tick size in the SDK's ROUNDING_CONFIG, +# so the tick size is not needed to floor a size. ``MIN_SELLABLE_QTY`` in +# openpoly/portfolio/store.py is the ``10**-SIZE_DECIMALS`` twin of this +# constant, duplicated there so the portfolio layer keeps no execution import. +SIZE_DECIMALS = 2 + +# One share is the smallest remainder worth placing: below it no price <= 1.0 +# can clear the venue's $1 minimum notional. This is the single definition of +# "dust" — the exit monitor uses it to skip a position before evaluating it, +# and the executors use it (via ``quantize_size`` / ``dust_remainder_skip``) as +# the defensive fallback. If they disagreed, the monitor would keep producing +# sells the executors can only ever skip. +MIN_SELL_SHARES = 1.0 + +# Positions already warned about as an unsellable remainder — the exit monitor +# retries every tick and one WARNING per tick would be pure noise. +_dust_warned: set[int] = set() + + +def is_dust_qty(qty: float) -> bool: + """True when ``qty`` is below the venue's one-share sell minimum. + + Callers that hold a position (rather than a candidate order size) ask this + directly instead of round-tripping through ``quantize_size``, which needs a + price they may not have. + """ + return qty < MIN_SELL_SHARES + + +def quantize_size(qty: float, price: float) -> float: + """Floor ``qty`` to a size the venue accepts. + + ``price`` is unused: the SDK allows 2 size decimals at every tick size and + rounds the resulting maker/taker amount itself, so the price cannot make a + size invalid. It stays in the signature because sizing is a per-(qty, + price) venue question and both call sites read as such. + + Returns 0.0 only for a genuine remainder below one share — never for a qty + the venue would have accepted. + """ + if is_dust_qty(qty): + return 0.0 + scale = 10**SIZE_DECIMALS + # round() before floor() so binary representation (5.56 * 100 = + # 555.99999999999994) cannot silently shave a hundredth off the size. + return math.floor(round(qty * scale, 6)) / scale + + +def dust_remainder_skip(position: "HeldPosition") -> ExecResult: + """Skip a sell whose remaining qty is below one share. + + A partial sell can leave less than one share open, which is not a placeable + order — but it is not worthless either: the settlement monitor closes the + row at the resolution price (1.0 on the winning side), so the remainder is + worth ``qty * resolution_price``. Closing it at 0.0 instead booked a fake + realized loss and orphaned tokens that were still in the wallet. + + So the position stays OPEN and the sell skips. The cost is honest and + bounded: the row counts toward the open-position list and ``heat_cap_usd`` + until the market resolves. Warns once per position — the exit monitor + retries every tick. + """ + if position.position_id not in _dust_warned: + _dust_warned.add(position.position_id) + logger.warning( + "dust remainder: position %d (%s %s) has %.6f shares left — below " + "one share, not a placeable order; left open for settlement to close", + position.position_id, + position.market_id, + position.side, + position.qty, + ) + return ExecResult.skip("dust_remainder") diff --git a/openpoly/portfolio/models.py b/openpoly/portfolio/models.py index 3484fe7..b9efbcd 100644 --- a/openpoly/portfolio/models.py +++ b/openpoly/portfolio/models.py @@ -68,6 +68,13 @@ class PositionRecord: ``realized_pnl`` is a materialized derived value: it equals ``(sell_price - avg_entry_price) * qty`` from the two fills and is recomputable from the ledger, not an authoritative mutable field. + + ``entry_*`` are the entry decision's own inputs, frozen at open time: the + analyzer's ``p_model`` and ``confidence`` and the entry section's ``edge`` + (``fair - held_price``; the held price itself is ``avg_entry_price``). + They exist so an outcome can be joined back to the belief that produced it + — see ``openpoly.analytics.calibration``. None on any position opened + without an entry decision (manual, reconciled, pre-calibration rows). """ id: int @@ -82,3 +89,6 @@ class PositionRecord: closed_at: float | None close_reason: str | None realized_pnl: float | None + entry_p_model: float | None = None + entry_confidence: str | None = None + entry_edge: float | None = None diff --git a/openpoly/portfolio/store.py b/openpoly/portfolio/store.py index 408ee7c..612b592 100644 --- a/openpoly/portfolio/store.py +++ b/openpoly/portfolio/store.py @@ -12,7 +12,9 @@ from __future__ import annotations -from sqlalchemy import select +from collections.abc import Sequence + +from sqlalchemy import func, select from sqlalchemy.orm import Session, sessionmaker from openpoly.db.tables import FillRow, PositionRow @@ -24,9 +26,15 @@ Side, ) -# A residual qty at or below this (Polymarket sizes are ≤6 decimals) counts as -# fully sold — closes the position rather than leaving a dust remainder open. -_QTY_EPS = 1e-6 +# The venue's size precision: Polymarket accepts 2 size decimals, so 0.01 +# shares is the smallest quantity that can be expressed as an order size at +# all. A residual below it is unsellable by construction — not float noise to +# be tolerated but a quantity no exit can ever clear — so ``record_sell`` +# treats it as fully sold. Deliberately duplicated from ``SIZE_DECIMALS`` in +# openpoly/execution/sizing.py (10**-2): the portfolio layer is below +# execution and must not import it. tests/test_execution_sizing.py pins the +# two together. +MIN_SELLABLE_QTY = 0.01 def _to_held(row: PositionRow) -> HeldPosition: @@ -56,6 +64,9 @@ def _to_record(row: PositionRow) -> PositionRecord: closed_at=row.closed_at, close_reason=row.close_reason, realized_pnl=row.realized_pnl, + entry_p_model=row.entry_p_model, + entry_confidence=row.entry_confidence, + entry_edge=row.entry_edge, ) @@ -97,10 +108,18 @@ def open_position( news_id: str | None = None, order_id: str | None = None, # NEW tx_hash: str | None = None, # NEW + entry_p_model: float | None = None, + entry_confidence: str | None = None, + entry_edge: float | None = None, ) -> HeldPosition: """Open a position: insert the position-projection row + its buy fill in one transaction. Raises ``IntegrityError`` if an open position for (market_id, side) already exists — the partial unique index backstop. + + ``entry_p_model`` / ``entry_confidence`` / ``entry_edge`` are the entry + decision's own inputs, stored on the position so the outcome can later + be joined to the belief (calibration). They default to None: a manual + or reconciled open has no entry decision behind it. """ with self._session_factory() as session: pos = PositionRow( @@ -115,6 +134,9 @@ def open_position( closed_at=None, close_reason=None, realized_pnl=None, + entry_p_model=entry_p_model, + entry_confidence=entry_confidence, + entry_edge=entry_edge, ) session.add(pos) session.flush() # populate pos.id for the fill's position_id @@ -204,6 +226,10 @@ def record_sell( next exit tick sells the remainder — otherwise the unsold tokens are stranded on-chain as an orphan (the orphaned-remainder bug). Realized PnL accrues across partials. Raises ``ValueError`` if missing or not open. + + A residual below ``MIN_SELLABLE_QTY`` counts as fully sold: no exit + tick can ever clear it, so leaving the row open leaves it open + forever. """ with self._session_factory() as session: pos = session.get(PositionRow, position_id) @@ -229,12 +255,21 @@ def record_sell( ) ) pos.realized_pnl = (pos.realized_pnl or 0.0) + (sell_price - pos.avg_entry_price) * sold - if pos.qty - sold <= _QTY_EPS: + residual = pos.qty - sold + if residual < MIN_SELLABLE_QTY: + # Fully sold, or all that is left is a residue the venue has no + # order size for. The residue is dropped rather than carried: + # at any price ≤ 1.0 it is worth under $0.01, and keeping the + # row open for it costs a heat-cap slot plus an exit decision + # every tick until the market resolves. ``realized_pnl`` stays + # as accrued from the legs actually sold — the dropped residue + # is not booked as a loss. + pos.qty = sold pos.status = "closed" pos.closed_at = ts pos.close_reason = close_reason else: - pos.qty = pos.qty - sold + pos.qty = residual session.commit() return _to_record(pos) @@ -286,6 +321,34 @@ def list_fills(self, limit: int = 100) -> list[Fill]: ) return [_to_fill(r) for r in rows] + def buy_cost_basis(self, position_ids: Sequence[int]) -> dict[int, float]: + """What was actually paid to open each position — the sum of its BUY + fill notional, keyed by position id. + + ``PositionRow.qty`` cannot answer this: ``record_sell`` decrements it, + so after a partial sell the row carries the *residual* while + ``realized_pnl`` carries the gain on the whole thing. Measuring a + return against that residual inflates it without bound (see + ``openpoly.analytics.calibration``). The fill ledger is append-only, so + the opening cost is always recoverable from it. + + Positions with no BUY fill (reconciled or manually created rows) are + simply absent from the mapping. + """ + if not position_ids: + return {} + with self._session_factory() as session: + rows = session.execute( + select( + FillRow.position_id, + func.sum(FillRow.price * FillRow.qty), + ) + .where(FillRow.action == "buy") + .where(FillRow.position_id.in_(list(position_ids))) + .group_by(FillRow.position_id) + ).all() + return {int(pid): float(total or 0.0) for pid, total in rows} + def news_id_for_position(self, position_id: int) -> str | None: """Look up the news_id that triggered this position's BUY fill. diff --git a/openpoly/runtime/exit_monitor.py b/openpoly/runtime/exit_monitor.py index 2920f49..b209e10 100644 --- a/openpoly/runtime/exit_monitor.py +++ b/openpoly/runtime/exit_monitor.py @@ -48,6 +48,7 @@ from openpoly.db.tables import OrderBookSnapshot from openpoly.execution import ExecResult from openpoly.execution import executor as _executor_singleton +from openpoly.execution.sizing import is_dust_qty from openpoly.markets.manager import manager as market_source_manager from openpoly.markets.models import OrderBook from openpoly.markets.store import MarketStore @@ -178,6 +179,13 @@ def __init__( # position becomes markable (or closes), so a book that goes thin twice # is logged twice. self._unmarkable: set[int] = set() + # Positions already logged as ``dust_remainder`` — same dedup contract + # as ``_unmarkable``. A sub-one-share remainder is not a placeable + # order, so evaluating it produced a CloseIntent → a sell the executor + # can only skip → an ``error`` row, every tick, for as long as the + # market stayed unresolved (~720 rows/day into a 200-entry ring). One + # row per position; the id is dropped again when it stops being open. + self._dust: set[int] = set() @property def state(self) -> State: @@ -356,9 +364,11 @@ async def _tick_once(self) -> None: for held in opens: watch.setdefault(held.token_id, []).append(held.position_id) self._watch = watch - # Drop the unmarkable marker for anything no longer open, so the set - # can't grow across the process lifetime. - self._unmarkable &= {held.position_id for held in opens} + # Drop the unmarkable / dust markers for anything no longer open, so + # neither set can grow across the process lifetime. + open_ids = {held.position_id for held in opens} + self._unmarkable &= open_ids + self._dust &= open_ids blocked = 0 for held in opens: try: @@ -374,9 +384,21 @@ async def _tick_once(self) -> None: async def _evaluate(self, held: HeldPosition, catalog: MarketStore, ts: float) -> bool: """Evaluate one position. Returns True when it could not be evaluated (no order book, or no level deep enough to mark against — counted as - ``blocked``); False when held within thresholds or closed. ok / error - closes are logged; within-threshold and unmarkable holds are not (see - tick telemetry).""" + ``blocked``); False when held within thresholds, dust, or closed. ok / + error closes are logged; within-threshold and unmarkable holds are not + (see tick telemetry).""" + if is_dust_qty(held.qty): + # A remainder below one share cannot be sold at all (see + # execution.sizing): the row stays open until settlement closes it + # at the resolution price. Evaluating it anyway means a CloseIntent + # every tick and an ``error`` row for a sell that was never + # placeable — noise that evicts the real closes from the log ring. + # Not blocked either: nothing is wrong with the book, there is + # simply nothing to do. Logged once per position (see ``_dust``). + if held.position_id not in self._dust: + self._dust.add(held.position_id) + self._log(held, ts, verdict="skip", reason="dust_remainder") + return False book = catalog.get_order_book(held.token_id) if book is None or not book.bids: return True diff --git a/openpoly/sections/entry/edge_threshold_v0.py b/openpoly/sections/entry/edge_threshold_v0.py index f42a7e9..8a6ae85 100644 --- a/openpoly/sections/entry/edge_threshold_v0.py +++ b/openpoly/sections/entry/edge_threshold_v0.py @@ -7,10 +7,17 @@ spread = best ask - best bid edge = (p_model if YES else 1 - p_model) - held_price -A position is sized by ``order_size_usd`` at ``held_price``. The section emits a -decision only — an ``OrderIntent``; the executor turns it into an actual fill -and is authoritative on the realized price. The book is read level-1 only -(best ask / best bid), matching the executor's crude micro-stakes fill model. +A position is sized by ``order_size_usd`` at ``held_price``, optionally scaled +by how far the edge exceeds ``min_edge`` (``size_edge_multiplier_max``, off by +default — see that field). The section emits a decision only — an +``OrderIntent``; the executor turns it into an actual fill and is authoritative +on the realized price. The book is read level-1 only (best ask / best bid), +matching the executor's crude micro-stakes fill model. + +The intent also carries the belief behind it (``p_model`` / ``confidence`` / +``edge``) so the executor can freeze it onto the position row; that is what +``GET /api/analytics/calibration`` later reads, and it is the evidence that has +to exist before edge-scaled sizing is turned on. The section reads the live ``MarketStore`` singleton directly (same pattern as the embedding section — no capability injection). When configured with a @@ -52,12 +59,22 @@ class OrderIntent: ``price`` is the level-1 ask the section saw; the executor re-reads the live book at fill time and is authoritative on the actual fill price / qty. + + ``p_model`` / ``confidence`` / ``edge`` are the belief behind the decision. + They are carried here purely so the executor can freeze them onto the + position row: the analyzer_log ring evicts a call within a few hundred news + events, long before the position it opened closes, so without this the + outcome could never be joined back to the prediction (calibration). They + default to None so a hand-built intent stays valid. """ market_id: str side: Side price: float qty: float + p_model: float | None = None + confidence: str | None = None + edge: float | None = None class EdgeThresholdConfig(BaseModel): @@ -114,6 +131,22 @@ class EdgeThresholdConfig(BaseModel): "``same_market_cooldown_minutes`` is ignored." ), ) + size_edge_multiplier_max: float = Field( + default=1.0, + ge=1.0, + le=5.0, + description=( + "Scale the order with the edge: notional = order_size_usd × " + "clamp(edge / min_edge, 1.0, this). 1.0 (the default) disables " + "scaling entirely — sizing stays exactly order_size_usd / " + "held_price, as it always was. Raise it ONLY after GET " + "/api/analytics/calibration shows p_model is actually calibrated: " + "each bucket's win rate close to its own midpoint, with n ≥ 100 " + "behind the buckets being relied on. Betting more on a larger " + "'edge' computed from an uncalibrated probability only loses " + "faster. heat_cap_usd, when set, still bounds the scaled notional." + ), + ) heat_cap_usd: float = Field( default=0.0, ge=0.0, @@ -169,7 +202,7 @@ class EdgeThresholdConfig(BaseModel): class EdgeThresholdEntryV0: SECTION_TYPE = "entry" - SECTION_VERSION = "0.3.0" + SECTION_VERSION = "0.4.0" REQUIRES = ["order_book", "market_data"] Config = EdgeThresholdConfig @@ -207,6 +240,10 @@ def run(self, input: SectionInput) -> SectionOutput: portfolio = ( self._portfolio_provider() if needs_portfolio and self._portfolio_provider else None ) + # Kept for the sizing step below: the heat cap has to bound what is + # actually bought, so a scaled order is capped by the headroom left + # over this same open exposure. None when the gate is off. + open_cost: float | None = None if portfolio is not None: # heat_cap: portfolio-wide ceiling. One get_open_positions call, # only sums the currently-open set, returns fast. @@ -319,10 +356,52 @@ def run(self, input: SectionInput) -> SectionOutput: signals=signals, ) - qty = self.config.order_size_usd / held_price - intent = OrderIntent(market_id=res.market_id, side=side, price=held_price, qty=qty) + notional = self._scaled_notional(edge, open_cost) + multiplier = notional / self.config.order_size_usd + if multiplier != 1.0: + signals["size_multiplier"] = round(multiplier, 4) + qty = notional / held_price + intent = OrderIntent( + market_id=res.market_id, + side=side, + price=held_price, + qty=qty, + # The belief behind the decision, carried so the executor can + # freeze it onto the position row (calibration). + p_model=res.p_model, + confidence=res.confidence, + edge=edge, + ) return SectionOutput(payload=intent, verdict="ok", signals=signals) + def _scaled_notional(self, edge: float, open_cost: float | None) -> float: + """Order notional in USD — ``order_size_usd``, optionally scaled by edge. + + The multiplier is ``clamp(edge / min_edge, 1.0, size_edge_multiplier_max)`` + and never shrinks an order: at the default cap of 1.0 this returns + exactly ``order_size_usd``, so sizing is unchanged from before the knob + existed. ``min_edge == 0`` makes the ratio meaningless (and undefined), + so scaling is off there too. + + ``heat_cap_usd`` then bounds the *scaled* notional: the extra size the + multiplier grants is trimmed to the headroom left over currently-open + exposure. It is deliberately never trimmed below ``order_size_usd`` — + the cap's existing job is to gate on exposure already taken (that + pre-check is unchanged), not to shrink the base order — so a config + with the knob at its 1.0 default behaves exactly as it did. + """ + base = self.config.order_size_usd + cap = self.config.size_edge_multiplier_max + if cap <= 1.0 or self.config.min_edge <= 0.0: + return base + multiplier = max(1.0, min(edge / self.config.min_edge, cap)) + heat_cap = self.config.heat_cap_usd + if heat_cap > 0 and open_cost is not None: + headroom = heat_cap - open_cost + if headroom < base * multiplier: + multiplier = max(1.0, headroom / base) + return base * multiplier + @staticmethod def CONTRACT_TEST() -> None: inst = EdgeThresholdEntryV0(EdgeThresholdConfig()) diff --git a/tests/test_analytics_calibration.py b/tests/test_analytics_calibration.py new file mode 100644 index 0000000..ccd843a --- /dev/null +++ b/tests/test_analytics_calibration.py @@ -0,0 +1,179 @@ +"""Tests for openpoly.analytics.calibration — the pure bucketing function. + +Mostly synthetic ``PositionRecord``s: the bucketing itself needs no DB and no +live catalog. The cost basis a return is measured against is the exception — +it comes from the BUY fill ledger, because the position row's ``qty`` is the +*residual* after partial sells. +""" + +from __future__ import annotations + +import pytest + +from openpoly.analytics.calibration import BUCKET_EDGES, calibration_report +from openpoly.db.engine import init_db, make_engine, make_session_factory +from openpoly.portfolio import PortfolioStore, PositionRecord + + +@pytest.fixture +def store(tmp_path): + engine = make_engine(f"sqlite:///{tmp_path}/p.db") + init_db(engine) + yield PortfolioStore(make_session_factory(engine)) + engine.dispose() + + +def _closed( + *, + p_model: float, + realized_pnl: float, + side: str = "yes", + qty: float = 10.0, + entry: float = 0.50, + position_id: int = 1, +) -> PositionRecord: + return PositionRecord( + id=position_id, + market_id=f"m{position_id}", + side=side, # type: ignore[arg-type] + token_id=f"t{position_id}", + condition_id=f"0xm{position_id}", + qty=qty, + avg_entry_price=entry, + status="closed", + opened_at=100.0, + closed_at=200.0, + close_reason="take_profit", + realized_pnl=realized_pnl, + entry_p_model=p_model, + entry_confidence="medium", + entry_edge=0.10, + ) + + +def _open(*, p_model: float, position_id: int = 99) -> PositionRecord: + return PositionRecord( + id=position_id, + market_id="mo", + side="yes", + token_id="to", + condition_id="0xmo", + qty=10.0, + avg_entry_price=0.50, + status="open", + opened_at=100.0, + closed_at=None, + close_reason=None, + realized_pnl=None, + entry_p_model=p_model, + ) + + +def test_report_shape_is_one_bucket_per_edge_pair() -> None: + buckets = calibration_report([]) + assert [(b.lower, b.upper) for b in buckets] == list(zip(BUCKET_EDGES, BUCKET_EDGES[1:])) + assert all(b.count == 0 for b in buckets) + assert all(b.win_rate is None and b.mean_return is None for b in buckets) + + +def test_buckets_by_probability_and_counts_wins() -> None: + positions = [ + _closed(p_model=0.62, realized_pnl=1.0, position_id=1), + _closed(p_model=0.65, realized_pnl=-2.0, position_id=2), + _closed(p_model=0.68, realized_pnl=3.0, position_id=3), + _closed(p_model=0.75, realized_pnl=-1.0, position_id=4), + ] + by_lower = {b.lower: b for b in calibration_report(positions)} + + assert by_lower[0.6].count == 3 + assert by_lower[0.6].win_rate == pytest.approx(2 / 3) + assert by_lower[0.7].count == 1 + assert by_lower[0.7].win_rate == 0.0 + assert by_lower[0.5].count == 0 + + +def test_mean_return_is_realized_pnl_over_cost_basis() -> None: + # cost = 0.50 * 10 = $5; +$1 → +20%, -$2 → -40%; mean = -10%. + positions = [ + _closed(p_model=0.55, realized_pnl=1.0, position_id=1), + _closed(p_model=0.55, realized_pnl=-2.0, position_id=2), + ] + bucket = next(b for b in calibration_report(positions) if b.lower == 0.5) + assert bucket.mean_return == pytest.approx(-0.10) + + +def test_no_side_is_bucketed_by_the_held_side_probability() -> None: + """A NO position on p_model=0.2 is a 0.8 bet on the side actually held — + bucketing the raw 0.2 would drop it out of the report entirely.""" + positions = [_closed(p_model=0.2, side="no", realized_pnl=1.0)] + by_lower = {b.lower: b for b in calibration_report(positions)} + assert by_lower[0.8].count == 1 + + +def test_open_and_unlabelled_positions_are_excluded() -> None: + """Only a closed position has an outcome; only a labelled one has a + prediction. Anything else would bias the report.""" + unlabelled = _closed(p_model=0.7, realized_pnl=1.0, position_id=2) + unlabelled = PositionRecord(**{**unlabelled.__dict__, "entry_p_model": None}) + positions = [_open(p_model=0.7), unlabelled] + assert all(b.count == 0 for b in calibration_report(positions)) + + +def test_top_bucket_includes_certainty() -> None: + """p=1.0 belongs in the last bucket, not off the end of it.""" + by_lower = {b.lower: b for b in calibration_report([_closed(p_model=1.0, realized_pnl=1.0)])} + assert by_lower[0.9].count == 1 + + +def test_bucket_boundary_belongs_to_the_upper_bucket() -> None: + by_lower = {b.lower: b for b in calibration_report([_closed(p_model=0.70, realized_pnl=1.0)])} + assert by_lower[0.7].count == 1 + assert by_lower[0.6].count == 0 + + +def test_zero_cost_basis_does_not_divide_by_zero() -> None: + positions = [_closed(p_model=0.65, realized_pnl=0.0, entry=0.0, position_id=1)] + bucket = next(b for b in calibration_report(positions) if b.lower == 0.6) + assert bucket.count == 1 + assert bucket.mean_return == 0.0 + + +def test_return_is_measured_against_the_opened_cost_basis_not_the_residual(store) -> None: + """Buy 10 @ 0.40 ($4.00), sell 9.4 @ 0.55, then close the 0.6 remainder at + 0.0. Realized is $1.17 — a +29% trade. Measuring it against the *residual* + 0.6 shares ($0.24) reports +488% and makes an uncalibrated model look + spectacular.""" + held = store.open_position( + market_id="m1", + side="yes", + token_id="t1", + condition_id="0xm1", + price=0.40, + qty=10.0, + ts=100.0, + news_id="n", + entry_p_model=0.75, + entry_confidence="medium", + entry_edge=0.10, + ) + store.record_sell( + held.position_id, + sold_qty=9.4, + sell_price=0.55, + ts=150.0, + close_reason="take_profit", + ) + store.close_position( + held.position_id, + sell_price=0.0, + ts=200.0, + close_reason="settlement", + ) + + positions = store.list_positions(10) + assert positions[0].realized_pnl == pytest.approx(1.17) + buckets = calibration_report(positions, store.buy_cost_basis([p.id for p in positions])) + + bucket = next(b for b in buckets if b.lower == 0.7) + assert bucket.count == 1 + assert bucket.mean_return == pytest.approx(0.2925) diff --git a/tests/test_api_portfolio.py b/tests/test_api_portfolio.py index adb08d3..4e56e89 100644 --- a/tests/test_api_portfolio.py +++ b/tests/test_api_portfolio.py @@ -450,3 +450,56 @@ def test_list_positions_market_question_null_when_no_catalog(env) -> None: assert p["market_question"] is None # analyzer_decisions still present (empty list when no log match) assert isinstance(p["analyzer_decisions"], list) + + +# ---------- GET /api/analytics/calibration ---------- + + +def test_calibration_empty_returns_every_bucket(env) -> None: + _store, client = env + r = client.get("/api/analytics/calibration") + assert r.status_code == 200, r.text + body = r.json() + assert body["sample_size"] == 0 + assert len(body["buckets"]) == 5 + assert body["buckets"][0]["lower"] == 0.5 + assert body["buckets"][0]["win_rate"] is None + + +def test_calibration_reports_closed_labelled_positions(env) -> None: + store, client = env + for i, (p_model, sell) in enumerate([(0.72, 1.0), (0.75, 0.0), (0.62, 1.0)], start=1): + held = store.open_position( + market_id=f"m{i}", + side="yes", + token_id=f"t{i}", + condition_id=f"0xm{i}", + price=0.50, + qty=10.0, + ts=100.0 + i, + news_id=f"n{i}", + entry_p_model=p_model, + entry_confidence="high", + entry_edge=0.20, + ) + store.close_position( + held.position_id, sell_price=sell, ts=300.0 + i, close_reason="take_profit" + ) + # An unlabelled position must not enter the sample. + store.open_position( + market_id="m9", + side="yes", + token_id="t9", + condition_id="0xm9", + price=0.50, + qty=10.0, + ts=150.0, + ) + + body = client.get("/api/analytics/calibration").json() + by_lower = {b["lower"]: b for b in body["buckets"]} + assert body["sample_size"] == 3 + assert by_lower[0.7]["count"] == 2 + assert by_lower[0.7]["win_rate"] == pytest.approx(0.5) + assert by_lower[0.6]["count"] == 1 + assert by_lower[0.6]["win_rate"] == 1.0 diff --git a/tests/test_api_portfolio_close.py b/tests/test_api_portfolio_close.py index d0d8ec0..1a4609a 100644 --- a/tests/test_api_portfolio_close.py +++ b/tests/test_api_portfolio_close.py @@ -18,6 +18,7 @@ from openpoly.markets.models import OrderBook from openpoly.markets.store import MarketStore from openpoly.portfolio import PortfolioStore +from openpoly.runtime.closing_registry import closing_ids, is_closing, mark_closing @pytest.fixture @@ -188,3 +189,87 @@ def test_close_all_partial_failure_does_not_block_others(env) -> None: assert store.get_position(p3.position_id).status == "closed" # 2 still open. assert store.get_position(p2.position_id).status == "open" + + +# ---------- in-flight close claims (the exit monitor holds the position) ---------- + + +def test_close_conflicts_with_an_in_flight_exit(env) -> None: + """The exit monitor's sell runs in a worker thread, so its position stays + ``open`` in the DB for seconds while the tokens are already being sold. + A manual close in that window is a second on-chain sell of the same + position — refuse it.""" + store, client = env + held = _open(store) + market_source_manager.store.set_order_books([_book("t1", bid=0.55)]) + mark_closing(held.position_id) + + r = client.post(f"/api/positions/{held.position_id}/close") + + assert r.status_code == 409 + assert r.json()["detail"] == "exit_in_flight" + # Untouched: the exit monitor still owns this position. + assert store.get_position(held.position_id).status == "open" + + +def test_close_registers_and_releases_the_claim(env) -> None: + """The manual close must itself claim the position, so the settlement and + reconciliation monitors skip it while the sell is in flight — and must + release it on the way out.""" + store, client = env + held = _open(store) + market_source_manager.store.set_order_books([_book("t1", bid=0.55)]) + + seen: list[bool] = [] + + class _Watching: + def execute_sell(self, position, *, close_reason, ts, trigger=None): + seen.append(is_closing(position.position_id)) + return Executor(store).execute_sell( + position, close_reason=close_reason, ts=ts, trigger=trigger + ) + + portfolio_routes.executor = _Watching() + r = client.post(f"/api/positions/{held.position_id}/close") + + assert r.status_code == 200, r.text + assert seen == [True] # claimed for the duration of the sell + assert closing_ids() == frozenset() # released afterwards + + +def test_close_releases_the_claim_when_the_sell_raises(env) -> None: + store, client = env + held = _open(store) + + class _Boom: + def execute_sell(self, position, *, close_reason, ts, trigger=None): + raise RuntimeError("clob down") + + portfolio_routes.executor = _Boom() + with pytest.raises(RuntimeError): + client.post(f"/api/positions/{held.position_id}/close") + + assert closing_ids() == frozenset() + + +def test_close_all_skips_positions_with_an_in_flight_exit(env) -> None: + """Bulk close must not fight the exit monitor either — the claimed ids are + skipped and reported, the rest still close.""" + store, client = env + p1 = _open(store, token_id="t1", market_id="m1") + p2 = _open(store, token_id="t2", market_id="m2") + market_source_manager.store.set_order_books([_book("t1", bid=0.55), _book("t2", bid=0.50)]) + mark_closing(p2.position_id) + + r = client.post("/api/positions/close-all") + + assert r.status_code == 200, r.text + body = r.json() + assert body["attempted"] == 2 + assert body["filled"] == 1 + assert body["skipped"] == 1 + by_id = {d["position_id"]: d for d in body["details"]} + assert by_id[p2.position_id]["ok"] is False + assert by_id[p2.position_id]["skip_reason"] == "exit_in_flight" + assert store.get_position(p1.position_id).status == "closed" + assert store.get_position(p2.position_id).status == "open" diff --git a/tests/test_clob_patch.py b/tests/test_clob_patch.py new file mode 100644 index 0000000..a2646c8 --- /dev/null +++ b/tests/test_clob_patch.py @@ -0,0 +1,213 @@ +"""Tests for openpoly.execution.clob_patch — the Cloudflare header patch. + +The module monkey-patches the SDK's HTTP helper at import time and re-exports +the SDK surface the executor uses. Both halves are load-bearing and neither was +covered: a patch that silently stopped applying (an SDK refactor of +``http_helpers.request``) would fail only against live Cloudflare, and a +re-export that disappeared would fail only at live-executor construction. + +No network: the transport is replaced with a recorder. +""" + +from __future__ import annotations + +import pytest +from py_clob_client_v2.http_helpers import helpers as v2_helpers + +from openpoly.execution import clob_patch + +BROWSER_UA_PREFIX = "Mozilla/5.0" + + +# ---------- the patch is installed ---------- + + +def test_sdk_request_is_the_patched_callable() -> None: + """Import order matters: everything else imports the SDK through this + module precisely so the helper is already swapped.""" + assert v2_helpers.request is clob_patch._patched_request + + +# ---------- header injection ---------- + + +class _Recorder: + """Stands in for ``_orig_request`` — records what the patch handed down.""" + + def __init__(self) -> None: + self.calls: list[tuple[tuple, dict]] = [] + + def __call__(self, *args, **kwargs): + self.calls.append((args, kwargs)) + return {"ok": True} + + +def test_patch_injects_browser_headers(monkeypatch) -> None: + rec = _Recorder() + monkeypatch.setattr(clob_patch, "_orig_request", rec) + + out = v2_helpers.request( + "https://clob.polymarket.com/order", + "POST", + {"Accept": "application/json"}, + {"body": 1}, + ) + + assert out == {"ok": True} + args, _kwargs = rec.calls[0] + headers = args[2] + assert headers["User-Agent"].startswith(BROWSER_UA_PREFIX) + assert headers["Origin"] == "https://polymarket.com" + assert headers["Referer"] == "https://polymarket.com/" + # The caller's own headers survive, and the caller's dict is not mutated. + assert headers["Accept"] == "application/json" + assert args[0] == "https://clob.polymarket.com/order" + assert args[1] == "POST" + assert args[3] == {"body": 1} + + +def test_patch_does_not_override_caller_supplied_headers(monkeypatch) -> None: + """``setdefault``, not assignment — a caller that deliberately sets one of + these keeps it.""" + rec = _Recorder() + monkeypatch.setattr(clob_patch, "_orig_request", rec) + + v2_helpers.request("https://x", "GET", {"Origin": "https://example.test"}) + + assert rec.calls[0][0][2]["Origin"] == "https://example.test" + + +def test_patch_leaves_a_none_headers_slot_populated(monkeypatch) -> None: + """The SDK passes ``headers=None`` on unauthenticated GETs; the patch must + still fill the slot rather than skip injection.""" + rec = _Recorder() + monkeypatch.setattr(clob_patch, "_orig_request", rec) + + v2_helpers.request("https://x", "GET", None) + + headers = rec.calls[0][0][2] + assert headers["Origin"] == "https://polymarket.com" + + +def test_patch_passes_through_when_headers_are_not_positional(monkeypatch) -> None: + """Guarded on arity so a future SDK call site passing headers by keyword is + forwarded untouched instead of being silently mangled.""" + rec = _Recorder() + monkeypatch.setattr(clob_patch, "_orig_request", rec) + + v2_helpers.request("https://x", "GET") + + args, _kwargs = rec.calls[0] + assert args == ("https://x", "GET") + + +def test_browser_headers_reach_the_wire(monkeypatch) -> None: + """End-to-end through the real SDK ``request``, with only the httpx client + replaced: Origin / Referer are what actually get sent. + + ``User-Agent`` is deliberately NOT asserted here — the SDK's own + ``_overload_headers`` assigns ``py_clob_client_v2`` over whatever the + patch set, so the browser UA does not survive to the wire. See the module + docstring of ``clob_patch``. + """ + + class _Resp: + status_code = 200 + + def json(self) -> dict: + return {"ok": True} + + class _Client: + def __init__(self) -> None: + self.headers: dict | None = None + + def request(self, *, method, url, headers, params, **_kw): + self.headers = headers + return _Resp() + + client = _Client() + monkeypatch.setattr(v2_helpers, "_http_client", client) + + out = v2_helpers.request("https://clob.polymarket.com/book", "GET", {}) + + assert out == {"ok": True} + assert client.headers is not None + assert client.headers["Origin"] == "https://polymarket.com" + assert client.headers["Referer"] == "https://polymarket.com/" + + +# ---------- re-exported SDK surface ---------- + + +def test_reexports_cover_every_symbol_the_executor_imports() -> None: + """live_executor imports these from clob_patch (never from the SDK + directly) so the patch is guaranteed to be in place first.""" + expected = { + "AssetType", + "BalanceAllowanceParams", + "ClobClient", + "OrderArgs", + "OrderPayload", + "OrderType", + "PartialCreateOrderOptions", + "Side", + } + assert expected <= set(clob_patch.__all__) + for name in expected: + assert getattr(clob_patch, name, None) is not None + + +def test_reexported_types_are_constructible_as_the_executor_uses_them() -> None: + """The exact call shapes in live_executor — a signature drift in the SDK + should fail here, not on the first live order.""" + args = clob_patch.OrderArgs( + token_id="t1", + price=0.55, + size=10.0, + side=clob_patch.Side.BUY, + ) + assert args.token_id == "t1" + assert args.size == 10.0 + + collateral = clob_patch.BalanceAllowanceParams(asset_type=clob_patch.AssetType.COLLATERAL) + assert collateral.asset_type == "COLLATERAL" + conditional = clob_patch.BalanceAllowanceParams( + asset_type=clob_patch.AssetType.CONDITIONAL, token_id="t1" + ) + assert conditional.token_id == "t1" + + assert clob_patch.OrderPayload(orderID="0xABC").orderID == "0xABC" + assert clob_patch.PartialCreateOrderOptions(neg_risk=True).neg_risk is True + assert str(clob_patch.OrderType.GTC).endswith("GTC") + assert clob_patch.Side.SELL != clob_patch.Side.BUY + assert callable(clob_patch.ClobClient) + + +def test_reexported_clob_client_exposes_the_methods_the_executor_calls() -> None: + """No network: attribute presence on the class, not an instance.""" + for method in ( + "create_and_post_order", + "update_balance_allowance", + "get_balance_allowance", + "cancel_order", + "get_order", + "derive_api_key", + "set_api_creds", + "get_address", + ): + assert callable(getattr(clob_patch.ClobClient, method, None)), method + + +def test_reexports_are_the_sdk_objects() -> None: + import py_clob_client_v2 + + assert clob_patch.ClobClient is py_clob_client_v2.ClobClient + assert clob_patch.Side is py_clob_client_v2.Side + + +@pytest.mark.parametrize("name", ["OrderArgs", "OrderPayload"]) +def test_reexported_dataclasses_reject_unknown_fields(name: str) -> None: + """Guards against a silently renamed field: passing a stale kwarg must + raise rather than be ignored.""" + with pytest.raises(TypeError): + getattr(clob_patch, name)(definitely_not_a_field=1) diff --git a/tests/test_db_engine.py b/tests/test_db_engine.py index 5a87bec..1fc5c2a 100644 --- a/tests/test_db_engine.py +++ b/tests/test_db_engine.py @@ -88,3 +88,46 @@ def test_ensure_fill_live_columns_migrates_old_db(tmp_path) -> None: cols = {r[1] for r in conn.execute(text("PRAGMA table_info(fill)")).fetchall()} assert "order_id" in cols assert "tx_hash" in cols + + +def test_position_table_has_entry_calibration_columns(tmp_path) -> None: + """A new DB gets the entry-signal columns straight from create_all.""" + from sqlalchemy import text + + engine = make_engine(f"sqlite:///{tmp_path}/cal.db") + init_db(engine) + with engine.begin() as conn: + cols = {r[1] for r in conn.execute(text("PRAGMA table_info(position)")).fetchall()} + assert {"entry_p_model", "entry_confidence", "entry_edge"} <= cols + + +def test_ensure_position_entry_columns_migrates_old_db(tmp_path) -> None: + """Old DB predating calibration: migration adds them, idempotent.""" + from sqlalchemy import text + from openpoly.db.manager import _ensure_position_entry_columns + + engine = make_engine(f"sqlite:///{tmp_path}/x.db") + with engine.begin() as conn: + conn.execute( + text(""" + CREATE TABLE position ( + id INTEGER PRIMARY KEY, + market_id VARCHAR NOT NULL, + side VARCHAR NOT NULL, + token_id VARCHAR NOT NULL, + condition_id VARCHAR NOT NULL, + qty FLOAT NOT NULL, + avg_entry_price FLOAT NOT NULL, + status VARCHAR NOT NULL, + opened_at FLOAT NOT NULL, + closed_at FLOAT, + close_reason VARCHAR, + realized_pnl FLOAT + ) + """) + ) + for _ in range(2): # second run must be a no-op + _ensure_position_entry_columns(engine) + with engine.begin() as conn: + cols = {r[1] for r in conn.execute(text("PRAGMA table_info(position)")).fetchall()} + assert {"entry_p_model", "entry_confidence", "entry_edge"} <= cols diff --git a/tests/test_db_manager.py b/tests/test_db_manager.py index edb4cc2..02ef3ac 100644 --- a/tests/test_db_manager.py +++ b/tests/test_db_manager.py @@ -78,5 +78,6 @@ async def test_status_reports_writer_stats(tmp_path): assert mgr.status()["writers"]["order_book"] == { "written": 1, "dropped": 0, + "errors": 0, "pending": 0, } diff --git a/tests/test_db_portfolio_store.py b/tests/test_db_portfolio_store.py index 9f8e668..cfd8b82 100644 --- a/tests/test_db_portfolio_store.py +++ b/tests/test_db_portfolio_store.py @@ -321,3 +321,92 @@ def test_record_sell_accrues_realized_across_partials(store) -> None: assert rec.status == "closed" assert rec.realized_pnl == pytest.approx((0.55 - 0.40) * 15.0 + (0.60 - 0.40) * 3.0) assert len([f for f in store.list_fills() if f.action == "sell"]) == 2 + + +def test_record_sell_sub_size_precision_residual_closes_the_position(store) -> None: + """The venue accepts 2 size decimals, so a residual below 0.01 shares can + never be expressed as an order size: leaving the row open keeps it open + forever, burning a heat-cap slot and an exit-tick decision every tick. It + is dropped (worth under $0.01 at any price <= 1.0) and the position closes + with the realized PnL accrued from the legs actually sold.""" + held = _open(store, price=0.40, qty=5.5678) + rec = store.record_sell( + held.position_id, + sold_qty=5.56, + sell_price=0.55, + ts=200.0, + close_reason="take_profit", + trigger="take_profit", + ) + assert rec.status == "closed" + assert rec.close_reason == "take_profit" + assert rec.closed_at == 200.0 + assert rec.qty == pytest.approx(5.56) # the 0.0078 residue is dropped + assert rec.realized_pnl == pytest.approx((0.55 - 0.40) * 5.56) # sold legs only + sell = next(f for f in store.list_fills() if f.action == "sell") + assert sell.qty == pytest.approx(5.56) + + +def test_record_sell_one_cent_residual_stays_open(store) -> None: + """0.01 shares is exactly one size increment — still placeable, so the row + must stay open rather than be written off.""" + held = _open(store, price=0.40, qty=5.57) + rec = store.record_sell( + held.position_id, + sold_qty=5.56, + sell_price=0.55, + ts=200.0, + close_reason="take_profit", + trigger="take_profit", + ) + assert rec.status == "open" + assert rec.qty == pytest.approx(0.01) + + +# ---------- entry calibration signals ---------- + + +def test_open_position_persists_entry_signals(store) -> None: + """The analyzer's belief at entry has to be joinable to the outcome later, + or calibration is unanswerable after the fact.""" + held = store.open_position( + market_id="m1", + side="yes", + token_id="t1", + condition_id="0xm1", + price=0.42, + qty=20.0, + ts=100.0, + news_id="n1", + entry_p_model=0.72, + entry_confidence="high", + entry_edge=0.30, + ) + rec = store.get_position(held.position_id) + assert rec is not None + assert rec.entry_p_model == 0.72 + assert rec.entry_confidence == "high" + assert rec.entry_edge == 0.30 + # And they survive the close, which is when they become useful. + store.close_position(held.position_id, sell_price=0.50, ts=200.0, close_reason="take_profit") + closed = store.get_position(held.position_id) + assert closed is not None + assert closed.entry_p_model == 0.72 + + +def test_open_position_entry_signals_default_to_none(store) -> None: + """Hand-opened / reconciled positions carry no analyzer signal.""" + held = store.open_position( + market_id="m2", + side="no", + token_id="t2", + condition_id="0xm2", + price=0.30, + qty=10.0, + ts=100.0, + ) + rec = store.get_position(held.position_id) + assert rec is not None + assert rec.entry_p_model is None + assert rec.entry_confidence is None + assert rec.entry_edge is None diff --git a/tests/test_db_writer.py b/tests/test_db_writer.py index 41c3088..dc41f3f 100644 --- a/tests/test_db_writer.py +++ b/tests/test_db_writer.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio +import logging +import time from openpoly.db.writer import WriteBehindWriter @@ -80,3 +82,120 @@ async def test_batching(): async def test_stop_when_not_started_is_safe(): writer = WriteBehindWriter(lambda batch: None) await writer.stop() # never started -> no-op, must not raise + + +# ---------- observability: drops and sink errors must be countable + visible ---------- + + +async def test_overflow_counts_and_warns_once(caplog) -> None: + """A silently dropped row is a silently lost book / news sample. Drops are + counted and surfaced at WARNING — rate-limited so a saturated queue cannot + itself become the flood.""" + writer = WriteBehindWriter(lambda batch: None, queue_maxsize=2) + with caplog.at_level(logging.WARNING, logger="openpoly.db.writer"): + assert writer.enqueue("a") is True + assert writer.enqueue("b") is True + for _ in range(5): + assert writer.enqueue("overflow") is False + + assert writer.dropped == 5 + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "dropped" in warnings[0].getMessage() + + +async def test_sink_error_counts_and_warns_without_killing_the_loop(caplog) -> None: + received: list = [] + calls = {"n": 0} + + def flaky(batch: list) -> None: + calls["n"] += 1 + if calls["n"] == 1: + raise RuntimeError("db down") + received.extend(batch) + + writer = WriteBehindWriter(flaky, batch_size=1) + with caplog.at_level(logging.WARNING, logger="openpoly.db.writer"): + await writer.start() + writer.enqueue("x") + await _wait_until(lambda: writer.errors == 1) + writer.enqueue("y") + await _wait_until(lambda: received == ["y"]) + await writer.stop() + + assert writer.errors == 1 + assert writer.written == 1 # only the successful batch counted + assert any("sink failed" in r.getMessage() for r in caplog.records) + + +async def test_warnings_are_rate_limited_then_resume(monkeypatch) -> None: + """The throttle is a time window, not a one-shot mute: once the window has + passed the next drop is reported again, with the cumulative count.""" + import openpoly.db.writer as writer_mod + + clock = {"t": 1000.0} + monkeypatch.setattr(writer_mod.time, "monotonic", lambda: clock["t"]) + writer = WriteBehindWriter(lambda batch: None, queue_maxsize=1) + writer.enqueue("a") + + logged: list[str] = [] + monkeypatch.setattr( + writer_mod.logger, + "warning", + lambda msg, *args: logged.append(msg % args), + ) + + writer.enqueue("drop-1") + writer.enqueue("drop-2") + assert len(logged) == 1 + + clock["t"] += writer_mod.WARN_INTERVAL_SECONDS + 1 + writer.enqueue("drop-3") + assert len(logged) == 2 + assert "3" in logged[1] # cumulative count, not a per-event message + + +async def test_stop_completes_the_in_flight_batch() -> None: + """``stop`` used to cancel the drain task mid-``to_thread``: the batch the + sink was already writing was never counted (and, for a slow sink, the + process could exit under it). The in-flight write is drained first.""" + received: list = [] + + def slow(batch: list) -> None: + time.sleep(0.2) + received.extend(batch) + + writer = WriteBehindWriter(slow, batch_size=10) + await writer.start() + writer.enqueue("row") + await _wait_until(lambda: writer.pending == 0) # the drain has taken it + await writer.stop() + + assert received == ["row"] + assert writer.written == 1 + + +async def test_stop_drain_times_out_without_raising(monkeypatch) -> None: + """A sink wedged forever must not wedge shutdown — the drain is bounded.""" + import openpoly.db.writer as writer_mod + + monkeypatch.setattr(writer_mod, "STOP_DRAIN_TIMEOUT_SECONDS", 0.05) + started = asyncio.Event() + + def wedged(batch: list) -> None: + started.set() + time.sleep(0.6) + + writer = WriteBehindWriter(wedged, batch_size=10) + await writer.start() + writer.enqueue("row") + await asyncio.wait_for(started.wait(), timeout=2.0) + await writer.stop() # must return promptly, must not raise + assert writer.written == 0 + # Let the wedged worker thread finish so it does not outlive the test. + await asyncio.sleep(0.7) + + +async def test_errors_counter_starts_at_zero() -> None: + writer = WriteBehindWriter(lambda batch: None) + assert writer.errors == 0 diff --git a/tests/test_execution_sizing.py b/tests/test_execution_sizing.py new file mode 100644 index 0000000..5c5cddd --- /dev/null +++ b/tests/test_execution_sizing.py @@ -0,0 +1,304 @@ +"""Tests for openpoly.execution.sizing — the one order-sizing rule both +executors share, plus the paper/live parity it is meant to guarantee.""" + +from __future__ import annotations + +import pytest + +from openpoly.db.engine import init_db, make_engine, make_session_factory +from openpoly.execution import PaperExecutor +from openpoly.execution.live_executor import LiveExecutor +from openpoly.execution.sizing import ( + MIN_NOTIONAL_USD, + MIN_SELL_SHARES, + SIZE_DECIMALS, + is_dust_qty, + quantize_size, +) +from openpoly.markets.manager import manager as market_source_manager +from openpoly.markets.models import OrderBook, normalize_gamma_market +from openpoly.markets.store import MarketStore, PollSummary +from openpoly.portfolio import PortfolioStore +from openpoly.portfolio.store import MIN_SELLABLE_QTY +from openpoly.sections.entry.edge_threshold_v0 import OrderIntent + +# ---------- quantize_size ---------- + + +def test_quantize_floors_to_two_decimal_size() -> None: + """The SDK's ROUNDING_CONFIG allows 2 size decimals at every tick size.""" + assert quantize_size(5.567, 0.50) == pytest.approx(5.56) + assert quantize_size(20.0, 0.42) == 20.0 + + +def test_quantize_below_one_share_is_zero() -> None: + """0.6 shares is a remainder, not a placeable order.""" + assert quantize_size(0.6, 0.50) == 0.0 + assert quantize_size(0.0, 0.50) == 0.0 + + +def test_quantize_does_not_require_a_cent_aligned_notional() -> None: + """3-decimal prices (the 0.001-tick regime winners exit through) must not + zero a whole-share qty: the venue rounds the amount itself.""" + assert quantize_size(6.0, 0.993) >= 6.0 - 0.01 + assert quantize_size(9.0, 0.999) == 9.0 + assert quantize_size(3.0, 0.005) == 3.0 + + +def test_min_notional_is_the_venue_floor_plus_buffer() -> None: + """$1.00 server minimum + a $0.10 rounding buffer.""" + assert MIN_NOTIONAL_USD == pytest.approx(1.10) + + +def test_is_dust_qty_is_the_shared_one_share_sell_minimum() -> None: + """The exit monitor and both executors have to agree on what dust is, or + the monitor keeps producing sells the executors will only ever skip.""" + assert MIN_SELL_SHARES == pytest.approx(1.0) + assert is_dust_qty(0.6) is True + assert is_dust_qty(0.999999) is True + assert is_dust_qty(1.0) is False + assert is_dust_qty(6.0) is False + + +def test_portfolio_sellable_minimum_tracks_the_venue_size_precision() -> None: + """``MIN_SELLABLE_QTY`` is duplicated in the portfolio layer (which must + not import execution); this pins the two definitions together.""" + assert MIN_SELLABLE_QTY == pytest.approx(10**-SIZE_DECIMALS) + + +# ---------- paper / live parity ---------- + + +@pytest.fixture(autouse=True) +def _isolate_market_store(): + saved = market_source_manager.store + market_source_manager.store = MarketStore() + yield + market_source_manager.store = saved + + +@pytest.fixture +def store(tmp_path): + engine = make_engine(f"sqlite:///{tmp_path}/p.db") + init_db(engine) + yield PortfolioStore(make_session_factory(engine)) + engine.dispose() + + +class _NoopClob: + """Records posts; every read succeeds so nothing but sizing can skip. + + ``ctf_balance_raw`` is what the SELL path's CTF-cache poll reads — high by + default so only sizing decides the outcome. + """ + + def __init__( + self, + *, + order_response: dict | None = None, + ctf_balance_raw: int = 10**18, + ) -> None: + self.posted: list = [] + self._response = order_response or { + "success": True, + "orderID": "0x1", + "makingAmount": "1", + "takingAmount": "2", + } + self._ctf_balance_raw = ctf_balance_raw + + def create_and_post_order(self, order_args, options, order_type): + self.posted.append(order_args) + return self._response + + def update_balance_allowance(self, params): + return None + + def get_balance_allowance(self, params): + return {"balance": str(self._ctf_balance_raw), "allowances": {}} + + def cancel_order(self, payload): + return None + + def get_order(self, order_id): + return {"size_matched": "0"} + + +def _market(market_id: str = "m1"): + m = normalize_gamma_market( + { + "id": market_id, + "conditionId": f"0x{market_id}", + "question": "Q?", + "clobTokenIds": f'["yes-{market_id}", "no-{market_id}"]', + }, + event={"id": "e", "title": "E", "tags": []}, + ) + assert m is not None + return m + + +def _populate(market, *books: OrderBook) -> None: + s = market_source_manager.store + s.replace([market], PollSummary(ts=1.0, fetched=1, kept=1, reason_counts={})) + s.set_order_books(list(books)) + + +def _book(token_id: str, *, bid: float, ask: float, bid_size: float = 100.0) -> OrderBook: + return OrderBook(token_id=token_id, ts=1.0, bids=[(bid, bid_size)], asks=[(ask, 100.0)]) + + +def test_both_executors_reject_the_same_sub_floor_buy(store) -> None: + """2 shares @ $0.50 = $1.00 — under the shared $1.10 floor. Neither + executor may place it, and neither may open a position.""" + m = _market() + _populate(m, _book(m.yes_token_id, bid=0.48, ask=0.50)) + intent = OrderIntent(market_id="m1", side="yes", price=0.50, qty=2.0) + + clob = _NoopClob() + live = LiveExecutor(portfolio=store, clob_client=clob).execute_buy(intent, news_id="n", ts=1.0) + paper = PaperExecutor(store).execute_buy(intent, news_id="n", ts=1.0) + + assert live.filled is False + assert paper.filled is False + assert clob.posted == [] + assert store.get_open_position("m1", "yes") is None + + +def test_paper_sell_is_capped_by_bid_depth_and_leaves_remainder_open(store) -> None: + """The paper BUY caps at level-1 ask depth; the SELL must cap at level-1 + bid depth the same way, and leave the unsold remainder open (live does).""" + m = _market() + _populate(m, _book(m.yes_token_id, bid=0.55, ask=0.56, bid_size=4.0)) + held = store.open_position( + market_id="m1", + side="yes", + token_id=m.yes_token_id, + condition_id=m.condition_id, + price=0.40, + qty=10.0, + ts=100.0, + news_id="n", + ) + + r = PaperExecutor(store).execute_sell(held, close_reason="take_profit", ts=200.0) + + assert r.filled is True + assert r.qty == pytest.approx(4.0) # only the bid depth filled + rec = store.get_position(held.position_id) + assert rec is not None + assert rec.status == "open" + assert rec.qty == pytest.approx(6.0) + assert rec.realized_pnl == pytest.approx((0.55 - 0.40) * 4.0) + + +def test_both_executors_sell_a_whole_position_into_a_three_decimal_bid(store) -> None: + """0.999 is where winners exit (the 0.001-tick regime above 0.96). A + 9-share position is a placeable order there: both executors must fill it + at 0.999 — neither may treat it as unsellable.""" + m = _market() + _populate(m, _book(m.yes_token_id, bid=0.999, ask=1.0)) + + paper_held = store.open_position( + market_id="m1", + side="yes", + token_id=m.yes_token_id, + condition_id=m.condition_id, + price=0.40, + qty=9.0, + ts=100.0, + news_id="n", + ) + paper = PaperExecutor(store).execute_sell(paper_held, close_reason="take_profit", ts=200.0) + assert paper.filled is True + assert paper.price == pytest.approx(0.999) + assert paper.qty == pytest.approx(9.0) + paper_rec = store.get_position(paper_held.position_id) + assert paper_rec is not None + assert paper_rec.status == "closed" + assert paper_rec.close_reason == "take_profit" + + live_held = store.open_position( + market_id="m1", + side="no", + token_id=m.no_token_id, + condition_id=m.condition_id, + price=0.40, + qty=9.0, + ts=100.0, + news_id="n", + ) + _populate( + m, _book(m.yes_token_id, bid=0.999, ask=1.0), _book(m.no_token_id, bid=0.999, ask=1.0) + ) + clob = _NoopClob( + order_response={ + "success": True, + "orderID": "0xS", + "makingAmount": "9.0", # tokens sent + "takingAmount": "8.991", # pUSD received → 0.999 + "transactionsHashes": ["0xT"], + } + ) + live = LiveExecutor(portfolio=store, clob_client=clob).execute_sell( + live_held, close_reason="take_profit", ts=200.0 + ) + assert live.filled is True + assert live.price == pytest.approx(0.999) + assert clob.posted[0].size == pytest.approx(9.0) + live_rec = store.get_position(live_held.position_id) + assert live_rec is not None + assert live_rec.status == "closed" + assert live_rec.close_reason == "take_profit" + + +def test_both_executors_skip_a_sub_one_share_remainder_and_leave_it_open(store) -> None: + """A genuine <1-share remainder is still worth its resolution price at + settlement. Neither executor may write it off: both skip and leave the row + open for the settlement monitor to close.""" + m = _market() + _populate(m, _book(m.yes_token_id, bid=0.55, ask=0.56)) + + paper_held = store.open_position( + market_id="m1", + side="yes", + token_id=m.yes_token_id, + condition_id=m.condition_id, + price=0.40, + qty=0.6, + ts=100.0, + news_id="n", + ) + paper = PaperExecutor(store).execute_sell(paper_held, close_reason="take_profit", ts=200.0) + assert paper.filled is False + assert paper.skip_reason == "dust_remainder" + paper_rec = store.get_position(paper_held.position_id) + assert paper_rec is not None + assert paper_rec.status == "open" + assert paper_rec.qty == pytest.approx(0.6) + assert paper_rec.realized_pnl is None + + live_held = store.open_position( + market_id="m1", + side="no", + token_id=m.no_token_id, + condition_id=m.condition_id, + price=0.40, + qty=0.6, + ts=100.0, + news_id="n", + ) + _populate( + m, _book(m.yes_token_id, bid=0.55, ask=0.56), _book(m.no_token_id, bid=0.55, ask=0.56) + ) + clob = _NoopClob() + live = LiveExecutor(portfolio=store, clob_client=clob).execute_sell( + live_held, close_reason="take_profit", ts=200.0 + ) + assert live.filled is False + assert live.skip_reason == "dust_remainder" + assert clob.posted == [] + live_rec = store.get_position(live_held.position_id) + assert live_rec is not None + assert live_rec.status == "open" + assert live_rec.qty == pytest.approx(0.6) diff --git a/tests/test_executor.py b/tests/test_executor.py index a9a5925..2eee03d 100644 --- a/tests/test_executor.py +++ b/tests/test_executor.py @@ -179,3 +179,35 @@ def test_unconfigured_executor_raises() -> None: _populate(_market(), _book("yes-m1", ask=0.42)) with pytest.raises(RuntimeError, match="PortfolioStore"): Executor().execute_buy(_intent(), news_id="n1", ts=1.0) + + +# ---------- entry calibration signals ---------- + + +def test_buy_persists_the_entry_signals_from_the_intent(store) -> None: + """The executor is the only thing that touches the position row, so it is + what has to carry the entry decision's signals into it.""" + _populate(_market(), _book("yes-m1", ask=0.42)) + intent = OrderIntent( + market_id="m1", + side="yes", + price=0.42, + qty=20.0, + p_model=0.68, + confidence="high", + edge=0.26, + ) + r = Executor(store).execute_buy(intent, news_id="n1", ts=1.0) + assert r.filled + rec = store.get_position(r.position_id) + assert rec is not None + assert rec.entry_p_model == 0.68 + assert rec.entry_confidence == "high" + assert rec.entry_edge == 0.26 + + +def test_buy_without_signals_leaves_them_null(store) -> None: + _populate(_market(), _book("yes-m1", ask=0.42)) + r = Executor(store).execute_buy(_intent(qty=20.0), news_id="n1", ts=1.0) + rec = store.get_position(r.position_id) + assert rec is not None and rec.entry_p_model is None diff --git a/tests/test_exit_monitor.py b/tests/test_exit_monitor.py index a3194a5..0c0a688 100644 --- a/tests/test_exit_monitor.py +++ b/tests/test_exit_monitor.py @@ -49,6 +49,7 @@ def _held( avg: float = 0.40, market_id: str = "m1", side: str = "yes", + qty: float = 20.0, ) -> HeldPosition: return HeldPosition( position_id=position_id, @@ -56,7 +57,7 @@ def _held( side=side, # type: ignore[arg-type] token_id=token_id, condition_id=f"0x{market_id}", - qty=20.0, + qty=qty, avg_entry_price=avg, opened_at=1.0, ) @@ -979,3 +980,61 @@ async def _close_the_other_position() -> None: assert [(e.position_id, e.reason) for e in skips] == [ (second.position_id, "position_no_longer_open") ] + + +# ---------- sub-one-share remainder (dust) ---------- + + +async def test_dust_position_is_skipped_once_and_never_sold() -> None: + """A remainder below one share is not a placeable order: the executors + skip it and the row stays open until settlement. Evaluating it every tick + therefore produced a CloseIntent -> an unfilled sell -> an ``error`` row, + every 120s forever, which evicts the real closes from the 200-entry ring + and inflates the error counter. The monitor must not evaluate it at all: + one ``skip`` row per position, no sell, and it is not ``blocked``.""" + market_source_manager.store.set_order_books([_book("t1", bid=0.55)]) + ex = _FakeExecutor() + m = _monitor(_FakePortfolio([_held(1, "t1", avg=0.40, qty=0.6)]), ex) + + for _ in range(3): + await m._tick_once() + + assert ex.calls == [] + entries = exit_log.entries() + assert len(entries) == 1 + assert entries[0].verdict == "skip" + assert entries[0].reason == "dust_remainder" + assert entries[0].position_id == 1 + assert [e for e in entries if e.verdict == "error"] == [] + assert m.open_positions == 1 + assert m.blocked == 0 + + +async def test_sellable_position_is_unaffected_by_the_dust_guard() -> None: + """Six shares is a placeable order — the dust guard must not touch it.""" + market_source_manager.store.set_order_books([_book("t1", bid=0.55)]) + ex = _FakeExecutor(result=ExecResult.ok(price=0.55, qty=6.0, position_id=1)) + m = _monitor(_FakePortfolio([_held(1, "t1", avg=0.40, qty=6.0)]), ex) + + await m._tick_once() + + assert [c["position_id"] for c in ex.calls] == [1] + entries = exit_log.entries() + assert [e.verdict for e in entries] == ["ok"] + assert entries[0].trigger == "take_profit" + + +async def test_dust_marker_is_pruned_when_the_position_stops_being_open() -> None: + """The dedup set is pruned against the current open list every tick, like + ``_unmarkable`` — it must not grow across the process lifetime.""" + market_source_manager.store.set_order_books([_book("t1", bid=0.55)]) + ex = _FakeExecutor() + pf = _FakePortfolio([_held(1, "t1", avg=0.40, qty=0.6)]) + m = _monitor(pf, ex) + + await m._tick_once() + assert m._dust == {1} + + pf._positions = [] + await m._tick_once() + assert m._dust == set() diff --git a/tests/test_live_executor.py b/tests/test_live_executor.py index e64028c..a41e1dc 100644 --- a/tests/test_live_executor.py +++ b/tests/test_live_executor.py @@ -34,6 +34,7 @@ def __init__( ctf_balance_sequence: list[int] | None = None, cancel_raises: bool = False, order_status: dict[str, Any] | None = None, + balance_read_raises: bool = False, ) -> None: self._response = order_response or { "success": True, @@ -51,6 +52,7 @@ def __init__( # gate read and the post-exception confirmation polls. self._ctf_balance_sequence = list(ctf_balance_sequence) if ctf_balance_sequence else None self._cancel_raises = cancel_raises + self._balance_read_raises = balance_read_raises # get_order response; default "0" so the final qty falls back to the # reported fill (max(matched, reported)). self._order_status = order_status or {"size_matched": "0"} @@ -66,7 +68,10 @@ def create_and_post_order(self, order_args, options, order_type): def update_balance_allowance(self, params): self.allowance_updates.append(params) - if self._allowance_update_raises: + # Scoped to the pre-signing COLLATERAL refresh: the CONDITIONAL read is + # the lost-response confirmation baseline, whose failure is a separate + # (and fatal) case — see balance_read_raises. + if self._allowance_update_raises and params.asset_type == "COLLATERAL": raise RuntimeError("cache refresh failed") def cancel_order(self, payload): @@ -78,6 +83,8 @@ def get_order(self, order_id): return self._order_status def get_balance_allowance(self, params): + if self._balance_read_raises: + raise RuntimeError("balance read failed") # CONDITIONAL queries return the CTF balance the SELL poll checks; # COLLATERAL queries don't matter for these tests. if self._ctf_balance_sequence is not None: @@ -177,13 +184,13 @@ def test_buy_skips_when_below_min_notional(store) -> None: def test_buy_quantizes_fractional_qty_down(store) -> None: - """qty=5.56 → floors to 5; maker = 5 * 0.50 = $2.50 (clean cents).""" + """qty=5.567 → floors to 5.56; the SDK allows 2 size decimals.""" m = _market("m1") _populate(m) clob = _FakeClob() le = LiveExecutor(portfolio=store, clob_client=clob) - le.execute_buy(_intent(qty=5.56, price=0.50), news_id="n", ts=1.0) - assert clob.posted[0]["order_args"].size == 5.0 + le.execute_buy(_intent(qty=5.567, price=0.50), news_id="n", ts=1.0) + assert clob.posted[0]["order_args"].size == pytest.approx(5.56) def test_buy_market_not_in_catalog_skips(store) -> None: @@ -267,7 +274,8 @@ def test_buy_neg_risk_flag_passed_to_options(store) -> None: def test_buy_allowance_refresh_failure_is_non_fatal(store) -> None: - """Allowance cache refresh is best-effort — order should still attempt.""" + """The pre-signing COLLATERAL allowance refresh is best-effort — the order + should still attempt (unlike the CTF baseline read, which is not).""" m = _market("m1") _populate(m) clob = _FakeClob(allowance_update_raises=True) @@ -799,3 +807,133 @@ def test_sell_partial_fill_cancels_resting_remainder(store) -> None: r = le.execute_sell(held, close_reason="stop_loss", ts=200.0) assert r.filled is True assert clob.cancelled == ["0xSPART"] # the unsold 3 don't rest + + +# ---------- dust remainder (below one share: not sellable, still valuable) ---------- + + +def test_sell_skips_a_sub_one_share_remainder_and_leaves_it_open(store) -> None: + """A partial sell can leave < 1 share open, which is not a placeable order. + It is still worth its resolution price at settlement, so the sell skips and + the row stays open — writing it off at 0.0 would book a fake loss and + orphan the tokens.""" + m = _market("m1") + _populate(m, _book(m.yes_token_id, bid=0.55)) + held = store.open_position( + market_id="m1", + side="yes", + token_id=m.yes_token_id, + condition_id=m.condition_id, + price=0.40, + qty=10.0, + ts=100.0, + news_id="n", + ) + # A prior partial sell leaves 0.6 shares open. + store.record_sell( + held.position_id, + sold_qty=9.4, + sell_price=0.55, + ts=150.0, + close_reason="take_profit", + ) + remainder = store.get_open_position("m1", "yes") + assert remainder is not None and remainder.qty == pytest.approx(0.6) + + clob = _FakeClob() + le = LiveExecutor(portfolio=store, clob_client=clob) + r = le.execute_sell(remainder, close_reason="take_profit", ts=200.0) + + assert r.filled is False + assert r.skip_reason == "dust_remainder" + assert clob.posted == [] # nothing placeable was ever sent + rec = store.get_position(held.position_id) + assert rec is not None + assert rec.status == "open" + assert rec.qty == pytest.approx(0.6) + # Only the earlier partial's gain is realized — the remainder is not a loss. + assert rec.realized_pnl == pytest.approx((0.55 - 0.40) * 9.4) + + +def test_sell_does_not_skip_a_whole_share_position(store) -> None: + """A position of 2 shares is a placeable order — it sells, it is not dust.""" + m = _market("m1") + _populate(m, _book(m.yes_token_id, bid=0.55)) + held = store.open_position( + market_id="m1", + side="yes", + token_id=m.yes_token_id, + condition_id=m.condition_id, + price=0.40, + qty=2.0, + ts=100.0, + news_id="n", + ) + clob = _FakeClob( + order_response={ + "success": True, + "orderID": "0xSELL", + "makingAmount": "2.0", + "takingAmount": "1.1", + "transactionsHashes": ["0xSTX"], + } + ) + le = LiveExecutor(portfolio=store, clob_client=clob) + r = le.execute_sell(held, close_reason="take_profit", ts=200.0) + + assert r.filled is True + assert r.price == pytest.approx(0.55) + assert len(clob.posted) == 1 + rec = store.get_position(held.position_id) + assert rec is not None and rec.close_reason == "take_profit" + + +# ---------- order idempotency fallback (no client order id in the SDK) ---------- + + +def test_buy_refuses_to_post_without_a_ctf_baseline(store) -> None: + """Without a pre-order CTF balance there is no way to tell a lost response + from a real fill, and the SDK offers no client order id to query by — so a + fill would become an untracked position. Refuse to place the order.""" + m = _market("m1") + _populate(m) + clob = _FakeClob(balance_read_raises=True) + le = LiveExecutor(portfolio=store, clob_client=clob) + r = le.execute_buy(_intent(), news_id="n", ts=1.0) + + assert r.filled is False + assert r.skip_reason == "ctf_balance_unavailable" + assert clob.posted == [] + assert store.get_open_position("m1", "yes") is None + + +def test_live_buy_persists_the_entry_signals_from_the_intent(store) -> None: + """Same calibration contract as paper — a live fill must be joinable to the + belief that opened it.""" + m = _market("m1") + _populate(m) + clob = _FakeClob( + order_response={ + "success": True, + "orderID": "0xORDER", + "makingAmount": "4.0", + "takingAmount": "10.0", + } + ) + intent = OrderIntent( + market_id="m1", + side="yes", + price=0.5, + qty=10.0, + p_model=0.61, + confidence="medium", + edge=0.11, + ) + le = LiveExecutor(portfolio=store, clob_client=clob) + r = le.execute_buy(intent, news_id="n1", ts=100.0) + assert r.filled is True + rec = store.get_position(r.position_id) + assert rec is not None + assert rec.entry_p_model == 0.61 + assert rec.entry_confidence == "medium" + assert rec.entry_edge == 0.11 diff --git a/tests/test_section_entry_edge.py b/tests/test_section_entry_edge.py index 0e82876..1820655 100644 --- a/tests/test_section_entry_edge.py +++ b/tests/test_section_entry_edge.py @@ -770,3 +770,144 @@ def test_kill_consecutive_runs_before_lockout() -> None: out = _run(inst, _ar(p_model=0.30)) assert out.verdict == "skip" assert out.reason == "kill_consecutive_losses" + + +# ---------- size_edge_multiplier_max (edge-scaled sizing, OFF by default) ---------- +# +# ask 0.50 / bid 0.48 with p_model 0.60 gives edge = 0.10 = 2 x the default +# min_edge, so the multiplier under test is exactly 2.0 before clamping. + + +def _edge_book() -> OrderBook: + return _book("yes-m1", bid=0.48, ask=0.50) + + +def test_default_sizing_ignores_edge_entirely() -> None: + """Default is 1.0: sizing must stay exactly order_size_usd / held_price, + whatever the edge, and must not add a multiplier signal.""" + _populate(_market(), _edge_book()) + out = _run(EdgeThresholdEntryV0(EdgeThresholdConfig()), _ar(p_model=0.60)) + assert out.verdict == "ok" + assert isinstance(out.payload, OrderIntent) + assert out.payload.qty == pytest.approx(10.0 / 0.50) + assert "size_multiplier" not in out.signals + + +def test_edge_multiplier_scales_the_order() -> None: + """edge = 2 x min_edge with headroom to 3 x → double the notional.""" + _populate(_market(), _edge_book()) + inst = EdgeThresholdEntryV0(EdgeThresholdConfig(size_edge_multiplier_max=3.0)) + out = _run(inst, _ar(p_model=0.60)) + assert out.verdict == "ok" + assert out.payload.qty == pytest.approx(2 * 10.0 / 0.50) + assert out.signals["size_multiplier"] == pytest.approx(2.0) + + +def test_edge_multiplier_is_clamped_to_its_maximum() -> None: + _populate(_market(), _edge_book()) + inst = EdgeThresholdEntryV0(EdgeThresholdConfig(size_edge_multiplier_max=1.5)) + out = _run(inst, _ar(p_model=0.60)) + assert out.payload.qty == pytest.approx(1.5 * 10.0 / 0.50) + + +def test_a_marginal_edge_barely_scales_the_order() -> None: + """Scaling is proportional, so an edge only just over min_edge buys only + just over order_size_usd — the knob is not a step function.""" + # ask 0.55 / p_model 0.605 → edge = 0.055 = 1.1 x the 0.05 min_edge. + _populate(_market(), _book("yes-m1", bid=0.52, ask=0.55)) + inst = EdgeThresholdEntryV0(EdgeThresholdConfig(size_edge_multiplier_max=3.0)) + out = _run(inst, _ar(p_model=0.605)) + assert out.verdict == "ok" + assert out.payload.qty == pytest.approx(1.1 * 10.0 / 0.55) + assert out.payload.qty > 10.0 / 0.55 + + +def test_zero_min_edge_does_not_divide_by_zero() -> None: + _populate(_market(), _edge_book()) + inst = EdgeThresholdEntryV0(EdgeThresholdConfig(min_edge=0.0, size_edge_multiplier_max=3.0)) + out = _run(inst, _ar(p_model=0.60)) + assert out.verdict == "ok" + assert out.payload.qty == pytest.approx(10.0 / 0.50) + + +def test_heat_cap_binds_on_the_scaled_notional() -> None: + """The cap has to bound what actually gets bought. A 2x multiplier wants + $20 of exposure against a $15 cap — the extra size the multiplier grants + is what gets cut, down to the headroom.""" + _populate(_market(), _edge_book()) + inst = EdgeThresholdEntryV0( + EdgeThresholdConfig(heat_cap_usd=15.0, size_edge_multiplier_max=3.0), + portfolio_provider=lambda: _FakePortfolio([]), + ) + out = _run(inst, _ar(p_model=0.60)) + assert out.verdict == "ok" + assert out.payload.qty == pytest.approx(15.0 / 0.50) # $15, not $20 + assert out.signals["size_multiplier"] == pytest.approx(1.5) + + +def test_heat_cap_counts_existing_exposure_against_the_scaled_notional() -> None: + """One $4 open position eats into the headroom the multiplier may use.""" + _populate(_market(), _edge_book()) + opens = [_rec("other1", "yes", opened_at=_time.time() - 600, position_id=10)] + inst = EdgeThresholdEntryV0( + EdgeThresholdConfig(heat_cap_usd=18.0, size_edge_multiplier_max=3.0), + portfolio_provider=lambda: _FakePortfolio(opens), + ) + out = _run(inst, _ar(p_model=0.60)) + assert out.verdict == "ok" + # headroom = 18 - 4 = $14 → multiplier 1.4. + assert out.payload.qty == pytest.approx(14.0 / 0.50) + + +def test_heat_cap_never_shrinks_the_base_order() -> None: + """The cap gates pre-existing exposure (unchanged); it must not start + shrinking an unscaled order below order_size_usd.""" + _populate(_market(), _edge_book()) + opens = [_rec("other1", "yes", opened_at=_time.time() - 600, position_id=10)] + inst = EdgeThresholdEntryV0( + EdgeThresholdConfig(heat_cap_usd=6.0, size_edge_multiplier_max=3.0), + portfolio_provider=lambda: _FakePortfolio(opens), + ) + out = _run(inst, _ar(p_model=0.60)) + assert out.verdict == "ok" + assert out.payload.qty == pytest.approx(10.0 / 0.50) + + +def test_multiplier_bounds_are_enforced_by_config() -> None: + with pytest.raises(ValueError): + EdgeThresholdConfig(size_edge_multiplier_max=0.5) + with pytest.raises(ValueError): + EdgeThresholdConfig(size_edge_multiplier_max=5.5) + + +def test_multiplier_description_points_at_the_calibration_gate() -> None: + """The knob is only safe after calibration says so — the config has to say + that, since the canvas UI shows nothing else.""" + field = EdgeThresholdConfig.model_fields["size_edge_multiplier_max"] + assert field.default == 1.0 + assert "calibration" in field.description + + +# ---------- the intent carries the belief that produced it ---------- + + +def test_intent_carries_the_entry_signals() -> None: + _populate(_market(), _edge_book()) + out = _run(EdgeThresholdEntryV0(EdgeThresholdConfig()), _ar(p_model=0.60)) + intent = out.payload + assert intent.p_model == pytest.approx(0.60) + assert intent.confidence == "medium" + assert intent.edge == pytest.approx(0.10) + + +def test_intent_carries_the_raw_p_model_on_a_no_side() -> None: + """Stored raw; the held-side view is derived by the calibration report.""" + _populate(_market(), _book("no-m1", bid=0.38, ask=0.40)) + out = _run(EdgeThresholdEntryV0(EdgeThresholdConfig()), _ar(p_model=0.30)) + assert out.payload.side == "no" + assert out.payload.p_model == pytest.approx(0.30) + assert out.payload.edge == pytest.approx(0.30) + + +def test_section_version_bumped_for_the_sizing_knob() -> None: + assert EdgeThresholdEntryV0.SECTION_VERSION == "0.4.0" From c6005fddaea1f4cc854c2c897bf930d6042b8c61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nahim=20Rodr=C3=ADguez?= Date: Sun, 30 Aug 2026 17:20:23 -0600 Subject: [PATCH 03/27] feat(security): API token auth, versioned migrations, snapshot retention Mutating routes require X-OpenPoly-Token when configured; empty or unresolvable tokens fail closed and live mode is refused (and demoted at startup) without a usable token, even when runtime.json is unwritable. A schema_version table replaces the hand-rolled PRAGMA checks, order book snapshots get a composite index plus an hourly retention prune wired from the canvas config, credential fragments leave INFO logs, and carried review follow-ups land (partial-fill bookkeeping, heat-cap fallback, reconciliation dust filter, calibration sample hygiene). --- .env.example | 37 ++ CHANGELOG.md | 76 +++ docs/architecture/03-system-config.md | 6 + docs/deploy/README.md | 88 ++++ docs/deploy/separated-deployment.md | 8 + openpoly/analytics/calibration.py | 18 +- openpoly/api/canvas_routes.py | 5 +- openpoly/api/inspect_routes.py | 4 +- openpoly/api/main.py | 60 ++- openpoly/api/market_routes.py | 11 +- openpoly/api/news_routes.py | 13 +- openpoly/api/portfolio_routes.py | 58 ++- openpoly/api/runtime_routes.py | 7 +- openpoly/api/secrets_routes.py | 9 +- openpoly/api/security.py | 276 +++++++++++ openpoly/api/wallet_routes.py | 37 +- openpoly/db/engine.py | 20 +- openpoly/db/manager.py | 236 +++++++-- openpoly/db/migrations.py | 211 ++++++++ openpoly/db/tables.py | 10 + openpoly/embedding/manager.py | 5 +- openpoly/execution/live_executor.py | 30 +- openpoly/markets/polymarket_api.py | 15 +- openpoly/runtime/exit_monitor.py | 38 +- openpoly/runtime/reconciliation_monitor.py | 7 + openpoly/sections/database/sqlite.py | 7 + openpoly/sections/entry/edge_threshold_v0.py | 25 +- openpoly/wallet/runtime_state.py | 12 +- tests/conftest.py | 7 + tests/test_analytics_calibration.py | 39 +- tests/test_api_inspect.py | 36 ++ tests/test_api_mode_switch.py | 17 +- tests/test_api_portfolio_close.py | 83 +++- tests/test_api_security.py | 479 +++++++++++++++++++ tests/test_credential_logging.py | 138 ++++++ tests/test_db_engine.py | 70 --- tests/test_db_migrations.py | 201 ++++++++ tests/test_db_retention.py | 234 +++++++++ tests/test_exit_monitor.py | 139 ++++++ tests/test_live_executor.py | 43 ++ tests/test_market_api.py | 36 ++ tests/test_section_database.py | 29 ++ tests/test_section_entry_edge.py | 42 ++ tests/test_wallet_runtime_state.py | 30 ++ 44 files changed, 2770 insertions(+), 182 deletions(-) create mode 100644 openpoly/api/security.py create mode 100644 openpoly/db/migrations.py create mode 100644 tests/test_api_security.py create mode 100644 tests/test_credential_logging.py create mode 100644 tests/test_db_migrations.py create mode 100644 tests/test_db_retention.py diff --git a/.env.example b/.env.example index 6ab956a..3be0eb7 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,43 @@ OPENPOLY_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com # TradingNews WS auth token. Resolved via env: ref in canvas / secret store. OPENPOLY_TRADINGNEWS_API_KEY= +# --- API access control (Phase 3) ------------------------------------------ +# OPENPOLY_API_TOKEN — shared secret required in the `X-OpenPoly-Token` header +# on every mutating route (POST / PUT / DELETE / PATCH). Reads are never +# gated. Leave it unset for loopback development: the backend still works, +# logs one WARNING at startup, and REFUSES to switch to live mode +# (`POST /api/system/mode` returns 403 `api_token_required`). +# +# The value may be the literal token, or a `*_ref` in the same indirection +# every other secret uses, so the token need not sit in this file: +# OPENPOLY_API_TOKEN=env:OPENPOLY_API_TOKEN_VALUE +# OPENPOLY_API_TOKEN=local:api-token +# A ref that does not resolve fails CLOSED — every mutating route 401s. +# Generate one with: python3 -c "import secrets; print(secrets.token_urlsafe(32))" +# +# Note for the web UI: with a token set, the frontend must send the header on +# its mutating calls (canvas save, manual close, mode switch). Until it does, +# run the UI against a backend with the token unset, or drive the gated +# routes from curl / the Swagger UI with the header. +OPENPOLY_API_TOKEN= + +# OPENPOLY_ALLOWED_HOSTS — comma-separated extra Host header values the backend +# will answer to. `localhost`, `127.0.0.1` and `[::1]` are always allowed, so +# the default loopback + SSH-tunnel setup needs nothing here. Any other Host +# gets 421 Misdirected Request — this is what stops a browser on the +# operator's machine from being talked into driving the loopback API under a +# hostname that resolves to 127.0.0.1 (DNS rebinding). Set it only when the +# backend is reached through a real name (a reverse proxy, say); `*` disables +# the check entirely and should be a deliberate choice. +# OPENPOLY_ALLOWED_HOSTS=openpoly.internal.example.com + +# --- Retention ------------------------------------------------------------- +# order_book_snapshot is the one table that grows without bound. The database +# section prunes it hourly, keeping the last 7 days by default; the window is +# the `order_book_retention_days` field on the database section's config (0 +# disables the prune). Reclaimed rows are reported as +# `retention.pruned_rows` on GET /api/inspect/db-status. + # --- Boot behavior --------------------------------------------------------- # Set to "0" to skip auto-starting the news + market sources on app # startup (default: enabled). Useful on memory-constrained hosts where diff --git a/CHANGELOG.md b/CHANGELOG.md index 902dceb..12bfd5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,82 @@ Dates are US-style (MM/DD/YYYY). --- +## 08/30/2026 — Phase 3: what counts as a real outcome, and who is allowed to trade + +Nothing here changes an entry or exit threshold. What changed is which numbers +the system is willing to *believe* about its own trades, and the conditions +under which it is willing to trade at all. + +**A partial sell realizes a partial gain.** The exit monitor computed realized +P&L as `(fill_price − entry) × held.qty` — the whole position — on every filled +sell, including one that only cleared part of it. An IOC order fills against +whatever depth was resting, and `record_sell` reduces `qty` and leaves the row +open when the residual is still sellable, so a sell of 8 of 20 shares was +logged as if all 20 had gone. It now realizes `result.qty`, and — the more +consequential half — it stops treating the position as finished: the remainder +is still an open position with a trailing stop, so its peak and its book +subscription are kept. Dropping them re-seeded the stop at the next tick's mark +and threw away the run-up the position had already had. "Still open after the +sell" is the test, not `qty < held.qty`: a sell leaving a sub-0.01 residue +closes the row outright, and that is a completed exit. + +**"Closed" has to mean flat.** The manual close routes reported `filled: true` +for a sell capped by bid depth — indistinguishable from a completed exit, with +15 of 25 shares still on the book and no way for the operator to know. The +single close now answers `partial` (and `remaining_qty` when it is), and +close-all counts partials under their own counter with `ok` meaning *flat*, +never merely "the order went through". Bulk close is the button pressed to be +out of the market; a summary that counted a half-sold position as closed was +answering a different question than the one being asked. + +**A fabricated zero is not evidence.** The calibration report bucketed every +closed position with an `entry_p_model`, including ones closed as `reconciled` +— whose realized P&L is recorded as exactly 0 by construction, because the +position was exited outside the ledger and the real exit price cannot be +attributed back to it. Counting that zero scores a trade whose result is +unknown as a loss, so a run of reconciled closes reads as a miscalibrated model +rather than as missing data — and calibration is the gate that decides whether +`size_edge_multiplier_max` may ever rise above 1.0. Reconciled closes are now +excluded; `settlement`, `take_profit`, `stop_loss`, `peak_drawdown` and manual +closes all closed at a price that actually happened and still count. + +**Sizing above the base order requires a readable portfolio.** `heat_cap_usd` +is the only thing bounding an edge-scaled order. When the portfolio could not +be read the open exposure was unknown, the cap bound nothing, and a 3x +multiplier sized straight past the ceiling the operator set precisely to stop +that. The multiplier is now refused in that case — base size, with +`size_multiplier_skipped: portfolio_unavailable` in the signals. "The portfolio +was unreadable" is exactly the moment not to take the larger position on trust. + +**An unreadable balance is unknown, not zero.** The pre-order CTF balance read +returned 0 when the venue's balance field was present but unparseable, which +defeated the `ctf_balance_unavailable` guard added on 08/30: the caller saw a +successfully read baseline of 0 and posted the order. It now returns unknown +and the buy refuses, which is what that guard was for. In the other direction, +sub-0.01-share residues are no longer counted as on-chain holdings at all — the +reconciliation monitor's reverse diff was raising `untracked_onchain_holding` +against positions `record_sell` had correctly closed, which is the fastest way +to train an operator to ignore a warning that matters. + +**Live trading now requires an authenticated API.** Every mutating route +(POST / PUT / DELETE / PATCH) is guarded by an optional shared secret +(`X-OpenPoly-Token`, from `OPENPOLY_API_TOKEN`); reads stay open. Leaving it +unset keeps loopback development working and logs one warning — but the switch +to live mode is **refused outright** (403 `api_token_required`). Loopback is +not an authorization boundary: every other process on the host can reach it, +and real funds behind an unauthenticated endpoint is not a state anyone should +reach by omission. A `Host` allowlist backs it up, refusing any name that is +not loopback or explicitly allowed (421), which is what turns the DNS-rebinding +path into a rejection rather than a mode switch. See +[docs/deploy](docs/deploy/README.md#securing-the-api). + +**Order-book history is now pruned, and the window is a strategy parameter.** +`order_book_snapshot` grew without bound; it is kept for 7 days by default. +That number is not arbitrary: peak bootstrap rebuilds a position's trailing +stop from the snapshots taken since it opened, so the retention window has to +outlive the longest position the strategy will hold. Shorten it and a +long-held winner comes back from a restart with its peak reset to entry. + ## 08/30/2026 — Execution integrity, and sizing that has to earn the right Three beliefs changed about the gap between what the system *records* and what diff --git a/docs/architecture/03-system-config.md b/docs/architecture/03-system-config.md index dabdf8e..446466d 100644 --- a/docs/architecture/03-system-config.md +++ b/docs/architecture/03-system-config.md @@ -37,6 +37,12 @@ The store treats `local:` names as flat, opaque keys. `/` is still permitted, bu - File at `~/.openpoly/secrets.json`, chmod `0o600`. Override path via env `OPENPOLY_SECRET_STORE`. - **Plaintext at rest**. Same-user processes can read. Acceptable for grain-scale paper; mainnet should swap this for an OS-keychain backed store (`keychain:` scheme). - Backend HTTP **must bind loopback** — no public exposure of the store. +- Loopback is not on its own an authorization boundary: every other process on + the host can reach it. Set `OPENPOLY_API_TOKEN` so the secret-store write + routes (`POST` / `DELETE /api/secrets/local`) require the `X-OpenPoly-Token` + header, and `OPENPOLY_ALLOWED_HOSTS` if the backend is reached through a name + other than loopback. See [Securing the API](../deploy/README.md#securing-the-api). + The token itself may be a `*_ref`, so it can live in the same store. - Endpoints **never return secret values**; only names + `created_at` (enforced by Pydantic response models + grep tests). - Section impls never see the resolved value either — runtime injects the initialized client (per capability injection in [02-strategy-sections.md](02-strategy-sections.md)). diff --git a/docs/deploy/README.md b/docs/deploy/README.md index 883a0cd..32aaa62 100644 --- a/docs/deploy/README.md +++ b/docs/deploy/README.md @@ -30,6 +30,94 @@ openPoly **defaults to paper mode** — no real funds are touched until you explicitly switch to live (`POST /api/system/mode`). See the repository [DISCLAIMER](../../DISCLAIMER.md) before going live. +## Securing the API + +The backend binds `127.0.0.1`, and until Phase 3 that was the whole of its +access control. It is thinner than it sounds: `POST /api/system/mode` flips +paper→live, `PUT /api/wallet/config` repoints the signing key, and +`POST /api/secrets/local` writes the secret store — all reachable by anything +that can open a socket to the loopback port, which includes every other process +on the host and anything sharing your SSH tunnel. + +Two guards now sit in front of that, answering different questions. + +### `OPENPOLY_API_TOKEN` — who is calling + +A shared secret sent as the `X-OpenPoly-Token` header. It is **required on +every mutating route** (POST / PUT / DELETE / PATCH) and ignored on reads, +which expose no secret values. + +```bash +# Generate one, then put it in the backend's environment (.env / systemd unit) +python3 -c "import secrets; print(secrets.token_urlsafe(32))" +``` + +```bash +curl -X POST http://127.0.0.1:8000/api/system/mode \ + -H "X-OpenPoly-Token: $OPENPOLY_API_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"mode":"live"}' +``` + +The value may be the literal token or a `*_ref` in the same indirection every +other secret uses (`env:NAME`, `local:name` — see +[`03-system-config.md`](../architecture/03-system-config.md)), so it need not +live in the unit file in plaintext. A ref that stops resolving **fails closed**: +every mutating route answers `401 invalid_api_token` rather than quietly +reverting to open. + +| `OPENPOLY_API_TOKEN` | Mutating routes | Reads | Live mode | +|---|---|---|---| +| unset | open (one WARNING at startup) | open | **refused** — 403 `api_token_required` | +| set | require a matching header, else 401 | open | allowed (subject to the wallet preflight) | + +Leaving it unset keeps the local development loop exactly as it was. It cannot +be left unset for live trading: real funds behind an unauthenticated endpoint is +not a state anyone should reach by omission, so the switch is refused outright. + +> **Web UI caveat.** The frontend does not yet attach the header, so with a +> token configured its mutating actions (canvas save, manual close, mode switch) +> will get 401. Either run the UI against a backend with the token unset, or +> drive the gated routes from `curl` / the Swagger UI. Reads — the canvas, +> Inspector, positions, logs — are unaffected either way. + +### `OPENPOLY_ALLOWED_HOSTS` — what name they used + +A browser can be induced to send requests to a loopback service, but it cannot +forge the `Host` header. Every request whose Host is neither loopback +(`localhost`, `127.0.0.1`, `[::1]`) nor listed here is answered +**421 Misdirected Request** — which is what turns the DNS-rebinding path into a +rejection instead of a live-mode switch. + +```bash +# Only needed when the backend is reached through a real name (reverse proxy). +OPENPOLY_ALLOWED_HOSTS=openpoly.internal.example.com +``` + +The default loopback and SSH-tunnel setups need no entry: Vite's proxy forwards +the browser's own `Host`, which is `localhost:5173` or `127.0.0.1:5173`. If you +run the dev server with `--host` and open the UI from another machine on the +LAN, that machine's URL becomes the Host and you must add it here. `*` disables +the check; make that a deliberate choice, not a default. There is deliberately **no +CORS allowance** — the frontend is same-origin through Vite's proxy, and a +permissive `Access-Control-Allow-Origin` would hand back exactly what the Host +allowlist takes away. + +## Disk growth + +`order_book_snapshot` is the one table that grows without bound: one row per +tracked token per sampling cycle, forever. The database section prunes it +hourly, keeping the last **7 days** by default (`order_book_retention_days` on +its config; `0` disables the prune). Keep the window longer than your longest +held position — peak bootstrap rebuilds a trailing stop from the snapshots +taken since the position opened. How much has been reclaimed shows up as +`retention.pruned_rows` on `GET /api/inspect/db-status`. + +Schema changes are applied by a small versioned migration runner +(`openpoly/db/migrations.py`) recording its progress in a `schema_version` +table, so an existing database is upgraded in place on startup and an +interrupted migration is retried rather than half-applied. + ## Why a separated mode exists at all Polymarket's CLOB `POST /order` endpoint is **region-blocked**. Order placement diff --git a/docs/deploy/separated-deployment.md b/docs/deploy/separated-deployment.md index edc5979..4fc2469 100644 --- a/docs/deploy/separated-deployment.md +++ b/docs/deploy/separated-deployment.md @@ -75,6 +75,10 @@ For live trading you need at least `OPENPOLY_POLYMARKET_PK` and `OPENPOLY_AUTOSTART_SOURCES=0` is recommended on memory-constrained hosts so the embedding model (~500MB) doesn't load at boot. +Set `OPENPOLY_API_TOKEN` too — on a shared VPS the loopback port is reachable by +every other process and by anyone else on the tunnel, and **live mode is refused +without it**. See [Securing the API](./README.md#securing-the-api). + Create `~/.openpoly/runtime.json` (chmod 600) — wallet config + exec mode: ```json @@ -169,6 +173,10 @@ ssh openpoly-vps 'journalctl -u openpoly -n 50 --no-pager' ## Bringing up live trading +0. Set `OPENPOLY_API_TOKEN` in `/opt/openpoly/.env` and restart — the switch to + live is refused with 403 `api_token_required` while the API is + unauthenticated. Send it as `X-OpenPoly-Token` on every mutating call (the + Swagger UI's "Try it out" lets you add the header per request). 1. Confirm a clean paper boot (smoke test above). 2. Open the Swagger UI over the tunnel → `POST /api/system/mode` `{"mode":"live"}`. 3. Preflight runs: derives API creds and checks pUSD balance + V2 allowances. diff --git a/openpoly/analytics/calibration.py b/openpoly/analytics/calibration.py index 872139f..5f6037c 100644 --- a/openpoly/analytics/calibration.py +++ b/openpoly/analytics/calibration.py @@ -25,6 +25,16 @@ # by construction (the entry section picks the side p_model favours). BUCKET_EDGES: tuple[float, ...] = (0.5, 0.6, 0.7, 0.8, 0.9, 1.0) +# Close reasons whose ``realized_pnl`` is not a measured outcome. A +# ``reconciled`` close books 0 by construction — the position was exited +# outside the ledger and the real exit price cannot be attributed back to it +# (see ``ReconciliationMonitor``) — so scoring it counts a trade whose result +# is unknown as a loss, and a run of them reads as a miscalibrated model +# rather than as missing data. Every other reason (``settlement``, +# ``take_profit``, ``stop_loss``, ``peak_drawdown``, manual) closed at a price +# that actually happened and is counted. +UNMEASURED_CLOSE_REASONS: frozenset[str] = frozenset({"reconciled"}) + @dataclass(frozen=True) class CalibrationBucket: @@ -78,9 +88,11 @@ def calibration_report( ) -> list[CalibrationBucket]: """Bucket closed, labelled positions by held-side probability. - Excluded: still-open positions (no outcome yet) and positions without an + Excluded: still-open positions (no outcome yet), positions without an ``entry_p_model`` (opened manually, by reconciliation, or before the column - existed) — counting either would bias the very number being measured. + existed), and positions closed for a reason in + ``UNMEASURED_CLOSE_REASONS`` — counting any of them would bias the very + number being measured. ``cost_basis`` maps position id → what was paid to open it, from ``PortfolioStore.buy_cost_basis``. It is what ``mean_return`` divides by, @@ -103,6 +115,8 @@ def calibration_report( for record in positions: if record.status != "closed" or record.realized_pnl is None: continue + if record.close_reason in UNMEASURED_CLOSE_REASONS: + continue probability = _held_side_probability(record) if probability is None: continue diff --git a/openpoly/api/canvas_routes.py b/openpoly/api/canvas_routes.py index 2b69ae3..adaa802 100644 --- a/openpoly/api/canvas_routes.py +++ b/openpoly/api/canvas_routes.py @@ -28,8 +28,9 @@ import logging from typing import Any -from fastapi import APIRouter, Header, HTTPException, Response +from fastapi import APIRouter, Depends, Header, HTTPException, Response +from openpoly.api.security import require_api_token from openpoly.runtime.canvas_store import ( load_template_with_rev, save_template, @@ -63,7 +64,7 @@ def get_canvas_template(response: Response) -> dict[str, Any]: return {**template, "rev": rev} -@router.put("/api/canvas/template") +@router.put("/api/canvas/template", dependencies=[Depends(require_api_token)]) async def put_canvas_template( body: dict[str, Any], response: Response, diff --git a/openpoly/api/inspect_routes.py b/openpoly/api/inspect_routes.py index 98d3f05..34ef628 100644 --- a/openpoly/api/inspect_routes.py +++ b/openpoly/api/inspect_routes.py @@ -172,5 +172,7 @@ def inspect_order_book_history( def inspect_db_status( db: DatabaseManager = Depends(get_database_manager), ) -> dict[str, Any]: - """Persistence-layer status — table row counts + write-behind writer stats.""" + """Persistence-layer status — table row counts, write-behind writer stats, + and the retention block (``pruned_rows`` / ``last_prune_at``), which is the + only outward sign the order-book prune is still running.""" return db.status() diff --git a/openpoly/api/main.py b/openpoly/api/main.py index 913fb4d..ed20dd0 100644 --- a/openpoly/api/main.py +++ b/openpoly/api/main.py @@ -27,8 +27,15 @@ from openpoly.api.portfolio_routes import router as portfolio_router from openpoly.api.runtime_routes import router as runtime_router from openpoly.api.secrets_routes import router as secrets_router +from openpoly.api.security import ( + API_TOKEN_ENV, + HostAllowlistMiddleware, + api_token_ok, + log_startup_security_state, +) from openpoly.api.wallet_routes import router as wallet_router from openpoly.db.engine import get_session_factory +from openpoly.db.manager import DatabaseConfig from openpoly.db.manager import manager as database_manager from openpoly.embedding.manager import manager as embedding_manager from openpoly.execution import executor @@ -40,7 +47,7 @@ from openpoly.runtime.settlement_monitor import settlement_monitor from openpoly.runtime import reconciliation_monitor as _recon_mod from openpoly.runtime.reconciliation_monitor import ReconciliationMonitor -from openpoly.runtime.orchestrator import get_orchestrator +from openpoly.runtime.orchestrator import _canvas_config, get_orchestrator from openpoly.sections._registry import CatalogEntry, scan from openpoly.sections.news_source.tradingnews_ws import TradingNewsWSConfig from openpoly.wallet.runtime_state import runtime_state @@ -106,19 +113,61 @@ async def _autostart_sources() -> None: logger.exception("news_source autostart failed") +def _demote_restored_live_without_token() -> None: + """Re-check the restored exec mode against the API's authentication. + + ``runtime.json`` outlives the process, so ``exec_mode: "live"`` comes back + on every restart — including the restart where the token env var went + missing (unit file edited, secret rotated away, container redeployed + without it). The mode switch refuses live without a usable token; a restore + that skipped that check would put real funds behind an open API precisely + when nobody is watching. Demote to paper and persist it, so the operator + has to fix the token and flip the switch deliberately. The demotion fails + closed: if persistence fails, the in-memory mode is still forced to paper. + """ + if runtime_state.exec_mode != "live" or api_token_ok(): + return + logger.error( + "restored exec_mode=live but %s is unset or unusable — forcing paper " + "mode: live trading behind an unauthenticated API is refused", + API_TOKEN_ENV, + ) + try: + runtime_state.set_mode("paper") + except Exception: # noqa: BLE001 — startup must survive an unwritable state file + # Fail closed: persistence failed, but the dispatcher routes on the + # in-memory mode, so leaving it at "live" would trade real funds behind + # an unauthenticated API. Force paper without touching disk; runtime.json + # still says live, which only means this demotion re-runs on the next boot. + runtime_state.set_mode("paper", persist=False) + logger.critical( + "could not persist the forced paper mode; this process is running " + "paper in memory but runtime.json still says live and the demotion " + "will re-run at the next boot — fix %s or runtime.json before " + "trusting this process with live mode", + API_TOKEN_ENV, + ) + + @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: # Startup: wire the pipeline. Manager forwards each fresh NewsItem # via its sync ``_on_item`` hook → orchestrator.enqueue → worker. + # Say once whether the API is authenticated — an unset token is a working + # loopback default, not a silent one (see openpoly.api.security). + log_startup_security_state() # Wallet + exec_mode state — read first so the dispatcher routes to the # correct executor at the moment the orchestrator starts dispatching. runtime_state.load() + _demote_restored_live_without_token() orch = get_orchestrator() news_source_manager.set_pipeline_hook(orch.enqueue) await orch.start() # Persistence — the database section's manager owns the engine + the two - # write-behind writers (order-book sampling loop + news stream). - await database_manager.start() + # write-behind writers (order-book sampling loop + news stream). Its config + # comes from the canvas like every other section's, so the retention window + # the operator set is the one the prune loop uses. + await database_manager.start(config=_canvas_config(DatabaseConfig, "database")) # Executor — inject a PortfolioStore now the DB engine + tables are up; # entry fills and exit sells write fill / position through it. portfolio = PortfolioStore(get_session_factory()) @@ -197,6 +246,11 @@ async def _held_condition_sides() -> set[tuple[str, str]]: app = FastAPI(title="openPoly", version="0.0.0", lifespan=lifespan) +# Host allowlist first, before routing: a request under a name this backend was +# never meant to answer to is refused whatever it was going to ask for. No CORS +# middleware on purpose — the frontend is same-origin through Vite's proxy, and +# a permissive allowance would hand back what this middleware takes away. +app.add_middleware(HostAllowlistMiddleware) app.include_router(news_router) app.include_router(market_router) app.include_router(inspect_router) diff --git a/openpoly/api/market_routes.py b/openpoly/api/market_routes.py index 2237042..ad422c6 100644 --- a/openpoly/api/market_routes.py +++ b/openpoly/api/market_routes.py @@ -15,9 +15,10 @@ from typing import Any -from fastapi import APIRouter +from fastapi import APIRouter, Depends from pydantic import BaseModel +from openpoly.api.security import require_api_token from openpoly.markets.manager import MarketSourceConfig from openpoly.markets.manager import manager as market_source_manager @@ -51,7 +52,9 @@ def _build_payload() -> MarketSnapshotPayload: return MarketSnapshotPayload(**snap, events=events) -@router.post("/source/start", response_model=MarketSourceResponse) +@router.post( + "/source/start", response_model=MarketSourceResponse, dependencies=[Depends(require_api_token)] +) async def start_source( config: MarketSourceConfig | None = None, ) -> MarketSourceResponse: @@ -65,7 +68,9 @@ async def start_source( return MarketSourceResponse(ok=True, snapshot=_build_payload()) -@router.post("/source/stop", response_model=MarketSourceResponse) +@router.post( + "/source/stop", response_model=MarketSourceResponse, dependencies=[Depends(require_api_token)] +) async def stop_source() -> MarketSourceResponse: await market_source_manager.stop() return MarketSourceResponse(ok=True, snapshot=_build_payload()) diff --git a/openpoly/api/news_routes.py b/openpoly/api/news_routes.py index 37f0041..62fab55 100644 --- a/openpoly/api/news_routes.py +++ b/openpoly/api/news_routes.py @@ -23,7 +23,7 @@ from urllib.parse import quote import websockets -from fastapi import APIRouter +from fastapi import APIRouter, Depends from pydantic import BaseModel from websockets.exceptions import ( InvalidStatus, @@ -31,6 +31,7 @@ WebSocketException, ) +from openpoly.api.security import require_api_token from openpoly.news.manager import manager as news_source_manager from openpoly.news.secrets import SecretsError, resolve as resolve_secret @@ -51,7 +52,7 @@ class NewsTestResponse(BaseModel): latency_ms: int | None = None -@router.post("/test", response_model=NewsTestResponse) +@router.post("/test", response_model=NewsTestResponse, dependencies=[Depends(require_api_token)]) async def test_connection(req: NewsTestRequest) -> NewsTestResponse: try: api_key = resolve_secret(req.api_key_ref) @@ -137,7 +138,9 @@ def _build_payload() -> SnapshotPayload: return SnapshotPayload(**snap, events=events, recent_messages=recent_messages) -@router.post("/source/start", response_model=NewsSourceResponse) +@router.post( + "/source/start", response_model=NewsSourceResponse, dependencies=[Depends(require_api_token)] +) async def start_source(req: NewsSourceStartRequest) -> NewsSourceResponse: # Fast-fail on secret resolution before bothering the manager / source. try: @@ -159,7 +162,9 @@ async def start_source(req: NewsSourceStartRequest) -> NewsSourceResponse: return NewsSourceResponse(ok=True, snapshot=_build_payload()) -@router.post("/source/stop", response_model=NewsSourceResponse) +@router.post( + "/source/stop", response_model=NewsSourceResponse, dependencies=[Depends(require_api_token)] +) async def stop_source() -> NewsSourceResponse: await news_source_manager.stop() return NewsSourceResponse(ok=True, snapshot=_build_payload()) diff --git a/openpoly/api/portfolio_routes.py b/openpoly/api/portfolio_routes.py index 8e5f6f0..73909cd 100644 --- a/openpoly/api/portfolio_routes.py +++ b/openpoly/api/portfolio_routes.py @@ -19,6 +19,7 @@ from sqlalchemy.orm import Session, sessionmaker +from openpoly.api.security import require_api_token from openpoly.analytics.calibration import calibration_report from openpoly.db.engine import get_session_factory from openpoly.execution import executor @@ -82,7 +83,7 @@ def list_fills( return {"fills": [asdict(f) for f in rows]} -@router.post("/positions/{position_id}/close") +@router.post("/positions/{position_id}/close", dependencies=[Depends(require_api_token)]) async def close_position( position_id: int, store: PortfolioStore = Depends(get_portfolio_store), @@ -93,6 +94,11 @@ async def close_position( the ``ExecResult`` — ``filled`` is False (with a ``skip_reason``) when the order book has no bid liquidity right now. + A fill is capped by the level-1 bid's depth, so ``filled`` alone does not + mean the position is gone: the body carries ``partial`` (and, when partial, + ``remaining_qty``) so "sold 10 of 25, 15 still on the book" is not reported + as a completed exit. + Async, and it never awaits between the open-position lookup and the synchronous ``execute_sell`` — so the close is atomic with respect to the ExitMonitor tick on the same event loop. @@ -124,10 +130,27 @@ async def close_position( result = executor.execute_sell(held, close_reason="manual", ts=time.time(), trigger=None) finally: clear_closing(position_id) - return asdict(result) + body = asdict(result) + if result.filled: + # "Still open" is the authoritative test, not ``qty < held.qty``: a + # sell leaving a sub-0.01 residue closes the row outright (see + # ``PortfolioStore.record_sell``), and that is a completed exit. + remaining = _remaining_qty(store, position_id) + body["partial"] = remaining is not None + if remaining is not None: + body["remaining_qty"] = remaining + return body -@router.post("/positions/close-all") +def _remaining_qty(store: PortfolioStore, position_id: int) -> float | None: + """Open qty left on ``position_id`` after a sell, or None when it is flat.""" + record = store.get_position(position_id) + if record is None or record.status != "open": + return None + return record.qty + + +@router.post("/positions/close-all", dependencies=[Depends(require_api_token)]) async def close_all_positions( store: PortfolioStore = Depends(get_portfolio_store), ) -> dict[str, Any]: @@ -143,14 +166,28 @@ async def close_all_positions( monitor is already selling (see ``closing_registry``) are skipped with ``exit_in_flight`` and reported in ``details`` rather than sold twice; each position this route does sell is claimed for the duration. + + A per-position ``ok`` means **flat**, not "the order went through": a sell + capped by the level-1 bid's depth leaves the rest of the position on the + book, and that is counted under ``partial``, never under ``filled``. Bulk + close is the button an operator presses to be out of the market, so a + summary that counted a half-sold position as closed would be answering a + different question than the one being asked. """ opens = store.get_open_positions() if not opens: - return {"attempted": 0, "filled": 0, "skipped": 0, "errored": 0, "details": []} + return { + "attempted": 0, + "filled": 0, + "partial": 0, + "skipped": 0, + "errored": 0, + "details": [], + } now = time.time() details: list[dict[str, Any]] = [] - filled = skipped = errored = 0 + filled = partial = skipped = errored = 0 for held in opens: entry: dict[str, Any] = { "position_id": held.position_id, @@ -172,10 +209,16 @@ async def close_all_positions( errored += 1 else: if result.filled: - entry["ok"] = True entry["price"] = result.price entry["qty"] = result.qty - filled += 1 + remaining = _remaining_qty(store, held.position_id) + entry["partial"] = remaining is not None + entry["ok"] = remaining is None + if remaining is None: + filled += 1 + else: + entry["remaining_qty"] = remaining + partial += 1 else: entry["ok"] = False entry["skip_reason"] = result.skip_reason @@ -186,6 +229,7 @@ async def close_all_positions( return { "attempted": len(opens), "filled": filled, + "partial": partial, "skipped": skipped, "errored": errored, "details": details, diff --git a/openpoly/api/runtime_routes.py b/openpoly/api/runtime_routes.py index 8d9d72c..23a23ab 100644 --- a/openpoly/api/runtime_routes.py +++ b/openpoly/api/runtime_routes.py @@ -21,9 +21,10 @@ import time from typing import Any -from fastapi import APIRouter +from fastapi import APIRouter, Depends from pydantic import BaseModel, ValidationError +from openpoly.api.security import require_api_token from openpoly.llm import LLMClient, LLMError from openpoly.runtime.orchestrator import get_orchestrator from openpoly.runtime.section_log import ( @@ -102,7 +103,9 @@ class AnalyzerTestResponse(BaseModel): latency_ms: int | None = None -@router.post("/analyzer/test", response_model=AnalyzerTestResponse) +@router.post( + "/analyzer/test", response_model=AnalyzerTestResponse, dependencies=[Depends(require_api_token)] +) def test_analyzer(req: AnalyzerTestRequest) -> AnalyzerTestResponse: """Verify the analyzer's LLM config with one minimal forced tool call. diff --git a/openpoly/api/secrets_routes.py b/openpoly/api/secrets_routes.py index f5eaca2..b24c7ff 100644 --- a/openpoly/api/secrets_routes.py +++ b/openpoly/api/secrets_routes.py @@ -13,9 +13,10 @@ from __future__ import annotations -from fastapi import APIRouter, HTTPException, Response +from fastapi import APIRouter, Depends, HTTPException, Response from pydantic import BaseModel +from openpoly.api.security import require_api_token from openpoly.news.secret_store import ( InvalidName, NameNotFound, @@ -44,7 +45,9 @@ class ListSecretsResponse(BaseModel): entries: list[SecretEntryResponse] -@router.post("/local", response_model=CreateSecretResponse) +@router.post( + "/local", response_model=CreateSecretResponse, dependencies=[Depends(require_api_token)] +) async def create_local(req: CreateSecretRequest) -> CreateSecretResponse: try: entry = await get_store().set(req.name, req.value) @@ -67,7 +70,7 @@ def list_local(prefix: str | None = None) -> ListSecretsResponse: ) -@router.delete("/local/{name:path}", status_code=204) +@router.delete("/local/{name:path}", status_code=204, dependencies=[Depends(require_api_token)]) async def delete_local(name: str) -> Response: try: await get_store().delete(name) diff --git a/openpoly/api/security.py b/openpoly/api/security.py new file mode 100644 index 0000000..8d04fdc --- /dev/null +++ b/openpoly/api/security.py @@ -0,0 +1,276 @@ +"""API hardening — shared-secret token on mutating routes + Host allowlist. + +The backend is designed to bind loopback (see ``docs/deploy``), and that was +the whole of its access control: every route was open to anything that could +reach the socket. That is thinner than it looks. ``POST /api/system/mode`` +flips paper→live, ``PUT /api/wallet/config`` repoints the signing key, and +``POST /api/secrets/local`` writes the secret store — so "anything that can +reach the socket" includes any other process on the host, anything sharing the +SSH tunnel, and any web page the operator has open that can be talked into +issuing a cross-origin request to ``127.0.0.1`` under a hostname that resolves +there (DNS rebinding). + +Two independent guards, because they answer different questions: + +* **Token** — *who is calling?* An optional shared secret in the + ``X-OpenPoly-Token`` header, checked by a dependency on every route that + mutates state (POST / PUT / DELETE / PATCH). Reads stay open: they expose no + secret values and gating them would break the canvas' polling for no gain. +* **Host allowlist** — *what name did they use to get here?* A browser can be + induced to send a request to a loopback service, but it cannot forge the + ``Host`` header. Refusing every host that is not loopback or explicitly + allowed is what makes the rebinding path a 421 instead of a live-mode switch. + +Leaving the token unset keeps the local development workflow (backend on +127.0.0.1, Vite proxy in front of it) working exactly as before — but it is +logged loudly at startup, and it **hard-blocks the switch to live mode**: real +funds behind an unauthenticated endpoint is not a default anyone should be able +to reach by omission. + +CORS is deliberately not configured here. The frontend is served through Vite's +proxy (same origin), so no cross-origin allowance is needed, and adding a +permissive one would hand back exactly what the Host allowlist just took away. +""" + +from __future__ import annotations + +import hmac +import logging +import os + +from fastapi import Header, HTTPException +from starlette.datastructures import Headers +from starlette.responses import JSONResponse +from starlette.types import ASGIApp, Receive, Scope, Send + +from openpoly.news.secrets import SecretsError, resolve + +logger = logging.getLogger(__name__) + +API_TOKEN_ENV = "OPENPOLY_API_TOKEN" +API_TOKEN_HEADER = "X-OpenPoly-Token" +ALLOWED_HOSTS_ENV = "OPENPOLY_ALLOWED_HOSTS" + +# HTTP methods that change state. Everything here is guarded; GET / HEAD / +# OPTIONS are not. +MUTATING_METHODS = frozenset({"POST", "PUT", "DELETE", "PATCH"}) + +# Always accepted, with or without the allowlist: these are the names the +# operator's own machine uses to reach a loopback-bound backend. +LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "[::1]", "::1"}) + +# Prefixes that mark the value as a ``*_ref`` (see ``openpoly.news.secrets``) +# rather than the literal token. Anything else is used verbatim, so a literal +# token containing a colon still works. +_REF_SCHEMES = ("env:", "local:", "vault:", "keychain:") + +_ALLOWLIST_WILDCARD = "*" + +# One-shot guard for the startup warning: the lifespan may run more than once +# in a process (tests, reload) and the warning is meant to be seen, not spammed. +_warned_no_token = False + + +# ---------- token ---------- + + +def _raw_token_setting() -> str: + return os.environ.get(API_TOKEN_ENV, "").strip() + + +def api_token_configured() -> bool: + """True when the operator has set a token — regardless of whether it + currently resolves.""" + return bool(_raw_token_setting()) + + +def resolve_api_token() -> str | None: + """The expected token value, or None when unset, **unresolvable, or empty**. + + Callers must treat None as "deny" whenever ``api_token_configured()`` is + True: a ref that stopped resolving (deleted secret, typo in the unit file) + must not silently degrade into the open dev mode. + + An empty resolution is the same failure wearing a worse disguise. ``env:FOO`` + with ``FOO`` exported empty resolves to ``""`` without raising, and an empty + expected token *matches an empty header* — every mutating route would open + to any caller willing to send the header blank. Empty and whitespace-only + values are therefore invalid, never usable secrets. + """ + raw = _raw_token_setting() + if not raw: + return None + if raw.startswith(_REF_SCHEMES): + try: + value = resolve(raw) + except (SecretsError, NotImplementedError) as exc: + logger.error( + "%s is a secret ref that does not resolve (%s) — every mutating " + "route will be refused until it does", + API_TOKEN_ENV, + exc, + ) + return None + else: + value = raw + if not value.strip(): + logger.error( + "%s resolves to an empty value — every mutating route will be " + "refused, and live mode with it, until it holds a real secret", + API_TOKEN_ENV, + ) + return None + return value + + +def api_token_ok() -> bool: + """True when a token is configured **and** currently usable. + + The one question both guards ask, so "configured" and "checkable" can never + drift apart: the route dependency uses it to decide whether a request can be + authenticated at all, and the live-mode gate uses it to decide whether real + funds may go behind this API. + """ + return api_token_configured() and resolve_api_token() is not None + + +def _tokens_match(supplied: str, expected: str) -> bool: + """Constant-time compare of two token strings. + + Compared as UTF-8 bytes: ``hmac.compare_digest`` raises ``TypeError`` on + ``str`` inputs holding non-ASCII code points, so a header with one accented + character would otherwise be a 500 rather than a refusal — and would rule + out a non-ASCII token entirely. Encoding never fails on ``str``. + """ + return hmac.compare_digest(supplied.encode("utf-8"), expected.encode("utf-8")) + + +def require_api_token( + supplied: str | None = Header(default=None, alias=API_TOKEN_HEADER), +) -> None: + """FastAPI dependency guarding one mutating route. + + No token configured → pass (loopback dev mode). Otherwise the header must + be present and match, compared with ``hmac.compare_digest`` so a wrong + token cannot be recovered a byte at a time from response timing. A token + that is configured but does not resolve to a usable value denies everything + — including the empty header that an empty expected value would accept. + """ + if not api_token_configured(): + return + expected = resolve_api_token() + if expected is None or supplied is None or not _tokens_match(supplied, expected): + raise HTTPException( + status_code=401, + detail={ + "error": "invalid_api_token", + "message": f"missing or invalid {API_TOKEN_HEADER} header", + }, + ) + + +def log_startup_security_state() -> None: + """Say once, at startup, whether the API is authenticated.""" + global _warned_no_token + if api_token_configured(): + logger.info("API token configured — mutating routes require %s", API_TOKEN_HEADER) + return + if _warned_no_token: + return + _warned_no_token = True + logger.warning( + "%s is not set: every mutating API route is open to anything that can " + "reach this socket. Fine for loopback development; set it before " + "exposing the backend, and note that live mode is refused without it.", + API_TOKEN_ENV, + ) + + +def reset_startup_warning_for_tests() -> None: + """Test hook — re-arm the one-shot startup warning.""" + global _warned_no_token + _warned_no_token = False + + +# ---------- Host allowlist ---------- + + +def _hostname(raw: str) -> str: + """Host header → bare hostname, port stripped, lowercased. + + IPv6 literals arrive bracketed (``[::1]:8000``); the brackets are kept so + the value matches how the allowlist and ``LOOPBACK_HOSTS`` spell it. + """ + value = raw.strip().lower() + if not value: + return "" + if value.startswith("["): + end = value.find("]") + return value[: end + 1] if end != -1 else value + if value.count(":") > 1: + # Bare (unbracketed) IPv6 literal — no port to strip. + return value + return value.split(":", 1)[0] + + +def allowed_hosts() -> set[str]: + """Loopback plus whatever ``OPENPOLY_ALLOWED_HOSTS`` adds (comma-separated). + + Read per request rather than cached: the value is a deployment knob, and a + cache here would mean a restart to fix a lockout. + """ + extra = os.environ.get(ALLOWED_HOSTS_ENV, "") + parsed = {part.strip().lower() for part in extra.split(",") if part.strip()} + return set(LOOPBACK_HOSTS) | parsed + + +def host_allowed(raw_host: str) -> bool: + """Whether a request carrying this ``Host`` header may proceed. + + An absent/empty Host is allowed: it carries no name to misdirect through + (HTTP/1.1 requires one, so in practice this is only reached by a local + HTTP/1.0 client). + """ + if _ALLOWLIST_WILDCARD in {p.strip() for p in os.environ.get(ALLOWED_HOSTS_ENV, "").split(",")}: + return True + host = _hostname(raw_host) + if not host: + return True + return host in allowed_hosts() + + +class HostAllowlistMiddleware: + """Reject requests whose ``Host`` is neither loopback nor allowlisted. + + A plain ASGI middleware rather than ``BaseHTTPMiddleware``: it has to run + before anything else touches the request, and it never needs the body. + Answers **421 Misdirected Request** — the status that means "this server is + not the one for that authority", which is precisely the situation. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] not in ("http", "websocket"): + await self.app(scope, receive, send) + return + raw_host = Headers(scope=scope).get("host", "") + if host_allowed(raw_host): + await self.app(scope, receive, send) + return + logger.warning("rejected request with disallowed Host header: %r", raw_host[:100]) + if scope["type"] == "websocket": + await send({"type": "websocket.close", "code": 1008}) + return + response = JSONResponse( + { + "error": "host_not_allowed", + "message": ( + f"Host {_hostname(raw_host)!r} is not allowed; add it to " + f"{ALLOWED_HOSTS_ENV} if this backend is meant to serve it" + ), + }, + status_code=421, + ) + await response(scope, receive, send) diff --git a/openpoly/api/wallet_routes.py b/openpoly/api/wallet_routes.py index dbefa30..ae112ec 100644 --- a/openpoly/api/wallet_routes.py +++ b/openpoly/api/wallet_routes.py @@ -30,6 +30,7 @@ from pydantic import BaseModel from openpoly.api.portfolio_routes import get_portfolio_store +from openpoly.api.security import API_TOKEN_ENV, api_token_ok, require_api_token from openpoly.execution import executor from openpoly.execution.live_executor import build_live_executor from openpoly.markets.polymarket_api import fetch_wallet_positions_value @@ -129,7 +130,11 @@ def get_wallet_config() -> WalletConfigResponse: ) -@router.put("/api/wallet/config", response_model=WalletConfigResponse) +@router.put( + "/api/wallet/config", + response_model=WalletConfigResponse, + dependencies=[Depends(require_api_token)], +) def put_wallet_config(body: PutWalletConfigRequest) -> WalletConfigResponse: _validate_ref_format(body.private_key_ref) _validate_address(body.funder_address, "funder_address") @@ -153,8 +158,11 @@ def put_wallet_config(body: PutWalletConfigRequest) -> WalletConfigResponse: funder_address=body.funder_address, ) ) - logger.info( - "wallet config updated; signer=%s funder=%s", + # Same split as the live-executor factory: the event at INFO, the wallet + # identifiers at DEBUG (see openpoly/execution/live_executor.py). + logger.info("wallet config updated") + logger.debug( + "wallet config bound: signer=%s funder=%s", signer, body.funder_address[:10] + "…", ) @@ -174,7 +182,9 @@ class SetModeResponse(BaseModel): mode: Literal["paper", "live"] -@router.post("/api/system/mode", response_model=SetModeResponse) +@router.post( + "/api/system/mode", response_model=SetModeResponse, dependencies=[Depends(require_api_token)] +) def set_mode( body: SetModeRequest, store: PortfolioStore = Depends(get_portfolio_store), @@ -183,6 +193,25 @@ def set_mode( if target == runtime_state.exec_mode: return SetModeResponse(mode=target) + # Live mode is the point where an unauthenticated API stops being a + # development convenience and starts being a way for anything that can + # reach this socket to spend real funds. Refuse it outright rather than + # letting the operator discover the exposure afterwards. Checked before + # every other precondition: no amount of correct wallet config makes an + # open endpoint acceptable here. + if target == "live" and not api_token_ok(): + raise HTTPException( + status_code=403, + detail={ + "error": "api_token_required", + "message": ( + f"set {API_TOKEN_ENV} to a value that resolves to a non-empty " + "secret before switching to live mode — live trading behind " + "an unauthenticated API is refused" + ), + }, + ) + open_positions = store.get_open_positions() if open_positions: raise HTTPException( diff --git a/openpoly/db/engine.py b/openpoly/db/engine.py index 1b1869c..e073d70 100644 --- a/openpoly/db/engine.py +++ b/openpoly/db/engine.py @@ -14,7 +14,7 @@ import os from typing import Any -from sqlalchemy import Engine, create_engine, event +from sqlalchemy import Engine, create_engine, event, inspect from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker # A single SQLite file, relative to the working directory. @@ -63,10 +63,24 @@ def make_session_factory(engine: Engine) -> sessionmaker[Session]: def init_db(engine: Engine) -> None: """Create all registered tables that do not yet exist. - Single-schema, paper-stage table bootstrap. Alembic migrations are deferred - until schema evolution on a live database actually matters. + An **empty** database is stamped to the latest schema version afterwards: + ``create_all`` builds every table at its current shape, so no migration has + anything left to do and recording that fact is what keeps a fresh install + from re-running the whole history (see ``openpoly.db.migrations``). + + A database that already holds tables is left unstamped — it may predate any + given migration, and only ``run_migrations`` can decide. ``create_all`` + still runs, because it is what adds tables introduced since that database + was created; it just cannot alter the ones already there. """ + # Imported here, not at module scope: migrations imports ``Base`` from this + # module, so a top-level import would be circular. + from openpoly.db.migrations import stamp_version + + fresh = not inspect(engine).get_table_names() Base.metadata.create_all(engine) + if fresh: + stamp_version(engine) # Process-wide engine — lazily created, shared by the app lifespan (write-behind diff --git a/openpoly/db/manager.py b/openpoly/db/manager.py index 5707d7c..72033ec 100644 --- a/openpoly/db/manager.py +++ b/openpoly/db/manager.py @@ -1,22 +1,29 @@ """Database runtime manager. -Owns the persistence layer's runtime objects — the SQLAlchemy engine and the -two write-behind writers (order book + news). Lifted out of the FastAPI -lifespan so the ``database`` section has a manager to back it, mirroring -``MarketSourceManager`` / ``NewsSourceManager``. +Owns the persistence layer's runtime objects — the SQLAlchemy engine, the two +write-behind writers (order book + news), and the retention prune loop. Lifted +out of the FastAPI lifespan so the ``database`` section has a manager to back +it, mirroring ``MarketSourceManager`` / ``NewsSourceManager``. + +Schema evolution lives in ``openpoly.db.migrations``: ``start`` runs +``init_db`` (create_all + stamp a fresh database) then ``run_migrations``, +which is the only thing that can alter a table an older process created. """ from __future__ import annotations +import asyncio import contextlib import logging +import time from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy import Engine, func, select, text from openpoly.db.book_store import make_order_book_sink from openpoly.db.engine import get_engine, init_db, make_session_factory +from openpoly.db.migrations import run_migrations from openpoly.db.news_store import make_news_sink from openpoly.db.tables import ( FillRow, @@ -31,50 +38,43 @@ logger = logging.getLogger(__name__) -def _ensure_fill_live_columns(engine: Engine) -> None: - """Idempotent migration: add order_id / tx_hash columns to fill table if - they are missing (older DBs predate slice C). New DBs get the columns - via init_db()'s create_all and skip this entirely. - - SQLite's ALTER TABLE ADD COLUMN only fails if the column exists, so we - PRAGMA-check first instead of catching.""" - with engine.begin() as conn: - existing = {r[1] for r in conn.execute(text("PRAGMA table_info(fill)")).fetchall()} - if "order_id" not in existing: - conn.execute(text("ALTER TABLE fill ADD COLUMN order_id VARCHAR")) - logger.info("migration: added fill.order_id") - if "tx_hash" not in existing: - conn.execute(text("ALTER TABLE fill ADD COLUMN tx_hash VARCHAR")) - logger.info("migration: added fill.tx_hash") - - -def _ensure_position_entry_columns(engine: Engine) -> None: - """Idempotent migration: add the entry-signal columns to the position table - if they are missing (older DBs predate calibration). New DBs get them via - init_db()'s create_all and skip this entirely. - - Same hand-rolled PRAGMA-then-ALTER shape as ``_ensure_fill_live_columns``: - SQLite's ALTER TABLE ADD COLUMN only fails if the column exists, so we - check first instead of catching.""" - with engine.begin() as conn: - existing = {r[1] for r in conn.execute(text("PRAGMA table_info(position)")).fetchall()} - for column, sql_type in ( - ("entry_p_model", "FLOAT"), - ("entry_confidence", "VARCHAR"), - ("entry_edge", "FLOAT"), - ): - if column not in existing: - conn.execute(text(f"ALTER TABLE position ADD COLUMN {column} {sql_type}")) - logger.info("migration: added position.%s", column) +# How often the retention sweep runs. Order-book rows accumulate at the book +# sampler's pace (one row per tracked token per sample), so nothing about this +# is latency-sensitive — hourly keeps each sweep's delete set small enough that +# the single SQLite writer is never held for long. +PRUNE_INTERVAL_SECONDS = 3600.0 + +# Rows deleted per statement. SQLite has one writer: a single unbounded DELETE +# over months of snapshots holds that writer (and the WAL) for as long as it +# takes, stalling the executor's synchronous fill writes behind it. Batching +# means the lock is released between chunks. +PRUNE_BATCH_ROWS = 5000 + +SECONDS_PER_DAY = 86400.0 class DatabaseConfig(BaseModel): """Config for the ``database`` section. - The DB is system infrastructure — no tunable params; the persistence - wiring (one SQLite file, two write-behind writers) is fixed. + The persistence wiring itself (one SQLite file, two write-behind writers) + is fixed system infrastructure. The one tunable is retention: order-book + snapshots are the only table that grows without bound, and they are + sampling telemetry rather than a ledger — the fill / position tables are + the record that must never be dropped. """ + order_book_retention_days: float = Field( + default=7.0, + ge=0.0, + description=( + "Delete order_book_snapshot rows older than this many days. 0 " + "disables the prune entirely (rows are kept forever). The window " + "has to outlive the longest-held position, because peak bootstrap " + "rebuilds a trailing stop from snapshots taken since the position " + "opened." + ), + ) + class DatabaseManager: """Owns the persistence runtime: the engine + the two write-behind writers. @@ -85,28 +85,79 @@ class DatabaseManager: def __init__(self) -> None: self._engine: Engine | None = None + self._config = DatabaseConfig() self._book_writer: WriteBehindWriter | None = None self._news_writer: WriteBehindWriter | None = None + # Retention prune loop. + self._prune_task: asyncio.Task[None] | None = None + self._prune_stop = asyncio.Event() + self._pruned_once = asyncio.Event() + self._pruned_rows = 0 + self._last_prune_at: float | None = None # ---------- lifecycle ---------- - async def start(self, engine: Engine | None = None) -> None: - """Create the engine + tables + write-behind writers and start them. + def configure(self, engine: Engine, config: DatabaseConfig | None = None) -> None: + """Bind the engine + section config without starting anything. + + ``start`` calls this first; it is also the seam for anything that needs + the read/prune side against a specific engine without the write-behind + writers running (the same shape as ``ExitMonitor.configure``). + """ + self._engine = engine + self._config = config or DatabaseConfig() + + def apply_config(self, config: DatabaseConfig) -> None: + """Swap the section config without touching the engine or the writers. + + Retention is the only tunable, and the prune reads its window at the + start of every sweep — so a config applied while the loop is running + takes effect on the next sweep instead of needing a restart. A plain + attribute assignment is all the synchronization this needs: the prune + runs in a worker thread but only ever *reads* the reference, and CPython + rebinds it atomically, so a sweep sees either the old config or the new + one and never a half-applied mix. + """ + self._config = config + + async def start( + self, + engine: Engine | None = None, + config: DatabaseConfig | None = None, + ) -> None: + """Create the engine + schema + write-behind writers and start them. ``engine`` overrides the process engine — tests pass a throwaway one. + Schema bootstrap is two steps: ``init_db`` creates any table that does + not exist yet (and stamps a brand-new database at the latest version), + then ``run_migrations`` alters the tables an older process created. A + migration that fails raises out of here — the writers must not be + pointed at a database whose schema is unknown. """ - self._engine = engine or get_engine() + self.configure(engine or get_engine(), config) + assert self._engine is not None # narrowed by configure init_db(self._engine) - _ensure_fill_live_columns(self._engine) - _ensure_position_entry_columns(self._engine) + run_migrations(self._engine) factory = make_session_factory(self._engine) self._book_writer = WriteBehindWriter(make_order_book_sink(factory)) self._news_writer = WriteBehindWriter(make_news_sink(factory)) await self._book_writer.start() await self._news_writer.start() + self._prune_stop = asyncio.Event() + self._pruned_once = asyncio.Event() + self._prune_task = asyncio.create_task(self._prune_loop()) async def stop(self) -> None: - """Stop both writers, flushing whatever is still queued.""" + """Stop the prune loop and both writers, flushing whatever is queued.""" + if self._prune_task is not None: + self._prune_stop.set() + self._prune_task.cancel() + try: + await self._prune_task + except asyncio.CancelledError: + pass + finally: + self._prune_task = None if self._book_writer is not None: await self._book_writer.stop() if self._news_writer is not None: @@ -116,6 +167,82 @@ async def shutdown(self) -> None: with contextlib.suppress(Exception): await self.stop() + # ---------- retention ---------- + + @property + def pruned_rows(self) -> int: + """Order-book snapshot rows deleted by retention this process.""" + return self._pruned_rows + + @property + def prune_task_running(self) -> bool: + return self._prune_task is not None and not self._prune_task.done() + + async def wait_for_prune(self, timeout: float = 5.0) -> None: + """Block until the prune loop has completed its first sweep.""" + await asyncio.wait_for(self._pruned_once.wait(), timeout=timeout) + + def prune_order_books(self, now: float | None = None) -> int: + """Delete ``order_book_snapshot`` rows past the retention window. + + Synchronous and batched: ``PRUNE_BATCH_ROWS`` ids per statement, each + its own transaction, so the single SQLite writer is handed back between + chunks instead of being held for the whole delete. Returns the number + of rows removed (0 when retention is disabled or the manager has no + engine yet). + """ + if self._engine is None: + return 0 + retention_days = self._config.order_book_retention_days + if retention_days <= 0: + return 0 + stamp = time.time() if now is None else now + cutoff = stamp - retention_days * SECONDS_PER_DAY + deleted = 0 + while True: + with self._engine.begin() as conn: + removed = conn.execute( + text( + "DELETE FROM order_book_snapshot WHERE id IN (" + " SELECT id FROM order_book_snapshot" + " WHERE recorded_at < :cutoff LIMIT :batch" + ")" + ), + {"cutoff": cutoff, "batch": PRUNE_BATCH_ROWS}, + ).rowcount + deleted += removed + if removed < PRUNE_BATCH_ROWS: + break + self._pruned_rows += deleted + self._last_prune_at = stamp + if deleted: + logger.info( + "retention: pruned %d order_book_snapshot rows older than %.1f days", + deleted, + retention_days, + ) + return deleted + + async def _prune_loop(self) -> None: + """Prune on start, then once an hour until stopped. + + Pruning on start matters: a process that was down for a week comes back + to a table holding a week of rows nobody will read, and waiting an hour + to reclaim that is pure downside. The delete is offloaded to a thread — + it is blocking DB work and the event loop is also serving requests. + """ + while not self._prune_stop.is_set(): + try: + await asyncio.to_thread(self.prune_order_books) + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 — retention must never kill the loop + logger.exception("retention prune failed") + finally: + self._pruned_once.set() + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(self._prune_stop.wait(), timeout=PRUNE_INTERVAL_SECONDS) + # ---------- persist hooks (wired into the source managers) ---------- def enqueue_order_book(self, book: OrderBook) -> bool: @@ -134,13 +261,24 @@ def enqueue_news(self, item: NewsItem) -> bool: # ---------- status (powers the database section inspector) ---------- def status(self) -> dict[str, Any]: - """Snapshot of the persistence layer — table row counts + writer stats.""" + """Snapshot of the persistence layer — table row counts, writer stats, + and what retention has reclaimed. + + ``retention.pruned_rows`` is the only outward sign the prune is running + at all: a stuck sweep otherwise shows up as nothing but a table that + keeps growing. + """ return { "tables": self._table_counts(), "writers": { "order_book": self._writer_stats(self._book_writer), "news": self._writer_stats(self._news_writer), }, + "retention": { + "retention_days": self._config.order_book_retention_days, + "pruned_rows": self._pruned_rows, + "last_prune_at": self._last_prune_at, + }, } def _table_counts(self) -> dict[str, int]: diff --git a/openpoly/db/migrations.py b/openpoly/db/migrations.py new file mode 100644 index 0000000..d637cb7 --- /dev/null +++ b/openpoly/db/migrations.py @@ -0,0 +1,211 @@ +"""Versioned schema migrations. + +``create_all`` alone only ever *adds* tables — it never notices that an +existing table is missing a column added later, so every schema change used to +arrive as another hand-rolled ``_ensure_*`` PRAGMA-then-ALTER helper in +``db.manager``, run unconditionally on every start with no record of what had +already been applied. That is a migration system with the bookkeeping left out: +nothing says which changes a given database has seen, a half-applied change +looks identical to a fully-applied one, and a failure is silent. + +This module is that bookkeeping, kept deliberately small (Alembic is not a +dependency and a single-file SQLite database does not warrant one): + +* ``schema_version`` — one table, one row, one integer: the highest migration + applied to this database. Not part of ``Base.metadata`` on purpose, so + ``create_all`` / ``drop_all`` never own it. +* ``MIGRATIONS`` — an ordered ``(version, fn)`` list. Each ``fn`` takes a + ``Connection`` and is applied inside its own transaction, with the version + row written in the same transaction: a migration that raises leaves the + recorded version exactly where it was, and the next start retries it. +* Every migration is **idempotent** — it PRAGMA-checks before it alters. Some + deployed databases already had these columns from the old ``_ensure_*`` path + but have no ``schema_version`` row, so migration 1 must be safe to re-run + against a database that is effectively already at version N. + +A fresh database gets ``create_all`` (which builds the current schema +directly) and is then stamped to ``LATEST_VERSION`` — see ``engine.init_db``. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable + +from sqlalchemy import Connection, Engine, text + +logger = logging.getLogger(__name__) + +SCHEMA_VERSION_TABLE = "schema_version" + +# Composite index backing ``ExitMonitor.bootstrap_peaks`` and the per-token +# history route, both of which filter (token_id, recorded_at) — and the +# retention prune, which deletes by ``recorded_at``. Kept in sync with the +# ``Index(...)`` declared on ``OrderBookSnapshot`` so fresh databases get the +# same index from ``create_all``. +ORDER_BOOK_TOKEN_TIME_INDEX = "ix_order_book_snapshot_token_recorded" + +Migration = Callable[[Connection], None] + + +# ---------- helpers ---------- + + +def _table_columns(conn: Connection, table: str) -> set[str]: + """Column names of ``table`` — empty set when the table does not exist.""" + return {row[1] for row in conn.execute(text(f"PRAGMA table_info({table})")).fetchall()} + + +def _table_exists(conn: Connection, table: str) -> bool: + row = conn.execute( + text("SELECT name FROM sqlite_master WHERE type='table' AND name = :name"), + {"name": table}, + ).first() + return row is not None + + +def _add_missing_columns( + conn: Connection, + table: str, + columns: tuple[tuple[str, str], ...], +) -> None: + """``ALTER TABLE ADD COLUMN`` for each missing column. + + SQLite's ``ADD COLUMN`` fails when the column already exists, so the + PRAGMA check is what makes this re-runnable — which it must be, because a + database migrated by the old ``_ensure_*`` helpers arrives here with the + columns present and no version row. + """ + if not _table_exists(conn, table): + # create_all builds the table at its current shape; nothing to alter. + return + existing = _table_columns(conn, table) + for column, sql_type in columns: + if column in existing: + continue + conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {sql_type}")) + logger.info("migration: added %s.%s", table, column) + + +# ---------- the migrations ---------- + + +def _m001_fill_live_columns(conn: Connection) -> None: + """Live-execution provenance on the fill ledger (was ``_ensure_fill_live_columns``).""" + _add_missing_columns( + conn, + "fill", + (("order_id", "VARCHAR"), ("tx_hash", "VARCHAR")), + ) + + +def _m002_position_entry_columns(conn: Connection) -> None: + """Entry-signal columns for calibration (was ``_ensure_position_entry_columns``).""" + _add_missing_columns( + conn, + "position", + ( + ("entry_p_model", "FLOAT"), + ("entry_confidence", "VARCHAR"), + ("entry_edge", "FLOAT"), + ), + ) + + +def _m003_order_book_token_time_index(conn: Connection) -> None: + """Composite ``(token_id, recorded_at)`` index on ``order_book_snapshot``. + + The table only had a ``token_id`` index, so both readers that matter — + peak bootstrap and the per-token history route — scanned every row ever + recorded for a token to find the ones inside their time window, and the + retention prune had no index at all to delete by. + """ + if not _table_exists(conn, "order_book_snapshot"): + return + conn.execute( + text( + f"CREATE INDEX IF NOT EXISTS {ORDER_BOOK_TOKEN_TIME_INDEX} " + "ON order_book_snapshot (token_id, recorded_at)" + ) + ) + + +# Ordered, append-only. Never renumber or reuse a version: the number recorded +# in a deployed database is the only thing that says what has run there. +MIGRATIONS: list[tuple[int, Migration]] = [ + (1, _m001_fill_live_columns), + (2, _m002_position_entry_columns), + (3, _m003_order_book_token_time_index), +] + +LATEST_VERSION: int = MIGRATIONS[-1][0] + + +# ---------- the version row ---------- + + +def _ensure_version_table(conn: Connection) -> None: + conn.execute( + text(f"CREATE TABLE IF NOT EXISTS {SCHEMA_VERSION_TABLE} (version INTEGER NOT NULL)") + ) + + +def _read_version(conn: Connection) -> int: + row = conn.execute(text(f"SELECT version FROM {SCHEMA_VERSION_TABLE}")).first() + return int(row[0]) if row is not None else 0 + + +def _write_version(conn: Connection, version: int) -> None: + """Single-row upsert, written inside the caller's transaction.""" + updated = conn.execute( + text(f"UPDATE {SCHEMA_VERSION_TABLE} SET version = :v"), + {"v": version}, + ).rowcount + if not updated: + conn.execute( + text(f"INSERT INTO {SCHEMA_VERSION_TABLE} (version) VALUES (:v)"), + {"v": version}, + ) + + +def current_version(engine: Engine) -> int: + """The highest migration applied to this database (0 = none recorded).""" + with engine.begin() as conn: + _ensure_version_table(conn) + return _read_version(conn) + + +def stamp_version(engine: Engine, version: int = LATEST_VERSION) -> None: + """Record ``version`` without running anything. + + Used for a database ``create_all`` just built at the current schema: every + migration would be a no-op against it, so recording the endpoint directly + is both correct and the only way a fresh database starts clean. + """ + with engine.begin() as conn: + _ensure_version_table(conn) + _write_version(conn, version) + + +def run_migrations(engine: Engine) -> int: + """Apply every migration newer than the recorded version. Returns the new + version. + + Each migration runs in its own transaction together with the version write, + so an exception aborts that migration whole and leaves the recorded version + at the last one that fully succeeded. The exception propagates: a database + that could not be migrated must not be handed to the writers as if it had + been. + """ + version = current_version(engine) + if version >= LATEST_VERSION: + return version + for target, migrate in MIGRATIONS: + if target <= version: + continue + with engine.begin() as conn: + migrate(conn) + _write_version(conn, target) + logger.info("migration %d applied", target) + version = target + return version diff --git a/openpoly/db/tables.py b/openpoly/db/tables.py index 188934c..28f4a9b 100644 --- a/openpoly/db/tables.py +++ b/openpoly/db/tables.py @@ -14,9 +14,19 @@ class OrderBookSnapshot(Base): A time series: one row per market per book-sampling cycle. ``bids_json`` / ``asks_json`` hold ``[[price, size], ...]`` best-first — the depth ladder, not a quote snapshot (size is what makes walk-book / slippage answerable). + + This is the one table that grows without bound (every token, every sampling + cycle, forever), so it carries the retention prune (see + ``DatabaseManager.prune_order_books``) and a composite + ``(token_id, recorded_at)`` index: every reader that matters filters on + both — peak bootstrap and the per-token history route — and the prune + deletes by ``recorded_at``. The plain ``token_id`` index is kept because + dropping it would rewrite the table on existing databases for no gain; the + composite one supersedes it for these queries. """ __tablename__ = "order_book_snapshot" + __table_args__ = (Index("ix_order_book_snapshot_token_recorded", "token_id", "recorded_at"),) id: Mapped[int] = mapped_column(primary_key=True) token_id: Mapped[str] = mapped_column(index=True) diff --git a/openpoly/embedding/manager.py b/openpoly/embedding/manager.py index 00ac9ec..f3caabf 100644 --- a/openpoly/embedding/manager.py +++ b/openpoly/embedding/manager.py @@ -112,7 +112,10 @@ async def start( self._store = MarketEmbeddingStore(session_factory) if self._store is not None: await self._load_cache() - self._stop.clear() + # Recreate the Event each start so it binds to the current event + # loop (the singleton outlives loops in tests); mirrors the other + # runtime monitors. + self._stop = asyncio.Event() self._task = asyncio.create_task(self._warm_loop()) self._state = "running" diff --git a/openpoly/execution/live_executor.py b/openpoly/execution/live_executor.py index 4af96bb..f603962 100644 --- a/openpoly/execution/live_executor.py +++ b/openpoly/execution/live_executor.py @@ -149,7 +149,15 @@ def get_collateral_balance_raw(self) -> int | None: def _read_ctf_balance_raw(self, token_id: str) -> int | None: """Refresh + read the wallet's CTF balance for ``token_id`` (raw 1e6 - units). None when the read fails — callers treat that as 'unknown'.""" + units). None when the read fails — callers treat that as 'unknown'. + + A balance field that is absent, null, or not a number is *unknown*, not + zero. Returning 0 for it defeated the ``ctf_balance_unavailable`` guard + entirely: the caller saw a successfully read baseline of 0, posted the + order, and — if the response was then lost — confirmed the fill against + a baseline that had never been read, turning the first parseable read + into an invented fill delta. + """ try: self._clob.update_balance_allowance( BalanceAllowanceParams(asset_type=AssetType.CONDITIONAL, token_id=token_id) @@ -157,9 +165,10 @@ def _read_ctf_balance_raw(self, token_id: str) -> int | None: ba = self._clob.get_balance_allowance( BalanceAllowanceParams(asset_type=AssetType.CONDITIONAL, token_id=token_id) ) - return int(ba.get("balance", 0)) - except (TypeError, ValueError): - return 0 + return int(ba.get("balance")) # type: ignore[arg-type] + except (TypeError, ValueError) as exc: + logger.warning("CTF balance unparseable for %s: %s", token_id, exc) + return None except Exception as exc: # noqa: BLE001 logger.warning("CTF balance read failed: %s", exc) return None @@ -525,10 +534,17 @@ def build_live_executor( ) creds = clob.derive_api_key() clob.set_api_creds(creds) - logger.info( - "live executor ready: signer=%s funder=%s api_key=%s", + # The event is INFO; its payload is not. The L2 API key is a live trading + # credential and never appears at any level — a prefix of a credential is + # still a piece of one, and it bought nothing that "ready" does not say. + # The signer / funder addresses are public on chain but still identify the + # operator's wallet, so they sit at DEBUG: reachable when someone is + # deliberately debugging, absent from the log file and from every pasted + # snippet by default. + logger.info("live executor ready") + logger.debug( + "live executor bound: signer=%s funder=%s", clob.get_address(), wallet.funder_address[:10] + "…", - creds.api_key[:8] + "…", ) return LiveExecutor(portfolio=portfolio, clob_client=clob) diff --git a/openpoly/markets/polymarket_api.py b/openpoly/markets/polymarket_api.py index 1a9855f..62d6016 100644 --- a/openpoly/markets/polymarket_api.py +++ b/openpoly/markets/polymarket_api.py @@ -22,6 +22,7 @@ parse_clob_book, parse_price_history, ) +from openpoly.portfolio.store import MIN_SELLABLE_QTY logger = logging.getLogger(__name__) @@ -192,15 +193,21 @@ async def fetch_held_condition_sides( base_url: str = DATA_API_BASE_URL, timeout: float = DEFAULT_TIMEOUT, client: httpx.AsyncClient | None = None, + min_size: float = MIN_SELLABLE_QTY, ) -> set[tuple[str, str]]: """Return the ``(condition_id, side)`` pairs the wallet holds on-chain. Reads the Polymarket data-api ``/positions`` indexer for ``funder`` — it is authoritative on what the wallet actually holds and accounts for neg-risk wrapping (raw ``balanceOf`` on a token id does not). ``side`` is the held - outcome lowercased (``yes`` / ``no``). Only positions with a positive size - are included; flat (size 0) ones are omitted so the reconciliation monitor - treats them as exited. + outcome lowercased (``yes`` / ``no``). + + A position counts as held only at ``min_size`` shares or more, which + defaults to the venue's size precision (``MIN_SELLABLE_QTY``). Anything + under that is a residue no order can ever clear: ``record_sell`` closes the + ledger position when the remainder falls below it, so reporting the residue + as a holding made the reconciliation monitor's reverse diff raise + ``untracked_onchain_holding`` against a position that was closed correctly. """ raw = await _get_json( f"{base_url}/positions", @@ -220,7 +227,7 @@ async def fetch_held_condition_sides( size = float(pos.get("size") or 0) except (TypeError, ValueError): size = 0.0 - if not cid or not isinstance(outcome, str) or size <= 0: + if not cid or not isinstance(outcome, str) or size < min_size: continue held.add((str(cid), outcome.strip().lower())) return held diff --git a/openpoly/runtime/exit_monitor.py b/openpoly/runtime/exit_monitor.py index b209e10..5add884 100644 --- a/openpoly/runtime/exit_monitor.py +++ b/openpoly/runtime/exit_monitor.py @@ -365,10 +365,15 @@ async def _tick_once(self) -> None: watch.setdefault(held.token_id, []).append(held.position_id) self._watch = watch # Drop the unmarkable / dust markers for anything no longer open, so - # neither set can grow across the process lifetime. + # neither set can grow across the process lifetime. The peak dict is + # pruned on the same line and for a stronger reason: ``_close`` drops a + # peak only for the positions *this* monitor closed, and the settlement + # and reconciliation monitors close positions behind its back — every + # one of those left an entry here forever. open_ids = {held.position_id for held in opens} self._unmarkable &= open_ids self._dust &= open_ids + self._peak = {pid: peak for pid, peak in self._peak.items() if pid in open_ids} blocked = 0 for held in opens: try: @@ -504,13 +509,30 @@ async def _close( finally: clear_closing(held.position_id) if result.filled and result.price is not None: - realized = (result.price - held.avg_entry_price) * held.qty - # Position is closed; drop its peak so a future re-entry on the - # same position_id (shouldn't happen, but be safe) starts fresh, - # and stop observing its token so a book delivered before the next - # sweep cannot resurrect the entry. - self._peak.pop(held.position_id, None) - self._unwatch(held.token_id, held.position_id) + # A sell is not necessarily the whole position: an IOC order fills + # against whatever depth was resting, and ``record_sell`` reduces + # ``qty`` and leaves the row open when the residual is still + # sellable. Realizing against ``held.qty`` therefore booked the + # gain on shares that were never sold. + sold_qty = result.qty if result.qty is not None else held.qty + realized = (result.price - held.avg_entry_price) * sold_qty + # "Still open" is the authoritative test for a partial, not + # ``sold_qty < held.qty``: a sell leaving a sub-0.01 residue is a + # *full* close (see ``PortfolioStore.record_sell``), and treating + # it as partial would strand a peak for a closed position. The + # cheap comparison guards the DB read for the common full fill. + partial = sold_qty < held.qty and self._still_open(held.position_id) + if not partial: + # Position is closed; drop its peak so a future re-entry on the + # same position_id (shouldn't happen, but be safe) starts fresh, + # and stop observing its token so a book delivered before the + # next sweep cannot resurrect the entry. + self._peak.pop(held.position_id, None) + self._unwatch(held.token_id, held.position_id) + # Partial: the remainder is still an open position with a trailing + # stop, so its peak and its book subscription are kept — resetting + # them would re-seed the stop at the next tick's mark and throw + # away the run-up the position has already had. self._log( held, ts, diff --git a/openpoly/runtime/reconciliation_monitor.py b/openpoly/runtime/reconciliation_monitor.py index a816e8e..dc91ae7 100644 --- a/openpoly/runtime/reconciliation_monitor.py +++ b/openpoly/runtime/reconciliation_monitor.py @@ -146,6 +146,13 @@ async def _tick_once(self) -> None: # cancel failed, or an external transfer). Alert loudly, but NEVER # auto-open: the cost basis is unknown and auto-created positions would # confuse entry dedup. A human decides what to do with it. + # + # What counts as "holds" is the fetcher's call, and it excludes anything + # under ``MIN_SELLABLE_QTY`` (see ``fetch_held_condition_sides``): a + # sub-0.01-share residue left behind by ``record_sell`` closing a + # position is not an untracked holding, it is the rounding the close + # deliberately dropped, and alerting on it trains the operator to + # ignore this warning. known = {(p.condition_id, p.side) for p in opens} for cid, side in sorted(held - known): if (cid, side) in self._alerted: diff --git a/openpoly/sections/database/sqlite.py b/openpoly/sections/database/sqlite.py index 5b7a5ee..f84aa71 100644 --- a/openpoly/sections/database/sqlite.py +++ b/openpoly/sections/database/sqlite.py @@ -22,6 +22,13 @@ class SqliteDatabase: Config = DatabaseConfig def __init__(self, config: DatabaseConfig) -> None: + # Construction is deliberately side-effect free. The manager is a + # process-wide singleton, and sections get constructed incidentally — + # the registry's ``CONTRACT_TEST`` builds one with *default* config on + # every catalog scan. Pushing the config into the manager from here + # would let that scan silently revert the operator's retention window. + # The canvas value reaches the prune loop through the lifespan wiring + # (``database_manager.start(config=...)`` in ``openpoly.api.main``). self.config = config def run(self, input: SectionInput) -> SectionOutput: diff --git a/openpoly/sections/entry/edge_threshold_v0.py b/openpoly/sections/entry/edge_threshold_v0.py index 8a6ae85..a79dc4a 100644 --- a/openpoly/sections/entry/edge_threshold_v0.py +++ b/openpoly/sections/entry/edge_threshold_v0.py @@ -356,7 +356,9 @@ def run(self, input: SectionInput) -> SectionOutput: signals=signals, ) - notional = self._scaled_notional(edge, open_cost) + notional, size_skip_reason = self._scaled_notional(edge, open_cost) + if size_skip_reason is not None: + signals["size_multiplier_skipped"] = size_skip_reason multiplier = notional / self.config.order_size_usd if multiplier != 1.0: signals["size_multiplier"] = round(multiplier, 4) @@ -374,9 +376,14 @@ def run(self, input: SectionInput) -> SectionOutput: ) return SectionOutput(payload=intent, verdict="ok", signals=signals) - def _scaled_notional(self, edge: float, open_cost: float | None) -> float: + def _scaled_notional(self, edge: float, open_cost: float | None) -> tuple[float, str | None]: """Order notional in USD — ``order_size_usd``, optionally scaled by edge. + Returns ``(notional, skipped_reason)``; ``skipped_reason`` is non-None + only when a multiplier was available but deliberately not applied, so + the caller can surface it as a signal rather than leaving an unexplained + base-size order. + The multiplier is ``clamp(edge / min_edge, 1.0, size_edge_multiplier_max)`` and never shrinks an order: at the default cap of 1.0 this returns exactly ``order_size_usd``, so sizing is unchanged from before the knob @@ -389,18 +396,26 @@ def _scaled_notional(self, edge: float, open_cost: float | None) -> float: the cap's existing job is to gate on exposure already taken (that pre-check is unchanged), not to shrink the base order — so a config with the knob at its 1.0 default behaves exactly as it did. + + When the cap is on but ``open_cost`` is unknown (no portfolio to read), + the multiplier is refused outright rather than applied unbounded. The + cap is the only thing standing between a 3x multiplier and 3x the + intended exposure, and "the portfolio was unreadable" is exactly the + moment not to take the larger position on trust. """ base = self.config.order_size_usd cap = self.config.size_edge_multiplier_max if cap <= 1.0 or self.config.min_edge <= 0.0: - return base - multiplier = max(1.0, min(edge / self.config.min_edge, cap)) + return base, None heat_cap = self.config.heat_cap_usd + if heat_cap > 0 and open_cost is None: + return base, "portfolio_unavailable" + multiplier = max(1.0, min(edge / self.config.min_edge, cap)) if heat_cap > 0 and open_cost is not None: headroom = heat_cap - open_cost if headroom < base * multiplier: multiplier = max(1.0, headroom / base) - return base * multiplier + return base * multiplier, None @staticmethod def CONTRACT_TEST() -> None: diff --git a/openpoly/wallet/runtime_state.py b/openpoly/wallet/runtime_state.py index a4ac056..1c0b1c4 100644 --- a/openpoly/wallet/runtime_state.py +++ b/openpoly/wallet/runtime_state.py @@ -100,12 +100,22 @@ def load(self) -> None: self._exec_mode = "paper" self._wallet = None - def set_mode(self, mode: ExecMode) -> None: + def set_mode(self, mode: ExecMode, *, persist: bool = True) -> None: # Save-then-mutate: a disk failure must leave in-memory state matching # what is on disk, so callers retrying with a different mode don't see # phantom success (spec §9: "if the PUT disk write fails → do not mutate in-memory state"). + # + # ``persist=False`` is the fail-closed escape hatch for a safety + # demotion (startup forcing paper). There the in-memory mode is the + # thing that must change — the dispatcher routes on it — and rolling it + # back because the file could not be written would keep the process + # trading live. Disk is left alone, so the demotion simply re-runs on + # the next boot. if mode not in _VALID_MODES: raise ValueError(f"unknown exec mode: {mode!r}") + if not persist: + self._exec_mode = mode + return prev = self._exec_mode self._exec_mode = mode try: diff --git a/tests/conftest.py b/tests/conftest.py index 7dbea93..27b08fc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,9 +19,16 @@ def _test_db_url(tmp_path_factory: pytest.TempPathFactory) -> Iterator[None]: # Keep the lifespan from opening real news / market connections — tests # that exercise it drive the managers explicitly. os.environ["OPENPOLY_AUTOSTART_SOURCES"] = "0" + # The Host allowlist (openpoly.api.security) refuses anything that is not + # loopback or explicitly allowed. The two hostnames the test clients use — + # ``testserver`` (Starlette's TestClient default) and ``test`` (the httpx + # ASGITransport base_url) — are allowed here rather than in the production + # default, which must stay strict. + os.environ["OPENPOLY_ALLOWED_HOSTS"] = "testserver,test" yield os.environ.pop("OPENPOLY_DB_URL", None) os.environ.pop("OPENPOLY_AUTOSTART_SOURCES", None) + os.environ.pop("OPENPOLY_ALLOWED_HOSTS", None) @pytest.fixture(autouse=True) diff --git a/tests/test_analytics_calibration.py b/tests/test_analytics_calibration.py index ccd843a..271839d 100644 --- a/tests/test_analytics_calibration.py +++ b/tests/test_analytics_calibration.py @@ -31,6 +31,7 @@ def _closed( qty: float = 10.0, entry: float = 0.50, position_id: int = 1, + close_reason: str = "take_profit", ) -> PositionRecord: return PositionRecord( id=position_id, @@ -43,7 +44,7 @@ def _closed( status="closed", opened_at=100.0, closed_at=200.0, - close_reason="take_profit", + close_reason=close_reason, realized_pnl=realized_pnl, entry_p_model=p_model, entry_confidence="medium", @@ -177,3 +178,39 @@ def test_return_is_measured_against_the_opened_cost_basis_not_the_residual(store bucket = next(b for b in buckets if b.lower == 0.7) assert bucket.count == 1 assert bucket.mean_return == pytest.approx(0.2925) + + +# ---------- reconciled closes carry no measurable outcome ---------- + + +def test_reconciled_closes_are_excluded() -> None: + """A reconciled close records ``realized_pnl = 0`` by construction: the + position was exited outside the ledger and the real exit price cannot be + attributed back to it (see ``ReconciliationMonitor``). Counting that zero + scores every such trade as a loss, so a bucket full of reconciled rows + reads as a badly calibrated model rather than as missing data.""" + positions = [ + _closed(p_model=0.85, realized_pnl=0.0, position_id=1, close_reason="reconciled"), + _closed(p_model=0.85, realized_pnl=0.0, position_id=2, close_reason="reconciled"), + _closed(p_model=0.85, realized_pnl=5.0, position_id=3), + ] + buckets = calibration_report(positions) + top = [b for b in buckets if b.lower == 0.8][0] + assert top.count == 1 + assert top.win_rate == pytest.approx(1.0) + + +@pytest.mark.parametrize( + "close_reason", ["settlement", "take_profit", "stop_loss", "peak_drawdown", "manual"] +) +def test_real_close_reasons_are_counted(close_reason: str) -> None: + positions = [_closed(p_model=0.85, realized_pnl=5.0, close_reason=close_reason)] + top = [b for b in calibration_report(positions) if b.lower == 0.8][0] + assert top.count == 1 + + +def test_a_null_close_reason_is_still_counted() -> None: + """Only ``reconciled`` is fabricated; an unlabelled close is not.""" + positions = [_closed(p_model=0.85, realized_pnl=5.0, close_reason=None)] + top = [b for b in calibration_report(positions) if b.lower == 0.8][0] + assert top.count == 1 diff --git a/tests/test_api_inspect.py b/tests/test_api_inspect.py index a7059c9..36ab306 100644 --- a/tests/test_api_inspect.py +++ b/tests/test_api_inspect.py @@ -218,6 +218,42 @@ def test_inspect_db_status_unstarted_manager(): assert body["writers"] == {"order_book": None, "news": None} +def test_inspect_db_status_exposes_pruned_rows(tmp_path): + """Retention has to be observable from outside: a prune that silently + stopped otherwise looks exactly like a table that is simply growing.""" + import time + + from openpoly.db.engine import init_db, make_engine, make_session_factory + from openpoly.db.manager import DatabaseConfig + from openpoly.db.tables import OrderBookSnapshot + + now = time.time() + engine = make_engine(f"sqlite:///{tmp_path}/prune.db") + init_db(engine) + with make_session_factory(engine)() as session: + session.add( + OrderBookSnapshot( + token_id="t", + recorded_at=now - 40 * 86400.0, + bids_json="[]", + asks_json="[]", + ) + ) + session.commit() + mgr = DatabaseManager() + mgr.configure(engine, DatabaseConfig(order_book_retention_days=7.0)) + mgr.prune_order_books(now=now) + + app.dependency_overrides[get_database_manager] = lambda: mgr + try: + body = TestClient(app).get("/api/inspect/db-status").json() + finally: + app.dependency_overrides.clear() + engine.dispose() + assert body["retention"]["pruned_rows"] == 1 + assert body["retention"]["retention_days"] == 7.0 + + # ---------- /api/inspect/order-books/{token_id} ---------- diff --git a/tests/test_api_mode_switch.py b/tests/test_api_mode_switch.py index 1c8b13f..3e42a84 100644 --- a/tests/test_api_mode_switch.py +++ b/tests/test_api_mode_switch.py @@ -10,6 +10,7 @@ import openpoly.api.wallet_routes as wallet_routes from openpoly.api.main import app from openpoly.api.portfolio_routes import get_portfolio_store +from openpoly.api.security import API_TOKEN_HEADER from openpoly.db.engine import init_db, make_engine, make_session_factory from openpoly.portfolio import PortfolioStore from openpoly.wallet.runtime_state import RuntimeState, WalletSpec @@ -23,6 +24,9 @@ NEGRISK_V2 = "0xe2222d279d744050d28e00520010520000310F59" MAX_UINT = str(2**256 - 1) +# Shared secret the fixture configures so the live switch clears its token gate. +TEST_API_TOKEN = "mode-switch-test-token" + @pytest.fixture def env( @@ -30,6 +34,13 @@ def env( ) -> tuple[TestClient, PortfolioStore, RuntimeState]: monkeypatch.setenv("OPENPOLY_RUNTIME_STATE", str(tmp_path / "runtime.json")) monkeypatch.setenv("OPENPOLY_POLYMARKET_PK", TEST_PRIVKEY) + # Live mode is refused outright while the API has no shared secret (see + # openpoly.api.security). These tests are about the *wallet* preflight, so + # the token gate is satisfied here; the gate itself is covered in + # tests/test_api_security.py. The header is not needed on top of it — the + # per-route dependency compares only when a token is configured, and the + # client below sends it. + monkeypatch.setenv("OPENPOLY_API_TOKEN", TEST_API_TOKEN) engine = make_engine(f"sqlite:///{tmp_path}/portfolio.db") init_db(engine) @@ -40,7 +51,11 @@ def env( rs.load() monkeypatch.setattr(wallet_routes, "runtime_state", rs) - yield TestClient(app), store, rs + yield ( + TestClient(app, headers={API_TOKEN_HEADER: TEST_API_TOKEN}), + store, + rs, + ) app.dependency_overrides.clear() engine.dispose() diff --git a/tests/test_api_portfolio_close.py b/tests/test_api_portfolio_close.py index 1a4609a..1cef644 100644 --- a/tests/test_api_portfolio_close.py +++ b/tests/test_api_portfolio_close.py @@ -124,7 +124,14 @@ def test_close_all_with_no_open_returns_noop(env) -> None: r = client.post("/api/positions/close-all") assert r.status_code == 200 body = r.json() - assert body == {"attempted": 0, "filled": 0, "skipped": 0, "errored": 0, "details": []} + assert body == { + "attempted": 0, + "filled": 0, + "partial": 0, + "skipped": 0, + "errored": 0, + "details": [], + } def test_close_all_three_positions_all_succeed(env) -> None: @@ -273,3 +280,77 @@ def test_close_all_skips_positions_with_an_in_flight_exit(env) -> None: assert by_id[p2.position_id]["skip_reason"] == "exit_in_flight" assert store.get_position(p1.position_id).status == "closed" assert store.get_position(p2.position_id).status == "open" + + +# ---------- partial fills (the bid could not absorb the whole position) ---------- + + +def _thin_book(token_id: str, bid: float, size: float) -> OrderBook: + return OrderBook( + token_id=token_id, + ts=1.0, + bids=[(bid, size)], + asks=[(bid + 0.02, 100.0)], + ) + + +def test_close_reports_a_partial_fill(env) -> None: + """A 25-share position into a 10-share bid sells 10 and stays open. The + response said ``filled: true`` and nothing else — indistinguishable from a + completed exit, so the operator had no way to know 15 shares were still on + the book.""" + store, client = env + held = _open(store) # qty 25 + market_source_manager.store.set_order_books([_thin_book("t1", bid=0.55, size=10.0)]) + + body = client.post(f"/api/positions/{held.position_id}/close").json() + + assert body["filled"] is True + assert body["partial"] is True + assert body["qty"] == pytest.approx(10.0) + assert body["remaining_qty"] == pytest.approx(15.0) + assert store.get_position(held.position_id).status == "open" + + +def test_close_reports_a_full_fill_as_not_partial(env) -> None: + store, client = env + held = _open(store) + market_source_manager.store.set_order_books([_book("t1", bid=0.55)]) + + body = client.post(f"/api/positions/{held.position_id}/close").json() + + assert body["filled"] is True + assert body["partial"] is False + assert "remaining_qty" not in body + + +def test_close_all_counts_partials_separately(env) -> None: + store, client = env + p1 = _open(store, token_id="t1", market_id="m1") # fully absorbed + p2 = _open(store, token_id="t2", market_id="m2") # thin bid → partial + market_source_manager.store.set_order_books( + [ + _book("t1", bid=0.55), + _thin_book("t2", bid=0.50, size=10.0), + ] + ) + + body = client.post("/api/positions/close-all").json() + + assert body["attempted"] == 2 + assert body["filled"] == 1 + assert body["partial"] == 1 + assert body["skipped"] == 0 + by_id = {d["position_id"]: d for d in body["details"]} + # ``ok`` means flat: a position with 15 shares still on the book is not. + assert by_id[p1.position_id]["ok"] is True + assert by_id[p2.position_id]["ok"] is False + assert by_id[p2.position_id]["partial"] is True + assert by_id[p2.position_id]["remaining_qty"] == pytest.approx(15.0) + assert store.get_position(p2.position_id).status == "open" + + +def test_close_all_noop_body_carries_the_partial_counter(env) -> None: + _store, client = env + body = client.post("/api/positions/close-all").json() + assert body["partial"] == 0 diff --git a/tests/test_api_security.py b/tests/test_api_security.py new file mode 100644 index 0000000..b0027bd --- /dev/null +++ b/tests/test_api_security.py @@ -0,0 +1,479 @@ +"""API hardening — shared-secret token on mutating routes + Host allowlist. + +The backend binds loopback and holds a wallet private-key ref, a secret store, +and a paper→live switch behind routes that had no authentication at all. These +tests pin both halves of the fix: nothing that mutates state is reachable +without the configured token, and nothing is reachable under a Host the +operator did not allow (the DNS-rebinding shape against a loopback service). +""" + +from __future__ import annotations + +import json +import logging + +import pytest +from fastapi import HTTPException +from fastapi.routing import APIRoute +from fastapi.testclient import TestClient + +from openpoly.api.main import app +from openpoly.api.security import ( + ALLOWED_HOSTS_ENV, + API_TOKEN_ENV, + API_TOKEN_HEADER, + MUTATING_METHODS, + log_startup_security_state, + require_api_token, + reset_startup_warning_for_tests, +) + +TOKEN = "s3cret-token" + +# A mutating route with no side effect worth isolating: stopping an already +# stopped market source is a no-op that still proves the dependency ran. +MUTATING_PATH = "/api/market/source/stop" + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +@pytest.fixture(autouse=True) +def _clean_token(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv(API_TOKEN_ENV, raising=False) + reset_startup_warning_for_tests() + yield + reset_startup_warning_for_tests() + + +# ---------- token on mutating routes ---------- + + +def test_mutating_route_rejected_without_token( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(API_TOKEN_ENV, TOKEN) + r = client.post(MUTATING_PATH) + assert r.status_code == 401 + assert r.json()["detail"]["error"] == "invalid_api_token" + + +def test_mutating_route_rejected_with_wrong_token( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(API_TOKEN_ENV, TOKEN) + r = client.post(MUTATING_PATH, headers={API_TOKEN_HEADER: "nope"}) + assert r.status_code == 401 + + +def test_mutating_route_passes_with_token( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(API_TOKEN_ENV, TOKEN) + r = client.post(MUTATING_PATH, headers={API_TOKEN_HEADER: TOKEN}) + assert r.status_code == 200 + + +def test_mutating_route_open_when_no_token_configured(client: TestClient) -> None: + """Loopback dev mode: an unset token keeps the local workflow working.""" + r = client.post(MUTATING_PATH) + assert r.status_code == 200 + + +def test_get_routes_are_unaffected_by_the_token( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(API_TOKEN_ENV, TOKEN) + assert client.get("/api/health").status_code == 200 + assert client.get("/api/market/source/status").status_code == 200 + + +def test_token_may_be_a_secret_ref(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """The value follows the same ``*_ref`` indirection as every other secret, + so the token never has to sit in the systemd unit's env in plaintext.""" + monkeypatch.setenv("OPENPOLY_TEST_TOKEN_HOLDER", TOKEN) + monkeypatch.setenv(API_TOKEN_ENV, "env:OPENPOLY_TEST_TOKEN_HOLDER") + assert client.post(MUTATING_PATH, headers={API_TOKEN_HEADER: TOKEN}).status_code == 200 + assert client.post(MUTATING_PATH, headers={API_TOKEN_HEADER: "env:x"}).status_code == 401 + + +def test_unresolvable_token_ref_fails_closed( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """A configured-but-unresolvable token must deny, never fall back to the + open dev mode — that would turn a typo into a silently unauthenticated + backend.""" + monkeypatch.delenv("OPENPOLY_MISSING_TOKEN", raising=False) + monkeypatch.setenv(API_TOKEN_ENV, "env:OPENPOLY_MISSING_TOKEN") + assert client.post(MUTATING_PATH).status_code == 401 + assert client.post(MUTATING_PATH, headers={API_TOKEN_HEADER: TOKEN}).status_code == 401 + + +def test_blank_token_env_is_treated_as_unset( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(API_TOKEN_ENV, " ") + assert client.post(MUTATING_PATH).status_code == 200 + + +def test_every_mutating_route_declares_the_token_dependency() -> None: + """The guard is per-route, so a route added later without it is a hole. + This test is what closes that: it enumerates the app, not a hand-list.""" + missing = [ + f"{sorted(route.methods & MUTATING_METHODS)} {route.path}" + for route in app.routes + if isinstance(route, APIRoute) + and route.methods & MUTATING_METHODS + and not any(d.dependency is require_api_token for d in route.dependencies) + ] + assert missing == [] + + +# ---------- startup warning ---------- + + +def test_startup_warns_once_when_no_token(caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING, logger="openpoly.api.security"): + log_startup_security_state() + log_startup_security_state() + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert API_TOKEN_ENV in warnings[0].getMessage() + + +def test_startup_does_not_warn_when_token_configured( + caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(API_TOKEN_ENV, TOKEN) + with caplog.at_level(logging.WARNING, logger="openpoly.api.security"): + log_startup_security_state() + assert [r for r in caplog.records if r.levelno == logging.WARNING] == [] + + +# ---------- live mode needs a token ---------- + + +def test_live_mode_refused_without_token(client: TestClient) -> None: + r = client.post("/api/system/mode", json={"mode": "live"}) + assert r.status_code == 403 + assert r.json()["detail"]["error"] == "api_token_required" + + +def test_live_mode_not_refused_for_that_reason_with_token( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """With a token configured the live switch reaches its real preflight — + it still fails on the unconfigured wallet, but never on the token.""" + from openpoly.api.portfolio_routes import get_portfolio_store + + class _NoPositions: + def get_open_positions(self): + return [] + + monkeypatch.setenv(API_TOKEN_ENV, TOKEN) + app.dependency_overrides[get_portfolio_store] = _NoPositions + try: + r = client.post( + "/api/system/mode", + json={"mode": "live"}, + headers={API_TOKEN_HEADER: TOKEN}, + ) + finally: + app.dependency_overrides.clear() + assert r.status_code != 403 + assert r.json()["detail"]["error"] == "wallet_not_configured" + + +def test_paper_mode_switch_is_not_gated(client: TestClient) -> None: + r = client.post("/api/system/mode", json={"mode": "paper"}) + assert r.status_code == 200 + + +# ---------- Host allowlist ---------- + + +@pytest.mark.parametrize("host", ["localhost", "127.0.0.1", "localhost:8000"]) +def test_loopback_hosts_are_always_allowed(host: str) -> None: + r = TestClient(app, base_url=f"http://{host}").get("/api/health") + assert r.status_code == 200 + + +@pytest.mark.parametrize( + "host", ["localhost", "LOCALHOST", "127.0.0.1", "[::1]", "[::1]:18000", "::1", ""] +) +def test_loopback_host_headers_pass_the_check(host: str) -> None: + """Checked at the predicate: Starlette's TestClient cannot build a URL for + a bracketed IPv6 authority, so the header shapes are asserted directly.""" + from openpoly.api.security import host_allowed + + assert host_allowed(host) is True + + +def test_unknown_host_is_rejected() -> None: + r = TestClient(app, base_url="http://evil.example.com").get("/api/health") + assert r.status_code == 421 + assert r.json()["error"] == "host_not_allowed" + + +def test_allowlist_env_admits_a_host(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(ALLOWED_HOSTS_ENV, "openpoly.example.com, other.example.com") + r = TestClient(app, base_url="http://openpoly.example.com").get("/api/health") + assert r.status_code == 200 + r = TestClient(app, base_url="http://third.example.com").get("/api/health") + assert r.status_code == 421 + + +def test_allowlist_wildcard_admits_everything(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(ALLOWED_HOSTS_ENV, "*") + r = TestClient(app, base_url="http://anything.example.com").get("/api/health") + assert r.status_code == 200 + + +def test_host_check_applies_to_mutating_routes_too(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(API_TOKEN_ENV, raising=False) + r = TestClient(app, base_url="http://evil.example.com").post(MUTATING_PATH) + assert r.status_code == 421 + + +def test_port_is_ignored_when_matching_the_allowlist(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(ALLOWED_HOSTS_ENV, "openpoly.example.com") + r = TestClient(app, base_url="http://openpoly.example.com:18000").get("/api/health") + assert r.status_code == 200 + + +# ---------- a configured token that resolves to nothing must fail closed ---------- + +# Holder for the "set but empty" shape: an env var that exists with an empty +# value resolves without raising, so it reaches the comparison as "" — and an +# empty expected value matches an empty supplied header. +EMPTY_HOLDER = "OPENPOLY_TEST_EMPTY_TOKEN_HOLDER" + + +def test_token_ref_resolving_to_empty_fails_closed( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """``env:FOO`` with FOO set-but-empty is a broken deployment, not an open + one: an empty expected token would match an empty supplied header and let + every caller through.""" + monkeypatch.setenv(EMPTY_HOLDER, "") + monkeypatch.setenv(API_TOKEN_ENV, f"env:{EMPTY_HOLDER}") + assert client.post(MUTATING_PATH).status_code == 401 + assert client.post(MUTATING_PATH, headers={API_TOKEN_HEADER: ""}).status_code == 401 + assert client.post(MUTATING_PATH, headers={API_TOKEN_HEADER: TOKEN}).status_code == 401 + + +def test_token_ref_resolving_to_whitespace_fails_closed( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(EMPTY_HOLDER, " ") + monkeypatch.setenv(API_TOKEN_ENV, f"env:{EMPTY_HOLDER}") + assert client.post(MUTATING_PATH, headers={API_TOKEN_HEADER: " "}).status_code == 401 + + +def test_resolve_api_token_returns_none_for_an_empty_resolution( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from openpoly.api.security import resolve_api_token + + monkeypatch.setenv(EMPTY_HOLDER, "") + monkeypatch.setenv(API_TOKEN_ENV, f"env:{EMPTY_HOLDER}") + assert resolve_api_token() is None + + +def test_api_token_ok_distinguishes_unset_from_unusable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``api_token_ok`` is the single question both the route guard and the + live-mode gate ask: is there a token that can actually be checked?""" + from openpoly.api.security import api_token_ok + + monkeypatch.delenv(API_TOKEN_ENV, raising=False) + assert api_token_ok() is False + monkeypatch.setenv(API_TOKEN_ENV, TOKEN) + assert api_token_ok() is True + monkeypatch.setenv(EMPTY_HOLDER, "") + monkeypatch.setenv(API_TOKEN_ENV, f"env:{EMPTY_HOLDER}") + assert api_token_ok() is False + monkeypatch.delenv("OPENPOLY_MISSING_TOKEN", raising=False) + monkeypatch.setenv(API_TOKEN_ENV, "env:OPENPOLY_MISSING_TOKEN") + assert api_token_ok() is False + + +def test_live_mode_refused_when_the_token_resolves_empty( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """The live switch must treat an unusable token as no token at all.""" + monkeypatch.setenv(EMPTY_HOLDER, "") + monkeypatch.setenv(API_TOKEN_ENV, f"env:{EMPTY_HOLDER}") + r = client.post("/api/system/mode", json={"mode": "live"}, headers={API_TOKEN_HEADER: ""}) + assert r.status_code in (401, 403) + + +# ---------- non-ASCII tokens / headers ---------- + +# All code points below U+0100, so the value survives the latin-1 round trip +# HTTP header values use on the wire. +NON_ASCII_TOKEN = "s3cret-tökén" + + +def test_non_ascii_header_is_refused_not_a_server_error( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """``hmac.compare_digest`` rejects non-ASCII ``str`` inputs; comparing the + encoded bytes keeps a hostile header a 401 instead of a 500.""" + monkeypatch.setenv(API_TOKEN_ENV, TOKEN) + r = client.post(MUTATING_PATH, headers={API_TOKEN_HEADER: "tökén".encode("utf-8")}) + assert r.status_code == 401 + + +def test_non_ascii_token_matches_an_identical_header(monkeypatch: pytest.MonkeyPatch) -> None: + """A non-ASCII token still authenticates — the guard must refuse the wrong + value, not every value it cannot compare. + + Asserted against the dependency rather than through the test client: HTTP + header values are latin-1 on the wire and the client transport re-encodes + them as UTF-8, so an end-to-end version would be pinning that transcoding + instead of the comparison under test. + """ + monkeypatch.setenv(API_TOKEN_ENV, NON_ASCII_TOKEN) + require_api_token(supplied=NON_ASCII_TOKEN) # must not raise + with pytest.raises(HTTPException) as excinfo: + require_api_token(supplied="s3cret-tökèn") + assert excinfo.value.status_code == 401 + + +# ---------- a restored live mode is re-checked at startup ---------- + + +def _write_runtime(path, mode: str) -> None: + path.write_text(json.dumps({"wallet": None, "exec_mode": mode, "updated_at": 1.0})) + + +class _StubEmbeddingManager: + """Stand-in for the process-wide embedding manager during the lifespan. + + Its warm loop is the one long-lived task in startup whose stop Event is + created once per process rather than per start, so driving the real one + from a test's event loop leaves a landmine for the next lifespan test. + Nothing here is under test, so it is replaced outright. + """ + + async def start(self, **_kwargs: object) -> None: + return None + + async def stop(self) -> None: + return None + + +async def test_startup_forces_paper_when_restored_live_has_no_usable_token( + tmp_path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """runtime.json survives the deployment that lost its token: restoring + ``live`` behind an unauthenticated API would spend real funds for anything + that can reach the socket.""" + import openpoly.api.main as main_mod + from openpoly.wallet.runtime_state import RuntimeState + + path = tmp_path / "runtime.json" + _write_runtime(path, "live") + rs = RuntimeState(path) + monkeypatch.setattr(main_mod, "runtime_state", rs) + monkeypatch.setattr(main_mod, "embedding_manager", _StubEmbeddingManager()) + monkeypatch.delenv(API_TOKEN_ENV, raising=False) + + with caplog.at_level(logging.ERROR, logger="openpoly.api.main"): + async with main_mod.lifespan(app): + pass + + assert rs.exec_mode == "paper" + assert json.loads(path.read_text())["exec_mode"] == "paper" + assert [r for r in caplog.records if r.levelno >= logging.ERROR] != [] + + +async def test_startup_forces_paper_in_memory_when_runtime_json_is_unwritable( + tmp_path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """The demotion must fail CLOSED. If the state file cannot be rewritten the + process still must not route orders through the live executor, so the + in-memory mode is forced to paper regardless of persistence. Disk keeps + saying live, which only means the same demotion re-runs next boot.""" + import openpoly.api.main as main_mod + from openpoly.wallet.runtime_state import RuntimeState + + path = tmp_path / "runtime.json" + _write_runtime(path, "live") + rs = RuntimeState(path) + + def boom(self: RuntimeState) -> None: + raise OSError("simulated read-only state directory") + + monkeypatch.setattr(RuntimeState, "_save", boom) + monkeypatch.setattr(main_mod, "runtime_state", rs) + monkeypatch.setattr(main_mod, "embedding_manager", _StubEmbeddingManager()) + monkeypatch.delenv(API_TOKEN_ENV, raising=False) + + with caplog.at_level(logging.CRITICAL, logger="openpoly.api.main"): + async with main_mod.lifespan(app): + pass + + assert rs.exec_mode == "paper" + assert json.loads(path.read_text())["exec_mode"] == "live" + assert [r for r in caplog.records if r.levelno >= logging.CRITICAL] != [] + + +def test_restored_live_survives_a_configured_token( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The guard demotes an unauthenticated live restore, not every live one.""" + import openpoly.api.main as main_mod + from openpoly.wallet.runtime_state import RuntimeState + + path = tmp_path / "runtime.json" + _write_runtime(path, "live") + rs = RuntimeState(path) + rs.load() + monkeypatch.setattr(main_mod, "runtime_state", rs) + monkeypatch.setenv(API_TOKEN_ENV, TOKEN) + + main_mod._demote_restored_live_without_token() + + assert rs.exec_mode == "live" + assert json.loads(path.read_text())["exec_mode"] == "live" + + +def test_restored_live_is_demoted_when_the_token_ref_resolves_empty( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + import openpoly.api.main as main_mod + from openpoly.wallet.runtime_state import RuntimeState + + path = tmp_path / "runtime.json" + _write_runtime(path, "live") + rs = RuntimeState(path) + rs.load() + monkeypatch.setattr(main_mod, "runtime_state", rs) + monkeypatch.setenv(EMPTY_HOLDER, "") + monkeypatch.setenv(API_TOKEN_ENV, f"env:{EMPTY_HOLDER}") + + main_mod._demote_restored_live_without_token() + + assert rs.exec_mode == "paper" + assert json.loads(path.read_text())["exec_mode"] == "paper" + + +def test_restored_paper_is_left_alone(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + import openpoly.api.main as main_mod + from openpoly.wallet.runtime_state import RuntimeState + + path = tmp_path / "runtime.json" + _write_runtime(path, "paper") + rs = RuntimeState(path) + rs.load() + monkeypatch.setattr(main_mod, "runtime_state", rs) + monkeypatch.delenv(API_TOKEN_ENV, raising=False) + + main_mod._demote_restored_live_without_token() + + assert rs.exec_mode == "paper" diff --git a/tests/test_credential_logging.py b/tests/test_credential_logging.py new file mode 100644 index 0000000..5bd6484 --- /dev/null +++ b/tests/test_credential_logging.py @@ -0,0 +1,138 @@ +"""Credential fragments must not reach INFO logs. + +The live-executor factory and the wallet-config route both used to announce the +signer address, a funder prefix and an API-key prefix at INFO — the level that +actually lands in ``/var/log/openpoly.out`` and in every pasted debug snippet. +The API-key fragment is gone entirely (it is a live trading credential, and a +prefix of one is still a prefix of one); the addresses moved to DEBUG, where +they are available when someone is deliberately looking. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +import openpoly.api.wallet_routes as wallet_routes +from openpoly.api.main import app +from openpoly.execution.live_executor import build_live_executor +from openpoly.wallet.runtime_state import RuntimeState, WalletSpec + +# Anvil's deterministic dev key #0 — public, well-known, safe to bake into tests. +TEST_PRIVKEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" +TEST_SIGNER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" +TEST_FUNDER = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" +FAKE_API_KEY = "apikey-0123456789abcdef" + + +class _FakeCreds: + api_key = FAKE_API_KEY + api_secret = "secret" + api_passphrase = "passphrase" + + +class _FakeClobClient: + def __init__(self, *_args, **_kwargs) -> None: + pass + + def derive_api_key(self): + return _FakeCreds() + + def set_api_creds(self, _creds) -> None: + pass + + def get_address(self) -> str: + return TEST_SIGNER + + +def _build(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("OPENPOLY_POLYMARKET_PK", TEST_PRIVKEY) + monkeypatch.setattr("openpoly.execution.clob_patch.ClobClient", _FakeClobClient) + wallet = WalletSpec( + private_key_ref="env:OPENPOLY_POLYMARKET_PK", + funder_address=TEST_FUNDER, + ) + return build_live_executor(wallet, None) # type: ignore[arg-type] + + +# ---------- live executor factory ---------- + + +def test_build_live_executor_logs_no_credentials_at_info( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level(logging.INFO, logger="openpoly.execution.live_executor"): + _build(monkeypatch) + emitted = "\n".join(r.getMessage() for r in caplog.records) + assert TEST_SIGNER not in emitted + assert TEST_FUNDER[:10] not in emitted + assert FAKE_API_KEY[:8] not in emitted + # The event itself is still announced — only its payload changed. + assert "live executor ready" in emitted + + +def test_build_live_executor_logs_addresses_at_debug( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level(logging.DEBUG, logger="openpoly.execution.live_executor"): + _build(monkeypatch) + emitted = "\n".join(r.getMessage() for r in caplog.records) + assert TEST_SIGNER in emitted + assert TEST_FUNDER[:10] in emitted + + +def test_build_live_executor_never_logs_the_api_key( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Not even a fragment, and not even at DEBUG: it is a live credential.""" + with caplog.at_level(logging.DEBUG, logger="openpoly.execution.live_executor"): + _build(monkeypatch) + emitted = "\n".join(r.getMessage() for r in caplog.records) + assert FAKE_API_KEY[:6] not in emitted + + +# ---------- wallet config route ---------- + + +@pytest.fixture +def wallet_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: + monkeypatch.setenv("OPENPOLY_RUNTIME_STATE", str(tmp_path / "runtime.json")) + monkeypatch.setenv("OPENPOLY_POLYMARKET_PK", TEST_PRIVKEY) + fresh = RuntimeState() + fresh.load() + monkeypatch.setattr(wallet_routes, "runtime_state", fresh) + return TestClient(app) + + +def _put(client: TestClient): + return client.put( + "/api/wallet/config", + json={ + "private_key_ref": "env:OPENPOLY_POLYMARKET_PK", + "funder_address": TEST_FUNDER, + }, + ) + + +def test_wallet_config_logs_no_addresses_at_info( + wallet_client: TestClient, caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level(logging.INFO, logger="openpoly.api.wallet_routes"): + assert _put(wallet_client).status_code == 200 + emitted = "\n".join(r.getMessage() for r in caplog.records) + assert TEST_SIGNER not in emitted + assert TEST_FUNDER[:10] not in emitted + assert "wallet config updated" in emitted + + +def test_wallet_config_logs_addresses_at_debug( + wallet_client: TestClient, caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level(logging.DEBUG, logger="openpoly.api.wallet_routes"): + assert _put(wallet_client).status_code == 200 + emitted = "\n".join(r.getMessage() for r in caplog.records) + assert TEST_SIGNER in emitted + assert TEST_FUNDER[:10] in emitted diff --git a/tests/test_db_engine.py b/tests/test_db_engine.py index 1fc5c2a..7c9a87e 100644 --- a/tests/test_db_engine.py +++ b/tests/test_db_engine.py @@ -52,44 +52,6 @@ def test_init_db_creates_fill_with_order_id_tx_hash(tmp_path) -> None: assert "tx_hash" in cols -def test_ensure_fill_live_columns_migrates_old_db(tmp_path) -> None: - """Old DB without those columns: migration adds them, idempotent.""" - from sqlalchemy import text - from openpoly.db.manager import _ensure_fill_live_columns - - engine = make_engine(f"sqlite:///{tmp_path}/x.db") - # Simulate old schema by creating fill without the new columns - with engine.begin() as conn: - conn.execute( - text(""" - CREATE TABLE fill ( - id INTEGER PRIMARY KEY, - ts FLOAT NOT NULL, - market_id VARCHAR NOT NULL, - side VARCHAR NOT NULL, - action VARCHAR NOT NULL, - price FLOAT NOT NULL, - qty FLOAT NOT NULL, - fee FLOAT NOT NULL, - position_id INTEGER NOT NULL, - news_id VARCHAR, - "trigger" VARCHAR - ) - """) - ) - _ensure_fill_live_columns(engine) - with engine.begin() as conn: - cols = {r[1] for r in conn.execute(text("PRAGMA table_info(fill)")).fetchall()} - assert "order_id" in cols - assert "tx_hash" in cols - # Second run is a no-op - _ensure_fill_live_columns(engine) - with engine.begin() as conn: - cols = {r[1] for r in conn.execute(text("PRAGMA table_info(fill)")).fetchall()} - assert "order_id" in cols - assert "tx_hash" in cols - - def test_position_table_has_entry_calibration_columns(tmp_path) -> None: """A new DB gets the entry-signal columns straight from create_all.""" from sqlalchemy import text @@ -99,35 +61,3 @@ def test_position_table_has_entry_calibration_columns(tmp_path) -> None: with engine.begin() as conn: cols = {r[1] for r in conn.execute(text("PRAGMA table_info(position)")).fetchall()} assert {"entry_p_model", "entry_confidence", "entry_edge"} <= cols - - -def test_ensure_position_entry_columns_migrates_old_db(tmp_path) -> None: - """Old DB predating calibration: migration adds them, idempotent.""" - from sqlalchemy import text - from openpoly.db.manager import _ensure_position_entry_columns - - engine = make_engine(f"sqlite:///{tmp_path}/x.db") - with engine.begin() as conn: - conn.execute( - text(""" - CREATE TABLE position ( - id INTEGER PRIMARY KEY, - market_id VARCHAR NOT NULL, - side VARCHAR NOT NULL, - token_id VARCHAR NOT NULL, - condition_id VARCHAR NOT NULL, - qty FLOAT NOT NULL, - avg_entry_price FLOAT NOT NULL, - status VARCHAR NOT NULL, - opened_at FLOAT NOT NULL, - closed_at FLOAT, - close_reason VARCHAR, - realized_pnl FLOAT - ) - """) - ) - for _ in range(2): # second run must be a no-op - _ensure_position_entry_columns(engine) - with engine.begin() as conn: - cols = {r[1] for r in conn.execute(text("PRAGMA table_info(position)")).fetchall()} - assert {"entry_p_model", "entry_confidence", "entry_edge"} <= cols diff --git a/tests/test_db_migrations.py b/tests/test_db_migrations.py new file mode 100644 index 0000000..20de7e2 --- /dev/null +++ b/tests/test_db_migrations.py @@ -0,0 +1,201 @@ +"""Tests for openpoly.db.migrations — the versioned schema migration runner.""" + +from __future__ import annotations + +import pytest +from sqlalchemy import text + +from openpoly.db.engine import init_db, make_engine +from openpoly.db.migrations import ( + LATEST_VERSION, + MIGRATIONS, + SCHEMA_VERSION_TABLE, + current_version, + run_migrations, +) + +# --- legacy (pre-schema_version) DDL, verbatim from the old create_all shape --- + +_LEGACY_FILL = """ +CREATE TABLE fill ( + id INTEGER PRIMARY KEY, + ts FLOAT NOT NULL, + market_id VARCHAR NOT NULL, + side VARCHAR NOT NULL, + action VARCHAR NOT NULL, + price FLOAT NOT NULL, + qty FLOAT NOT NULL, + fee FLOAT NOT NULL, + position_id INTEGER NOT NULL, + news_id VARCHAR, + "trigger" VARCHAR +) +""" + +_LEGACY_POSITION = """ +CREATE TABLE position ( + id INTEGER PRIMARY KEY, + market_id VARCHAR NOT NULL, + side VARCHAR NOT NULL, + token_id VARCHAR NOT NULL, + condition_id VARCHAR NOT NULL, + qty FLOAT NOT NULL, + avg_entry_price FLOAT NOT NULL, + status VARCHAR NOT NULL, + opened_at FLOAT NOT NULL, + closed_at FLOAT, + close_reason VARCHAR, + realized_pnl FLOAT +) +""" + +_LEGACY_ORDER_BOOK = """ +CREATE TABLE order_book_snapshot ( + id INTEGER PRIMARY KEY, + token_id VARCHAR NOT NULL, + recorded_at FLOAT NOT NULL, + bids_json VARCHAR NOT NULL, + asks_json VARCHAR NOT NULL +) +""" + + +def _columns(engine, table: str) -> set[str]: + with engine.begin() as conn: + return {r[1] for r in conn.execute(text(f"PRAGMA table_info({table})")).fetchall()} + + +def _indexes(engine, table: str) -> set[str]: + with engine.begin() as conn: + return {r[1] for r in conn.execute(text(f"PRAGMA index_list({table})")).fetchall()} + + +def _legacy_engine(tmp_path, name: str = "legacy.db"): + """A DB shaped like an older openPoly: application tables, no schema_version.""" + engine = make_engine(f"sqlite:///{tmp_path / name}") + with engine.begin() as conn: + conn.execute(text(_LEGACY_FILL)) + conn.execute(text(_LEGACY_POSITION)) + conn.execute(text(_LEGACY_ORDER_BOOK)) + return engine + + +# ---------- registry invariants ---------- + + +def test_migrations_are_ordered_and_unique() -> None: + versions = [v for v, _ in MIGRATIONS] + assert versions == sorted(versions) + assert len(set(versions)) == len(versions) + assert versions[0] == 1 + assert LATEST_VERSION == versions[-1] + + +# ---------- fresh DB ---------- + + +def test_fresh_db_is_stamped_to_latest(tmp_path) -> None: + """create_all on an empty file stamps the latest version — no migration + needs to run, the schema is already current.""" + engine = make_engine(f"sqlite:///{tmp_path / 'fresh.db'}") + init_db(engine) + assert current_version(engine) == LATEST_VERSION + engine.dispose() + + +def test_fresh_db_run_migrations_is_a_noop(tmp_path) -> None: + engine = make_engine(f"sqlite:///{tmp_path / 'fresh.db'}") + init_db(engine) + assert run_migrations(engine) == LATEST_VERSION + engine.dispose() + + +# ---------- legacy DB ---------- + + +def test_legacy_db_has_no_version_table_before_migrating(tmp_path) -> None: + engine = make_engine(f"sqlite:///{tmp_path / 'legacy.db'}") + with engine.begin() as conn: + conn.execute(text(_LEGACY_FILL)) + with engine.begin() as conn: + names = { + r[0] for r in conn.execute(text("SELECT name FROM sqlite_master WHERE type='table'")) + } + assert SCHEMA_VERSION_TABLE not in names + engine.dispose() + + +def test_legacy_db_migrates_to_latest_and_is_idempotent(tmp_path) -> None: + """A DB predating schema_version migrates cleanly, and running twice is a + no-op (each migration PRAGMA-checks before it alters).""" + engine = _legacy_engine(tmp_path) + for _ in range(2): + assert run_migrations(engine) == LATEST_VERSION + assert current_version(engine) == LATEST_VERSION + assert {"order_id", "tx_hash"} <= _columns(engine, "fill") + assert {"entry_p_model", "entry_confidence", "entry_edge"} <= _columns(engine, "position") + assert "ix_order_book_snapshot_token_recorded" in _indexes(engine, "order_book_snapshot") + engine.dispose() + + +def test_legacy_db_that_already_has_new_columns_migrates(tmp_path) -> None: + """The old hand-rolled ``_ensure_*`` path already added the columns on some + deployed DBs; the migration must recognise that and only stamp.""" + engine = _legacy_engine(tmp_path, "half.db") + with engine.begin() as conn: + conn.execute(text("ALTER TABLE fill ADD COLUMN order_id VARCHAR")) + conn.execute(text("ALTER TABLE fill ADD COLUMN tx_hash VARCHAR")) + conn.execute(text("ALTER TABLE position ADD COLUMN entry_p_model FLOAT")) + assert run_migrations(engine) == LATEST_VERSION + assert {"order_id", "tx_hash"} <= _columns(engine, "fill") + assert {"entry_p_model", "entry_confidence", "entry_edge"} <= _columns(engine, "position") + engine.dispose() + + +def test_init_db_then_run_migrations_on_legacy_db(tmp_path) -> None: + """The real bootstrap order: create_all fills in missing tables, then the + runner migrates the pre-existing ones.""" + engine = _legacy_engine(tmp_path, "boot.db") + init_db(engine) + assert run_migrations(engine) == LATEST_VERSION + assert {"order_id", "tx_hash"} <= _columns(engine, "fill") + engine.dispose() + + +# ---------- failure ---------- + + +def test_failing_migration_leaves_version_unchanged( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A migration that raises must not advance (or partially advance) the + recorded version — the next start retries it from the same point.""" + engine = _legacy_engine(tmp_path, "boom.db") + + def _boom(_conn) -> None: + raise RuntimeError("migration exploded") + + first_version, first_fn = MIGRATIONS[0] + monkeypatch.setattr( + "openpoly.db.migrations.MIGRATIONS", + [(first_version, first_fn), (first_version + 1, _boom)], + ) + with pytest.raises(RuntimeError, match="migration exploded"): + run_migrations(engine) + assert current_version(engine) == first_version + engine.dispose() + + +def test_failing_first_migration_leaves_version_at_zero( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + engine = _legacy_engine(tmp_path, "boom0.db") + + def _boom(_conn) -> None: + raise RuntimeError("nope") + + monkeypatch.setattr("openpoly.db.migrations.MIGRATIONS", [(1, _boom)]) + with pytest.raises(RuntimeError): + run_migrations(engine) + assert current_version(engine) == 0 + engine.dispose() diff --git a/tests/test_db_retention.py b/tests/test_db_retention.py new file mode 100644 index 0000000..898c706 --- /dev/null +++ b/tests/test_db_retention.py @@ -0,0 +1,234 @@ +"""order_book_snapshot retention — the prune that keeps the one unbounded +table bounded (DatabaseManager.prune_order_books + its hourly loop).""" + +from __future__ import annotations + +import time + +import pytest +from sqlalchemy import func, select + +from openpoly.db.engine import init_db, make_engine, make_session_factory +from openpoly.db.manager import ( + PRUNE_BATCH_ROWS, + DatabaseConfig, + DatabaseManager, +) +from openpoly.db.tables import OrderBookSnapshot + +DAY = 86400.0 + + +def _engine(tmp_path, name: str = "retention.db"): + engine = make_engine(f"sqlite:///{tmp_path / name}") + init_db(engine) + return engine + + +def _seed(engine, ages_days: list[float], *, now: float) -> None: + """One snapshot row per entry, ``age_days`` old.""" + with make_session_factory(engine)() as session: + for i, age in enumerate(ages_days): + session.add( + OrderBookSnapshot( + token_id=f"tok-{i % 3}", + recorded_at=now - age * DAY, + bids_json="[[0.4, 10.0]]", + asks_json="[[0.42, 8.0]]", + ) + ) + session.commit() + + +def _count(engine) -> int: + with make_session_factory(engine)() as session: + return session.execute(select(func.count()).select_from(OrderBookSnapshot)).scalar_one() + + +def _remaining_ages(engine, now: float) -> list[float]: + with make_session_factory(engine)() as session: + rows = session.execute(select(OrderBookSnapshot.recorded_at)).scalars().all() + return sorted((now - r) / DAY for r in rows) + + +# ---------- the prune itself ---------- + + +def test_prune_deletes_only_rows_older_than_retention(tmp_path) -> None: + now = time.time() + engine = _engine(tmp_path) + _seed(engine, [0.1, 3.0, 6.9, 7.1, 30.0], now=now) + + mgr = DatabaseManager() + mgr.configure(engine, DatabaseConfig(order_book_retention_days=7.0)) + deleted = mgr.prune_order_books(now=now) + + assert deleted == 2 + assert _count(engine) == 3 + assert all(age < 7.0 for age in _remaining_ages(engine, now)) + engine.dispose() + + +def test_prune_is_idempotent(tmp_path) -> None: + now = time.time() + engine = _engine(tmp_path) + _seed(engine, [1.0, 20.0], now=now) + mgr = DatabaseManager() + mgr.configure(engine, DatabaseConfig(order_book_retention_days=7.0)) + + assert mgr.prune_order_books(now=now) == 1 + assert mgr.prune_order_books(now=now) == 0 + assert _count(engine) == 1 + engine.dispose() + + +def test_prune_accumulates_the_pruned_rows_counter(tmp_path) -> None: + now = time.time() + engine = _engine(tmp_path) + _seed(engine, [10.0, 11.0], now=now) + mgr = DatabaseManager() + mgr.configure(engine, DatabaseConfig(order_book_retention_days=7.0)) + + assert mgr.pruned_rows == 0 + mgr.prune_order_books(now=now) + assert mgr.pruned_rows == 2 + _seed(engine, [12.0], now=now) + mgr.prune_order_books(now=now) + assert mgr.pruned_rows == 3 + engine.dispose() + + +def test_prune_disabled_when_retention_is_zero(tmp_path) -> None: + now = time.time() + engine = _engine(tmp_path) + _seed(engine, [1000.0], now=now) + mgr = DatabaseManager() + mgr.configure(engine, DatabaseConfig(order_book_retention_days=0.0)) + + assert mgr.prune_order_books(now=now) == 0 + assert _count(engine) == 1 + engine.dispose() + + +def test_prune_before_start_is_a_noop() -> None: + assert DatabaseManager().prune_order_books() == 0 + + +def test_prune_batches_the_delete(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + """More expired rows than one batch: the delete runs in chunks so the + single SQLite writer is released between them, and still clears them all.""" + now = time.time() + engine = _engine(tmp_path) + monkeypatch.setattr("openpoly.db.manager.PRUNE_BATCH_ROWS", 3) + _seed(engine, [10.0] * 7 + [1.0], now=now) + + mgr = DatabaseManager() + mgr.configure(engine, DatabaseConfig(order_book_retention_days=7.0)) + assert mgr.prune_order_books(now=now) == 7 + assert _count(engine) == 1 + engine.dispose() + + +def test_prune_batch_default_is_five_thousand() -> None: + assert PRUNE_BATCH_ROWS == 5000 + + +# ---------- status ---------- + + +def test_status_reports_retention_block(tmp_path) -> None: + now = time.time() + engine = _engine(tmp_path) + _seed(engine, [40.0], now=now) + mgr = DatabaseManager() + mgr.configure(engine, DatabaseConfig(order_book_retention_days=7.0)) + mgr.prune_order_books(now=now) + + retention = mgr.status()["retention"] + assert retention["pruned_rows"] == 1 + assert retention["retention_days"] == pytest.approx(7.0) + assert retention["last_prune_at"] == pytest.approx(now) + engine.dispose() + + +def test_status_retention_before_any_prune(tmp_path) -> None: + mgr = DatabaseManager() + retention = mgr.status()["retention"] + assert retention["pruned_rows"] == 0 + assert retention["last_prune_at"] is None + + +# ---------- lifecycle ---------- + + +async def test_start_prunes_expired_rows(tmp_path) -> None: + """The sweep runs on start, so a process that was down for a month does + not wait an hour to reclaim the space.""" + now = time.time() + engine = make_engine(f"sqlite:///{tmp_path / 'life.db'}") + init_db(engine) + _seed(engine, [1.0, 90.0], now=now) + + mgr = DatabaseManager() + await mgr.start(engine=engine, config=DatabaseConfig(order_book_retention_days=7.0)) + try: + await mgr.wait_for_prune() + assert _count(engine) == 1 + assert mgr.pruned_rows == 1 + finally: + await mgr.stop() + engine.dispose() + + +async def test_stop_cancels_the_prune_loop(tmp_path) -> None: + engine = make_engine(f"sqlite:///{tmp_path / 'loop.db'}") + init_db(engine) + mgr = DatabaseManager() + await mgr.start(engine=engine) + await mgr.stop() + assert mgr.prune_task_running is False + engine.dispose() + + +# ---------- index ---------- + + +def test_order_book_snapshot_has_composite_index(tmp_path) -> None: + from sqlalchemy import text + + engine = _engine(tmp_path, "idx.db") + with engine.begin() as conn: + names = { + r[1] for r in conn.execute(text("PRAGMA index_list(order_book_snapshot)")).fetchall() + } + assert "ix_order_book_snapshot_token_recorded" in names + engine.dispose() + + +# ---------- retention reconfigured after start ---------- + + +def test_apply_config_changes_the_window_for_the_next_sweep(tmp_path) -> None: + """The prune reads its window per sweep, so a config applied after start + takes effect without a restart.""" + now = time.time() + engine = _engine(tmp_path, "apply.db") + _seed(engine, [10.0], now=now) + mgr = DatabaseManager() + mgr.configure(engine, DatabaseConfig(order_book_retention_days=30.0)) + assert mgr.prune_order_books(now=now) == 0 + mgr.apply_config(DatabaseConfig(order_book_retention_days=7.0)) + assert mgr.prune_order_books(now=now) == 1 + assert _count(engine) == 0 + + +def test_apply_config_zero_disables_the_prune(tmp_path) -> None: + now = time.time() + engine = _engine(tmp_path, "apply_zero.db") + _seed(engine, [400.0], now=now) + mgr = DatabaseManager() + mgr.configure(engine, DatabaseConfig(order_book_retention_days=7.0)) + mgr.apply_config(DatabaseConfig(order_book_retention_days=0.0)) + assert mgr.prune_order_books(now=now) == 0 + assert _count(engine) == 1 + assert mgr.status()["retention"]["retention_days"] == 0.0 diff --git a/tests/test_exit_monitor.py b/tests/test_exit_monitor.py index 0c0a688..0e2912d 100644 --- a/tests/test_exit_monitor.py +++ b/tests/test_exit_monitor.py @@ -1038,3 +1038,142 @@ async def test_dust_marker_is_pruned_when_the_position_stops_being_open() -> Non pf._positions = [] await m._tick_once() assert m._dust == set() + + +# ---------- partial fills (a sell that did not clear the position) ---------- + + +class _PartialFillPortfolio(_FakePortfolio): + """A store that behaves like the real one on a partial sell. + + ``record_sell`` reduces ``qty`` and leaves the row OPEN when the residual + is still sellable — the plain ``_FakePortfolio`` reports every position as + open forever, which cannot distinguish a partial close from a full one. + """ + + def __init__(self, positions: list[HeldPosition], *, sold_qty: float) -> None: + super().__init__(positions) + self._sold = sold_qty + + def apply_sell(self, position_id: int) -> None: + for index, p in enumerate(self._positions): + if p.position_id != position_id: + continue + residual = p.qty - self._sold + if residual < 0.01: + self._positions.pop(index) + else: + self._positions[index] = HeldPosition( + position_id=p.position_id, + market_id=p.market_id, + side=p.side, + token_id=p.token_id, + condition_id=p.condition_id, + qty=residual, + avg_entry_price=p.avg_entry_price, + opened_at=p.opened_at, + ) + return + + +class _PartialExecutor(_FakeExecutor): + """Fills ``sold_qty`` of the position and tells the store about it.""" + + def __init__(self, portfolio: _PartialFillPortfolio, *, price: float, sold_qty: float) -> None: + super().__init__(result=ExecResult.ok(price=price, qty=sold_qty, position_id=1)) + self._portfolio = portfolio + self._sold = sold_qty + + def execute_sell(self, position, *, close_reason, ts, trigger): # type: ignore[override] + result = super().execute_sell(position, close_reason=close_reason, ts=ts, trigger=trigger) + self._portfolio.apply_sell(position.position_id) + return result + + +async def test_partial_fill_realizes_only_the_sold_quantity() -> None: + """Realized PnL was computed against ``held.qty`` — the whole position — + even when the sell only cleared part of it, overstating every partial exit + in the log.""" + market_source_manager.store.set_order_books([_book("t1", bid=0.55)]) + pf = _PartialFillPortfolio([_held(1, "t1", avg=0.40, qty=20.0)], sold_qty=8.0) + m = _monitor(pf, _PartialExecutor(pf, price=0.55, sold_qty=8.0)) + + await m._tick_once() + + entry = exit_log.entries()[-1] + assert entry.verdict == "ok" + assert entry.realized_pnl == pytest.approx((0.55 - 0.40) * 8.0) + + +async def test_partial_fill_keeps_tracking_the_remainder() -> None: + """The remainder is still an open position with a trailing stop; dropping + its peak and unwatching its token resets that stop to the next tick's mark + and loses the run-up the position already had.""" + market_source_manager.store.set_order_books([_book("t1", bid=0.55)]) + pf = _PartialFillPortfolio([_held(1, "t1", avg=0.40, qty=20.0)], sold_qty=8.0) + m = _monitor(pf, _PartialExecutor(pf, price=0.55, sold_qty=8.0)) + + await m._tick_once() + + assert m._peak[1] == pytest.approx(0.55) + assert m._watch["t1"] == [1] + + +async def test_full_fill_still_drops_the_peak_and_unwatches() -> None: + market_source_manager.store.set_order_books([_book("t1", bid=0.55)]) + pf = _PartialFillPortfolio([_held(1, "t1", avg=0.40, qty=20.0)], sold_qty=20.0) + m = _monitor(pf, _PartialExecutor(pf, price=0.55, sold_qty=20.0)) + + await m._tick_once() + + assert 1 not in m._peak + assert "t1" not in m._watch + assert exit_log.entries()[-1].realized_pnl == pytest.approx((0.55 - 0.40) * 20.0) + + +async def test_dust_residual_close_is_not_treated_as_partial() -> None: + """A sell that leaves less than one hundredth of a share closes the row + (see ``PortfolioStore.record_sell``) even though ``result.qty`` is under + ``held.qty`` — that is a full close, and its peak must be dropped.""" + market_source_manager.store.set_order_books([_book("t1", bid=0.55)]) + pf = _PartialFillPortfolio([_held(1, "t1", avg=0.40, qty=20.0)], sold_qty=19.995) + m = _monitor(pf, _PartialExecutor(pf, price=0.55, sold_qty=19.995)) + + await m._tick_once() + + assert 1 not in m._peak + assert "t1" not in m._watch + + +# ---------- peak pruning ---------- + + +async def test_peak_entries_for_closed_positions_are_pruned_each_tick() -> None: + """A position closed by the settlement or reconciliation monitor never + passes through ``_close``, so its peak was never dropped — the dict grew + for the life of the process. It is pruned against the open set like the + ``_unmarkable`` / ``_dust`` markers.""" + market_source_manager.store.set_order_books([_book("t1", bid=0.41)]) + pf = _FakePortfolio([_held(1, "t1", avg=0.40)]) + m = _monitor(pf, _FakeExecutor()) + + await m._tick_once() + m._peak[99] = 0.9 # closed elsewhere; never seen by _close + assert m._peak[1] == pytest.approx(0.41) + + await m._tick_once() + assert 99 not in m._peak + assert m._peak[1] == pytest.approx(0.41) + + +async def test_peak_is_dropped_when_the_position_stops_being_open() -> None: + market_source_manager.store.set_order_books([_book("t1", bid=0.41)]) + pf = _FakePortfolio([_held(1, "t1", avg=0.40)]) + m = _monitor(pf, _FakeExecutor()) + + await m._tick_once() + assert 1 in m._peak + + pf._positions = [] + await m._tick_once() + assert m._peak == {} diff --git a/tests/test_live_executor.py b/tests/test_live_executor.py index a41e1dc..6455610 100644 --- a/tests/test_live_executor.py +++ b/tests/test_live_executor.py @@ -937,3 +937,46 @@ def test_live_buy_persists_the_entry_signals_from_the_intent(store) -> None: assert rec.entry_p_model == 0.61 assert rec.entry_confidence == "medium" assert rec.entry_edge == 0.11 + + +def test_buy_refuses_when_the_ctf_balance_is_unparseable(store) -> None: + """A balance field that is present but not a number is *unknown*, not zero. + + Returning 0 made the pre-order baseline look successfully read, so the + ``ctf_balance_unavailable`` guard never fired — and a lost response after a + real fill would then be confirmed against a fabricated baseline of 0, + inventing a fill delta out of the first successful read. + """ + + class _GarbageBalance: + def __init__(self) -> None: + self.posted: list[dict] = [] + + def update_balance_allowance(self, params) -> None: + pass + + def get_balance_allowance(self, params): + return {"balance": "not-a-number", "allowances": {}} + + def create_and_post_order(self, *a, **k): + raise AssertionError("must not post without a baseline") + + _populate(_market("m1")) + clob = _GarbageBalance() + r = LiveExecutor(portfolio=store, clob_client=clob).execute_buy(_intent(), news_id="n", ts=1.0) + + assert r.filled is False + assert r.skip_reason == "ctf_balance_unavailable" + assert clob.posted == [] + + +def test_read_ctf_balance_raw_returns_none_for_a_missing_balance_field(store) -> None: + class _NoBalanceKey: + def update_balance_allowance(self, params) -> None: + pass + + def get_balance_allowance(self, params): + return {"balance": None, "allowances": {}} + + le = LiveExecutor(portfolio=store, clob_client=_NoBalanceKey()) + assert le._read_ctf_balance_raw("tok") is None diff --git a/tests/test_market_api.py b/tests/test_market_api.py index 8deaa37..c1fbad7 100644 --- a/tests/test_market_api.py +++ b/tests/test_market_api.py @@ -331,3 +331,39 @@ def handler(request: httpx.Request) -> httpx.Response: value = await fetch_wallet_positions_value("0xFUNDER", client=client) assert value is None + + +async def test_held_condition_sides_ignores_unsellable_dust(): + """A residue below the venue's 0.01-share size precision is not a holding. + + ``PortfolioStore.record_sell`` closes a position whose residual falls under + ``MIN_SELLABLE_QTY``, because no order can ever clear it. The indexer still + reports that residue, so the reconciliation monitor's reverse diff saw a + holding with no open position behind it and raised + ``untracked_onchain_holding`` for a position that was correctly closed. + """ + from openpoly.portfolio.store import MIN_SELLABLE_QTY + + payload = [ + {"conditionId": "0xaaa", "outcome": "Yes", "size": 0.004}, # dust — excluded + {"conditionId": "0xbbb", "outcome": "No", "size": MIN_SELLABLE_QTY}, # exactly at + {"conditionId": "0xccc", "outcome": "Yes", "size": 12.0}, + ] + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=payload) + + async with _mock_client(handler) as client: + held = await fetch_held_condition_sides("0xFUNDER", client=client) + + assert held == {("0xbbb", "no"), ("0xccc", "yes")} + + +async def test_held_condition_sides_min_size_is_overridable(): + payload = [{"conditionId": "0xaaa", "outcome": "Yes", "size": 5.0}] + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=payload) + + async with _mock_client(handler) as client: + assert await fetch_held_condition_sides("0xF", client=client, min_size=10.0) == set() diff --git a/tests/test_section_database.py b/tests/test_section_database.py index aa2cff3..f39fbed 100644 --- a/tests/test_section_database.py +++ b/tests/test_section_database.py @@ -31,3 +31,32 @@ def test_registry_discovers_database_section(): assert len(db_entries) == 1 assert db_entries[0].name == "SqliteDatabase" assert db_entries[0].version == "0.1.0" + + +def test_section_construction_does_not_mutate_the_manager_config(): + """Constructing a section is not a lifecycle event. The manager singleton is + process-wide, so a constructor that pushed its config into it would let any + incidental construction — the registry's contract test, an inspector preview — + overwrite the window the operator actually deployed.""" + from openpoly.db.manager import manager + + try: + manager.apply_config(DatabaseConfig(order_book_retention_days=30.0)) + SqliteDatabase(DatabaseConfig(order_book_retention_days=1.0)) + assert manager.status()["retention"]["retention_days"] == 30.0 + finally: + manager.apply_config(DatabaseConfig()) + + +def test_catalog_scan_leaves_an_applied_retention_intact(): + """The lazy catalog scan runs every section's CONTRACT_TEST, which builds a + SqliteDatabase with *default* config. That must not roll the live retention + window back to the default the first time somebody opens the catalog.""" + from openpoly.db.manager import manager + + try: + manager.apply_config(DatabaseConfig(order_book_retention_days=30.0)) + scan() + assert manager.status()["retention"]["retention_days"] == 30.0 + finally: + manager.apply_config(DatabaseConfig()) diff --git a/tests/test_section_entry_edge.py b/tests/test_section_entry_edge.py index 1820655..79e579c 100644 --- a/tests/test_section_entry_edge.py +++ b/tests/test_section_entry_edge.py @@ -911,3 +911,45 @@ def test_intent_carries_the_raw_p_model_on_a_no_side() -> None: def test_section_version_bumped_for_the_sizing_knob() -> None: assert EdgeThresholdEntryV0.SECTION_VERSION == "0.4.0" + + +def test_multiplier_is_skipped_when_the_portfolio_is_unavailable() -> None: + """``heat_cap_usd`` is what bounds a scaled order. With no portfolio to + read, the open exposure is unknown — so the cap cannot bound anything, and + a 2x multiplier would size *past* a cap the operator set precisely to stop + that. Fall back to the base size and say why.""" + _populate(_market(), _edge_book()) + inst = EdgeThresholdEntryV0( + EdgeThresholdConfig(heat_cap_usd=100.0, size_edge_multiplier_max=3.0), + portfolio_provider=lambda: None, + ) + out = _run(inst, _ar(p_model=0.60)) + assert out.verdict == "ok" + assert out.payload.qty == pytest.approx(10.0 / 0.50) + assert "size_multiplier" not in out.signals + assert out.signals["size_multiplier_skipped"] == "portfolio_unavailable" + + +def test_no_skipped_signal_when_the_heat_cap_is_off() -> None: + """Without a heat cap there is nothing for the open exposure to bound, so + an absent portfolio is not a reason to refuse the multiplier.""" + _populate(_market(), _edge_book()) + inst = EdgeThresholdEntryV0( + EdgeThresholdConfig(heat_cap_usd=0.0, size_edge_multiplier_max=3.0), + portfolio_provider=lambda: None, + ) + out = _run(inst, _ar(p_model=0.60)) + assert out.verdict == "ok" + assert out.payload.qty == pytest.approx(2 * 10.0 / 0.50) + assert "size_multiplier_skipped" not in out.signals + + +def test_no_skipped_signal_when_scaling_is_off() -> None: + _populate(_market(), _edge_book()) + inst = EdgeThresholdEntryV0( + EdgeThresholdConfig(heat_cap_usd=100.0), + portfolio_provider=lambda: None, + ) + out = _run(inst, _ar(p_model=0.60)) + assert out.verdict == "ok" + assert "size_multiplier_skipped" not in out.signals diff --git a/tests/test_wallet_runtime_state.py b/tests/test_wallet_runtime_state.py index 0e06df8..a400213 100644 --- a/tests/test_wallet_runtime_state.py +++ b/tests/test_wallet_runtime_state.py @@ -116,6 +116,36 @@ def boom(self: RuntimeState) -> None: assert rs.exec_mode == "paper" # rolled back +def test_set_mode_without_persist_skips_disk_and_keeps_memory( + state_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """``persist=False`` is the fail-closed seam for a safety demotion: the + in-memory mode is what the dispatcher routes on, so it must land even when + the state file cannot be written. Disk stays untouched (and still says + live), which is what makes the demotion re-run on the next boot.""" + state_path.write_text(json.dumps({"wallet": None, "exec_mode": "live", "updated_at": 1.0})) + rs = RuntimeState() + rs.load() + assert rs.exec_mode == "live" + + def boom(self: RuntimeState) -> None: + raise OSError("simulated read-only filesystem") + + monkeypatch.setattr(RuntimeState, "_save", boom) + rs.set_mode("paper", persist=False) + + assert rs.exec_mode == "paper" + assert json.loads(state_path.read_text())["exec_mode"] == "live" + + +def test_set_mode_without_persist_still_rejects_unknown(state_path: Path) -> None: + rs = RuntimeState() + rs.load() + with pytest.raises(ValueError): + rs.set_mode("chaos", persist=False) # type: ignore[arg-type] + assert rs.exec_mode == "paper" + + def test_set_wallet_disk_failure_does_not_mutate_memory( state_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From b80c8df6dc70d2d10fda3689e0b1e0cec0946320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nahim=20Rodr=C3=ADguez?= Date: Sun, 30 Aug 2026 17:20:23 -0600 Subject: [PATCH 04/27] chore: frontend tests, CSRF origin guard, runtime docs, monitor base vitest covers the canvas store and template IO (blocking in CI); the static catalog mirrors the real section schemas and close-all reports partial fills honestly; mutating requests attach the API token from a new settings panel. The backend refuses cross-origin writes via Sec-Fetch-Site/Origin authority checks (vite proxy pins changeOrigin:false to stay same-authority), runtime monitors get architecture docs and a shared TickLoopMonitor base, and mypy plus pre-commit join the toolchain non-blocking. --- .env.example | 13 +- .github/workflows/ci.yml | 13 +- .pre-commit-config.yaml | 30 + CHANGELOG.md | 63 ++ CONTRIBUTING.md | 23 +- SECURITY.md | 11 + docs/architecture/00-overview.md | 9 +- docs/architecture/07-runtime-monitors.md | 353 ++++++++++++ docs/deploy/README.md | 92 ++- docs/deploy/separated-deployment.md | 6 +- frontend/README.md | 17 +- frontend/package.json | 4 +- frontend/src/canvas/KeysDrawer.tsx | 2 + frontend/src/canvas/ModeSwitchDialog.tsx | 54 +- frontend/src/canvas/store.test.ts | 539 ++++++++++++++++++ frontend/src/canvas/templateIO.test.ts | 413 ++++++++++++++ frontend/src/canvas/templateIO.ts | 3 +- frontend/src/demo/fixtures/activity.ts | 3 + frontend/src/lib/apiClient.ts | 88 +++ frontend/src/sections/analyzer/llmTest.ts | 4 +- frontend/src/sections/catalog.ts | 181 +++++- .../src/sections/market_source/statusStore.ts | 6 +- .../src/sections/news_source/statusStore.ts | 6 +- frontend/src/setting/ApiTokenPanel.tsx | 98 ++++ frontend/src/setting/secretsClient.ts | 6 +- frontend/src/setting/testConnection.ts | 4 +- frontend/src/setting/walletStore.ts | 47 +- frontend/src/testing/memoryStorage.ts | 37 ++ frontend/vite.config.ts | 16 +- frontend/yarn.lock | 188 +++++- openpoly/api/security.py | 251 +++++++- openpoly/runtime/exit_monitor.py | 98 ++-- openpoly/runtime/orchestrator.py | 40 +- openpoly/runtime/reconciliation_monitor.py | 61 +- openpoly/runtime/settlement_monitor.py | 63 +- openpoly/runtime/tick_loop.py | 124 ++++ pyproject.toml | 14 + tests/test_api_security.py | 279 ++++++++- uv.lock | 353 +++++++++++- 39 files changed, 3309 insertions(+), 303 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 docs/architecture/07-runtime-monitors.md create mode 100644 frontend/src/canvas/store.test.ts create mode 100644 frontend/src/canvas/templateIO.test.ts create mode 100644 frontend/src/lib/apiClient.ts create mode 100644 frontend/src/setting/ApiTokenPanel.tsx create mode 100644 frontend/src/testing/memoryStorage.ts create mode 100644 openpoly/runtime/tick_loop.py diff --git a/.env.example b/.env.example index 3be0eb7..5512247 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,7 @@ OPENPOLY_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com # --- News source (M3+) ----------------------------------------------------- # TradingNews WS auth token. Resolved via env: ref in canvas / secret store. -OPENPOLY_TRADINGNEWS_API_KEY= +OPENPOLY_TRADINGNEWS_KEY= # --- API access control (Phase 3) ------------------------------------------ # OPENPOLY_API_TOKEN — shared secret required in the `X-OpenPoly-Token` header @@ -43,10 +43,13 @@ OPENPOLY_TRADINGNEWS_API_KEY= # A ref that does not resolve fails CLOSED — every mutating route 401s. # Generate one with: python3 -c "import secrets; print(secrets.token_urlsafe(32))" # -# Note for the web UI: with a token set, the frontend must send the header on -# its mutating calls (canvas save, manual close, mode switch). Until it does, -# run the UI against a backend with the token unset, or drive the gated -# routes from curl / the Swagger UI with the header. +# ASCII ONLY. An HTTP header value cannot carry a code point above U+00FF and +# the browser's fetch() throws on one, so a non-ASCII token breaks every +# mutating call from the web UI (token_urlsafe above is already ASCII). +# +# For the web UI: paste the same value into Keys -> API token. It is kept in +# that browser's localStorage (openpoly_api_token) and attached as +# X-OpenPoly-Token to mutating requests only. OPENPOLY_API_TOKEN= # OPENPOLY_ALLOWED_HOSTS — comma-separated extra Host header values the backend diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 134cb1b..9ac8e45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,8 +38,15 @@ jobs: run: uv run ruff format --check . continue-on-error: true + # Intentionally NOT a gate: mypy is new to a codebase that predates it, so + # it reports rather than blocks. Config (lenient on purpose) lives in + # pyproject.toml under [tool.mypy]. Tighten it, then promote this step. + - name: mypy (informational) + run: uv run mypy + continue-on-error: true + frontend: - name: frontend (typecheck) + name: frontend (typecheck + lint + tests) runs-on: ubuntu-latest defaults: run: @@ -64,3 +71,7 @@ jobs: # Blocking gate — ESLint must pass (errors cleared). - name: Lint run: yarn lint + + # Blocking gate — vitest unit suite (canvas store + template (de)serialization). + - name: Tests + run: yarn test diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..cd55e96 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +# Pre-commit hooks — the same two ruff commands CI runs, on the files you are +# about to commit. +# +# Local `uv run` hooks rather than the upstream ruff mirror on purpose: the ruff +# version is already pinned in `pyproject.toml`'s dev group, and a mirror `rev` +# is a second place to bump it — which is exactly how a hook starts disagreeing +# with CI. This way there is one pin. +# +# Install once: uv run pre-commit install +# Run manually: uv run pre-commit run --all-files +# +# Formatting is a fix-in-place hook: it rewrites the file and fails the commit, +# so you re-stage and commit again. That mirrors CI, where the format check is +# informational and only lint is a gate. +repos: + - repo: local + hooks: + - id: ruff-check + name: ruff check + entry: uv run ruff check --force-exclude + language: system + types_or: [python, pyi] + require_serial: true + + - id: ruff-format + name: ruff format + entry: uv run ruff format --force-exclude + language: system + types_or: [python, pyi] + require_serial: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 12bfd5f..75a99f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,69 @@ Dates are US-style (MM/DD/YYYY). --- +## 08/30/2026 — Phase 4: the canvas can now tell you the truth about the strategy + +Phase 4 is mostly engineering — a test framework, a base class, docs, hooks — +and that is out of scope for this file. Three of its changes are not, because +they change what the operator is told about the strategy, and what a page in +another tab is allowed to do to it. + +**The canvas' offline catalog was advertising a strategy that no longer +exists.** `frontend/src/sections/catalog.ts` carries a fallback copy of the +section catalog, used whenever the backend is unreachable, and +`defaultConfigForType` seeds a new node's config from it. It still described +`exit` at v0.1.0 as two knobs — `take_profit_pct` and `stop_loss_pct` — which +is the strategy as it stood before the trailing lock existed. An operator +reading that panel saw a system that takes profit at +20% and has no trailing +behaviour at all, when the shipped exit (v0.3.0) has take-profit **off**, arms +a trailing lock at +30% of cost basis, and floors the trail at two ticks or the +live spread. `entry` was three versions stale in the same way: no +`size_edge_multiplier_max`, no `heat_cap_usd`, none of the A4 kill switches. +Both entries are now mirrored field-for-field from the pydantic Configs, +defaults and descriptions included, at the versions actually running. + +**A bulk close that half-worked said it had failed.** `POST +/api/positions/close-all` started reporting partials under their own counter in +Phase 3, but the mode-switch dialog still folded them into `skipped + errored` +— and because a partial carries neither a `skip_reason` nor an `error`, it +rendered as `1 failed (unknown)`. The operator pressing "close all" to be out +of the market was told the sell had not happened when it had, on the one screen +where that question is the entire point. Partials now read as *N partially +closed, remainder open*, with the residual share count, and only genuine skips +and errors count as failures. + +**Any web page could close your book.** A body-less `POST` is a CORS *simple +request*: the browser issues it for real and only withholds the response. The +Host allowlist admits loopback by design, so +`fetch('http://127.0.0.1:8000/api/positions/close-all', {method:'POST'})` from +any page the operator happened to have open reached the route and sold +everything — with the API token unset (the loopback default), and equally with +it set, because a browser attaches no header it was not asked to. Never seeing +the response does not undo the sell. Every mutating request a browser labels +cross-origin is now refused **403 cross_origin_write** before it reaches a +route. `Sec-Fetch-Site: cross-site` is refused outright, without consulting the +`Origin`; `same-origin` and `none` settle it the other way. `same-site` — and +any value this code does not know — settles nothing and falls through to the +`Origin`, which must name the **same authority, host *and* port**, as the +request's own `Host`, or be listed in `OPENPOLY_ALLOWED_HOSTS`. Loopback gets +no port-free pass on that check: it is the one authority every local page +shares, so admitting it would let a dev server on `localhost:3000` drive the +backend on `localhost:8000`. `curl`, systemd timers and other non-browser +clients send neither header and are unaffected. The web UI, in turn, now sends the API +token: paste it once into **Keys → API token** (ASCII only — an HTTP header +cannot carry anything else). + +Everything else in Phase 4 is engineering and leaves behaviour unchanged: a +vitest suite over the canvas store and template (de)serialization (blocking in +CI), a shared `TickLoopMonitor` base for the three runtime monitors, the +module-scope monkey-patched methods folded back into their classes, mypy and +pre-commit wired up, and +[docs/architecture/07-runtime-monitors.md](docs/architecture/07-runtime-monitors.md) +documenting the runtime — including that `bootstrap_peaks` is **not wired**, so +trailing-stop peaks reset on every restart. + +--- + ## 08/30/2026 — Phase 3: what counts as a real outcome, and who is allowed to trade Nothing here changes an entry or exit threshold. What changed is which numbers diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 06d9450..450e85a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,11 +51,30 @@ uv run ruff format . # Frontend (from frontend/) yarn typecheck yarn lint +yarn test ``` CI **blocks merges** on `ruff check` + `pytest` (backend) and `yarn typecheck` + -`yarn lint` (frontend). `ruff format` is **not** a CI gate — run it locally to -keep formatting consistent, but it won't fail your PR. +`yarn lint` + `yarn test` (frontend). `ruff format` is **not** a CI gate — run it +locally to keep formatting consistent, but it won't fail your PR. `mypy` runs in +CI too, also **non-blocking**: the codebase predates it, so it reports rather +than gates. Don't add new errors to files you touch; see `[tool.mypy]` in +`pyproject.toml`. + +### Pre-commit hooks (optional but recommended) + +`.pre-commit-config.yaml` wires the two ruff commands above onto staged files, +so a PR never fails CI on a lint error you could have caught locally. It runs +them through `uv run`, using the ruff version already pinned in `pyproject.toml` +— one pin, no second version to keep in sync. + +```bash +uv run pre-commit install # once, per clone +uv run pre-commit run --all-files +``` + +The format hook rewrites files in place and fails the commit when it changes +something; re-stage and commit again. If you added a **section**, also: diff --git a/SECURITY.md b/SECURITY.md index c7d2d8a..8488e1f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -32,6 +32,17 @@ and coordinated disclosure. the explicit opt-in. - **Dependencies** — known-vulnerable third-party packages. +### Dependencies + +One pin is worth calling out explicitly: `pyproject.toml` holds +`py-clob-client-v2==1.0.1rc1`. The Polymarket V2 client has no GA release yet, +and it is the dependency that builds, signs, and submits real orders — a +pre-release in that position is a standing risk, not a detail. **Adopt the GA +release as soon as it ships**, and re-run the live smoke test against it before +trusting it with funds. Until then, treat the RC's behavior as unpinned by any +stability guarantee, and pin the exact version (never a range) so an +unreviewed RC cannot arrive through a routine install. + ## What's *not* a vulnerability - **Trading losses.** openPoly is a high-risk trading framework; losing money is diff --git a/docs/architecture/00-overview.md b/docs/architecture/00-overview.md index f176f59..d7bb459 100644 --- a/docs/architecture/00-overview.md +++ b/docs/architecture/00-overview.md @@ -17,12 +17,18 @@ live trading kicks in at M4 against Polygon mainnet only. |---|---|---| | Backend | Python + FastAPI, single process | shared language with strategy logic | | DB | SQLite | single system, single writer — zero infra | -| Polymarket SDK | `py-clob-client` | official client (order placement) | +| Polymarket SDK | `py-clob-client-v2` | official client (order placement) | | Frontend | React + React Flow | strategy canvas UI | openPoly is a **single system** — one process, one pipeline, one SQLite file. See [01-isolation.md](01-isolation.md). +> **The Polymarket SDK is pinned to a release candidate.** `pyproject.toml` +> pins `py-clob-client-v2==1.0.1rc1` — the V2 client has no GA release yet, and +> it is the code path that signs and submits real orders. Adopt the GA version +> as soon as it ships, and re-run the live smoke test when you do; see +> [SECURITY.md](../../SECURITY.md#dependencies). + ## License & ethos - **MIT**. Open-source is a mindset, not a future milestone. @@ -46,3 +52,4 @@ See [01-isolation.md](01-isolation.md). - [04-wallet-config.md](04-wallet-config.md) — wallet config (single prod wallet, M4) - [05-runtime-network-risk.md](05-runtime-network-risk.md) — network scope, risk budget, exit policy - [06-polymarket-api.md](06-polymarket-api.md) — Gamma / CLOB / Data API surfaces and how openPoly uses them +- [07-runtime-monitors.md](07-runtime-monitors.md) — the timer-driven exit / settlement / reconciliation loops, the executor dispatcher, entry-side brakes diff --git a/docs/architecture/07-runtime-monitors.md b/docs/architecture/07-runtime-monitors.md new file mode 100644 index 0000000..1f45c93 --- /dev/null +++ b/docs/architecture/07-runtime-monitors.md @@ -0,0 +1,353 @@ +# Runtime monitors + +Everything in `openpoly/runtime/` that runs on a timer rather than on an event. + +The news pipeline is event-driven: a `NewsItem` arrives, the orchestrator walks +it through embedding → analyzer → entry, and a position may open. Nothing about +that shape can *close* a position — closing is driven by price, by clock, and by +on-chain reality, none of which produce an event on the news socket. So there +are three periodic sweeps, each answering a different question about an open +position: + +| Monitor | Question | Default tick | Closes with reason | +|---|---|---|---| +| `ExitMonitor` | has it hit a threshold? | 120s | `take_profit` / `stop_loss` / `peak_drawdown` | +| `SettlementMonitor` | has its market resolved? | 300s | `settlement` | +| `ReconciliationMonitor` | does the wallet still hold it? | 300s | `reconciled` | + +They are deliberately separate loops. A Gamma outage stalls settlement without +touching the take-profit path; a data-api outage stalls reconciliation without +touching either. One loop with three responsibilities would have coupled all +three failures together. + +All three are module-level singletons that the FastAPI lifespan `configure()`s +with a `PortfolioStore` and `start()`s — construction touches no DB. See +[05-runtime-network-risk.md](05-runtime-network-risk.md) for the exit policy +these implement, and [02-strategy-sections.md](02-strategy-sections.md) for the +section contract the exit monitor calls into. + +## The shared loop: `TickLoopMonitor` + +`runtime/tick_loop.py` owns start / stop / the loop itself; each monitor +subclasses it and implements `_tick_once`. Three properties are load-bearing: + +- **A tick error never kills the loop.** `_tick_once` runs inside + `try/except Exception`, logged with the subclass's own module logger. + `CancelledError` is re-raised — shutdown is not a tick failure. +- **The stop `Event` is recreated on every `start()`.** An `asyncio.Event` binds + to the loop it is first awaited on, and these are process singletons started + on a fresh loop by every test; a once-in-`__init__` event would wait forever + on the second start. +- **The loop yields before sleeping.** `await asyncio.sleep(0)` runs even when + the tick found nothing to do, because a tick with no await point starves every + other task on the loop — the reconnect starvation described in + [05-runtime-network-risk.md](05-runtime-network-risk.md#async-reconnect-starvation). + +`stop()` cancels the loop task, then calls the `_after_stop()` hook, then flips +the state to `stopped`. Only the exit monitor overrides that hook. + +## ExitMonitor + +`runtime/exit_monitor.py`. Each tick reads every open position, marks it, runs +the `exit` section on it, and routes any resulting `CloseIntent` to +`executor.execute_sell`. + +### Depth-guarded mark + +The mark is **the first bid level carrying at least `min_mark_bid_size` shares** +(default 5.0) — and nothing else. A resting level-1 bid can be a single +minimum-size probe far from fair value, and marking there produced false +stop-outs. + +There is deliberately **no mid fallback**. Both executors sell into the book's +raw level-1 bid, so a mid mark would evaluate take-profit and peak-drawdown +against a price the position can never realize — closing a "winner" into a dust +bid at a loss. When no level qualifies, the position is reported **blocked**, +held, and logged once per occurrence as `no_executable_bid`, so an unevaluable +position (its stop-loss cannot fire) is visible rather than silent. + +### Trailing logic and the peak + +The exit section's trailing lock needs a peak; the monitor is what tracks it. + +- `self._peak[position_id]` is the monotone maximum of the depth-guarded mark + across this process's lifetime, seeded at the position's first observed mark. +- `observe_book` is a **push hook** wired to the market-source book sampler. + The exit tick runs every 120s but the sampler refreshes far more often; + without the hook, a run-up that happens and reverses between two ticks is + invisible and the lock trails a peak that never existed. Only tokens held by a + position seen on the last sweep are tracked (`self._watch`), so this stays a + dict lookup on a hot path. The limitation is real: there is no push/WS book + feed, so "fresh" means the sampler's poll interval (60s by default), not every + quote update. +- The peak dict is pruned to the currently-open set at the top of every tick. + `_close` only drops the peaks of positions *this* monitor closed, and the + settlement and reconciliation monitors close positions behind its back — every + one of those used to leave an entry behind forever. +- On a **partial** fill the peak and the book subscription are kept: the + remainder is still an open position with a trailing stop, and resetting them + would re-seed the stop at the next tick's mark and throw away the run-up the + position already had. + +> **`bootstrap_peaks` exists but is not wired.** The method rebuilds each open +> position's peak from the `order_book_snapshot` table at startup (applying the +> same depth guard, so a recorded dust bid cannot seed an unreachable peak), but +> nothing calls it — the FastAPI lifespan goes straight from `configure()` to +> `start()`. **Peaks therefore reset on every restart**: after a restart a +> position's peak re-seeds at the first mark observed, so a trailing lock that +> had armed on an earlier run-up is disarmed until the price makes a new high. +> The stop-loss is unaffected. Wiring it is a one-line lifespan change plus the +> session factory; it is listed here rather than fixed silently because it +> changes exit behavior on restart. + +### Dust skip + +A remainder below one share (`execution.sizing.is_dust_qty`) is skipped **before** +evaluation. It cannot be sold at any price ≤ 1.0 without falling under the +venue's $1 minimum, so evaluating it produced a `CloseIntent` → a sell the +executor could only skip → an `error` row, every tick, for as long as the market +stayed unresolved (~720 rows/day into a 200-entry ring). The row stays open until +settlement closes it at the resolution price. Logged once per position +(`dust_remainder`), and **not** counted as blocked: nothing is wrong with the +book, there is simply nothing to do. + +### The closing-registry claim protocol + +Three loops can close the same position. They used to be serialized by the event +loop because `execute_sell` ran inline; it no longer does — the live sell is +offloaded with `asyncio.to_thread` and hands the loop back for the seconds the +on-chain order takes. In that window another monitor can see a position the +wallet has already emptied and close it first, and the exit monitor then fails +to persist the real fill (`ValueError: position N is closed`) — losing the actual +exit price and realized PnL. + +`runtime/closing_registry.py` is the fix, and it is deliberately small: a +process-local `set` of position ids, mutated only on the event-loop thread (the +sell runs in a worker, the registry calls around it do not), so no lock. + +The protocol, in order: + +1. The section returns a `CloseIntent`. +2. The monitor **re-reads the position's status** (`_still_open`). The sweep's + open list was read at the top of the tick and every sell since handed the + loop back; selling from that stale snapshot means an `execute_sell` against + an already-closed row — a spurious `error` on paper, a real on-chain sell + that can never be persisted on live. +3. `mark_closing(position_id)`. There is **no `await` between the check and the + claim**, so nothing can close it in between. `_still_open` is synchronous by + design for exactly this reason. +4. The sell runs as its own task, awaited under `asyncio.shield`. +5. `clear_closing` in a `finally` — a failed sell must never leave a position + permanently unreconcilable. + +The settlement and reconciliation monitors skip any claimed id for that tick. +They are periodic sweeps, so skipping costs nothing: they reconsider on the next +tick, by which time the sell has landed or been abandoned. Both manual-close +routes (`POST /api/positions/{id}/close`, `POST /api/positions/close-all`) take +the same claim, and neither sells a position the monitor already holds. They +report it differently because they answer different questions: the single-close +route refuses the whole request with `409 exit_in_flight`, while close-all is a +bulk operation that must not abort on one position — it returns `200` and marks +that position in `details` with `ok: false, skip_reason: "exit_in_flight"` +(counted under `skipped`). + +### In-flight drain on shutdown + +`execute_sell` runs in a worker thread and **cannot be cancelled** — it completes +on-chain and in the DB regardless. So the sell plus its bookkeeping live in +their own task, and `_after_stop()` waits up to +`INFLIGHT_DRAIN_TIMEOUT_SECONDS` (30s, covering the live executor's own retry +budget) for it. Cancelling the tick loop without draining would strand the +`exit_log` entry and the peak cleanup for a close that already happened. The +drain runs on every `stop()`, including one where the loop was never started. + +### Tick telemetry + +Within-threshold holds write **no** `exit_log` entry: at one row per position +per tick they evicted the rare `ok` / `error` closes from the ring. Liveness is +carried instead by `last_tick_at` / `open_positions` / `blocked`, surfaced via +`GET /api/exit/log` so the canvas badge can show "the monitor is working" +without the flood. `no_executable_bid` and `dust_remainder` are logged once per +occurrence and re-armed when the position becomes markable again or closes. + +### Hot-swap + +`replace_exit_section` swaps the section instance under `_exit_lock` while the +monitor runs. An in-flight `run(...)` keeps the old instance alive through its +own reference; the next tick reads the attribute and gets the new one. Called by +`api/canvas_routes._apply_canvas_reload` after a canvas PUT changes the exit +config — same atomicity story as the orchestrator's `replace_section`. + +## SettlementMonitor + +`runtime/settlement_monitor.py`. When a market resolves, Gamma stamps +`outcomePrices` on it — but the resolved market drops out of the discovery +catalog (`/events` is filtered to `closed=false`), so the exit monitor sees no +order book and the position sits `open` forever. + +Each tick groups open positions by `condition_id` and fetches exactly those +markets through `fetch_markets_by_condition_id`, which does **not** pass +`closed=false`. For a resolved market it calls `PortfolioStore.close_position` +directly at the 0/1 final price — no broker tx, no CLOB call. + +Only clean resolutions are accepted: `outcomePrices` must be `{0.0, 1.0}` as a +set. A disputed market that settles split (`[0.5, 0.5]`) is skipped as +`ambiguous_outcome` and reconsidered next tick, because downstream PnL math on a +split would be fiction. + +Every non-close outcome writes a `settlement_log` entry rather than passing +silently — `still_trading`, `no_outcome_prices`, `market_not_returned_by_gamma`, +`gamma_fetch_failed`, `exit_in_flight`. A settlement lag that is invisible looks +identical to no lag at all. + +**CTF redemption** — turning winning tokens into pUSD on the DepositWallet — is +a separate on-chain action and is **out of scope**. The ledger closes; the +tokens are redeemed elsewhere. + +## ReconciliationMonitor + +`runtime/reconciliation_monitor.py`. The other two monitors assume the DB ledger +matches on-chain reality. It can diverge: a position exited on-chain (sold, +redeemed, transferred) without openPoly recording the close. The row then sits +`open` forever, the exit monitor fires into a void, and the UI shows fictional +exposure. Settlement cannot catch it — that only closes positions whose *market* +resolved, and this market is still trading. + +Each tick asks an injected `holdings_fetcher` what the wallet actually holds. +Production wires `fetch_held_condition_sides`, which reads the Polymarket +data-api `/positions` indexer — authoritative, and it accounts for neg-risk +wrapping, which a raw `balanceOf` on a token id does not. + +**Forward diff** — open in the ledger, absent on-chain → close as `reconciled`. +Three gates before it fires: + +- `live_check`: production passes `exec_mode == "live"`. In paper mode the + indexer knows nothing of paper positions, so an ungated sweep would close + every one of them. Default `None` (always run) is for tests. +- `grace_seconds` (300 default): a fresh buy's indexer update lags, and + reconciling it would close a position that was just opened. +- `is_closing`: the exit monitor is mid-sell and the wallet can already read + flat while its fill is still being persisted. + +Realized PnL is recorded as **0** (closed at `avg_entry_price`). The real exit +price is on-chain but cannot be reliably attributed back to a specific openPoly +position when the same market was traded more than once, so no number is +fabricated. The reconciled close stops the bleed; PnL truth is a separate, +manual concern. + +**Reverse diff** — held on-chain, no open ledger position → log +`untracked_onchain_holding` and warn, **once** per `(condition_id, side)` per +process. It never auto-opens a position: the cost basis is unknown and a +synthetic position would corrupt entry dedup. A human decides. + +**`min_size`** is what makes the reverse diff usable. `fetch_held_condition_sides` +counts a position as held only at `min_size` shares or more (default +`MIN_SELLABLE_QTY`, the venue's size precision). Below that is a residue no +order can clear — `record_sell` closes the ledger position when the remainder +falls under it — so reporting it as a holding raised `untracked_onchain_holding` +against a position that was closed correctly, which trains the operator to +ignore the warning. + +## The executor dispatcher + +`execution/dispatcher.py`. The monitors and the orchestrator all call +`executor.execute_buy` / `execute_sell` and get an `ExecResult`; none of them +knows which implementation filled. `ExecutorDispatcher` routes on +`runtime_state.exec_mode`, which is the single place mode-awareness lives. + +- `paper` (default) → `PaperExecutor`, a level-1 fill model capped by that + level's depth on both sides. +- `live` → the CLOB executor, pre-built by the lifespan whenever a wallet is + configured, regardless of the current mode, so a UI flip is cheap. +- `live` with no live executor → `ExecResult.skip("live_not_ready")` plus a + warning, so a paper-only deployment cannot be talked into a half-live state. + +`get_collateral_balance_raw` is deliberately mode-**independent**: the wallet +balance is an on-chain fact, so the dashboard shows the same number in both +modes. + +Both executors size through `execution/sizing.py` — one definition of the venue +rules (2 size decimals, one-share minimum, $1.10 minimum notional). Paper is the +simulation of live, so a paper fill that live would have rejected is a lie about +the strategy's realized behavior. + +### Startup demotion to paper + +`runtime.json` outlives the process, so `exec_mode: "live"` comes back on every +restart — including the restart where the API token went missing (unit file +edited, secret rotated away, container redeployed without it). The mode-switch +route refuses live without a usable token; a *restore* that skipped that check +would put real funds behind an open API precisely when nobody is watching. + +`api/main._demote_restored_live_without_token` re-runs the check at startup and +forces paper. It **fails closed**: if persisting the demotion fails, the +in-memory mode is still forced to paper (the dispatcher routes on the in-memory +value) and the failure is logged `CRITICAL`. `runtime.json` still says live, +which only means the demotion re-runs next boot. + +## Entry-side gates and the kill switch + +Not a monitor, but the other half of the risk story: the brakes live in the +`entry` section (`sections/entry/edge_threshold_v0.py`), because the cheapest +place to stop a loss is before the buy. All are opt-in via canvas config, all +default to off, and **all are entry-only** — a tripped brake never closes +anything, and open positions keep running their normal exit logic (the exit +monitor and manual close both still work). + +| Knob | Trips when | +|---|---| +| `heat_cap_usd` | Σ(qty × avg_entry_price) over open positions is at or above the cap | +| `same_market_cooldown_minutes` | a position on the same (market, side) opened or closed within the window | +| `same_market_lifetime_lockout` | any prior position exists on (market, side) — supersedes the cooldown | +| `kill_max_consecutive_losses` | the most recent N closed positions are all losses | +| `kill_daily_loss_usd` | Σ realized PnL over the last 24h is at or below `-limit` | +| `kill_max_drawdown_usd` | the cumulative realized-PnL curve has dropped this far from its peak | + +The gates share one bounded read (`list_positions(limit=500)`), and the portfolio +is fetched **only when at least one gate is enabled** — so a default config +touches no DB at all, which the contract tests rely on. Order is cheapest-first: +heat cap → kill switches → per-market lockout, first trip wins. + +`heat_cap_usd` does double duty. Besides gating entry on exposure already taken, +it bounds `size_edge_multiplier_max`: the extra size the edge multiplier grants +is trimmed to the headroom left over open exposure, and never below the base +`order_size_usd`. When the cap is on but the portfolio is unreadable the +multiplier is refused outright (`portfolio_unavailable`) rather than applied +unbounded — "the portfolio was unreadable" is exactly the moment not to take a +3× position on trust. + +Edge-scaled sizing ships **off** (`size_edge_multiplier_max: 1.0`). Betting more +on a larger "edge" computed from an uncalibrated probability only loses faster, +so the knob is gated on `GET /api/analytics/calibration` first showing each +bucket's win rate near its own midpoint with n ≥ 100 behind it. + +## Write-behind persistence + +`db/writer.py`. The hot paths — the news WS callback, the market poll and +book-sample loops — must never block on a DB round-trip. They `enqueue` +synchronously into a bounded queue (5000 rows); one background task drains it in +batches (200) and persists each batch off the loop through an injected sink. + +Overflow drops the **newest** row rather than blocking a producer, the same +discipline as the orchestrator's queue. + +Four counters, all readable and all surfaced rather than swallowed — a dropped +row is a lost order-book or news sample and a failing sink is a persistence +outage, and neither used to leave a trace outside a counter nobody read: + +| Counter | Meaning | +|---|---| +| `dropped` | rows refused because the queue was full | +| `written` | rows the sink accepted | +| `errors` | sink failures (a whole batch each) | +| `pending` | rows currently queued | + +Both failure kinds report at WARNING with the **cumulative** count, rate-limited +to one message per kind per `WARN_INTERVAL_SECONDS` (60): a saturated queue must +not turn its own diagnosis into the flood. + +`stop()` waits (bounded, `STOP_DRAIN_TIMEOUT_SECONDS` = 10s) for the batch +already in flight instead of cancelling out from under it. The worker thread +behind `asyncio.to_thread` runs to completion regardless, so cancelling only +threw away the bookkeeping for a batch that did get written — the same reasoning +as the exit monitor's in-flight drain. diff --git a/docs/deploy/README.md b/docs/deploy/README.md index 32aaa62..b3f875e 100644 --- a/docs/deploy/README.md +++ b/docs/deploy/README.md @@ -39,7 +39,7 @@ paper→live, `PUT /api/wallet/config` repoints the signing key, and that can open a socket to the loopback port, which includes every other process on the host and anything sharing your SSH tunnel. -Two guards now sit in front of that, answering different questions. +Three guards now sit in front of that, answering different questions. ### `OPENPOLY_API_TOKEN` — who is calling @@ -75,11 +75,21 @@ Leaving it unset keeps the local development loop exactly as it was. It cannot be left unset for live trading: real funds behind an unauthenticated endpoint is not a state anyone should reach by omission, so the switch is refused outright. -> **Web UI caveat.** The frontend does not yet attach the header, so with a -> token configured its mutating actions (canvas save, manual close, mode switch) -> will get 401. Either run the UI against a backend with the token unset, or -> drive the gated routes from `curl` / the Swagger UI. Reads — the canvas, -> Inspector, positions, logs — are unaffected either way. +> **Use ASCII characters only.** An HTTP header value cannot carry a code point +> above `U+00FF`, and the browser's `fetch` throws a `TypeError` rather than +> sending one — so a token with an accent or an emoji in it turns *every* +> mutating request from the web UI into a client-side crash, not a 401. The +> backend compares UTF-8 bytes and would accept such a token from `curl`, which +> makes the failure look like a UI bug rather than a token you cannot type. Keep +> it to printable ASCII; `secrets.token_urlsafe` above already does. + +**Setting it in the web UI.** Open **Keys → API token** and paste the same value +you gave the backend. It is stored in that browser's `localStorage` (key +`openpoly_api_token`) and attached as `X-OpenPoly-Token` to mutating requests +only — reads keep the shape they always had. It lives in the browser rather than +in the backend secret store because it is the credential *for* that store, so it +cannot be kept behind it. Each browser needs its own copy; clearing site data +clears it. ### `OPENPOLY_ALLOWED_HOSTS` — what name they used @@ -94,15 +104,79 @@ rejection instead of a live-mode switch. OPENPOLY_ALLOWED_HOSTS=openpoly.internal.example.com ``` -The default loopback and SSH-tunnel setups need no entry: Vite's proxy forwards -the browser's own `Host`, which is `localhost:5173` or `127.0.0.1:5173`. If you +The default loopback and SSH-tunnel setups need no entry: Vite's proxy is +configured with `changeOrigin: false` (`frontend/vite.config.ts`), so it +forwards the browser's own `Host` — `localhost:5173` or `127.0.0.1:5173` — +rather than rewriting it to the proxy target. That is deliberate, and the +`Origin` section below says why. If you run the dev server with `--host` and open the UI from another machine on the LAN, that machine's URL becomes the Host and you must add it here. `*` disables -the check; make that a deliberate choice, not a default. There is deliberately **no +the check; make that a deliberate choice, not a default. The `Host` check +matches on hostname alone, so an entry that pins a port (`host:port`, see the +`Origin` section below) counts only for the `Origin` check — list the bare +hostname as well if the backend is also *reached* under that name. There is deliberately **no CORS allowance** — the frontend is same-origin through Vite's proxy, and a permissive `Access-Control-Allow-Origin` would hand back exactly what the Host allowlist takes away. +### Origin / `Sec-Fetch-Site` — whose page issued this + +The Host allowlist admits loopback by design, and a body-less `POST` is a CORS +*simple request*: the browser sends it and only refuses the caller sight of the +**response**. So this, from any page you happen to have open — + +```js +fetch('http://127.0.0.1:8000/api/positions/close-all', { method: 'POST' }) +``` + +— used to reach the route and bulk-close the book. The attacker never saw the +answer, which does not undo the sell. The token is no defence either: a browser +attaches no header it was not asked to, and with the token unset (the loopback +default) nothing was being checked at all. + +Every **mutating** request that does not come from this backend's own origin is +therefore refused with **403 `cross_origin_write`**, before it reaches any +route: + +- `Sec-Fetch-Site: cross-site` → refused outright. The browser sets this header + and page script cannot. The `Origin` is not consulted, so the allowlist below + cannot undo this refusal — the 403 body says so. +- `Sec-Fetch-Site: same-origin` or `none` (a typed-in URL) → allowed. Both name + this very origin. +- `Sec-Fetch-Site: same-site`, any value not listed above, or no fetch metadata + at all → decided by the `Origin`. **`same-site` is not `same-origin`**: it + only means the registrable domain matches, and every `localhost:` is + same-site with every other one, so a page served by any dev server or local + tool on `localhost:3000` would otherwise be able to drive the backend on + `localhost:8000` with the token unset. +- An `Origin` is allowed when it names the **same authority — host *and* port** + — as the request's own `Host` (a scheme's default port is implied on both + sides, so `https://host` matches `Host: host`), or when the operator listed + it in `OPENPOLY_ALLOWED_HOSTS`. Anything else is refused, including + `Origin: null` (sandboxed iframe, `data:` document), which counts as refused + rather than absent. Loopback gets no free pass here: it is the one authority + every local page shares. +- **Neither header present → allowed.** `curl`, a systemd timer and a Python + client carry no hostile page's authority, and the guard exists to stop a + browser being used as a confused deputy. Scripted use is unaffected. + +Reads are not guarded: a cross-origin `GET` leaks nothing the browser will hand +back anyway. + +`OPENPOLY_ALLOWED_HOSTS` is the escape hatch for a UI served from somewhere +else. An entry may be a bare host (`openpoly.internal.example.com` — admits it +as an `Origin` on any port) or pin the port (`openpoly.internal.example.com:5173`, +`localhost:3000` — admits that origin only). The default Vite setup needs no +entry: the proxy forwards the browser's own `Host` (`changeOrigin: false` in +`frontend/vite.config.ts`), so a canvas save arrives with `Origin: +http://localhost:5173` against `Host: localhost:5173` and is same-authority on +the `Origin` check alone. Vite's `'/api': target` shorthand would instead +normalize to `changeOrigin: true` and rewrite the `Host` to the proxy target, +which only stays working for browsers that send `Sec-Fetch-Site: same-origin` +— a client without fetch metadata would be 403'd on every mutation. If you put +your own reverse proxy in front of the backend, either preserve the browser's +`Host` the same way or add the UI's origin to `OPENPOLY_ALLOWED_HOSTS`. + ## Disk growth `order_book_snapshot` is the one table that grows without bound: one row per diff --git a/docs/deploy/separated-deployment.md b/docs/deploy/separated-deployment.md index 4fc2469..5707887 100644 --- a/docs/deploy/separated-deployment.md +++ b/docs/deploy/separated-deployment.md @@ -175,8 +175,10 @@ ssh openpoly-vps 'journalctl -u openpoly -n 50 --no-pager' 0. Set `OPENPOLY_API_TOKEN` in `/opt/openpoly/.env` and restart — the switch to live is refused with 403 `api_token_required` while the API is - unauthenticated. Send it as `X-OpenPoly-Token` on every mutating call (the - Swagger UI's "Try it out" lets you add the header per request). + unauthenticated. Use an ASCII-only value. Send it as `X-OpenPoly-Token` on + every mutating call (the Swagger UI's "Try it out" lets you add the header + per request); in the web UI, paste it into **Keys → API token** once and it + is attached for you. 1. Confirm a clean paper boot (smoke test above). 2. Open the Swagger UI over the tunnel → `POST /api/system/mode` `{"mode":"live"}`. 3. Preflight runs: derives API creds and checks pUSD balance + V2 allowances. diff --git a/frontend/README.md b/frontend/README.md index 00bb020..fa8192b 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -14,13 +14,14 @@ The frontend is a Vite dev server that proxies API calls to the backend. ```bash # from frontend/ yarn install -VITE_API_PROXY_TARGET=http://127.0.0.1:18000 yarn dev +yarn dev ``` -`VITE_API_PROXY_TARGET` points the dev server at your backend. In the default -same-machine setup that is the local backend on `127.0.0.1:18000`; for the -geoblock / separated-deployment setup it points at your SSH tunnel. See -[`docs/deploy/`](../docs/deploy/) for both. +The dev server proxies `/api` to `http://127.0.0.1:8000` by default, so no +environment variable is needed when the backend runs on the same machine. +`VITE_API_PROXY_TARGET` overrides the target — e.g. +`VITE_API_PROXY_TARGET=http://127.0.0.1:18000 yarn dev` for the geoblock / +separated-deployment SSH tunnel. See [`docs/deploy/`](../docs/deploy/) for both. ## Scripts @@ -30,8 +31,14 @@ geoblock / separated-deployment setup it points at your SSH tunnel. See | `yarn build` | Type-check (`tsc -b`) + production build | | `yarn typecheck` | Type-check only, no emit | | `yarn lint` | ESLint | +| `yarn test` | Vitest unit suite (`vitest run`) | | `yarn format` | Prettier write | +Tests live next to the module they cover (`src/canvas/store.test.ts`). Vitest +runs in the `node` environment — no jsdom — so the suite covers logic modules +(state transitions, (de)serialization, the API client) rather than rendering. +`src/testing/` holds the shared stubs, e.g. the in-memory `localStorage`. + ## Layout `src/sections/` mirrors the backend `openpoly/sections/` by name — each strategy diff --git a/frontend/package.json b/frontend/package.json index 2f71fc6..4dd1b08 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "build": "tsc -b && vite build", "demo:build": "vite build --config vite.config.demo.ts", "lint": "eslint .", + "test": "vitest run", "typecheck": "tsc -b --noEmit", "format": "prettier --write .", "preview": "vite preview" @@ -47,6 +48,7 @@ "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", "vite": "^8.0.12", - "vite-plugin-singlefile": "^2.3.3" + "vite-plugin-singlefile": "^2.3.3", + "vitest": "^4.1.11" } } diff --git a/frontend/src/canvas/KeysDrawer.tsx b/frontend/src/canvas/KeysDrawer.tsx index 06a5ce0..cb457a5 100644 --- a/frontend/src/canvas/KeysDrawer.tsx +++ b/frontend/src/canvas/KeysDrawer.tsx @@ -5,6 +5,7 @@ * so secrets can be managed without leaving the canvas. Opened from the * CanvasTopBar "Keys" button; backdrop click closes. */ +import { ApiTokenPanel } from '../setting/ApiTokenPanel' import { StoredKeysPanel } from '../setting/StoredKeysPanel' import { WalletPanel } from '../setting/WalletPanel' @@ -41,6 +42,7 @@ export function KeysDrawer({
+
diff --git a/frontend/src/canvas/ModeSwitchDialog.tsx b/frontend/src/canvas/ModeSwitchDialog.tsx index 6a59977..9dc9303 100644 --- a/frontend/src/canvas/ModeSwitchDialog.tsx +++ b/frontend/src/canvas/ModeSwitchDialog.tsx @@ -48,18 +48,41 @@ function blockerMessage(result: SwitchModeResult): string | null { } } +/** + * Human summary of a bulk close. + * + * Three outcomes, not two. `ok` from the backend means **flat**; a sell capped + * by the level-1 bid's depth comes back as `partial` with the remainder still + * on the book. Folding partials into "failed" (they carry neither a + * `skip_reason` nor an `error`, so they used to render as `unknown`) told the + * operator the sell did not happen — the opposite of the truth, on the one + * screen where "am I out of the market?" is the whole question. + */ function closeAllSummary(r: CloseAllResult): string { if (r.attempted === 0) return 'No open positions to close.' if (r.filled === r.attempted) return `Closed ${r.filled} positions.` + + const parts = [`Closed ${r.filled}/${r.attempted}.`] + if (r.partial > 0) { + const remaining = r.details.reduce((sum, d) => sum + (d.remaining_qty ?? 0), 0) + parts.push( + `${r.partial} partially closed, remainder open` + + (remaining > 0 ? ` (${remaining.toFixed(2)} shares).` : '.'), + ) + } const failed = r.skipped + r.errored - const reasons = Array.from( - new Set( - r.details - .filter((d) => !d.ok) - .map((d) => d.skip_reason ?? (d.error ? 'executor_error' : 'unknown')) - ), - ).join(', ') - return `Closed ${r.filled}/${r.attempted}. ${failed} failed (${reasons}). Residuals stay open — retry or close manually.` + if (failed > 0) { + const reasons = Array.from( + new Set( + r.details + .filter((d) => !d.ok && !d.partial) + .map((d) => d.skip_reason ?? (d.error ? 'executor_error' : 'unknown')), + ), + ).join(', ') + parts.push(`${failed} failed (${reasons}).`) + } + parts.push('Anything still open stays open — retry or close manually.') + return parts.join(' ') } export function ModeSwitchDialog({ target, onClose }: Props) { @@ -146,14 +169,15 @@ export function ModeSwitchDialog({ target, onClose }: Props) {

{isLive ? ( <> - Live mode signs and submits real IOC orders on Polygon mainnet with - the configured wallet (Polymarket CLOB).{' '} + Live mode signs and submits real orders on Polygon mainnet with + the configured wallet (Polymarket CLOB). Settlement detection is + active.{' '} - Settlement detection (slice E) and kill-switch enforcement (A4) - are not yet implemented — open positions will not auto-close on - market resolution, and there is no hard daily-loss / drawdown - brake beyond the entry-side heat_cap. Monitor positions - manually until those land. + The kill-switch brakes (consecutive-loss, daily-loss, drawdown) + are OFF by default — each stays disabled until you set its + limit above 0 in the entry section config. This is experimental + software trading real money: monitor positions and review the + exit log regularly. ) : ( diff --git a/frontend/src/canvas/store.test.ts b/frontend/src/canvas/store.test.ts new file mode 100644 index 0000000..646c35f --- /dev/null +++ b/frontend/src/canvas/store.test.ts @@ -0,0 +1,539 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { MemoryStorage } from '../testing/memoryStorage' +import type { Template } from './templateIO' +import { STORAGE_KEY, TEMPLATE_VERSION } from './templateIO' + +// `store.ts` reads localStorage at module scope, so every test imports it +// *after* seeding the stub. `vi.resetModules()` also resets the module-level +// node-id counter and the one-shot bootstrap guard. +type CanvasStore = typeof import('./store').useCanvasStore + +async function freshStore(): Promise { + vi.resetModules() + const mod = await import('./store') + return mod.useCanvasStore +} + +function tpl(overrides: Partial