feat: add five-session post-stop reentry lockdown
This commit is contained in:
@@ -282,6 +282,7 @@ async def _qualified_setups(db: AsyncSession) -> list[dict]:
|
||||
db,
|
||||
live_recommendation=True,
|
||||
exclude_open_trade_tickers=True,
|
||||
exclude_reentry_lockdown_tickers=True,
|
||||
)
|
||||
config = await get_activation_config(db)
|
||||
return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)]
|
||||
|
||||
@@ -94,6 +94,7 @@ from app.services.scoring_service import (
|
||||
compute_technical_from_arrays,
|
||||
)
|
||||
from app.services.sr_service import detect_gate_target_ladder, detect_sr_levels
|
||||
from app.services.trade_policy import REENTRY_LOCKDOWN_SESSIONS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -991,6 +992,68 @@ def _replay_and_signals(
|
||||
)
|
||||
|
||||
|
||||
def _replay_candidates_for_period(
|
||||
symbol: str,
|
||||
columns: tuple,
|
||||
config: dict,
|
||||
activation: dict,
|
||||
benchmark_closes: dict[date, float] | None,
|
||||
start_date: date,
|
||||
) -> list[dict]:
|
||||
"""Slim picklable replay used by local event studies.
|
||||
|
||||
Unlike the full report worker it skips factor-series construction and only
|
||||
evaluates setup dates on or after ``start_date``.
|
||||
"""
|
||||
date_ords, opens, highs, lows, closes, volumes = columns
|
||||
bars = [
|
||||
SimpleNamespace(
|
||||
date=date.fromordinal(o), open=op, high=hi, low=lo, close=cl, volume=vo
|
||||
)
|
||||
for o, op, hi, lo, cl, vo in zip(
|
||||
date_ords, opens, highs, lows, closes, volumes
|
||||
)
|
||||
]
|
||||
candidates: list[dict] = []
|
||||
for i in range(MIN_LOOKBACK - 1, len(bars) - HORIZON, STEP_DAYS):
|
||||
if bars[i].date < start_date:
|
||||
continue
|
||||
window = bars[: i + 1]
|
||||
window_closes = [float(r.close) for r in window]
|
||||
window_dates = [r.date for r in window]
|
||||
residual_momentum = _residual_momentum_12_1(
|
||||
window_dates,
|
||||
window_closes,
|
||||
len(window) - 1,
|
||||
benchmark_closes,
|
||||
)
|
||||
vol_6m = _realized_vol_6m(window_closes, len(window) - 1)
|
||||
iso = bars[i].date.isocalendar()
|
||||
for setup in _window_setups(window, config, activation):
|
||||
if setup["direction"] != "long":
|
||||
continue
|
||||
candidates.append({
|
||||
"symbol": symbol,
|
||||
"date": bars[i].date.isoformat(),
|
||||
"iso_week": (iso[0], iso[1]),
|
||||
"direction": "long",
|
||||
"entry": setup["entry"],
|
||||
"stop": setup["stop"],
|
||||
"target": setup["target"],
|
||||
"rr": setup["rr"],
|
||||
"confidence": setup["confidence"],
|
||||
"primary_prob": setup["primary_prob"],
|
||||
"best_prob": setup["best_prob"],
|
||||
"momentum": setup["momentum"],
|
||||
"residual_momentum": residual_momentum,
|
||||
"vol_6m": vol_6m,
|
||||
"meets_core": setup["meets_core"],
|
||||
"action": setup["action"],
|
||||
"risk_level": setup["risk_level"],
|
||||
})
|
||||
return candidates
|
||||
|
||||
|
||||
def _backtest_worker_count() -> int:
|
||||
"""How many worker processes to replay tickers across. Capped to cpu_count-1
|
||||
so a core stays free for the web server; 1 means sequential."""
|
||||
@@ -1293,9 +1356,17 @@ def _simulate_portfolio(
|
||||
max_positions: int = SIM_MAX_POSITIONS,
|
||||
risk_per_trade: float = SIM_RISK_PER_TRADE,
|
||||
atr_trail_multiplier: float = ATR_TRAIL_MULTIPLIER,
|
||||
reentry_cooldown_days: int = 0,
|
||||
initial_stop_refresh_fn: (
|
||||
Callable[[str, int, float, dict, Any], float | None] | None
|
||||
) = None,
|
||||
post_stop_reentry_fn: (
|
||||
Callable[[str, int, dict, Any], dict | None] | None
|
||||
) = None,
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
include_curve: bool = False,
|
||||
include_trades: bool = False,
|
||||
) -> dict | None:
|
||||
"""Replay the qualified setups as ONE capital-constrained book and report
|
||||
portfolio economics from the daily equity curve (return, CAGR, drawdown,
|
||||
@@ -1309,7 +1380,14 @@ def _simulate_portfolio(
|
||||
runs the ATR trail *and* the S/R take-profit together — the trade ends at
|
||||
whichever comes first. Stops fill at the worse of stop or open (gaps
|
||||
modeled); positions still open at the end are closed at their last mark.
|
||||
Returns None when there is nothing to trade.
|
||||
``reentry_cooldown_days`` blocks a ticker for that many market sessions
|
||||
after an initial-stop loss. Profitable trailing-stop exits do not trigger
|
||||
it. ``initial_stop_refresh_fn`` may supply a lower, point-in-time valid long
|
||||
stop when the active initial stop is touched; the replacement is still
|
||||
checked against the same bar. ``post_stop_reentry_fn`` turns an initial
|
||||
stop-out into a stateful episode and is the only path by which that ticker
|
||||
can re-enter until the callback emits a new candidate. Returns None when
|
||||
there is nothing to trade.
|
||||
"""
|
||||
if qualified_fn is None:
|
||||
def _default_qualified(c: dict) -> bool:
|
||||
@@ -1362,6 +1440,14 @@ def _simulate_portfolio(
|
||||
curve: list[tuple[int, float]] = []
|
||||
trades: list[dict] = []
|
||||
skipped_full = 0
|
||||
skipped_cooldown = 0
|
||||
cooldown_until_index: dict[str, int] = {}
|
||||
stop_refresh_attempts = 0
|
||||
stop_refreshes = 0
|
||||
stop_refresh_same_bar_hits = 0
|
||||
post_stop_states: dict[str, dict] = {}
|
||||
post_stop_events = 0
|
||||
reentry_events: list[dict] = []
|
||||
technical_cache: dict[tuple[str, int], float | None] = {}
|
||||
atr_cache: dict[tuple[str, int], float | None] = {}
|
||||
|
||||
@@ -1426,7 +1512,7 @@ def _simulate_portfolio(
|
||||
atr_cache[key] = None
|
||||
return atr_cache[key]
|
||||
|
||||
def _close_trade(sym: str, fill: float, reason: str) -> None:
|
||||
def _close_trade(sym: str, fill: float, reason: str) -> dict:
|
||||
nonlocal cash
|
||||
pos = positions.pop(sym)
|
||||
proceeds = pos["shares"] * fill
|
||||
@@ -1434,16 +1520,29 @@ def _simulate_portfolio(
|
||||
cash += proceeds - cost
|
||||
risk = pos["entry"] - pos["initial_stop"]
|
||||
trades.append({
|
||||
"symbol": sym,
|
||||
"entry_ord": pos["entry_ord"],
|
||||
"exit_ord": o,
|
||||
"entry": pos["entry"],
|
||||
"initial_stop": pos["initial_stop"],
|
||||
"active_stop": pos["stop"],
|
||||
"fill": fill,
|
||||
"pnl": proceeds - pos["shares"] * pos["entry"] - cost - pos["entry_cost"],
|
||||
"r": (fill - pos["entry"]) / risk if risk > 0 else 0.0,
|
||||
"hold": pos["bars_held"],
|
||||
"reason": reason,
|
||||
"stop_refreshes": pos["stop_refreshes"],
|
||||
"is_reentry": pos["is_reentry"],
|
||||
"reentry_wait_sessions": pos["reentry_wait_sessions"],
|
||||
"transaction_cost": pos["entry_cost"] + cost,
|
||||
})
|
||||
return pos
|
||||
|
||||
def _marked_equity() -> float:
|
||||
return cash + sum(p["shares"] * p["last_close"] for p in positions.values())
|
||||
|
||||
for o in calendar:
|
||||
cooldown_days = max(0, int(reentry_cooldown_days))
|
||||
for calendar_index, o in enumerate(calendar):
|
||||
# 1) exits on today's bars (stop intraday, target intraday, time at close)
|
||||
for sym in list(positions):
|
||||
pos = positions[sym]
|
||||
@@ -1460,8 +1559,42 @@ def _simulate_portfolio(
|
||||
if pos["stop"] > pos["initial_stop"] + 1e-9
|
||||
else "stop"
|
||||
)
|
||||
_close_trade(sym, min(pos["stop"], bar.open), reason)
|
||||
continue
|
||||
survived_refresh = False
|
||||
if reason == "stop" and initial_stop_refresh_fn is not None:
|
||||
stop_refresh_attempts += 1
|
||||
refreshed_stop = initial_stop_refresh_fn(
|
||||
sym, o, float(pos["stop"]), pos, bar
|
||||
)
|
||||
if (
|
||||
refreshed_stop is not None
|
||||
and 0 < float(refreshed_stop) < pos["stop"] - 1e-9
|
||||
):
|
||||
pos["stop"] = float(refreshed_stop)
|
||||
pos["stop_refreshes"] += 1
|
||||
stop_refreshes += 1
|
||||
if bar.low > pos["stop"]:
|
||||
survived_refresh = True
|
||||
else:
|
||||
stop_refresh_same_bar_hits += 1
|
||||
if not survived_refresh:
|
||||
fill = min(pos["stop"], bar.open)
|
||||
closed_pos = _close_trade(sym, fill, reason)
|
||||
if reason == "stop" and cooldown_days:
|
||||
cooldown_until_index[sym] = calendar_index + cooldown_days
|
||||
if reason == "stop" and post_stop_reentry_fn is not None:
|
||||
post_stop_events += 1
|
||||
post_stop_states[sym] = {
|
||||
"stop_ord": o,
|
||||
"stop_calendar_index": calendar_index,
|
||||
"stop_day_high": float(bar.high),
|
||||
"stop_day_low": float(bar.low),
|
||||
"stop_day_close": float(bar.close),
|
||||
"exit_fill": float(fill),
|
||||
"previous_entry": float(closed_pos["entry"]),
|
||||
"previous_stop": float(closed_pos["initial_stop"]),
|
||||
"gate_went_unqualified": False,
|
||||
}
|
||||
continue
|
||||
if exit_policy in ("target", "atr_trail3_target") and pos["target"] and bar.high >= pos["target"]:
|
||||
_close_trade(sym, pos["target"], "target")
|
||||
continue
|
||||
@@ -1493,8 +1626,29 @@ def _simulate_portfolio(
|
||||
|
||||
# 2) entries at today's close, best momentum first
|
||||
equity = _marked_equity()
|
||||
fixed_todays = list(entries_by_ord.get(o, ()))
|
||||
reentry_todays: list[dict] = []
|
||||
if post_stop_reentry_fn is not None:
|
||||
fixed_todays = [
|
||||
candidate
|
||||
for candidate in fixed_todays
|
||||
if candidate["symbol"] not in post_stop_states
|
||||
]
|
||||
for sym, state in list(post_stop_states.items()):
|
||||
bar = _bar(sym, o)
|
||||
if bar is None:
|
||||
continue
|
||||
state["sessions_since_stop"] = (
|
||||
calendar_index - state["stop_calendar_index"]
|
||||
)
|
||||
candidate = post_stop_reentry_fn(sym, o, state, bar)
|
||||
if candidate is None:
|
||||
continue
|
||||
tagged = dict(candidate)
|
||||
tagged["_post_stop_reentry"] = True
|
||||
reentry_todays.append(tagged)
|
||||
todays = sorted(
|
||||
entries_by_ord.get(o, ()),
|
||||
fixed_todays + reentry_todays,
|
||||
key=lambda c: c.get(ranking_key) or 0.0,
|
||||
reverse=True,
|
||||
)
|
||||
@@ -1502,6 +1656,9 @@ def _simulate_portfolio(
|
||||
sym = c["symbol"]
|
||||
if sym in positions:
|
||||
continue
|
||||
if calendar_index < cooldown_until_index.get(sym, -1):
|
||||
skipped_cooldown += 1
|
||||
continue
|
||||
if len(positions) >= max_positions:
|
||||
skipped_full += 1
|
||||
continue
|
||||
@@ -1518,9 +1675,23 @@ def _simulate_portfolio(
|
||||
continue
|
||||
entry_cost = shares * entry * COST_PER_SIDE
|
||||
cash -= shares * entry + entry_cost
|
||||
is_reentry = bool(c.get("_post_stop_reentry"))
|
||||
reentry_wait_sessions: int | None = None
|
||||
if is_reentry:
|
||||
state = post_stop_states.pop(sym, None)
|
||||
if state is not None:
|
||||
reentry_wait_sessions = int(state["sessions_since_stop"])
|
||||
reentry_events.append({
|
||||
"symbol": sym,
|
||||
"stop_ord": state["stop_ord"],
|
||||
"reentry_ord": o,
|
||||
"wait_sessions": reentry_wait_sessions,
|
||||
"reason": c.get("_reentry_reason"),
|
||||
})
|
||||
positions[sym] = {
|
||||
"shares": shares,
|
||||
"entry": entry,
|
||||
"entry_ord": o,
|
||||
"initial_stop": stop,
|
||||
"stop": stop,
|
||||
"target": float(c["target"]) if c.get("target") else None,
|
||||
@@ -1528,6 +1699,9 @@ def _simulate_portfolio(
|
||||
"bars_held": 0,
|
||||
"last_close": entry,
|
||||
"highest_close": entry,
|
||||
"stop_refreshes": 0,
|
||||
"is_reentry": is_reentry,
|
||||
"reentry_wait_sessions": reentry_wait_sessions,
|
||||
}
|
||||
equity = _marked_equity()
|
||||
|
||||
@@ -1659,6 +1833,42 @@ def _simulate_portfolio(
|
||||
result["equity_curve"] = curve_payload
|
||||
if benchmark_payload is not None:
|
||||
result["benchmark_curve"] = benchmark_payload
|
||||
if cooldown_days:
|
||||
result["reentry_cooldown_days"] = cooldown_days
|
||||
result["skipped_cooldown"] = skipped_cooldown
|
||||
if initial_stop_refresh_fn is not None:
|
||||
result["stop_refresh_attempts"] = stop_refresh_attempts
|
||||
result["stop_refreshes"] = stop_refreshes
|
||||
result["stop_refresh_same_bar_hits"] = stop_refresh_same_bar_hits
|
||||
if post_stop_reentry_fn is not None:
|
||||
result["post_stop_events"] = post_stop_events
|
||||
result["post_stop_reentries"] = len(reentry_events)
|
||||
result["post_stop_states_open_at_end"] = len(post_stop_states)
|
||||
result["reentry_events"] = [
|
||||
{
|
||||
**{
|
||||
key: value
|
||||
for key, value in event.items()
|
||||
if key not in {"stop_ord", "reentry_ord"}
|
||||
},
|
||||
"stop_date": date.fromordinal(event["stop_ord"]).isoformat(),
|
||||
"reentry_date": date.fromordinal(event["reentry_ord"]).isoformat(),
|
||||
}
|
||||
for event in reentry_events
|
||||
]
|
||||
if include_trades:
|
||||
result["trade_details"] = [
|
||||
{
|
||||
**{
|
||||
key: value
|
||||
for key, value in trade.items()
|
||||
if key not in {"entry_ord", "exit_ord"}
|
||||
},
|
||||
"entry_date": date.fromordinal(trade["entry_ord"]).isoformat(),
|
||||
"exit_date": date.fromordinal(trade["exit_ord"]).isoformat(),
|
||||
}
|
||||
for trade in trades
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
@@ -2019,13 +2229,15 @@ PORTFOLIO_MONITOR_STRATEGIES: tuple[dict, ...] = (
|
||||
},
|
||||
{
|
||||
"strategy": PRODUCTION_PORTFOLIO_STRATEGY,
|
||||
"label": "Production: residual/high-vol 80/20 + 3x ATR trail",
|
||||
"label": "Production: residual/high-vol 80/20 + 3x ATR trail + 5-session lockdown",
|
||||
"description": (
|
||||
"The live strategy: production activation gate and Admin exit policy "
|
||||
"as currently configured, 80/20 residual/high-vol rank."
|
||||
"as currently configured, 80/20 residual/high-vol rank, and a "
|
||||
"five-session re-entry lockdown after an initial-stop exit."
|
||||
),
|
||||
"entry_variant": "residual80_highvol_blend80_20_fixed10",
|
||||
"exit_policy": "atr_trail3",
|
||||
"reentry_lockdown_sessions": REENTRY_LOCKDOWN_SESSIONS,
|
||||
# The production row replays what the platform actually does right now:
|
||||
# the live qualification flag (runtime Admin activation settings) and the
|
||||
# live Admin exit policy, instead of the frozen research-variant gate.
|
||||
@@ -2149,6 +2361,9 @@ def _min_rr_sweep(
|
||||
exit_policy = str(strategy["exit_policy"])
|
||||
row_hold_days = hold_days
|
||||
trail_multiplier = ATR_TRAIL_MULTIPLIER
|
||||
reentry_lockdown_sessions = int(
|
||||
strategy.get("reentry_lockdown_sessions", 0)
|
||||
)
|
||||
if strategy.get("use_live_config") and live_exit_policy is not None:
|
||||
exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
|
||||
str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3"
|
||||
@@ -2188,6 +2403,7 @@ def _min_rr_sweep(
|
||||
max_positions=int(entry_cfg["max_positions"]),
|
||||
risk_per_trade=float(entry_cfg["risk_per_trade"]),
|
||||
atr_trail_multiplier=trail_multiplier,
|
||||
reentry_cooldown_days=reentry_lockdown_sessions,
|
||||
start_date=sweep_start,
|
||||
)
|
||||
if sim is None:
|
||||
@@ -2212,6 +2428,7 @@ def _min_rr_sweep(
|
||||
"live_qualified_setups": live_qualified,
|
||||
"reproduces_production_gate": reproduces,
|
||||
"exit_policy": exit_policy,
|
||||
"reentry_lockdown_sessions": reentry_lockdown_sessions,
|
||||
"entries_from": sweep_start.isoformat() if sweep_start else None,
|
||||
"window": "out-of-sample (test)" if sweep_start else "full history (in-sample)",
|
||||
"rows": rows,
|
||||
@@ -2267,6 +2484,9 @@ def _holdout_evaluation(
|
||||
exit_policy = str(strategy["exit_policy"])
|
||||
row_hold_days = hold_days
|
||||
trail_multiplier = ATR_TRAIL_MULTIPLIER
|
||||
reentry_lockdown_sessions = int(
|
||||
strategy.get("reentry_lockdown_sessions", 0)
|
||||
)
|
||||
if strategy.get("use_live_config") and live_exit_policy is not None:
|
||||
exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
|
||||
str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3"
|
||||
@@ -2296,6 +2516,7 @@ def _holdout_evaluation(
|
||||
max_positions=int(entry_cfg["max_positions"]),
|
||||
risk_per_trade=float(entry_cfg["risk_per_trade"]),
|
||||
atr_trail_multiplier=trail_multiplier,
|
||||
reentry_cooldown_days=reentry_lockdown_sessions,
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
include_curve=True,
|
||||
@@ -2307,6 +2528,7 @@ def _holdout_evaluation(
|
||||
return {
|
||||
"split_date": split.isoformat(),
|
||||
"strategy": strategy["strategy"],
|
||||
"reentry_lockdown_sessions": reentry_lockdown_sessions,
|
||||
"rows": rows,
|
||||
"note": (
|
||||
"Train = entries before the split; test = entries on/after it. The two "
|
||||
@@ -2339,6 +2561,9 @@ def _portfolio_monitor(
|
||||
# policy. The overlay opts into this deliberately so only ordering
|
||||
# changes relative to the production row.
|
||||
use_live = bool(strategy.get("use_live_config"))
|
||||
reentry_lockdown_sessions = int(
|
||||
strategy.get("reentry_lockdown_sessions", 0)
|
||||
)
|
||||
exit_policy = str(strategy["exit_policy"])
|
||||
row_hold_days = hold_days
|
||||
trail_multiplier = ATR_TRAIL_MULTIPLIER
|
||||
@@ -2367,6 +2592,7 @@ def _portfolio_monitor(
|
||||
max_positions=int(entry_cfg["max_positions"]),
|
||||
risk_per_trade=float(entry_cfg["risk_per_trade"]),
|
||||
atr_trail_multiplier=trail_multiplier,
|
||||
reentry_cooldown_days=reentry_lockdown_sessions,
|
||||
start_date=start,
|
||||
include_curve=True,
|
||||
)
|
||||
@@ -2381,6 +2607,7 @@ def _portfolio_monitor(
|
||||
"ranking_key": ranking_key,
|
||||
"exit_policy": exit_policy,
|
||||
"live_exit_mode": live_exit_mode,
|
||||
"reentry_lockdown_sessions": reentry_lockdown_sessions,
|
||||
"lookback": lookback["lookback"],
|
||||
"lookback_label": lookback["label"],
|
||||
**sim,
|
||||
@@ -2393,6 +2620,9 @@ def _portfolio_monitor(
|
||||
"label": s["label"],
|
||||
"description": s["description"],
|
||||
"is_production": bool(s.get("is_production")),
|
||||
"reentry_lockdown_sessions": int(
|
||||
s.get("reentry_lockdown_sessions", 0)
|
||||
),
|
||||
}
|
||||
for s in strategies
|
||||
],
|
||||
@@ -2405,7 +2635,8 @@ def _portfolio_monitor(
|
||||
"Portfolio monitor runs supported named strategies across cached lookbacks. "
|
||||
"The structural overlay appears only in its explicit research arm and changes "
|
||||
"ordering, not production qualification. Local snapshot backtests remain the "
|
||||
"research surface for broad variant sweeps."
|
||||
"research surface for broad variant sweeps. The production row applies the "
|
||||
"same five-session post-initial-stop re-entry lockdown as the live setup list."
|
||||
),
|
||||
}
|
||||
|
||||
@@ -2618,7 +2849,8 @@ def _build_recommendation(report: dict) -> dict:
|
||||
if production_row is not None:
|
||||
headline = (
|
||||
"Production baseline: residual/high-vol 80/20 entry rank with a "
|
||||
"3x ATR trailing exit and 30-trading-day max hold."
|
||||
"3x ATR trailing exit, 30-trading-day max hold, and 5-session "
|
||||
"re-entry lockdown after an initial stop."
|
||||
)
|
||||
if (
|
||||
production_row.get("cagr_pct") is not None
|
||||
@@ -2994,6 +3226,7 @@ async def run_backtest(
|
||||
"target_model": target_model,
|
||||
"target_model_label": BACKTEST_TARGET_MODELS[target_model],
|
||||
"is_production_target_model": target_model == PRODUCTION_GTL_TARGET_MODEL,
|
||||
"production_reentry_lockdown_sessions": REENTRY_LOCKDOWN_SESSIONS,
|
||||
},
|
||||
"activation": activation,
|
||||
"overall_qualified": _bucket_stats(qualified),
|
||||
|
||||
@@ -29,6 +29,7 @@ from app.models.trade_setup import TradeSetup
|
||||
from app.services.indicator_service import _extract_ohlcv, compute_atr
|
||||
from app.services.price_service import query_ohlcv
|
||||
from app.services.sr_service import detect_gate_target_ladder
|
||||
from app.services.trade_policy import get_reentry_lockdown_ticker_ids
|
||||
from app.services.recommendation_service import (
|
||||
_risk_level_from_conflicts,
|
||||
build_recommendation_snapshot,
|
||||
@@ -771,6 +772,7 @@ async def get_trade_setups(
|
||||
symbol: str | None = None,
|
||||
live_recommendation: bool = False,
|
||||
exclude_open_trade_tickers: bool = False,
|
||||
exclude_reentry_lockdown_tickers: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Get latest stored trade setups, optionally filtered.
|
||||
|
||||
@@ -794,15 +796,20 @@ async def get_trade_setups(
|
||||
stmt = stmt.where(TradeSetup.confidence_score >= min_confidence)
|
||||
if recommended_action is not None and not live_recommendation:
|
||||
stmt = stmt.where(TradeSetup.recommended_action == recommended_action)
|
||||
excluded_ticker_ids: set[int] = set()
|
||||
if exclude_open_trade_tickers:
|
||||
open_trade_result = await db.execute(
|
||||
select(PaperTrade.ticker_id)
|
||||
.where(PaperTrade.status == "open")
|
||||
.distinct()
|
||||
)
|
||||
open_ticker_ids = {ticker_id for ticker_id, in open_trade_result.all()}
|
||||
if open_ticker_ids:
|
||||
stmt = stmt.where(~TradeSetup.ticker_id.in_(open_ticker_ids))
|
||||
excluded_ticker_ids.update(
|
||||
ticker_id for ticker_id, in open_trade_result.all()
|
||||
)
|
||||
if exclude_reentry_lockdown_tickers:
|
||||
excluded_ticker_ids.update(await get_reentry_lockdown_ticker_ids(db))
|
||||
if excluded_ticker_ids:
|
||||
stmt = stmt.where(~TradeSetup.ticker_id.in_(excluded_ticker_ids))
|
||||
|
||||
stmt = stmt.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc())
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Shared live/backtest trading-policy constants and availability checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.paper_trade import PaperTrade
|
||||
|
||||
# A ticker stopped at its initial stop may qualify again immediately, but the
|
||||
# July 2026 event study showed that waiting five market sessions materially
|
||||
# improved the production book. The stop session is wait_session=0; the first
|
||||
# permitted re-entry is wait_session=5, provided the normal gate still passes.
|
||||
REENTRY_LOCKDOWN_SESSIONS = 5
|
||||
|
||||
|
||||
async def get_reentry_lockdown_ticker_ids(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
as_of: date | None = None,
|
||||
sessions: int = REENTRY_LOCKDOWN_SESSIONS,
|
||||
) -> set[int]:
|
||||
"""Ticker ids still inside the post-initial-stop market-session lockdown.
|
||||
|
||||
The market calendar is derived from stored OHLCV dates, not calendar days.
|
||||
A stop on session D is released once five later stored sessions exist. Only
|
||||
an initial-stop close (``close_reason == "stop"``) starts the lockdown;
|
||||
trailing, target, time, and manual exits do not.
|
||||
"""
|
||||
sessions = max(0, int(sessions))
|
||||
if sessions == 0:
|
||||
return set()
|
||||
|
||||
session_cutoff = as_of or datetime.now(timezone.utc).date()
|
||||
session_result = await db.execute(
|
||||
select(OHLCVRecord.date)
|
||||
.where(OHLCVRecord.date <= session_cutoff)
|
||||
.distinct()
|
||||
.order_by(OHLCVRecord.date.desc())
|
||||
.limit(sessions)
|
||||
)
|
||||
recent_sessions = [row[0] for row in session_result.all()]
|
||||
if not recent_sessions:
|
||||
return set()
|
||||
|
||||
# Stops on or after the oldest of the latest N sessions have fewer than N
|
||||
# later completed sessions. Once that oldest session rolls forward, the
|
||||
# corresponding stop automatically leaves the result set.
|
||||
lockdown_threshold = min(recent_sessions)
|
||||
threshold_start = datetime.combine(
|
||||
lockdown_threshold,
|
||||
time.min,
|
||||
tzinfo=timezone.utc,
|
||||
)
|
||||
result = await db.execute(
|
||||
select(PaperTrade.ticker_id)
|
||||
.where(
|
||||
PaperTrade.status == "closed",
|
||||
PaperTrade.close_reason == "stop",
|
||||
PaperTrade.closed_at.is_not(None),
|
||||
PaperTrade.closed_at >= threshold_start,
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
return {ticker_id for ticker_id, in result.all()}
|
||||
Reference in New Issue
Block a user