cramamoorthy commited on
Commit
bcf7c28
·
1 Parent(s): 09b5b07

Deploy backend

Browse files
.env.example CHANGED
@@ -17,7 +17,7 @@ LOG_LEVEL=INFO
17
  # Cron ($0: internal scheduler + optional free HF Space Scheduler UI)
18
  CRON_SECRET=generate-a-long-random-string
19
  ENABLE_INTERNAL_CRON=true
20
- DAILY_FORECAST_LIMIT_PER_COUNTRY=50
21
 
22
  # CORS (comma-separated origins)
23
  CORS_ORIGINS=https://yourdomain.com,http://localhost:4321
 
17
  # Cron ($0: internal scheduler + optional free HF Space Scheduler UI)
18
  CRON_SECRET=generate-a-long-random-string
19
  ENABLE_INTERNAL_CRON=true
20
+ DAILY_FORECAST_LIMIT_PER_COUNTRY=200
21
 
22
  # CORS (comma-separated origins)
23
  CORS_ORIGINS=https://yourdomain.com,http://localhost:4321
app/main.py CHANGED
@@ -16,7 +16,7 @@ from app.services.timesfm_service import TimesFMService
16
  from app.services.data_service import StockDataService
17
  from app.services.chart_service import ChartService
18
  from app.services.database_service import DatabaseService
19
- from app.models.schemas import ForecastResponse, StockInfo, HealthResponse
20
  from app.cron import router as cron_router
21
  from app.internal_cron import start_internal_cron
22
  from app.middleware import RateLimitMiddleware, RequestLoggingMiddleware
@@ -140,6 +140,59 @@ async def health_check():
140
  )
