Italianhype commited on
Commit
6293752
·
1 Parent(s): 0df6164

fix intraday evidence pipeline

Browse files
backend/app/providers/replay_data_provider.py CHANGED
@@ -1,7 +1,7 @@
1
  from __future__ import annotations
2
 
3
  from dataclasses import dataclass, field
4
- from datetime import datetime, timezone
5
  from typing import Protocol
6
  from urllib.parse import quote
7
 
@@ -13,6 +13,11 @@ from app.providers.yfinance_provider import NasdaqHistoricalProvider, StooqProvi
13
 
14
 
15
  REPLAY_TIMEFRAMES = frozenset({"1d", "15m", "5m", "1m"})
 
 
 
 
 
16
 
17
 
18
  @dataclass(frozen=True)
@@ -41,6 +46,88 @@ class ReplayDataProvider(Protocol):
41
  def fetch(self, request: ReplayDataRequest) -> ProviderBars: ...
42
 
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  class YahooReplayDataProvider:
45
  name = "yahoo_chart"
46
  supported_timeframes = REPLAY_TIMEFRAMES
@@ -53,10 +140,11 @@ class YahooReplayDataProvider:
53
  def fetch(self, request: ReplayDataRequest) -> ProviderBars:
54
  if request.timeframe not in self.supported_timeframes:
55
  return ProviderBars(self.name, source_metadata=self.source_metadata, blockers=["UNSUPPORTED_TIMEFRAME"])
 
56
  url = f"https://query1.finance.yahoo.com/v8/finance/chart/{quote(request.source_symbol)}"
57
  params = {
58
- "period1": int(_as_utc(request.start).timestamp()),
59
- "period2": int(_as_utc(request.end).timestamp()) + 1,
60
  "interval": request.timeframe,
61
  "includePrePost": "false",
62
  "events": "div,splits",
@@ -100,11 +188,12 @@ class YFinanceReplayDataProvider:
100
  }
101
 
102
  def fetch(self, request: ReplayDataRequest) -> ProviderBars:
 
103
  try:
