from __future__ import annotations import logging import time from typing import Callable, Awaitable, Optional, Sequence from app.schemas.schemas import PositionMetrics, PatternSignal from app.services.metals.bullionvault_service import get_bullionvault_gold_price from app.services.metals.gold_price_fetcher import gold_price_fetcher logger = logging.getLogger(__name__) class PriceAnchorService: """Rescales simulated metric snapshots to the live gold price feed.""" def __init__(self, ttl_seconds: int = 30) -> None: self._ttl = ttl_seconds self._cache_price: Optional[float] = None self._cache_ts: float = 0.0 async def get_anchor_price(self, symbol: str = "XAUUSD") -> Optional[float]: now = time.time() if self._cache_price and (now - self._cache_ts) < self._ttl: return self._cache_price fetchers: Sequence[Callable[[], Awaitable[Optional[float]]]] = ( self._get_bullionvault_price, self._get_fallback_price, ) for fetch in fetchers: try: price = await fetch() except Exception as exc: # pragma: no cover - best effort logging only logger.warning("Price anchor fetch failed: %s", exc) continue if price and price > 0: self._cache_price = float(price) self._cache_ts = now return self._cache_price return self._cache_price def get_anchor_price_sync(self, symbol: str = "XAUUSD") -> Optional[float]: """Synchronous version that returns cached price only""" now = time.time() if self._cache_price and (now - self._cache_ts) < self._ttl: return self._cache_price return self._cache_price async def _get_bullionvault_price(self) -> Optional[float]: data = await get_bullionvault_gold_price("USD") return float(data["price"]) if data and data.get("price") else None async def _get_fallback_price(self) -> Optional[float]: data = await gold_price_fetcher.get_current_gold_price() return float(data["price"]) if data and data.get("price") else None def apply_anchor(self, metrics: PositionMetrics, anchor_price: Optional[float]) -> PositionMetrics: if not anchor_price or metrics.current_price <= 0: return metrics scale = anchor_price / metrics.current_price if abs(scale - 1.0) < 0.005: # Already close enough to the anchor, skip unnecessary work return metrics if not 0.2 <= scale <= 5: logger.warning("Skipping unrealistic price anchor scaling (scale=%.4f)", scale) return metrics scaled = metrics.model_copy(deep=True) def scale_value(value: Optional[float], decimals: int = 4) -> Optional[float]: if value is None: return None return round(value * scale, decimals) def scale_list(values: list[float]) -> list[float]: return [round(v * scale, 2) for v in values] scaled.current_price = round(anchor_price, 2) scaled.previous_close = scale_value(scaled.previous_close, 2) scaled.high = scale_value(scaled.high, 2) scaled.low = scale_value(scaled.low, 2) scaled.atr14 = scale_value(scaled.atr14) scaled.ema21 = scale_value(scaled.ema21) scaled.sma55 = scale_value(scaled.sma55) scaled.sma100 = scale_value(scaled.sma100) scaled.sma200 = scale_value(scaled.sma200) scaled.bb_basis = scale_value(scaled.bb_basis) scaled.bb_upper = scale_value(scaled.bb_upper) scaled.bb_lower = scale_value(scaled.bb_lower) scaled.zlsma = scale_value(scaled.zlsma) scaled.chandelier_long_stop = scale_value(scaled.chandelier_long_stop, 2) scaled.chandelier_short_stop = scale_value(scaled.chandelier_short_stop, 2) scaled.momentum12 = scale_value(scaled.momentum12) scaled.support_levels = scale_list(scaled.support_levels) scaled.resistance_levels = scale_list(scaled.resistance_levels) scaled.pattern_signals = [ signal.model_copy(update={"price": scale_value(signal.price, 2)}) for signal in scaled.pattern_signals ] if scaled.previous_close is not None: scaled.change = round(scaled.current_price - scaled.previous_close, 4) if scaled.previous_close: scaled.change_percent = round((scaled.change / scaled.previous_close) * 100, 4) else: scaled.change = scale_value(scaled.change) if scaled.previous_close: scaled.change_percent = round((scaled.change or 0.0) / scaled.previous_close * 100, 4) return scaled price_anchor_service = PriceAnchorService()