ifieryarrows commited on
Commit
cea2da7
·
verified ·
1 Parent(s): 16dba36

Sync from GitHub (tests passed)

Browse files
Files changed (1) hide show
  1. app/heatmap.py +220 -17
app/heatmap.py CHANGED
@@ -7,6 +7,7 @@ import json
7
  import logging
8
  import math
9
  import re
 
10
  import time
11
  from datetime import datetime, timedelta, timezone
12
  from pathlib import Path
@@ -19,9 +20,13 @@ from app.models import HeatmapCache
19
 
20
  logger = logging.getLogger(__name__)
21
  HEATMAP_TTL = timedelta(minutes=15)
 
 
22
  HEATMAP_REFRESH_LOCK = "heatmap:refresh"
23
  _snapshot_memo_lock = RLock()
24
  _snapshot_memo: Optional[dict] = None
 
 
25
 
26
  # The legacy project taxonomy remains the source of ``view=themes``.
27
  HEATMAP_GROUP_MAP: dict[str, tuple[str, str]] = {
@@ -180,6 +185,11 @@ def _clean_label(value: object, fallback: str) -> str:
180
  return label if label and label.lower() not in {"none", "nan", "n/a"} else fallback
181
 
182
 
 
 
 
 
 
183
  def _instrument_type(info: dict, category: str, ticker: str) -> str:
184
  quote_type = str(info.get("quoteType") or "").upper()
185
  supported = {"EQUITY", "ETF", "MUTUALFUND", "FUTURE", "CRYPTOCURRENCY", "CURRENCY"}
@@ -199,9 +209,11 @@ def _instrument_type(info: dict, category: str, ticker: str) -> str:
199
  def _market_path(item: dict) -> tuple[str, str]:
200
  instrument_type = str(item.get("instrumentType") or "equity").lower()
201
  if instrument_type == "equity":
 
 
202
  return (
203
- _clean_label(item.get("sector"), "Other Equities"),
204
- _clean_label(item.get("industry"), "Unclassified Equities"),
205
  )
206
  asset_labels = {
207
  "etf": "Funds & ETFs",
@@ -293,16 +305,30 @@ def find_category(payload: dict, category_id: str) -> Optional[dict]:
293
  return None
294
 
295
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  def _history_sparklines(frame: pd.DataFrame, symbols: list[str], points: int = 10) -> dict[str, list[float]]:
297
  output: dict[str, list[float]] = {}
298
  if frame is None or frame.empty:
299
  return output
300
  for symbol in symbols:
301
  try:
302
- if isinstance(frame.columns, pd.MultiIndex):
303
- series = frame[symbol]["Close"] if symbol in frame.columns.get_level_values(0) else frame["Close"][symbol]
304
- else:
305
- series = frame["Close"]
306
  values = [float(value) for value in series.dropna().tail(points).tolist() if math.isfinite(float(value))]
307
  if len(values) >= 2 and values[0] != 0:
308
  output[symbol] = [round(value / values[0] * 100.0, 3) for value in values]
@@ -311,6 +337,114 @@ def _history_sparklines(frame: pd.DataFrame, symbols: list[str], points: int = 1
311
  return output
312
 
313
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  def _coverage_is_healthy(new_count: int, requested_count: int, previous_count: int) -> bool:
315
  if new_count <= 0:
316
  return False
@@ -350,6 +484,19 @@ def refresh_market_heatmap() -> None:
350
  session.commit()
351
  previous_payload = cache.payload_json if isinstance(cache.payload_json, dict) else {}
352
  previous_count = len(flatten_heatmap_leaves(previous_payload))
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  try:
354
  stage_start = time.perf_counter()
355
  universe_rows = _load_universe()
@@ -359,14 +506,18 @@ def refresh_market_heatmap() -> None:
359
 
360
  import yfinance as yf
361
 
 
 
362
  stage_start = time.perf_counter()
363
  sparkline_by_symbol: dict[str, list[float]] = {}
 
364
  try:
365
  history = yf.download(
366
  tickers=" ".join(symbols), period="3mo", interval="1d",
367
  group_by="ticker", auto_adjust=False, progress=False, threads=True,
368
  )
369
  sparkline_by_symbol = _history_sparklines(history, symbols)
 
370
  except Exception as history_error:
371
  logger.warning("Heatmap sparkline batch failed; publishing quotes: %s", history_error)
372
  stage_ms["history"] = (time.perf_counter() - stage_start) * 1000
@@ -374,6 +525,11 @@ def refresh_market_heatmap() -> None:
374
  stage_start = time.perf_counter()
375
  all_data: list[dict] = []
376
  snapshot_time = _utcnow()
 
 
 
 
 
377
  for offset in range(0, len(symbols), 50):
378
  if offset:
379
  time.sleep(1.5)
@@ -381,39 +537,84 @@ def refresh_market_heatmap() -> None:
381
  try:
382
  tickers = yf.Tickers(" ".join(batch))
383
  for symbol in batch:
 
384
  try:
385
  ticker = tickers.tickers.get(symbol)
386
- info = ticker.info or {} if ticker else {}
387
- raw_price = info.get("regularMarketPrice") or info.get("currentPrice")
388
- if raw_price is None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  continue
390
- price = float(raw_price)
 
 
391
  row = row_by_symbol[symbol]
 
392
  category = str(row.get("category") or "")
393
  group, subgroup = HEATMAP_GROUP_MAP.get(category, ("Other", category or "Uncategorized"))
394
  weight, weight_label = _derive_weight(info, price)
 
 
 
 
 
395
  instrument_type = _instrument_type(info, category, symbol)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  all_data.append({
397
  "id": stable_node_id("instrument", symbol),
398
  "name": symbol,
399
- "shortName": info.get("shortName") or info.get("longName") or symbol,
400
  "price": round(price, 4),
401
- "changePercent": round(float(info.get("regularMarketChangePercent") or 0.0), 4),
402
  "weight": round(weight, 2),
403
  "weightLabel": weight_label,
404
  "group": group, "subgroup": subgroup,
405
  "category": category, "sourceTag": row.get("source_tag", ""),
406
  "instrumentType": instrument_type,
407
- "sector": _clean_label(info.get("sector"), "Other Equities") if instrument_type == "equity" else None,
408
- "industry": _clean_label(info.get("industry"), "Unclassified Equities") if instrument_type == "equity" else None,
409
- "exchange": info.get("exchange") or info.get("fullExchangeName"),
410
  "logoTicker": symbol if instrument_type in {"equity", "etf", "mutualfund"} else None,
411
- "sparkline": sparkline_by_symbol.get(symbol),
412
- "asOf": _as_of(info, snapshot_time),
 
413
  })
414
  except Exception as symbol_error:
415
  logger.debug("Heatmap quote failed for %s: %s", symbol, symbol_error)
416
  except Exception as batch_error:
 
417
  logger.warning("Heatmap quote batch %d failed: %s", offset // 50, batch_error)
418
  stage_ms["quotes"] = (time.perf_counter() - stage_start) * 1000
419
  if not _coverage_is_healthy(len(all_data), len(symbols), previous_count):
@@ -432,6 +633,7 @@ def refresh_market_heatmap() -> None:
432
  invalidate_heatmap_snapshot_memo()
433
  logger.info("heatmap_refresh %s", json.dumps({
434
  "status": "success", "symbols": len(all_data), "requested": len(symbols),
 
435
  "stage_ms": {key: round(value, 2) for key, value in stage_ms.items()},
436
  "total_ms": round((time.perf_counter() - started) * 1000, 2),
437
  }, sort_keys=True))
@@ -448,6 +650,7 @@ def refresh_market_heatmap() -> None:
448
  session.commit()
449
  logger.error("heatmap_refresh %s", json.dumps({
450
  "status": "error", "error": str(error)[:500], "preserved_symbols": previous_count,
 
451
  "stage_ms": {key: round(value, 2) for key, value in stage_ms.items()},
452
  "total_ms": round((time.perf_counter() - started) * 1000, 2),
453
  }, sort_keys=True), exc_info=True)
 
7
  import logging
8
  import math
9
  import re
10
+ import tempfile
11
  import time
12
  from datetime import datetime, timedelta, timezone
13
  from pathlib import Path
 
20
 
21
  logger = logging.getLogger(__name__)
22
  HEATMAP_TTL = timedelta(minutes=15)
23
+ HEATMAP_METADATA_TTL = timedelta(hours=24)
24
+ HEATMAP_METADATA_BATCH_SIZE = 12
25
  HEATMAP_REFRESH_LOCK = "heatmap:refresh"
26
  _snapshot_memo_lock = RLock()
27
  _snapshot_memo: Optional[dict] = None
28
+ _yfinance_cache_lock = RLock()
29
+ _yfinance_cache_location: Optional[Path] = None
30
 
31
  # The legacy project taxonomy remains the source of ``view=themes``.
32
  HEATMAP_GROUP_MAP: dict[str, tuple[str, str]] = {
 
185
  return label if label and label.lower() not in {"none", "nan", "n/a"} else fallback
186
 
187
 
188
+ def _equity_metadata_label(value: object, placeholder: str) -> Optional[str]:
189
+ label = _clean_label(value, "")
190
+ return None if not label or label.casefold() == placeholder.casefold() else label
191
+
192
+
193
  def _instrument_type(info: dict, category: str, ticker: str) -> str:
194
  quote_type = str(info.get("quoteType") or "").upper()
195
  supported = {"EQUITY", "ETF", "MUTUALFUND", "FUTURE", "CRYPTOCURRENCY", "CURRENCY"}
 
209
  def _market_path(item: dict) -> tuple[str, str]:
210
  instrument_type = str(item.get("instrumentType") or "equity").lower()
211
  if instrument_type == "equity":
212
+ sector = _equity_metadata_label(item.get("sector"), "Other Equities")
213
+ industry = _equity_metadata_label(item.get("industry"), "Unclassified Equities")
214
  return (
215
+ _clean_label(sector or item.get("group"), "Other Equities"),
216
+ _clean_label(industry or item.get("subgroup"), "Unclassified Equities"),
217
  )
218
  asset_labels = {
219
  "etf": "Funds & ETFs",
 
305
  return None
306
 
307
 
308
+ def _history_close_series(frame: pd.DataFrame, symbol: str) -> Optional[pd.Series]:
309
+ if frame is None or frame.empty:
310
+ return None
311
+ try:
312
+ if isinstance(frame.columns, pd.MultiIndex):
313
+ if symbol in frame.columns.get_level_values(0):
314
+ return frame[symbol]["Close"]
315
+ if symbol in frame.columns.get_level_values(1):
316
+ return frame["Close"][symbol]
317
+ return None
318
+ return frame["Close"]
319
+ except (KeyError, TypeError):
320
+ return None
321
+
322
+
323
  def _history_sparklines(frame: pd.DataFrame, symbols: list[str], points: int = 10) -> dict[str, list[float]]:
324
  output: dict[str, list[float]] = {}
325
  if frame is None or frame.empty:
326
  return output
327
  for symbol in symbols:
328
  try:
329
+ series = _history_close_series(frame, symbol)
330
+ if series is None:
331
+ continue
 
332
  values = [float(value) for value in series.dropna().tail(points).tolist() if math.isfinite(float(value))]
333
  if len(values) >= 2 and values[0] != 0:
334
  output[symbol] = [round(value / values[0] * 100.0, 3) for value in values]
 
337
  return output
338
 
339
 
340
+ def _history_quotes(frame: pd.DataFrame, symbols: list[str]) -> dict[str, dict]:
341
+ """Extract latest price/change from the already-batched chart response."""
342
+ output: dict[str, dict] = {}
343
+ if frame is None or frame.empty:
344
+ return output
345
+ for symbol in symbols:
346
+ series = _history_close_series(frame, symbol)
347
+ if series is None:
348
+ continue
349
+ try:
350
+ finite = series.dropna()
351
+ values = [float(value) for value in finite.tail(2).tolist() if math.isfinite(float(value))]
352
+ if not values or values[-1] <= 0:
353
+ continue
354
+ previous = values[-2] if len(values) >= 2 else None
355
+ change = ((values[-1] / previous) - 1.0) * 100.0 if previous and previous > 0 else 0.0
356
+ as_of = None
357
+ if len(finite.index):
358
+ timestamp = pd.Timestamp(finite.index[-1])
359
+ timestamp = timestamp.tz_localize(timezone.utc) if timestamp.tzinfo is None else timestamp.tz_convert(timezone.utc)
360
+ as_of = timestamp.isoformat()
361
+ output[symbol] = {
362
+ "price": values[-1],
363
+ "changePercent": change,
364
+ "asOf": as_of,
365
+ }
366
+ except (TypeError, ValueError, ZeroDivisionError, OSError):
367
+ continue
368
+ return output
369
+
370
+
371
+ def _finite_float(*values: object) -> Optional[float]:
372
+ for value in values:
373
+ if value is None:
374
+ continue
375
+ try:
376
+ parsed = float(value)
377
+ except (TypeError, ValueError):
378
+ continue
379
+ if math.isfinite(parsed):
380
+ return parsed
381
+ return None
382
+
383
+
384
+ def _utc_timestamp(value: object) -> Optional[datetime]:
385
+ if value is None:
386
+ return None
387
+ try:
388
+ parsed = value if isinstance(value, datetime) else datetime.fromisoformat(str(value).replace("Z", "+00:00"))
389
+ except (TypeError, ValueError):
390
+ return None
391
+ if parsed.tzinfo is None:
392
+ parsed = parsed.replace(tzinfo=timezone.utc)
393
+ return parsed.astimezone(timezone.utc)
394
+
395
+
396
+ def _metadata_refresh_symbols(
397
+ symbols: list[str],
398
+ previous_by_symbol: dict[str, dict],
399
+ previous_cached_at: object,
400
+ now: datetime,
401
+ ) -> set[str]:
402
+ cached_at = _utc_timestamp(previous_cached_at)
403
+ due: list[str] = []
404
+ for symbol in symbols:
405
+ previous = previous_by_symbol.get(symbol)
406
+ if not previous:
407
+ due.append(symbol)
408
+ continue
409
+ metadata_at = (
410
+ _utc_timestamp(previous.get("metadataAsOf"))
411
+ if "metadataAsOf" in previous
412
+ else cached_at
413
+ )
414
+ if metadata_at is None or now - metadata_at >= HEATMAP_METADATA_TTL:
415
+ due.append(symbol)
416
+ return set(due[:HEATMAP_METADATA_BATCH_SIZE])
417
+
418
+
419
+ def _configure_yfinance_cache(yfinance_module: object) -> Path:
420
+ """Keep yfinance's SQLite caches off read-only runtime filesystems."""
421
+ global _yfinance_cache_location
422
+ cache_dir = Path(tempfile.gettempdir()) / "coppermind-yfinance-cache"
423
+ with _yfinance_cache_lock:
424
+ if _yfinance_cache_location == cache_dir:
425
+ return cache_dir
426
+ cache_dir.mkdir(parents=True, exist_ok=True)
427
+ setter = getattr(yfinance_module, "set_tz_cache_location", None)
428
+ if not callable(setter):
429
+ raise RuntimeError("installed yfinance does not support a custom cache location")
430
+ setter(str(cache_dir))
431
+ # yfinance's first threaded download can race while lazily creating its
432
+ # SQLite timezone/cookie databases. Initialise both caches serially
433
+ # before the 202-symbol batch starts.
434
+ cache_module = getattr(yfinance_module, "cache", None)
435
+ for getter_name in ("get_tz_cache", "get_cookie_cache"):
436
+ getter = getattr(cache_module, getter_name, None)
437
+ if not callable(getter):
438
+ raise RuntimeError(f"installed yfinance is missing cache initializer {getter_name}")
439
+ cache = getter()
440
+ initialise = getattr(cache, "initialise", None)
441
+ if not callable(initialise):
442
+ raise RuntimeError(f"installed yfinance cache {getter_name} cannot be initialized")
443
+ initialise()
444
+ _yfinance_cache_location = cache_dir
445
+ return cache_dir
446
+
447
+
448
  def _coverage_is_healthy(new_count: int, requested_count: int, previous_count: int) -> bool:
449
  if new_count <= 0:
450
  return False
 
484
  session.commit()
485
  previous_payload = cache.payload_json if isinstance(cache.payload_json, dict) else {}
486
  previous_count = len(flatten_heatmap_leaves(previous_payload))
487
+ previous_by_symbol = {
488
+ str(leaf.get("name")): leaf for leaf in flatten_heatmap_leaves(previous_payload)
489
+ if leaf.get("name")
490
+ }
491
+ provider_stats = {
492
+ "info_success": 0,
493
+ "info_empty": 0,
494
+ "info_error": 0,
495
+ "info_without_price": 0,
496
+ "history_price_fallback": 0,
497
+ "missing_price": 0,
498
+ "batch_error": 0,
499
+ }
500
  try:
501
  stage_start = time.perf_counter()
502
  universe_rows = _load_universe()
 
506
 
507
  import yfinance as yf
508
 
509
+ _configure_yfinance_cache(yf)
510
+
511
  stage_start = time.perf_counter()
512
  sparkline_by_symbol: dict[str, list[float]] = {}
513
+ quote_by_symbol: dict[str, dict] = {}
514
  try:
515
  history = yf.download(
516
  tickers=" ".join(symbols), period="3mo", interval="1d",
517
  group_by="ticker", auto_adjust=False, progress=False, threads=True,
518
  )
519
  sparkline_by_symbol = _history_sparklines(history, symbols)
520
+ quote_by_symbol = _history_quotes(history, symbols)
521
  except Exception as history_error:
522
  logger.warning("Heatmap sparkline batch failed; publishing quotes: %s", history_error)
523
  stage_ms["history"] = (time.perf_counter() - stage_start) * 1000
 
525
  stage_start = time.perf_counter()
526
  all_data: list[dict] = []
527
  snapshot_time = _utcnow()
528
+ metadata_symbols = _metadata_refresh_symbols(
529
+ symbols, previous_by_symbol, cache.cached_at, snapshot_time,
530
+ )
531
+ provider_stats["metadata_scheduled"] = len(metadata_symbols)
532
+ provider_stats["info_skipped"] = 0
533
  for offset in range(0, len(symbols), 50):
534
  if offset:
535
  time.sleep(1.5)
 
537
  try:
538
  tickers = yf.Tickers(" ".join(batch))
539
  for symbol in batch:
540
+ info: dict = {}
541
  try:
542
  ticker = tickers.tickers.get(symbol)
543
+ history_quote = quote_by_symbol.get(symbol, {})
544
+ should_fetch_info = symbol in metadata_symbols or not history_quote
545
+ if should_fetch_info:
546
+ try:
547
+ info = (ticker.info or {}) if ticker else {}
548
+ provider_stats["info_success" if info else "info_empty"] += 1
549
+ except Exception as info_error:
550
+ provider_stats["info_error"] += 1
551
+ logger.debug("Heatmap metadata failed for %s: %s", symbol, info_error)
552
+ else:
553
+ provider_stats["info_skipped"] += 1
554
+ info_price = _finite_float(info.get("regularMarketPrice"), info.get("currentPrice"))
555
+ if info and info_price is None:
556
+ provider_stats["info_without_price"] += 1
557
+ raw_price = info_price or _finite_float(history_quote.get("price"))
558
+ if raw_price is None or raw_price <= 0:
559
+ provider_stats["missing_price"] += 1
560
  continue
561
+ if info_price is None:
562
+ provider_stats["history_price_fallback"] += 1
563
+ price = raw_price
564
  row = row_by_symbol[symbol]
565
+ previous = previous_by_symbol.get(symbol, {})
566
  category = str(row.get("category") or "")
567
  group, subgroup = HEATMAP_GROUP_MAP.get(category, ("Other", category or "Uncategorized"))
568
  weight, weight_label = _derive_weight(info, price)
569
+ if weight_label == "Equal Weight":
570
+ previous_weight = _finite_float(previous.get("weight"))
571
+ if previous_weight is not None and previous_weight > 0:
572
+ weight = previous_weight
573
+ weight_label = str(previous.get("weightLabel") or "Previous Snapshot")
574
  instrument_type = _instrument_type(info, category, symbol)
575
+ sector = (
576
+ _equity_metadata_label(info.get("sector"), "Other Equities")
577
+ or _equity_metadata_label(previous.get("sector"), "Other Equities")
578
+ )
579
+ industry = (
580
+ _equity_metadata_label(info.get("industry"), "Unclassified Equities")
581
+ or _equity_metadata_label(previous.get("industry"), "Unclassified Equities")
582
+ )
583
+ change_percent = _finite_float(
584
+ info.get("regularMarketChangePercent"), history_quote.get("changePercent"), 0.0,
585
+ )
586
+ has_market_time = info.get("regularMarketTime") is not None
587
+ if should_fetch_info:
588
+ metadata_as_of = snapshot_time.isoformat()
589
+ elif "metadataAsOf" in previous:
590
+ metadata_as_of = previous.get("metadataAsOf")
591
+ elif previous:
592
+ metadata_as_of = (_utc_timestamp(cache.cached_at) or snapshot_time).isoformat()
593
+ else:
594
+ metadata_as_of = None
595
  all_data.append({
596
  "id": stable_node_id("instrument", symbol),
597
  "name": symbol,
598
+ "shortName": info.get("shortName") or info.get("longName") or previous.get("shortName") or symbol,
599
  "price": round(price, 4),
600
+ "changePercent": round(change_percent or 0.0, 4),
601
  "weight": round(weight, 2),
602
  "weightLabel": weight_label,
603
  "group": group, "subgroup": subgroup,
604
  "category": category, "sourceTag": row.get("source_tag", ""),
605
  "instrumentType": instrument_type,
606
+ "sector": sector if instrument_type == "equity" else None,
607
+ "industry": industry if instrument_type == "equity" else None,
608
+ "exchange": info.get("exchange") or info.get("fullExchangeName") or previous.get("exchange"),
609
  "logoTicker": symbol if instrument_type in {"equity", "etf", "mutualfund"} else None,
610
+ "sparkline": sparkline_by_symbol.get(symbol) or previous.get("sparkline"),
611
+ "asOf": _as_of(info, snapshot_time) if has_market_time else history_quote.get("asOf") or snapshot_time.isoformat(),
612
+ "metadataAsOf": metadata_as_of,
613
  })
614
  except Exception as symbol_error:
615
  logger.debug("Heatmap quote failed for %s: %s", symbol, symbol_error)
616
  except Exception as batch_error:
617
+ provider_stats["batch_error"] += len(batch)
618
  logger.warning("Heatmap quote batch %d failed: %s", offset // 50, batch_error)
619
  stage_ms["quotes"] = (time.perf_counter() - stage_start) * 1000
620
  if not _coverage_is_healthy(len(all_data), len(symbols), previous_count):
 
633
  invalidate_heatmap_snapshot_memo()
634
  logger.info("heatmap_refresh %s", json.dumps({
635
  "status": "success", "symbols": len(all_data), "requested": len(symbols),
636
+ "provider": provider_stats,
637
  "stage_ms": {key: round(value, 2) for key, value in stage_ms.items()},
638
  "total_ms": round((time.perf_counter() - started) * 1000, 2),
639
  }, sort_keys=True))
 
650
  session.commit()
651
  logger.error("heatmap_refresh %s", json.dumps({
652
  "status": "error", "error": str(error)[:500], "preserved_symbols": previous_count,
653
+ "provider": provider_stats,
654
  "stage_ms": {key: round(value, 2) for key, value in stage_ms.items()},
655
  "total_ms": round((time.perf_counter() - started) * 1000, 2),
656
  }, sort_keys=True), exc_info=True)