Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 19 additions & 25 deletions scoring/performance_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

Invoked at the start of each daily run before any agents execute.
Reads from investment_thesis and technical_scores tables, fetches current
prices via yfinance, writes to score_performance table.
prices via polygon grouped-daily (primary) with a daily_closes S3 fallback
(alpha-engine-data's staging/daily_closes/ — yfinance removed in the
yfinance-centralization arc, 2026-05-16), writes to score_performance table.
No LLM involved.
"""

Expand All @@ -17,7 +19,6 @@
from typing import Optional

import pandas as pd
import yfinance as yf

from config import (
RATING_BUY_THRESHOLD,
Expand Down Expand Up @@ -91,34 +92,27 @@ def run_performance_checks(db_conn: sqlite3.Connection, today: str) -> dict:
except Exception:
pass

# Fallback to yfinance for any missing tickers
price_data = None
# Fallback for any missing tickers: alpha-engine-data's daily_closes
# S3 staging parquet (read via the in-repo feature_store_reader). This
# replaces the former yfinance batch-download fallback leg
# (yfinance-centralization arc, 2026-05-16). Polygon grouped-daily
# above stays the PRIMARY path; this only swaps the *fallback*.
# read_latest_daily_closes() returns {ticker: close} or None and never
# raises — same graceful-degrade as the old try/except: if both
# polygon and the fallback yield nothing, fall through to
# _compute_accuracy_stats with no new evaluations recorded.
daily_closes: dict[str, float] = {}
if len(polygon_prices) < len(tickers_needed):
try:
price_data = yf.download(
tickers=tickers_needed,
period="2d",
interval="1d",
auto_adjust=True,
progress=False,
group_by="ticker",
threads=True,
)
except Exception:
if not polygon_prices:
return _compute_accuracy_stats(db_conn, today)
from data.fetchers.feature_store_reader import read_latest_daily_closes

daily_closes = read_latest_daily_closes() or {}
if not polygon_prices and not daily_closes:
return _compute_accuracy_stats(db_conn, today)

def get_latest_price(ticker: str) -> Optional[float]:
if ticker in polygon_prices:
return polygon_prices[ticker]
if price_data is None:
return None
try:
if len(tickers_needed) == 1:
return float(price_data["Close"].dropna().iloc[-1])
return float(price_data[ticker]["Close"].dropna().iloc[-1])
except Exception:
return None
return daily_closes.get(ticker)

spy_price = get_latest_price("SPY")

Expand Down
139 changes: 112 additions & 27 deletions tests/test_performance_tracker.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
"""
Tests for scoring/performance_tracker.py.
Uses in-memory SQLite — no network, no S3.
yf.download is mocked for tests that would trigger it.

The yfinance fallback leg was replaced by the alpha-engine-data
daily_closes S3 reader (yfinance-centralization arc, 2026-05-16,
plan doc: alpha-engine-docs/private/yfinance-centralization-260516.md,
item R5 / PR 3). Polygon grouped-daily stays the PRIMARY path. The
fallback tests below fake ``feature_store_reader.read_latest_daily_closes``
via ``monkeypatch`` (NOT ``unittest.mock.patch`` — documented full-suite
bleed in this repo; mirrors tests/test_held_thesis_strict.py style).
"""

import sqlite3
import pytest
import pandas as pd
from unittest.mock import patch

_pt = pytest.importorskip("scoring.performance_tracker", reason="scoring.performance_tracker is gitignored")
get_trading_day_offset = _pt.get_trading_day_offset
Expand Down Expand Up @@ -76,12 +81,27 @@ def _insert_tech_dates(conn, dates):
conn.commit()


def _mock_price_data(tickers_and_prices: dict) -> dict:
"""Return a dict of {ticker: DataFrame} matching yf.download multi-ticker output."""
return {
ticker: pd.DataFrame({"Close": [price]})
for ticker, price in tickers_and_prices.items()
}
def _fake_daily_closes(monkeypatch, tickers_and_prices: dict | None):
"""Fake the daily_closes S3 fallback reader (no S3/network).