141
 
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  @app.get("/api/v1/forecast/{symbol}", response_model=ForecastResponse)
144
  async def get_forecast(
145
  symbol: str,
@@ -183,6 +236,12 @@ async def get_forecast(
183
  horizon=horizon
184
  )
185
 
 
 
 
 
 
 
186
  # Generate chart
187
  chart_svg = chart_service.generate_forecast_chart(
188
  symbol=symbol,
@@ -191,6 +250,32 @@ async def get_forecast(
191
  current_price=current_price
192
  )
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  # Prepare response
195
  response = ForecastResponse(
196
  symbol=symbol,
@@ -210,6 +295,9 @@ async def get_forecast(
210
  'p90': [float(x) for x in forecast_result['quantiles']['p90']],
211
  },
212
  chart_svg=chart_svg,
 
 
 
213
  methodology_version="timesfm-2.5-200m-v1.0"
214
  )
215
 
 
16
  from app.services.data_service import StockDataService
17
  from app.services.chart_service import ChartService
18
  from app.services.database_service import DatabaseService
19
+ from app.models.schemas import ForecastResponse, StockInfo, HealthResponse, BacktestAccuracy, FinancialMetrics
20
  from app.cron import router as cron_router
21
  from app.internal_cron import start_internal_cron
22
  from app.middleware import RateLimitMiddleware, RequestLoggingMiddleware
 
140
  )
141
 
142
 
143
+ async def calculate_backtest_accuracy(
144
+ historical_prices: list[float],
145
+ timesfm: TimesFMService
146
+ ) -> dict:
147
+ """
148
+ Calculate 5-day and 20-day forecast accuracy percentages
149
+ by slicing historical close prices, running forecasts, and
150
+ calculating MAPE against actual realized prices.
151
+
152
+ Accuracy = max(0.0, 100.0 - MAPE_percentage)
153
+ """
154
+ if len(historical_prices) < 100:
155
+ return {"accuracy_5d": 0.0, "accuracy_20d": 0.0}
156
+
157
+ try:
158
+ # --- 5-Day Horizon Backtest ---
159
+ slice_5d = historical_prices[:-5]
160
+ realized_5d = historical_prices[-5:]
161
+
162
+ forecast_5d = await timesfm.predict(historical_prices=slice_5d, horizon=5)
163
+ point_5d = forecast_5d.get("point_forecast", [])
164
+
165
+ if len(point_5d) >= 5 and len(realized_5d) == 5:
166
+ errors = [abs(p - r) / r for p, r in zip(point_5d, realized_5d) if r > 0]
167
+ mape = sum(errors) / len(errors) if errors else 0.0
168
+ accuracy_5d = max(0.0, min(100.0, 100.0 - (mape * 100.0)))
169
+ else:
170
+ accuracy_5d = 0.0
171
+
172
+ # --- 20-Day Horizon Backtest ---
173
+ slice_20d = historical_prices[:-20]
174
+ realized_20d = historical_prices[-20:]
175
+
176
+ forecast_20d = await timesfm.predict(historical_prices=slice_20d, horizon=20)
177
+ point_20d = forecast_20d.get("point_forecast", [])
178
+
179
+ if len(point_20d) >= 20 and len(realized_20d) == 20:
180
+ errors = [abs(p - r) / r for p, r in zip(point_20d, realized_20d) if r > 0]
181
+ mape = sum(errors) / len(errors) if errors else 0.0
182
+ accuracy_20d = max(0.0, min(100.0, 100.0 - (mape * 100.0)))
183
+ else:
184
+ accuracy_20d = 0.0
185
+
186
+ return {
187
+ "accuracy_5d": round(accuracy_5d, 2),
188
+ "accuracy_20d": round(accuracy_20d, 2)
189
+ }
190
+
191
+ except Exception as e:
192
+ logger.warning(f"Error calculating backtest accuracy: {e}")
193
+ return {"accuracy_5d": 0.0, "accuracy_20d": 0.0}
194
+
195
+
196
  @app.get("/api/v1/forecast/{symbol}", response_model=ForecastResponse)
197
  async def get_forecast(
198
  symbol: str,
 
236
  horizon=horizon
237
  )
238
 
239
+ # Calculate backtesting validation accuracy
240
+ backtest_acc = await calculate_backtest_accuracy(
241
+ historical_prices=close_series.tolist(),
242
+ timesfm=timesfm_service
243
+ )
244
+
245
  # Generate chart
246
  chart_svg = chart_service.generate_forecast_chart(
247
  symbol=symbol,
 
250
  current_price=current_price
251
  )
252
 
253
+ # Last 60 historical close prices, formatted timezone-safely (YYYY-MM-DD)
254
+ history_subset = close_series[-60:]
255
+ historical_prices_data = [
256
+ {
257
+ 'time': date.strftime('%Y-%m-%d'),
258
+ 'value': float(close)
259
+ }
260
+ for date, close in zip(history_subset.index, history_subset)
261
+ ]
262
+
263
+ # Get financial metrics from attributes
264
+ from app.services.data_service import clean_float
265
+ pe_ratio = clean_float(stock_df.attrs.get('pe_ratio'))
266
+ dividend_yield = clean_float(stock_df.attrs.get('dividend_yield'))
267
+ fifty_two_week_low = clean_float(stock_df.attrs.get('fifty_two_week_low'))
268
+ fifty_two_week_high = clean_float(stock_df.attrs.get('fifty_two_week_high'))
269
+ market_cap = clean_float(stock_df.attrs.get('market_cap'))
270
+
271
+ financial_metrics = FinancialMetrics(
272
+ pe_ratio=pe_ratio,
273
+ dividend_yield=dividend_yield,
274
+ fifty_two_week_low=fifty_two_week_low,
275
+ fifty_two_week_high=fifty_two_week_high,
276
+ market_cap=market_cap,
277
+ )
278
+
279
  # Prepare response
280
  response = ForecastResponse(
281
  symbol=symbol,
 
295
  'p90': [float(x) for x in forecast_result['quantiles']['p90']],
296
  },
297
  chart_svg=chart_svg,
298
+ historical_prices=historical_prices_data,
299
+ backtest_accuracy=backtest_acc,
300
+ financial_metrics=financial_metrics,
301
  methodology_version="timesfm-2.5-200m-v1.0"
302
  )
303
 
app/models/schemas.py CHANGED
@@ -12,6 +12,27 @@ class Quantiles(BaseModel):
12
  p90: list[float] = Field(..., description="90th percentile (optimistic)")
13
 
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  class ForecastResponse(BaseModel):
16
  """Complete forecast response with all data"""
17
  symbol: str
@@ -25,6 +46,9 @@ class ForecastResponse(BaseModel):
25
  percentage_change: float = Field(..., description="Predicted % change from current")
26
  quantiles: Quantiles
27
  chart_svg: Optional[str] = Field(default=None, description="SVG chart as string")
 
 
 
28
  methodology_version: str
29
 
