plans(018): sub-second exit thread — the last real execution-latency lever
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
680ddc2174
commit
375973fe21
2 files changed
+123
No files matched your search
@@ -0,0 +1,122 @@
|
|||||||
|
# Plan 018: Sub-second exit management on a dedicated thread
|
||||||
|
|
||||||
|
> **Executor instructions**: Live money path — TDD every change, run the exit
|
||||||
|
> tests before/after, honor STOP conditions. When done, update
|
||||||
|
> `plans/README.md`.
|
||||||
|
>
|
||||||
|
> **Drift check**: `git diff --stat 680ddc2..HEAD -- run_live_paper.py strategy/ tests/`
|
||||||
|
|
||||||
|
## Status
|
||||||
|
- **Priority**: P1 (owner's core ask: get out of trades fast; exits leak the P&L on fast small-caps)
|
||||||
|
- **Effort**: M
|
||||||
|
- **Risk**: MED-HIGH (touches the live exit execution path)
|
||||||
|
- **Depends on**: none (config speedups already shipped 680ddc2)
|
||||||
|
- **Category**: latency / money-path
|
||||||
|
- **Planned at**: commit `680ddc2`, 2026-07-18
|
||||||
|
|
||||||
|
## Why this matters
|
||||||
|
|
||||||
|
Exit management (`LiveExitManager.manage()` — breakeven ratchet, trail, tier
|
||||||
|
scale-out, catastrophe/first-red) runs as a step on the **single-threaded main
|
||||||
|
scheduler**. Even at the new 4s interval it (a) queues behind slower steps
|
||||||
|
(ingest, discover, news) that share the thread, so real reaction can drift well
|
||||||
|
past 4s, and (b) is bounded by the scheduler tick. On a $2–20 small-cap that
|
||||||
|
spikes and round-trips in seconds, a stop or profit-tier that reacts "sometime
|
||||||
|
in the next 4–10s" gives back the gain. This is the owner's #1 felt problem
|
||||||
|
("late exits, gave back gains"), and unlike entry timing (verified fine, memory
|
||||||
|
`execution-plus-judgment-mode`), exit latency is real and measurable.
|
||||||
|
|
||||||
|
The fix: run exit management on its **own dedicated thread** (like the fast
|
||||||
|
trigger thread already does for entries), at a sub-second-to-1s cadence,
|
||||||
|
independent of the scheduler queue. Entries already got this treatment
|
||||||
|
(`trigger_loop` on its own thread); exits deserve the symmetric fix.
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
- `run_live_paper.py`:
|
||||||
|
- `trigger_loop()` (~line 1078) — the PATTERN to mirror: own daemon thread,
|
||||||
|
`while not stop_event.wait(interval): _trigger_pass()`, wrapped in
|
||||||
|
try/except so one error never kills the loop. Started ~line 1339.
|
||||||
|
- `exit_mgr = LiveExitManager(client, store, _rth_bars, cfg, session_id)`
|
||||||
|
(~line 1097) — the object whose `.manage()` does all exit work.
|
||||||
|
- `step_manage_exits()` (~line 1128) calls `exit_mgr.manage()` on the
|
||||||
|
scheduler at `TRADING_EXIT_MANAGE_INTERVAL_SECONDS` (now 4s).
|
||||||
|
- `strategy/.../LiveExitManager.manage()` — reads current bars/price per
|
||||||
|
position, computes desired stop/target/scale, submits broker orders. MUST be
|
||||||
|
audited for thread-safety before running it concurrently with the trigger
|
||||||
|
thread and scheduler (shared broker client, shared store).
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### Step 1: Audit thread-safety of the shared resources
|
||||||
|
Before adding a thread, confirm what `manage()` touches that the trigger
|
||||||
|
thread / scheduler also touch: the Alpaca `client` (HTTP — usually fine,
|
||||||
|
confirm no shared mutable cursor), `rt["store"]` (EventStore — has a `_lock`
|
||||||
|
around emit, good), `research_con` (the facade — NOT thread-safe; if
|
||||||
|
`manage()` reads bars via a shared `research_con`, it needs its OWN connection
|
||||||
|
like `step_quotes`/catalyst provider do, per the memory note
|
||||||
|
`entry-path-fast-vs-auto` about dedicated connections). Document findings; if
|
||||||
|
`manage()` shares `research_con`, Step 2 must give the exit thread its own.
|
||||||
|
|
||||||
|
**Verify**: written audit; grep every attribute `manage()` reads.
|
||||||
|
|
||||||
|
### Step 2: Move exit management to its own thread
|
||||||
|
- Add `exit_interval = float(os.environ.get("TRADING_EXIT_THREAD_INTERVAL_SECONDS", "1.0"))`.
|
||||||
|
- `def exit_loop(): while not stop_event.wait(exit_interval): try: exit_mgr.manage() except: log` — mirror `trigger_loop` exactly.
|
||||||
|
- Give the exit thread its OWN research DB connection if Step 1 found sharing
|
||||||
|
(open it inside the thread, single-threaded use, like the enrich/quote paths).
|
||||||
|
- Start it next to the trigger thread (~line 1339), gated on
|
||||||
|
`_flag("TRADING_EXECUTION_ENABLED","1")` and `client is not None`.
|
||||||
|
- REMOVE the `scheduler.add("exits", ...)` step (or gate it off) so exits don't
|
||||||
|
run on BOTH the thread and the scheduler (double-submit risk).
|
||||||
|
- Keep `TRADING_EXIT_MANAGE_INTERVAL_SECONDS` honored as a fallback only if the
|
||||||
|
thread is disabled.
|
||||||
|
|
||||||
|
**Verify**: unit test — a fake exit_mgr whose `.manage()` counts calls, driven
|
||||||
|
by a short-interval loop, is invoked repeatedly off the scheduler; the
|
||||||
|
scheduler no longer registers an "exits" step (grep the scheduler.add list).
|
||||||
|
|
||||||
|
### Step 3: Idempotency / double-submit guard
|
||||||
|
At 1s cadence the manager runs ~4× more often. Confirm `manage()` is idempotent
|
||||||
|
— it must not re-submit a stop/scale it already placed (check for an existing
|
||||||
|
resting order before submitting; `get_orders(symbols=...)` scoped, per the
|
||||||
|
`get-orders-truncation` memory). If it isn't already idempotent, add the guard.
|
||||||
|
This is the highest-risk correctness point — a fast loop that re-fires orders
|
||||||
|
is worse than a slow one.
|
||||||
|
|
||||||
|
**Verify**: test that two back-to-back `manage()` calls on the same position
|
||||||
|
with an already-resting protective stop submit the stop ONCE.
|
||||||
|
|
||||||
|
### Step 4: Backpressure / cost check
|
||||||
|
1s cadence × ~10 positions = more broker calls. Confirm within Alpaca limits
|
||||||
|
(scope reads by `symbols=`, reuse the position snapshot the sync step already
|
||||||
|
fetches rather than re-polling per position). If `manage()` is heavy, cap the
|
||||||
|
effective rate or reuse cached quotes.
|
||||||
|
|
||||||
|
**Verify**: measure `manage()` wall-time; at 1s interval it must finish well
|
||||||
|
inside the interval for the real position count.
|
||||||
|
|
||||||
|
## Done criteria
|
||||||
|
- [ ] Exit management runs on its own daemon thread at ~1s, off the scheduler
|
||||||
|
- [ ] `manage()` thread-safe (own DB connection if needed) + idempotent (no double-submit)
|
||||||
|
- [ ] Scheduler no longer runs a competing "exits" step
|
||||||
|
- [ ] Exit tests green; full suite green; `ruff --select F` clean
|
||||||
|
- [ ] `TRADING_EXIT_THREAD_INTERVAL_SECONDS` in the deploy configs (default 1.0)
|
||||||
|
|
||||||
|
## STOP conditions
|
||||||
|
- `manage()` shares mutable state with the trigger thread that can't be cleanly
|
||||||
|
isolated → STOP, report; a data race in the exit path is worse than 4s latency.
|
||||||
|
- `manage()` is NOT idempotent and making it so requires restructuring the exit
|
||||||
|
engine → STOP, plan that separately; do not ship a fast loop that double-fires.
|
||||||
|
- Any change would touch entry/sizing logic → out of scope.
|
||||||
|
|
||||||
|
## Maintenance notes
|
||||||
|
- Symmetry: entries on `trigger_loop`, exits on `exit_loop` — two fast daemon
|
||||||
|
threads + the slow scheduler for everything non-time-critical. Keep that shape.
|
||||||
|
- If the owner later wants tick-level exits, the thread is the seam to feed a
|
||||||
|
streaming quote instead of a polled one — don't shorten the poll below ~0.5s
|
||||||
|
without moving to a push feed.
|
||||||
|
- This is the last measurable execution-latency lever. Beyond it, remaining P&L
|
||||||
|
is strategy (edge), not speed — and the exhaustive search says the edge isn't
|
||||||
|
there for gap-ORB (see orderflow-shelf-verdicts). Speed makes the good days
|
||||||
|
cleaner; it does not create an edge.
|
||||||
@@ -34,6 +34,7 @@ your row when done.
|
|||||||
| 015 | Backtest replay dashboard (/backtest) — theoretical entries/exits over past sessions under CURRENT settings, via Experiment-OS replay | P2 | L | 013 (chart component) | **DONE (2026-07-17, review-APPROVED first pass; commit 8ac222a on `worktree-agent-ae4d3f36e18c6e7a5` — awaiting owner merge). replay_window = assembly-only over harness primitives (same _entry_index/adverse-fill/R math); /api/backtest 45-session clamp + 10-min cache behind a dedicated lock; /backtest page with THEORETICAL banner + X-of-Y coverage line + per-trade drill-in (blue entry/purple exit markers). Deviations accepted: /api/chart gained backward-compatible date= anchor (72h cap couldn't reach old sessions); batching idiom taken from regime.py (plan's run() pointer was wrong).** |
|
| 015 | Backtest replay dashboard (/backtest) — theoretical entries/exits over past sessions under CURRENT settings, via Experiment-OS replay | P2 | L | 013 (chart component) | **DONE (2026-07-17, review-APPROVED first pass; commit 8ac222a on `worktree-agent-ae4d3f36e18c6e7a5` — awaiting owner merge). replay_window = assembly-only over harness primitives (same _entry_index/adverse-fill/R math); /api/backtest 45-session clamp + 10-min cache behind a dedicated lock; /backtest page with THEORETICAL banner + X-of-Y coverage line + per-trade drill-in (blue entry/purple exit markers). Deviations accepted: /api/chart gained backward-compatible date= anchor (72h cap couldn't reach old sessions); batching idiom taken from regime.py (plan's run() pointer was wrong).** |
|
||||||
| 016 | EDGAR dilution enrichment — unbreak silent nightly refresh (UTC current_date bug + missing float_cache DDL) + pre-register dilution_veto_v1 keep-or-zero experiment | P2 | M | 006 | **DONE (2026-07-17, review-APPROVED first pass; f6999c7 merged). iv_dilution_gate keep-or-zero (fail-open on unfetched symbols, early results biased toward zero never inflated), dilution_veto_v1 self-registers via the experiments/*.json glob, nightly adjudication live. Prod backfill seeded same day (304 symbols swept, 245 filings / 91 symbols). FIRST ADJUDICATION 2026-07-17: **FAIL** (n=2044, expR_delta +0.0012 vs +0.05 bar) — but only 64/2069 fires (3%) had filing coverage (backfill covers recent actives only; historical fires read unflagged by fail-open design), AND the flagged fires ran BETTER than unflagged (baseline −0.038 vs −0.377, n=64): early evidence points AGAINST the dilution-trap thesis, not merely underpowered. Bar-design lesson: min_n counted ALL fires not flagged ones. Verdict terminal (no goalpost-moving); filings keep accruing nightly. IF revisited: dilution_veto_v2 with a flagged-fire min_n metric, after ~3-4 weeks coverage.** |
|
| 016 | EDGAR dilution enrichment — unbreak silent nightly refresh (UTC current_date bug + missing float_cache DDL) + pre-register dilution_veto_v1 keep-or-zero experiment | P2 | M | 006 | **DONE (2026-07-17, review-APPROVED first pass; f6999c7 merged). iv_dilution_gate keep-or-zero (fail-open on unfetched symbols, early results biased toward zero never inflated), dilution_veto_v1 self-registers via the experiments/*.json glob, nightly adjudication live. Prod backfill seeded same day (304 symbols swept, 245 filings / 91 symbols). FIRST ADJUDICATION 2026-07-17: **FAIL** (n=2044, expR_delta +0.0012 vs +0.05 bar) — but only 64/2069 fires (3%) had filing coverage (backfill covers recent actives only; historical fires read unflagged by fail-open design), AND the flagged fires ran BETTER than unflagged (baseline −0.038 vs −0.377, n=64): early evidence points AGAINST the dilution-trap thesis, not merely underpowered. Bar-design lesson: min_n counted ALL fires not flagged ones. Verdict terminal (no goalpost-moving); filings keep accruing nightly. IF revisited: dilution_veto_v2 with a flagged-fire min_n metric, after ~3-4 weeks coverage.** |
|
||||||
| 017 | L2 order-book depth recorder **[VERDICT 2026-07-18: order-book overhang is REAL but too weak to trade. n=2191 under the real deployed exit: high-overhang fires -0.676R vs low -0.759R = +0.083R separation, SAME direction as the sniff (held from n=23 to n=2191 — genuinely real, not noise). BUT both buckets deeply negative; gating fails the random-gate null (delta +0.28 was mostly exposure-shrink). It reduces losses, does NOT create profit or beat a random gate. Live gap>=10% band: n=13/74, no signal. FAIL under the pre-registered bar. Same shape as the regime gate: real modest signal on a negative-EV base = less-bad, not positive. depth_features(2202) + code retained for a possible broader-population redesign; NOT wired live. Databento spend ~$16 free credits. REDESIGN LEAD REFUTED 2026-07-18: gap x overhang grid under real exit shows lower-gap is the WORST cell (0-3% -0.831R), not a hidden edge; NO positive cell exists anywhere. Exhaustive search complete — no positive subpopulation in gap-ORB.]** — the one untested selection axis; FREE-first (Phase 0-1 = $0 sniff test + recorder), paid feed only after the data earns it, pre-registered verdict before any live use | P1 | L | 006 | TODO (owner-approved to plan, no spend yet) |
|
| 017 | L2 order-book depth recorder **[VERDICT 2026-07-18: order-book overhang is REAL but too weak to trade. n=2191 under the real deployed exit: high-overhang fires -0.676R vs low -0.759R = +0.083R separation, SAME direction as the sniff (held from n=23 to n=2191 — genuinely real, not noise). BUT both buckets deeply negative; gating fails the random-gate null (delta +0.28 was mostly exposure-shrink). It reduces losses, does NOT create profit or beat a random gate. Live gap>=10% band: n=13/74, no signal. FAIL under the pre-registered bar. Same shape as the regime gate: real modest signal on a negative-EV base = less-bad, not positive. depth_features(2202) + code retained for a possible broader-population redesign; NOT wired live. Databento spend ~$16 free credits. REDESIGN LEAD REFUTED 2026-07-18: gap x overhang grid under real exit shows lower-gap is the WORST cell (0-3% -0.831R), not a hidden edge; NO positive cell exists anywhere. Exhaustive search complete — no positive subpopulation in gap-ORB.]** — the one untested selection axis; FREE-first (Phase 0-1 = $0 sniff test + recorder), paid feed only after the data earns it, pre-registered verdict before any live use | P1 | L | 006 | TODO (owner-approved to plan, no spend yet) |
|
||||||
|
| 018 | Sub-second exit management on a dedicated thread — the last measurable execution-latency lever (exits leak P&L on fast small-caps; entries verified fine) | P1 | M | — | TODO |
|
||||||
|
|
||||||
Status values: TODO | IN PROGRESS | DONE | BLOCKED (with one-line reason) | REJECTED (with one-line rationale)
|
Status values: TODO | IN PROGRESS | DONE | BLOCKED (with one-line reason) | REJECTED (with one-line rationale)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user