feat: shadow book + shadow-vs-manual performance comparison
The manual paper book only contains trades taken by hand, inside a 20
minute window, on days someone was available. The backtest that validated
this strategy auto-takes the top-ranked qualified setups up to capacity
every session. The forward record was therefore measuring strategy plus
discretion plus availability -- and degrading silently on busy days.
The shadow book closes that gap: it mirrors the backtest's selection rule
(top strategy_rank qualified, up to capacity, 1% fixed-fractional risk)
and shares the manual book's exit policy, so the only difference between
the two books is which setups get taken. Selection ordering reuses the
strategy_rank the scanner already stores rather than recomputing it, so
the two cannot drift apart. It runs as a near-close pipeline step right
after the scan, marking entries at the same prices a human would see.
Gate-reset re-entry state is now scoped per book -- the books diverge as
soon as their entries differ, and each must see only its own stops.
Performance view rewritten around the comparison:
- three series (shadow, manual, SPY) from a new endpoint
- SPY changes from a per-trade cost-basis counterfactual to plain
buy-and-hold %, since one line has to serve two books
- headline stats are R-multiples, not currency: the books size
differently, so only R compares across them
- configurable start date, because the strategy has been revised
repeatedly and pre-cutover trades ran under rules that no longer
exist
Migration 024 also repairs the numeric weekday crons written by 023,
rewriting only rows still holding the broken form so hand-corrected
settings survive. Its literals are inlined because bound parameters
render as NULL under 'alembic upgrade --sql'.
The shadow book is opt-in and writes nothing until enabled. Verify its
first selections match a backtest of that day's cross-section before
trusting any point on the curve.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,12 +22,22 @@ def _ny_trading_date(moment: datetime) -> date:
|
||||
return moment.astimezone(_REENTRY_DAY_TZ).date()
|
||||
|
||||
|
||||
MANUAL_BOOK = "manual"
|
||||
SHADOW_BOOK = "shadow"
|
||||
|
||||
|
||||
async def _latest_initial_stop_trades(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
closed_before: datetime | None = None,
|
||||
book: str = MANUAL_BOOK,
|
||||
) -> dict[int, PaperTrade]:
|
||||
"""Return a ticker's latest closed trade only when it was an initial stop."""
|
||||
"""Return a ticker's latest closed trade only when it was an initial stop.
|
||||
|
||||
Scoped to one ``book``: the discretionary and shadow books diverge as soon
|
||||
as their entries differ, so each must see only its own stop history when
|
||||
deciding whether a ticker is locked out of re-entry.
|
||||
"""
|
||||
ranked_stmt = (
|
||||
select(
|
||||
PaperTrade.id.label("trade_id"),
|
||||
@@ -41,6 +51,7 @@ async def _latest_initial_stop_trades(
|
||||
.where(
|
||||
PaperTrade.status == "closed",
|
||||
PaperTrade.closed_at.is_not(None),
|
||||
PaperTrade.book == book,
|
||||
)
|
||||
)
|
||||
if closed_before is not None:
|
||||
@@ -58,7 +69,9 @@ async def _latest_initial_stop_trades(
|
||||
return {trade.ticker_id: trade for trade in result.scalars()}
|
||||
|
||||
|
||||
async def get_reentry_gate_locks(db: AsyncSession) -> dict[int, datetime]:
|
||||
async def get_reentry_gate_locks(
|
||||
db: AsyncSession, *, book: str = MANUAL_BOOK
|
||||
) -> dict[int, datetime]:
|
||||
"""Return tickers still waiting for a post-stop gate failure.
|
||||
|
||||
A later qualified setup is actionable only after the daily scanner has
|
||||
@@ -66,7 +79,7 @@ async def get_reentry_gate_locks(db: AsyncSession) -> dict[int, datetime]:
|
||||
then a fresh qualification. The returned timestamp is the stop time and is
|
||||
useful for diagnostics; callers normally only need the keys.
|
||||
"""
|
||||
latest = await _latest_initial_stop_trades(db)
|
||||
latest = await _latest_initial_stop_trades(db, book=book)
|
||||
return {
|
||||
ticker_id: trade.closed_at
|
||||
for ticker_id, trade in latest.items()
|
||||
@@ -80,6 +93,7 @@ async def observe_reentry_gate_transitions(
|
||||
evaluated_ticker_ids: Iterable[int],
|
||||
qualified_ticker_ids: Iterable[int],
|
||||
observed_at: datetime | None = None,
|
||||
book: str = MANUAL_BOOK,
|
||||
) -> set[int]:
|
||||
"""Persist gate-failure and later requalification observations.
|
||||
|
||||
@@ -93,7 +107,7 @@ async def observe_reentry_gate_transitions(
|
||||
return set()
|
||||
qualified = {int(ticker_id) for ticker_id in qualified_ticker_ids}
|
||||
timestamp = observed_at or datetime.now(timezone.utc)
|
||||
latest = await _latest_initial_stop_trades(db, closed_before=timestamp)
|
||||
latest = await _latest_initial_stop_trades(db, closed_before=timestamp, book=book)
|
||||
updated: set[int] = set()
|
||||
for ticker_id in evaluated:
|
||||
trade = latest.get(ticker_id)
|
||||
|
||||
Reference in New Issue
Block a user