bf6c890029
- Migrate news_engine to google-genai SDK with gemini-2.5-flash-lite - Batch all headlines into one API call per scan (30 calls -> 1) - In-memory cache keyed by headline hash; fallback to keyword scoring - Update requirements.txt: add google-genai, python-dotenv; remove transformers - Add .gitignore; update CLAUDE.md with Gemini live section Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
558 lines
22 KiB
Python
558 lines
22 KiB
Python
"""
|
|
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 json
|
|
import logging
|
|
import os
|
|
import xml.etree.ElementTree as ET
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
import requests
|
|
|
|
# Load .env so GEMINI_API_KEY is available without exporting it manually.
|
|
try:
|
|
from dotenv import load_dotenv
|
|
load_dotenv()
|
|
except ImportError:
|
|
pass
|
|
|
|
# New unified Google GenAI SDK (replaces google-generativeai).
|
|
try:
|
|
from google import genai
|
|
from google.genai import types as genai_types
|
|
_GENAI_AVAILABLE = True
|
|
except ImportError:
|
|
genai = None
|
|
genai_types = None
|
|
_GENAI_AVAILABLE = False
|
|
|
|
from config import NewsConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@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
|
|
surprise: Optional[str] = None # "positive", "negative", "inline", None
|
|
|
|
@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]
|
|
|
|
|
|
@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 using Google Gemini API.
|
|
|
|
Provides fundamental context that modifies technical signal confidence.
|
|
"""
|
|
|
|
# Keywords that indicate directional sentiment (kept for fallback and keyword extraction)
|
|
BULLISH_KEYWORDS = {
|
|
"rate hike", "hawkish", "strong growth", "beat expectations",
|
|
"above forecast", "surplus", "expansion", "employment gains",
|
|
"strong jobs", "gdp growth", "inflation rising", "tightening",
|
|
"positive surprise", "record high", "bullish", "rally",
|
|
"stronger than expected", "upbeat",
|
|
}
|
|
|
|
BEARISH_KEYWORDS = {
|
|
"rate cut", "dovish", "recession", "slowdown", "contraction",
|
|
"below forecast", "deficit", "miss expectations", "weak data",
|
|
"unemployment rise", "negative surprise", "bearish", "selloff",
|
|
"weaker than expected", "downbeat", "easing", "stimulus",
|
|
}
|
|
|
|
# Currency-specific keywords for headline parsing
|
|
CURRENCY_KEYWORDS = {
|
|
"USD": ["dollar", "fed", "fomc", "treasury", "us ", "american", "nonfarm", "nfp"],
|
|
"EUR": ["euro", "ecb", "eurozone", "eu ", "german", "french"],
|
|
"GBP": ["pound", "sterling", "boe", "bank of england", "uk ", "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"],
|
|
}
|
|
|
|
# High-impact event types (these move markets significantly)
|
|
HIGH_IMPACT_EVENTS = {
|
|
"interest rate decision", "nonfarm payrolls", "non-farm",
|
|
"gdp", "cpi", "inflation", "employment change",
|
|
"retail sales", "pmi", "fomc", "ecb press conference",
|
|
"boe rate", "rba rate", "snb rate", "boc rate",
|
|
}
|
|
|
|
# gemini-2.5-flash-lite has a much higher free-tier quota (1000 RPD vs 20
|
|
# for gemini-2.5-flash) and is more than capable for headline classification.
|
|
GEMINI_MODEL = "gemini-2.5-flash-lite"
|
|
BATCH_MAX_HEADLINES = 60 # cap per Gemini call to keep prompts small
|
|
|
|
def __init__(self, config: NewsConfig):
|
|
self.config = config
|
|
self._session = requests.Session()
|
|
self._session.headers.update({
|
|
"User-Agent": "Mozilla/5.0 (compatible; ForexIntel/1.0)"
|
|
})
|
|
|
|
# In-memory cache for Gemini-scored headlines so repeat fetches across
|
|
# /scan calls don't re-spend tokens on the same RSS items.
|
|
self._sentiment_cache: Dict[int, float] = {}
|
|
|
|
# Initialize Google Gemini via the new unified google-genai SDK.
|
|
api_key = os.environ.get("GEMINI_API_KEY")
|
|
self.gemini_client = None
|
|
if not _GENAI_AVAILABLE:
|
|
logger.warning("google-genai SDK not installed — falling back to keyword sentiment.")
|
|
elif not api_key:
|
|
logger.warning("GEMINI_API_KEY not set — falling back to keyword sentiment.")
|
|
else:
|
|
try:
|
|
self.gemini_client = genai.Client(api_key=api_key)
|
|
logger.info(f"Google Gemini ({self.GEMINI_MODEL}) configured.")
|
|
except Exception as e:
|
|
logger.error(f"Failed to configure Gemini: {e}")
|
|
self.gemini_client = None
|
|
|
|
def get_fundamental_context(
|
|
self, currencies: Optional[List[str]] = None
|
|
) -> 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)
|
|
|
|
context.events_today = [e for e in events if e.time.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
|
|
for currency in currencies:
|
|
scores = [
|
|
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
|
|
|
|
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).
|
|
"""
|
|
clean = pair_symbol.replace("=X", "")
|
|
base = clean[:3]
|
|
quote = clean[3:]
|
|
|
|
# Check upcoming high-impact events
|
|
for event in context.events_upcoming_1h:
|
|
if event.is_high_impact and event.currency in (base, quote):
|
|
mins = event.minutes_until
|
|
if mins <= self.config.pre_event_buffer_minutes:
|
|
return "danger", f"{event.currency} {event.event} in {mins:.0f}min"
|
|
else:
|
|
return "caution", f"{event.currency} {event.event} in {mins:.0f}min"
|
|
|
|
# Check for recent high-impact releases
|
|
for event in context.events_today:
|
|
if event.is_high_impact and event.currency in (base, quote):
|
|
if not event.is_upcoming and event.minutes_until > -self.config.post_event_buffer_minutes:
|
|
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.
|
|
"""
|
|
clean = pair_symbol.replace("=X", "")
|
|
base = clean[:3]
|
|
quote = clean[3:]
|
|
|
|
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 ────────────────────────────────────────────
|
|
|
|
def _fetch_economic_calendar(self, currencies: List[str]) -> List[EconomicEvent]:
|
|
try:
|
|
events = self._try_fetch_calendar_api()
|
|
if events:
|
|
return [e for e in events if e.currency in currencies]
|
|
except Exception as e:
|
|
logger.debug(f"Calendar API fetch exception: {e}")
|
|
|
|
logger.info("Economic calendar data unavailable. Risk checks skipped.")
|
|
return []
|
|
|
|
def _try_fetch_calendar_api(self) -> List[EconomicEvent]:
|
|
events = []
|
|
url = "https://nfs.faireconomy.media/ff_calendar_thisweek.json"
|
|
resp = self._session.get(url, timeout=10)
|
|
if resp.status_code != 200:
|
|
return []
|
|
|
|
data = resp.json()
|
|
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
|
|
|
|
def _fetch_news_sentiment(self) -> List[NewsSentiment]:
|
|
# Stage 1: collect all candidate (title, pub_date, source) from RSS feeds
|
|
# without scoring yet.
|
|
candidates: List[Tuple[str, datetime, str]] = []
|
|
for feed_url in self.config.rss_feeds:
|
|
try:
|
|
resp = self._session.get(feed_url, timeout=10)
|
|
if resp.status_code != 200:
|
|
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
|
|
)
|
|
if pub_date and (datetime.now(timezone.utc) - pub_date).total_seconds() > 86400:
|
|
continue
|
|
if not self._extract_currencies(title):
|
|
continue # skip headlines that don't mention any tracked currency
|
|
candidates.append((title, pub_date or datetime.now(timezone.utc), feed_url))
|
|
except Exception as e:
|
|
logger.debug(f"Failed to fetch RSS from {feed_url}: {e}")
|
|
|
|
# Stage 2: batch-score all relevant titles in one Gemini call (or as
|
|
# few as possible). Cached titles are served from memory; only the
|
|
# uncached subset is sent to the API.
|
|
unique_titles = list({t for t, _, _ in candidates})
|
|
scores = self._score_headlines_batch(unique_titles)
|
|
|
|
# Stage 3: build NewsSentiment objects using the scored map.
|
|
return [
|
|
NewsSentiment(
|
|
headline=title,
|
|
source=source,
|
|
timestamp=ts,
|
|
currencies_mentioned=self._extract_currencies(title),
|
|
sentiment_score=scores.get(title, 0.0),
|
|
keywords=self._extract_keywords(title),
|
|
)
|
|
for title, ts, source in candidates
|
|
]
|
|
|
|
def _score_headlines_batch(self, headlines: List[str]) -> Dict[str, float]:
|
|
"""
|
|
Score a list of headlines with a single Gemini API call.
|
|
Cached titles are returned from memory; only the uncached subset is
|
|
sent to the model. Falls back to keyword scoring on API failure.
|
|
"""
|
|
out: Dict[str, float] = {}
|
|
to_score: List[str] = []
|
|
for h in headlines:
|
|
key = hash(h)
|
|
if key in self._sentiment_cache:
|
|
out[h] = self._sentiment_cache[key]
|
|
else:
|
|
to_score.append(h)
|
|
|
|
if not to_score:
|
|
return out
|
|
|
|
if self.gemini_client is None:
|
|
for h in to_score:
|
|
s = self._fallback_keyword_sentiment(h)
|
|
self._sentiment_cache[hash(h)] = s
|
|
out[h] = s
|
|
return out
|
|
|
|
# Process in chunks to keep the prompt reasonable.
|
|
for i in range(0, len(to_score), self.BATCH_MAX_HEADLINES):
|
|
chunk = to_score[i : i + self.BATCH_MAX_HEADLINES]
|
|
chunk_scores = self._gemini_score_chunk(chunk)
|
|
for h in chunk:
|
|
s = chunk_scores.get(h)
|
|
if s is None:
|
|
s = self._fallback_keyword_sentiment(h)
|
|
self._sentiment_cache[hash(h)] = s
|
|
out[h] = s
|
|
return out
|
|
|
|
def _gemini_score_chunk(self, headlines: List[str]) -> Dict[str, float]:
|
|
"""Single Gemini call returning {headline: score} for the chunk."""
|
|
numbered = "\n".join(f'{i + 1}. "{h}"' for i, h in enumerate(headlines))
|
|
prompt = (
|
|
"You are an expert Forex fundamental analyst. Score each headline "
|
|
"from -1.0 (extremely bearish for its primary currency) to +1.0 "
|
|
"(extremely bullish). Use 0.0 if neutral or off-topic. Consider "
|
|
"context: surprises beating forecast = bullish; misses = bearish; "
|
|
'central bank "pause" or "patient" = dovish/bearish; "hawkish" or '
|
|
'"hike" = bullish.\n\n'
|
|
f"Headlines:\n{numbered}\n\n"
|
|
'Respond ONLY with a JSON array, one object per headline, in order:\n'
|
|
'[{"id": 1, "score": 0.5}, {"id": 2, "score": -0.3}, ...]'
|
|
)
|
|
try:
|
|
response = self.gemini_client.models.generate_content(
|
|
model=self.GEMINI_MODEL,
|
|
contents=prompt,
|
|
config=genai_types.GenerateContentConfig(
|
|
response_mime_type="application/json",
|
|
temperature=0.0,
|
|
),
|
|
)
|
|
data = json.loads(response.text)
|
|
scores: Dict[str, float] = {}
|
|
for entry in data:
|
|
idx = int(entry.get("id", 0)) - 1
|
|
if 0 <= idx < len(headlines):
|
|
raw = float(entry.get("score", 0.0))
|
|
scores[headlines[idx]] = max(-1.0, min(1.0, raw))
|
|
return scores
|
|
except Exception as e:
|
|
logger.warning(f"Gemini batch scoring failed ({len(headlines)} headlines): {e}")
|
|
return {}
|
|
|
|
def _analyze_headline_sentiment(self, headline: str) -> float:
|
|
"""
|
|
Score a single headline -1.0 to +1.0 via Google Gemini.
|
|
Falls back to keyword sentiment if the API is unavailable or fails.
|
|
Cached per-headline so repeat calls cost zero tokens.
|
|
"""
|
|
cache_key = hash(headline)
|
|
if cache_key in self._sentiment_cache:
|
|
return self._sentiment_cache[cache_key]
|
|
|
|
if self.gemini_client is None:
|
|
score = self._fallback_keyword_sentiment(headline)
|
|
self._sentiment_cache[cache_key] = score
|
|
return score
|
|
|
|
prompt = (
|
|
"You are an expert Forex fundamental analyst. "
|
|
"Analyze this financial news headline:\n"
|
|
f'"{headline}"\n\n'
|
|
"Determine the sentiment score on the primary currency mentioned.\n"
|
|
"-1.0 means extremely bearish (rate cuts, recession, weak data).\n"
|
|
" 1.0 means extremely bullish (rate hikes, strong growth, beats).\n"
|
|
" 0.0 means neutral or irrelevant.\n\n"
|
|
'Respond ONLY with valid JSON: {"score": 0.85}'
|
|
)
|
|
|
|
try:
|
|
response = self.gemini_client.models.generate_content(
|
|
model=self.GEMINI_MODEL,
|
|
contents=prompt,
|
|
config=genai_types.GenerateContentConfig(
|
|
response_mime_type="application/json",
|
|
temperature=0.0,
|
|
),
|
|
)
|
|
data = json.loads(response.text)
|
|
score = max(-1.0, min(1.0, float(data.get("score", 0.0))))
|
|
except Exception as e:
|
|
logger.debug(f"Gemini API failed for '{headline[:60]}': {e}")
|
|
score = self._fallback_keyword_sentiment(headline)
|
|
|
|
self._sentiment_cache[cache_key] = score
|
|
return score
|
|
|
|
def _fallback_keyword_sentiment(self, headline: str) -> float:
|
|
"""Original keyword-based fallback if Gemini API is unavailable or fails."""
|
|
headline_lower = headline.lower()
|
|
bullish_score = sum(1 for kw in self.BULLISH_KEYWORDS if kw in headline_lower)
|
|
bearish_score = sum(1 for kw in self.BEARISH_KEYWORDS if kw in headline_lower)
|
|
|
|
total = bullish_score + bearish_score
|
|
if total == 0:
|
|
return 0.0
|
|
|
|
return (bullish_score - bearish_score) / total
|
|
|
|
def _extract_currencies(self, headline: str) -> List[str]:
|
|
headline_lower = headline.lower()
|
|
found = []
|
|
|
|
for currency, keywords in self.CURRENCY_KEYWORDS.items():
|
|
if currency.lower() in headline_lower:
|
|
found.append(currency)
|
|
continue
|
|
for kw in keywords:
|
|
if kw in headline_lower:
|
|
found.append(currency)
|
|
break
|
|
|
|
return list(set(found))
|
|
|
|
def _extract_keywords(self, headline: str) -> List[str]:
|
|
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]:
|
|
if not date_str:
|
|
return None
|
|
|
|
formats = [
|
|
"%a, %d %b %Y %H:%M:%S %z",
|
|
"%a, %d %b %Y %H:%M:%S %Z",
|
|
"%Y-%m-%dT%H:%M:%S%z",
|
|
"%Y-%m-%d %H:%M:%S",
|
|
]
|
|
for fmt in formats:
|
|
try:
|
|
dt = datetime.strptime(date_str.strip(), fmt)
|
|
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]:
|
|
pairs = {
|
|
"USD": ["EURUSD=X", "GBPUSD=X", "USDJPY=X", "USDCHF=X", "AUDUSD=X", "USDCAD=X", "NZDUSD=X"],
|
|
"EUR": ["EURUSD=X", "EURGBP=X", "EURJPY=X", "EURCHF=X"],
|
|
"GBP": ["GBPUSD=X", "EURGBP=X", "GBPJPY=X"],
|
|
"JPY": ["USDJPY=X", "EURJPY=X", "GBPJPY=X", "AUDJPY=X", "CADJPY=X"],
|
|
"CHF": ["USDCHF=X", "EURCHF=X"],
|
|
"AUD": ["AUDUSD=X", "AUDJPY=X"],
|
|
"CAD": ["USDCAD=X", "CADJPY=X"],
|
|
"NZD": ["NZDUSD=X"],
|
|
}
|
|
return pairs.get(currency, []) |