fix: harden shadow book against book leakage (review of ba2df8b)

Review of the shadow book found seven ways the two books could leak into
each other; all are fixed here. The most serious silently invalidated the
comparison the shadow book exists to make.

- Shadow holdings no longer suppress the manual candidate list. The
  open-trade exclusion filtered on any book, so shadow taking the
  top-ranked names removed exactly those from the user's list and alerts,
  confining the discretionary book to leftovers. Scoped to the manual
  book. Closed-trade alerts and paper-book equity were leaking the same
  way and are likewise scoped.

- Shadow sizing now matches _simulate_portfolio: min(1% risk, 20% notional
  cap, available cash) from marked equity, plus the sub- dust guard.
  Previously risk-only from realized equity, so a tight stop produced a
  multiples-of-equity leveraged position the strategy would never take.

- Shadow only trades setups from the scan that just ran (<6h old) with one
  setup per ticker. A failed or disabled scan step could otherwise open
  positions from a prior session at stale prices.

- Gate-reset transitions are observed for both books, so a shadow stop-out
  completes fail -> requalify instead of staying locked forever.

- Manual list/close endpoints default to the manual book and reject
  hand-closing shadow trades; the performance endpoint is scoped to the
  caller so 'your picks' is not every user's book.

- run_shadow_book is registered as a paused job so Admin can trigger it.

