Spaces:
Runtime error
Runtime error
Hermes Agent commited on
Commit ·
e1bb088
1
Parent(s): e1da003
feat: optimize TimesFM compute, add offline test suite, and implement YMYL quality gates
Browse files- app/models/__init__.py +1 -0
- app/services/database_service.py +31 -31
- requirements.txt +2 -0
- scripts/daily_pipeline.py +32 -15
- tests/test_caching.py +60 -0
- tests/test_forecast_api.py +100 -0
app/models/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Models package
|
app/services/database_service.py
CHANGED
|
@@ -182,6 +182,15 @@ class DatabaseService:
|
|
| 182 |
# No stocks table exists — rename stocks_v2 to stocks
|
| 183 |
await self._execute("ALTER TABLE stocks_v2 RENAME TO stocks")
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
# Create indexes
|
| 186 |
await self._execute("""
|
| 187 |
CREATE INDEX IF NOT EXISTS idx_stocks_symbol
|
|
@@ -213,15 +222,6 @@ class DatabaseService:
|
|
| 213 |
ON forecasts(created_at)
|
| 214 |
""")
|
| 215 |
|
| 216 |
-
# Add new columns for sync tracking (migration)
|
| 217 |
-
await self._add_column_if_not_exists("stocks", "trading_status", "TEXT DEFAULT 'active'")
|
| 218 |
-
await self._add_column_if_not_exists("stocks", "last_synced", "INTEGER DEFAULT 0")
|
| 219 |
-
await self._add_column_if_not_exists("stocks", "market_cap_tier", "TEXT DEFAULT NULL")
|
| 220 |
-
await self._add_column_if_not_exists("stocks", "data_quality_score", "INTEGER DEFAULT 100")
|
| 221 |
-
await self._add_column_if_not_exists("stocks", "yfinance_available", "INTEGER DEFAULT 1")
|
| 222 |
-
await self._add_column_if_not_exists("stocks", "description", "TEXT DEFAULT NULL")
|
| 223 |
-
await self._add_column_if_not_exists("stocks", "listing_date", "TEXT DEFAULT NULL")
|
| 224 |
-
|
| 225 |
# Create sync log table
|
| 226 |
await self._execute("""
|
| 227 |
CREATE TABLE IF NOT EXISTS sync_log (
|
|
@@ -716,35 +716,35 @@ class DatabaseService:
|
|
| 716 |
logger.error(f"Failed to create sync log: {e}")
|
| 717 |
return None
|
| 718 |
|
| 719 |
-
async def update_sync_log(self, log_id: int, **
|
| 720 |
"""Update sync log entry"""
|
| 721 |
try:
|
| 722 |
-
|
| 723 |
params = []
|
| 724 |
|
| 725 |
-
if 'completed_at' in
|
| 726 |
-
|
| 727 |
-
params.append(
|
| 728 |
-
if 'stocks_added' in
|
| 729 |
-
|
| 730 |
-
params.append(
|
| 731 |
-
if 'stocks_removed' in
|
| 732 |
-
|
| 733 |
-
params.append(
|
| 734 |
-
if 'stocks_updated' in
|
| 735 |
-
|
| 736 |
-
params.append(
|
| 737 |
-
if 'errors' in
|
| 738 |
-
|
| 739 |
-
params.append(
|
| 740 |
-
if 'status' in
|
| 741 |
-
|
| 742 |
-
params.append(
|
| 743 |
|
| 744 |
params.append(log_id)
|
| 745 |
|
| 746 |
-
if
|
| 747 |
-
sql = f"UPDATE sync_log SET {', '.join(
|
| 748 |
await self._execute(sql, params)
|
| 749 |
except Exception as e:
|
| 750 |
logger.error(f"Failed to update sync log: {e}")
|
|
|
|
| 182 |
# No stocks table exists — rename stocks_v2 to stocks
|
| 183 |
await self._execute("ALTER TABLE stocks_v2 RENAME TO stocks")
|
| 184 |
|
| 185 |
+
# Add new columns for sync tracking (migration)
|
| 186 |
+
await self._add_column_if_not_exists("stocks", "trading_status", "TEXT DEFAULT 'active'")
|
| 187 |
+
await self._add_column_if_not_exists("stocks", "last_synced", "INTEGER DEFAULT 0")
|
| 188 |
+
await self._add_column_if_not_exists("stocks", "market_cap_tier", "TEXT DEFAULT NULL")
|
| 189 |
+
await self._add_column_if_not_exists("stocks", "data_quality_score", "INTEGER DEFAULT 100")
|
| 190 |
+
await self._add_column_if_not_exists("stocks", "yfinance_available", "INTEGER DEFAULT 1")
|
| 191 |
+
await self._add_column_if_not_exists("stocks", "description", "TEXT DEFAULT NULL")
|
| 192 |
+
await self._add_column_if_not_exists("stocks", "listing_date", "TEXT DEFAULT NULL")
|
| 193 |
+
|
| 194 |
# Create indexes
|
| 195 |
await self._execute("""
|
| 196 |
CREATE INDEX IF NOT EXISTS idx_stocks_symbol
|
|
|
|
| 222 |
ON forecasts(created_at)
|
| 223 |
""")
|
| 224 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
# Create sync log table
|
| 226 |
await self._execute("""
|
| 227 |
CREATE TABLE IF NOT EXISTS sync_log (
|
|
|
|
| 716 |
logger.error(f"Failed to create sync log: {e}")
|
| 717 |
return None
|
| 718 |
|
| 719 |
+
async def update_sync_log(self, log_id: int, **updates_dict):
|
| 720 |
"""Update sync log entry"""
|
| 721 |
try:
|
| 722 |
+
clauses = []
|
| 723 |
params = []
|
| 724 |
|
| 725 |
+
if 'completed_at' in updates_dict:
|
| 726 |
+
clauses.append("completed_at = ?")
|
| 727 |
+
params.append(updates_dict['completed_at'])
|
| 728 |
+
if 'stocks_added' in updates_dict:
|
| 729 |
+
clauses.append("stocks_added = ?")
|
| 730 |
+
params.append(updates_dict['stocks_added'])
|
| 731 |
+
if 'stocks_removed' in updates_dict:
|
| 732 |
+
clauses.append("stocks_removed = ?")
|
| 733 |
+
params.append(updates_dict['stocks_removed'])
|
| 734 |
+
if 'stocks_updated' in updates_dict:
|
| 735 |
+
clauses.append("stocks_updated = ?")
|
| 736 |
+
params.append(updates_dict['stocks_updated'])
|
| 737 |
+
if 'errors' in updates_dict:
|
| 738 |
+
clauses.append("errors = ?")
|
| 739 |
+
params.append(updates_dict['errors'])
|
| 740 |
+
if 'status' in updates_dict:
|
| 741 |
+
clauses.append("status = ?")
|
| 742 |
+
params.append(updates_dict['status'])
|
| 743 |
|
| 744 |
params.append(log_id)
|
| 745 |
|
| 746 |
+
if clauses:
|
| 747 |
+
sql = f"UPDATE sync_log SET {', '.join(clauses)} WHERE id = ?"
|
| 748 |
await self._execute(sql, params)
|
| 749 |
except Exception as e:
|
| 750 |
logger.error(f"Failed to update sync log: {e}")
|
requirements.txt
CHANGED
|
@@ -16,3 +16,5 @@ httpx==0.28.0
|
|
| 16 |
tenacity==9.0.0
|
| 17 |
python-multipart==0.0.12
|
| 18 |
curl-cffi==0.15.0
|
|
|
|
|
|
|
|
|
| 16 |
tenacity==9.0.0
|
| 17 |
python-multipart==0.0.12
|
| 18 |
curl-cffi==0.15.0
|
| 19 |
+
pytest==7.4.3
|
| 20 |
+
pytest-asyncio==0.21.1
|
scripts/daily_pipeline.py
CHANGED
|
@@ -111,24 +111,41 @@ async def process_stock(
|
|
| 111 |
print("[WARN] Insufficient data")
|
| 112 |
return False
|
| 113 |
|
| 114 |
-
# Generate
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
for horizon in HORIZONS:
|
| 116 |
-
forecast
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
-
# Generate chart for every horizon
|
| 126 |
chart_svg = None
|
| 127 |
try:
|
| 128 |
chart_svg = chart_service.generate_forecast_chart(
|
| 129 |
symbol=clean_symbol,
|
| 130 |
historical_prices=stock_data['Close'].tolist()[-60:],
|
| 131 |
-
forecast=
|
| 132 |
current_price=float(stock_data['Close'].iloc[-1]),
|
| 133 |
)
|
| 134 |
except Exception as ce:
|
|
@@ -146,15 +163,15 @@ async def process_stock(
|
|
| 146 |
'current_price': float(stock_data['Close'].iloc[-1]),
|
| 147 |
'last_updated': stock_data.index[-1].isoformat(),
|
| 148 |
'horizon_days': horizon,
|
| 149 |
-
'point_forecast': float(
|
| 150 |
'percentage_change': float(
|
| 151 |
-
((
|
| 152 |
/ stock_data['Close'].iloc[-1]) * 100
|
| 153 |
),
|
| 154 |
'quantiles': {
|
| 155 |
-
'p10': [float(x) for x in
|
| 156 |
-
'p50': [float(x) for x in
|
| 157 |
-
'p90': [float(x) for x in
|
| 158 |
},
|
| 159 |
'chart_svg': chart_svg,
|
| 160 |
'methodology_version': 'timesfm-2.5-200m-v1.0',
|
|
|
|
| 111 |
print("[WARN] Insufficient data")
|
| 112 |
return False
|
| 113 |
|
| 114 |
+
# Generate forecast once at the maximum horizon
|
| 115 |
+
max_horizon = max(HORIZONS)
|
| 116 |
+
full_forecast = await timesfm.predict(
|
| 117 |
+
historical_prices=stock_data['Close'].tolist(),
|
| 118 |
+
horizon=max_horizon,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
if full_forecast is None:
|
| 122 |
+
print(f"[WARN] Forecast failed (h={max_horizon})")
|
| 123 |
+
return False
|
| 124 |
+
|
| 125 |
+
# Generate forecasts and charts for each horizon by slicing
|
| 126 |
for horizon in HORIZONS:
|
| 127 |
+
# Slice point forecast and quantiles to the current horizon length
|
| 128 |
+
sliced_point_forecast = full_forecast['point_forecast'][:horizon]
|
| 129 |
+
sliced_quantiles = {
|
| 130 |
+
'p10': full_forecast['quantiles']['p10'][:horizon],
|
| 131 |
+
'p50': full_forecast['quantiles']['p50'][:horizon],
|
| 132 |
+
'p90': full_forecast['quantiles']['p90'][:horizon],
|
| 133 |
+
}
|
| 134 |
|
| 135 |
+
# Reconstruct horizon-specific forecast object for the chart service
|
| 136 |
+
horizon_forecast = {
|
| 137 |
+
'point_forecast': sliced_point_forecast,
|
| 138 |
+
'quantiles': sliced_quantiles,
|
| 139 |
+
'method': full_forecast.get('method', 'timesfm')
|
| 140 |
+
}
|
| 141 |
|
| 142 |
+
# Generate chart for every horizon using the sliced data
|
| 143 |
chart_svg = None
|
| 144 |
try:
|
| 145 |
chart_svg = chart_service.generate_forecast_chart(
|
| 146 |
symbol=clean_symbol,
|
| 147 |
historical_prices=stock_data['Close'].tolist()[-60:],
|
| 148 |
+
forecast=horizon_forecast,
|
| 149 |
current_price=float(stock_data['Close'].iloc[-1]),
|
| 150 |
)
|
| 151 |
except Exception as ce:
|
|
|
|
| 163 |
'current_price': float(stock_data['Close'].iloc[-1]),
|
| 164 |
'last_updated': stock_data.index[-1].isoformat(),
|
| 165 |
'horizon_days': horizon,
|
| 166 |
+
'point_forecast': float(sliced_point_forecast[-1]),
|
| 167 |
'percentage_change': float(
|
| 168 |
+
((sliced_point_forecast[-1] - stock_data['Close'].iloc[-1])
|
| 169 |
/ stock_data['Close'].iloc[-1]) * 100
|
| 170 |
),
|
| 171 |
'quantiles': {
|
| 172 |
+
'p10': [float(x) for x in sliced_quantiles['p10']],
|
| 173 |
+
'p50': [float(x) for x in sliced_quantiles['p50']],
|
| 174 |
+
'p90': [float(x) for x in sliced_quantiles['p90']],
|
| 175 |
},
|
| 176 |
'chart_svg': chart_svg,
|
| 177 |
'methodology_version': 'timesfm-2.5-200m-v1.0',
|
tests/test_caching.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sqlite3
|
| 2 |
+
import pytest
|
| 3 |
+
import json
|
| 4 |
+
import time
|
| 5 |
+
from app.services.database_service import DatabaseService
|
| 6 |
+
|
| 7 |
+
class SQLiteTestDatabaseService(DatabaseService):
|
| 8 |
+
def __init__(self):
|
| 9 |
+
super().__init__(url="libsql://dummy", token="dummy")
|
| 10 |
+
self.conn = sqlite3.connect(":memory:")
|
| 11 |
+
self.conn.row_factory = sqlite3.Row
|
| 12 |
+
self.connected = True
|
| 13 |
+
|
| 14 |
+
async def initialize(self):
|
| 15 |
+
await self._create_tables()
|
| 16 |
+
|
| 17 |
+
async def _execute(self, sql: str, params: list = None) -> list[dict]:
|
| 18 |
+
cursor = self.conn.cursor()
|
| 19 |
+
cursor.execute(sql, params or [])
|
| 20 |
+
self.conn.commit()
|
| 21 |
+
if cursor.description:
|
| 22 |
+
cols = [col[0] for col in cursor.description]
|
| 23 |
+
return [dict(zip(cols, row)) for row in cursor.fetchall()]
|
| 24 |
+
return []
|
| 25 |
+
|
| 26 |
+
@pytest.mark.asyncio
|
| 27 |
+
async def test_cache_forecast_lifecycle():
|
| 28 |
+
db = SQLiteTestDatabaseService()
|
| 29 |
+
await db.initialize()
|
| 30 |
+
|
| 31 |
+
forecast_data = {
|
| 32 |
+
"symbol": "AAPL",
|
| 33 |
+
"horizon_days": 10,
|
| 34 |
+
"point_forecast": 150.0,
|
| 35 |
+
"percentage_change": 1.5
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
await db.cache_forecast("AAPL", 10, forecast_data)
|
| 39 |
+
|
| 40 |
+
cached = await db.get_cached_forecast("AAPL", 10, max_age_hours=24)
|
| 41 |
+
assert cached is not None
|
| 42 |
+
assert cached["symbol"] == "AAPL"
|
| 43 |
+
|
| 44 |
+
@pytest.mark.asyncio
|
| 45 |
+
async def test_cache_forecast_expiration():
|
| 46 |
+
db = SQLiteTestDatabaseService()
|
| 47 |
+
await db.initialize()
|
| 48 |
+
|
| 49 |
+
forecast_data = {"symbol": "AAPL", "horizon_days": 10}
|
| 50 |
+
stale_time = int(time.time()) - (25 * 3600) # 25 hours ago
|
| 51 |
+
|
| 52 |
+
await db._execute(
|
| 53 |
+
"INSERT INTO forecasts (symbol, horizon_days, forecast_data, created_at) VALUES (?, ?, ?, ?)",
|
| 54 |
+
["AAPL", 10, json.dumps(forecast_data), stale_time]
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# Stale cache retrieval should return None for 24h limit
|
| 58 |
+
assert await db.get_cached_forecast("AAPL", 10, max_age_hours=24) is None
|
| 59 |
+
# But valid if max age is 48h
|
| 60 |
+
assert await db.get_cached_forecast("AAPL", 10, max_age_hours=48) is not None
|
tests/test_forecast_api.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from unittest.mock import AsyncMock, MagicMock
|
| 3 |
+
from fastapi.testclient import TestClient
|
| 4 |
+
import pandas as pd
|
| 5 |
+
import app.main as main
|
| 6 |
+
|
| 7 |
+
# Create TestClient without context manager to bypass actual lifespan events
|
| 8 |
+
client = TestClient(main.app, raise_server_exceptions=False)
|
| 9 |
+
|
| 10 |
+
@pytest.fixture(autouse=True)
|
| 11 |
+
def setup_mocks(monkeypatch):
|
| 12 |
+
mock_db = AsyncMock()
|
| 13 |
+
mock_db.connected = True
|
| 14 |
+
|
| 15 |
+
mock_timesfm = AsyncMock()
|
| 16 |
+
mock_timesfm.model = MagicMock()
|
| 17 |
+
|
| 18 |
+
mock_data = AsyncMock()
|
| 19 |
+
mock_chart = MagicMock()
|
| 20 |
+
|
| 21 |
+
monkeypatch.setattr(main, "db_service", mock_db)
|
| 22 |
+
monkeypatch.setattr(main, "timesfm_service", mock_timesfm)
|
| 23 |
+
monkeypatch.setattr(main, "data_service", mock_data)
|
| 24 |
+
monkeypatch.setattr(main, "chart_service", mock_chart)
|
| 25 |
+
|
| 26 |
+
# Disable rate limiting for testing by mocking the _check method
|
| 27 |
+
monkeypatch.setattr("app.middleware.RateLimitMiddleware._check", lambda self, ip, path: (False, 0))
|
| 28 |
+
|
| 29 |
+
return mock_db, mock_timesfm, mock_data, mock_chart
|
| 30 |
+
|
| 31 |
+
def test_health_check_healthy(setup_mocks):
|
| 32 |
+
mock_db, _, _, _ = setup_mocks
|
| 33 |
+
mock_db._execute.return_value = [{"1": 1}]
|
| 34 |
+
|
| 35 |
+
response = client.get("/health")
|
| 36 |
+
assert response.status_code == 200
|
| 37 |
+
data = response.json()
|
| 38 |
+
assert data["status"] == "healthy"
|
| 39 |
+
assert data["database_connected"] is True
|
| 40 |
+
assert data["model_loaded"] is True
|
| 41 |
+
|
| 42 |
+
def test_health_check_db_disconnected(setup_mocks):
|
| 43 |
+
mock_db, _, _, _ = setup_mocks
|
| 44 |
+
mock_db.connected = False
|
| 45 |
+
|
| 46 |
+
response = client.get("/health")
|
| 47 |
+
assert response.status_code == 200
|
| 48 |
+
assert response.json()["database_connected"] is False
|
| 49 |
+
|
| 50 |
+
def test_get_forecast_cache_hit(setup_mocks):
|
| 51 |
+
mock_db, _, _, _ = setup_mocks
|
| 52 |
+
mock_db.get_cached_forecast.return_value = {
|
| 53 |
+
"symbol": "AAPL",
|
| 54 |
+
"name": "Apple Inc.",
|
| 55 |
+
"exchange": "NASDAQ",
|
| 56 |
+
"currency": "USD",
|
| 57 |
+
"current_price": 150.0,
|
| 58 |
+
"last_updated": "2026-06-19T00:00:00",
|
| 59 |
+
"horizon_days": 20,
|
| 60 |
+
"point_forecast": 155.0,
|
| 61 |
+
"percentage_change": 3.33,
|
| 62 |
+
"quantiles": {"p10": [148.0], "p50": [155.0], "p90": [162.0]},
|
| 63 |
+
"chart_svg": "<svg></svg>",
|
| 64 |
+
"methodology_version": "timesfm-2.5-200m-v1.0"
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
response = client.get("/api/v1/forecast/AAPL?horizon=20")
|
| 68 |
+
assert response.status_code == 200
|
| 69 |
+
assert response.json()["symbol"] == "AAPL"
|
| 70 |
+
mock_db.get_cached_forecast.assert_called_once_with("AAPL", 20)
|
| 71 |
+
|
| 72 |
+
def test_get_forecast_cache_miss_and_generate(setup_mocks):
|
| 73 |
+
mock_db, mock_timesfm, mock_data, mock_chart = setup_mocks
|
| 74 |
+
mock_db.get_cached_forecast.return_value = None
|
| 75 |
+
|
| 76 |
+
mock_df = pd.DataFrame(
|
| 77 |
+
{"Close": [140.0 + i for i in range(100)]},
|
| 78 |
+
index=pd.date_range("2026-01-01", periods=100)
|
| 79 |
+
)
|
| 80 |
+
mock_df.attrs["name"] = "Apple Inc."
|
| 81 |
+
mock_df.attrs["exchange"] = "NASDAQ"
|
| 82 |
+
mock_df.attrs["currency"] = "USD"
|
| 83 |
+
mock_data.get_stock_data.return_value = mock_df
|
| 84 |
+
|
| 85 |
+
mock_timesfm.predict.return_value = {
|
| 86 |
+
"point_forecast": [245.0] * 20,
|
| 87 |
+
"quantiles": {
|
| 88 |
+
"p10": [240.0] * 20,
|
| 89 |
+
"p50": [245.0] * 20,
|
| 90 |
+
"p90": [250.0] * 20
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
mock_chart.generate_forecast_chart.return_value = "<svg>chart</svg>"
|
| 94 |
+
|
| 95 |
+
response = client.get("/api/v1/forecast/AAPL?horizon=20")
|
| 96 |
+
assert response.status_code == 200
|
| 97 |
+
data = response.json()
|
| 98 |
+
assert data["symbol"] == "AAPL"
|
| 99 |
+
assert data["current_price"] == 239.0
|
| 100 |
+
mock_db.cache_forecast.assert_called_once()
|