feat: implement dynamic historical backfilling from yfinance in get_stock_history
Browse files- backend/app/database/crud.py +66 -1
backend/app/database/crud.py
CHANGED
|
@@ -201,8 +201,73 @@ async def get_stock_history(db: AsyncSession, ticker: str, limit: int = 100) ->
|
|
| 201 |
.order_by(models.StockHistory.timestamp.desc())
|
| 202 |
.limit(limit)
|
| 203 |
)
|
| 204 |
-
# Return in chronological order (oldest to newest) for indicators
|
| 205 |
history = list(result.scalars().all())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
history.reverse()
|
| 207 |
return history
|
| 208 |
|
|
|
|
| 201 |
.order_by(models.StockHistory.timestamp.desc())
|
| 202 |
.limit(limit)
|
| 203 |
)
|
|
|
|
| 204 |
history = list(result.scalars().all())
|
| 205 |
+
|
| 206 |
+
# If database has under 100 records for this ticker, dynamically backfill from yfinance
|
| 207 |
+
if len(history) < 100:
|
| 208 |
+
import yfinance as yf
|
| 209 |
+
import asyncio
|
| 210 |
+
|
| 211 |
+
try:
|
| 212 |
+
# yfinance allows fetching 1m interval historical data up to 30 days. We fetch last 5 days.
|
| 213 |
+
yf_ticker = yf.Ticker(ticker.upper())
|
| 214 |
+
df = await asyncio.get_event_loop().run_in_executor(
|
| 215 |
+
None,
|
| 216 |
+
lambda: yf_ticker.history(period="5d", interval="1m")
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
if not df.empty:
|
| 220 |
+
df = df.reset_index()
|
| 221 |
+
|
| 222 |
+
# Identify timestamp column
|
| 223 |
+
time_col = None
|
| 224 |
+
for col in ['Date', 'Datetime', 'index', 'timestamp']:
|
| 225 |
+
if col in df.columns:
|
| 226 |
+
time_col = col
|
| 227 |
+
break
|
| 228 |
+
|
| 229 |
+
if time_col:
|
| 230 |
+
candles_to_insert = []
|
| 231 |
+
for _, row in df.iterrows():
|
| 232 |
+
ts = row[time_col]
|
| 233 |
+
if hasattr(ts, 'to_pydatetime'):
|
| 234 |
+
ts_dt = ts.to_pydatetime()
|
| 235 |
+
elif isinstance(ts, str):
|
| 236 |
+
ts_dt = datetime.datetime.fromisoformat(ts)
|
| 237 |
+
else:
|
| 238 |
+
ts_dt = ts
|
| 239 |
+
|
| 240 |
+
if ts_dt.tzinfo is not None:
|
| 241 |
+
ts_dt = ts_dt.replace(tzinfo=None)
|
| 242 |
+
|
| 243 |
+
candles_to_insert.append({
|
| 244 |
+
"ticker": ticker.upper(),
|
| 245 |
+
"timestamp": ts_dt,
|
| 246 |
+
"open": float(row["Open"]),
|
| 247 |
+
"high": float(row["High"]),
|
| 248 |
+
"low": float(row["Low"]),
|
| 249 |
+
"close": float(row["Close"]),
|
| 250 |
+
"volume": int(row["Volume"]) if "Volume" in row else 0
|
| 251 |
+
})
|
| 252 |
+
|
| 253 |
+
if candles_to_insert:
|
| 254 |
+
# Perform batch insert ignoring duplicates
|
| 255 |
+
stmt = pg_insert(models.StockHistory).values(candles_to_insert)
|
| 256 |
+
await db.execute(stmt.on_conflict_do_nothing(index_elements=["ticker", "timestamp"]))
|
| 257 |
+
await db.commit()
|
| 258 |
+
|
| 259 |
+
# Re-query the database with the fully backfilled candles
|
| 260 |
+
result = await db.execute(
|
| 261 |
+
select(models.StockHistory)
|
| 262 |
+
.where(models.StockHistory.ticker == ticker.upper())
|
| 263 |
+
.order_by(models.StockHistory.timestamp.desc())
|
| 264 |
+
.limit(limit)
|
| 265 |
+
)
|
| 266 |
+
history = list(result.scalars().all())
|
| 267 |
+
except Exception as e:
|
| 268 |
+
print(f"MLOps Dynamic Backfill: Failed to populate history for {ticker}: {e}")
|
| 269 |
+
|
| 270 |
+
# Return in chronological order (oldest to newest) for indicators
|
| 271 |
history.reverse()
|
| 272 |
return history
|
| 273 |
|