""" 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 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 logger = logging.getLogger(__name__) _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 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}") 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: return "caution", ( f"{event.currency} {event.event} released — {surprise} surprise " f"(actual {event.actual} vs forecast {event.forecast})" ) 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 # ── 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", } 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. """ for fetcher in (self._try_fetch_calendar_api, self._try_fetch_myfxbook_rss): try: events = fetcher() if events: return events except Exception as e: logger.debug(f"Calendar fetch exception ({fetcher.__name__}): {e}") 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"