104
  frame = yf.download(
105
  request.source_symbol,
106
- start=request.start,
107
- end=request.end,
108
  interval=request.timeframe,
109
  auto_adjust=True,
110
  progress=False,
@@ -148,7 +237,7 @@ class DailyProviderReplayAdapter:
148
 
149
 
150
  def default_replay_providers(*, include_yfinance: bool = True) -> list[ReplayDataProvider]:
151
- providers: list[ReplayDataProvider] = [YahooReplayDataProvider()]
152
  if include_yfinance:
153
  providers.append(YFinanceReplayDataProvider())
154
  providers.extend([DailyProviderReplayAdapter(StooqProvider()), DailyProviderReplayAdapter(NasdaqHistoricalProvider())])
@@ -175,6 +264,15 @@ def normalize_replay_frame(frame: pd.DataFrame) -> pd.DataFrame:
175
  return output.loc[~invalid]
176
 
177
 
 
 
 
 
 
 
 
 
 
178
  def _as_utc(value: datetime) -> datetime:
179
  if value.tzinfo is None:
180
  return value.replace(tzinfo=timezone.utc)
 
1
  from __future__ import annotations
2
 
3
  from dataclasses import dataclass, field
4
+ from datetime import datetime, time as clock_time, timedelta, timezone
5
  from typing import Protocol
6
  from urllib.parse import quote
7
 
 
13
 
14
 
15
  REPLAY_TIMEFRAMES = frozenset({"1d", "15m", "5m", "1m"})
16
+ INTRADAY_RETENTION_DAYS = {
17
+ "1m": 7,
18
+ "5m": 59,
19
+ "15m": 59,
20
+ }
21
 
22
 
23
  @dataclass(frozen=True)
 
46
  def fetch(self, request: ReplayDataRequest) -> ProviderBars: ...
47
 
48
 
49
+ class NasdaqChartReplayDataProvider:
50
+ """Public US intraday fallback using Nasdaq's observed last-sale chart path."""
51
+
52
+ name = "nasdaq_chart"
53
+ supported_timeframes = frozenset({"1m", "5m", "15m"})
54
+ source_metadata = {
55
+ "source": "Nasdaq public quote chart API",
56
+ "license": "Public endpoint; downstream use remains subject to provider terms.",
57
+ "bar_construction": "observed_last_sale_path",
58
+ "ohlcv_completeness": "Price path only; volume unavailable and OHLC is aggregated from observed minute values.",
59
+ "data_quality_score": 55.0,
60
+ }
61
+
62
+ def __init__(self) -> None:
63
+ self._observed_cache: dict[tuple[str, str], tuple[pd.DataFrame, str | None]] = {}
64
+
65
+ def fetch(self, request: ReplayDataRequest) -> ProviderBars:
66
+ if request.timeframe not in self.supported_timeframes:
67
+ return ProviderBars(self.name, source_metadata=self.source_metadata, blockers=["UNSUPPORTED_TIMEFRAME"])
68
+ if str(request.market or "").strip().upper() not in {"USA", "US", "UNITED STATES", "NASDAQ", "NYSE"}:
69
+ return ProviderBars(self.name, source_metadata=self.source_metadata, blockers=["UNSUPPORTED_MARKET"])
70
+ observed, blocker = self._observed_path(request)
71
+ if blocker:
72
+ return ProviderBars(self.name, source_metadata=self.source_metadata, blockers=[blocker])
73
+ start = _naive_utc(request.start)
74
+ end = _naive_utc(request.end)
75
+ observed = observed.loc[(observed.index >= start) & (observed.index <= end)]
76
+ if observed.empty:
77
+ return ProviderBars(self.name, source_metadata=self.source_metadata, blockers=["NO_INTRADAY_HISTORY"])
78
+ if request.timeframe == "1m":
79
+ frame = observed.assign(Open=observed["Close"], High=observed["Close"], Low=observed["Close"], Volume=None)
80
+ else:
81
+ frequency = {"5m": "5min", "15m": "15min"}[request.timeframe]
82
+ frame = observed["Close"].resample(frequency).agg(Open="first", High="max", Low="min", Close="last")
83
+ frame["Volume"] = None
84
+ return ProviderBars(self.name, normalize_replay_frame(frame), self.source_metadata, [])
85
+
86
+ def _observed_path(self, request: ReplayDataRequest) -> tuple[pd.DataFrame, str | None]:
87
+ cache_key = (request.source_symbol.upper(), str(request.market or "").upper())
88
+ cached = self._observed_cache.get(cache_key)
89
+ if cached is not None:
90
+ return cached[0].copy(), cached[1]
91
+ url = f"https://api.nasdaq.com/api/quote/{quote(request.source_symbol)}/chart"
92
+ try:
93
+ response = requests.get(
94
+ url,
95
+ params={"assetclass": "stocks"},
96
+ headers=provider_headers(),
97
+ timeout=12,
98
+ )
99
+ response.raise_for_status()
100
+ payload = response.json()
101
+ except Exception:
102
+ result = (pd.DataFrame(), "PROVIDER_UNAVAILABLE")
103
+ self._observed_cache[cache_key] = result
104
+ return result
105
+ chart = (payload.get("data") or {}).get("chart") or []
106
+ points = [row for row in chart if row.get("x") is not None and row.get("y") is not None]
107
+ if not points:
108
+ result = (pd.DataFrame(), "NO_INTRADAY_HISTORY")
109
+ self._observed_cache[cache_key] = result
110
+ return result
111
+ index = pd.to_datetime([row["x"] for row in points], unit="ms", utc=True)
112
+ values = pd.to_numeric([row["y"] for row in points], errors="coerce")
113
+ observed = pd.DataFrame({"Close": values}, index=index).dropna(subset=["Close"])
114
+ if observed.empty:
115
+ result = (pd.DataFrame(), "NO_INTRADAY_HISTORY")
116
+ self._observed_cache[cache_key] = result
117
+ return result
118
+ eastern = observed.index.tz_convert("America/New_York")
119
+ regular_session = (eastern.time >= clock_time(9, 30)) & (eastern.time <= clock_time(16, 0))
120
+ observed = observed.loc[regular_session]
121
+ if observed.empty:
122
+ result = (pd.DataFrame(), "MARKET_SESSION_UNAVAILABLE")
123
+ self._observed_cache[cache_key] = result
124
+ return result
125
+ observed.index = observed.index.tz_convert(None)
126
+ result = (observed, None)
127
+ self._observed_cache[cache_key] = result
128
+ return observed.copy(), None
129
+
130
+
131
  class YahooReplayDataProvider:
132
  name = "yahoo_chart"
133
  supported_timeframes = REPLAY_TIMEFRAMES
 
140
  def fetch(self, request: ReplayDataRequest) -> ProviderBars:
141
  if request.timeframe not in self.supported_timeframes:
142
  return ProviderBars(self.name, source_metadata=self.source_metadata, blockers=["UNSUPPORTED_TIMEFRAME"])
143
+ start, end = provider_request_window(request)
144
  url = f"https://query1.finance.yahoo.com/v8/finance/chart/{quote(request.source_symbol)}"
145
  params = {
146
+ "period1": int(_as_utc(start).timestamp()),
147
+ "period2": int(_as_utc(end).timestamp()) + 1,
148
  "interval": request.timeframe,
149
  "includePrePost": "false",
150
  "events": "div,splits",
 
188
  }
189
 
190
  def fetch(self, request: ReplayDataRequest) -> ProviderBars:
191
+ start, end = provider_request_window(request)
192
  try:
193
  frame = yf.download(
194
  request.source_symbol,
195
+ start=start,
196
+ end=end,
197
  interval=request.timeframe,
198
  auto_adjust=True,
199
  progress=False,
 
237
 
238
 
239
  def default_replay_providers(*, include_yfinance: bool = True) -> list[ReplayDataProvider]:
240
+ providers: list[ReplayDataProvider] = [NasdaqChartReplayDataProvider(), YahooReplayDataProvider()]
241
  if include_yfinance:
242
  providers.append(YFinanceReplayDataProvider())
243
  providers.extend([DailyProviderReplayAdapter(StooqProvider()), DailyProviderReplayAdapter(NasdaqHistoricalProvider())])
 
264
  return output.loc[~invalid]
265
 
266
 
267
+ def provider_request_window(request: ReplayDataRequest) -> tuple[datetime, datetime]:
268
+ """Clamp intraday requests to the retention windows exposed by Yahoo sources."""
269
+
270
+ retention_days = INTRADAY_RETENTION_DAYS.get(request.timeframe)
271
+ if retention_days is None:
272
+ return request.start, request.end
273
+ return max(request.start, request.end - timedelta(days=retention_days)), request.end
274
+
275
+
276
  def _as_utc(value: datetime) -> datetime:
277
  if value.tzinfo is None:
278
  return value.replace(tzinfo=timezone.utc)
backend/app/services/intraday_opportunity.py CHANGED
@@ -134,7 +134,10 @@ class BlumIntradayOpportunityEngine:
134
  return IntradayDecision(INTRADAY_BLOCKED, "SPREAD_TOO_WIDE", "Estimated spread is too large relative to the target.", costs=costs, **base)
135
 
136
  confidence = min(95.0, strategy.walk_forward_score)
137
- edge_score = min(100.0, 50.0 + float(strategy.metrics.get("expectancy_r") or 0.0) * 100.0)
 
 
 
138
  if confidence < strategy.minimum_confidence or edge_score < strategy.minimum_edge_score:
139
  return IntradayDecision(INTRADAY_BLOCKED, "QUANT_EDGE_BELOW_THRESHOLD", "Promoted strategy does not meet current confidence or Quant Edge threshold.", confidence=confidence, edge_score=edge_score, regime=regime, session=session, costs=costs, **base)
140
  size = self.sizer.size(
 
134
  return IntradayDecision(INTRADAY_BLOCKED, "SPREAD_TOO_WIDE", "Estimated spread is too large relative to the target.", costs=costs, **base)
135
 
136
  confidence = min(95.0, strategy.walk_forward_score)
137
+ expectancy_r = strategy.metrics.get("expectancy_r")
138
+ if expectancy_r is None:
139
+ expectancy_r = strategy.metrics.get("net_expectancy_r")
140
+ edge_score = min(100.0, 50.0 + float(expectancy_r or 0.0) * 100.0)
141
  if confidence < strategy.minimum_confidence or edge_score < strategy.minimum_edge_score:
142
  return IntradayDecision(INTRADAY_BLOCKED, "QUANT_EDGE_BELOW_THRESHOLD", "Promoted strategy does not meet current confidence or Quant Edge threshold.", confidence=confidence, edge_score=edge_score, regime=regime, session=session, costs=costs, **base)
143
  size = self.sizer.size(
backend/app/services/intraday_paper_engine.py CHANGED
@@ -88,6 +88,7 @@ class BlumIntradayPaperEngine:
88
  self.learning = IntradayPaperLearningService()
89
  self.no_trade_learning = IntradayNoTradeLearningService(evaluation_minutes=settings.intraday_no_trade_evaluation_minutes)
90
  self._desk_context: dict[int, tuple[str, str]] = {}
 
91
 
92
  def run_once(self, db: Session, *, trigger: str = "manual", assets: Iterable[Asset] | None = None) -> dict:
93
  if not settings.intraday_paper_enabled:
@@ -165,6 +166,7 @@ class BlumIntradayPaperEngine:
165
  run.summary_json = {
166
  "decisions": decisions[:50],
167
  "lifecycle": lifecycle,
 
168
  "policy": "Strict 1d/15m/5m/1m paper-forward evidence; no timeframe fallback or broker execution.",
169
  }
170
  self.paper.refresh_live_game_counts(db, game)
@@ -227,9 +229,32 @@ class BlumIntradayPaperEngine:
227
  seen.add(asset.id)
228
  self._desk_context[asset.id] = (agent.agent_name, agent.benchmark)
229
  ranked.append(asset)
230
- if len(ranked) >= self.max_assets:
231
- return ranked
232
- return ranked
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
 
234
  def _portfolio_state(self, db: Session, game: LiveForwardPaperGame) -> IntradayPortfolioState:
235
  rows = db.scalars(
@@ -822,6 +847,7 @@ def intraday_snapshot_summary(db: Session, *, now: datetime | None = None) -> di
822
  no_trade_pending = int(
823
  db.scalar(select(func.count(IntradayNoTradeDecision.id)).where(IntradayNoTradeDecision.status == "PENDING")) or 0
824
  )
 
825
  if not latest_run:
826
  inactivity_reason = "No intraday run has completed."
827
  next_action = "Wait for the bounded backend worker or invoke the explicit manual POST."
@@ -869,6 +895,7 @@ def intraday_snapshot_summary(db: Session, *, now: datetime | None = None) -> di
869
  "desks": dict(Counter(row.desk or "UNKNOWN" for row in rows)),
870
  "setups": dict(Counter(row.setup_type for row in rows)),
871
  "latest_blockers": latest_run.data_blockers if latest_run else ["No intraday run has completed."],
 
872
  "evidence_type": PAPER_FORWARD_INTRADAY,
873
  "policy": "Read-only forward paper evidence. No trade or recalculation is triggered by this snapshot.",
874
  }
 
88
  self.learning = IntradayPaperLearningService()
89
  self.no_trade_learning = IntradayNoTradeLearningService(evaluation_minutes=settings.intraday_no_trade_evaluation_minutes)
90
  self._desk_context: dict[int, tuple[str, str]] = {}
91
+ self._discovery_metadata: dict = {}
92
 
93
  def run_once(self, db: Session, *, trigger: str = "manual", assets: Iterable[Asset] | None = None) -> dict:
94
  if not settings.intraday_paper_enabled:
 
166
  run.summary_json = {
167
  "decisions": decisions[:50],
168
  "lifecycle": lifecycle,
169
+ "asset_discovery": self._discovery_metadata,
170
  "policy": "Strict 1d/15m/5m/1m paper-forward evidence; no timeframe fallback or broker execution.",
171
  }
172
  self.paper.refresh_live_game_counts(db, game)
 
229
  seen.add(asset.id)
230
  self._desk_context[asset.id] = (agent.agent_name, agent.benchmark)
231
  ranked.append(asset)
232
+
233
+ ranked.sort(key=lambda row: (row.ticker.upper(), row.id))
234
+ previous = db.scalar(
235
+ select(IntradayPaperRun)
236
+ .where(IntradayPaperRun.status != "RUNNING")
237
+ .order_by(desc(IntradayPaperRun.id))
238
+ .limit(1)
239
+ )
240
+ previous_discovery = (previous.summary_json or {}).get("asset_discovery") if previous else {}
241
+ cursor_before = (previous_discovery or {}).get("cursor_after")
242
+ start_index = 0
243
+ if cursor_before is not None:
244
+ for index, asset in enumerate(ranked):
245
+ if asset.id == cursor_before:
246
+ start_index = (index + 1) % max(1, len(ranked))
247
+ break
248
+ rotated = ranked[start_index:] + ranked[:start_index]
249
+ selected = rotated[: self.max_assets]
250
+ self._discovery_metadata = {
251
+ "policy": "round_robin_stored_market_universe",
252
+ "universe_size": len(ranked),
253
+ "cursor_before": cursor_before,
254
+ "cursor_after": selected[-1].id if selected else cursor_before,
255
+ "selected_tickers": [asset.ticker for asset in selected],
256
+ }
257
+ return selected
258
 
259
  def _portfolio_state(self, db: Session, game: LiveForwardPaperGame) -> IntradayPortfolioState:
260
  rows = db.scalars(
 
847
  no_trade_pending = int(
848
  db.scalar(select(func.count(IntradayNoTradeDecision.id)).where(IntradayNoTradeDecision.status == "PENDING")) or 0
849
  )
850
+ strategy_registry = BlumPromotedStrategyRegistry().status(db)
851
  if not latest_run:
852
  inactivity_reason = "No intraday run has completed."
853
  next_action = "Wait for the bounded backend worker or invoke the explicit manual POST."
 
895
  "desks": dict(Counter(row.desk or "UNKNOWN" for row in rows)),
896
  "setups": dict(Counter(row.setup_type for row in rows)),
897
  "latest_blockers": latest_run.data_blockers if latest_run else ["No intraday run has completed."],
898
+ "strategy_registry": strategy_registry,
899
  "evidence_type": PAPER_FORWARD_INTRADAY,
900
  "policy": "Read-only forward paper evidence. No trade or recalculation is triggered by this snapshot.",
901
  }
backend/app/services/live_forward_paper_trading.py CHANGED
@@ -547,11 +547,24 @@ class LiveForwardPaperTradingService(_TradingLabLiveForwardService):
547
  waiting: list[dict] = []
548
  data_blocked: list[dict] = []
549
  skipped: list[dict] = []
 
 
 
 
 
 
 
 
 
 
550
 
551
  for trade in rows:
552
  if int(live_game.open_positions or 0) >= settings.live_trading_game_max_open_positions:
553
  skipped.append({"trade_id": trade.id, "ticker": trade.ticker, "reason": "max_open_positions_reached"})
554
  break
 
 
 
555
 
556
  if classification_from_trade(trade) != TRADE_CANDIDATE:
557
  waiting.append({
@@ -581,6 +594,7 @@ class LiveForwardPaperTradingService(_TradingLabLiveForwardService):
581
  continue
582
 
583
  opened.append(self.open_candidate_trade(db, live_game, trade, latest_date, latest_price, condition))
 
584
 
585
  return {"opened": opened, "waiting": waiting, "data_blocked": data_blocked, "skipped": skipped}
586
 
 
547
  waiting: list[dict] = []
548
  data_blocked: list[dict] = []
549
  skipped: list[dict] = []
550
+ open_tickers = {
551
+ str(ticker).upper()
552
+ for ticker in db.scalars(
553
+ select(LiveForwardPaperTrade.ticker).where(
554
+ LiveForwardPaperTrade.game_id == live_game.id,
555
+ LiveForwardPaperTrade.status.in_(["ORDER_SUBMITTED", "PARTIALLY_FILLED", "OPEN"]),
556
+ )
557
+ ).all()
558
+ if ticker
559
+ }
560
 
561
  for trade in rows:
562
  if int(live_game.open_positions or 0) >= settings.live_trading_game_max_open_positions:
563
  skipped.append({"trade_id": trade.id, "ticker": trade.ticker, "reason": "max_open_positions_reached"})
564
  break
565
+ if trade.ticker.upper() in open_tickers:
566
+ skipped.append({"trade_id": trade.id, "ticker": trade.ticker, "reason": "ticker_position_already_open"})
567
+ continue
568
 
569
  if classification_from_trade(trade) != TRADE_CANDIDATE:
570
  waiting.append({
 
594
  continue
595
 
596
  opened.append(self.open_candidate_trade(db, live_game, trade, latest_date, latest_price, condition))
597
+ open_tickers.add(trade.ticker.upper())
598
 
599
  return {"opened": opened, "waiting": waiting, "data_blocked": data_blocked, "skipped": skipped}
600
 
backend/app/services/promoted_strategy_registry.py CHANGED
@@ -57,8 +57,13 @@ class BlumPromotedStrategyRegistry:
57
  return None
58
  sample_size = int(row.sample_size or 0)
59
  stability = number(metrics.get("stability_score"), 0.0)
60
- expectancy = number(metrics.get("expectancy_r"), 0.0)
 
 
 
61
  benchmark_excess = number(metrics.get("benchmark_excess"), 0.0)
 
 
62
  timeframe_stack = tuple(metrics.get("timeframe_stack") or REQUIRED_INTRADAY_TIMEFRAMES)
63
  if sample_size < settings.replay_min_promotion_samples:
64
  return None
@@ -83,7 +88,7 @@ class BlumPromotedStrategyRegistry:
83
  minimum_confidence=number(metrics.get("minimum_confidence"), 60.0),
84
  minimum_edge_score=number(metrics.get("minimum_edge_score"), settings.blum_quant_edge_min_score),
85
  validated_trade_count=sample_size,
86
- walk_forward_score=number(metrics.get("walk_forward_score"), 0.0),
87
  expected_costs=dict(metrics.get("expected_costs") or {}),
88
  max_allowed_drawdown=abs(number(metrics.get("max_drawdown"), 0.0)),
89
  promotion_timestamp=row.created_at,
 
57
  return None
58
  sample_size = int(row.sample_size or 0)
59
  stability = number(metrics.get("stability_score"), 0.0)
60
+ expectancy = number(
61
+ metrics.get("expectancy_r"),
62
+ number(metrics.get("net_expectancy_r"), 0.0),
63
+ )
64
  benchmark_excess = number(metrics.get("benchmark_excess"), 0.0)
65
+ deflated_sharpe = number(metrics.get("deflated_sharpe_probability"), 0.0) * 100.0
66
+ derived_walk_forward_score = min(stability, deflated_sharpe) if deflated_sharpe > 0 else stability
67
  timeframe_stack = tuple(metrics.get("timeframe_stack") or REQUIRED_INTRADAY_TIMEFRAMES)
68
  if sample_size < settings.replay_min_promotion_samples:
69
  return None
 
88
  minimum_confidence=number(metrics.get("minimum_confidence"), 60.0),
89
  minimum_edge_score=number(metrics.get("minimum_edge_score"), settings.blum_quant_edge_min_score),
90
  validated_trade_count=sample_size,
91
+ walk_forward_score=number(metrics.get("walk_forward_score"), derived_walk_forward_score),
92
  expected_costs=dict(metrics.get("expected_costs") or {}),
93
  max_allowed_drawdown=abs(number(metrics.get("max_drawdown"), 0.0)),
94
  promotion_timestamp=row.created_at,
backend/app/services/replay_data.py CHANGED
@@ -79,7 +79,6 @@ class MultiProviderReplayDataService:
79
  for provider in self.providers:
80
  if timeframe not in provider.supported_timeframes:
81
  attempts.append({"provider": provider.name, "status": "blocked", "blockers": ["UNSUPPORTED_TIMEFRAME"]})
82
- blockers.append("UNSUPPORTED_TIMEFRAME")
83
  continue
84
  request = ReplayDataRequest(
85
  source_symbol=asset.ticker,
@@ -112,7 +111,7 @@ class MultiProviderReplayDataService:
112
  "blockers": list(result.blockers),
113
  }
114
  )
115
- blockers.extend(result.blockers)
116
  if normalized.empty:
117
  continue
118
  self._persist_bars(db, asset, timeframe, provider.name, normalized, result.source_metadata)
@@ -197,7 +196,9 @@ class MultiProviderReplayDataService:
197
  )
198
  ).all()
199
  )
200
- quality = _frame_quality(frame)
 
 
201
  acquired_at = datetime.utcnow()
202
  for timestamp, (_, row) in zip(timestamps, frame.iterrows()):
203
  if timestamp in existing:
 
79
  for provider in self.providers:
80
  if timeframe not in provider.supported_timeframes:
81
  attempts.append({"provider": provider.name, "status": "blocked", "blockers": ["UNSUPPORTED_TIMEFRAME"]})
 
82
  continue
83
  request = ReplayDataRequest(
84
  source_symbol=asset.ticker,
 
111
  "blockers": list(result.blockers),
112
  }
113
  )
114
+ blockers.extend(code for code in result.blockers if not code.startswith("UNSUPPORTED_"))
115
  if normalized.empty:
116
  continue
117
  self._persist_bars(db, asset, timeframe, provider.name, normalized, result.source_metadata)
 
196
  )
197
  ).all()
198
  )
