Karan6124 commited on
Commit
d07b262
·
1 Parent(s): 30ecb84

fix: add 15-minute Redis dynamic backfill lock in crud.get_stock_history to prevent hammering yfinance on cold database

Browse files
Files changed (1) hide show
  1. backend/app/database/crud.py +90 -57
backend/app/database/crud.py CHANGED
@@ -201,69 +201,102 @@ async def get_stock_history(db: AsyncSession, ticker: str, limit: int = 100) ->
201
  )
202
  history = list(result.scalars().all())
203
 
204
- # If database has under 100 records for this ticker, dynamically backfill from yfinance
 
205
  if len(history) < 100:
206
- import yfinance as yf
207
- import asyncio
208
 
209
- try:
210
- # yfinance allows fetching 1m interval historical data up to 30 days. We fetch last 5 days.
211
- yf_ticker = yf.Ticker(ticker.upper())
212
- df = await asyncio.get_event_loop().run_in_executor(
213
- None,
214
- lambda: yf_ticker.history(period="5d", interval="1m")
215
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
 
217
- if not df.empty:
218
- df = df.reset_index()
 
 
 
 
 
219
 
220
- # Identify timestamp column
221
- time_col = None
222
- for col in ['Date', 'Datetime', 'index', 'timestamp']:
223
- if col in df.columns:
224
- time_col = col
225
- break
226
-
227
- if time_col:
228
- candles_to_insert = []
229
- for _, row in df.iterrows():
230
- ts = row[time_col]
231
- if hasattr(ts, 'to_pydatetime'):
232
- ts_dt = ts.to_pydatetime()
233
- elif isinstance(ts, str):
234
- ts_dt = datetime.datetime.fromisoformat(ts)
235
- else:
236
- ts_dt = ts
237
-
238
- if ts_dt.tzinfo is not None:
239
- ts_dt = ts_dt.replace(tzinfo=None)
240
-
241
- candles_to_insert.append({
242
- "ticker": ticker.upper(),
243
- "timestamp": ts_dt,
244
- "open": float(row["Open"]),
245
- "high": float(row["High"]),
246
- "low": float(row["Low"]),
247
- "close": float(row["Close"]),
248
- "volume": int(row["Volume"]) if "Volume" in row else 0
249
- })
250
 
251
- if candles_to_insert:
252
- # Perform batch insert ignoring duplicates
253
- stmt = pg_insert(models.StockHistory).values(candles_to_insert)
254
- await db.execute(stmt.on_conflict_do_nothing(index_elements=["ticker", "timestamp"]))
255
- await db.commit()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
 
257
- # Re-query the database with the fully backfilled candles
258
- result = await db.execute(
259
- select(models.StockHistory)
260
- .where(models.StockHistory.ticker == ticker.upper())
261
- .order_by(models.StockHistory.timestamp.desc())
262
- .limit(limit)
263
- )
264
- history = list(result.scalars().all())
265
- except Exception as e:
266
- print(f"MLOps Dynamic Backfill: Failed to populate history for {ticker}: {e}")
 
 
 
 
 
 
 
267
 
268
  # Return in chronological order (oldest to newest) for indicators
269
  history.reverse()
 
201
  )
202
  history = list(result.scalars().all())
203
 
204
+ # If database has under 100 records for this ticker, dynamically backfill from yfinance.
205
+ # We use a 15-minute Redis-based lockout to avoid hammering yfinance when database is cold.
206
  if len(history) < 100:
207
+ import redis
208
+ from backend.app.config.settings import settings
209
 
210
+ # Connect to Redis logical DB 1 (same as service cache)
211
+ redis_client = None
212
+ if settings.REDIS_URL:
213
+ try:
214
+ redis_cache_url = settings.REDIS_URL
215
+ if redis_cache_url.endswith("/0"):
216
+ redis_cache_url = redis_cache_url[:-2] + "/1"
217
+ elif not any(redis_cache_url.endswith(f"/{i}") for i in range(16)):
218
+ redis_cache_url = redis_cache_url.rstrip("/") + "/1"
219
+ redis_client = redis.from_url(redis_cache_url, decode_responses=True)
220
+ except Exception:
221
+ pass
222
+
223
+ lock_key = f"quantiq:backfill_lock:{ticker.upper()}"
224
+ already_attempted = False
225
+ if redis_client:
226
+ try:
227
+ already_attempted = bool(redis_client.get(lock_key))
228
+ except Exception:
229
+ pass
230
+
231
+ if not already_attempted:
232
+ if redis_client:
233
+ try:
234
+ redis_client.setex(lock_key, 900, "1") # 15 minutes TTL
235
+ except Exception:
236
+ pass
237
+
238
+ import yfinance as yf
239
+ import asyncio
240
 
241
+ try:
242
+ # yfinance allows fetching 1m interval historical data up to 30 days. We fetch last 5 days.
243
+ yf_ticker = yf.Ticker(ticker.upper())
244
+ df = await asyncio.get_event_loop().run_in_executor(
245
+ None,
246
+ lambda: yf_ticker.history(period="5d", interval="1m")
247
+ )
248
 
249
+ if df is not None and not df.empty:
250
+ df = df.reset_index()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
 
252
+ # Identify timestamp column
253
+ time_col = None
254
+ for col in ['Date', 'Datetime', 'index', 'timestamp']:
255
+ if col in df.columns:
256
+ time_col = col
257
+ break
258
+
259
+ if time_col:
260
+ candles_to_insert = []
261
+ for _, row in df.iterrows():
262
+ ts = row[time_col]
263
+ if hasattr(ts, 'to_pydatetime'):
264
+ ts_dt = ts.to_pydatetime()
265
+ elif isinstance(ts, str):
266
+ ts_dt = datetime.datetime.fromisoformat(ts)
267
+ else:
268
+ ts_dt = ts
269
+
270
+ if ts_dt.tzinfo is not None:
271
+ ts_dt = ts_dt.replace(tzinfo=None)
272
+
273
+ candles_to_insert.append({
274
+ "ticker": ticker.upper(),
275
+ "timestamp": ts_dt,
276
+ "open": float(row["Open"]),
277
+ "high": float(row["High"]),
278
+ "low": float(row["Low"]),
279
+ "close": float(row["Close"]),
280
+ "volume": int(row["Volume"]) if "Volume" in row else 0
281
+ })
282
 
283
+ if candles_to_insert:
284
+ # Perform batch insert ignoring duplicates
285
+ stmt = pg_insert(models.StockHistory).values(candles_to_insert)
286
+ await db.execute(stmt.on_conflict_do_nothing(index_elements=["ticker", "timestamp"]))
287
+ await db.commit()
288
+
289
+ # Re-query the database with the fully backfilled candles
290
+ result = await db.execute(
291
+ select(models.StockHistory)
292
+ .where(models.StockHistory.ticker == ticker.upper())
293
+ .order_by(models.StockHistory.timestamp.desc())
294
+ .limit(limit)
295
+ )
296
+ history = list(result.scalars().all())
297
+ except Exception as e:
298
+ print(f"MLOps Dynamic Backfill: Failed to populate history for {ticker}: {e}")
299
+
300
 
301
  # Return in chronological order (oldest to newest) for indicators
302
  history.reverse()