Spaces:
Running
Running
Commit ·
6293752
1
Parent(s): 0df6164
fix intraday evidence pipeline
Browse files- backend/app/providers/replay_data_provider.py +104 -6
- backend/app/services/intraday_opportunity.py +4 -1
- backend/app/services/intraday_paper_engine.py +30 -3
- backend/app/services/live_forward_paper_trading.py +14 -0
- backend/app/services/promoted_strategy_registry.py +7 -2
- backend/app/services/replay_data.py +4 -3
- backend/tests/test_live_forward_paper_trading.py +30 -0
- backend/tests/test_live_intraday_paper_engine.py +67 -0
- backend/tests/test_replay_data_engine.py +179 -2
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(
|
| 59 |
-
"period2": int(_as_utc(
|
| 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=
|
| 107 |
-
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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 231 |
-
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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"),
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|