199
+ provider_quality = metadata.get("data_quality_score") if isinstance(metadata, dict) else None
200
+ quality = _number(provider_quality) if provider_quality is not None else _frame_quality(frame)
201
+ quality = max(0.0, min(100.0, float(quality or 0.0)))
202
  acquired_at = datetime.utcnow()
203
  for timestamp, (_, row) in zip(timestamps, frame.iterrows()):
204
  if timestamp in existing:
backend/tests/test_live_forward_paper_trading.py CHANGED
@@ -681,6 +681,36 @@ def test_paper_forward_lifecycle_opens_candidate_when_trigger_is_met():
681
  assert any(event.event_type == "POSITION_OPENED" for event in events)
682
 
683
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
684
  def test_paper_forward_lifecycle_keeps_candidate_when_trigger_not_met():
685
  with setup_db() as db:
686
  asset = seed_asset(db)
 
681
  assert any(event.event_type == "POSITION_OPENED" for event in events)
682
 
683
 
684
+ def test_paper_forward_lifecycle_allows_only_one_open_position_per_ticker():
685
+ with setup_db() as db:
686
+ asset = seed_asset(db)
687
+ service = LiveForwardPaperTradingService()
688
+ first_payload = candidate(target_1=130.0, target_2=150.0)
689
+ second_payload = candidate(target_1=128.0, target_2=145.0)
690
+ second_payload["setup"] = {"setup_type": "pullback"}
691
+ second_payload["trade_plan"] = {
692
+ **second_payload["trade_plan"],
693
+ "entry_trigger": "pullback reclaim above support",
694
+ }
695
+ service.create_candidate(db, first_payload)
696
+ service.create_candidate(db, second_payload)
697
+ add_price(db, asset, 1, 101.0)
698
+
699
+ report = run_lifecycle(service, db)
700
+ open_rows = db.scalars(
701
+ select(PaperForwardTrade).where(
702
+ PaperForwardTrade.ticker == "NVDA",
703
+ PaperForwardTrade.status == "OPEN",
704
+ )
705
+ ).all()
706
+
707
+ assert len(open_rows) == 1
708
+ assert any(
709
+ row.get("reason") == "ticker_position_already_open"
710
+ for row in report["phases"]["open_eligible_trades"]["skipped"]
711
+ )
712
+
713
+
714
  def test_paper_forward_lifecycle_keeps_candidate_when_trigger_not_met():
