diff --git a/docs/user_guide.html b/docs/user_guide.html
index 2db2087..a4a321a 100644
--- a/docs/user_guide.html
+++ b/docs/user_guide.html
@@ -130,6 +130,7 @@
/scan | Run an immediate analysis of the primary timeframe. |
/autoscan | Toggle automatic scanning (M5 / M15 / H1 / H4 at each candle close). |
/status | Uptime, auto-scan state, WebSocket price-monitor state. |
+/news | Net XAU/USD news sentiment score (informational only — never gates signals). |
/cache · /clearcache | Inspect / reset the market-data cache. |
/help | Command reference. |
diff --git a/telegram_bot.py b/telegram_bot.py
index 41813e6..bb3978a 100755
--- a/telegram_bot.py
+++ b/telegram_bot.py
@@ -19,6 +19,8 @@ WebSocket: Streams live ticks from the streaming provider — config.json
EMA200) cached from the last boundary scan, triggers an
immediate M15 scan and sends a Level Break Alert.
5-minute cooldown prevents spam.
+News: /news replies with the net XAU/USD news sentiment score
+ (display-only — news never gates signals).
Owner lock: All commands and buttons only respond to TELEGRAM_CHAT_ID
(the owner). Everyone else gets a polite refusal — the bot
@@ -69,6 +71,7 @@ from app_config import (
telegram_channel_id,
)
from providers import BaseDataProvider, make_data_provider
+from news_engine import FundamentalContext, NewsEngine
from pair_signals import PAIR_PRODUCERS
from signal_aggregator import SignalAggregator, SignalReport
@@ -951,6 +954,7 @@ _HELP_TEXT = (
"/scan — Quick scan menu\n"
"/autoscan — Toggle auto-scan on/off\n"
"/status — Bot status, WS connection, live prices\n"
+ "/news — Net news sentiment score for XAU/USD\n"
"/cache — Show data cache age per pair/TF\n"
"/clearcache — Wipe data cache, force fresh fetch\n"
"/help — This message\n\n"
@@ -1030,6 +1034,36 @@ async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None
await update.message.reply_text(_status_text(), parse_mode=ParseMode.MARKDOWN)
+def _news_text(context: FundamentalContext, engine: NewsEngine) -> str:
+ net, desc = engine.get_pair_sentiment(XAUUSD, context)
+ lines = [
+ "📰 *XAU/USD News Score*\n",
+ f"*Net score:* `{net:+.2f}` — {desc}",
+ ]
+ # A cold/failed feed yields 0.00 "Neutral" — flag it so a data outage
+ # can't be mistaken for a genuinely neutral read.
+ if not context.news_sentiment:
+ lines.append("_no scored headlines yet — score defaults to 0_")
+ return "\n".join(lines)
+
+
+def _news_report_sync() -> str:
+ engine = NewsEngine(PlatformConfig().news)
+ return _news_text(engine.get_fundamental_context(), engine)
+
+
+@_owner_only
+async def cmd_news(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ msg = await update.message.reply_text("⏳ Fetching news…")
+ loop = asyncio.get_running_loop()
+ try:
+ text = await loop.run_in_executor(_executor, _news_report_sync)
+ except Exception as exc:
+ logger.exception("News fetch failed")
+ text = f"❌ News fetch failed: {_escape_md(str(exc))}"
+ await msg.edit_text(text, parse_mode=ParseMode.MARKDOWN)
+
+
@_owner_only
async def cmd_cache(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
dp = _get_shared_dp()
@@ -1245,6 +1279,7 @@ async def _post_init(app: Application) -> None:
("scan", "Quick scan buttons"),
("autoscan", "Toggle auto-scan on/off"),
("status", "Bot status, WS connection, live prices"),
+ ("news", "XAU/USD net news sentiment score"),
("cache", "Show data cache age per pair/TF"),
("clearcache", "Wipe data cache, force fresh fetch"),
("help", "All commands"),
@@ -1374,6 +1409,7 @@ def main() -> None:
app.add_handler(CommandHandler("scan", cmd_scan))
app.add_handler(CommandHandler("help", cmd_help))
app.add_handler(CommandHandler("status", cmd_status))
+ app.add_handler(CommandHandler("news", cmd_news))
app.add_handler(CommandHandler("autoscan", cmd_autoscan))
app.add_handler(CommandHandler("cache", cmd_cache))
app.add_handler(CommandHandler("clearcache", cmd_clearcache))
diff --git a/test_all.py b/test_all.py
index f8656ca..ebd2254 100644
--- a/test_all.py
+++ b/test_all.py
@@ -1591,7 +1591,7 @@ print("\n═══ Module 7: telegram_bot.py ═══")
from telegram_bot import (
_escape_md, _signal_label, _mtf_line, _indicator_summary,
- format_signal_message, _format_scan_summary,
+ format_signal_message, _format_scan_summary, _news_text,
_seconds_until_boundary, _check_level_breach,
scan_keyboard, SCAN_BUTTONS, SCAN_LOOKUP,
_update_level_cache, _last_prices, _level_cache, _level_cache_ts,
@@ -1881,6 +1881,31 @@ def _():
assert ws_to_pair["XAU/USD"] == XAUUSD
+@test("_news_text bullish score")
+def _():
+ ctx = FundamentalContext(news_sentiment={"XAU": 0.5, "USD": -0.1})
+ text = _news_text(ctx, NewsEngine(PlatformConfig().news))
+ assert "+0.60" in text
+ assert "Bullish" in text
+ assert "no scored headlines" not in text
+
+
+@test("_news_text bearish score")
+def _():
+ ctx = FundamentalContext(news_sentiment={"XAU": -0.4, "USD": 0.1})
+ text = _news_text(ctx, NewsEngine(PlatformConfig().news))
+ assert "-0.50" in text
+ assert "Bearish" in text
+
+
+@test("_news_text degrades on empty context")
+def _():
+ text = _news_text(FundamentalContext(), NewsEngine(PlatformConfig().news))
+ assert "+0.00" in text
+ assert "Neutral" in text
+ assert "no scored headlines" in text
+
+
# ══════════════════════════════════════════════════════════════════════
# MODULE 8: Integration / Cross-module tests
# ══════════════════════════════════════════════════════════════════════