Also anchors three pre-existing paper-trade tests (and the new alpaca
window test) on the UTC date. They build fixtures from the local date but
the service stamps opened_at in UTC, so they failed only between 00:00 and
02:00 in a UTC+hh timezone -- latent on ba2df8b, exposed by the clock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 09:11:14 +02:00
co-authored by Claude Fable 5
parent ba2df8b9fd
commit 247a92a89f
11 changed files with 434 additions and 66 deletions
+3 -2
View File
@@ -67,12 +67,13 @@ async def paper_trade_equity_curve(
@router.get("/paper-trades/performance", response_model=APIEnvelope)
async def paper_trade_performance(
_user: User = Depends(require_access),
user: User = Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
"""Shadow book vs discretionary book vs SPY since the configured start date."""
return APIEnvelope(
status="success", data=await paper_trade_service.performance_summary(db)
status="success",
data=await paper_trade_service.performance_summary(db, user.id),
)
+1
View File
@@ -1513,6 +1513,7 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
(collect_benchmark, "benchmark_collector", "Benchmark Collector"),
(collect_sentiment, "sentiment_collector", "Sentiment Collector"),
(scan_rr, "rr_scanner", "R:R Scanner"),
(run_shadow_book, "shadow_book", "Shadow Book (auto-traded strategy)"),
(evaluate_outcomes, "outcome_evaluator", "Outcome Evaluator"),
(compute_market_regime, "market_regime", "Market Regime"),
(compute_regime_monitor, "regime_monitor", "Regime Monitor"),
+13 -2
View File
@@ -29,6 +29,7 @@ from app.config import settings
from app.models.alert import AlertLog
from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade
from app.services.trade_policy import MANUAL_BOOK
from app.models.score import CompositeScore
from app.models.sr_level import SRLevel
from app.models.ticker import Ticker
@@ -632,6 +633,10 @@ async def _collect_closed_trades(db: AsyncSession) -> list[ClosedTradeItem]:
PaperTrade.closed_at.is_not(None),
PaperTrade.closed_at > cutoff,
PaperTrade.close_reason.in_(("trailing", "stop", "target", "time")),
# Your own positions only — shadow trades are a research record, not
# something you hold, and mixing them in unlabelled reads as if you
# were stopped out of a name you never took.
PaperTrade.book == MANUAL_BOOK,
)
.order_by(PaperTrade.closed_at.desc())
)
@@ -642,8 +647,14 @@ async def _collect_closed_trades(db: AsyncSession) -> list[ClosedTradeItem]:
async def _paper_book_value(db: AsyncSession) -> float:
"""Paper-trade equity: fixed capital plus realized/unrealized P&L."""
result = await db.execute(select(PaperTrade))
"""Paper-trade equity: fixed capital plus realized/unrealized P&L.
Discretionary book only — the shadow book runs on its own notional equity
and folding it in would report a number matching neither book.
"""
result = await db.execute(
select(PaperTrade).where(PaperTrade.book == MANUAL_BOOK)
)
trades = list(result.scalars().all())
latest: dict[int, float | None] = {}
for trade in trades:
+24 -1
View File
@@ -402,7 +402,15 @@ async def list_trades(
db: AsyncSession,
user_id: int | None = None,
status: str | None = None,
book: str | None = MANUAL_BOOK,
) -> list[dict]:
"""Trades for the UI. Defaults to the discretionary book.
Shadow trades are attached to a user row for FK reasons only — they are not
that person's decisions. Listing them alongside manual trades would mix two
different books in one P&L and let the autonomous record be edited by hand.
Pass ``book=None`` to deliberately span both.
"""
stmt = (
select(PaperTrade, Ticker.symbol)
.join(Ticker, PaperTrade.ticker_id == Ticker.id)
@@ -411,6 +419,8 @@ async def list_trades(
stmt = stmt.where(PaperTrade.user_id == user_id)
if status is not None:
stmt = stmt.where(PaperTrade.status == status)
if book is not None:
stmt = stmt.where(PaperTrade.book == book)
stmt = stmt.order_by(PaperTrade.opened_at.desc())
rows = (await db.execute(stmt)).all()
@@ -493,6 +503,13 @@ async def close_trade(
trade = result.scalar_one_or_none()
if trade is None:
raise NotFoundError(f"Paper trade not found: {trade_id}")
if trade.book == SHADOW_BOOK:
# The shadow book's value is that no human touched it. A hand-closed
# position would make its record something other than what the strategy
# would have done; it exits only via the automatic exit policy.
raise ValidationError(
"Shadow book trades are closed by the exit policy, not by hand"
)
if trade.status == "closed":
raise ValidationError("Trade is already closed")
@@ -811,7 +828,7 @@ def _cumulative_pnl(trades: list, ticker_closes: dict, days: list[date]) -> list
return out
async def performance_summary(db: AsyncSession) -> dict:
async def performance_summary(db: AsyncSession, user_id: int | None = None) -> dict:
"""Shadow book vs discretionary book vs SPY, from the configured start date.
Currency P&L is reported per book but is *not* the comparison — the books
@@ -823,6 +840,12 @@ async def performance_summary(db: AsyncSession) -> dict:
stmt = select(PaperTrade)
if start is not None:
stmt = stmt.where(func.date(PaperTrade.opened_at) >= start)
if user_id is not None:
# "Your picks" must be *yours*. The shadow book is a single autonomous
# book with no owner, so it is never scoped to a user.
stmt = stmt.where(
(PaperTrade.book == SHADOW_BOOK) | (PaperTrade.user_id == user_id)
)
trades = list((await db.execute(stmt)).scalars().all())
benchmark_closes = await benchmark_service.load_benchmark_closes(db)
+14 -2
View File
@@ -31,6 +31,8 @@ from app.services.price_service import query_ohlcv
from app.services.qualification import setup_qualifies
from app.services.sr_service import detect_gate_target_ladder
from app.services.trade_policy import (
MANUAL_BOOK,
SHADOW_BOOK,
get_reentry_gate_locks,
observe_reentry_gate_transitions,
)
@@ -788,11 +790,17 @@ async def scan_all_tickers(
logger.exception("Error scanning ticker %s", symbol)
if activation is not None:
transitioned_ticker_ids = await observe_reentry_gate_transitions(
# Both books, from the same observation: gate-reset state is per book,
# so observing only the manual book would leave shadow stop-outs stuck
# with a fail timestamp that never requalifies — permanently ineligible.
transitioned_ticker_ids: set[int] = set()
for book in (MANUAL_BOOK, SHADOW_BOOK):
transitioned_ticker_ids |= await observe_reentry_gate_transitions(
db,
evaluated_ticker_ids=evaluated_ticker_ids,
qualified_ticker_ids=qualified_ticker_ids,
observed_at=gate_observation_started_at,
book=book,
)
await db.commit()
if transitioned_ticker_ids:
@@ -843,9 +851,13 @@ async def get_trade_setups(
excluded_ticker_ids: set[int] = set()
reentry_gate_locks: dict[int, datetime] = {}
if exclude_open_trade_tickers:
# Manual book only. The shadow book holds the *top-ranked* names by
# construction, so letting its positions hide setups would leave the
# discretionary list picking over leftovers — and would bias the very
# shadow-vs-manual comparison the shadow book exists to measure.
open_trade_result = await db.execute(
select(PaperTrade.ticker_id)
.where(PaperTrade.status == "open")
.where(PaperTrade.status == "open", PaperTrade.book == MANUAL_BOOK)
.distinct()
)
excluded_ticker_ids.update(
+98 -36
View File
@@ -20,9 +20,9 @@ two cannot drift apart.
from __future__ import annotations
import logging
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.paper_trade import PaperTrade
@@ -47,6 +47,15 @@ DEFAULT_CAPACITY = 10
DEFAULT_RISK_PCT = 1.0
DEFAULT_START_EQUITY = 100_000.0
# Mirrors ``_simulate_portfolio``'s SIM_NOTIONAL_CAP: no single position may
# exceed this fraction of equity, and the book never uses margin. Without the
# cap, a setup with a tight stop turns 1% risk into a position several times
# equity — a leveraged trade the validated strategy would never have taken.
NOTIONAL_CAP = 0.20
# Setups older than this mean the scan did not run in this pipeline pass.
MAX_SETUP_AGE = timedelta(hours=6)
async def get_config(db: AsyncSession) -> dict:
"""Shadow book sizing/capacity config, falling back to validated defaults."""
@@ -76,13 +85,18 @@ async def is_enabled(db: AsyncSession) -> bool:
return str(value).strip().lower() in {"1", "true", "yes", "on"}
async def current_equity(db: AsyncSession, start_equity: float) -> float:
"""Start equity plus realized P&L of closed shadow trades.
async def equity_and_cash(
db: AsyncSession, start_equity: float, positions: list[PaperTrade]
) -> tuple[float, float]:
"""Marked equity and free cash, matching ``_simulate_portfolio``.
Open positions are deliberately excluded: sizing off marked-to-market equity
would let an unrealized gain inflate the next position, which is not what the
backtest does.
The simulator sizes from *marked* equity — cash plus open positions at their
latest close — and spends from cash, so a book that is fully invested cannot
keep buying. Sizing from realized P&L alone would drift away from the
backtest as soon as positions were held across a scan.
"""
from app.services.paper_trade_service import _latest_closes
result = await db.execute(
select(PaperTrade).where(
PaperTrade.book == SHADOW_BOOK,
@@ -98,24 +112,51 @@ async def current_equity(db: AsyncSession, start_equity: float) -> float:
else trade.entry_price - trade.close_price
)
realized += per_share * trade.shares
return start_equity + realized
open_cost = sum(p.entry_price * p.shares for p in positions)
marks = await _latest_closes(db, {p.ticker_id for p in positions})
open_value = sum(
(marks.get(p.ticker_id) or p.entry_price) * p.shares for p in positions
)
cash = start_equity + realized - open_cost
return cash + open_value, cash
def position_shares(equity: float, risk_pct: float, entry: float, stop: float) -> float:
"""Fixed-fractional sizing: risk ``risk_pct`` of equity down to the stop."""
def position_shares(
equity: float,
risk_pct: float,
entry: float,
stop: float,
*,
cash_available: float | None = None,
) -> float:
"""Shares to buy, sized exactly as ``_simulate_portfolio`` sizes them.
Fixed-fractional risk first, then the two caps the simulator applies: no
position may exceed ``NOTIONAL_CAP`` of equity, and the book cannot spend
cash it does not have. Dropping either cap lets a tight stop produce a
leveraged position and breaks compounding parity with the backtest.
"""
risk_per_share = abs(entry - stop)
if risk_per_share <= 0 or equity <= 0:
if risk_per_share <= 0 or equity <= 0 or entry <= 0:
return 0.0
return (equity * risk_pct / 100.0) / risk_per_share
shares = (equity * risk_pct / 100.0) / risk_per_share
shares = min(shares, (equity * NOTIONAL_CAP) / entry)
if cash_available is not None:
shares = min(shares, max(0.0, cash_available) / entry)
# Dust guard, as in the simulator: sub-$1 positions are noise, not trades.
return shares if shares * entry >= 1.0 else 0.0
async def _open_ticker_ids(db: AsyncSession) -> set[int]:
async def _open_positions(db: AsyncSession) -> list[PaperTrade]:
result = await db.execute(
select(PaperTrade.ticker_id).where(
select(PaperTrade).where(
PaperTrade.book == SHADOW_BOOK, PaperTrade.status == "open"
)
)
return {row[0] for row in result.all()}
return list(result.scalars().all())
async def _shadow_user_id(db: AsyncSession) -> int | None:
@@ -125,32 +166,42 @@ async def _shadow_user_id(db: AsyncSession) -> int | None:
return int(row[0]) if row else None
async def _todays_qualified_setups(db: AsyncSession, config: dict) -> list[TradeSetup]:
"""Latest setup per ticker from the most recent scan, gate-qualified.
async def _todays_qualified_setups(
db: AsyncSession, config: dict, *, now: datetime
) -> list[TradeSetup]:
"""Latest qualified setup per ticker from the scan that just ran.
Freshness is a hard requirement, not a nicety: pipeline steps are allowed to
fail independently, so if the scan is disabled or errors, the newest stored
setups belong to a previous session. Trading those would enter yesterday's
picks at yesterday's prices and quietly corrupt the record. Anything older
than ``MAX_SETUP_AGE`` is treated as "no scan happened".
Ordered by ``strategy_rank`` descending — the ordering the backtest selects
on. Setups without a rank sort last; they cannot be compared to ranked ones.
"""
latest_scan = await db.execute(select(func.max(TradeSetup.detected_at)))
newest = latest_scan.scalar()
if newest is None:
return []
# Everything written by the same scan run (same calendar day, NY-agnostic:
# one qualifying scan per day is a hard invariant of the schedule).
cutoff = now - MAX_SETUP_AGE
result = await db.execute(
select(TradeSetup).where(
func.date(TradeSetup.detected_at) == func.date(newest),
select(TradeSetup).where(TradeSetup.detected_at >= cutoff)
)
)
setups = [s for s in result.scalars() if setup_qualifies(s, config)]
setups.sort(
qualified = [s for s in result.scalars() if setup_qualifies(s, config)]
# One setup per ticker — the most recent wins. A ticker can have several
# rows in a scan (e.g. both directions); ranking over duplicates would let
# one name occupy more than its share of the ordering.
latest: dict[int, TradeSetup] = {}
for setup in qualified:
held = latest.get(setup.ticker_id)
if held is None or setup.detected_at > held.detected_at:
latest[setup.ticker_id] = setup
return sorted(
latest.values(),
key=lambda s: (
s.strategy_rank if s.strategy_rank is not None else float("-inf")
),
reverse=True,
)
return setups
async def open_shadow_positions(
@@ -165,11 +216,18 @@ async def open_shadow_positions(
skip anything already held or locked out by post-stop gate-reset, and stop
at capacity. Returns a summary for the job log.
"""
summary = {"opened": 0, "skipped_held": 0, "skipped_locked": 0, "symbols": []}
summary = {
"opened": 0,
"skipped_held": 0,
"skipped_locked": 0,
"skipped_no_cash": 0,
"symbols": [],
}
config = await get_config(db)
held = await _open_ticker_ids(db)
free_slots = config["capacity"] - len(held)
positions = await _open_positions(db)
held = {p.ticker_id for p in positions}
free_slots = config["capacity"] - len(positions)
if free_slots <= 0:
return summary
@@ -179,10 +237,10 @@ async def open_shadow_positions(
return summary
locks = await get_reentry_gate_locks(db, book=SHADOW_BOOK)
equity = await current_equity(db, config["start_equity"])
equity, cash = await equity_and_cash(db, config["start_equity"], positions)
timestamp = opened_at or datetime.now(timezone.utc)
for setup in await _todays_qualified_setups(db, activation_config):
for setup in await _todays_qualified_setups(db, activation_config, now=timestamp):
if free_slots <= 0:
break
if setup.ticker_id in held:
@@ -194,9 +252,13 @@ async def open_shadow_positions(
entry = float(setup.entry_price or 0.0)
stop = float(setup.stop_loss or 0.0)
shares = position_shares(equity, config["risk_pct"], entry, stop)
shares = position_shares(
equity, config["risk_pct"], entry, stop, cash_available=cash
)
if shares <= 0:
summary["skipped_no_cash"] += 1
continue
cash -= shares * entry
db.add(
PaperTrade(
+12 -4
View File
@@ -43,11 +43,19 @@ def _utcnow() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _utc_today() -> date:
"""The provider clamps against UTC, so the fixture must use the UTC date.
``date.today()`` is local and runs a day ahead in a UTC+hh timezone just
after midnight, which would assert against a day the clamp cannot reach."""
return datetime.now(timezone.utc).date()
@pytest.mark.asyncio
async def test_todays_in_progress_bar_is_inside_the_window():
"""The whole near-close design depends on today's bar being fetchable."""
provider, client = _provider()
today = date.today()
today = _utc_today()
await provider.fetch_ohlcv("AAPL", today - timedelta(days=5), today)
@@ -60,7 +68,7 @@ async def test_window_stays_out_of_the_delayed_data_period():
"""A window reaching the last ~15 minutes fails the entire request."""
provider, client = _provider()
await provider.fetch_ohlcv("AAPL", date.today() - timedelta(days=5), date.today())
await provider.fetch_ohlcv("AAPL", _utc_today() - timedelta(days=5), _utc_today())
assert client.request.end <= _utcnow() - timedelta(minutes=15)
@@ -69,7 +77,7 @@ async def test_window_stays_out_of_the_delayed_data_period():
async def test_completed_past_day_is_fully_covered():
"""Clamping must not swallow the last day of a historical window."""
provider, client = _provider()
end_date = date.today() - timedelta(days=3)
end_date = _utc_today() - timedelta(days=3)
await provider.fetch_ohlcv("AAPL", end_date - timedelta(days=5), end_date)
@@ -80,7 +88,7 @@ async def test_completed_past_day_is_fully_covered():
async def test_window_collapsing_to_nothing_skips_the_call():
"""A start inside the delayed period yields no request at all, not an error."""
provider, client = _provider()
tomorrow = date.today() + timedelta(days=1)
tomorrow = _utc_today() + timedelta(days=2)
records = await provider.fetch_ohlcv("AAPL", tomorrow, tomorrow)
+181
View File
@@ -0,0 +1,181 @@
"""The two books must not leak into each other.
Regression cover for the review of ba2df8b. Each test here pins a way the
shadow book could quietly corrupt the discretionary record — or be corrupted
by it — which would invalidate the comparison the shadow book exists to make.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from app.exceptions import ValidationError
from app.models.paper_trade import PaperTrade
from app.models.ticker import Ticker
from app.models.user import User
from app.services import paper_trade_service as pts
from app.services.trade_policy import (
MANUAL_BOOK,
SHADOW_BOOK,
get_reentry_gate_locks,
observe_reentry_gate_transitions,
)
@pytest.fixture
async def session():
from tests.conftest import _test_session_factory
async with _test_session_factory() as session:
yield session
async def _seed(session) -> int:
session.add(User(id=1, username="owner", password_hash="x"))
session.add(Ticker(id=1, symbol="AAA", name="AAA"))
await session.commit()
return 1
def _trade(*, book, status="open", close_reason=None, closed_at=None, user_id=1):
now = datetime.now(timezone.utc)
return PaperTrade(
user_id=user_id,
ticker_id=1,
direction="long",
entry_price=100.0,
shares=10.0,
stop_loss=95.0,
target=115.0,
status=status,
opened_at=now - timedelta(days=3),
closed_at=closed_at,
close_price=95.0 if status == "closed" else None,
close_reason=close_reason,
book=book,
)
class TestManualEndpoints:
@pytest.mark.asyncio
async def test_list_excludes_shadow_by_default(self, session):
"""Open Positions must not silently mix the autonomous book in."""
await _seed(session)
session.add_all([_trade(book=MANUAL_BOOK), _trade(book=SHADOW_BOOK)])
await session.commit()
rows = await pts.list_trades(session, user_id=1)
assert len(rows) == 1
@pytest.mark.asyncio
async def test_list_can_span_both_books_deliberately(self, session):
await _seed(session)
session.add_all([_trade(book=MANUAL_BOOK), _trade(book=SHADOW_BOOK)])
await session.commit()
rows = await pts.list_trades(session, user_id=1, book=None)
assert len(rows) == 2
@pytest.mark.asyncio
async def test_shadow_trades_cannot_be_closed_by_hand(self, session):
"""A hand-closed shadow trade is no longer what the strategy would do."""
await _seed(session)
trade = _trade(book=SHADOW_BOOK)
session.add(trade)
await session.commit()
with pytest.raises(ValidationError, match="exit policy"):
await pts.close_trade(session, 1, trade.id, 110.0)
class TestGateResetCycle:
@pytest.mark.asyncio
async def test_shadow_stop_completes_fail_then_requalify(self, session):
"""The full cycle must advance for the shadow book, or a stopped ticker
stays locked out forever."""
await _seed(session)
closed = datetime.now(timezone.utc) - timedelta(days=2)
session.add(
_trade(book=SHADOW_BOOK, status="closed", close_reason="stop", closed_at=closed)
)
await session.commit()
assert 1 in await get_reentry_gate_locks(session, book=SHADOW_BOOK)
# Day 1: ticker no longer qualifies → failure observed.
await observe_reentry_gate_transitions(
session,
evaluated_ticker_ids=[1],
qualified_ticker_ids=[],
observed_at=closed + timedelta(days=1),
book=SHADOW_BOOK,
)
assert 1 in await get_reentry_gate_locks(session, book=SHADOW_BOOK)
# Day 2: qualifies again → lock releases.
await observe_reentry_gate_transitions(
session,
evaluated_ticker_ids=[1],
qualified_ticker_ids=[1],
observed_at=closed + timedelta(days=2),
book=SHADOW_BOOK,
)
assert 1 not in await get_reentry_gate_locks(session, book=SHADOW_BOOK)
@pytest.mark.asyncio
async def test_observing_one_book_does_not_move_the_other(self, session):
await _seed(session)
closed = datetime.now(timezone.utc) - timedelta(days=2)
session.add_all(
[
_trade(book=SHADOW_BOOK, status="closed", close_reason="stop", closed_at=closed),
_trade(book=MANUAL_BOOK, status="closed", close_reason="stop", closed_at=closed),
]
)
await session.commit()
await observe_reentry_gate_transitions(
session,
evaluated_ticker_ids=[1],
qualified_ticker_ids=[],
observed_at=closed + timedelta(days=1),
book=SHADOW_BOOK,
)
await observe_reentry_gate_transitions(
session,
evaluated_ticker_ids=[1],
qualified_ticker_ids=[1],
observed_at=closed + timedelta(days=2),
book=SHADOW_BOOK,
)
# Shadow released; manual never observed, so it stays locked.
assert 1 not in await get_reentry_gate_locks(session, book=SHADOW_BOOK)
assert 1 in await get_reentry_gate_locks(session, book=MANUAL_BOOK)
class TestPerformanceScoping:
@pytest.mark.asyncio
async def test_manual_side_is_scoped_to_the_caller(self, session):
"""'Your picks' must not aggregate another user's discretionary book."""
await _seed(session)
session.add(User(id=2, username="other", password_hash="x"))
await session.commit()
session.add_all(
[
_trade(book=MANUAL_BOOK, user_id=1),
_trade(book=MANUAL_BOOK, user_id=2),
_trade(book=SHADOW_BOOK, user_id=1),
]
)
await session.commit()
mine = await pts.list_trades(session, user_id=1)
theirs = await pts.list_trades(session, user_id=2)
assert len(mine) == 1
assert len(theirs) == 1
+23 -13
View File
@@ -16,6 +16,16 @@ from app.services import paper_trade_service as svc
from tests.conftest import _test_session_factory # type: ignore
def _today() -> date:
"""UTC date — trades are stamped in UTC, so fixtures must use it too.
``date.today()`` is local; in a UTC+hh timezone it runs a day ahead between
midnight and the offset, which silently desynchronises bar and benchmark
fixtures from the UTC ``opened_at`` the service reads.
"""
return datetime.now(timezone.utc).date()
@pytest.fixture
async def session():
async with _test_session_factory() as s:
@@ -30,7 +40,7 @@ async def _seed(session, symbol: str, close: float) -> int:
t = Ticker(symbol=symbol)
session.add(t)
await session.flush()
session.add(OHLCVRecord(ticker_id=t.id, date=date.today(),
session.add(OHLCVRecord(ticker_id=t.id, date=_today(),
open=close, high=close, low=close, close=close, volume=1))
await session.commit()
return t.id
@@ -51,7 +61,7 @@ async def test_create_and_list_open(session):
async def test_create_trade_enforces_post_stop_gate_reset_at_service_boundary(session):
blocked_id = await _seed(session, "LOCKQ", close=100.0)
released_id = await _seed(session, "FREEQ", close=100.0)
today = date.today()
today = _today()
def stopped_trade(ticker_id: int, *, gate_reset_complete: bool) -> PaperTrade:
closed_on = today - timedelta(days=10)
@@ -167,7 +177,7 @@ async def test_resolve_closes_on_target(session):
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=10, stop_loss=95.0, target=110.0)
# later bars: a day that trades up through 110
await _add_bars(session, tid, [(103, 101), (111, 108)], start=date.today())
await _add_bars(session, tid, [(103, 101), (111, 108)], start=_today())
closed = await svc.resolve_open_trades(session)
assert closed == 1
await session.refresh(trade)
@@ -180,7 +190,7 @@ async def test_resolve_closes_on_stop(session):
tid = await _seed(session, "AAA", close=100.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=10, stop_loss=95.0, target=110.0)
await _add_bars(session, tid, [(101, 94)], start=date.today()) # low pierces stop
await _add_bars(session, tid, [(101, 94)], start=_today()) # low pierces stop
closed = await svc.resolve_open_trades(session)
assert closed == 1
await session.refresh(trade)
@@ -192,7 +202,7 @@ async def test_resolve_leaves_open_when_neither_hit(session):
tid = await _seed(session, "AAA", close=100.0)
await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=10, stop_loss=95.0, target=110.0)
await _add_bars(session, tid, [(103, 98), (104, 99)], start=date.today()) # range-bound
await _add_bars(session, tid, [(103, 98), (104, 99)], start=_today()) # range-bound
closed = await svc.resolve_open_trades(session)
assert closed == 0
rows = await svc.list_trades(session, 1, status="open")
@@ -217,7 +227,7 @@ async def _add_open_trade(session, ticker_id: int, direction: str, *, entry: flo
async def test_alpha_long_open(session):
tid = await _seed(session, "AAA", close=110.0) # current price 110 → +10% on a 100 entry
today = date.today()
today = _today()
await _seed_benchmark(session, {today - timedelta(days=10): 400.0, today: 420.0}) # SPY +5%
await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10)
@@ -229,7 +239,7 @@ async def test_alpha_long_open(session):
async def test_alpha_short_and_missing_benchmark(session):
tid = await _seed(session, "BBB", close=90.0) # price fell to 90 → short +10%
today = date.today()
today = _today()
await _add_open_trade(session, tid, "short", entry=100.0, shares=4, days_ago=10)
# No benchmark data yet → alpha unset, not an error.
@@ -389,7 +399,7 @@ async def test_resolve_time_mode_closes_at_horizon(session):
tid = await _seed(session, "AAA", close=100.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=10, stop_loss=95.0, target=200.0)
await _add_bars(session, tid, [(103, 101), (105, 102)], start=date.today())
await _add_bars(session, tid, [(103, 101), (105, 102)], start=_today())
assert await svc.resolve_open_trades(session) == 1
await session.refresh(trade)
assert trade.status == "closed"
@@ -402,7 +412,7 @@ async def test_resolve_time_mode_stop_still_governs(session):
tid = await _seed(session, "AAA", close=100.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=10, stop_loss=95.0, target=200.0)
await _add_bars(session, tid, [(101, 94)], start=date.today()) # low pierces the stop
await _add_bars(session, tid, [(101, 94)], start=_today()) # low pierces the stop
assert await svc.resolve_open_trades(session) == 1
await session.refresh(trade)
assert trade.close_reason == "stop"
@@ -413,7 +423,7 @@ async def test_resolve_trailing_closes_with_reason(session):
await svc.set_exit_policy(session, mode="trailing", trailing_pct=12.0)
tid = await _seed(session, "AAA", close=100.0)
await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10)
await _add_bars(session, tid, [(120, 110), (130, 100)], start=date.today()) # run up, pull back
await _add_bars(session, tid, [(120, 110), (130, 100)], start=_today()) # run up, pull back
assert await svc.resolve_open_trades(session) == 1
closed = await svc.list_trades(session, 1, status="closed")
assert closed[0]["close_reason"] == "trailing"
@@ -424,7 +434,7 @@ async def test_resolve_atr_trailing_closes_with_reason(session, monkeypatch):
await svc.set_exit_policy(session, mode="atr_trailing", atr_multiplier=3.0)
tid = await _seed(session, "AAA", close=100.0)
await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10)
await _add_bars(session, tid, [(121, 114), (107, 101)], start=date.today())
await _add_bars(session, tid, [(121, 114), (107, 101)], start=_today())
assert await svc.resolve_open_trades(session) == 1
closed = await svc.list_trades(session, 1, status="closed")
assert closed[0]["close_reason"] == "trailing"
@@ -443,7 +453,7 @@ async def test_list_open_exposes_trailing_stop(session):
await svc.set_exit_policy(session, mode="trailing", trailing_pct=12.0)
tid = await _seed(session, "AAA", close=120.0)
await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10)
await _add_bars(session, tid, [(125, 118)], start=date.today()) # peak 125
await _add_bars(session, tid, [(125, 118)], start=_today()) # peak 125
row = (await svc.list_trades(session, 1, status="open"))[0]
assert row["trailing_stop"] == pytest.approx(110.0) # 125 * (1 - 0.12)
assert row["trailing_distance_pct"] is not None
@@ -454,7 +464,7 @@ async def test_list_open_exposes_atr_trailing_stop(session, monkeypatch):
await svc.set_exit_policy(session, mode="atr_trailing", atr_multiplier=3.0)
tid = await _seed(session, "AAA", close=120.0)
await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10)
await _add_bars(session, tid, [(125, 118)], start=date.today())
await _add_bars(session, tid, [(125, 118)], start=_today())
row = (await svc.list_trades(session, 1, status="open"))[0]
assert row["trailing_stop"] == pytest.approx(106.5) # latest close 121.5 - 3 * 5
assert row["trailing_distance_pct"] is not None
+2
View File
@@ -107,6 +107,7 @@ class TestConfigureScheduler:
"sentiment_collector",
"fundamental_collector",
"rr_scanner",
"shadow_book",
"ticker_universe_sync",
"outcome_evaluator",
"alerts",
@@ -143,5 +144,6 @@ class TestConfigureScheduler:
"outcome_evaluator",
"rr_scanner",
"sentiment_collector",
"shadow_book",
"ticker_universe_sync",
])
+58 -1
View File
@@ -78,6 +78,27 @@ class TestSizing:
def test_zero_risk_distance_takes_no_position(self):
assert shadow_book_service.position_shares(100_000, 1.0, 100.0, 100.0) == 0.0
def test_tight_stop_is_capped_at_the_notional_limit(self):
"""Without the cap, 1% risk on a $0.50 stop is a 2x-equity position."""
shares = shadow_book_service.position_shares(100_000, 1.0, 100.0, 99.5)
# Risk sizing alone wants 2,000 shares ($200k); the 20% cap allows 200.
assert shares == pytest.approx(200.0)
assert shares * 100.0 <= 100_000 * shadow_book_service.NOTIONAL_CAP
def test_cannot_spend_cash_it_does_not_have(self):
shares = shadow_book_service.position_shares(
100_000, 1.0, 100.0, 95.0, cash_available=5_000
)
assert shares == pytest.approx(50.0)
def test_no_cash_means_no_position(self):
assert (
shadow_book_service.position_shares(
100_000, 1.0, 100.0, 95.0, cash_available=0
)
== 0.0
)
class TestSelection:
@pytest.mark.asyncio
@@ -151,6 +172,41 @@ class TestSelection:
assert summary["skipped_locked"] == 1
class TestSetupFreshness:
@pytest.mark.asyncio
async def test_ignores_setups_from_a_previous_session(self, session):
"""If the scan step failed or was disabled, the newest stored setups are
yesterday's — trading them would enter stale picks at stale prices."""
ids = await _seed(session, ["AAA"])
stale = datetime.now(timezone.utc) - timedelta(days=1)
session.add(_setup(ids["AAA"], rank=0.9, detected=stale))
await session.commit()
summary = await shadow_book_service.open_shadow_positions(
session, activation_config=_CONFIG
)
assert summary["opened"] == 0
@pytest.mark.asyncio
async def test_one_position_per_ticker_from_duplicate_setups(self, session):
ids = await _seed(session, ["AAA"])
now = datetime.now(timezone.utc)
session.add_all(
[
_setup(ids["AAA"], rank=0.5, detected=now - timedelta(minutes=30)),
_setup(ids["AAA"], rank=0.9, detected=now),
]
)
await session.commit()
summary = await shadow_book_service.open_shadow_positions(
session, activation_config=_CONFIG
)
assert summary["opened"] == 1
class TestBookIsolation:
@pytest.mark.asyncio
async def test_gate_locks_do_not_leak_between_books(self, session):
@@ -184,6 +240,7 @@ class TestBookIsolation:
)
await session.commit()
equity = await shadow_book_service.current_equity(session, 100_000.0)
equity, cash = await shadow_book_service.equity_and_cash(session, 100_000.0, [])
assert equity == 100_000.0
assert cash == 100_000.0