``read_latest_daily_closes`` is imported *inside* run_performance_checks
from data.fetchers.feature_store_reader, so patch it there. Passing
None simulates an unavailable feature store (reader returns None).
"""
import data.fetchers.feature_store_reader as fsr

monkeypatch.setattr(
fsr,
"read_latest_daily_closes",
lambda: dict(tickers_and_prices) if tickers_and_prices else None,
)


def _disable_polygon(monkeypatch):
"""Force the polygon grouped-daily PRIMARY path to yield nothing so the
daily_closes fallback is exercised (the in-function
`from polygon_client import polygon_client` then raises, caught)."""
monkeypatch.setitem(__import__("sys").modules, "polygon_client", None)


# ── get_trading_day_offset ────────────────────────────────────────────────────
Expand Down Expand Up @@ -284,20 +304,29 @@ def test_no_pending_rows_returns_stats(self, db):
assert "accuracy_10d" in result
assert "recalibration_flag" in result

@patch.dict("sys.modules", {"polygon_client": None})
@patch("scoring.performance_tracker.yf.download")
def test_skips_when_yfinance_fails(self, mock_dl, db):
def test_module_is_yfinance_free(self):
"""Post-PR3 the module imports no yfinance and has no yf symbol."""
import inspect

src = inspect.getsource(_pt)
assert "import yfinance" not in src
assert "yf.download" not in src
assert not hasattr(_pt, "yf")

def test_degrades_when_fallback_unavailable(self, db, monkeypatch):
"""Polygon empty + daily_closes reader returns None → graceful
degrade to accuracy-stats-only, never raises (replaces the old
'skips when yfinance fails' contract)."""
_disable_polygon(monkeypatch)
_fake_daily_closes(monkeypatch, None) # reader returns None
db.execute(
"INSERT INTO score_performance(symbol, score_date, score, price_on_date) VALUES ('PLTR', '2025-12-01', 75.0, 100.0)"
)
db.commit()
mock_dl.side_effect = Exception("network error")
result = run_performance_checks(db, "2026-03-05")
assert "accuracy_10d" in result # falls back gracefully
assert "accuracy_10d" in result # falls back gracefully, no raise

@patch.dict("sys.modules", {"polygon_client": None})
@patch("scoring.performance_tracker.yf.download")
def test_evaluates_10d_window(self, mock_dl, db):
def test_evaluates_10d_window_via_daily_closes_fallback(self, db, monkeypatch):
score_date = "2025-12-01"
today = "2026-03-05"

Expand All @@ -317,7 +346,8 @@ def test_evaluates_10d_window(self, mock_dl, db):
)
db.commit()

mock_dl.return_value = _mock_price_data({"PLTR": 115.0, "SPY": 510.0})
_disable_polygon(monkeypatch)
_fake_daily_closes(monkeypatch, {"PLTR": 115.0, "SPY": 510.0})

result = run_performance_checks(db, today)
assert "accuracy_10d" in result
Expand All @@ -328,9 +358,7 @@ def test_evaluates_10d_window(self, mock_dl, db):
assert row[0] == 115.0
assert abs(row[1] - 15.0) < 0.1 # (115/100 - 1) * 100 = 15%

@patch.dict("sys.modules", {"polygon_client": None})
@patch("scoring.performance_tracker.yf.download")
def test_beat_spy_flag_set(self, mock_dl, db):
def test_beat_spy_flag_set_via_daily_closes_fallback(self, db, monkeypatch):
score_date = "2025-12-01"
today = "2026-03-05"

Expand All @@ -347,17 +375,16 @@ def test_beat_spy_flag_set(self, mock_dl, db):
db.commit()

# PLTR +20%, SPY +2% → beats SPY
mock_dl.return_value = _mock_price_data({"PLTR": 120.0, "SPY": 510.0})
_disable_polygon(monkeypatch)
_fake_daily_closes(monkeypatch, {"PLTR": 120.0, "SPY": 510.0})
run_performance_checks(db, today)

row = db.execute(
"SELECT beat_spy_10d FROM score_performance WHERE symbol='PLTR'"
).fetchone()
assert row[0] == 1

@patch.dict("sys.modules", {"polygon_client": None})
@patch("scoring.performance_tracker.yf.download")
def test_missing_current_price_skips_row(self, mock_dl, db):
def test_missing_current_price_skips_row(self, db, monkeypatch):
score_date = "2025-12-01"
td_dates = [f"2025-12-{i:02d}" for i in range(2, 12)]
_insert_tech_dates(db, td_dates)
Expand All @@ -367,7 +394,65 @@ def test_missing_current_price_skips_row(self, mock_dl, db):
)
db.commit()

# Price data missing for PLTR
mock_dl.return_value = _mock_price_data({"SPY": 510.0})
# PLTR absent from the fallback (only SPY present) → row skipped.
_disable_polygon(monkeypatch)
_fake_daily_closes(monkeypatch, {"SPY": 510.0})
result = run_performance_checks(db, "2026-03-05")
assert "accuracy_10d" in result
row = db.execute(
"SELECT price_10d FROM score_performance WHERE symbol='PLTR'"
).fetchone()
assert row[0] is None # no eval recorded — graceful skip

def test_polygon_primary_path_unaffected(self, db, monkeypatch):
"""The yfinance→daily_closes swap is fallback-only: when polygon
grouped-daily returns prices, the daily_closes reader is never
called (primary path unchanged)."""
score_date = "2025-12-01"
today = "2026-03-05"
td_dates = [f"2025-12-{i:02d}" for i in range(2, 12)]
_insert_tech_dates(db, td_dates)
db.execute(
"INSERT INTO macro_snapshots(date, sp500_close) VALUES (?, ?)",
(score_date, 500.0),
)
db.execute(
"INSERT INTO score_performance(symbol, score_date, score, price_on_date) VALUES (?, ?, ?, ?)",
("PLTR", score_date, 75.0, 100.0),
)
db.commit()

# Stub polygon_client so the PRIMARY path supplies all prices.
import sys
import types

fake_mod = types.ModuleType("polygon_client")

class _FakeClient:
def get_grouped_daily(self, _today):
return {
"PLTR": {"close": 130.0},
"SPY": {"close": 505.0},
}

fake_mod.polygon_client = lambda: _FakeClient()
monkeypatch.setitem(sys.modules, "polygon_client", fake_mod)

# Tripwire: the fallback reader must NOT be called when polygon
# covers every needed ticker.
import data.fetchers.feature_store_reader as fsr

def _boom():
raise AssertionError(
"read_latest_daily_closes called despite polygon covering all tickers"
)

monkeypatch.setattr(fsr, "read_latest_daily_closes", _boom)

result = run_performance_checks(db, today)
assert "accuracy_10d" in result
row = db.execute(
"SELECT price_10d, return_10d FROM score_performance WHERE symbol='PLTR'"
).fetchone()
assert row[0] == 130.0
assert abs(row[1] - 30.0) < 0.1 # (130/100 - 1) * 100
Loading