feat: near-close scan schedule and distinct-day gate reset

Move the only qualifying R:R scan to 15:30 ET with chained Telegram alerts,
put outcome eval after a final-bar OHLCV fetch, enforce NY trading-day
requalify semantics, stamp paper trades fill_mode=near_close, and migrate
stored schedule_* keys to America/New_York.
This commit is contained in:
2026-07-18 17:55:39 +02:00
parent 5a61b164f6
commit 736451e26f
14 changed files with 428 additions and 83 deletions
+22 -14
View File
@@ -60,11 +60,11 @@ flowchart TD
**What happens after an initial stop.** The stop always closes the trade and realizes its costs. The ticker is then locked until a successful daily full-universe scan first observes it outside the production gate and a later scan observes a fresh qualification. A continuously qualified ticker therefore cannot generate an immediate duplicate entry. Other exit reasons do not start this reset. See the [daily post-stop re-entry study](docs/research/post-stop-reentry.md). **What happens after an initial stop.** The stop always closes the trade and realizes its costs. The ticker is then locked until a successful daily full-universe scan first observes it outside the production gate and a later scan observes a fresh qualification. A continuously qualified ticker therefore cannot generate an immediate duplicate entry. Other exit reasons do not start this reset. See the [daily post-stop re-entry study](docs/research/post-stop-reentry.md).
**Live timing matters.** The full daily pipeline runs the R:R scan before Outcome Eval. A stop closed by that Outcome Eval—or by an intraday evaluation after the day's full scan—therefore cannot use its stop-day gate state. The earliest failure observation is the next successful full scan, and requalification needs a subsequent full scan. The research `gate_reset` arm evaluated the stop before its same-session gate check; the live boundary is consequently analogous to the study's stricter `strict_gate_reset` arm. This known event-ordering difference is quantified below. **Live timing matters.** The **only** full-universe R:R scan runs near the US close (~15:30 ET), then Telegram alerts fire immediately so manual fills can still hit MOC. Outcome eval runs later (~16:45 ET) after a fresh OHLCV fetch of the final bar. Morning jobs refresh data/sentiment/regime without scanning. Stops closed by earlier same-day intraday outcome evals can get a **same-day** fail observation at the near-close scan — closer to the promoted research `gate_reset` arm than the old morning-scan `strict_gate_reset` analogue. Stops after the bell still need a later day. Same-day fail+qualify cannot unlock: `trade_policy` requires the failure to fall on an earlier America/New_York trading date.
## How It Works ## How It Works
Scheduled pipelines turn raw prices into a ranked, gated list of tradeable setups. Everything downstream of OHLCV is recomputed from stored data, so each refresh is cheap and idempotent. Job timing is cron-based and configurable in **Admin → Jobs** (default timezone Europe/Berlin). Scheduled pipelines turn raw prices into a ranked, gated list of tradeable setups. Everything downstream of OHLCV is recomputed from stored data, so each refresh is cheap and idempotent. Job timing is cron-based and configurable in **Admin → Jobs** (default timezone **America/New_York** so the near-close scan tracks the cash close through DST).
### Price-level architecture: two different jobs ### Price-level architecture: two different jobs
@@ -127,26 +127,34 @@ percentile rails show each input. Only momentum carries the live activation-
gate marker. These are cross-sectional scan percentiles, not historical chart gate marker. These are cross-sectional scan percentiles, not historical chart
indicators. indicators.
### Daily Load — the full refresh ### Pipelines (America/New_York)
Once a day (default 07:00). Steps run **in dependency order**, each consuming the previous step's fresh output: **Morning** (~02:00 ET) — data and display only, **no** qualifying R:R scan:
1. **OHLCV** fetch the latest daily bars for every tracked ticker (Alpaca); new tickers backfill ~5 years. 1. **OHLCV** — latest daily bars (Alpaca); new tickers backfill ~5 years.
2. **Sentiment**fetch sentiment for the names that matter and are stale (> 5 days): top-pick feeders (residual-momentum leaders with a tradeable long setup), the watchlist, and open paper trades, plus a top-N-by-composite discovery net. Runs *before* the scan so the scan sees fresh sentiment. 2. **Sentiment**stale names that matter (top-pick feeders, watchlist, open paper, discovery net). Display context only; the activation gate is price-only.
3. **R:R Scan** — persist clean Structural S/R for charts/alerts, recompute the 5-dimension scores, and build long/short setups from a transient Gate Target Ladder (ATR stops and nominal gate targets) for every ticker. Attach each ticker's residual 121 momentum activation percentile plus the promoted 80/20 production rank. The completed full-universe scan also advances post-stop locks from gate failure to later requalification; failed scans never count as a transition. 3. **Market Regime** + **Regime Monitor** — breadth/trend and the v2 risk thermometer; feed no trades.
4. **Outcome Eval** — resolve setups that hit target/stop or expired (default 30 trading days) and auto-close paper trades per the exit policy (default: 3x ATR trail with a 30-trading-day max hold).
5. **Market Regime** — recompute the regime index (breadth/trend).
6. **Regime Monitor** — separate v2 State/Warning risk thermometer with fixed-basket breadth, VIX, credit, and point-in-time fundamentals; feeds no trades.
A failing step is logged; the pipeline continues with the next. **Near-close** (~15:30 ET MonFri) — the only full-universe qualifying observation:
1. **OHLCV fetch** — refresh the in-progress day-t bar (same path as intraday).
2. **R:R Scan** — Structural S/R, scores, Gate Target Ladder setups, residual 121 + 80/20 rank. Advances post-stop gate-reset transitions; failed scans never count.
3. **Telegram alerts** — chained immediately so manual MOC fills can still hit ~15:50/15:55.
**After close** (~16:45 ET MonFri):
1. **OHLCV fetch** — final bar (not the partial near-close bar).
2. **Outcome Eval** — resolve setups and auto-close paper trades (default 3× ATR trail, 30-day max hold).
A failing step is logged; the pipeline continues with the next. Near-close duration is logged; warn if > 10 minutes.
### Intraday — light refresh ### Intraday — light refresh
Hourly across the US session (MonFri): only **OHLCV → Outcome Eval**, to keep prices current and close paper trades intraday. No scan/sentiment — the dashboard recomputes live R:R from the latest price, so fresh prices are enough. Hourly mid-session (MonFri ~10:0015:00 ET): only **OHLCV → Outcome Eval**, to keep prices current and close paper trades intraday. No scan/sentiment — the dashboard recomputes live R:R from the latest price.
### Other jobs ### Other jobs
Fundamentals (weekly, early Monday) · Alerts (hourly, Telegram) · Backtest (weekly) · Ticker-universe sync (daily). Deep history backfill and event study are manual-only (Admin → Jobs). Fundamentals (weekly, early Monday ET) · Backtest (weekly) · Ticker-universe sync (daily). Alerts auto-fire only via the near-close pipeline (still manually triggerable). Deep history backfill and event study are manual-only (Admin → Jobs).
### From score to "top pick" ### From score to "top pick"
@@ -193,7 +201,7 @@ The production policy is **normal gate reset**, evaluated with daily setup oppor
In the disjoint 2025+ book, gate reset also beat immediate re-entry (Sharpe 1.66 vs 1.55; CAGR 41.8% vs 39.3%) and the fixed five-session rule (Sharpe 1.43; CAGR 32.7%). Its lead over both survived costs of 0.2% and 0.3% per side. The result is capacity-specific: cooldown 5 won at capacity 5, while immediate had slightly higher return and Sharpe at capacity 15. Production uses capacity 10, so that is the portfolio for which this decision is valid. In the disjoint 2025+ book, gate reset also beat immediate re-entry (Sharpe 1.66 vs 1.55; CAGR 41.8% vs 39.3%) and the fixed five-session rule (Sharpe 1.43; CAGR 32.7%). Its lead over both survived costs of 0.2% and 0.3% per side. The result is capacity-specific: cooldown 5 won at capacity 5, while immediate had slightly higher return and Sharpe at capacity 15. Production uses capacity 10, so that is the portfolio for which this decision is valid.
Those promotion numbers belong to the selected normal-reset study arm. Under the live scheduler's stricter first-observation timing, the full-period analogue was Sharpe 1.68 / CAGR 44.8% / DD 23.4%; in the disjoint 2025+ book it was Sharpe 1.38 / CAGR 32.9% / DD 21.0%. The matrix therefore validates the state-machine choice but is not exact scheduler-order parity. Closing this timing gap would require a separately reviewed pipeline-order change, not a documentation reinterpretation. Those promotion numbers belong to the selected normal-reset study arm. Under the **pre-cutover** morning-scan scheduler (scan always before any outcome eval), live first-observation timing matched the stricter `strict_gate_reset` analogue (full-period Sharpe 1.68 / CAGR 44.8% / DD 23.4%). After the **near-close cutover** (2026-07), stops closed by earlier same-day intraday evals can receive a same-day fail observation at ~15:30 ET — moving live behavior **toward** the promoted `gate_reset` arm. Requalification still requires a later America/New_York trading date than the failure (`trade_policy` distinct-day guard). Full definitions and all nine policy arms: [docs/research/post-stop-reentry.md](docs/research/post-stop-reentry.md); execution evidence: [docs/research/execution-recovery.md](docs/research/execution-recovery.md).
`gate_reset` and a simple `next_session` block happened to produce the same executed live-universe portfolio in this sample. Their rules are still different: this establishes that same-day re-entry was harmful here, but does not isolate a separate historical return premium from the reset condition. Gate reset was promoted because it represents a genuinely new signal episode and did not sacrifice results in the production book. Full definitions, all nine policy arms, cost/capacity sensitivity, and legacy-rank results are in [docs/research/post-stop-reentry.md](docs/research/post-stop-reentry.md); source report: [`reports/daily_reentry_matrix.json`](reports/daily_reentry_matrix.json). `gate_reset` and a simple `next_session` block happened to produce the same executed live-universe portfolio in this sample. Their rules are still different: this establishes that same-day re-entry was harmful here, but does not isolate a separate historical return premium from the reset condition. Gate reset was promoted because it represents a genuinely new signal episode and did not sacrifice results in the production book. Full definitions, all nine policy arms, cost/capacity sensitivity, and legacy-rank results are in [docs/research/post-stop-reentry.md](docs/research/post-stop-reentry.md); source report: [`reports/daily_reentry_matrix.json`](reports/daily_reentry_matrix.json).
@@ -0,0 +1,73 @@
"""near-close schedule cutover + paper trade fill_mode era tag
Revision ID: 023
Revises: 022
Create Date: 2026-07-18 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "023"
down_revision: Union[str, None] = "022"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Deliberate schedule rewrite (not a soft defaults refresh). Old stored values
# are logged then replaced so prod does not keep scanning at 07:00 Berlin.
_SCHEDULE_REWRITE: dict[str, str] = {
"schedule_timezone": "America/New_York",
"schedule_daily_pipeline_cron": "0 2 * * *",
"schedule_near_close_pipeline_cron": "30 15 * * 1-5",
"schedule_after_close_pipeline_cron": "45 16 * * 1-5",
"schedule_intraday_pipeline_cron": "0 10-15 * * 1-5",
"schedule_fundamentals_cron": "0 1 * * 1",
}
def upgrade() -> None:
op.add_column(
"paper_trades",
sa.Column("fill_mode", sa.String(length=20), nullable=True),
)
conn = op.get_bind()
settings = sa.table(
"system_settings",
sa.column("id", sa.Integer),
sa.column("key", sa.String),
sa.column("value", sa.Text),
sa.column("updated_at", sa.DateTime(timezone=True)),
)
now = sa.func.now()
for key, new_value in _SCHEDULE_REWRITE.items():
row = conn.execute(
sa.select(settings.c.value).where(settings.c.key == key)
).fetchone()
old_value = row[0] if row is not None else None
# Always log so ops can recover the pre-cutover schedule from migration output.
print(
f"schedule_cutover {key}: {old_value!r} -> {new_value!r}",
flush=True,
)
if row is None:
conn.execute(
sa.insert(settings).values(
key=key, value=new_value, updated_at=now
)
)
else:
conn.execute(
sa.update(settings)
.where(settings.c.key == key)
.values(value=new_value, updated_at=now)
)
def downgrade() -> None:
op.drop_column("paper_trades", "fill_mode")
# Do not restore old crons — unknown prior values; leave stored schedule as-is.
+3
View File
@@ -46,3 +46,6 @@ class PaperTrade(Base):
reentry_gate_requalified_at: Mapped[datetime | None] = mapped_column( reentry_gate_requalified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True DateTime(timezone=True), nullable=True
) )
# Execution era for forward vs backtest comparison:
# null/legacy = pre-cutover morning-scan, "near_close" = post near-close cutover.
fill_mode: Mapped[str | None] = mapped_column(String(20), nullable=True)
+137 -21
View File
@@ -93,7 +93,9 @@ _JOB_NAMES = [
"regime_monitor", "regime_monitor",
"event_study", "event_study",
"backtest", "backtest",
"daily_pipeline", "daily_pipeline", # morning: OHLCV/sentiment/regime — no qualifying scan
"near_close_pipeline", # OHLCV fetch → R:R scan → Telegram alerts
"after_close_pipeline", # OHLCV fetch → outcome eval (final bar)
"intraday_pipeline", "intraday_pipeline",
] ]
@@ -1177,17 +1179,41 @@ async def sync_ticker_universe() -> None:
# updates its own runtime status while the pipeline runs. # updates its own runtime status while the pipeline runs.
# #
# Daily (full): the complete data→signal refresh, once a day. # Daily (full): the complete data→signal refresh, once a day.
# Morning (America/New_York ~02:00): refresh data + display context. No R:R scan
# — the qualifying full-universe scan runs once near the US close so post-stop
# gate-reset sees one observation per trading day (plus the trade_policy
# distinct-day guard for manual re-scans).
_DAILY_PIPELINE_STEPS = [ _DAILY_PIPELINE_STEPS = [
("data_collector", "collect_ohlcv"), ("data_collector", "collect_ohlcv"),
("benchmark_collector", "collect_benchmark"), ("benchmark_collector", "collect_benchmark"),
("sentiment_collector", "collect_sentiment"), ("sentiment_collector", "collect_sentiment"),
("rr_scanner", "scan_rr"),
("outcome_evaluator", "evaluate_outcomes"),
("market_regime", "compute_market_regime"), ("market_regime", "compute_market_regime"),
# Observational only — runs here for scheduling; its output feeds nothing else. # Observational only — display/alerts; not trade selection.
("regime_monitor", "compute_regime_monitor"), ("regime_monitor", "compute_regime_monitor"),
] ]
# Near-close (~15:30 ET MonFri): refresh in-progress day-t bars (already how
# the intraday pipeline keeps the dashboard live), then the only daily
# qualifying R:R scan, then Telegram immediately so manual fills can still hit
# MOC cutoffs (~15:50/15:55). Under a 15-minute delayed SIP feed a 15:30 scan
# may see ~15:15 prices — immaterial for a 12-1 momentum signal.
#
# US early-close days (~3/year, 13:00 ET close): this job runs post-close and
# entries behave like stale_close (still acceptable per execution-recovery matrix).
# No exchange calendar dependency.
_NEAR_CLOSE_PIPELINE_STEPS = [
("data_collector", "collect_ohlcv"),
("rr_scanner", "scan_rr"),
("alerts", "dispatch_alerts_job"),
]
# After close (~16:45 ET MonFri): fresh OHLCV fetch so outcomes resolve on the
# final bar, not the near-close partial bar, then outcome/paper close.
_AFTER_CLOSE_PIPELINE_STEPS = [
("data_collector", "collect_ohlcv"),
("outcome_evaluator", "evaluate_outcomes"),
]
# Intraday (light): keep prices current and resolve outcomes through the day, # Intraday (light): keep prices current and resolve outcomes through the day,
# without the expensive scan/sentiment. The dashboard recomputes live R:R from # without the expensive scan/sentiment. The dashboard recomputes live R:R from
# the latest price, so refreshing OHLCV is enough to stop prices lagging; the # the latest price, so refreshing OHLCV is enough to stop prices lagging; the
@@ -1197,6 +1223,10 @@ _INTRADAY_PIPELINE_STEPS = [
("outcome_evaluator", "evaluate_outcomes"), ("outcome_evaluator", "evaluate_outcomes"),
] ]
# Warn if near-close fetch+scan+alert drifts past this — entries leave the close
# and the stale_close floor quietly becomes the ceiling.
_NEAR_CLOSE_DURATION_WARN_SECONDS = 600
async def _run_pipeline(job_name: str, steps: list[tuple[str, str]]) -> None: async def _run_pipeline(job_name: str, steps: list[tuple[str, str]]) -> None:
"""Run an ordered list of (step_name, coroutine_name) steps. """Run an ordered list of (step_name, coroutine_name) steps.
@@ -1232,11 +1262,44 @@ async def _run_pipeline(job_name: str, steps: list[tuple[str, str]]) -> None:
async def run_daily_pipeline() -> None: async def run_daily_pipeline() -> None:
"""Full daily flow: OHLCV → benchmark → sentiment → R:R scan → outcome eval """Morning flow: OHLCV → benchmark → sentiment → market regime (no scan)."""
(+paper close) → market regime."""
await _run_pipeline("daily_pipeline", _DAILY_PIPELINE_STEPS) await _run_pipeline("daily_pipeline", _DAILY_PIPELINE_STEPS)
async def run_near_close_pipeline() -> None:
"""Near-close flow: OHLCV fetch → R:R scan → Telegram alerts.
Logs wall duration; warn if past 10 minutes so operators notice close drift.
"""
import time
started = time.monotonic()
await _run_pipeline("near_close_pipeline", _NEAR_CLOSE_PIPELINE_STEPS)
elapsed = time.monotonic() - started
payload = {
"job": "near_close_pipeline",
"duration_seconds": round(elapsed, 1),
}
if elapsed > _NEAR_CLOSE_DURATION_WARN_SECONDS:
_log_event(
logging.WARNING,
"near_close_pipeline_slow",
**payload,
threshold_seconds=_NEAR_CLOSE_DURATION_WARN_SECONDS,
message=(
"Near-close pipeline exceeded 10 minutes — entries may drift from "
"the close toward the stale_close research floor"
),
)
else:
_log_event(logging.INFO, "near_close_pipeline_duration", **payload)
async def run_after_close_pipeline() -> None:
"""After-close flow: OHLCV fetch (final bar) → outcome eval (+paper close)."""
await _run_pipeline("after_close_pipeline", _AFTER_CLOSE_PIPELINE_STEPS)
async def run_intraday_pipeline() -> None: async def run_intraday_pipeline() -> None:
"""Light intraday flow: refresh OHLCV → evaluate outcomes (+paper close).""" """Light intraday flow: refresh OHLCV → evaluate outcomes (+paper close)."""
await _run_pipeline("intraday_pipeline", _INTRADAY_PIPELINE_STEPS) await _run_pipeline("intraday_pipeline", _INTRADAY_PIPELINE_STEPS)
@@ -1268,16 +1331,28 @@ def _parse_frequency(freq: str) -> dict[str, int]:
# every process restart, so on a box that's redeployed often it can keep being # every process restart, so on a box that's redeployed often it can keep being
# deferred and never fire. Cron fires at a fixed local time regardless. # deferred and never fire. Cron fires at a fixed local time regardless.
# All wall times are America/New_York after the near-close execution cutover.
# Stored SystemSetting values shadow these defaults — deploy migration 023
# rewrites schedule_* keys so prod does not keep scanning at 07:00 Berlin.
SCHEDULE_DEFAULTS: dict[str, str] = { SCHEDULE_DEFAULTS: dict[str, str] = {
"schedule_timezone": "Europe/Berlin", "schedule_timezone": "America/New_York",
"schedule_daily_pipeline_cron": "0 7 * * *", # full refresh, ready by ~8am # Morning data/display refresh (no qualifying R:R scan).
"schedule_intraday_pipeline_cron": "0 14-22 * * 1-5", # hourly across the US session "schedule_daily_pipeline_cron": "0 2 * * *",
"schedule_fundamentals_cron": "0 4 * * 1", # weekly, early Monday (slow job) # Fetch in-progress bars → scan → Telegram (manual MOC window).
"schedule_near_close_pipeline_cron": "30 15 * * 1-5",
# Fetch final bars → outcome eval (must not run on the partial near-close bar).
"schedule_after_close_pipeline_cron": "45 16 * * 1-5",
# Hourly mid-session price + outcome (10:0015:00 ET MonFri).
"schedule_intraday_pipeline_cron": "0 10-15 * * 1-5",
# Weekly fundamentals early Monday NY.
"schedule_fundamentals_cron": "0 1 * * 1",
} }
# job id -> schedule setting key # job id -> schedule setting key
_CRON_JOBS: dict[str, str] = { _CRON_JOBS: dict[str, str] = {
"daily_pipeline": "schedule_daily_pipeline_cron", "daily_pipeline": "schedule_daily_pipeline_cron",
"near_close_pipeline": "schedule_near_close_pipeline_cron",
"after_close_pipeline": "schedule_after_close_pipeline_cron",
"intraday_pipeline": "schedule_intraday_pipeline_cron", "intraday_pipeline": "schedule_intraday_pipeline_cron",
"fundamental_collector": "schedule_fundamentals_cron", "fundamental_collector": "schedule_fundamentals_cron",
} }
@@ -1357,7 +1432,29 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
scheduler.add_job( scheduler.add_job(
run_daily_pipeline, run_daily_pipeline,
_cron_trigger(cfg["schedule_daily_pipeline_cron"], tz, "schedule_daily_pipeline_cron"), _cron_trigger(cfg["schedule_daily_pipeline_cron"], tz, "schedule_daily_pipeline_cron"),
id="daily_pipeline", name="Daily Pipeline", replace_existing=True, id="daily_pipeline", name="Morning Pipeline", replace_existing=True,
)
scheduler.add_job(
run_near_close_pipeline,
_cron_trigger(
cfg["schedule_near_close_pipeline_cron"],
tz,
"schedule_near_close_pipeline_cron",
),
id="near_close_pipeline",
name="Near-Close Pipeline (scan+alert)",
replace_existing=True,
)
scheduler.add_job(
run_after_close_pipeline,
_cron_trigger(
cfg["schedule_after_close_pipeline_cron"],
tz,
"schedule_after_close_pipeline_cron",
),
id="after_close_pipeline",
name="After-Close Pipeline (outcome)",
replace_existing=True,
) )
scheduler.add_job( scheduler.add_job(
run_intraday_pipeline, run_intraday_pipeline,
@@ -1377,10 +1474,12 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
sync_ticker_universe, "interval", hours=24, sync_ticker_universe, "interval", hours=24,
id="ticker_universe_sync", name="Ticker Universe Sync", replace_existing=True, id="ticker_universe_sync", name="Ticker Universe Sync", replace_existing=True,
) )
alerts_interval = _parse_frequency(settings.alerts_frequency) # Alerts auto-fire only via near_close_pipeline (scan → alert before MOC).
# Keep the job registered for Admin manual trigger; no independent interval.
scheduler.add_job( scheduler.add_job(
dispatch_alerts_job, "interval", **alerts_interval, dispatch_alerts_job, "interval", weeks=520,
id="alerts", name="Alerts Dispatcher", replace_existing=True, id="alerts", name="Alerts Dispatcher",
replace_existing=True, next_run_time=None,
) )
scheduler.add_job( scheduler.add_job(
run_backtest_job, "interval", hours=168, run_backtest_job, "interval", hours=168,
@@ -1400,10 +1499,27 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
replace_existing=True, next_run_time=None, replace_existing=True, next_run_time=None,
) )
_log_event(logging.INFO, "scheduler_configured", timezone=tz, daily_pipeline={ _log_event(
"cron": cfg["schedule_daily_pipeline_cron"], logging.INFO,
"steps": [name for name, _ in _DAILY_PIPELINE_STEPS], "scheduler_configured",
}, intraday_pipeline={ timezone=tz,
"cron": cfg["schedule_intraday_pipeline_cron"], daily_pipeline={
"steps": [name for name, _ in _INTRADAY_PIPELINE_STEPS], "cron": cfg["schedule_daily_pipeline_cron"],
}, fundamental_collector={"cron": cfg["schedule_fundamentals_cron"]}, independent=["ticker_universe_sync", "alerts", "backtest"]) "steps": [name for name, _ in _DAILY_PIPELINE_STEPS],
},
near_close_pipeline={
"cron": cfg["schedule_near_close_pipeline_cron"],
"steps": [name for name, _ in _NEAR_CLOSE_PIPELINE_STEPS],
},
after_close_pipeline={
"cron": cfg["schedule_after_close_pipeline_cron"],
"steps": [name for name, _ in _AFTER_CLOSE_PIPELINE_STEPS],
},
intraday_pipeline={
"cron": cfg["schedule_intraday_pipeline_cron"],
"steps": [name for name, _ in _INTRADAY_PIPELINE_STEPS],
},
fundamental_collector={"cron": cfg["schedule_fundamentals_cron"]},
independent=["ticker_universe_sync", "backtest"],
manual_only=["alerts", "data_backfill", "event_study"],
)
+3 -1
View File
@@ -75,9 +75,11 @@ class ActivationConfigUpdate(BaseModel):
class ScheduleConfigUpdate(BaseModel): class ScheduleConfigUpdate(BaseModel):
"""Cron schedule for the pipelines + fundamentals. Crons are 5-field """Cron schedule for the pipelines + fundamentals. Crons are 5-field
(min hour dom month dow); timezone is an IANA name (e.g. Europe/Berlin).""" (min hour dom month dow); timezone is an IANA name (e.g. America/New_York)."""
schedule_timezone: str | None = Field(default=None, max_length=64) schedule_timezone: str | None = Field(default=None, max_length=64)
schedule_daily_pipeline_cron: str | None = Field(default=None, max_length=120) schedule_daily_pipeline_cron: str | None = Field(default=None, max_length=120)
schedule_near_close_pipeline_cron: str | None = Field(default=None, max_length=120)
schedule_after_close_pipeline_cron: str | None = Field(default=None, max_length=120)
schedule_intraday_pipeline_cron: str | None = Field(default=None, max_length=120) schedule_intraday_pipeline_cron: str | None = Field(default=None, max_length=120)
schedule_fundamentals_cron: str | None = Field(default=None, max_length=120) schedule_fundamentals_cron: str | None = Field(default=None, max_length=120)
+2
View File
@@ -47,6 +47,8 @@ class PaperTradeResponse(BaseModel):
alpha_pct: float | None = None alpha_pct: float | None = None
alpha_usd: float | None = None alpha_usd: float | None = None
close_reason: str | None = None close_reason: str | None = None
# Execution era: null = pre-cutover / unknown; "near_close" = post schedule cutover.
fill_mode: str | None = None
# Live trailing-stop level + how far price sits above it (% ), for open trades # Live trailing-stop level + how far price sits above it (% ), for open trades
# when the trailing exit policy is active. # when the trailing exit policy is active.
trailing_stop: float | None = None trailing_stop: float | None = None
+7 -2
View File
@@ -566,6 +566,8 @@ VALID_JOB_NAMES = {
"event_study", "event_study",
"backtest", "backtest",
"daily_pipeline", "daily_pipeline",
"near_close_pipeline",
"after_close_pipeline",
"intraday_pipeline", "intraday_pipeline",
} }
@@ -583,17 +585,20 @@ JOB_LABELS = {
"regime_monitor": "Regime Monitor", "regime_monitor": "Regime Monitor",
"event_study": "Event Study", "event_study": "Event Study",
"backtest": "Backtest", "backtest": "Backtest",
"daily_pipeline": "Daily Pipeline", "daily_pipeline": "Morning Pipeline",
"near_close_pipeline": "Near-Close Pipeline (scan+alert)",
"after_close_pipeline": "After-Close Pipeline (outcome)",
"intraday_pipeline": "Intraday Pipeline", "intraday_pipeline": "Intraday Pipeline",
} }
# Jobs driven by the daily_pipeline (in order) rather than their own timer. # Jobs driven by a pipeline (in order) rather than their own auto timer.
PIPELINE_MEMBERS = { PIPELINE_MEMBERS = {
"data_collector", "data_collector",
"benchmark_collector", "benchmark_collector",
"sentiment_collector", "sentiment_collector",
"rr_scanner", "rr_scanner",
"outcome_evaluator", "outcome_evaluator",
"alerts",
"market_regime", "market_regime",
"regime_monitor", "regime_monitor",
} }
+4
View File
@@ -333,6 +333,9 @@ async def create_trade(
target=target, target=target,
status="open", status="open",
opened_at=datetime.now(timezone.utc), opened_at=datetime.now(timezone.utc),
# Near-close cutover era — Track Record must not mix with morning-scan
# fills or future broker-routed fills when comparing to backtests.
fill_mode="near_close",
) )
db.add(trade) db.add(trade)
await db.commit() await db.commit()
@@ -386,6 +389,7 @@ def _to_dict(
"alpha_pct": alpha_pct, "alpha_pct": alpha_pct,
"alpha_usd": alpha_usd, "alpha_usd": alpha_usd,
"close_reason": trade.close_reason, "close_reason": trade.close_reason,
"fill_mode": trade.fill_mode,
"trailing_stop": trailing[0] if trailing else None, "trailing_stop": trailing[0] if trailing else None,
"trailing_distance_pct": trailing[1] if trailing else None, "trailing_distance_pct": trailing[1] if trailing else None,
} }
+20 -3
View File
@@ -3,13 +3,24 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable from collections.abc import Iterable
from datetime import datetime, timezone from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo
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.paper_trade import PaperTrade from app.models.paper_trade import PaperTrade
# Gate-reset "day" boundary matches US cash equities session calendar, not UTC.
_REENTRY_DAY_TZ = ZoneInfo("America/New_York")
def _ny_trading_date(moment: datetime) -> date:
"""Calendar date in America/New_York for a gate-reset observation."""
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
return moment.astimezone(_REENTRY_DAY_TZ).date()
async def _latest_initial_stop_trades( async def _latest_initial_stop_trades(
db: AsyncSession, db: AsyncSession,
@@ -93,8 +104,14 @@ async def observe_reentry_gate_transitions(
trade.reentry_gate_failed_at = timestamp trade.reentry_gate_failed_at = timestamp
updated.add(ticker_id) updated.add(ticker_id)
elif ticker_id in qualified: elif ticker_id in qualified:
trade.reentry_gate_requalified_at = timestamp # Study semantics: requalify only on a *subsequent* daily observation.
updated.add(ticker_id) # Same America/New_York calendar day as the failure does not unlock,
# even if multiple full-universe scans run (manual + near-close).
if _ny_trading_date(trade.reentry_gate_failed_at) < _ny_trading_date(
timestamp
):
trade.reentry_gate_requalified_at = timestamp
updated.add(ticker_id)
if updated: if updated:
await db.flush() await db.flush()
+74 -28
View File
@@ -99,40 +99,79 @@ tail-trimming until shown otherwise.
--- ---
## Ops design — settle before touching the scheduler ## Ops design — implementation plan (code-checked)
### 1. One canonical qualifying scan per day (non-negotiable) Assumptions verified against current code before ship:
Post-stop gate-reset was validated on **one full-universe observation per day**. - Intraday pipeline already fetches/upserts the **in-progress day-t bar** all
Adding a near-close scan *alongside* the 07:00 scan would let fail→qualify session (`fetch_ohlcv` end_date defaults to today). Near-close job =
transitions complete twice as fast and **silently change** the validated re-entry **OHLCV fetch → R:R scan** (no new snapshot synthesizer).
policy. - One global `schedule_timezone` (default `Europe/Berlin`); stored
`SystemSetting` values shadow code defaults — **defaults alone do not
migrate prod**.
- `observe_reentry_gate_transitions` stamps timestamps with **no same-day
guard** today — dual scans would accelerate fail→requalify unless fixed in
`trade_policy`.
**Move** the R:R scan to near-close. **Leave** sentiment / fundamentals / ### Semantic guard (ship step 1 — precondition)
OHLCV-backfill at 07:00 (or existing early slots). Preserve **scan before
Outcome Eval** (scan late session, eval after close) so the documented
strict-gate-reset live analogue is unchanged.
### 2. Cron in `America/New_York`, not `Europe/Berlin` In `trade_policy` (not the scheduler):
DST offsets shift on different dates. A Berlin-fixed wall time drifts ~1 hour > `reentry_gate_requalified_at` may only be set when `reentry_gate_failed_at`
off the US close for a week or two twice a year. > falls on an **earlier America/New_York trading date** than the current
> observation.
### 3. Fill mechanics and feed honesty Manual mid-day scans stay allowed; same-day fail+qualify cannot unlock.
Unit test: fail 10:00 / qualify 15:35 same day → still locked; qualify next day → unlocked.
- NYSE MOC cutoff **15:50 ET**; Nasdaq **15:55 ET**. ### Schedule split
- Scan ~**15:3015:40 ET** on a latest-price snapshot; place entries by 15:50.
- For now: paper-trade entries marked at the **actual close**.
- Document Alpaca entitlement: if only 15-minute-delayed SIP, a 15:35 scan sees
~15:20 prices — immaterial for a 12-month signal, but write it down so nobody
treats it as a bug.
### 4. Partial-bar plumbing | Slot (America/New_York) | Jobs |
|---|---|
| Morning (~02:00) | OHLCV backfill, benchmark, sentiment, fundamentals — **no** qualifying R:R scan |
| Near-close (~15:30 MonFri) | OHLCV fetch (refresh day-t bar) → **R:R scan** (only daily qualifying observation) |
| After close (~16:3017:00) | **Outcome eval** on its own slot (not chained to the partial-bar scan) |
| Intraday hourly | Unchanged in NY terms; last ~16:00 still mid-session under 15m feed |
- Scan synthesizes day-ts in-progress bar from the snapshot. - Near-close scan **15 only**; US-holiday no-ops are fine (stale identical data
- Nightly OHLCV job overwrites with the final bar via existing idempotent upsert. cant flip gates) — comment only, no exchange calendar.
- Forward paper record marks entries at the actual near-close fill so the live - **Do not** run morning + near-close qualifying scans; move the scan, dont add a second.
track measures the new execution honestly.
### Behavior change to document (not an accident)
With scan at ~15:35 ET, stops closed by **earlier same-day** intraday outcome
evals can get a **same-day fail observation** — closer to the **promoted**
`gate_reset` arm (stop-day close may establish failure) than todays
`strict_gate_reset` analogue (scan always before any eval). Stops after the
bell still wait a day. Rewrite README “Live timing matters” / post-stop sections
and a line here when shipping.
### Feed / paper honesty
- Document 15-minute delayed SIP: 15:35 scan may see ~15:20 prices; OK for 12-1.
- Paper entry price ≈ scan entry (near close) is nearly automatic; add
**`fill_mode=near_close` era tag** so Track Record can separate morning-scan /
near-close / future broker-routed eras.
- Morning sentiment staleness is display-only; gate is price-only (GTL parity /
neutral-sentiment backtest). One doc line closes that.
### Stored settings migration (ship step 4)
Flip global default TZ to `America/New_York` and re-express crons in NY time.
**Also** migration (or documented Admin rewrite) of stored `schedule_*` keys so
prod does not keep 07:00 Berlin silently.
### Ship order
1. `trade_policy` distinct-day requalify guard + unit test
2. Near-close job = existing fetch → scan; outcome eval own after-close slot
3. Paper `fill_mode=near_close` era tag; verify entry marking
4. Defaults + **stored settings migration** + README/research timing rewrite
### Out of scope
Broker MOC routing, more fill-timing sim, nasdaq_all / fip / sector (grade later
under the fill mode you trade).
--- ---
@@ -141,6 +180,13 @@ off the US close for a week or two twice a year.
| Item | Status | | Item | Status |
|---|---| |---|---|
| Research evidence | **Closed** — this doc + matrix report | | Research evidence | **Closed** — this doc + matrix report |
| Scheduler move (R:R scan → NY near-close) | **Not started** — blocked on ops design above | | Distinct-day gate-reset guard | **Shipped**`trade_policy` + unit test |
| Partial-bar scan path | **Not started** | | Schedule split + near-close scan→alert | **Shipped** — morning / near-close / after-close (fetch→outcome) |
| Paper fill-at-close marking | **Not started** (may already mark at close; verify when shipping) | | Paper era tag | **Shipped**`fill_mode=near_close` on new paper trades |
| Settings migration + docs | **Shipped** — alembic 023 rewrites schedule_*; README updated |
### Shipped behavior change (not accidental)
Near-close scan at ~15:30 ET lets same-day fail observations after earlier
intraday stop closes — closer to promoted `gate_reset` than the old
morning-scan `strict_gate_reset` analogue. Documented in README.
@@ -4,34 +4,48 @@ import { useScheduleSettings, useUpdateScheduleSettings } from '../../hooks/useA
import { SkeletonTable } from '../ui/Skeleton'; import { SkeletonTable } from '../ui/Skeleton';
const DEFAULTS: ScheduleConfig = { const DEFAULTS: ScheduleConfig = {
schedule_timezone: 'Europe/Berlin', schedule_timezone: 'America/New_York',
schedule_daily_pipeline_cron: '0 7 * * *', schedule_daily_pipeline_cron: '0 2 * * *',
schedule_intraday_pipeline_cron: '0 14-22 * * 1-5', schedule_near_close_pipeline_cron: '30 15 * * 1-5',
schedule_fundamentals_cron: '0 4 * * 1', schedule_after_close_pipeline_cron: '45 16 * * 1-5',
schedule_intraday_pipeline_cron: '0 10-15 * * 1-5',
schedule_fundamentals_cron: '0 1 * * 1',
}; };
const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: boolean }[] = [ const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: boolean }[] = [
{ {
key: 'schedule_timezone', key: 'schedule_timezone',
label: 'Timezone', label: 'Timezone',
hint: 'IANA name, e.g. Europe/Berlin. All times below are in this zone.', hint: 'IANA name. Prefer America/New_York so the near-close scan tracks the US cash close through DST.',
}, },
{ {
key: 'schedule_daily_pipeline_cron', key: 'schedule_daily_pipeline_cron',
label: 'Daily pipeline (full)', label: 'Morning pipeline',
hint: 'OHLCV → sentiment → R:R scan → outcomes → regime. Default 07:00 so data is ready by 8.', hint: 'OHLCV → benchmark → sentiment → regime (no R:R scan). Default 02:00 ET.',
mono: true,
},
{
key: 'schedule_near_close_pipeline_cron',
label: 'Near-close pipeline (scan + alert)',
hint: 'OHLCV fetch → R:R scan → Telegram. Default 15:30 ET MonFri so manual MOC fills can still hit ~15:50/15:55.',
mono: true,
},
{
key: 'schedule_after_close_pipeline_cron',
label: 'After-close pipeline (outcome)',
hint: 'OHLCV fetch (final bar) → outcome eval. Default 16:45 ET MonFri — not chained to the partial near-close bar.',
mono: true, mono: true,
}, },
{ {
key: 'schedule_intraday_pipeline_cron', key: 'schedule_intraday_pipeline_cron',
label: 'Intraday pipeline (light)', label: 'Intraday pipeline (light)',
hint: 'Refresh prices + resolve outcomes. Default hourly across the US session, weekdays.', hint: 'Refresh prices + resolve outcomes mid-session. Default hourly 10:0015:00 ET weekdays.',
mono: true, mono: true,
}, },
{ {
key: 'schedule_fundamentals_cron', key: 'schedule_fundamentals_cron',
label: 'Fundamentals (weekly)', label: 'Fundamentals (weekly)',
hint: 'Slow, rate-limited. Default early Monday so it finishes well before the day starts.', hint: 'Slow, rate-limited. Default early Monday ET.',
mono: true, mono: true,
}, },
]; ];
@@ -43,7 +57,7 @@ export function ScheduleSettings() {
const [form, setForm] = useState<ScheduleConfig>(DEFAULTS); const [form, setForm] = useState<ScheduleConfig>(DEFAULTS);
useEffect(() => { useEffect(() => {
if (data) setForm(data); if (data) setForm({ ...DEFAULTS, ...data });
}, [data]); }, [data]);
if (isLoading) return <SkeletonTable rows={2} cols={2} />; if (isLoading) return <SkeletonTable rows={2} cols={2} />;
@@ -55,8 +69,8 @@ export function ScheduleSettings() {
<h3 className="text-sm font-semibold text-gray-200">Pipeline Schedule</h3> <h3 className="text-sm font-semibold text-gray-200">Pipeline Schedule</h3>
<p className="mt-1 text-xs text-gray-500"> <p className="mt-1 text-xs text-gray-500">
When the jobs run, as 5-field cron (<span className="num">min hour day month weekday</span>). When the jobs run, as 5-field cron (<span className="num">min hour day month weekday</span>).
Saved changes apply to the running scheduler immediately no redeploy. The big nightly run Saved changes apply to the running scheduler immediately no redeploy.
does the full refresh; the light intraday run just keeps prices current. One qualifying R:R scan per day is the near-close job; alerts fire immediately after that scan.
</p> </p>
</div> </div>
@@ -66,7 +80,7 @@ export function ScheduleSettings() {
<span className="text-xs text-gray-400">{f.label}</span> <span className="text-xs text-gray-400">{f.label}</span>
<input <input
type="text" type="text"
value={form[f.key]} value={form[f.key] ?? ''}
spellCheck={false} spellCheck={false}
onChange={(e) => setForm((prev) => ({ ...prev, [f.key]: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, [f.key]: e.target.value }))}
className={`w-full input-glass px-3 py-2 text-sm ${f.mono ? 'num' : ''}`} className={`w-full input-glass px-3 py-2 text-sm ${f.mono ? 'num' : ''}`}
+3 -1
View File
@@ -187,10 +187,12 @@ export interface ActivationConfig {
exclude_neutral: boolean; exclude_neutral: boolean;
} }
// Cron schedule for the daily/intraday pipelines + fundamentals // Cron schedule for morning / near-close / after-close / intraday + fundamentals
export interface ScheduleConfig { export interface ScheduleConfig {
schedule_timezone: string; schedule_timezone: string;
schedule_daily_pipeline_cron: string; schedule_daily_pipeline_cron: string;
schedule_near_close_pipeline_cron: string;
schedule_after_close_pipeline_cron: string;
schedule_intraday_pipeline_cron: string; schedule_intraday_pipeline_cron: string;
schedule_fundamentals_cron: string; schedule_fundamentals_cron: string;
} }
+4
View File
@@ -115,6 +115,8 @@ class TestConfigureScheduler:
"event_study", "event_study",
"backtest", "backtest",
"daily_pipeline", "daily_pipeline",
"near_close_pipeline",
"after_close_pipeline",
"intraday_pipeline", "intraday_pipeline",
} }
@@ -125,6 +127,7 @@ class TestConfigureScheduler:
job_ids = [j.id for j in scheduler.get_jobs()] job_ids = [j.id for j in scheduler.get_jobs()]
# Each ID should appear exactly once # Each ID should appear exactly once
assert sorted(job_ids) == sorted([ assert sorted(job_ids) == sorted([
"after_close_pipeline",
"alerts", "alerts",
"backtest", "backtest",
"benchmark_collector", "benchmark_collector",
@@ -134,6 +137,7 @@ class TestConfigureScheduler:
"data_backfill", "data_backfill",
"fundamental_collector", "fundamental_collector",
"market_regime", "market_regime",
"near_close_pipeline",
"regime_monitor", "regime_monitor",
"event_study", "event_study",
"outcome_evaluator", "outcome_evaluator",
+49
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import pytest import pytest
from sqlalchemy import select
from app.models.paper_trade import PaperTrade from app.models.paper_trade import PaperTrade
from app.models.ticker import Ticker from app.models.ticker import Ticker
@@ -129,6 +130,54 @@ async def test_latest_stop_starts_a_new_gate_reset_episode(session):
assert ticker.id in await get_reentry_gate_locks(session) assert ticker.id in await get_reentry_gate_locks(session)
async def test_same_day_fail_then_qualify_stays_locked(session):
"""Fail at 10:00 NY and qualify at 15:35 NY same day must not unlock."""
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
ticker = Ticker(symbol="SAMEDAY")
session.add(ticker)
await session.flush()
stopped_at = datetime(2026, 7, 15, 14, 0, tzinfo=timezone.utc) # 10:00 ET
session.add(_stopped_trade(ticker.id, closed_at=stopped_at))
await session.commit()
fail_at = datetime(2026, 7, 15, 14, 5, tzinfo=timezone.utc) # ~10:05 ET
updated = await observe_reentry_gate_transitions(
session,
evaluated_ticker_ids={ticker.id},
qualified_ticker_ids=set(),
observed_at=fail_at,
)
assert updated == {ticker.id}
qualify_same_day = datetime(2026, 7, 15, 19, 35, tzinfo=timezone.utc) # 15:35 ET
updated = await observe_reentry_gate_transitions(
session,
evaluated_ticker_ids={ticker.id},
qualified_ticker_ids={ticker.id},
observed_at=qualify_same_day,
)
assert updated == set()
assert ticker.id in await get_reentry_gate_locks(session)
trade = (
await session.execute(select(PaperTrade).where(PaperTrade.ticker_id == ticker.id))
).scalar_one()
assert trade.reentry_gate_failed_at is not None
assert trade.reentry_gate_requalified_at is None
qualify_next_day = datetime(2026, 7, 16, 19, 35, tzinfo=timezone.utc) # next day 15:35 ET
updated = await observe_reentry_gate_transitions(
session,
evaluated_ticker_ids={ticker.id},
qualified_ticker_ids={ticker.id},
observed_at=qualify_next_day,
)
assert updated == {ticker.id}
await session.refresh(trade)
assert trade.reentry_gate_requalified_at is not None
assert ticker.id not in await get_reentry_gate_locks(session)
async def test_newer_non_stop_exit_supersedes_historical_stop(session): async def test_newer_non_stop_exit_supersedes_historical_stop(session):
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True)) session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
ticker = Ticker(symbol="LATEREXIT") ticker = Ticker(symbol="LATEREXIT")