ifieryarrows commited on
Commit
32ad98f
·
verified ·
1 Parent(s): 3b43982

Sync from GitHub (tests passed)

Browse files
app/heatmap.py CHANGED
@@ -203,6 +203,9 @@ def refresh_market_heatmap() -> None:
203
  the heatmap cache. Uses project taxonomy (not Yahoo sector/industry).
204
  """
205
  from app.db import SessionLocal
 
 
 
206
 
207
  with SessionLocal() as session:
208
  cache: Optional[HeatmapCache] = session.query(HeatmapCache).first()
@@ -292,7 +295,7 @@ def refresh_market_heatmap() -> None:
292
  now = _utcnow()
293
  cache.payload_json = root
294
  cache.cached_at = now
295
- cache.expires_at = now + timedelta(minutes=15)
296
  cache.refresh_started_at = None
297
  cache.refresh_error = None
298
  session.commit()
 
203
  the heatmap cache. Uses project taxonomy (not Yahoo sector/industry).
204
  """
205
  from app.db import SessionLocal
206
+ from app.settings import get_settings
207
+
208
+ settings = get_settings()
209
 
210
  with SessionLocal() as session:
211
  cache: Optional[HeatmapCache] = session.query(HeatmapCache).first()
 
295
  now = _utcnow()
296
  cache.payload_json = root
297
  cache.cached_at = now
298
+ cache.expires_at = now + timedelta(seconds=settings.heatmap_cache_ttl_seconds)
299
  cache.refresh_started_at = None
300
  cache.refresh_error = None
301
  session.commit()
app/instruments.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """Canonical instrument identifiers used across API, training and UI metadata."""
2
+
3
+ TARGET_SYMBOL = "HG=F"
4
+ TARGET_DISPLAY_NAME = "COMEX Copper Futures"
5
+ TARGET_PROVIDER = "yfinance"
6
+ TARGET_TRADINGVIEW_SYMBOL = "COMEX:HG1!"
7
+
8
+ SPOT_COPPER_SYMBOLS = {"XCU/USD", "XCUUSD", "XCU=X"}
9
+ SPOT_COPPER_DISPLAY_NAME = "Copper spot (XCU/USD)"
10
+
app/main.py CHANGED
@@ -25,6 +25,7 @@ from app.db import init_db, SessionLocal, get_db_type
25
  from app.models import NewsArticle, PriceBar, DailySentiment, DailySentimentV2, AnalysisSnapshot, NewsSentimentV2, NewsProcessed, NewsRaw
26
  from app.settings import get_settings
27
  from app.lock import is_pipeline_locked
 
28
  # NOTE: Faz 1 - API is snapshot-only, no report generation
29
  # generate_analysis_report and save_analysis_snapshot are now worker-only