715
  with setup_db() as db:
716
  asset = seed_asset(db)
backend/tests/test_live_intraday_paper_engine.py CHANGED
@@ -19,6 +19,7 @@ from app.models import (
19
  LiveForwardPaperTradeEvent,
20
  PaperExecutionFill,
21
  PaperExecutionOrder,
 
22
  ReplayMarketBar,
23
  ReplayStrategyValidation,
24
  StrategyCandidateVariant,
@@ -198,6 +199,30 @@ def test_registry_rejects_unstable_negative_or_overfit_strategy():
198
  assert rows == []
199
 
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  def test_data_gateway_requires_all_four_timeframes_without_fallback():
202
  with setup_db() as db:
203
  asset = seed_asset(db)
@@ -285,6 +310,47 @@ def test_intraday_trade_evidence_constant_is_forward_only():
285
  assert PAPER_FORWARD_INTRADAY != "REPLAY_EVIDENCE"
286
 
287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  def test_intraday_trade_persists_strategy_cost_and_lifecycle_metadata():
289
  with setup_db() as db:
290
  game = LiveForwardPaperGame(game_id="intraday-test", starting_capital=10_000, current_capital=10_000, cash=10_000)
@@ -518,6 +584,7 @@ def test_intraday_snapshot_is_read_only_and_reports_no_activity_truthfully():
518
  assert before == after == 0
519
  assert snapshot["status"] == "NO_INTRADAY_RUNS"
520
  assert snapshot["trades_opened_today"] == 0
 
521
 
522
 
523
  def test_intraday_run_reports_data_blocked_when_no_eligible_market_data_exists():
 
19
  LiveForwardPaperTradeEvent,
20
  PaperExecutionFill,
21
  PaperExecutionOrder,
22
+ PriceHistory,
23
  ReplayMarketBar,
24
  ReplayStrategyValidation,
25
  StrategyCandidateVariant,
 
199
  assert rows == []
200
 
201
 
202
+ def test_registry_accepts_metrics_emitted_by_alpha_strategy_factory():
203
+ with setup_db() as db:
204
+ promoted = seed_validation(
205
+ db,
206
+ metrics={
207
+ "expectancy_r": None,
208
+ "walk_forward_score": None,
209
+ "net_expectancy_r": 0.25,
210
+ "deflated_sharpe_probability": 0.97,
211
+ "stability_score": 74.0,
212
+ },
213
+ )
214
+
215
+ rows = BlumPromotedStrategyRegistry().list_eligible(
216
+ db,
217
+ market="USA",
218
+ asset_class="Stock",
219
+ )
220
+
221
+ assert [row.validation_id for row in rows] == [promoted.id]
222
+ assert rows[0].metrics["net_expectancy_r"] == 0.25
223
+ assert rows[0].walk_forward_score >= 60.0
224
+
225
+
226
  def test_data_gateway_requires_all_four_timeframes_without_fallback():
227
  with setup_db() as db:
228
  asset = seed_asset(db)
 
310
  assert PAPER_FORWARD_INTRADAY != "REPLAY_EVIDENCE"
311
 
312
 
313
+ def test_intraday_discovery_rotates_across_the_stored_asset_universe():
314
+ with setup_db() as db:
315
+ for index in range(25):
316
+ asset = seed_asset(db, ticker=f"T{index:02d}", market="USA")
317
+ db.add(
318
+ PriceHistory(
319
+ asset_id=asset.id,
320
+ date=NOW.date(),
321
+ open=100.0,
322
+ high=101.0,
323
+ low=99.0,
324
+ close=100.0,
325
+ volume=1_000_000,
326
+ provider="fixture",
327
+ )
328
+ )
329
+ db.commit()
330
+ engine = BlumIntradayPaperEngine(
331
+ now_provider=lambda: NOW,
332
+ refresh_missing=False,
333
+ max_assets=10,
334
+ )
335
+
336
+ first = engine.run_once(db, trigger="test")
337
+ second = engine.run_once(db, trigger="test")
338
+
339
+ first_tickers = {
340
+ row["ticker"]
341
+ for row in first["blockers"]
342
+ if row.get("reason") == "NO_PROMOTED_INTRADAY_STRATEGY"
343
+ }
344
+ second_tickers = {
345
+ row["ticker"]
346
+ for row in second["blockers"]
347
+ if row.get("reason") == "NO_PROMOTED_INTRADAY_STRATEGY"
348
+ }
349
+ assert len(first_tickers) == 10
350
+ assert len(second_tickers) == 10
351
+ assert first_tickers.isdisjoint(second_tickers)
352
+
353
+
354
  def test_intraday_trade_persists_strategy_cost_and_lifecycle_metadata():
355
  with setup_db() as db:
356
  game = LiveForwardPaperGame(game_id="intraday-test", starting_capital=10_000, current_capital=10_000, cash=10_000)
 
584
  assert before == after == 0
585
  assert snapshot["status"] == "NO_INTRADAY_RUNS"
586
  assert snapshot["trades_opened_today"] == 0
587
+ assert snapshot["strategy_registry"]["status"] == "NO_PROMOTED_STRATEGIES"
588
 
589
 
590
  def test_intraday_run_reports_data_blocked_when_no_eligible_market_data_exists():
backend/tests/test_replay_data_engine.py CHANGED
@@ -1,4 +1,4 @@
1
- from datetime import datetime, timedelta
2
 
3
  import pandas as pd
4
  import pytest
@@ -8,7 +8,13 @@ from sqlalchemy.orm import Session
8
 
9
  from app.core.database import Base
10
  from app.models import Asset, PriceHistory, ReplayMarketBar
11
- from app.providers.replay_data_provider import ProviderBars, ReplayDataRequest
 
 
 
 
 
 
12
  from app.services.replay_data import MultiProviderReplayDataService
13
 
14
 
@@ -119,6 +125,177 @@ def frame(start: datetime, periods: int, frequency: str) -> pd.DataFrame:
119
  )
