feat: adopt Phase 3 gate and paper-trade exit policy
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user