30
  from app.schemas import (
@@ -119,7 +120,7 @@ app.add_middleware(
119
  description="Returns the latest cached analysis snapshot. No live computation - all heavy work is done by the worker."
120
  )
121
  async def get_analysis(
122
- symbol: str = Query(default="HG=F", description="Trading symbol")
123
  ):
124
  """
125
  Get current analysis report.
@@ -251,7 +252,7 @@ async def get_analysis(
251
  description="Returns historical data for charting, including prices and sentiment."
252
  )
253
  async def get_history(
254
- symbol: str = Query(default="HG=F", description="Trading symbol"),
255
  days: int = Query(default=180, ge=7, le=730, description="Number of days of history")
256
  ):
257
  """
@@ -445,7 +446,7 @@ async def health_check():
445
  # --- Latest persisted TFT snapshot ------------------------------
446
  latest_tft = (
447
  session.query(TFTPredictionSnapshot)
448
- .filter(TFTPredictionSnapshot.symbol == "HG=F")
449
  .order_by(TFTPredictionSnapshot.generated_at.desc())
450
  .first()
451
  )
@@ -456,7 +457,7 @@ async def health_check():
456
  # --- Latest TFT training timestamp ------------------------------
457
  latest_tft_model = (
458
  session.query(TFTModelMetadata)
459
- .filter(TFTModelMetadata.symbol == "HG=F")
460
  .order_by(TFTModelMetadata.trained_at.desc())
461
  .first()
462
  )
@@ -464,7 +465,7 @@ async def health_check():
464
  tft_model_trained_at = _iso(latest_tft_model.trained_at)
465
 
466
  # --- PriceBar freshness -----------------------------------------
467
- target = "HG=F"
468
  latest_bar = (
469
  session.query(PriceBar.date)
470
  .filter(PriceBar.symbol == target)
@@ -590,17 +591,22 @@ async def get_market_prices():
590
 
591
  @app.get(
592
  "/api/market-heatmap",
593
- summary="Get CopperMind universe heatmap (15-min cache)",
594
  description=(
595
  "Returns a group->subgroup->symbol treemap payload sourced exclusively from the "
596
  "CopperMind project universe (broad_universe.csv). Uses stale-while-revalidate "
597
- "caching with a 15-minute TTL. No general market indices are included."
 
598
  )
599
  )
600
  async def get_market_heatmap(background_tasks: BackgroundTasks):
601
  from app.models import HeatmapCache
602
  from app.heatmap import refresh_market_heatmap
603
 
 
 
 
 
604
  # Stuck refresh safety: if a refresh has been "in progress" for longer than
605
  # this, assume the worker crashed and allow a fresh background refresh to
606
  # be kicked off. yfinance batch fetch for the full universe finishes in
@@ -640,7 +646,11 @@ async def get_market_heatmap(background_tasks: BackgroundTasks):
640
  "refresh_in_progress": True,
641
  "last_updated_at": None,
642
  "next_refresh_at": None,
643
- "source_delay_minutes": 15,
 
 
 
 
644
  "payload_count": 0,
645
  "refresh_error": cache.refresh_error if cache else None,
646
  "cache_state": "empty",
@@ -688,7 +698,11 @@ async def get_market_heatmap(background_tasks: BackgroundTasks):
688
  "refresh_in_progress": refresh_in_progress,
689
  "last_updated_at": cache.cached_at.isoformat() if cache.cached_at else None,
690
  "next_refresh_at": cache.expires_at.isoformat() if cache.expires_at else None,
691
- "source_delay_minutes": 15,
 
 
 
 
692
  "payload_count": payload_count,
693
  "refresh_error": cache.refresh_error,
694
  "cache_state": cache_state,
@@ -704,50 +718,33 @@ async def get_market_heatmap(background_tasks: BackgroundTasks):
704
 
705
  @app.get(
706
  "/api/live-price",
707
- summary="Get real-time copper price from Twelve Data",
708
- description="Returns live XCU/USD price for header display. Uses Twelve Data API for reliability."
 
 
 
709
  )
710
- async def get_live_price():
 
 
711
  """
712
- Get real-time copper price from Twelve Data.
713
-
714
- Used for the header price display. Separate from yfinance to avoid rate limits.
 
715
  """
716
- import httpx
717
-
718
- settings = get_settings()
719
-
720
- if not settings.twelvedata_api_key:
721
- logger.warning("Twelve Data API key not configured")
722
- return {"price": None, "error": "API key not configured"}
723
-
724
  try:
725
- async with httpx.AsyncClient(timeout=10.0) as client:
726
- response = await client.get(
727
- "https://api.twelvedata.com/price",
728
- params={
729
- "symbol": "XCU/USD",
730
- "apikey": settings.twelvedata_api_key,
731
- }
732
- )
733
-
734
- if response.status_code == 200:
735
- data = response.json()
736
- price = data.get("price")
737
- if price:
738
- return {
739
- "symbol": "XCU/USD",
740
- "price": round(float(price), 4),
741
- "error": None,
742
- }
743
- else:
744
- return {"price": None, "error": data.get("message", "No price data")}
745
- else:
746
- return {"price": None, "error": f"API error: {response.status_code}"}
747
-
748
  except Exception as e:
749
- from app.settings import mask_api_key
750
- logger.error(f"Twelve Data API error: {mask_api_key(str(e))}")
751
  return {"price": None, "error": "API error"}
752
 
753
 
@@ -840,7 +837,7 @@ async def websocket_live_price(websocket: WebSocket):
840
  description="Returns the AI-generated analysis stored after pipeline completion."
841
  )
842
  async def get_commentary(
843
- symbol: str = Query(default="HG=F", description="Symbol to get commentary for")
844
  ):
845
  """
846
  Get AI commentary for the specified symbol.
@@ -895,7 +892,7 @@ _TFT_CACHE_TTL_S = 300 # 5 minutes
895
  },
896
  )
897
  async def get_tft_analysis(
898
- symbol: str = "HG=F",
899
  source: str = "snapshot",
900
  ):
901
  """
@@ -1124,7 +1121,7 @@ async def trigger_pipeline(
1124
  description="Combines XGBoost and TFT-ASRO signals into a directional consensus."
1125
  )
1126
  async def get_consensus(
1127
- symbol: str = Query(default="HG=F", description="Trading symbol")
1128
  ):
1129
  from deep_learning.inference.predictor import ensemble_directional_vote, generate_tft_analysis
1130
 
@@ -1166,7 +1163,7 @@ async def get_consensus(
1166
  description="Returns training metrics, quality gate results, and feature importance."
1167
  )
1168
  async def get_tft_summary(
1169
- symbol: str = Query(default="HG=F", description="Target symbol")
1170
  ):
1171
  from app.models import TFTModelMetadata
1172
  from app.quality_gate import evaluate_quality_gate
@@ -1259,7 +1256,7 @@ async def get_tft_summary(
1259
  "a 404 error."
1260
  ),
1261
  )