120
 
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  def seed_replay_bars(db: Session, asset: Asset, start: datetime, count: int, timeframe: str = "5m") -> None:
123
  for index in range(count):
124
  timestamp = start + timedelta(minutes=5 * index)
 
1
+ from datetime import datetime, timedelta, timezone
2
 
3
  import pandas as pd
4
  import pytest
 
8
 
9
  from app.core.database import Base
10
  from app.models import Asset, PriceHistory, ReplayMarketBar
11
+ from app.providers.replay_data_provider import (
12
+ NasdaqChartReplayDataProvider,
13
+ ProviderBars,
14
+ ReplayDataRequest,
15
+ YahooReplayDataProvider,
16
+ YFinanceReplayDataProvider,
17
+ )
18
  from app.services.replay_data import MultiProviderReplayDataService
19
 
20
 
 
125
  )
126
 
127
 
128
+ class FakeYahooResponse:
129
+ def raise_for_status(self) -> None:
130
+ return None
131
+
132
+ def json(self) -> dict:
133
+ return {
134
+ "chart": {
135
+ "result": [
136
+ {
137
+ "timestamp": [1_752_657_600],
138
+ "indicators": {
139
+ "quote": [
140
+ {
141
+ "open": [100.0],
142
+ "high": [101.0],
143
+ "low": [99.0],
144
+ "close": [100.5],
145
+ "volume": [1_000_000],
146
+ }
147
+ ]
148
+ },
149
+ }
150
+ ],
151
+ "error": None,
152
+ }
153
+ }
154
+
155
+
156
+ class FakeNasdaqResponse:
157
+ def raise_for_status(self) -> None:
158
+ return None
159
+
160
+ def json(self) -> dict:
161
+ start = datetime(2026, 7, 15, 13, 30, tzinfo=timezone.utc)
162
+ return {
163
+ "data": {
164
+ "symbol": "NVDA",
165
+ "chart": [
166
+ {
167
+ "x": int((start + timedelta(minutes=index)).timestamp() * 1000),
168
+ "y": 170.0 + index / 10,
169
+ }
170
+ for index in range(31)
171
+ ],
172
+ }
173
+ }
174
+
175
+
176
+ def test_yahoo_replay_clamps_one_minute_request_to_provider_retention(monkeypatch):
177
+ captured: dict = {}
178
+
179
+ def fake_get(url, *, params, headers, timeout):
180
+ captured.update(params)
181
+ return FakeYahooResponse()
182
+
183
+ monkeypatch.setattr("app.providers.replay_data_provider.requests.get", fake_get)
184
+ end = datetime(2026, 7, 16, 12, 0)
185
+ request = ReplayDataRequest("NVDA", "NVDA", "USA", "1m", end - timedelta(days=365), end)
186
+
187
+ YahooReplayDataProvider().fetch(request)
188
+
189
+ requested_start = datetime.utcfromtimestamp(captured["period1"])
190
+ assert requested_start == end - timedelta(days=7)
191
+
192
+
193
+ def test_yfinance_replay_clamps_five_minute_request_to_provider_retention(monkeypatch):
194
+ captured: dict = {}
195
+
196
+ def fake_download(symbol, **kwargs):
197
+ captured.update(kwargs)
198
+ return pd.DataFrame()
199
+
200
+ monkeypatch.setattr("app.providers.replay_data_provider.yf.download", fake_download)
201
+ end = datetime(2026, 7, 16, 12, 0)
202
+ request = ReplayDataRequest("NVDA", "NVDA", "USA", "5m", end - timedelta(days=365), end)
203
+
204
+ YFinanceReplayDataProvider().fetch(request)
205
+
206
+ assert captured["start"] == end - timedelta(days=59)
207
+
208
+
209
+ def test_nasdaq_chart_provider_resamples_observed_price_path(monkeypatch):
210
+ monkeypatch.setattr(
211
+ "app.providers.replay_data_provider.requests.get",
212
+ lambda *args, **kwargs: FakeNasdaqResponse(),
213
+ )
214
+ end = datetime(2026, 7, 16, 12, 0)
215
+
216
+ result = NasdaqChartReplayDataProvider().fetch(
217
+ ReplayDataRequest("NVDA", "NVDA", "USA", "15m", end - timedelta(days=10), end)
218
+ )
219
+
220
+ assert result.blockers == []
221
+ assert len(result.frame) == 3
222
+ assert result.frame.iloc[0]["Open"] == 170.0
223
+ assert result.frame.iloc[0]["Close"] == 171.4
224
+ assert result.source_metadata["bar_construction"] == "observed_last_sale_path"
225
+ assert result.source_metadata["data_quality_score"] == 55.0
226
+
227
+
228
+ def test_nasdaq_chart_provider_never_returns_bars_after_requested_end(monkeypatch):
229
+ monkeypatch.setattr(
230
+ "app.providers.replay_data_provider.requests.get",
231
+ lambda *args, **kwargs: FakeNasdaqResponse(),
232
+ )
233
+ end = datetime(2026, 7, 14, 20, 0)
234
+
235
+ result = NasdaqChartReplayDataProvider().fetch(
236
+ ReplayDataRequest("NVDA", "NVDA", "USA", "1m", end - timedelta(days=2), end)
237
+ )
238
+
239
+ assert result.frame.empty
240
+ assert result.blockers == ["NO_INTRADAY_HISTORY"]
241
+
242
+
243
+ def test_nasdaq_chart_provider_reuses_one_price_path_for_all_timeframes(monkeypatch):
244
+ request_count = 0
245
+
246
+ def fake_get(*args, **kwargs):
247
+ nonlocal request_count
248
+ request_count += 1
249
+ return FakeNasdaqResponse()
250
+
251
+ monkeypatch.setattr("app.providers.replay_data_provider.requests.get", fake_get)
252
+ provider = NasdaqChartReplayDataProvider()
253
+ end = datetime(2026, 7, 16, 12, 0)
254
+ for timeframe in ("1m", "5m", "15m"):
255
+ provider.fetch(ReplayDataRequest("NVDA", "NVDA", "USA", timeframe, end - timedelta(days=10), end))
256
+
257
+ assert request_count == 1
258
+
259
+
260
+ def test_replay_persistence_honors_provider_quality_override():
261
+ start = datetime(2026, 7, 10, 8, 0)
262
+ provider = RecordingProvider(frame(start, periods=5, frequency="15min"))
263
+ provider.source_metadata = {"license": "test", "data_quality_score": 55.0}
264
+ with setup_db() as db:
265
+ asset = seed_asset(db)
266
+ MultiProviderReplayDataService([provider]).ensure_coverage(
267
+ db,
268
+ asset=asset,
269
+ timeframe="15m",
270
+ start=start,
271
+ end=start + timedelta(hours=1),
272
+ )
273
+ rows = db.scalars(select(ReplayMarketBar).where(ReplayMarketBar.asset_id == asset.id)).all()
274
+
275
+ assert rows
276
+ assert {row.data_quality_score for row in rows} == {55.0}
277
+
278
+
279
+ def test_unsupported_provider_does_not_pollute_successful_fallback_blockers():
280
+ start = datetime(2026, 7, 10, 8, 0)
281
+ unsupported = RecordingProvider()
282
+ unsupported.supported_timeframes = frozenset({"1d"})
283
+ fallback = RecordingProvider(frame(start, periods=5, frequency="15min"))
284
+ fallback.name = "fallback"
285
+ with setup_db() as db:
286
+ asset = seed_asset(db)
287
+ result = MultiProviderReplayDataService([unsupported, fallback]).ensure_coverage(
288
+ db,
289
+ asset=asset,
290
+ timeframe="15m",
291
+ start=start,
292
+ end=start + timedelta(hours=1),
293
+ )
294
+
295
+ assert result.provider == "fallback"
296
+ assert "UNSUPPORTED_TIMEFRAME" not in result.blockers
297
+
298
+
299
  def seed_replay_bars(db: Session, asset: Asset, start: datetime, count: int, timeframe: str = "5m") -> None:
300
  for index in range(count):
301
  timestamp = start + timedelta(minutes=5 * index)