30
 
 
12
  p90: list[float] = Field(..., description="90th percentile (optimistic)")
13
 
14
 
15
+ class HistoricalPrice(BaseModel):
16
+ """Historical price data point for plotting"""
17
+ time: str = Field(..., description="Date in YYYY-MM-DD format")
18
+ value: float = Field(..., description="Closing price")
19
+
20
+
21
+ class BacktestAccuracy(BaseModel):
22
+ """Backtesting forecast accuracy metrics"""
23
+ accuracy_5d: float = Field(..., description="5-day forecast accuracy percentage")
24
+ accuracy_20d: float = Field(..., description="20-day forecast accuracy percentage")
25
+
26
+
27
+ class FinancialMetrics(BaseModel):
28
+ """Fundamental financial metrics for a stock"""
29
+ pe_ratio: Optional[float] = Field(default=None, description="Trailing P/E ratio")
30
+ dividend_yield: Optional[float] = Field(default=None, description="Dividend yield percentage")
31
+ fifty_two_week_low: Optional[float] = Field(default=None, description="52-week low price")
32
+ fifty_two_week_high: Optional[float] = Field(default=None, description="52-week high price")
33
+ market_cap: Optional[float] = Field(default=None, description="Market capitalization")
34
+
35
+
36
  class ForecastResponse(BaseModel):
37
  """Complete forecast response with all data"""
38
  symbol: str
 
46
  percentage_change: float = Field(..., description="Predicted % change from current")
47
  quantiles: Quantiles
48
  chart_svg: Optional[str] = Field(default=None, description="SVG chart as string")
49
+ historical_prices: list[HistoricalPrice] = Field(default=[], description="List of recent historical prices for plotting")
50
+ backtest_accuracy: Optional[BacktestAccuracy] = Field(default=None, description="TimesFM backtesting validation metrics")
51
+ financial_metrics: Optional[FinancialMetrics] = Field(default=None, description="Key fundamental statistics")
52
  methodology_version: str
53
 
54
 
app/services/data_service.py CHANGED
@@ -34,6 +34,20 @@ MAX_WORKERS = 10 # Parallel fetches
34
  DEFAULT_DELAY = 0.5 # Base delay between batches (seconds)
35
 
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  class StockDataService:
38
  """Service for fetching stock data from Yahoo Finance with rate-limit protection"""
39
 
@@ -88,6 +102,18 @@ class StockDataService:
88
  df.index = pd.to_datetime(df.index)
89
  else:
90
  df = pd.read_parquet(path)
 
 
 
 
 
 
 
 
 
 
 
 
91
  logger.debug(f"Disk cache hit for {symbol} ({period})")
92
  return df
93
  except Exception as e:
@@ -103,6 +129,25 @@ class StockDataService:
103
  except ImportError:
104
  json_path = cache_path.with_suffix(".json")
105
  df.reset_index().to_json(json_path, orient="records", date_format="iso")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  logger.debug(f"Cached {symbol} ({period}) to disk")
107
  except Exception as e:
108
  logger.debug(f"Skipped disk cache for {symbol}: {e}")
@@ -112,11 +157,12 @@ class StockDataService:
112
  try:
113
  now = datetime.now()
114
  expired = 0
115
- for cache_file in CACHE_DIR.glob("*.parquet"):
116
- age = now - datetime.fromtimestamp(cache_file.stat().st_mtime)
117
- if age > timedelta(hours=CACHE_TTL_HOURS * 2):
118
- cache_file.unlink()
119
- expired += 1
 
120
  if expired:
121
  logger.info(f"Cleared {expired} expired cache files")
122
  except Exception as e:
@@ -202,9 +248,17 @@ class StockDataService:
202
  # Add metadata as DataFrame attributes
203
  try:
204
  info = ticker.info
 
 
205
  df.attrs['name'] = info.get('longName', info.get('shortName', symbol))
206
  df.attrs['exchange'] = info.get('exchange', 'UNKNOWN')
207
  df.attrs['currency'] = info.get('currency', 'USD')
 
 
 
 
 
 
208
  except Exception:
209
  df.attrs['name'] = symbol
210
  df.attrs['exchange'] = 'UNKNOWN'
@@ -440,9 +494,10 @@ class StockDataService:
440
 
441
  try:
442
  count = 0
443
- for cache_file in CACHE_DIR.glob("*.parquet"):
444
- cache_file.unlink()
445
- count += 1
 
