feat: adopt Phase 3 gate and paper-trade exit policy
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 56s
Deploy / deploy (push) Successful in 33s

Production strategy change based on the July 2026 backtest: paper trades now default to a 30-trading-day hold with the initial stop (classic momentum hold-and-rerank), while target and trailing exits remain available in Admin. The exit policy API/UI now carries hold_days and close_reason can be 'time'.

The activation confidence floor default is now 0/off because the gate ablation showed it added no per-trade edge while filtering out usable setups. Migration 015 clears stored activation_min_confidence and paper_exit_mode so the new defaults take effect; this intentionally resets Track Record comparability from this deploy.

Verification: 451 backend tests pass, ruff check app/ clean, frontend npm run build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 15:20:34 +02:00
parent 29a61cb2ca
commit 1e82dfad7f
10 changed files with 224 additions and 43 deletions
+7 -5
View File
@@ -39,10 +39,9 @@ SUPPORTED_TICKER_UNIVERSES = {"sp500", "nasdaq100", "nasdaq_all"}
# Track Record's qualified stats. The outcome evaluator deliberately ignores
# these — every setup is evaluated so the gate itself can be validated.
#
# The core test is expected value (in R): probability-weighted asymmetry, so a
# fat-but-improbable target and a likely-but-thin one are both rejected. R:R and
# confidence are floors; high-conviction / clean-read / target-probability are
# optional tighteners (off by default — turn on to be more selective).
# The core selection is cross-sectional 12-1 momentum (top percentile of the
# universe, long-only). R:R and confidence are floors; high-conviction /
# clean-read are optional tighteners (off by default).
_ACTIVATION_FLOAT_KEYS: dict[str, str] = {
"min_momentum_percentile": "activation_min_momentum_percentile",
"min_rr": "activation_min_rr",
@@ -56,7 +55,10 @@ _ACTIVATION_BOOL_KEYS: dict[str, str] = {
ACTIVATION_DEFAULTS: dict[str, float | bool] = {
"min_momentum_percentile": 80.0,
"min_rr": 1.2,
"min_confidence": 55.0,
# 0 = off. The July 2026 gate ablation showed the confidence floor added
# nothing (identical net/trade with it removed, under both exit models)
# while cutting ~25% of qualified trades.
"min_confidence": 0.0,
"require_high_conviction": False,
"exclude_conflicts": False,
# On by default: a NEUTRAL ("no clear setup") recommendation isn't an
+66 -18
View File
@@ -20,19 +20,26 @@ from app.services.outcome_service import (
evaluate_setup_against_bars,
)
# Exit policy for OPEN paper trades (auto-close). "trailing" rides a trailing stop
# (validated as the best exit in the backtest); "target" closes at the setup's
# stop/target. Stored in SystemSetting so it's tunable + transparent in the UI.
# Exit policy for OPEN paper trades (auto-close). "time" holds a fixed number of
# trading days with the initial stop and exits at that day's close the exit the
# July 2026 backtest validated (the classic momentum hold-and-re-rank); "trailing"
# rides a trailing stop; "target" closes at the setup's stop/target. Stored in
# SystemSetting so it's tunable + transparent in the UI.
KEY_EXIT_MODE = "paper_exit_mode"
KEY_TRAILING_PCT = "paper_trailing_pct"
DEFAULT_EXIT_MODE = "trailing"
KEY_HOLD_DAYS = "paper_hold_days"
DEFAULT_EXIT_MODE = "time"
DEFAULT_TRAILING_PCT = 12.0
DEFAULT_HOLD_DAYS = 30
_VALID_EXIT_MODES = ("time", "trailing", "target")
async def get_exit_policy(db: AsyncSession) -> dict:
"""Active auto-exit policy: {'mode': 'trailing'|'target', 'trailing_pct': float}."""
"""Active auto-exit policy:
{'mode': 'time'|'trailing'|'target', 'trailing_pct': float, 'hold_days': int}."""
mode = (await settings_store.get_value(db, KEY_EXIT_MODE, DEFAULT_EXIT_MODE)).strip().lower()
if mode not in ("trailing", "target"):
if mode not in _VALID_EXIT_MODES:
mode = DEFAULT_EXIT_MODE
raw = await settings_store.get_value(db, KEY_TRAILING_PCT, str(DEFAULT_TRAILING_PCT))
try:
@@ -40,22 +47,36 @@ async def get_exit_policy(db: AsyncSession) -> dict:
except (TypeError, ValueError):
pct = DEFAULT_TRAILING_PCT
pct = max(0.5, min(90.0, pct))
return {"mode": mode, "trailing_pct": pct}
raw_days = await settings_store.get_value(db, KEY_HOLD_DAYS, str(DEFAULT_HOLD_DAYS))
try:
hold_days = int(float(raw_days))
except (TypeError, ValueError):
hold_days = DEFAULT_HOLD_DAYS
hold_days = max(2, min(250, hold_days))
return {"mode": mode, "trailing_pct": pct, "hold_days": hold_days}
async def set_exit_policy(
db: AsyncSession, *, mode: str | None = None, trailing_pct: float | None = None
db: AsyncSession,
*,
mode: str | None = None,
trailing_pct: float | None = None,
hold_days: int | None = None,
) -> dict:
"""Persist the auto-exit policy (admin). Validates inputs."""
if mode is not None:
mode = mode.strip().lower()
if mode not in ("trailing", "target"):
raise ValidationError("mode must be 'trailing' or 'target'")
if mode not in _VALID_EXIT_MODES:
raise ValidationError("mode must be 'time', 'trailing' or 'target'")
await settings_store.upsert_setting(db, KEY_EXIT_MODE, mode)
if trailing_pct is not None:
if not 0.5 <= float(trailing_pct) <= 90.0:
raise ValidationError("trailing_pct must be between 0.5 and 90")
await settings_store.upsert_setting(db, KEY_TRAILING_PCT, str(float(trailing_pct)))
if hold_days is not None:
if not 2 <= int(hold_days) <= 250:
raise ValidationError("hold_days must be between 2 and 250")
await settings_store.upsert_setting(db, KEY_HOLD_DAYS, str(int(hold_days)))
await db.commit()
return await get_exit_policy(db)
@@ -101,6 +122,23 @@ async def _max_high_after(db: AsyncSession, ticker_id: int, since: date) -> floa
return float(v) if v is not None else None
def _time_close(
direction: str, init_stop: float, hold_days: int, rows: list[tuple]
) -> tuple[float, date, str] | None:
"""Walk post-entry ``rows`` of (date, open, high, low, close); close at the
initial stop if hit (a gap through it fills at the open, matching the
backtest's fill model), else at the ``hold_days``-th bar's close ('time').
None while neither has happened."""
long = direction == "long"
for i, (d, open_, high, low, close) in enumerate(rows):
if (low <= init_stop) if long else (high >= init_stop):
fill = min(init_stop, open_) if long else max(init_stop, open_)
return float(fill), d, "stop"
if i + 1 >= hold_days:
return float(close), d, "time"
return None
def _trailing_close(
direction: str, entry: float, init_stop: float, trail_frac: float, bars: list[Bar]
) -> tuple[float, date, str] | None:
@@ -297,12 +335,12 @@ async def close_trade(
async def resolve_open_trades(db: AsyncSession) -> int:
"""Auto-close open trades whose stop or target was hit in the daily bars.
"""Auto-close open trades per the active exit policy, from the daily bars.
Walks the bars after each trade's open (same logic as the outcome evaluator).
Target hit → close at the target; stop (or an ambiguous same-bar touch) →
close at the stop. Trades that have hit neither stay open. Returns the count
closed.
Walks the bars after each trade's open. 'time' closes at the initial stop or
the hold_days-th close; 'trailing' at the trailing/initial stop; 'target' at
the setup's target or stop (same logic as the outcome evaluator). Trades that
have hit nothing stay open. Returns the count closed.
"""
result = await db.execute(select(PaperTrade).where(PaperTrade.status == "open"))
open_trades = list(result.scalars().all())
@@ -312,22 +350,32 @@ async def resolve_open_trades(db: AsyncSession) -> int:
policy = await get_exit_policy(db)
mode = policy["mode"]
trail_frac = policy["trailing_pct"] / 100.0
hold_days = policy["hold_days"]
closed = 0
for trade in open_trades:
bars_result = await db.execute(
select(OHLCVRecord.date, OHLCVRecord.high, OHLCVRecord.low)
select(
OHLCVRecord.date, OHLCVRecord.open, OHLCVRecord.high,
OHLCVRecord.low, OHLCVRecord.close,
)
.where(
OHLCVRecord.ticker_id == trade.ticker_id,
OHLCVRecord.date > trade.opened_at.date(),
)
.order_by(OHLCVRecord.date.asc())
)
bars = [Bar(date=d, high=h, low=lo) for d, h, lo in bars_result.all()]
rows = bars_result.all()
bars = [Bar(date=d, high=h, low=lo) for d, _, h, lo, _ in rows]
if not bars:
continue
if mode == "trailing":
if mode == "time":
hit = _time_close(trade.direction, trade.stop_loss, hold_days, rows)
if hit is None:
continue # neither the stop nor the hold horizon reached yet
close_price, close_date, reason = hit
elif mode == "trailing":
hit = _trailing_close(trade.direction, trade.entry_price, trade.stop_loss, trail_frac, bars)
if hit is None:
continue # neither the trailing nor the initial stop reached yet