Fix news engine (keywords, calendar fallback, RSS URL), remove Telegram guards
News engine: expand sentiment keywords (18%→60% headline coverage), add MyFXBook RSS as fallback calendar source when Forex Factory returns 429, add retry/backoff on calendar API rate limits, update stale ForexLive URL to investinglive.com, add date formats without seconds for MyFXBook RSS. Telegram bot: remove flood control, rate limiter, and dashboard failure tracking — all sends go directly through bot.send_message/edit_message_text. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+145
-23
@@ -9,6 +9,8 @@ A perfect technical setup can be destroyed by an unexpected NFP print.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -81,17 +83,26 @@ class NewsEngine:
|
||||
# Keywords that indicate directional sentiment
|
||||
BULLISH_KEYWORDS = {
|
||||
"rate hike", "hawkish", "strong growth", "beat expectations",
|
||||
"above forecast", "surplus", "expansion", "employment gains",
|
||||
"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", "deficit", "miss expectations", "weak data",
|
||||
"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",
|
||||
}
|
||||
|
||||
# Currency-specific keywords for headline parsing
|
||||
@@ -115,6 +126,8 @@ class NewsEngine:
|
||||
"boe rate", "rba rate", "snb rate", "boc rate",
|
||||
}
|
||||
|
||||
_calendar_backoff_until: float = 0.0
|
||||
|
||||
def __init__(self, config: NewsConfig):
|
||||
self.config = config
|
||||
self._session = requests.Session()
|
||||
@@ -252,18 +265,27 @@ class NewsEngine:
|
||||
|
||||
# ── 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 _fetch_economic_calendar(self, currencies: List[str]) -> 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.
|
||||
"""
|
||||
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}")
|
||||
for fetcher in (self._try_fetch_calendar_api, self._try_fetch_myfxbook_rss):
|
||||
try:
|
||||
events = fetcher()
|
||||
if events:
|
||||
return [e for e in events if e.currency in currencies]
|
||||
except Exception as e:
|
||||
logger.debug(f"Calendar fetch exception ({fetcher.__name__}): {e}")
|
||||
|
||||
logger.info(
|
||||
"Economic calendar data unavailable. "
|
||||
@@ -273,36 +295,134 @@ class NewsEngine:
|
||||
|
||||
def _try_fetch_calendar_api(self) -> List[EconomicEvent]:
|
||||
"""Attempt to fetch from the Forex Factory free JSON calendar."""
|
||||
events = []
|
||||
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"
|
||||
resp = self._session.get(url, timeout=10)
|
||||
|
||||
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 []
|
||||
|
||||
data = resp.json()
|
||||
impact_map = {"High": "high", "Medium": "medium", "Low": "low"}
|
||||
try:
|
||||
root = ET.fromstring(resp.content)
|
||||
except ET.ParseError:
|
||||
return []
|
||||
|
||||
for item in data:
|
||||
events = []
|
||||
for item in root.iter("item"):
|
||||
try:
|
||||
impact = impact_map.get(item.get("impact", ""), "low")
|
||||
|
||||
date_str = item.get("date", "")
|
||||
if not date_str:
|
||||
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
|
||||
event_time = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
||||
|
||||
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"<td[^>]*>(.*?)</td>", 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=item.get("country", ""),
|
||||
currency=currency,
|
||||
impact=impact,
|
||||
event=item.get("title", ""),
|
||||
forecast=item.get("forecast") or None,
|
||||
previous=item.get("previous") or None,
|
||||
actual=item.get("actual") or None,
|
||||
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]:
|
||||
@@ -401,6 +521,8 @@ class NewsEngine:
|
||||
formats = [
|
||||
"%a, %d %b %Y %H:%M:%S %z", # RFC 822 with numeric tz offset
|
||||
"%a, %d %b %Y %H:%M:%S %Z", # RFC 822 with tz abbreviation (GMT/UTC)
|
||||
"%a, %d %b %Y %H:%M %Z", # RFC 822 without seconds (MyFXBook)
|
||||
"%a, %d %b %Y %H:%M %z", # RFC 822 without seconds, numeric offset
|
||||
"%Y-%m-%dT%H:%M:%S%z", # ISO 8601 with offset
|
||||
"%Y-%m-%d %H:%M:%S", # ISO 8601 naive (assume UTC)
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user