446
  logger.info(f"Cleared disk cache ({count} files)")
447
  except Exception as e:
448
  logger.warning(f"Disk cache clear failed: {e}")
 
34
  DEFAULT_DELAY = 0.5 # Base delay between batches (seconds)
35
 
36
 
37
+ def clean_float(val) -> Optional[float]:
38
+ """Safely convert value to float, filtering out non-finite values (NaN, Inf) and None."""
39
+ if val is None:
40
+ return None
41
+ try:
42
+ f_val = float(val)
43
+ import math
44
+ if math.isnan(f_val) or math.isinf(f_val):
45
+ return None
46
+ return f_val
47
+ except (ValueError, TypeError):
48
+ return None
49
+
50
+
51
  class StockDataService:
52
  """Service for fetching stock data from Yahoo Finance with rate-limit protection"""
53
 
 
102
  df.index = pd.to_datetime(df.index)
103
  else:
104
  df = pd.read_parquet(path)
105
+
106
+ # Restore attrs from meta JSON if present
107
+ meta_path = cache_path.with_suffix(".meta.json")
108
+ if meta_path.exists():
109
+ try:
110
+ with open(meta_path, "r") as f:
111
+ meta_data = json.load(f)
112
+ for k, v in meta_data.items():
113
+ df.attrs[k] = v
114
+ except Exception as me:
115
+ logger.warning(f"Failed to read metadata file: {me}")
116
+
117
  logger.debug(f"Disk cache hit for {symbol} ({period})")
118
  return df
119
  except Exception as e:
 
129
  except ImportError:
130
  json_path = cache_path.with_suffix(".json")
131
  df.reset_index().to_json(json_path, orient="records", date_format="iso")
132
+
133
+ # Save attrs to a separate JSON file
134
+ meta_path = cache_path.with_suffix(".meta.json")
135
+ meta_data = {
136
+ 'name': df.attrs.get('name'),
137
+ 'exchange': df.attrs.get('exchange'),
138
+ 'currency': df.attrs.get('currency'),
139
+ 'pe_ratio': df.attrs.get('pe_ratio'),
140
+ 'dividend_yield': df.attrs.get('dividend_yield'),
141
+ 'fifty_two_week_low': df.attrs.get('fifty_two_week_low'),
142
+ 'fifty_two_week_high': df.attrs.get('fifty_two_week_high'),
143
+ 'market_cap': df.attrs.get('market_cap'),
144
+ }
145
+ try:
146
+ with open(meta_path, "w") as f:
147
+ json.dump(meta_data, f)
148
+ except Exception as me:
149
+ logger.warning(f"Failed to write metadata file: {me}")
150
+
151
  logger.debug(f"Cached {symbol} ({period}) to disk")
152
  except Exception as e:
153
  logger.debug(f"Skipped disk cache for {symbol}: {e}")
 
157
  try:
158
  now = datetime.now()
159
  expired = 0
160
+ for pattern in ("*.parquet", "*.json", "*.meta.json"):
161
+ for cache_file in CACHE_DIR.glob(pattern):
162
+ age = now - datetime.fromtimestamp(cache_file.stat().st_mtime)
163
+ if age > timedelta(hours=CACHE_TTL_HOURS * 2):
164
+ cache_file.unlink()
165
+ expired += 1
166
  if expired:
167
  logger.info(f"Cleared {expired} expired cache files")
168
  except Exception as e:
 
248
  # Add metadata as DataFrame attributes
249
  try:
250
  info = ticker.info
251
+ if not isinstance(info, dict):
252
+ info = {}
253
  df.attrs['name'] = info.get('longName', info.get('shortName', symbol))
254
  df.attrs['exchange'] = info.get('exchange', 'UNKNOWN')
255
  df.attrs['currency'] = info.get('currency', 'USD')
256
+ # Key fundamentals sanitized
257
+ df.attrs['pe_ratio'] = clean_float(info.get('trailingPE'))
258
+ df.attrs['dividend_yield'] = clean_float(info.get('dividendYield'))
259
+ df.attrs['fifty_two_week_low'] = clean_float(info.get('fiftyTwoWeekLow'))
260
+ df.attrs['fifty_two_week_high'] = clean_float(info.get('fiftyTwoWeekHigh'))
261
+ df.attrs['market_cap'] = clean_float(info.get('marketCap'))
262
  except Exception:
263
  df.attrs['name'] = symbol
264
  df.attrs['exchange'] = 'UNKNOWN'
 
494
 
495
  try:
496
  count = 0
497
+ for pattern in ("*.parquet", "*.json", "*.meta.json"):
498
+ for cache_file in CACHE_DIR.glob(pattern):
499
+ cache_file.unlink()
500
+ count += 1
501
  logger.info(f"Cleared disk cache ({count} files)")
502
  except Exception as e:
503
  logger.warning(f"Disk cache clear failed: {e}")
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, **updates):
720
  """Update sync log entry"""
721
  try:
722
- updates = []
723
  params = []
724
 
725
- if 'completed_at' in updates:
726
- updates.append("completed_at = ?")
727
- params.append(updates['completed_at'])
728
- if 'stocks_added' in updates:
729
- updates.append("stocks_added = ?")
730
- params.append(updates['stocks_added'])
731
- if 'stocks_removed' in updates:
732
- updates.append("stocks_removed = ?")
733
- params.append(updates['stocks_removed'])
734
- if 'stocks_updated' in updates:
735
- updates.append("stocks_updated = ?")
736
- params.append(updates['stocks_updated'])
737
- if 'errors' in updates:
738
- updates.append("errors = ?")
739
- params.append(updates['errors'])
740
- if 'status' in updates:
741
- updates.append("status = ?")
742
- params.append(updates['status'])
743
 
744
  params.append(log_id)
745
 
746
- if updates:
747
- sql = f"UPDATE sync_log SET {', '.join(updates)} WHERE id = ?"
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
@@ -29,7 +29,7 @@ logger = logging.getLogger(__name__)
29
  sys.path.insert(0, str(Path(__file__).parent.parent))
30
 
31
  from app.services.database_service import DatabaseService
32
- from app.services.data_service import StockDataService
33
  from app.services.timesfm_service import TimesFMService
34
  from app.services.chart_service import ChartService
35
  from dotenv import load_dotenv
@@ -47,7 +47,7 @@ HORIZONS = [1, 5, 20, 60]
47
  MAX_STOCKS_PER_COUNTRY = int(os.getenv("DAILY_FORECAST_LIMIT_PER_COUNTRY", "10000"))
48
 
49
  # Delay between individual stock fetches (seconds)
50
- STOCK_DELAY = 0.3
51
 
52
  # Country names for display (ASCII-safe for Windows)
