feat: require gate reset before post-stop reentry
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
"""add persistent post-stop gate-reset observation
|
||||||
|
|
||||||
|
Revision ID: 022
|
||||||
|
Revises: 021
|
||||||
|
Create Date: 2026-07-17 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "022"
|
||||||
|
down_revision: Union[str, None] = "021"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"paper_trades",
|
||||||
|
sa.Column("reentry_gate_failed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"paper_trades",
|
||||||
|
sa.Column(
|
||||||
|
"reentry_gate_requalified_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("paper_trades", "reentry_gate_requalified_at")
|
||||||
|
op.drop_column("paper_trades", "reentry_gate_failed_at")
|
||||||
@@ -36,3 +36,13 @@ class PaperTrade(Base):
|
|||||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
# How the trade was closed: "time" | "trailing" | "stop" | "target" | "manual".
|
# How the trade was closed: "time" | "trailing" | "stop" | "target" | "manual".
|
||||||
close_reason: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
close_reason: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||||
|
# A trade stopped at its initial stop starts a re-entry gate-reset episode.
|
||||||
|
# The daily full-universe scanner records both state transitions: the first
|
||||||
|
# failed gate observation and a later fresh qualification. Re-entry remains
|
||||||
|
# non-actionable until both timestamps exist.
|
||||||
|
reentry_gate_failed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
reentry_gate_requalified_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ async def list_trade_setups(
|
|||||||
recommended_action=recommended_action,
|
recommended_action=recommended_action,
|
||||||
live_recommendation=True,
|
live_recommendation=True,
|
||||||
exclude_open_trade_tickers=True,
|
exclude_open_trade_tickers=True,
|
||||||
exclude_reentry_lockdown_tickers=True,
|
exclude_reentry_gate_locked_tickers=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
data = []
|
data = []
|
||||||
@@ -99,7 +99,7 @@ async def get_ticker_trade_setups(
|
|||||||
db,
|
db,
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
live_recommendation=True,
|
live_recommendation=True,
|
||||||
include_reentry_lockdown=True,
|
include_reentry_gate_lock=True,
|
||||||
)
|
)
|
||||||
data = []
|
data = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
|||||||
@@ -59,6 +59,6 @@ class TradeSetupResponse(BaseModel):
|
|||||||
momentum_percentile: float | None = None
|
momentum_percentile: float | None = None
|
||||||
strategy_rank: float | None = None
|
strategy_rank: float | None = None
|
||||||
volatility_percentile: float | None = None
|
volatility_percentile: float | None = None
|
||||||
reentry_lockdown_remaining_sessions: int | None = None
|
reentry_gate_reset_required: bool = False
|
||||||
context_as_of: TradeSetupContextAsOfResponse | None = None
|
context_as_of: TradeSetupContextAsOfResponse | None = None
|
||||||
recommendation_summary: RecommendationSummaryResponse | None = None
|
recommendation_summary: RecommendationSummaryResponse | None = None
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ async def _qualified_setups(db: AsyncSession) -> list[dict]:
|
|||||||
db,
|
db,
|
||||||
live_recommendation=True,
|
live_recommendation=True,
|
||||||
exclude_open_trade_tickers=True,
|
exclude_open_trade_tickers=True,
|
||||||
exclude_reentry_lockdown_tickers=True,
|
exclude_reentry_gate_locked_tickers=True,
|
||||||
)
|
)
|
||||||
config = await get_activation_config(db)
|
config = await get_activation_config(db)
|
||||||
return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)]
|
return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)]
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ from app.services.scoring_service import (
|
|||||||
compute_technical_from_arrays,
|
compute_technical_from_arrays,
|
||||||
)
|
)
|
||||||
from app.services.sr_service import detect_gate_target_ladder, detect_sr_levels
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -103,6 +102,7 @@ KEY_REPORT = "backtest_report"
|
|||||||
WEEKLY_BACKTEST_CADENCE = "weekly"
|
WEEKLY_BACKTEST_CADENCE = "weekly"
|
||||||
DAILY_BACKTEST_CADENCE = "daily"
|
DAILY_BACKTEST_CADENCE = "daily"
|
||||||
DEFAULT_BACKTEST_CADENCE = WEEKLY_BACKTEST_CADENCE
|
DEFAULT_BACKTEST_CADENCE = WEEKLY_BACKTEST_CADENCE
|
||||||
|
PRODUCTION_REENTRY_POLICY = "gate_reset"
|
||||||
BACKTEST_CADENCE_SESSIONS = {
|
BACKTEST_CADENCE_SESSIONS = {
|
||||||
WEEKLY_BACKTEST_CADENCE: 5,
|
WEEKLY_BACKTEST_CADENCE: 5,
|
||||||
DAILY_BACKTEST_CADENCE: 1,
|
DAILY_BACKTEST_CADENCE: 1,
|
||||||
@@ -1429,6 +1429,72 @@ LIVE_EXIT_MODE_TO_SIM = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_gate_reset_reentry_fn(
|
||||||
|
candidates: list[dict],
|
||||||
|
prices: dict[str, tuple],
|
||||||
|
*,
|
||||||
|
cadence: str,
|
||||||
|
qualified_fn: Callable[[dict], bool] | None = None,
|
||||||
|
ranking_key: str = PRODUCTION_PERCENTILE_KEY,
|
||||||
|
) -> Callable[[str, int, dict, Any], dict | None]:
|
||||||
|
"""Build the production post-stop gate-reset callback.
|
||||||
|
|
||||||
|
Missing candidates count as a gate failure only on dates on which that
|
||||||
|
ticker was actually evaluated at the selected replay cadence. This keeps a
|
||||||
|
weekly backtest from treating the four non-evaluation sessions between two
|
||||||
|
weekly observations as false gate exits.
|
||||||
|
"""
|
||||||
|
cadence = validate_backtest_cadence(cadence)
|
||||||
|
if qualified_fn is None:
|
||||||
|
def _default_qualified(candidate: dict) -> bool:
|
||||||
|
return bool(candidate.get("qualified"))
|
||||||
|
|
||||||
|
qualified_fn = _default_qualified
|
||||||
|
|
||||||
|
evaluation_ords: dict[str, set[int]] = {}
|
||||||
|
step_sessions = backtest_step_sessions(cadence)
|
||||||
|
for symbol, columns in prices.items():
|
||||||
|
ordinals = columns[0]
|
||||||
|
evaluation_ords[symbol] = {
|
||||||
|
int(ordinals[index])
|
||||||
|
for index in range(MIN_LOOKBACK - 1, len(ordinals) - HORIZON, step_sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
qualified_by_symbol_date: dict[tuple[str, int], dict] = {}
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate.get("direction") != "long" or not qualified_fn(candidate):
|
||||||
|
continue
|
||||||
|
key = (
|
||||||
|
str(candidate["symbol"]),
|
||||||
|
date.fromisoformat(str(candidate["date"])).toordinal(),
|
||||||
|
)
|
||||||
|
previous = qualified_by_symbol_date.get(key)
|
||||||
|
if previous is None or float(candidate.get(ranking_key) or 0.0) > float(
|
||||||
|
previous.get(ranking_key) or 0.0
|
||||||
|
):
|
||||||
|
qualified_by_symbol_date[key] = candidate
|
||||||
|
|
||||||
|
def _gate_reset(
|
||||||
|
symbol: str,
|
||||||
|
asof_ord: int,
|
||||||
|
state: dict,
|
||||||
|
_bar: Any,
|
||||||
|
) -> dict | None:
|
||||||
|
if asof_ord not in evaluation_ords.get(symbol, set()):
|
||||||
|
return None
|
||||||
|
candidate = qualified_by_symbol_date.get((symbol, asof_ord))
|
||||||
|
if candidate is None:
|
||||||
|
state["gate_went_unqualified"] = True
|
||||||
|
return None
|
||||||
|
if not state.get("gate_went_unqualified"):
|
||||||
|
return None
|
||||||
|
emitted = dict(candidate)
|
||||||
|
emitted["_reentry_reason"] = "gate_failed_then_requalified"
|
||||||
|
return emitted
|
||||||
|
|
||||||
|
return _gate_reset
|
||||||
|
|
||||||
|
|
||||||
def _simulate_portfolio(
|
def _simulate_portfolio(
|
||||||
candidates: list[dict],
|
candidates: list[dict],
|
||||||
prices: dict[str, tuple],
|
prices: dict[str, tuple],
|
||||||
@@ -2325,35 +2391,35 @@ PORTFOLIO_MONITOR_STRATEGIES: tuple[dict, ...] = (
|
|||||||
"exit_policy": "hold",
|
"exit_policy": "hold",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"strategy": "production_live_no_lockdown",
|
"strategy": "production_live_immediate",
|
||||||
"label": "Live setup + 3x ATR trail (no re-entry lockdown)",
|
"label": "Live setup + 3x ATR trail (immediate re-entry)",
|
||||||
"description": (
|
"description": (
|
||||||
"Exact live activation, ordering, and Admin exit policy, with only "
|
"Exact live activation, ordering, and Admin exit policy, with only "
|
||||||
"the post-stop re-entry lockdown disabled as the comparison baseline."
|
"the post-stop gate reset disabled as the comparison baseline."
|
||||||
),
|
),
|
||||||
"entry_variant": "residual80_highvol_blend80_20_fixed10",
|
"entry_variant": "residual80_highvol_blend80_20_fixed10",
|
||||||
"exit_policy": "atr_trail3",
|
"exit_policy": "atr_trail3",
|
||||||
"reentry_lockdown_sessions": 0,
|
"reentry_policy": "immediate",
|
||||||
"use_live_config": True,
|
"use_live_config": True,
|
||||||
"comparison_arm": "live_no_lockdown",
|
"comparison_arm": "live_immediate",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"strategy": PRODUCTION_PORTFOLIO_STRATEGY,
|
"strategy": PRODUCTION_PORTFOLIO_STRATEGY,
|
||||||
"label": "Production: residual/high-vol 80/20 + 3x ATR trail + 5-session lockdown",
|
"label": "Production: residual/high-vol 80/20 + 3x ATR trail + gate reset",
|
||||||
"description": (
|
"description": (
|
||||||
"The live strategy: production activation gate and Admin exit policy "
|
"The live strategy: production activation gate and Admin exit policy "
|
||||||
"as currently configured, 80/20 residual/high-vol rank, and a "
|
"as currently configured, 80/20 residual/high-vol rank, and re-entry "
|
||||||
"five-session re-entry lockdown after an initial-stop exit."
|
"only after the gate fails and later qualifies again."
|
||||||
),
|
),
|
||||||
"entry_variant": "residual80_highvol_blend80_20_fixed10",
|
"entry_variant": "residual80_highvol_blend80_20_fixed10",
|
||||||
"exit_policy": "atr_trail3",
|
"exit_policy": "atr_trail3",
|
||||||
"reentry_lockdown_sessions": REENTRY_LOCKDOWN_SESSIONS,
|
"reentry_policy": PRODUCTION_REENTRY_POLICY,
|
||||||
# The production row replays what the platform actually does right now:
|
# The production row replays what the platform actually does right now:
|
||||||
# the live qualification flag (runtime Admin activation settings) and the
|
# the live qualification flag (runtime Admin activation settings) and the
|
||||||
# live Admin exit policy, instead of the frozen research-variant gate.
|
# live Admin exit policy, instead of the frozen research-variant gate.
|
||||||
"use_live_config": True,
|
"use_live_config": True,
|
||||||
"is_production": True,
|
"is_production": True,
|
||||||
"comparison_arm": "live_lockdown_5",
|
"comparison_arm": "live_gate_reset",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2456,6 +2522,7 @@ def _min_rr_sweep(
|
|||||||
threshold: float,
|
threshold: float,
|
||||||
hold_days: int,
|
hold_days: int,
|
||||||
live_exit_policy: dict | None = None,
|
live_exit_policy: dict | None = None,
|
||||||
|
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Portfolio economics of the production book at each R:R floor.
|
"""Portfolio economics of the production book at each R:R floor.
|
||||||
|
|
||||||
@@ -2472,9 +2539,7 @@ def _min_rr_sweep(
|
|||||||
exit_policy = str(strategy["exit_policy"])
|
exit_policy = str(strategy["exit_policy"])
|
||||||
row_hold_days = hold_days
|
row_hold_days = hold_days
|
||||||
trail_multiplier = ATR_TRAIL_MULTIPLIER
|
trail_multiplier = ATR_TRAIL_MULTIPLIER
|
||||||
reentry_lockdown_sessions = int(
|
reentry_policy = str(strategy.get("reentry_policy", "immediate"))
|
||||||
strategy.get("reentry_lockdown_sessions", 0)
|
|
||||||
)
|
|
||||||
if strategy.get("use_live_config") and live_exit_policy is not None:
|
if strategy.get("use_live_config") and live_exit_policy is not None:
|
||||||
exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
|
exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
|
||||||
str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3"
|
str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3"
|
||||||
@@ -2514,7 +2579,19 @@ def _min_rr_sweep(
|
|||||||
max_positions=int(entry_cfg["max_positions"]),
|
max_positions=int(entry_cfg["max_positions"]),
|
||||||
risk_per_trade=float(entry_cfg["risk_per_trade"]),
|
risk_per_trade=float(entry_cfg["risk_per_trade"]),
|
||||||
atr_trail_multiplier=trail_multiplier,
|
atr_trail_multiplier=trail_multiplier,
|
||||||
reentry_cooldown_sessions=reentry_lockdown_sessions,
|
post_stop_reentry_fn=(
|
||||||
|
_make_gate_reset_reentry_fn(
|
||||||
|
candidates,
|
||||||
|
prices,
|
||||||
|
cadence=cadence,
|
||||||
|
qualified_fn=qualified_fn,
|
||||||
|
ranking_key=str(
|
||||||
|
entry_cfg.get("ranking_key") or entry_cfg["percentile_key"]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if reentry_policy == "gate_reset"
|
||||||
|
else None
|
||||||
|
),
|
||||||
start_date=sweep_start,
|
start_date=sweep_start,
|
||||||
)
|
)
|
||||||
if sim is None:
|
if sim is None:
|
||||||
@@ -2539,7 +2616,7 @@ def _min_rr_sweep(
|
|||||||
"live_qualified_setups": live_qualified,
|
"live_qualified_setups": live_qualified,
|
||||||
"reproduces_production_gate": reproduces,
|
"reproduces_production_gate": reproduces,
|
||||||
"exit_policy": exit_policy,
|
"exit_policy": exit_policy,
|
||||||
"reentry_lockdown_sessions": reentry_lockdown_sessions,
|
"reentry_policy": reentry_policy,
|
||||||
"entries_from": sweep_start.isoformat() if sweep_start else None,
|
"entries_from": sweep_start.isoformat() if sweep_start else None,
|
||||||
"window": "out-of-sample (test)" if sweep_start else "full history (in-sample)",
|
"window": "out-of-sample (test)" if sweep_start else "full history (in-sample)",
|
||||||
"rows": rows,
|
"rows": rows,
|
||||||
@@ -2573,6 +2650,7 @@ def _holdout_evaluation(
|
|||||||
hold_days: int,
|
hold_days: int,
|
||||||
split: date,
|
split: date,
|
||||||
live_exit_policy: dict | None = None,
|
live_exit_policy: dict | None = None,
|
||||||
|
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""The production strategy simulated on entries BEFORE the split (train) and
|
"""The production strategy simulated on entries BEFORE the split (train) and
|
||||||
on entries ON/AFTER it (test), as separate books.
|
on entries ON/AFTER it (test), as separate books.
|
||||||
@@ -2595,9 +2673,7 @@ def _holdout_evaluation(
|
|||||||
exit_policy = str(strategy["exit_policy"])
|
exit_policy = str(strategy["exit_policy"])
|
||||||
row_hold_days = hold_days
|
row_hold_days = hold_days
|
||||||
trail_multiplier = ATR_TRAIL_MULTIPLIER
|
trail_multiplier = ATR_TRAIL_MULTIPLIER
|
||||||
reentry_lockdown_sessions = int(
|
reentry_policy = str(strategy.get("reentry_policy", "immediate"))
|
||||||
strategy.get("reentry_lockdown_sessions", 0)
|
|
||||||
)
|
|
||||||
if strategy.get("use_live_config") and live_exit_policy is not None:
|
if strategy.get("use_live_config") and live_exit_policy is not None:
|
||||||
exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
|
exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
|
||||||
str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3"
|
str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3"
|
||||||
@@ -2610,6 +2686,20 @@ def _holdout_evaluation(
|
|||||||
None if strategy.get("use_live_config")
|
None if strategy.get("use_live_config")
|
||||||
else lambda c, config=entry_cfg: _qualifies_strategy_variant(c, config)
|
else lambda c, config=entry_cfg: _qualifies_strategy_variant(c, config)
|
||||||
)
|
)
|
||||||
|
ranking_key = str(
|
||||||
|
entry_cfg.get("ranking_key") or entry_cfg["percentile_key"]
|
||||||
|
)
|
||||||
|
post_stop_reentry_fn = (
|
||||||
|
_make_gate_reset_reentry_fn(
|
||||||
|
candidates,
|
||||||
|
prices,
|
||||||
|
cadence=cadence,
|
||||||
|
qualified_fn=qualified_fn,
|
||||||
|
ranking_key=ranking_key,
|
||||||
|
)
|
||||||
|
if reentry_policy == "gate_reset"
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
for window, start, end in (
|
for window, start, end in (
|
||||||
@@ -2623,11 +2713,11 @@ def _holdout_evaluation(
|
|||||||
exit_policy,
|
exit_policy,
|
||||||
row_hold_days,
|
row_hold_days,
|
||||||
qualified_fn=qualified_fn,
|
qualified_fn=qualified_fn,
|
||||||
ranking_key=str(entry_cfg.get("ranking_key") or entry_cfg["percentile_key"]),
|
ranking_key=ranking_key,
|
||||||
max_positions=int(entry_cfg["max_positions"]),
|
max_positions=int(entry_cfg["max_positions"]),
|
||||||
risk_per_trade=float(entry_cfg["risk_per_trade"]),
|
risk_per_trade=float(entry_cfg["risk_per_trade"]),
|
||||||
atr_trail_multiplier=trail_multiplier,
|
atr_trail_multiplier=trail_multiplier,
|
||||||
reentry_cooldown_sessions=reentry_lockdown_sessions,
|
post_stop_reentry_fn=post_stop_reentry_fn,
|
||||||
start_date=start,
|
start_date=start,
|
||||||
end_date=end,
|
end_date=end,
|
||||||
include_curve=True,
|
include_curve=True,
|
||||||
@@ -2639,7 +2729,7 @@ def _holdout_evaluation(
|
|||||||
return {
|
return {
|
||||||
"split_date": split.isoformat(),
|
"split_date": split.isoformat(),
|
||||||
"strategy": strategy["strategy"],
|
"strategy": strategy["strategy"],
|
||||||
"reentry_lockdown_sessions": reentry_lockdown_sessions,
|
"reentry_policy": reentry_policy,
|
||||||
"rows": rows,
|
"rows": rows,
|
||||||
"note": (
|
"note": (
|
||||||
"Train = entries before the split; test = entries on/after it. The two "
|
"Train = entries before the split; test = entries on/after it. The two "
|
||||||
@@ -2655,6 +2745,7 @@ def _portfolio_monitor(
|
|||||||
_spy_closes: dict[date, float] | None,
|
_spy_closes: dict[date, float] | None,
|
||||||
hold_days: int,
|
hold_days: int,
|
||||||
live_exit_policy: dict | None = None,
|
live_exit_policy: dict | None = None,
|
||||||
|
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
latest_ord = max((max(cols[0]) for cols in prices.values() if cols[0]), default=None)
|
latest_ord = max((max(cols[0]) for cols in prices.values() if cols[0]), default=None)
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
@@ -2672,9 +2763,7 @@ def _portfolio_monitor(
|
|||||||
# policy. The overlay opts into this deliberately so only ordering
|
# policy. The overlay opts into this deliberately so only ordering
|
||||||
# changes relative to the production row.
|
# changes relative to the production row.
|
||||||
use_live = bool(strategy.get("use_live_config"))
|
use_live = bool(strategy.get("use_live_config"))
|
||||||
reentry_lockdown_sessions = int(
|
reentry_policy = str(strategy.get("reentry_policy", "immediate"))
|
||||||
strategy.get("reentry_lockdown_sessions", 0)
|
|
||||||
)
|
|
||||||
exit_policy = str(strategy["exit_policy"])
|
exit_policy = str(strategy["exit_policy"])
|
||||||
row_hold_days = hold_days
|
row_hold_days = hold_days
|
||||||
trail_multiplier = ATR_TRAIL_MULTIPLIER
|
trail_multiplier = ATR_TRAIL_MULTIPLIER
|
||||||
@@ -2690,6 +2779,17 @@ def _portfolio_monitor(
|
|||||||
None if use_live
|
None if use_live
|
||||||
else lambda c, config=entry_cfg: _qualifies_strategy_variant(c, config)
|
else lambda c, config=entry_cfg: _qualifies_strategy_variant(c, config)
|
||||||
)
|
)
|
||||||
|
post_stop_reentry_fn = (
|
||||||
|
_make_gate_reset_reentry_fn(
|
||||||
|
candidates,
|
||||||
|
prices,
|
||||||
|
cadence=cadence,
|
||||||
|
qualified_fn=qualified_fn,
|
||||||
|
ranking_key=ranking_key,
|
||||||
|
)
|
||||||
|
if reentry_policy == "gate_reset"
|
||||||
|
else None
|
||||||
|
)
|
||||||
for lookback in PORTFOLIO_MONITOR_LOOKBACKS:
|
for lookback in PORTFOLIO_MONITOR_LOOKBACKS:
|
||||||
start = _lookback_start(latest_ord, lookback["days"])
|
start = _lookback_start(latest_ord, lookback["days"])
|
||||||
sim = _simulate_portfolio(
|
sim = _simulate_portfolio(
|
||||||
@@ -2703,7 +2803,7 @@ def _portfolio_monitor(
|
|||||||
max_positions=int(entry_cfg["max_positions"]),
|
max_positions=int(entry_cfg["max_positions"]),
|
||||||
risk_per_trade=float(entry_cfg["risk_per_trade"]),
|
risk_per_trade=float(entry_cfg["risk_per_trade"]),
|
||||||
atr_trail_multiplier=trail_multiplier,
|
atr_trail_multiplier=trail_multiplier,
|
||||||
reentry_cooldown_sessions=reentry_lockdown_sessions,
|
post_stop_reentry_fn=post_stop_reentry_fn,
|
||||||
start_date=start,
|
start_date=start,
|
||||||
include_curve=True,
|
include_curve=True,
|
||||||
)
|
)
|
||||||
@@ -2719,7 +2819,7 @@ def _portfolio_monitor(
|
|||||||
"ranking_key": ranking_key,
|
"ranking_key": ranking_key,
|
||||||
"exit_policy": exit_policy,
|
"exit_policy": exit_policy,
|
||||||
"live_exit_mode": live_exit_mode,
|
"live_exit_mode": live_exit_mode,
|
||||||
"reentry_lockdown_sessions": reentry_lockdown_sessions,
|
"reentry_policy": reentry_policy,
|
||||||
"lookback": lookback["lookback"],
|
"lookback": lookback["lookback"],
|
||||||
"lookback_label": lookback["label"],
|
"lookback_label": lookback["label"],
|
||||||
**sim,
|
**sim,
|
||||||
@@ -2733,9 +2833,7 @@ def _portfolio_monitor(
|
|||||||
"description": s["description"],
|
"description": s["description"],
|
||||||
"is_production": bool(s.get("is_production")),
|
"is_production": bool(s.get("is_production")),
|
||||||
"comparison_arm": s.get("comparison_arm"),
|
"comparison_arm": s.get("comparison_arm"),
|
||||||
"reentry_lockdown_sessions": int(
|
"reentry_policy": str(s.get("reentry_policy", "immediate")),
|
||||||
s.get("reentry_lockdown_sessions", 0)
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
for s in strategies
|
for s in strategies
|
||||||
],
|
],
|
||||||
@@ -2749,7 +2847,7 @@ def _portfolio_monitor(
|
|||||||
"The structural overlay appears only in its explicit research arm and changes "
|
"The structural overlay appears only in its explicit research arm and changes "
|
||||||
"ordering, not production qualification. Local snapshot backtests remain the "
|
"ordering, not production qualification. Local snapshot backtests remain the "
|
||||||
"research surface for broad variant sweeps. The production row applies the "
|
"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."
|
"same post-initial-stop gate-reset rule as the live setup list."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2758,7 +2856,7 @@ def _production_cadence_comparison(
|
|||||||
monitor: dict | None,
|
monitor: dict | None,
|
||||||
cadence: str,
|
cadence: str,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""Compact full-history live/no-lockdown vs live/5-session comparison."""
|
"""Compact full-history live/immediate vs live/gate-reset comparison."""
|
||||||
if not monitor:
|
if not monitor:
|
||||||
return None
|
return None
|
||||||
arms: list[dict] = []
|
arms: list[dict] = []
|
||||||
@@ -2773,23 +2871,23 @@ def _production_cadence_comparison(
|
|||||||
}
|
}
|
||||||
arm_name = (
|
arm_name = (
|
||||||
"prod_live_setup"
|
"prod_live_setup"
|
||||||
if comparison_arm == "live_no_lockdown"
|
if comparison_arm == "live_immediate"
|
||||||
else "cooldown_5"
|
else "gate_reset"
|
||||||
)
|
)
|
||||||
compact["arm"] = f"{arm_name}_{cadence}"
|
compact["arm"] = f"{arm_name}_{cadence}"
|
||||||
compact["entry_cadence"] = cadence
|
compact["entry_cadence"] = cadence
|
||||||
arms.append(compact)
|
arms.append(compact)
|
||||||
if not arms:
|
if not arms:
|
||||||
return None
|
return None
|
||||||
arms.sort(key=lambda row: int(row.get("reentry_lockdown_sessions", 0)))
|
arms.sort(key=lambda row: row.get("reentry_policy") != "immediate")
|
||||||
return {
|
return {
|
||||||
"entry_cadence": cadence,
|
"entry_cadence": cadence,
|
||||||
"lookback": "all",
|
"lookback": "all",
|
||||||
"arms": arms,
|
"arms": arms,
|
||||||
"note": (
|
"note": (
|
||||||
"Both arms use the exact same live gate, ordering, Admin exit policy, "
|
"Both arms use the exact same live gate, ordering, Admin exit policy, "
|
||||||
"fees, and candidate cadence. Only the five-session post-stop "
|
"fees, and candidate cadence. Only the post-stop gate-reset rule "
|
||||||
"re-entry lockdown changes."
|
"changes."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3002,8 +3100,8 @@ def _build_recommendation(report: dict) -> dict:
|
|||||||
if production_row is not None:
|
if production_row is not None:
|
||||||
headline = (
|
headline = (
|
||||||
"Production baseline: residual/high-vol 80/20 entry rank with a "
|
"Production baseline: residual/high-vol 80/20 entry rank with a "
|
||||||
"3x ATR trailing exit, 30-trading-day max hold, and 5-session "
|
"3x ATR trailing exit, 30-trading-day max hold, and re-entry only "
|
||||||
"re-entry lockdown after an initial stop."
|
"after the gate fails and later qualifies again."
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
production_row.get("cagr_pct") is not None
|
production_row.get("cagr_pct") is not None
|
||||||
@@ -3357,17 +3455,19 @@ async def run_backtest(
|
|||||||
portfolio_monitor_report = _portfolio_monitor(
|
portfolio_monitor_report = _portfolio_monitor(
|
||||||
candidates, price_columns, spy_closes, hold_horizon,
|
candidates, price_columns, spy_closes, hold_horizon,
|
||||||
live_exit_policy=live_exit_policy,
|
live_exit_policy=live_exit_policy,
|
||||||
|
cadence=cadence,
|
||||||
)
|
)
|
||||||
split = _holdout_split()
|
split = _holdout_split()
|
||||||
if split is not None:
|
if split is not None:
|
||||||
holdout_report = _holdout_evaluation(
|
holdout_report = _holdout_evaluation(
|
||||||
candidates, price_columns, spy_closes, hold_horizon, split,
|
candidates, price_columns, spy_closes, hold_horizon, split,
|
||||||
live_exit_policy=live_exit_policy,
|
live_exit_policy=live_exit_policy,
|
||||||
|
cadence=cadence,
|
||||||
)
|
)
|
||||||
if _min_rr_sweep_enabled():
|
if _min_rr_sweep_enabled():
|
||||||
min_rr_sweep_report = _min_rr_sweep(
|
min_rr_sweep_report = _min_rr_sweep(
|
||||||
candidates, price_columns, spy_closes, activation, current_min_pct,
|
candidates, price_columns, spy_closes, activation, current_min_pct,
|
||||||
hold_horizon, live_exit_policy=live_exit_policy,
|
hold_horizon, live_exit_policy=live_exit_policy, cadence=cadence,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Portfolio simulation failed")
|
logger.exception("Portfolio simulation failed")
|
||||||
@@ -3390,7 +3490,7 @@ async def run_backtest(
|
|||||||
"target_model": target_model,
|
"target_model": target_model,
|
||||||
"target_model_label": BACKTEST_TARGET_MODELS[target_model],
|
"target_model_label": BACKTEST_TARGET_MODELS[target_model],
|
||||||
"is_production_target_model": target_model == PRODUCTION_GTL_TARGET_MODEL,
|
"is_production_target_model": target_model == PRODUCTION_GTL_TARGET_MODEL,
|
||||||
"production_reentry_lockdown_sessions": REENTRY_LOCKDOWN_SESSIONS,
|
"production_reentry_policy": PRODUCTION_REENTRY_POLICY,
|
||||||
},
|
},
|
||||||
"activation": activation,
|
"activation": activation,
|
||||||
"overall_qualified": _bucket_stats(qualified),
|
"overall_qualified": _bucket_stats(qualified),
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from app.services.outcome_service import (
|
|||||||
Bar,
|
Bar,
|
||||||
evaluate_setup_against_bars,
|
evaluate_setup_against_bars,
|
||||||
)
|
)
|
||||||
from app.services.trade_policy import get_reentry_lockdowns
|
from app.services.trade_policy import get_reentry_gate_locks
|
||||||
|
|
||||||
# Exit policy for OPEN paper trades (auto-close). Production defaults to the
|
# Exit policy for OPEN paper trades (auto-close). Production defaults to the
|
||||||
# July 2026 promoted strategy: initial stop + 3x ATR trailing stop, with a max
|
# July 2026 promoted strategy: initial stop + 3x ATR trailing stop, with a max
|
||||||
@@ -319,12 +319,9 @@ async def create_trade(
|
|||||||
raise ValidationError("shares and entry_price must be positive")
|
raise ValidationError("shares and entry_price must be positive")
|
||||||
|
|
||||||
ticker = await _get_ticker(db, symbol)
|
ticker = await _get_ticker(db, symbol)
|
||||||
remaining_sessions = (await get_reentry_lockdowns(db)).get(ticker.id)
|
if ticker.id in await get_reentry_gate_locks(db):
|
||||||
if remaining_sessions is not None:
|
|
||||||
suffix = "session" if remaining_sessions == 1 else "sessions"
|
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"{ticker.symbol} is in a post-stop re-entry lockdown: "
|
f"{ticker.symbol} requires a post-stop gate reset before re-entry"
|
||||||
f"{remaining_sessions} market {suffix} remaining"
|
|
||||||
)
|
)
|
||||||
trade = PaperTrade(
|
trade = PaperTrade(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|||||||
@@ -28,8 +28,12 @@ from app.models.ticker import Ticker
|
|||||||
from app.models.trade_setup import TradeSetup
|
from app.models.trade_setup import TradeSetup
|
||||||
from app.services.indicator_service import _extract_ohlcv, compute_atr
|
from app.services.indicator_service import _extract_ohlcv, compute_atr
|
||||||
from app.services.price_service import query_ohlcv
|
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.sr_service import detect_gate_target_ladder
|
||||||
from app.services.trade_policy import get_reentry_lockdowns
|
from app.services.trade_policy import (
|
||||||
|
get_reentry_gate_locks,
|
||||||
|
observe_reentry_gate_transitions,
|
||||||
|
)
|
||||||
from app.services.recommendation_service import (
|
from app.services.recommendation_service import (
|
||||||
_risk_level_from_conflicts,
|
_risk_level_from_conflicts,
|
||||||
build_recommendation_snapshot,
|
build_recommendation_snapshot,
|
||||||
@@ -700,12 +704,24 @@ async def scan_all_tickers(
|
|||||||
``progress_callback(processed, total, current_symbol)`` is invoked as each
|
``progress_callback(processed, total, current_symbol)`` is invoked as each
|
||||||
ticker is scanned so callers (e.g. the scheduler) can surface live progress.
|
ticker is scanned so callers (e.g. the scheduler) can surface live progress.
|
||||||
"""
|
"""
|
||||||
# Plain strings, not Ticker instances: the rollbacks below expire any ORM
|
# Plain ids/strings, not Ticker instances: the rollbacks below expire any
|
||||||
# objects held across them, and touching an expired attribute afterwards
|
# ORM objects held across them, and touching an expired attribute afterwards
|
||||||
# triggers sync lazy-loading, which raises on an AsyncSession.
|
# triggers sync lazy-loading, which raises on an AsyncSession.
|
||||||
result = await db.execute(select(Ticker.symbol).order_by(Ticker.symbol))
|
result = await db.execute(select(Ticker.id, Ticker.symbol).order_by(Ticker.symbol))
|
||||||
symbols = list(result.scalars().all())
|
ticker_rows = [(int(ticker_id), symbol) for ticker_id, symbol in result.all()]
|
||||||
total = len(symbols)
|
total = len(ticker_rows)
|
||||||
|
|
||||||
|
# Gate-reset observations must use the same runtime activation settings as
|
||||||
|
# the live setup list. If the config cannot be loaded, scan normally but do
|
||||||
|
# not mutate reset state from an evaluation whose rules are unknown.
|
||||||
|
activation: dict | None = None
|
||||||
|
try:
|
||||||
|
from app.services.admin_service import get_activation_config
|
||||||
|
|
||||||
|
activation = await get_activation_config(db)
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
logger.exception("Activation config load for re-entry gate reset failed")
|
||||||
|
|
||||||
# Rank the universe up front so each new setup carries both the residual
|
# Rank the universe up front so each new setup carries both the residual
|
||||||
# activation gate percentile and the promoted production ordering score.
|
# activation gate percentile and the promoted production ordering score.
|
||||||
@@ -721,7 +737,10 @@ async def scan_all_tickers(
|
|||||||
ranks = {}
|
ranks = {}
|
||||||
|
|
||||||
all_setups: list[TradeSetup] = []
|
all_setups: list[TradeSetup] = []
|
||||||
for index, symbol in enumerate(symbols):
|
evaluated_ticker_ids: set[int] = set()
|
||||||
|
qualified_ticker_ids: set[int] = set()
|
||||||
|
gate_observation_started_at = datetime.now(timezone.utc)
|
||||||
|
for index, (ticker_id, symbol) in enumerate(ticker_rows):
|
||||||
if progress_callback is not None:
|
if progress_callback is not None:
|
||||||
progress_callback(index, total, symbol)
|
progress_callback(index, total, symbol)
|
||||||
# Refresh scores first so the scheduled scan works off current data.
|
# Refresh scores first so the scheduled scan works off current data.
|
||||||
@@ -754,10 +773,33 @@ async def scan_all_tickers(
|
|||||||
primary_min_rr=PRIMARY_TARGET_MIN_RR,
|
primary_min_rr=PRIMARY_TARGET_MIN_RR,
|
||||||
)
|
)
|
||||||
all_setups.extend(setups)
|
all_setups.extend(setups)
|
||||||
|
if activation is not None:
|
||||||
|
try:
|
||||||
|
if any(setup_qualifies(setup, activation) for setup in setups):
|
||||||
|
qualified_ticker_ids.add(ticker_id)
|
||||||
|
evaluated_ticker_ids.add(ticker_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Gate-reset qualification observation failed for %s", symbol
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
logger.exception("Error scanning ticker %s", symbol)
|
logger.exception("Error scanning ticker %s", symbol)
|
||||||
|
|
||||||
|
if activation is not None:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
if transitioned_ticker_ids:
|
||||||
|
logger.info(
|
||||||
|
"Updated post-stop gate-reset state for %d ticker(s)",
|
||||||
|
len(transitioned_ticker_ids),
|
||||||
|
)
|
||||||
|
|
||||||
if progress_callback is not None and total:
|
if progress_callback is not None and total:
|
||||||
progress_callback(total, total, "")
|
progress_callback(total, total, "")
|
||||||
|
|
||||||
@@ -772,8 +814,8 @@ async def get_trade_setups(
|
|||||||
symbol: str | None = None,
|
symbol: str | None = None,
|
||||||
live_recommendation: bool = False,
|
live_recommendation: bool = False,
|
||||||
exclude_open_trade_tickers: bool = False,
|
exclude_open_trade_tickers: bool = False,
|
||||||
exclude_reentry_lockdown_tickers: bool = False,
|
exclude_reentry_gate_locked_tickers: bool = False,
|
||||||
include_reentry_lockdown: bool = False,
|
include_reentry_gate_lock: bool = False,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Get latest stored trade setups, optionally filtered.
|
"""Get latest stored trade setups, optionally filtered.
|
||||||
|
|
||||||
@@ -798,7 +840,7 @@ async def get_trade_setups(
|
|||||||
if recommended_action is not None and not live_recommendation:
|
if recommended_action is not None and not live_recommendation:
|
||||||
stmt = stmt.where(TradeSetup.recommended_action == recommended_action)
|
stmt = stmt.where(TradeSetup.recommended_action == recommended_action)
|
||||||
excluded_ticker_ids: set[int] = set()
|
excluded_ticker_ids: set[int] = set()
|
||||||
reentry_lockdowns: dict[int, int] = {}
|
reentry_gate_locks: dict[int, datetime] = {}
|
||||||
if exclude_open_trade_tickers:
|
if exclude_open_trade_tickers:
|
||||||
open_trade_result = await db.execute(
|
open_trade_result = await db.execute(
|
||||||
select(PaperTrade.ticker_id)
|
select(PaperTrade.ticker_id)
|
||||||
@@ -808,10 +850,10 @@ async def get_trade_setups(
|
|||||||
excluded_ticker_ids.update(
|
excluded_ticker_ids.update(
|
||||||
ticker_id for ticker_id, in open_trade_result.all()
|
ticker_id for ticker_id, in open_trade_result.all()
|
||||||
)
|
)
|
||||||
if exclude_reentry_lockdown_tickers or include_reentry_lockdown:
|
if exclude_reentry_gate_locked_tickers or include_reentry_gate_lock:
|
||||||
reentry_lockdowns = await get_reentry_lockdowns(db)
|
reentry_gate_locks = await get_reentry_gate_locks(db)
|
||||||
if exclude_reentry_lockdown_tickers:
|
if exclude_reentry_gate_locked_tickers:
|
||||||
excluded_ticker_ids.update(reentry_lockdowns)
|
excluded_ticker_ids.update(reentry_gate_locks)
|
||||||
if excluded_ticker_ids:
|
if excluded_ticker_ids:
|
||||||
stmt = stmt.where(~TradeSetup.ticker_id.in_(excluded_ticker_ids))
|
stmt = stmt.where(~TradeSetup.ticker_id.in_(excluded_ticker_ids))
|
||||||
|
|
||||||
@@ -866,14 +908,14 @@ async def get_trade_setups(
|
|||||||
),
|
),
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
if include_reentry_lockdown:
|
if include_reentry_gate_lock:
|
||||||
ticker_by_setup_id = {
|
ticker_by_setup_id = {
|
||||||
setup.id: setup.ticker_id for setup, _ in latest_rows
|
setup.id: setup.ticker_id for setup, _ in latest_rows
|
||||||
}
|
}
|
||||||
for row in rows_out:
|
for row in rows_out:
|
||||||
ticker_id = ticker_by_setup_id.get(row["id"])
|
ticker_id = ticker_by_setup_id.get(row["id"])
|
||||||
row["reentry_lockdown_remaining_sessions"] = (
|
row["reentry_gate_reset_required"] = (
|
||||||
reentry_lockdowns.get(ticker_id) if ticker_id is not None else None
|
ticker_id in reentry_gate_locks if ticker_id is not None else False
|
||||||
)
|
)
|
||||||
return rows_out
|
return rows_out
|
||||||
|
|
||||||
|
|||||||
@@ -1,120 +1,93 @@
|
|||||||
"""Shared live/backtest trading-policy constants and availability checks."""
|
"""Shared live trading-policy state and availability checks."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections.abc import Iterable
|
||||||
from datetime import date, datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.benchmark_price import BenchmarkPrice
|
|
||||||
from app.models.ohlcv import OHLCVRecord
|
|
||||||
from app.models.paper_trade import PaperTrade
|
from app.models.paper_trade import PaperTrade
|
||||||
from app.services.benchmark_service import BENCHMARK_SYMBOL
|
|
||||||
|
|
||||||
# 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_lockdowns(
|
async def _latest_initial_stop_trades(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
as_of: date | None = None,
|
closed_before: datetime | None = None,
|
||||||
sessions: int = REENTRY_LOCKDOWN_SESSIONS,
|
) -> dict[int, PaperTrade]:
|
||||||
) -> dict[int, int]:
|
"""Return the most recent initial-stop trade for each ticker."""
|
||||||
"""Return ``{ticker_id: remaining_sessions}`` for active lockdowns.
|
stmt = (
|
||||||
|
select(PaperTrade)
|
||||||
SPY is the canonical calendar for the platform's US-equity universe. When
|
|
||||||
the stored benchmark history does not reach an older stop, only that
|
|
||||||
ticker's own OHLCV dates are used as a conservative fallback. Unrelated
|
|
||||||
ticker dates can therefore never shorten a lockdown.
|
|
||||||
"""
|
|
||||||
sessions = max(0, int(sessions))
|
|
||||||
if sessions == 0:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
session_cutoff = as_of or datetime.now(timezone.utc).date()
|
|
||||||
stop_result = await db.execute(
|
|
||||||
select(
|
|
||||||
PaperTrade.ticker_id,
|
|
||||||
func.max(PaperTrade.closed_at).label("last_stop_at"),
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
PaperTrade.status == "closed",
|
PaperTrade.status == "closed",
|
||||||
PaperTrade.close_reason == "stop",
|
PaperTrade.close_reason == "stop",
|
||||||
PaperTrade.closed_at.is_not(None),
|
PaperTrade.closed_at.is_not(None),
|
||||||
)
|
)
|
||||||
.group_by(PaperTrade.ticker_id)
|
.order_by(
|
||||||
|
PaperTrade.ticker_id.asc(),
|
||||||
|
PaperTrade.closed_at.desc(),
|
||||||
|
PaperTrade.id.desc(),
|
||||||
)
|
)
|
||||||
stop_dates = {
|
)
|
||||||
ticker_id: stopped_at.date()
|
if closed_before is not None:
|
||||||
for ticker_id, stopped_at in stop_result.all()
|
stmt = stmt.where(PaperTrade.closed_at <= closed_before)
|
||||||
if stopped_at is not None and stopped_at.date() <= session_cutoff
|
result = await db.execute(stmt)
|
||||||
|
latest: dict[int, PaperTrade] = {}
|
||||||
|
for trade in result.scalars():
|
||||||
|
latest.setdefault(trade.ticker_id, trade)
|
||||||
|
return latest
|
||||||
|
|
||||||
|
|
||||||
|
async def get_reentry_gate_locks(db: AsyncSession) -> 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
|
||||||
|
observed an unqualified evaluation after the latest initial-stop exit and
|
||||||
|
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)
|
||||||
|
return {
|
||||||
|
ticker_id: trade.closed_at
|
||||||
|
for ticker_id, trade in latest.items()
|
||||||
|
if trade.reentry_gate_requalified_at is None and trade.closed_at is not None
|
||||||
}
|
}
|
||||||
if not stop_dates:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
benchmark_result = await db.execute(
|
|
||||||
select(BenchmarkPrice.date)
|
|
||||||
.where(
|
|
||||||
BenchmarkPrice.symbol == BENCHMARK_SYMBOL,
|
|
||||||
BenchmarkPrice.date <= session_cutoff,
|
|
||||||
)
|
|
||||||
.order_by(BenchmarkPrice.date.asc())
|
|
||||||
)
|
|
||||||
benchmark_dates = [row[0] for row in benchmark_result.all()]
|
|
||||||
|
|
||||||
lockdowns: dict[int, int] = {}
|
|
||||||
fallback_stops: dict[int, date] = {}
|
|
||||||
first_benchmark_date = benchmark_dates[0] if benchmark_dates else None
|
|
||||||
for ticker_id, stop_date in stop_dates.items():
|
|
||||||
completed = sum(day > stop_date for day in benchmark_dates)
|
|
||||||
if completed >= sessions:
|
|
||||||
continue
|
|
||||||
if first_benchmark_date is not None and first_benchmark_date <= stop_date:
|
|
||||||
lockdowns[ticker_id] = sessions - completed
|
|
||||||
else:
|
|
||||||
# The benchmark table starts after this stop (or is empty), so it
|
|
||||||
# cannot prove how many sessions elapsed. Resolve only this ticker
|
|
||||||
# against its own bars instead of using universe-wide dates.
|
|
||||||
fallback_stops[ticker_id] = stop_date
|
|
||||||
|
|
||||||
if fallback_stops:
|
|
||||||
own_session_result = await db.execute(
|
|
||||||
select(OHLCVRecord.ticker_id, OHLCVRecord.date)
|
|
||||||
.where(
|
|
||||||
OHLCVRecord.ticker_id.in_(fallback_stops),
|
|
||||||
OHLCVRecord.date > min(fallback_stops.values()),
|
|
||||||
OHLCVRecord.date <= session_cutoff,
|
|
||||||
)
|
|
||||||
.distinct()
|
|
||||||
)
|
|
||||||
own_dates: dict[int, set[date]] = defaultdict(set)
|
|
||||||
for ticker_id, market_date in own_session_result.all():
|
|
||||||
own_dates[ticker_id].add(market_date)
|
|
||||||
for ticker_id, stop_date in fallback_stops.items():
|
|
||||||
completed = sum(day > stop_date for day in own_dates[ticker_id])
|
|
||||||
if completed < sessions:
|
|
||||||
lockdowns[ticker_id] = sessions - completed
|
|
||||||
|
|
||||||
return lockdowns
|
|
||||||
|
|
||||||
|
|
||||||
async def get_reentry_lockdown_ticker_ids(
|
async def observe_reentry_gate_transitions(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
as_of: date | None = None,
|
evaluated_ticker_ids: Iterable[int],
|
||||||
sessions: int = REENTRY_LOCKDOWN_SESSIONS,
|
qualified_ticker_ids: Iterable[int],
|
||||||
|
observed_at: datetime | None = None,
|
||||||
) -> set[int]:
|
) -> set[int]:
|
||||||
"""Compatibility wrapper for callers that only need blocked ticker ids."""
|
"""Persist gate-failure and later requalification observations.
|
||||||
return set(
|
|
||||||
await get_reentry_lockdowns(
|
Only tickers whose scan completed successfully belong in
|
||||||
db,
|
``evaluated_ticker_ids``. This prevents a scanner exception from being
|
||||||
as_of=as_of,
|
mistaken for a real gate exit. The caller owns the transaction; this helper
|
||||||
sessions=sessions,
|
flushes so the new state is immediately visible in that transaction.
|
||||||
)
|
"""
|
||||||
)
|
evaluated = {int(ticker_id) for ticker_id in evaluated_ticker_ids}
|
||||||
|
if not evaluated:
|
||||||
|
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)
|
||||||
|
updated: set[int] = set()
|
||||||
|
for ticker_id in evaluated:
|
||||||
|
trade = latest.get(ticker_id)
|
||||||
|
if trade is None or trade.reentry_gate_requalified_at is not None:
|
||||||
|
continue
|
||||||
|
if trade.reentry_gate_failed_at is None:
|
||||||
|
if ticker_id not in qualified:
|
||||||
|
trade.reentry_gate_failed_at = timestamp
|
||||||
|
updated.add(ticker_id)
|
||||||
|
elif ticker_id in qualified:
|
||||||
|
trade.reentry_gate_requalified_at = timestamp
|
||||||
|
updated.add(ticker_id)
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
await db.flush()
|
||||||
|
return updated
|
||||||
|
|||||||
@@ -373,8 +373,8 @@ export function BacktestPanel() {
|
|||||||
<p className="text-[11px] text-gray-500">
|
<p className="text-[11px] text-gray-500">
|
||||||
Avg hold {fmtDays(monitorRun.avg_hold_days)} · Best {fmtR(monitorRun.best_trade_r)} / Worst{' '}
|
Avg hold {fmtDays(monitorRun.avg_hold_days)} · Best {fmtR(monitorRun.best_trade_r)} / Worst{' '}
|
||||||
{fmtR(monitorRun.worst_trade_r)} · Avg P&L per trade {fmtMoney(monitorRun.avg_trade_pnl)}
|
{fmtR(monitorRun.worst_trade_r)} · Avg P&L per trade {fmtMoney(monitorRun.avg_trade_pnl)}
|
||||||
{monitorRun.reentry_lockdown_sessions ? (
|
{monitorRun.reentry_policy === 'gate_reset' ? (
|
||||||
<> · Re-entry lockdown {monitorRun.reentry_lockdown_sessions} market sessions after initial stop</>
|
<> · Re-entry after gate failure and fresh qualification</>
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -67,14 +67,13 @@ function entryDrift(setup: TradeSetup, currentPrice?: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type NotActionableState =
|
type NotActionableState =
|
||||||
| { kind: 'lockdown'; remainingSessions: number }
|
| { kind: 'gate-reset' }
|
||||||
| { kind: 'invalidated' }
|
| { kind: 'invalidated' }
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
function notActionableState(setup: TradeSetup, currentPrice?: number) {
|
function notActionableState(setup: TradeSetup, currentPrice?: number) {
|
||||||
const remainingSessions = setup.reentry_lockdown_remaining_sessions ?? 0;
|
if (setup.reentry_gate_reset_required) {
|
||||||
if (remainingSessions > 0) {
|
return { kind: 'gate-reset' } satisfies NotActionableState;
|
||||||
return { kind: 'lockdown', remainingSessions } satisfies NotActionableState;
|
|
||||||
}
|
}
|
||||||
if (currentPrice == null) return null;
|
if (currentPrice == null) return null;
|
||||||
if (entryDrift(setup, currentPrice)?.status !== 'invalidated') return null;
|
if (entryDrift(setup, currentPrice)?.status !== 'invalidated') return null;
|
||||||
@@ -271,20 +270,17 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
|
|||||||
};
|
};
|
||||||
|
|
||||||
const inactiveState = notActionableState(setup, currentPrice);
|
const inactiveState = notActionableState(setup, currentPrice);
|
||||||
if (inactiveState?.kind === 'lockdown') {
|
if (inactiveState?.kind === 'gate-reset') {
|
||||||
const remaining = inactiveState.remainingSessions;
|
|
||||||
return (
|
return (
|
||||||
<div data-direction={setup.direction} className="rounded-xl border border-amber-400/20 bg-amber-400/[0.04] p-4">
|
<div data-direction={setup.direction} className="rounded-xl border border-amber-400/20 bg-amber-400/[0.04] p-4">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<DirTag direction={setup.direction} />
|
<DirTag direction={setup.direction} />
|
||||||
<span className="num text-[10px] uppercase tracking-[0.16em] text-amber-300">post-stop lockdown</span>
|
<span className="num text-[10px] uppercase tracking-[0.16em] text-amber-300">awaiting gate reset</span>
|
||||||
<span className="num ml-auto text-xs text-gray-500">
|
<span className="num ml-auto text-xs text-gray-500">re-entry paused</span>
|
||||||
{remaining} market session{remaining === 1 ? '' : 's'} remaining
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-[11.5px] leading-relaxed text-gray-400">
|
<p className="mt-2 text-[11.5px] leading-relaxed text-gray-400">
|
||||||
This setup remains visible for context but cannot be marked as taken. Once the lockdown expires,
|
This setup remains visible for context but cannot be marked as taken. The ticker must first fail
|
||||||
the scanner recalculates the normal gate before it can become actionable again.
|
the production gate; only a later fresh qualification can become actionable again.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -639,11 +635,11 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{preferredInactive ? (
|
{preferredInactive ? (
|
||||||
<span className="text-sm font-semibold text-gray-400">
|
<span className="text-sm font-semibold text-gray-400">
|
||||||
{preferredInactive.kind === 'lockdown' ? (
|
{preferredInactive.kind === 'gate-reset' ? (
|
||||||
<>
|
<>
|
||||||
Re-entry paused{' '}
|
Re-entry paused{' '}
|
||||||
<span className="font-normal text-gray-500">
|
<span className="font-normal text-gray-500">
|
||||||
({preferredInactive.remainingSessions} market session{preferredInactive.remainingSessions === 1 ? '' : 's'} remaining after stop)
|
(waiting for the gate to fail before a fresh qualification)
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function liveRiskReward(setup: TradeSetup, currentPrice: number): number
|
|||||||
* app/services/qualification.py — keep the two in sync.
|
* app/services/qualification.py — keep the two in sync.
|
||||||
*/
|
*/
|
||||||
export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boolean {
|
export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boolean {
|
||||||
if ((setup.reentry_lockdown_remaining_sessions ?? 0) > 0) return false;
|
if (setup.reentry_gate_reset_required) return false;
|
||||||
if (setup.rr_ratio < config.min_rr) return false;
|
if (setup.rr_ratio < config.min_rr) return false;
|
||||||
// Live R:R from current price — drops setups whose price has already run
|
// Live R:R from current price — drops setups whose price has already run
|
||||||
// toward target (reward consumed) or through the stop.
|
// toward target (reward consumed) or through the stop.
|
||||||
@@ -80,9 +80,8 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
|
|||||||
* qualifiesSetup rule-for-rule (keep the order in sync).
|
* qualifiesSetup rule-for-rule (keep the order in sync).
|
||||||
*/
|
*/
|
||||||
export function disqualifyReason(setup: TradeSetup, config: ActivationConfig): string | null {
|
export function disqualifyReason(setup: TradeSetup, config: ActivationConfig): string | null {
|
||||||
const lockdownRemaining = setup.reentry_lockdown_remaining_sessions ?? 0;
|
if (setup.reentry_gate_reset_required) {
|
||||||
if (lockdownRemaining > 0) {
|
return 'post-stop gate reset required';
|
||||||
return `post-stop lockdown · ${lockdownRemaining} session${lockdownRemaining === 1 ? '' : 's'} remaining`;
|
|
||||||
}
|
}
|
||||||
if (setup.rr_ratio < config.min_rr) {
|
if (setup.rr_ratio < config.min_rr) {
|
||||||
return `R:R ${setup.rr_ratio.toFixed(1)} below gate ${config.min_rr.toFixed(1)}`;
|
return `R:R ${setup.rr_ratio.toFixed(1)} below gate ${config.min_rr.toFixed(1)}`;
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export interface TradeSetup {
|
|||||||
momentum_percentile?: number | null;
|
momentum_percentile?: number | null;
|
||||||
strategy_rank?: number | null;
|
strategy_rank?: number | null;
|
||||||
volatility_percentile?: number | null;
|
volatility_percentile?: number | null;
|
||||||
reentry_lockdown_remaining_sessions?: number | null;
|
reentry_gate_reset_required?: boolean;
|
||||||
context_as_of?: TradeSetupContextAsOf | null;
|
context_as_of?: TradeSetupContextAsOf | null;
|
||||||
recommendation_summary?: RecommendationSummary;
|
recommendation_summary?: RecommendationSummary;
|
||||||
}
|
}
|
||||||
@@ -356,10 +356,10 @@ export interface BacktestPortfolioMonitorRun extends BacktestPortfolioPolicy {
|
|||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
is_production: boolean;
|
is_production: boolean;
|
||||||
comparison_arm?: 'live_no_lockdown' | 'live_lockdown_5' | null;
|
comparison_arm?: 'live_immediate' | 'live_gate_reset' | null;
|
||||||
entry_variant: string;
|
entry_variant: string;
|
||||||
exit_policy: string;
|
exit_policy: string;
|
||||||
reentry_lockdown_sessions?: number;
|
reentry_policy?: 'immediate' | 'gate_reset';
|
||||||
lookback: string;
|
lookback: string;
|
||||||
lookback_label: string;
|
lookback_label: string;
|
||||||
}
|
}
|
||||||
@@ -371,8 +371,8 @@ export interface BacktestPortfolioMonitor {
|
|||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
is_production: boolean;
|
is_production: boolean;
|
||||||
comparison_arm?: 'live_no_lockdown' | 'live_lockdown_5' | null;
|
comparison_arm?: 'live_immediate' | 'live_gate_reset' | null;
|
||||||
reentry_lockdown_sessions?: number;
|
reentry_policy?: 'immediate' | 'gate_reset';
|
||||||
}[];
|
}[];
|
||||||
lookbacks: { lookback: string; label: string }[];
|
lookbacks: { lookback: string; label: string }[];
|
||||||
runs: BacktestPortfolioMonitorRun[];
|
runs: BacktestPortfolioMonitorRun[];
|
||||||
@@ -415,7 +415,7 @@ export interface BacktestReport {
|
|||||||
target_model?: 'production_gtl' | 'structural_sr';
|
target_model?: 'production_gtl' | 'structural_sr';
|
||||||
target_model_label?: string;
|
target_model_label?: string;
|
||||||
is_production_target_model?: boolean;
|
is_production_target_model?: boolean;
|
||||||
production_reentry_lockdown_sessions?: number;
|
production_reentry_policy?: 'gate_reset';
|
||||||
};
|
};
|
||||||
overall_qualified: BacktestBucket;
|
overall_qualified: BacktestBucket;
|
||||||
overall_all: BacktestBucket;
|
overall_all: BacktestBucket;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""Run the four production cadence/lockdown arms on one offline snapshot.
|
"""Run the four production cadence/re-entry arms on one offline snapshot.
|
||||||
|
|
||||||
The command executes the complete backtest once weekly and once daily. Each
|
The command executes the complete backtest once weekly and once daily. Each
|
||||||
backtest contains two otherwise identical live-policy portfolio arms: no
|
backtest contains two otherwise identical live-policy portfolio arms: immediate
|
||||||
post-stop lockdown and the production five-session lockdown. It writes both
|
post-stop re-entry and the production gate-reset rule. It writes both full
|
||||||
full reports plus one compact four-arm comparison report.
|
reports plus one compact four-arm comparison report.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -77,7 +77,8 @@ def _print_arm(row: dict) -> None:
|
|||||||
print(
|
print(
|
||||||
f" {row['arm']}: Sharpe {row.get('sharpe')}, "
|
f" {row['arm']}: Sharpe {row.get('sharpe')}, "
|
||||||
f"CAGR {row.get('cagr_pct')}%, DD {row.get('max_drawdown_pct')}%, "
|
f"CAGR {row.get('cagr_pct')}%, DD {row.get('max_drawdown_pct')}%, "
|
||||||
f"trades {row.get('trades')}, skipped cooldown {row.get('skipped_cooldown', 0)}"
|
f"trades {row.get('trades')}, post-stop re-entries "
|
||||||
|
f"{row.get('post_stop_reentries', 0)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -139,16 +140,16 @@ async def _main() -> None:
|
|||||||
expected = {
|
expected = {
|
||||||
"prod_live_setup_weekly",
|
"prod_live_setup_weekly",
|
||||||
"prod_live_setup_daily",
|
"prod_live_setup_daily",
|
||||||
"cooldown_5_weekly",
|
"gate_reset_weekly",
|
||||||
"cooldown_5_daily",
|
"gate_reset_daily",
|
||||||
}
|
}
|
||||||
if {row.get("arm") for row in arms} != expected:
|
if {row.get("arm") for row in arms} != expected:
|
||||||
raise RuntimeError("The generated cadence report does not contain all four arms")
|
raise RuntimeError("The generated cadence report does not contain all four arms")
|
||||||
arm_order = {
|
arm_order = {
|
||||||
"prod_live_setup_weekly": 0,
|
"prod_live_setup_weekly": 0,
|
||||||
"prod_live_setup_daily": 1,
|
"prod_live_setup_daily": 1,
|
||||||
"cooldown_5_weekly": 2,
|
"gate_reset_weekly": 2,
|
||||||
"cooldown_5_daily": 3,
|
"gate_reset_daily": 3,
|
||||||
}
|
}
|
||||||
arms.sort(key=lambda row: arm_order[str(row["arm"])])
|
arms.sort(key=lambda row: arm_order[str(row["arm"])])
|
||||||
|
|
||||||
@@ -164,7 +165,7 @@ async def _main() -> None:
|
|||||||
"note": (
|
"note": (
|
||||||
"All four arms use the same snapshot, activation settings, target model, "
|
"All four arms use the same snapshot, activation settings, target model, "
|
||||||
"live Admin exit policy, fees, sizing, and portfolio constraints. Within "
|
"live Admin exit policy, fees, sizing, and portfolio constraints. Within "
|
||||||
"each cadence pair, only the five-session post-stop lockdown differs."
|
"each cadence pair, only the post-stop gate-reset rule differs."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
comparison_path = out_dir / f"{prefix}-comparison.json"
|
comparison_path = out_dir / f"{prefix}-comparison.json"
|
||||||
|
|||||||
@@ -679,11 +679,51 @@ class TestSimulatePortfolio:
|
|||||||
assert sim["trades"] == 1
|
assert sim["trades"] == 1
|
||||||
assert callback_dates == [self.ORD + 1]
|
assert callback_dates == [self.ORD + 1]
|
||||||
|
|
||||||
def test_production_monitor_applies_live_reentry_lockdown(self, monkeypatch):
|
def test_gate_reset_waits_for_failed_evaluation_then_requalification(self):
|
||||||
|
closes = [100.0] * 95
|
||||||
|
entry_ord = self.ORD + bt.MIN_LOOKBACK - 1
|
||||||
|
stop_ord = entry_ord + 1
|
||||||
|
reentry_ord = entry_ord + 3
|
||||||
|
closes[bt.MIN_LOOKBACK] = 94.0
|
||||||
|
closes[bt.MIN_LOOKBACK + 1] = 95.0
|
||||||
|
closes[bt.MIN_LOOKBACK + 2] = 96.0
|
||||||
|
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||||
|
candidates = [
|
||||||
|
_sim_cand("AAA", entry_ord, entry=100.0, stop=95.0, target=120.0),
|
||||||
|
# Still qualified on the stop day: this must not unlock re-entry.
|
||||||
|
_sim_cand("AAA", stop_ord, entry=94.0, stop=89.0, target=110.0),
|
||||||
|
# No candidate on the intervening session means the daily gate
|
||||||
|
# failed. A fresh qualification on the next session may re-enter.
|
||||||
|
_sim_cand("AAA", reentry_ord, entry=96.0, stop=90.0, target=115.0),
|
||||||
|
]
|
||||||
|
gate_reset = bt._make_gate_reset_reentry_fn(
|
||||||
|
candidates,
|
||||||
|
prices,
|
||||||
|
cadence="daily",
|
||||||
|
)
|
||||||
|
|
||||||
|
sim = bt._simulate_portfolio(
|
||||||
|
candidates,
|
||||||
|
prices,
|
||||||
|
None,
|
||||||
|
"hold",
|
||||||
|
30,
|
||||||
|
post_stop_reentry_fn=gate_reset,
|
||||||
|
include_trades=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sim is not None
|
||||||
|
assert sim["post_stop_reentries"] == 1
|
||||||
|
assert sim["trade_details"][1]["entry_date"] == date.fromordinal(
|
||||||
|
reentry_ord
|
||||||
|
).isoformat()
|
||||||
|
assert sim["reentry_events"][0]["wait_sessions"] == 2
|
||||||
|
|
||||||
|
def test_production_monitor_applies_live_gate_reset(self, monkeypatch):
|
||||||
def fake_simulator(*_args, **kwargs):
|
def fake_simulator(*_args, **kwargs):
|
||||||
return {
|
return {
|
||||||
"trades": 0,
|
"trades": 0,
|
||||||
"applied_reentry_lockdown": kwargs.get("reentry_cooldown_sessions", 0),
|
"applied_gate_reset": kwargs.get("post_stop_reentry_fn") is not None,
|
||||||
}
|
}
|
||||||
|
|
||||||
monkeypatch.setattr(bt, "_simulate_portfolio", fake_simulator)
|
monkeypatch.setattr(bt, "_simulate_portfolio", fake_simulator)
|
||||||
@@ -694,37 +734,38 @@ class TestSimulatePortfolio:
|
|||||||
production_rows = [
|
production_rows = [
|
||||||
row for row in monitor["runs"] if row["is_production"]
|
row for row in monitor["runs"] if row["is_production"]
|
||||||
]
|
]
|
||||||
comparison_rows = [
|
immediate_rows = [
|
||||||
row for row in monitor["runs"] if not row["is_production"]
|
row for row in monitor["runs"]
|
||||||
|
if row["comparison_arm"] == "live_immediate"
|
||||||
]
|
]
|
||||||
|
|
||||||
assert production_rows
|
assert production_rows
|
||||||
assert all(
|
assert all(
|
||||||
row["reentry_lockdown_sessions"] == bt.REENTRY_LOCKDOWN_SESSIONS
|
row["reentry_policy"] == "gate_reset"
|
||||||
and row["applied_reentry_lockdown"] == bt.REENTRY_LOCKDOWN_SESSIONS
|
and row["applied_gate_reset"] is True
|
||||||
for row in production_rows
|
for row in production_rows
|
||||||
)
|
)
|
||||||
assert comparison_rows
|
assert immediate_rows
|
||||||
assert all(
|
assert all(
|
||||||
row["reentry_lockdown_sessions"] == 0
|
row["reentry_policy"] == "immediate"
|
||||||
and row["applied_reentry_lockdown"] == 0
|
and row["applied_gate_reset"] is False
|
||||||
for row in comparison_rows
|
for row in immediate_rows
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_production_cadence_comparison_names_exact_two_arms(self):
|
def test_production_cadence_comparison_names_exact_two_arms(self):
|
||||||
monitor = {
|
monitor = {
|
||||||
"runs": [
|
"runs": [
|
||||||
{
|
{
|
||||||
"comparison_arm": "live_no_lockdown",
|
"comparison_arm": "live_immediate",
|
||||||
"lookback": "all",
|
"lookback": "all",
|
||||||
"reentry_lockdown_sessions": 0,
|
"reentry_policy": "immediate",
|
||||||
"trades": 10,
|
"trades": 10,
|
||||||
"equity_curve": [{"date": "2026-01-01", "value": 1.0}],
|
"equity_curve": [{"date": "2026-01-01", "value": 1.0}],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"comparison_arm": "live_lockdown_5",
|
"comparison_arm": "live_gate_reset",
|
||||||
"lookback": "all",
|
"lookback": "all",
|
||||||
"reentry_lockdown_sessions": 5,
|
"reentry_policy": "gate_reset",
|
||||||
"trades": 8,
|
"trades": 8,
|
||||||
"benchmark_curve": [{"date": "2026-01-01", "value": 1.0}],
|
"benchmark_curve": [{"date": "2026-01-01", "value": 1.0}],
|
||||||
},
|
},
|
||||||
@@ -736,7 +777,7 @@ class TestSimulatePortfolio:
|
|||||||
assert comparison is not None
|
assert comparison is not None
|
||||||
assert [row["arm"] for row in comparison["arms"]] == [
|
assert [row["arm"] for row in comparison["arms"]] == [
|
||||||
"prod_live_setup_daily",
|
"prod_live_setup_daily",
|
||||||
"cooldown_5_daily",
|
"gate_reset_daily",
|
||||||
]
|
]
|
||||||
assert all("equity_curve" not in row for row in comparison["arms"])
|
assert all("equity_curve" not in row for row in comparison["arms"])
|
||||||
assert all("benchmark_curve" not in row for row in comparison["arms"])
|
assert all("benchmark_curve" not in row for row in comparison["arms"])
|
||||||
@@ -997,7 +1038,7 @@ def test_build_recommendation_prefers_production_monitor_headline():
|
|||||||
})
|
})
|
||||||
assert rec["headline"] is not None
|
assert rec["headline"] is not None
|
||||||
assert "3x ATR trailing exit" in rec["headline"]
|
assert "3x ATR trailing exit" in rec["headline"]
|
||||||
assert "5-session re-entry lockdown" in rec["headline"]
|
assert "after the gate fails" in rec["headline"]
|
||||||
assert any(item["topic"] == "production" for item in rec["items"])
|
assert any(item["topic"] == "production" for item in rec["items"])
|
||||||
|
|
||||||
|
|
||||||
@@ -1261,10 +1302,7 @@ async def test_run_backtest_smoke(session):
|
|||||||
assert report["params"]["is_production_target_model"] is True
|
assert report["params"]["is_production_target_model"] is True
|
||||||
assert report["params"]["entry_cadence"] == "weekly"
|
assert report["params"]["entry_cadence"] == "weekly"
|
||||||
assert report["params"]["step_sessions"] == 5
|
assert report["params"]["step_sessions"] == 5
|
||||||
assert (
|
assert report["params"]["production_reentry_policy"] == "gate_reset"
|
||||||
report["params"]["production_reentry_lockdown_sessions"]
|
|
||||||
== bt.REENTRY_LOCKDOWN_SESSIONS
|
|
||||||
)
|
|
||||||
assert "net_avg_r" in report["overall_all"]
|
assert "net_avg_r" in report["overall_all"]
|
||||||
|
|
||||||
# ablation baseline reproduces the qualified set exactly, and every row
|
# ablation baseline reproduces the qualified set exactly, and every row
|
||||||
|
|||||||
@@ -48,22 +48,18 @@ async def test_create_and_list_open(session):
|
|||||||
assert row["current_price"] == 110.0 # marked to the latest close
|
assert row["current_price"] == 110.0 # marked to the latest close
|
||||||
|
|
||||||
|
|
||||||
async def test_create_trade_enforces_post_stop_lockdown_at_service_boundary(session):
|
async def test_create_trade_enforces_post_stop_gate_reset_at_service_boundary(session):
|
||||||
blocked_id = await _seed(session, "LOCKQ", close=100.0)
|
blocked_id = await _seed(session, "LOCKQ", close=100.0)
|
||||||
released_id = await _seed(session, "FREEQ", close=100.0)
|
released_id = await _seed(session, "FREEQ", close=100.0)
|
||||||
today = date.today()
|
today = date.today()
|
||||||
market_sessions = [
|
|
||||||
today - timedelta(days=8),
|
|
||||||
today - timedelta(days=7),
|
|
||||||
today - timedelta(days=6),
|
|
||||||
today - timedelta(days=3),
|
|
||||||
today - timedelta(days=2),
|
|
||||||
today - timedelta(days=1),
|
|
||||||
]
|
|
||||||
for market_date in market_sessions:
|
|
||||||
session.add(BenchmarkPrice(symbol="SPY", date=market_date, close=400.0))
|
|
||||||
|
|
||||||
def stopped_trade(ticker_id: int, closed_on: date) -> PaperTrade:
|
def stopped_trade(ticker_id: int, *, gate_reset_complete: bool) -> PaperTrade:
|
||||||
|
closed_on = today - timedelta(days=10)
|
||||||
|
reset_at = datetime.combine(
|
||||||
|
closed_on + timedelta(days=1),
|
||||||
|
datetime.min.time(),
|
||||||
|
tzinfo=timezone.utc,
|
||||||
|
)
|
||||||
return PaperTrade(
|
return PaperTrade(
|
||||||
user_id=1,
|
user_id=1,
|
||||||
ticker_id=ticker_id,
|
ticker_id=ticker_id,
|
||||||
@@ -81,17 +77,21 @@ async def test_create_trade_enforces_post_stop_lockdown_at_service_boundary(sess
|
|||||||
closed_on, datetime.min.time(), tzinfo=timezone.utc
|
closed_on, datetime.min.time(), tzinfo=timezone.utc
|
||||||
),
|
),
|
||||||
close_reason="stop",
|
close_reason="stop",
|
||||||
|
reentry_gate_failed_at=reset_at if gate_reset_complete else None,
|
||||||
|
reentry_gate_requalified_at=(
|
||||||
|
reset_at + timedelta(days=1) if gate_reset_complete else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
session.add_all(
|
session.add_all(
|
||||||
[
|
[
|
||||||
stopped_trade(blocked_id, market_sessions[1]),
|
stopped_trade(blocked_id, gate_reset_complete=False),
|
||||||
stopped_trade(released_id, market_sessions[0]),
|
stopped_trade(released_id, gate_reset_complete=True),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
with pytest.raises(ValidationError, match="1 market session remaining"):
|
with pytest.raises(ValidationError, match="requires a post-stop gate reset"):
|
||||||
await svc.create_trade(
|
await svc.create_trade(
|
||||||
session,
|
session,
|
||||||
1,
|
1,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ from hypothesis import given, settings, HealthCheck, strategies as st
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.benchmark_price import BenchmarkPrice
|
|
||||||
from app.models.ohlcv import OHLCVRecord
|
from app.models.ohlcv import OHLCVRecord
|
||||||
from app.models.paper_trade import PaperTrade
|
from app.models.paper_trade import PaperTrade
|
||||||
from app.models.signal_context_snapshot import SignalContextSnapshot
|
from app.models.signal_context_snapshot import SignalContextSnapshot
|
||||||
@@ -609,11 +608,10 @@ async def test_get_trade_setups_can_exclude_tickers_with_open_paper_trades(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
|
async def test_get_trade_setups_applies_initial_stop_gate_reset_lock(
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
):
|
):
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
today = now.date()
|
|
||||||
if await db_session.get(User, 1) is None:
|
if await db_session.get(User, 1) is None:
|
||||||
db_session.add(
|
db_session.add(
|
||||||
User(id=1, username="u", password_hash="x", role="user", has_access=True)
|
User(id=1, username="u", password_hash="x", role="user", has_access=True)
|
||||||
@@ -626,38 +624,6 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
|
|||||||
db_session.add_all([blocked, released, trailing])
|
db_session.add_all([blocked, released, trailing])
|
||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
|
|
||||||
# Six SPY sessions D0..D5 form the canonical market calendar. A stop on
|
|
||||||
# D0 has five later sessions and is released; a stop on D1 has only four.
|
|
||||||
market_sessions = [
|
|
||||||
today - timedelta(days=8),
|
|
||||||
today - timedelta(days=7),
|
|
||||||
today - timedelta(days=6),
|
|
||||||
today - timedelta(days=3),
|
|
||||||
today - timedelta(days=2),
|
|
||||||
today - timedelta(days=1),
|
|
||||||
]
|
|
||||||
for market_date in market_sessions:
|
|
||||||
db_session.add(
|
|
||||||
BenchmarkPrice(
|
|
||||||
symbol="SPY",
|
|
||||||
date=market_date,
|
|
||||||
close=400.0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# A bar from an unrelated/scanner-specific calendar must not release the
|
|
||||||
# ticker one session early. The old universe-wide DISTINCT query did.
|
|
||||||
db_session.add(
|
|
||||||
OHLCVRecord(
|
|
||||||
ticker_id=blocked.id,
|
|
||||||
date=today,
|
|
||||||
open=100.0,
|
|
||||||
high=101.0,
|
|
||||||
low=99.0,
|
|
||||||
close=100.0,
|
|
||||||
volume=1_000,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for ticker in (blocked, released, trailing):
|
for ticker in (blocked, released, trailing):
|
||||||
db_session.add(
|
db_session.add(
|
||||||
TradeSetup(
|
TradeSetup(
|
||||||
@@ -672,7 +638,13 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def closed_trade(ticker: Ticker, closed_on: date, reason: str) -> PaperTrade:
|
def closed_trade(
|
||||||
|
ticker: Ticker,
|
||||||
|
reason: str,
|
||||||
|
*,
|
||||||
|
gate_reset_complete: bool = False,
|
||||||
|
) -> PaperTrade:
|
||||||
|
closed_on = now.date() - timedelta(days=10)
|
||||||
return PaperTrade(
|
return PaperTrade(
|
||||||
user_id=1,
|
user_id=1,
|
||||||
ticker_id=ticker.id,
|
ticker_id=ticker.id,
|
||||||
@@ -690,13 +662,19 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
|
|||||||
closed_on, datetime.min.time(), tzinfo=timezone.utc
|
closed_on, datetime.min.time(), tzinfo=timezone.utc
|
||||||
),
|
),
|
||||||
close_reason=reason,
|
close_reason=reason,
|
||||||
|
reentry_gate_failed_at=(
|
||||||
|
now - timedelta(days=9) if gate_reset_complete else None
|
||||||
|
),
|
||||||
|
reentry_gate_requalified_at=(
|
||||||
|
now - timedelta(days=8) if gate_reset_complete else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
db_session.add_all(
|
db_session.add_all(
|
||||||
[
|
[
|
||||||
closed_trade(blocked, market_sessions[1], "stop"),
|
closed_trade(blocked, "stop"),
|
||||||
closed_trade(released, market_sessions[0], "stop"),
|
closed_trade(released, "stop", gate_reset_complete=True),
|
||||||
closed_trade(trailing, market_sessions[-1], "trailing"),
|
closed_trade(trailing, "trailing"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
@@ -710,7 +688,7 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
|
|||||||
row["symbol"]
|
row["symbol"]
|
||||||
for row in await get_trade_setups(
|
for row in await get_trade_setups(
|
||||||
db_session,
|
db_session,
|
||||||
exclude_reentry_lockdown_tickers=True,
|
exclude_reentry_gate_locked_tickers=True,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
assert "STOP4" not in available_symbols
|
assert "STOP4" not in available_symbols
|
||||||
@@ -719,10 +697,10 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
|
|||||||
annotated = await get_trade_setups(
|
annotated = await get_trade_setups(
|
||||||
db_session,
|
db_session,
|
||||||
symbol="STOP4",
|
symbol="STOP4",
|
||||||
include_reentry_lockdown=True,
|
include_reentry_gate_lock=True,
|
||||||
)
|
)
|
||||||
assert len(annotated) == 1
|
assert len(annotated) == 1
|
||||||
assert annotated[0]["reentry_lockdown_remaining_sessions"] == 1
|
assert annotated[0]["reentry_gate_reset_required"] is True
|
||||||
|
|
||||||
|
|
||||||
async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup:
|
async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup:
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.paper_trade import PaperTrade
|
||||||
|
from app.models.ticker import Ticker
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.trade_policy import (
|
||||||
|
get_reentry_gate_locks,
|
||||||
|
observe_reentry_gate_transitions,
|
||||||
|
)
|
||||||
|
from tests.conftest import _test_session_factory # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def session():
|
||||||
|
async with _test_session_factory() as db:
|
||||||
|
yield db
|
||||||
|
|
||||||
|
|
||||||
|
def _stopped_trade(
|
||||||
|
ticker_id: int,
|
||||||
|
*,
|
||||||
|
closed_at: datetime,
|
||||||
|
gate_failed_at: datetime | None = None,
|
||||||
|
gate_requalified_at: datetime | None = None,
|
||||||
|
) -> PaperTrade:
|
||||||
|
return PaperTrade(
|
||||||
|
user_id=1,
|
||||||
|
ticker_id=ticker_id,
|
||||||
|
direction="long",
|
||||||
|
entry_price=100.0,
|
||||||
|
shares=10.0,
|
||||||
|
stop_loss=95.0,
|
||||||
|
target=115.0,
|
||||||
|
status="closed",
|
||||||
|
opened_at=closed_at - timedelta(days=5),
|
||||||
|
close_price=95.0,
|
||||||
|
closed_at=closed_at,
|
||||||
|
close_reason="stop",
|
||||||
|
reentry_gate_failed_at=gate_failed_at,
|
||||||
|
reentry_gate_requalified_at=gate_requalified_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_observation_releases_only_evaluated_unqualified_tickers(session):
|
||||||
|
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
|
||||||
|
tickers = [
|
||||||
|
Ticker(symbol=symbol)
|
||||||
|
for symbol in ("FAILQ", "PASSQ", "ERRORQ", "LATEQ")
|
||||||
|
]
|
||||||
|
session.add_all(tickers)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
stopped_at = datetime.now(timezone.utc) - timedelta(days=1)
|
||||||
|
trades = [
|
||||||
|
_stopped_trade(ticker.id, closed_at=stopped_at)
|
||||||
|
for ticker in tickers[:3]
|
||||||
|
]
|
||||||
|
observed_at = datetime.now(timezone.utc)
|
||||||
|
trades.append(
|
||||||
|
_stopped_trade(
|
||||||
|
tickers[3].id,
|
||||||
|
closed_at=observed_at + timedelta(seconds=1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add_all(trades)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
updated = await observe_reentry_gate_transitions(
|
||||||
|
session,
|
||||||
|
evaluated_ticker_ids={tickers[0].id, tickers[1].id, tickers[3].id},
|
||||||
|
qualified_ticker_ids={tickers[1].id},
|
||||||
|
observed_at=observed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated == {tickers[0].id}
|
||||||
|
locks = await get_reentry_gate_locks(session)
|
||||||
|
assert set(locks) == {ticker.id for ticker in tickers}
|
||||||
|
assert trades[0].reentry_gate_failed_at == observed_at
|
||||||
|
assert trades[0].reentry_gate_requalified_at is None
|
||||||
|
assert trades[1].reentry_gate_failed_at is None
|
||||||
|
assert trades[2].reentry_gate_failed_at is None
|
||||||
|
assert trades[3].reentry_gate_failed_at is None
|
||||||
|
|
||||||
|
requalified_at = observed_at + timedelta(days=1)
|
||||||
|
updated = await observe_reentry_gate_transitions(
|
||||||
|
session,
|
||||||
|
evaluated_ticker_ids={tickers[0].id},
|
||||||
|
qualified_ticker_ids={tickers[0].id},
|
||||||
|
observed_at=requalified_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated == {tickers[0].id}
|
||||||
|
assert trades[0].reentry_gate_requalified_at == requalified_at
|
||||||
|
assert set(await get_reentry_gate_locks(session)) == {
|
||||||
|
tickers[1].id,
|
||||||
|
tickers[2].id,
|
||||||
|
tickers[3].id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_latest_stop_starts_a_new_gate_reset_episode(session):
|
||||||
|
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
|
||||||
|
ticker = Ticker(symbol="TWOSTOP")
|
||||||
|
session.add(ticker)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
first_stop = datetime.now(timezone.utc) - timedelta(days=20)
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
_stopped_trade(
|
||||||
|
ticker.id,
|
||||||
|
closed_at=first_stop,
|
||||||
|
gate_failed_at=first_stop + timedelta(days=1),
|
||||||
|
gate_requalified_at=first_stop + timedelta(days=2),
|
||||||
|
),
|
||||||
|
_stopped_trade(
|
||||||
|
ticker.id,
|
||||||
|
closed_at=first_stop + timedelta(days=10),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
assert ticker.id in await get_reentry_gate_locks(session)
|
||||||
Reference in New Issue
Block a user