1262
- async def get_latest_backtest(symbol: str = Query(default="HG=F", description="Target symbol")):
1263
  import pathlib
1264
  import json as _json
1265
 
 
25
  from app.models import NewsArticle, PriceBar, DailySentiment, DailySentimentV2, AnalysisSnapshot, NewsSentimentV2, NewsProcessed, NewsRaw
26
  from app.settings import get_settings
27
  from app.lock import is_pipeline_locked
28
+ from app.instruments import TARGET_SYMBOL
29
  # NOTE: Faz 1 - API is snapshot-only, no report generation
30
  # generate_analysis_report and save_analysis_snapshot are now worker-only
31
  from app.schemas import (
 
120
  description="Returns the latest cached analysis snapshot. No live computation - all heavy work is done by the worker."
121
  )
122
  async def get_analysis(
123
+ symbol: str = Query(default=TARGET_SYMBOL, description="Trading symbol")
124
  ):
125
  """
126
  Get current analysis report.
 
252
  description="Returns historical data for charting, including prices and sentiment."
253
  )
254
  async def get_history(
255
+ symbol: str = Query(default=TARGET_SYMBOL, description="Trading symbol"),
256
  days: int = Query(default=180, ge=7, le=730, description="Number of days of history")
257
  ):
258
  """
 
446
  # --- Latest persisted TFT snapshot ------------------------------
447
  latest_tft = (
448
  session.query(TFTPredictionSnapshot)
449
+ .filter(TFTPredictionSnapshot.symbol == TARGET_SYMBOL)
450
  .order_by(TFTPredictionSnapshot.generated_at.desc())
451
  .first()
452
  )
 
457
  # --- Latest TFT training timestamp ------------------------------
458
  latest_tft_model = (
459
  session.query(TFTModelMetadata)
460
+ .filter(TFTModelMetadata.symbol == TARGET_SYMBOL)
461
  .order_by(TFTModelMetadata.trained_at.desc())
462
  .first()
463
  )
 
465
  tft_model_trained_at = _iso(latest_tft_model.trained_at)
466
 
467
  # --- PriceBar freshness -----------------------------------------