53
  COUNTRY_NAMES = {
@@ -79,6 +79,59 @@ def get_yfinance_symbol(symbol: str, db_stocks: list[dict]) -> str:
79
  return symbol_upper
80
 
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  async def process_stock(
83
  stock: dict,
84
  db: DatabaseService,
@@ -111,29 +164,97 @@ async def process_stock(
111
  print("[WARN] Insufficient data")
112
  return False
113
 
114
- # Generate forecasts for each horizon
115
- for horizon in HORIZONS:
116
- forecast = await timesfm.predict(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  historical_prices=stock_data['Close'].tolist(),
118
- horizon=horizon,
119
  )
120
 
121
- if forecast is None:
122
- print(f"[WARN] Forecast failed (h={horizon})")
123
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
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=forecast,
132
  current_price=float(stock_data['Close'].iloc[-1]),
133
  )
134
  except Exception as ce:
135
  logger.warning(f"Chart generation failed for {clean_symbol} h={horizon}: {ce}")
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  # Prepare forecast data
138
  forecast_data = {
139
  'symbol': clean_symbol,
@@ -146,17 +267,20 @@ 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(forecast['point_forecast'][-1]),
150
  'percentage_change': float(
151
- ((forecast['point_forecast'][-1] - stock_data['Close'].iloc[-1])
152
  / stock_data['Close'].iloc[-1]) * 100
153
  ),
154
  'quantiles': {
155
- 'p10': [float(x) for x in forecast['quantiles']['p10']],
156
- 'p50': [float(x) for x in forecast['quantiles']['p50']],
157
- 'p90': [float(x) for x in forecast['quantiles']['p90']],
158
  },
159
  'chart_svg': chart_svg,
 
 
 
160
  'methodology_version': 'timesfm-2.5-200m-v1.0',
161
  }
162
 
 
29
  sys.path.insert(0, str(Path(__file__).parent.parent))
30
 
31
  from app.services.database_service import DatabaseService
32
+ from app.services.data_service import StockDataService, clean_float
33
  from app.services.timesfm_service import TimesFMService
34
  from app.services.chart_service import ChartService
35
  from dotenv import load_dotenv
 
47
  MAX_STOCKS_PER_COUNTRY = int(os.getenv("DAILY_FORECAST_LIMIT_PER_COUNTRY", "10000"))
48
 
49
  # Delay between individual stock fetches (seconds)
50
+ STOCK_DELAY = 0.1
51
 
52
  # Country names for display (ASCII-safe for Windows)
53
  COUNTRY_NAMES = {
 
79
  return symbol_upper
80
 
81
 
82
+ async def calculate_backtest_accuracy(
83
+ historical_prices: list[float],
84
+ timesfm: TimesFMService
85
+ ) -> dict:
86
+ """
87
+ Calculate 5-day and 20-day forecast accuracy percentages
88
+ by slicing historical close prices, running forecasts, and
89
+ calculating MAPE against actual realized prices.
90
+
91
+ Accuracy = max(0.0, 100.0 - MAPE_percentage)
92
+ """
93
+ if len(historical_prices) < 100:
94
+ return {"accuracy_5d": 0.0, "accuracy_20d": 0.0}
95
+
96
+ try:
97
+ # --- 5-Day Horizon Backtest ---
98
+ slice_5d = historical_prices[:-5]
99
+ realized_5d = historical_prices[-5:]
100
+
101
+ forecast_5d = await timesfm.predict(historical_prices=slice_5d, horizon=5)
102
+ point_5d = forecast_5d.get("point_forecast", [])
103
+
104
+ if len(point_5d) >= 5 and len(realized_5d) == 5:
105
+ errors = [abs(p - r) / r for p, r in zip(point_5d, realized_5d) if r > 0]
106
+ mape = sum(errors) / len(errors) if errors else 0.0
107
+ accuracy_5d = max(0.0, min(100.0, 100.0 - (mape * 100.0)))
108
+ else:
109
+ accuracy_5d = 0.0
110
+
111
+ # --- 20-Day Horizon Backtest ---
112
+ slice_20d = historical_prices[:-20]
113
+ realized_20d = historical_prices[-20:]
114
+
115
+ forecast_20d = await timesfm.predict(historical_prices=slice_20d, horizon=20)
116
+ point_20d = forecast_20d.get("point_forecast", [])
117
+
118
+ if len(point_20d) >= 20 and len(realized_20d) == 20:
119
+ errors = [abs(p - r) / r for p, r in zip(point_20d, realized_20d) if r > 0]
120
+ mape = sum(errors) / len(errors) if errors else 0.0
121
+ accuracy_20d = max(0.0, min(100.0, 100.0 - (mape * 100.0)))
122
+ else:
123
+ accuracy_20d = 0.0
124
+
125
+ return {
126
+ "accuracy_5d": round(accuracy_5d, 2),
127
+ "accuracy_20d": round(accuracy_20d, 2)
128
+ }
129
+
130
+ except Exception as e:
131
+ logger.warning(f"Error calculating backtest accuracy: {e}")
132
+ return {"accuracy_5d": 0.0, "accuracy_20d": 0.0}
133
+
134
+
135
  async def process_stock(
136
  stock: dict,
137
  db: DatabaseService,
 
164
  print("[WARN] Insufficient data")
165
  return False
166
 
167
+ # Generate forecast once at the maximum horizon
168
+ max_horizon = max(HORIZONS)
169
+ full_forecast = await timesfm.predict(
170
+ historical_prices=stock_data['Close'].tolist(),
171
+ horizon=max_horizon,
172
+ )
173
+
174
+ if full_forecast is None:
175
+ print(f"[WARN] Forecast failed (h={max_horizon})")
176
+ return False
177
+
178
+ # Check if we can reuse cached backtest accuracy
179
+ backtest_acc = None
180
+ try:
181
+ cached_forecast = await db.get_cached_forecast(
182
+ symbol=yf_symbol.upper(),
183
+ horizon=20,
184
+ max_age_hours=720,
185
+ )
186
+ if cached_forecast and isinstance(cached_forecast, dict):
187
+ cached_acc = cached_forecast.get("backtest_accuracy")
188
+ if (
189
+ isinstance(cached_acc, dict)
190
+ and "accuracy_5d" in cached_acc
191
+ and "accuracy_20d" in cached_acc
192
+ ):
193
+ backtest_acc = cached_acc
194
+ except Exception as e:
195
+ logger.warning(f"Error checking cached forecast for backtest accuracy reuse: {e}")
196
+
197
+ if backtest_acc is None:
198
+ # Calculate backtesting validation accuracy
199
+ backtest_acc = await calculate_backtest_accuracy(
200
  historical_prices=stock_data['Close'].tolist(),
201
+ timesfm=timesfm
202
  )
203
 
204
+ # Generate forecasts and charts for each horizon by slicing
205
+ for horizon in HORIZONS:
206
+ # Slice point forecast and quantiles to the current horizon length
207
+ sliced_point_forecast = full_forecast['point_forecast'][:horizon]
208
+ sliced_quantiles = {
209
+ 'p10': full_forecast['quantiles']['p10'][:horizon],
210
+ 'p50': full_forecast['quantiles']['p50'][:horizon],
211
+ 'p90': full_forecast['quantiles']['p90'][:horizon],
212
+ }
213
+
214
+ # Reconstruct horizon-specific forecast object for the chart service
215
+ horizon_forecast = {
216
+ 'point_forecast': sliced_point_forecast,
217
+ 'quantiles': sliced_quantiles,
218
+ 'method': full_forecast.get('method', 'timesfm')
219
+ }
220
 
221
+ # Generate chart for every horizon using the sliced data
222
  chart_svg = None
223
  try:
224
  chart_svg = chart_service.generate_forecast_chart(
225
  symbol=clean_symbol,
226
  historical_prices=stock_data['Close'].tolist()[-60:],
227
+ forecast=horizon_forecast,
228
  current_price=float(stock_data['Close'].iloc[-1]),
229
  )
230
  except Exception as ce:
231
  logger.warning(f"Chart generation failed for {clean_symbol} h={horizon}: {ce}")
232
 
233
+ # Last 60 historical close prices, formatted timezone-safely (YYYY-MM-DD)
234
+ history_subset = stock_data[-60:]
235
+ historical_prices = [
236
+ {
237
+ 'time': date.strftime('%Y-%m-%d'),
238
+ 'value': float(close)
239
+ }
240
+ for date, close in zip(history_subset.index, history_subset['Close'])
241
+ ]
242
+
243
+ # Get financial metrics
244
+ pe_ratio = clean_float(stock_data.attrs.get('pe_ratio'))
245
+ dividend_yield = clean_float(stock_data.attrs.get('dividend_yield'))
246
+ fifty_two_week_low = clean_float(stock_data.attrs.get('fifty_two_week_low'))
247
+ fifty_two_week_high = clean_float(stock_data.attrs.get('fifty_two_week_high'))
248
+ market_cap = clean_float(stock_data.attrs.get('market_cap'))
249
+
250
+ financial_metrics = {
251
+ 'pe_ratio': pe_ratio,
252
+ 'dividend_yield': dividend_yield,
253
+ 'fifty_two_week_low': fifty_two_week_low,
254
+ 'fifty_two_week_high': fifty_two_week_high,
255
+ 'market_cap': market_cap,
256
+ }
257
+
258
  # Prepare forecast data
259
  forecast_data = {
260
  'symbol': clean_symbol,
 
267
  'current_price': float(stock_data['Close'].iloc[-1]),
268
  'last_updated': stock_data.index[-1].isoformat(),
269
  'horizon_days': horizon,
270
+ 'point_forecast': float(sliced_point_forecast[-1]),
271
  'percentage_change': float(
272
+ ((sliced_point_forecast[-1] - stock_data['Close'].iloc[-1])
273
  / stock_data['Close'].iloc[-1]) * 100
274
  ),
275
  'quantiles': {
276
+ 'p10': [float(x) for x in sliced_quantiles['p10']],
277
+ 'p50': [float(x) for x in sliced_quantiles['p50']],
278
+ 'p90': [float(x) for x in sliced_quantiles['p90']],
279
  },
280
  'chart_svg': chart_svg,
281
+ 'historical_prices': historical_prices,
282
+ 'backtest_accuracy': backtest_acc,
283
+ 'financial_metrics': financial_metrics,
284
  'methodology_version': 'timesfm-2.5-200m-v1.0',
285
  }
286