""" Forex Signal Intelligence Platform - News & Sentiment Engine ============================================================= Monitors economic calendar events and news sentiment to provide fundamental context for technical signals. Key principle: High-impact news events override technical signals. A perfect technical setup can be destroyed by an unexpected NFP print. """ import hashlib import logging import re import threading import time import xml.etree.ElementTree as ET from dataclasses import dataclass, field from datetime import datetime, timezone from email.utils import parsedate_to_datetime from typing import Dict, List, Optional, Tuple import requests from config import NewsConfig from llm_client import get_llm_client logger = logging.getLogger(__name__) # ── DeepSeek prompts ───────────────────────────────────────────────── # STATIC constants on purpose: DeepSeek prices a repeated byte-identical # prompt prefix at the cache-hit rate (~50x cheaper), so nothing here may # be interpolated or timestamped. All variable content travels in the # user payload. LLM output is display/warning-only — it never gates # signals (see NewsConfig.sentiment_affects_signals). _LLM_SENTIMENT_SYSTEM = """\ You are a forex/gold news analyst. You receive JSON: {"headlines": ["...", ...]} For EACH headline, in the same order, score: - "s": overall market sentiment of the headline, -1.0 (very bearish) to 1.0 (very bullish), 0.0 if irrelevant or neutral. - "xau": the effect on the GOLD (XAU/USD) price, -1.0 (gold down) to 1.0 (gold up), 0.0 if irrelevant. Gold doctrine: hawkish Fed / rate hikes / rising real yields / strong dollar are gold-bearish; easing / rate cuts / safe-haven flows / geopolitical stress / weak dollar are gold-bullish. Handle negation correctly ("no rate hike" is not hawkish). Reply with ONLY JSON: {"scores": [[s, xau], ...]} — exactly one [s, xau] pair per input headline, in the same order, nothing else. Examples: "Fed signals faster rate hikes as inflation stays hot" -> [0.4, -0.7] "Gold surges to record high on safe-haven demand" -> [0.8, 0.9] "Dollar slips after soft jobs report; Treasury yields fall" -> [-0.4, 0.6] """ _LLM_EVENT_SYSTEM = """\ You are a forex/gold analyst. You receive one released economic event as JSON: {"event", "currency", "actual", "forecast", "previous", "vs_forecast"}. "vs_forecast" states whether the actual print came in above or below the forecast; it is computed programmatically — trust it over your own comparison of the numbers. If it is "unknown", compare actual and forecast yourself. Decide the likely effect of this RELEASE on the GOLD (XAU/USD) price. A stronger-than-forecast USD economy or a hawkish implication is usually gold-bearish; weaker data or a dovish implication is usually gold-bullish. Inverted metrics — for unemployment rate and unemployment/jobless claims a LOWER number means a STRONGER labor market: below forecast = strong economy = usually gold-bearish; above forecast = weak economy = usually gold-bullish. Example: {"event": "Unemployment Claims", "actual": "202K", "forecast": "217K", "vs_forecast": "below forecast"} -> {"gold_direction": "bearish", "reason": "fewer claims show strong labor market"} Reply with ONLY JSON: {"gold_direction": "bullish"|"bearish"|"neutral", "reason": ""} """ _LLM_SUMMARY_SYSTEM = """\ You are a forex/gold market-context writer. You receive JSON with recent "headlines" and today's high-impact "events" (released: true means the number is out). Write a 1-2 sentence plain-English summary of the current macro backdrop for a GOLD (XAU/USD) trader: the dominant theme and its likely pull on gold. No advice, no hedging boilerplate, max 45 words. Reply with ONLY JSON: {"summary": "..."} """ _RELEASE_VALUE_RE = re.compile(r"(-?\d+(?:\.\d+)?)\s*([kmbt])?", re.IGNORECASE) _RELEASE_MULTIPLIERS = {"k": 1e3, "m": 1e6, "b": 1e9, "t": 1e12} def _parse_release_value(raw: Optional[str]) -> Optional[float]: """Parse a calendar release value like '0.3%', '-1.7%', '228K', '1.1M'.""" if not raw: return None match = _RELEASE_VALUE_RE.search(raw.replace(",", "")) if not match: return None multiplier = _RELEASE_MULTIPLIERS.get((match.group(2) or "").lower(), 1.0) return float(match.group(1)) * multiplier @dataclass class EconomicEvent: """Single economic calendar event.""" time: datetime currency: str impact: str # "low", "medium", "high" event: str forecast: Optional[str] = None previous: Optional[str] = None actual: Optional[str] = None @property def surprise(self) -> Optional[str]: """Actual vs forecast: "positive"/"negative"/"inline", or None when either value is missing or non-numeric. Direction is numeric only — it does not know whether higher is market-bullish for this event.""" actual = _parse_release_value(self.actual) forecast = _parse_release_value(self.forecast) if actual is None or forecast is None: return None if actual > forecast: return "positive" if actual < forecast: return "negative" return "inline" @property def is_upcoming(self) -> bool: return self.time > datetime.now(timezone.utc) @property def minutes_until(self) -> float: delta = self.time - datetime.now(timezone.utc) return delta.total_seconds() / 60 @property def is_high_impact(self) -> bool: return self.impact == "high" @dataclass class NewsSentiment: """News sentiment analysis result.""" headline: str source: str timestamp: datetime currencies_mentioned: List[str] sentiment_score: float # -1.0 (bearish) to +1.0 (bullish) keywords: List[str] # Per-currency score: monetary-policy keywords flip sign for XAU # (hawkish is dollar-bullish but gold-bearish). sentiment_by_currency: Dict[str, float] = field(default_factory=dict) @dataclass class FundamentalContext: """Complete fundamental/news context for signal decisions.""" events_today: List[EconomicEvent] = field(default_factory=list) events_upcoming_1h: List[EconomicEvent] = field(default_factory=list) high_impact_warning: bool = False warning_message: str = "" news_sentiment: Dict[str, float] = field(default_factory=dict) # currency -> sentiment news_headlines: List[NewsSentiment] = field(default_factory=list) event_impact_on_pairs: Dict[str, str] = field(default_factory=dict) calendar_available: bool = False # True if live calendar data was fetched llm_summary: str = "" # DeepSeek market-context blurb (display-only) class NewsEngine: """ Economic calendar and news sentiment analyzer. Provides fundamental context that modifies technical signal confidence. """ # Keywords that indicate directional sentiment BULLISH_KEYWORDS = { "rate hike", "hawkish", "strong growth", "beat expectations", "above forecast", "above estimate", "above expectation", "surplus", "expansion", "employment gains", "strong jobs", "gdp growth", "inflation rising", "tightening", "positive surprise", "record high", "bullish", "rally", "stronger than expected", "upbeat", "beats", "surges", "soars", "jumps", "climbs", "rises", "higher than expected", "better than expected", "strong data", "robust", "resilient", "accelerates", } BEARISH_KEYWORDS = { "rate cut", "dovish", "recession", "slowdown", "contraction", "below forecast", "below estimate", "below expectation", "deficit", "miss expectations", "weak data", "unemployment rise", "negative surprise", "bearish", "selloff", "weaker than expected", "downbeat", "easing", "stimulus", "misses", "slides", "drops", "falls", "plunges", "tumbles", "weakens", "slumps", "declines", "retreats", "lower than expected", "worse than expected", "disappointing", "soft data", } # Monetary-policy keywords whose direction inverts for gold: tighter # policy lifts the dollar and yields, weighing on non-yielding gold; # easing does the opposite. Only unambiguous policy words — economic # data words (beats/misses) keep their normal direction. GOLD_INVERSE_KEYWORDS = { "rate hike", "hawkish", "tightening", "rate cut", "dovish", "easing", "stimulus", } # Currency-specific keywords for headline parsing. Matched with word # boundaries — bare "us"/"eu"/"uk" are deliberately excluded because as # substrings they false-positive constantly ("versus" → USD). CURRENCY_KEYWORDS = { "USD": ["dollar", "fed", "federal reserve", "fomc", "treasury", "american", "nonfarm", "nfp"], "EUR": ["euro", "ecb", "eurozone", "german", "french"], "GBP": ["pound", "sterling", "boe", "bank of england", "british"], "JPY": ["yen", "boj", "bank of japan", "japanese"], "CHF": ["franc", "snb", "swiss"], "AUD": ["aussie", "rba", "australian", "australia"], "CAD": ["loonie", "boc", "canadian", "canada"], "NZD": ["kiwi", "rbnz", "new zealand"], "XAU": ["gold", "precious metal", "safe haven", "bullion"], } # One compiled alternation per currency (code + keywords). (? FundamentalContext: """ Get complete fundamental context for signal decisions. Combines economic calendar + news sentiment into actionable context. """ if currencies is None: currencies = self.config.tracked_currencies context = FundamentalContext() # 1. Economic calendar events try: events = self._fetch_economic_calendar(currencies) now = datetime.now(timezone.utc) # Compare dates in UTC — feed timestamps carry their own offset # (FF sends US-Eastern), so a late-evening ET event would # otherwise land on the wrong day. context.events_today = [ e for e in events if e.time.astimezone(timezone.utc).date() == now.date() ] context.events_upcoming_1h = [ e for e in events if 0 < e.minutes_until <= 60 ] context.calendar_available = bool(events) # Check for high-impact warnings for event in events: if event.is_high_impact and 0 < event.minutes_until <= self.config.pre_event_buffer_minutes: context.high_impact_warning = True context.warning_message = ( f"HIGH IMPACT: {event.currency} {event.event} " f"in {event.minutes_until:.0f} min — signals may be unreliable" ) break # Map upcoming high-impact events to affected pairs for event in context.events_upcoming_1h: if event.is_high_impact: affected = self._get_affected_pairs(event.currency) for pair in affected: context.event_impact_on_pairs[pair] = ( f"{event.currency} {event.event} in {event.minutes_until:.0f}min" ) except Exception as e: logger.warning(f"Economic calendar fetch failed: {e}") # 2. News sentiment try: headlines = self._fetch_news_sentiment() context.news_headlines = headlines # Aggregate sentiment per currency (per-currency score where # available, so gold-inverted policy keywords are respected) for currency in currencies: scores = [ h.sentiment_by_currency.get(currency, h.sentiment_score) for h in headlines if currency in h.currencies_mentioned ] if scores: context.news_sentiment[currency] = sum(scores) / len(scores) except Exception as e: logger.warning(f"News sentiment fetch failed: {e}") # 3. LLM market-context summary (display-only) try: context.llm_summary = self._llm_market_summary(context) except Exception as e: logger.debug(f"LLM market summary failed: {e}") return context @staticmethod def _parse_pair(pair_symbol: str) -> Tuple[str, str]: if "/" in pair_symbol: parts = pair_symbol.split("/") return parts[0], parts[1] clean = pair_symbol.replace("=X", "") return clean[:3], clean[3:] def get_event_risk_level(self, pair_symbol: str, context: FundamentalContext) -> Tuple[str, str]: """ Get event risk level for a specific pair. Returns (risk_level, description). risk_level: "clear", "caution", "danger" """ base, quote = self._parse_pair(pair_symbol) # Check upcoming high-impact events. Grade from the SOONEST # matching event, not the first listed — an unsorted feed could # otherwise report "caution" for a far event while a nearer one # warrants "danger". soonest = None for event in context.events_upcoming_1h: if event.is_high_impact and event.currency in (base, quote): if soonest is None or event.minutes_until < soonest.minutes_until: soonest = event if soonest is not None: mins = soonest.minutes_until if mins <= self.config.pre_event_buffer_minutes: return "danger", f"{soonest.currency} {soonest.event} in {mins:.0f}min" return "caution", f"{soonest.currency} {soonest.event} in {mins:.0f}min" # Check for recent high-impact releases. A surprise print (actual # away from forecast) keeps the market unstable longer than an # inline release, so it gets a wider post-event window. for event in context.events_today: if event.is_high_impact and event.currency in (base, quote) and not event.is_upcoming: surprise = event.surprise surprised = surprise in ("positive", "negative") window = ( self.config.post_event_surprise_buffer_minutes if surprised else self.config.post_event_buffer_minutes ) if event.minutes_until > -window: if surprised: desc = ( f"{event.currency} {event.event} released — {surprise} surprise " f"(actual {event.actual} vs forecast {event.forecast})" ) # LLM enrichment touches the DESCRIPTION only — # `surprise` is numeric-direction and does not know # whether a beat is gold-bullish; the LLM does. gold_dir = self._llm_event_direction(event) if gold_dir: desc += f" — gold {gold_dir}" return "caution", desc return "caution", f"{event.currency} {event.event} just released" return "clear", "No significant events" def get_pair_sentiment( self, pair_symbol: str, context: FundamentalContext ) -> Tuple[float, str]: """ Get net sentiment for a pair based on news. Returns (score, description) where score is -1 to +1. Positive = bullish for the pair, negative = bearish. """ base, quote = self._parse_pair(pair_symbol) base_sent = context.news_sentiment.get(base, 0.0) quote_sent = context.news_sentiment.get(quote, 0.0) # Net sentiment: positive base or negative quote = bullish for pair net = base_sent - quote_sent if abs(net) < 0.1: desc = "Neutral news sentiment" elif net > 0.3: desc = f"Bullish sentiment ({base} positive / {quote} negative)" elif net > 0: desc = "Slightly bullish sentiment" elif net < -0.3: desc = f"Bearish sentiment ({base} negative / {quote} positive)" else: desc = "Slightly bearish sentiment" return net, desc def get_week_events( self, currencies: Optional[List[str]] = None ) -> List[EconomicEvent]: """Full cached calendar week (past and upcoming), filtered to `currencies` (default: tracked_currencies), sorted by time. Read-only accessor for display surfaces (the web dashboard) — rides the same class-level cache as signal scans, so it adds no network I/O. No production signal path calls this. """ if currencies is None: currencies = self.config.tracked_currencies events = self._cached_fetch("_calendar_cache", self._fetch_calendar_raw) return sorted( (e for e in events if e.currency in currencies), key=lambda e: e.time, ) # ── Private Methods ──────────────────────────────────────────── # Maps country names in MyFXBook titles to currency codes _COUNTRY_TO_CURRENCY = { "united states": "USD", "euro area": "EUR", "united kingdom": "GBP", "japan": "JPY", "switzerland": "CHF", "australia": "AUD", "canada": "CAD", "new zealand": "NZD", } @classmethod def _record_source(cls, name: str, ok: bool, detail: str = "", extra: str = "") -> None: """Track one dependency's fetch outcome for status displays. Logs state TRANSITIONS so the log file carries the story without a line every 10-min cycle: first failure WARNING (with detail), repeat failures DEBUG, recovery INFO.""" h = cls._source_health.setdefault( name, {"ok": None, "err": None, "detail": "", "extra": ""}) was_failing = h["err"] is not None and ( h["ok"] is None or h["err"] > h["ok"]) if ok: h["ok"] = time.time() h["extra"] = extra if was_failing: logger.info("Source recovered: %s", name) else: h["err"] = time.time() h["detail"] = detail if was_failing: logger.debug("Source still failing: %s (%s)", name, detail) else: logger.warning("Source failing: %s (%s)", name, detail) @staticmethod def source_is_failing(entry: Optional[dict]) -> bool: """True when the entry's last error is newer than its last ok.""" return bool(entry and entry["err"] is not None and (entry["ok"] is None or entry["err"] > entry["ok"])) def _cached_fetch(self, cache_attr: str, fetch_fn) -> list: """Return cached data for `cache_attr` or refetch when the TTL expired. Successful results live for context_cache_ttl_seconds; empty results only _EMPTY_RESULT_TTL so outages retry quickly. The lock is held across the fetch so concurrent scans share one request instead of stampeding the source.""" ts, data = getattr(NewsEngine, cache_attr) ttl = self.config.context_cache_ttl_seconds if data else self._EMPTY_RESULT_TTL if time.time() - ts < ttl: return data with NewsEngine._cache_lock: ts, data = getattr(NewsEngine, cache_attr) # re-check under lock ttl = self.config.context_cache_ttl_seconds if data else self._EMPTY_RESULT_TTL if time.time() - ts < ttl: return data data = fetch_fn() setattr(NewsEngine, cache_attr, (time.time(), data)) return data def _fetch_economic_calendar(self, currencies: List[str]) -> List[EconomicEvent]: """Cached calendar fetch, filtered to the tracked currencies.""" events = self._cached_fetch("_calendar_cache", self._fetch_calendar_raw) return [e for e in events if e.currency in currencies] def _fetch_calendar_raw(self) -> List[EconomicEvent]: """ Fetch economic calendar events from a free API. Tries Forex Factory first, falls back to MyFXBook RSS. Returns an empty list (with a log warning) if unavailable — we never generate fake events since that causes constant false alarms. """ sources = ( (self._try_fetch_calendar_api, "calendar:forexfactory", "ForexFactory"), (self._try_fetch_myfxbook_rss, "calendar:myfxbook", "MyFXBook"), ) for fetcher, src, label in sources: try: events = fetcher() if events: self._record_source(src, True) self._record_source( "calendar", True, extra=f"{len(events)} events via {label}") return events self._record_source(src, False, "no events returned") except Exception as e: self._record_source( src, False, f"{type(e).__name__}: {e}") logger.debug(f"Calendar fetch exception ({fetcher.__name__}): {e}") self._record_source("calendar", False, "all sources failed") logger.info( "Economic calendar data unavailable. " "Event risk checks will be skipped for this cycle." ) return [] def _try_fetch_calendar_api(self) -> List[EconomicEvent]: """Attempt to fetch from the Forex Factory free JSON calendar.""" now = time.time() if now < NewsEngine._calendar_backoff_until: wait = NewsEngine._calendar_backoff_until - now logger.debug(f"Calendar API in backoff for {wait:.0f}s more") return [] url = "https://nfs.faireconomy.media/ff_calendar_thisweek.json" for attempt in range(3): try: resp = self._session.get(url, timeout=10) except requests.RequestException as e: logger.debug(f"Calendar request failed (attempt {attempt + 1}): {e}") if attempt < 2: time.sleep(2 ** attempt) continue if resp.status_code == 429: retry_after = int(resp.headers.get("Retry-After", 300)) retry_after = min(retry_after, 600) NewsEngine._calendar_backoff_until = time.time() + retry_after logger.warning( f"Calendar API rate-limited (429), backing off {retry_after}s" ) return [] if resp.status_code != 200: logger.debug(f"Calendar API returned {resp.status_code}") return [] data = resp.json() events = [] impact_map = {"High": "high", "Medium": "medium", "Low": "low"} for item in data: try: impact = impact_map.get(item.get("impact", ""), "low") date_str = item.get("date", "") if not date_str: continue event_time = datetime.fromisoformat(date_str.replace("Z", "+00:00")) events.append(EconomicEvent( time=event_time, currency=item.get("country", ""), impact=impact, event=item.get("title", ""), forecast=item.get("forecast") or None, previous=item.get("previous") or None, actual=item.get("actual") or None, )) except Exception: continue return events return [] def _try_fetch_myfxbook_rss(self) -> List[EconomicEvent]: """Fallback calendar source: MyFXBook economic calendar RSS.""" url = "https://www.myfxbook.com/rss/forex-economic-calendar-events" try: resp = self._session.get(url, timeout=10) except requests.RequestException as e: logger.debug(f"MyFXBook calendar request failed: {e}") return [] if resp.status_code != 200: return [] try: root = ET.fromstring(resp.content) except ET.ParseError: return [] events = [] for item in root.iter("item"): try: title_el = item.find("title") pub_el = item.find("pubDate") desc_el = item.find("description") if title_el is None or title_el.text is None: continue title = title_el.text.strip() currency = "" title_lower = title.lower() for country, cur in self._COUNTRY_TO_CURRENCY.items(): if title_lower.startswith(country): currency = cur title = title[len(country):].strip() break if not currency: continue event_time = self._parse_rss_date( pub_el.text if pub_el is not None else None ) if event_time is None: continue impact = "low" desc_text = desc_el.text if desc_el is not None and desc_el.text else "" impact_match = re.search(r"sprite-(high|medium|low)-impact", desc_text) if impact_match: impact = impact_match.group(1) cells = re.findall(r"]*>(.*?)", desc_text, re.DOTALL) cells = [re.sub(r"<[^>]+>", "", c).strip() for c in cells] previous = cells[2] if len(cells) > 2 and cells[2] else None forecast = cells[3] if len(cells) > 3 and cells[3] else None actual = cells[4] if len(cells) > 4 and cells[4] else None events.append(EconomicEvent( time=event_time, currency=currency, impact=impact, event=title, forecast=forecast, previous=previous, actual=actual, )) except Exception: continue logger.debug(f"MyFXBook calendar: parsed {len(events)} events") return events def _fetch_news_sentiment(self) -> List[NewsSentiment]: """Cached headline fetch + sentiment analysis.""" return self._cached_fetch("_headlines_cache", self._fetch_news_sentiment_raw) def _fetch_news_sentiment_raw(self) -> List[NewsSentiment]: """Fetch and analyze news headlines for sentiment.""" headlines = [] seen_titles = set() ok_feeds = 0 for feed_url in self.config.rss_feeds: host = feed_url.split("/")[2] if "//" in feed_url else feed_url kept_before = len(headlines) try: resp = self._session.get(feed_url, timeout=10) if resp.status_code != 200: self._record_source( f"rss:{host}", False, f"HTTP {resp.status_code}") continue root = ET.fromstring(resp.content) for item in root.iter("item"): title_el = item.find("title") pub_date_el = item.find("pubDate") if title_el is None: continue title = title_el.text or "" pub_date = self._parse_rss_date( pub_date_el.text if pub_date_el is not None else None ) # Only recent headlines (last 24h) if pub_date and (datetime.now(timezone.utc) - pub_date).total_seconds() > 86400: continue # Dedup within and across feeds so a syndicated story # doesn't count twice in the sentiment average. Google # News appends " - Publisher", so compare without it. title_key = title.lower().rsplit(" - ", 1)[0].strip() if title_key in seen_titles: continue seen_titles.add(title_key) sentiment = self._analyze_headline_sentiment(title) currencies = self._extract_currencies(title) if currencies: headlines.append(NewsSentiment( headline=title, source=feed_url, timestamp=pub_date or datetime.now(timezone.utc), currencies_mentioned=currencies, sentiment_score=sentiment, keywords=self._extract_keywords(title), sentiment_by_currency={ cur: self._analyze_headline_sentiment(title, cur) for cur in currencies }, )) ok_feeds += 1 self._record_source( f"rss:{host}", True, extra=f"{len(headlines) - kept_before} kept") except Exception as e: self._record_source( f"rss:{host}", False, f"{type(e).__name__}: {e}") logger.debug(f"Failed to fetch RSS from {feed_url}: {e}") self._record_source( "headlines", ok_feeds > 0, detail="all feeds failed" if ok_feeds == 0 else "", extra=(f"{len(headlines)} headlines from " f"{ok_feeds}/{len(self.config.rss_feeds)} feeds")) try: self._apply_llm_sentiment(headlines) except Exception as e: logger.debug(f"LLM sentiment failed, keyword scores stand: {e}") return headlines def _analyze_headline_sentiment( self, headline: str, for_currency: Optional[str] = None ) -> float: """ Simple keyword-based sentiment analysis. Returns -1.0 (very bearish) to +1.0 (very bullish). When for_currency is "XAU", monetary-policy keywords contribute with inverted sign (see GOLD_INVERSE_KEYWORDS). Note: This is intentionally simple. NLP models like FinBERT would be more accurate but require large dependencies. This catches the most impactful sentiment signals from headlines. """ headline_lower = headline.lower() invert_policy = for_currency == "XAU" bullish_score = 0 bearish_score = 0 for kw in self.BULLISH_KEYWORDS: if kw in headline_lower: if invert_policy and kw in self.GOLD_INVERSE_KEYWORDS: bearish_score += 1 else: bullish_score += 1 for kw in self.BEARISH_KEYWORDS: if kw in headline_lower: if invert_policy and kw in self.GOLD_INVERSE_KEYWORDS: bullish_score += 1 else: bearish_score += 1 total = bullish_score + bearish_score if total == 0: return 0.0 return (bullish_score - bearish_score) / total # ── DeepSeek enrichment (display/warning-only, keyword fallback) ── def _llm_headline_scores( self, texts: List[str] ) -> Optional[List[Tuple[float, float]]]: """One batched DeepSeek call → [(overall, xau), ...] aligned with `texts`, or None on any failure/mismatch (caller keeps keyword scores). Tests stub this method directly.""" result = get_llm_client().chat_json( _LLM_SENTIMENT_SYSTEM, {"headlines": texts}) if not result: return None scores = result.get("scores") if not isinstance(scores, list) or len(scores) != len(texts): return None out = [] for item in scores: try: s = max(-1.0, min(1.0, float(item[0]))) x = max(-1.0, min(1.0, float(item[1]))) except (TypeError, KeyError, ValueError, IndexError): return None out.append((s, x)) return out def _apply_llm_sentiment(self, headlines: List[NewsSentiment]) -> None: """Overwrite keyword sentiment on `headlines` with LLM scores. Runs inside _cached_fetch, i.e. UNDER _cache_lock — cache access is direct and the (rare) HTTP call blocks sibling scans at most once per cache window, matching the existing shared-fetch design. Only headlines missing from the cache are sent; a failed batch changes nothing. """ if not headlines or not get_llm_client().available: return cache = NewsEngine._llm_sent_cache keyed = [(hashlib.sha1(h.headline.encode()).hexdigest(), h) for h in headlines] # Cap the batch so the reply fits the client's max_tokens — the # overflow stays keyword-scored this cycle and, still uncached, # goes out with the next window's batch. new = [(k, h) for k, h in keyed if k not in cache][:25] if new: scores = self._llm_headline_scores([h.headline for _, h in new]) if scores is not None: for (k, _), sc in zip(new, scores): cache[k] = sc while len(cache) > self._LLM_SENT_CACHE_MAX: cache.pop(next(iter(cache))) for k, h in keyed: sc = cache.get(k) if sc is None: continue overall, xau = sc h.sentiment_score = overall h.sentiment_by_currency = { cur: (xau if cur == "XAU" else overall) for cur in h.currencies_mentioned } def _llm_event_direction(self, event: EconomicEvent) -> Optional[str]: """Gold direction for a released surprise, e.g. "bearish: strong jobs lift yields". None → caller keeps the numeric description. Cached per release; failures are NOT cached (retry next scan). The actual-vs-forecast comparison is precomputed and sent as "vs_forecast": the 2026-08-06 validation found the model reads close K/M-suffixed pairs backwards ~11% of the time (e.g. claims 202K vs 217K called "higher than forecast") when left to compare the raw numbers itself.""" key = (event.event, event.currency, str(event.actual or "")) with NewsEngine._cache_lock: if key in NewsEngine._llm_event_cache: return NewsEngine._llm_event_cache[key] or None client = get_llm_client() if not client.available: return None vs_forecast = { "positive": "above forecast", "negative": "below forecast", "inline": "in line with forecast", }.get(event.surprise or "", "unknown") result = client.chat_json(_LLM_EVENT_SYSTEM, { "event": event.event, "currency": event.currency, "actual": event.actual, "forecast": event.forecast, "previous": event.previous, "vs_forecast": vs_forecast, }) if not result: return None direction = result.get("gold_direction") if direction not in ("bullish", "bearish", "neutral"): return None reason = str(result.get("reason", "")).strip()[:80] text = "" if direction == "neutral" else ( f"{direction}: {reason}" if reason else direction) with NewsEngine._cache_lock: NewsEngine._llm_event_cache[key] = text while len(NewsEngine._llm_event_cache) > self._LLM_EVENT_CACHE_MAX: NewsEngine._llm_event_cache.pop( next(iter(NewsEngine._llm_event_cache))) return text or None def _llm_market_summary(self, context: FundamentalContext) -> str: """1-2 sentence macro blurb for alerts; "" when unavailable. Cached (success OR failure) for the context-cache TTL so at most one call is made per window.""" with NewsEngine._cache_lock: ts, cached = NewsEngine._llm_summary_cache if time.time() - ts < self.config.context_cache_ttl_seconds: return cached high_impact = [e for e in context.events_today if e.is_high_impact] if not context.news_headlines and not high_impact: return "" client = get_llm_client() if not client.available: return "" result = client.chat_json(_LLM_SUMMARY_SYSTEM, { "headlines": [h.headline for h in context.news_headlines[:15]], "events": [ {"event": e.event, "currency": e.currency, "actual": e.actual, "forecast": e.forecast, "released": not e.is_upcoming} for e in high_impact[:10] ], }) summary = "" if result: summary = str(result.get("summary", "")).strip()[:300] with NewsEngine._cache_lock: NewsEngine._llm_summary_cache = (time.time(), summary) return summary def _extract_currencies(self, headline: str) -> List[str]: """Extract mentioned currencies from a headline.""" headline_lower = headline.lower() return [ currency for currency, pattern in self._CURRENCY_PATTERNS.items() if pattern.search(headline_lower) ] def _extract_keywords(self, headline: str) -> List[str]: """Extract relevant trading keywords from headline.""" headline_lower = headline.lower() return [kw for kw in self.BULLISH_KEYWORDS | self.BEARISH_KEYWORDS if kw in headline_lower] @staticmethod def _parse_rss_date(date_str: Optional[str]) -> Optional[datetime]: """Parse various RSS date formats into a timezone-aware datetime.""" if not date_str: return None # RFC 822/2822 forms, with or without seconds, numeric offsets AND # named zones (GMT, EST/EDT, PST…). The old strptime "%Z" path # accepted ANY abbreviation but attached no offset, so e.g. an EST # pubDate was silently stamped UTC — a 5-hour error. try: dt = parsedate_to_datetime(date_str.strip()) if dt is not None: if dt.tzinfo is None: # unknown zone name — assume UTC return dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) except (TypeError, ValueError): pass formats = [ "%Y-%m-%dT%H:%M:%S%z", # ISO 8601 with offset "%Y-%m-%d %H:%M:%S", # ISO 8601 naive (assume UTC) ] for fmt in formats: try: dt = datetime.strptime(date_str.strip(), fmt) # If strptime already attached tzinfo (from %z), keep it. # Otherwise the string had no timezone info — assume UTC. if dt.tzinfo is not None: return dt return dt.replace(tzinfo=timezone.utc) except ValueError: continue return None @staticmethod def _get_affected_pairs(currency: str) -> List[str]: _MAP = { "USD": ["XAU/USD"], "XAU": ["XAU/USD"], } return _MAP.get(currency, [])