468
+ target = TARGET_SYMBOL
469
  latest_bar = (
470
  session.query(PriceBar.date)
471
  .filter(PriceBar.symbol == target)
 
591
 
592
  @app.get(
593
  "/api/market-heatmap",
594
+ summary="Get CopperMind universe heatmap",
595
  description=(
596
  "Returns a group->subgroup->symbol treemap payload sourced exclusively from the "
597
  "CopperMind project universe (broad_universe.csv). Uses stale-while-revalidate "
598
+ "caching so Yahoo/yfinance is not polled on every frontend refresh. No general "
599
+ "market indices are included."
600
  )
601
  )
602
  async def get_market_heatmap(background_tasks: BackgroundTasks):
603
  from app.models import HeatmapCache
604
  from app.heatmap import refresh_market_heatmap
605
 
606
+ settings = get_settings()
607
+ source_delay_seconds = int(settings.heatmap_cache_ttl_seconds)
608
+ source_delay_minutes = max(1, round(source_delay_seconds / 60))
609
+
610
  # Stuck refresh safety: if a refresh has been "in progress" for longer than
611
  # this, assume the worker crashed and allow a fresh background refresh to
612
  # be kicked off. yfinance batch fetch for the full universe finishes in
 
646
  "refresh_in_progress": True,
647
  "last_updated_at": None,
648
  "next_refresh_at": None,
649
+ "source_delay_minutes": source_delay_minutes,
650
+ "source_delay_seconds": source_delay_seconds,
651
+ "frontend_poll_seconds": settings.heatmap_frontend_poll_seconds,
652
+ "provider": settings.heatmap_provider,
653
+ "live_update_mode": "snapshot_swr",
654
  "payload_count": 0,
655
  "refresh_error": cache.refresh_error if cache else None,
656
  "cache_state": "empty",
 
698
  "refresh_in_progress": refresh_in_progress,
699
  "last_updated_at": cache.cached_at.isoformat() if cache.cached_at else None,
700
  "next_refresh_at": cache.expires_at.isoformat() if cache.expires_at else None,
701
+ "source_delay_minutes": source_delay_minutes,
702
+ "source_delay_seconds": source_delay_seconds,
703
+ "frontend_poll_seconds": settings.heatmap_frontend_poll_seconds,
704
+ "provider": settings.heatmap_provider,
705
+ "live_update_mode": "snapshot_swr",
706
  "payload_count": payload_count,
707
  "refresh_error": cache.refresh_error,
708
  "cache_state": cache_state,
 
718
 
719
  @app.get(
720
  "/api/live-price",
721
+ summary="Get canonical copper futures price",
722
+ description=(
723
+ "Returns the canonical CopperMind target price. The project standard is "
724
+ "COMEX copper futures via HG=F; no spot XCU/USD substitution is applied."
725
+ )
726
  )
727
+ async def get_live_price(
728
+ symbol: str = Query(default=TARGET_SYMBOL, description="Canonical CopperMind symbol")
729
+ ):
730
  """
731
+ Get the current canonical target price.
732
+
733
+ The helper enforces exact-instrument lookup: HG=F uses yfinance first and
734
+ falls back to the latest DB close. Spot XCU/USD is no longer used by the UI.
735
  """
 
 
 
 
 
 
 
 
736
  try:
737
+ from app.inference import get_current_price
738
+
739
+ with SessionLocal() as session:
740
+ price = get_current_price(session, symbol)
741
+ return {
742
+ "symbol": symbol,
743
+ "price": round(float(price), 4) if price is not None else None,
744
+ "error": None if price is not None else "No price data",
745
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
746
  except Exception as e:
747
+ logger.error("Canonical live price error: %s", e)
 
748
  return {"price": None, "error": "API error"}
749
 
750
 
 
837
  description="Returns the AI-generated analysis stored after pipeline completion."
838
  )
839
  async def get_commentary(
840
+ symbol: str = Query(default=TARGET_SYMBOL, description="Symbol to get commentary for")
841
  ):
842
  """
843
  Get AI commentary for the specified symbol.
 
892
  },
893
  )
894
  async def get_tft_analysis(
895
+ symbol: str = TARGET_SYMBOL,
896
  source: str = "snapshot",
897
  ):
898
  """
 
1121
  description="Combines XGBoost and TFT-ASRO signals into a directional consensus."
1122
  )
1123
  async def get_consensus(
1124
+ symbol: str = Query(default=TARGET_SYMBOL, description="Trading symbol")
1125
  ):
1126
  from deep_learning.inference.predictor import ensemble_directional_vote, generate_tft_analysis
1127
 
 
1163
  description="Returns training metrics, quality gate results, and feature importance."
1164
  )
1165
  async def get_tft_summary(
1166
+ symbol: str = Query(default=TARGET_SYMBOL, description="Target symbol")
1167
  ):
1168
  from app.models import TFTModelMetadata
1169
  from app.quality_gate import evaluate_quality_gate
 
1256
  "a 404 error."
1257
  ),
1258
  )
1259
+ async def get_latest_backtest(symbol: str = Query(default=TARGET_SYMBOL, description="Target symbol")):
1260
  import pathlib
1261
  import json as _json
1262
 
app/settings.py CHANGED
@@ -11,6 +11,8 @@ from pathlib import Path
11
  from typing import Optional
12
  from pydantic_settings import BaseSettings, SettingsConfigDict
13
 
 
 
14
  logger = logging.getLogger(__name__)
15
 
16
 
@@ -107,6 +109,15 @@ class Settings(BaseSettings):
107
  # Twelve Data (Live Price)
108
  twelvedata_api_key: Optional[str] = None
109
 
 
 
 
 
 
 
 
 
 
110
  # Inference sentiment adjustment (aggressive but capped)
111
  inference_sentiment_multiplier_max: float = 2.0
112
  inference_sentiment_multiplier_min: float = 0.5
@@ -207,7 +218,7 @@ class Settings(BaseSettings):
207
  def target_symbol(self) -> str:
208
  """Primary symbol for predictions (first in list)."""
209
  symbols = self.symbols_list
210
- return symbols[0] if symbols else "HG=F"
211
 
212
  @staticmethod
213
  def _first_non_empty(*values: Optional[str]) -> Optional[str]:
 
11
  from typing import Optional
12
  from pydantic_settings import BaseSettings, SettingsConfigDict
13
 
14
+ from app.instruments import TARGET_SYMBOL
15
+
16
  logger = logging.getLogger(__name__)
17
 
18
 
 
109
  # Twelve Data (Live Price)
110
  twelvedata_api_key: Optional[str] = None
111
 
112
+ # Heatmap quote strategy
113
+ # Yahoo/yfinance is kept as a snapshot source. Polling the full project
114
+ # universe every 2-3 seconds would trigger rate limits and waste CPU, so
115
+ # the frontend can poll the API quickly while this server-side cache
116
+ # controls actual provider refreshes.
117
+ heatmap_cache_ttl_seconds: int = 900
118
+ heatmap_frontend_poll_seconds: int = 3
119
+ heatmap_provider: str = "yfinance_snapshot"
120
+
121
  # Inference sentiment adjustment (aggressive but capped)
122
  inference_sentiment_multiplier_max: float = 2.0
123
  inference_sentiment_multiplier_min: float = 0.5
 
218
  def target_symbol(self) -> str:
219
  """Primary symbol for predictions (first in list)."""
220
  symbols = self.symbols_list
221
+ return symbols[0] if symbols else TARGET_SYMBOL
222
 
223
  @staticmethod
224
  def _first_non_empty(*values: Optional[str]) -> Optional[str]:
deep_learning/inference/predictor.py CHANGED
@@ -44,6 +44,7 @@ warnings.filterwarnings(
44
  )
45
 
46
  from deep_learning.config import TFTASROConfig, get_tft_config
 
47
 
48
  logger = logging.getLogger(__name__)
49
 
@@ -114,7 +115,7 @@ class TFTPredictor:
114
  self._pca = load_pca(pca_path)
115
  return self._pca
116
 
117
- def predict(self, session, symbol: str = "HG=F") -> Dict[str, Any]:
118
  """
119
  Generate a TFT-ASRO prediction for the given symbol.
120
 
@@ -258,22 +259,15 @@ class TFTPredictor:
258
  def _describe_instrument(symbol: str) -> Dict[str, str]:
259
  """Return a structured label for the traded instrument."""
260
  mapping = {
261
- "HG=F": {
262
- "symbol": "HG=F",
263
  "kind": "futures",
264
- "name": "COMEX Copper Futures",
265
  "note": (
266
  "Continuous front-month contract (CME Group). Prices "
267
- "differ from LME spot / XCU_USD due to basis and "
268
- "contract roll, typically by 1–3 USD/ton."
269
  ),
270
  },
271
- "XCU=X": {
272
- "symbol": "XCU=X",
273
- "kind": "spot",
274
- "name": "Copper spot (XCU/USD)",
275
- "note": "LBMA-style reference spot price; differs from futures by basis.",
276
- },
277
  }
278
  return mapping.get(
279
  symbol,
@@ -468,7 +462,7 @@ def get_tft_predictor(cfg: Optional[TFTASROConfig] = None) -> TFTPredictor:
468
  return _predictor
469
 
470
 
471
- def generate_tft_analysis(session, symbol: str = "HG=F") -> Dict[str, Any]:
472
  """
473
  High-level API for generating a TFT-ASRO analysis report.
474
 
 
44
  )
45
 
46
  from deep_learning.config import TFTASROConfig, get_tft_config
47
+ from app.instruments import TARGET_DISPLAY_NAME, TARGET_SYMBOL
48
 
49
  logger = logging.getLogger(__name__)
50
 
 
115
  self._pca = load_pca(pca_path)
116
  return self._pca
117
 
118
+ def predict(self, session, symbol: str = TARGET_SYMBOL) -> Dict[str, Any]:
119
  """
120
  Generate a TFT-ASRO prediction for the given symbol.
121
 
 
259
  def _describe_instrument(symbol: str) -> Dict[str, str]:
260
  """Return a structured label for the traded instrument."""
261
  mapping = {
262
+ TARGET_SYMBOL: {
263
+ "symbol": TARGET_SYMBOL,
264
  "kind": "futures",
265
+ "name": TARGET_DISPLAY_NAME,
266
  "note": (
267
  "Continuous front-month contract (CME Group). Prices "
268
+ "are used consistently across training, inference and UI."
 
269
  ),
270
  },
 
 
 
 
 
 
271
  }
272
  return mapping.get(
273
  symbol,
 
462
  return _predictor
463
 
464
 
465
+ def generate_tft_analysis(session, symbol: str = TARGET_SYMBOL) -> Dict[str, Any]:
466
  """
467
  High-level API for generating a TFT-ASRO analysis report.
468