diff --git a/CLAUDE.md b/CLAUDE.md index a55da2c..126208f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ Every numeric threshold in `config.py` and `pair_signals/xauusd.py` was backtest ## Commands ```bash -python test_all.py # QA suite: 406 tests, 27 modules, no network (mocked) +python test_all.py # QA suite: 414 tests, 27 modules, no network (mocked) python main.py --pair XAUUSD --timeframe H1 --detail # CLI signal report (--json for full dump, # --panel to run the advisory LLM agent panel on-demand) python telegram_bot.py # the bot (refuses to boot without credentials) @@ -58,8 +58,8 @@ Signal currency everywhere is `SignalStrength` (−2..+2). DataFrames are UTC tz - **`engine.py`** — `TechnicalEngine`: 9 scored indicators (ema_trend, rsi, macd, bollinger, adx, roc, keltner, divergence, candlestick) + ATR as context. Regime classification (trending_up/down, `trending` = mixed/direction-UNKNOWN, transitioning, ranging) drives per-indicator weight multipliers and regime-aware RSI reinterpretation. Compute order matters (ATR first; MACD always computed even when not a pillar — divergence needs the histogram). Private series ride on the result as `_underscore` attrs; scorers/formatters skip anything `_`-prefixed or weight-0. - **`signal_aggregator.py`** — MTF agreement, diminishing-decay penalty system, exhaustion hard block, quality grading, then an ordered chain of post-grade gates (session, XAU SELL quality/vol/hour, regime blocks, M5 noise gates), then three setup generators: XAU ATR (A+/A), generic swing-based, plus the M5 pullback (`XAU_PB`) and chart-pattern (`XAU_CP`, `chart_patterns.py`) entry paths. The PB/CP generators run AFTER the gate chain and force quality to "B"; the CP path deliberately accepts grade-F composites and re-checks only session/day/big-candle (via warning prefixes) plus its own SELL/vol/RSI gates — it does NOT re-check exhaustion or the M5 min-score/MTF gates. **This is validated behavior, not a bug** (2026-08-04 audit: 226/586 trades in the shipped M5 12mo book are XAU_CP, +38.9R, 104 of them exhaustion-flagged and net-positive) — changing it is a rule-1 revalidation event. `TradeSetup.strategy` selects trailing params; trail params are stamped on the setup from the *producing* config so per-TF overrides reach formatters. - **`pair_signals/`** — per-pair config factories. **Always use the factory (`produce_signal` / `make_config`), never bare `PlatformConfig()`** — the factory applies the config.json overlay first, then the validated per-TF deltas (M5 hour-9 block, M15 trail arm 0.70, M15 SELL-hour block, M5 SELL-in-transitioning block, H1 all-shorts block (2026-09-03), etc.; evidence in the docstring). A test enforces factory ≡ inline-config equivalence. Confirmation map: M5→[M15,H1], M15→[M5,H1], H1→[M15,H4], H4→[H1,D1]. -- **`signal_log.py` / `advisory_outcome.py`** — SQLite signal-history logger (stdlib only). Hooked into `SignalAggregator.analyze_pair` only (the live entry point — the backtester never writes here, preserving parity), so every live scan from every surface (Telegram `telegram`, CLI `cli`, dashboard `dashboard`) lands one row in `logs/signals.db`: signal/grade/price, MTF + trade-setup + reasons JSON, full per-TF indicator snapshots, the last 14 closed OHLCV candles per fetched TF, and the news at scan time (event risk + calendar events + scored headlines, re-fetched via the NewsEngine class-level caches). Config.json `signal_log` block (`enabled`, `path`, `log_blocked_scans`; env `SIGNAL_LOG_ENABLED`/`SIGNAL_LOG_PATH`/`SIGNAL_LOG_BLOCKED_SCANS`). **Fail-safe by contract** — a logging failure can never break a scan. Panel fields arrive post-hoc: `log_signal` stashes the rowid and `agent_panel.attach_panel` writes verdict + creation/cache provenance + payload hash onto the same row. **Advisory outcomes (2026-09):** EVERY row with a trade setup is seeded at log time (not only panel rows); same-setup re-logs within 60 min from any surface (`setup_key` = tf|direction|entry|stop|strategy) are `DUPLICATE` rows pointing at the canonical row via `advisory_event_id`, so sum `advisory_r_multiple` over `RESOLVED` rows only. The versioned model advances from later stored closed-candle snapshots (next-open reanchoring, intrabar peak with close-only ratchet, SL-first, scheduled weekend/21:00-UTC closures allowed, 300-bar timeout, $0.30/$0.65 views); when a snapshot no longer overlaps the outcome's last bar it rebuilds the stream from every stored snapshot of that TF before giving up. It is a cleaned-mid evidence model, not execution P&L. **Post-gate truth columns:** `gate_reason` (the warning that removed the setup), `actionable` (grade ≥ C AND setup — the `action` text is written BEFORE the gate chain and can say "STRONG SELL — High-quality setup" on a D row), `cleaned_warnings_json` (what the XAU_CP acceptance stripped), `bar_range_median` (= the misnamed `spread` column: median High-Low, NOT a broker spread), `scan_kind` (`blocked` weekend/day early returns carry no news/candles), `config_fingerprint` (gate config hash — surfaces config drift between processes). **News is content-addressed:** `headlines_json`/`events_json` are interned into `news_snapshots` (they were 73% of a 667 MB DB); read through the `signals_with_news` view or `signal_log.read_news`. Readers must open with `mode=ro`, never `immutable=1` (blind to WAL rows). Tools: `analyze_signal_log.py` (`--stored-candles` DB-only replay with event/opportunity/one-position books + persona scorecard, `--parity` telegram-vs-dashboard divergence report), `scripts/backfill_signal_outcomes.py` (seed/dedup/advance legacy rows), `scripts/migrate_signal_log_news.py` (intern legacy blobs; `--vacuum` with both writers stopped). -- **`news_engine.py`** — Forex Factory calendar (MyFXBook RSS fallback) + RSS keyword sentiment with gold-inverse keywords. Class-level caches (fresh engine per scan). Optional DeepSeek enrichment via `llm_client.py` (requests-only, no SDK): batched headline sentiment overriding keyword scores, gold-direction on release surprises, and a market-summary blurb (`SignalReport.news_summary`) — **display/warning-only, keyword fallback on any failure, inert without a key**; prompts are static module constants (DeepSeek prefix-cache pricing) and requests send `thinking: disabled` (V4-Flash reasoning otherwise blows the timeout). By XAU-tuned default, event risk and sentiment **warn but do not gate signals** (`event_risk_caps_signals=False`, `sentiment_affects_signals=False`) — the LLM does not change that. +- **`signal_log.py` / `advisory_outcome.py`** — SQLite signal-history logger (stdlib only). Hooked into `SignalAggregator.analyze_pair` only (the live entry point — the backtester never writes here, preserving parity), so every live scan from every surface (Telegram `telegram`, CLI `cli`, dashboard `dashboard`) lands one row in `logs/signals.db`: signal/grade/price, MTF + trade-setup + reasons JSON, full per-TF indicator snapshots, the last 14 closed OHLCV candles per fetched TF, and the news at scan time (event risk + calendar events + scored headlines, re-fetched via the NewsEngine class-level caches). Config.json `signal_log` block (`enabled`, `path`, `log_blocked_scans`; env `SIGNAL_LOG_ENABLED`/`SIGNAL_LOG_PATH`/`SIGNAL_LOG_BLOCKED_SCANS`). **Fail-safe by contract** — a logging failure can never break a scan. Panel fields arrive post-hoc: `log_signal` stashes the rowid and `agent_panel.attach_panel` writes verdict + creation/cache provenance + payload hash onto the same row. **Advisory outcomes (2026-09):** EVERY row with a trade setup is seeded at log time (not only panel rows); same-setup re-logs within 60 min from any surface (`setup_key` = tf|direction|entry|stop|strategy) are `DUPLICATE` rows pointing at the canonical row via `advisory_event_id`, so sum `advisory_r_multiple` over `RESOLVED` rows only. The versioned model advances from later stored closed-candle snapshots (next-open reanchoring, intrabar peak with close-only ratchet, SL-first, scheduled weekend/21:00-UTC closures allowed, 300-bar timeout, $0.30/$0.65 views); when a snapshot no longer overlaps the outcome's last bar it rebuilds the stream from every stored snapshot of that TF before giving up. It is a cleaned-mid evidence model, not execution P&L. **Post-gate truth columns:** `gate_reason` (the warning that removed the setup), `actionable` (grade ≥ C AND setup — the `action` text is written BEFORE the gate chain and can say "STRONG SELL — High-quality setup" on a D row), `cleaned_warnings_json` (what the XAU_CP acceptance stripped), `bar_range_median` (= the misnamed `spread` column: median High-Low, NOT a broker spread), `scan_kind` (`blocked` weekend/day early returns carry no news/candles), `config_fingerprint` (gate config hash — surfaces config drift between processes). **News is content-addressed:** `headlines_json`/`events_json` are interned into `news_snapshots` (they were 73% of a 667 MB DB); read through the `signals_with_news` view or `signal_log.read_news`. Readers must open with `mode=ro`, never `immutable=1` (blind to WAL rows). Tools: `analyze_signal_log.py` (`--stored-candles` DB-only replay with event/opportunity/one-position books + persona scorecard, `--news` news-sentiment lead/lag + extremes + outcome buckets, `--parity` telegram-vs-dashboard divergence report), `scripts/backfill_signal_outcomes.py` (seed/dedup/advance legacy rows), `scripts/migrate_signal_log_news.py` (intern legacy blobs; `--vacuum` with both writers stopped). +- **`news_engine.py`** — Forex Factory calendar (MyFXBook RSS fallback) + RSS keyword sentiment with gold-inverse keywords. Class-level caches (fresh engine per scan). Optional DeepSeek enrichment via `llm_client.py` (requests-only, no SDK): batched headline sentiment overriding keyword scores, gold-direction on release surprises, and a market-summary blurb (`SignalReport.news_summary`) — **display/warning-only, keyword fallback on any failure, inert without a key**; prompts are static module constants (DeepSeek prefix-cache pricing) and requests send `thinking: disabled` (V4-Flash reasoning otherwise blows the timeout). By XAU-tuned default, event risk and sentiment **warn but do not gate signals** (`event_risk_caps_signals=False`, `sentiment_affects_signals=False`) — the LLM does not change that. **Headline pool (2026-09-04):** only headlines ≤ 4 h old with a parsable publish time (`headline_max_age_seconds=14400`, undated items dropped), and `get_fundamental_context` drops headlines whose every relevant score is inside ±0.1 (`_is_neutral_headline`, `NEUTRAL_HEADLINE_THRESHOLD`) before the per-currency mean — so `news_sentiment` from that date is a mean over directional headlines only (bolder values; log comparisons must split at the deploy date). The market summary prompt writes for someone who has never traded (no acronyms), sends headlines newest-first with the platform's own gold direction, and caps at 400 chars. **Verdict 2026-09-04** (`backtest/research/news_sentiment_value_2026-09-04.md`): `news_sentiment` is a trailing-24h echo of price (r≈0.67 with the past day, ≈0.03 with the next; flips 44 h after tops / 16 h after bottoms) — both gating flags stay False; re-check with `analyze_signal_log.py --stored-candles --news`. - **`agent_panel.py`** — ai-hedge-fund-style advisory panel: 4 DeepSeek persona calls (macro/technical/risk/contrarian, concurrent on a panel-private pool) + 1 synthesizer producing an **ADVISORY-ONLY** consensus (`AGREE/NEUTRAL/OPPOSE` + confidence + rationale) written onto `SignalReport.panel_*` fields. Attached **post-hoc** by consumers (`telegram_bot` alert path, `main --panel`, `dashboard_publisher`) — never runs inside `analyze_from_dataframes` (backtest parity, test-locked) and no gate reads its fields. Persona payloads are role-filtered: macro gets high-impact events plus only code-scored directional news; technical gets strategy/pattern, primary indicators, MTF, levels, and ATR-normalized candles; risk gets setup strategy, strategy cost assumption, trailing semantics, volatility/events/warnings (never the median candle range as spread); contrarian gets directional news, setup, timestamped latest-closed price, indicators, reasons, and warnings. Spend is bounded by exact cache, full-evidence-identity TTL reuse, and `max_panels_per_day`; changed evidence never inherits a direction-only cached verdict (test-locked — which also means TTL reuse effectively never fires live because price/candles change every bar; the daily cap is the real spend bound). Verdict provenance logs creation time, `MISS`/`EXACT_HIT`/`TTL_REUSE`, and a SHA-256 payload hash. Prompts forbid outside knowledge and outputs remain short. **OPPOSE guard (2026-09):** the 3-week signal-log scorecard showed the contrarian/risk/technical personas oppose almost every trend entry (chair said OPPOSE 73% of the time; opposed trades made +30R, PF 2.6) while the macro persona was the only informative voice (macro=support → 88% win). `_apply_oppose_guard` therefore downgrades a chair OPPOSE to NEUTRAL unless macro opposes, or technical opposes while macro is not supportive; the synthesizer prompt states the same rule. Re-score with `analyze_signal_log.py --stored-candles` (persona scorecard) before changing it again. **Verdict source (2026-09-03, owner decision):** all four personas still run and are logged (`panel_takes_json`), but `agent_panel.verdict_source` defaults to `"macro"` — the alert verdict is the macro persona's stance (support/neutral/oppose → AGREE/NEUTRAL/OPPOSE) and the synthesizer call is skipped (4 LLM calls per panel instead of 5); `"chair"` restores the four-voice synthesizer + OPPOSE guard. Re-score personas monthly with `analyze_signal_log.py --stored-candles`; weights are not to be tuned before ~300 graded outcomes with a held-out half. - **`market_outlook.py`** — hourly DeepSeek market brief for XAU/USD, using ATR-normalized M15/H1/H4 candles, visible indicator states, levels, news, and recent signal history (read `mode=ro`). It returns a written **ADVISORY-ONLY** bias/setup-forming assessment and persists it independently in `outlooks` within `logs/signals.db` together with the news, indicator/regime/level, and candle evidence sent to the model. It may be sent post-scan by Telegram or requested with `main.py --outlook`; it never imports or runs inside the signal path. Hourly dedup is by UTC hour BUCKET (the old `now − last < 3600` rule dropped every other hour because the H1 loop fires at boundary+10 s+scan time) plus its own UTC-day cap (default 12); every silent early return logs `market outlook: skipped (...)` and an empty LLM reply logs a warning — a dark feature must be visible in `logs/telegram.log`. Provider/news failures leave a safe partial brief while LLM/database failures degrade to no outlook. - **`telegram_bot.py`** — owner-locked bot + subscriber channel; candle-boundary auto-scan loops (M5/M15/H1/H4, boundary+10 s), alert quality threshold + fingerprint dedup (committed only after a successful send; the fingerprint is signal+direction — grade is NOT part of it, a grade UPGRADE re-alerts, an A↔B flicker does not), 📰 LLM market-context line on alerts when available, WebSocket level-breach monitor fed by the streaming provider (`data_providers.streaming`, falling back to the active provider; auto-disabled when the resolved provider can't stream or has no API key — Oanda does not stream, Tiingo does). The breach handler reuses this bar's M15 report (`_cached_or_scan`, keyed by closed-bar epoch) instead of rescanning — it used to log 3.5 M15 rows per candle. Rotating log: `logs/telegram.log`, 3-h rotation × 240 backups. diff --git a/analyze_signal_log.py b/analyze_signal_log.py index e20b928..c5d55f7 100644 --- a/analyze_signal_log.py +++ b/analyze_signal_log.py @@ -38,6 +38,14 @@ Three lenses: the "opportunity" (first event per tf+direction cluster) and one-position-per-timeframe views. +6. NEWS SENTIMENT (--news, with --stored-candles) — is the logged + news_sentiment score a leading or a trailing signal? Hourly mean + sentiment vs past/forward price moves at 1/4/8/24 h (split-half on + the forward number), the daily open->close read, what it said at the + window's high and low, and resolved-event R bucketed by + direction x news bucket x macro-persona stance. Warning-only feature: + nothing here gates a signal. + The DB is opened read-only (mode=ro — WAL-visible; the older immutable=1 URI silently missed the newest rows). Bar data for the default replay comes from the active provider via the backtester's fetch_history @@ -50,6 +58,7 @@ Usage: python analyze_signal_log.py --no-fetch # cache only python analyze_signal_log.py --parity # surface parity only python analyze_signal_log.py --stored-candles # DB-only replay + python analyze_signal_log.py --stored-candles --news # + news lens """ import argparse @@ -104,6 +113,8 @@ class LogRow: advisory_status: str = "" advisory_r: Optional[float] = None scan_kind: str = "" + news_sentiment: Optional[float] = None + event_risk: str = "" @dataclass @@ -132,7 +143,14 @@ _COLS = ("id, logged_at, source, timeframe, signal, score, quality," # Columns added by the 2026-09 observability work; absent on older DBs. _OPTIONAL_COLS = ("gate_reason", "actionable", "config_fingerprint", "advisory_event_id", "advisory_outcome_status", - "advisory_r_multiple", "scan_kind") + "advisory_r_multiple", "scan_kind", "news_sentiment", + "event_risk") + +# The aggregator's own "News:" reason threshold — the band outside which a +# logged sentiment score is called bullish/bearish for gold. +NEWS_BULL = 0.1 +NEWS_BEAR = -0.1 +NEWS_LAGS_H = (1, 4, 8, 24) def open_readonly(db_path: str) -> sqlite3.Connection: @@ -184,6 +202,10 @@ def load_rows(db_path: str) -> List[LogRow]: advisory_status=extra.get("advisory_outcome_status") or "", advisory_r=extra.get("advisory_r_multiple"), scan_kind=extra.get("scan_kind") or "", + news_sentiment=(float(extra["news_sentiment"]) + if extra.get("news_sentiment") is not None + else None), + event_risk=extra.get("event_risk") or "", )) return out finally: @@ -232,6 +254,12 @@ def cluster_setup_rows( return events +def _row_index(events: Sequence[SignalEvent]) -> Dict[int, SignalEvent]: + """row id -> the SignalEvent it belongs to (every row of an event + shares that event's replayed outcome).""" + return {r.id: ev for ev in events for r in ev.rows} + + # ── Trade construction + replay (backtester realism, reused code) ──── @@ -380,10 +408,7 @@ def panel_scorecard( Verdicted rows with a setup join their SignalEvent outcome; rows without a setup are scored on the +4h forward return instead. """ - by_row: Dict[int, SignalEvent] = {} - for ev in events: - for r in ev.rows: - by_row[r.id] = ev + by_row = _row_index(events) out = [] for row in rows: @@ -725,10 +750,7 @@ def persona_scorecard(rows: Sequence[LogRow], events: Sequence[SignalEvent], db_path: str) -> List[dict]: """Per persona × stance: n, mean R and win rate of the underlying event (needs panel_takes_json — read straight from the DB).""" - by_row: Dict[int, SignalEvent] = {} - for ev in events: - for r in ev.rows: - by_row[r.id] = ev + by_row = _row_index(events) con = open_readonly(db_path) agg: Dict[Tuple[str, str], List[float]] = {} try: @@ -753,6 +775,243 @@ def persona_scorecard(rows: Sequence[LogRow], events: Sequence[SignalEvent], for (p, s), v in sorted(agg.items())] +# ── News sentiment lead/lag ─────────────────────────────────────────── + + +def _hourly_sentiment(rows: Sequence[LogRow]) -> pd.Series: + """Mean logged news_sentiment per UTC hour (priced rows only).""" + buckets: Dict[pd.Timestamp, List[float]] = {} + for r in rows: + if not r.price or r.news_sentiment is None: + continue + ts = pd.Timestamp(r.logged_at) + ts = ts.tz_localize("UTC") if ts.tzinfo is None else ts.tz_convert("UTC") + buckets.setdefault(ts.floor("h"), []).append(float(r.news_sentiment)) + if not buckets: + return pd.Series(dtype=float) + return pd.Series({k: sum(v) / len(v) + for k, v in buckets.items()}).sort_index() + + +def _stream_frame(streams: Dict[str, List[dict]], tf: str) -> pd.DataFrame: + """Stored bars for one timeframe as an OHLC frame indexed by UTC ts.""" + bars = streams.get(tf) or [] + if not bars: + return pd.DataFrame() + df = pd.DataFrame(bars) + if "timestamp" not in df.columns or "close" not in df.columns: + return pd.DataFrame() + df.index = pd.to_datetime(df["timestamp"], utc=True) + keep = [c for c in ("open", "high", "low", "close") if c in df.columns] + return df[keep].astype(float).sort_index() + + +def _corr(a: pd.Series, b: pd.Series) -> Optional[float]: + """Pearson correlation of the overlapping, finite pairs (None if NaN).""" + try: + c = float(a.corr(b)) + except (TypeError, ValueError): + return None + return None if math.isnan(c) else c + + +def news_lead_lag(rows: Sequence[LogRow], streams: Dict[str, List[dict]], + tf: str = "1h") -> dict: + """Does the logged sentiment LEAD price, or only follow it? + + For each lag k (hours) the hourly mean sentiment is correlated with + the PAST move (open_t - open_{t-k}) and the FORWARD move + (close_{t+k-1} - open_t) — the scan in hour H sees the bar that + closed at H, so the forward window starts at bar H's open. A high + past-correlation with a ~zero forward-correlation means the score is + a trailing return in disguise. Split-half on the forward number + guards against reading one regime as an edge. + """ + sent = _hourly_sentiment(rows) + df = _stream_frame(streams, tf) + if sent.empty or df.empty: + return {"n_hours": 0} + joined = df.join(pd.DataFrame({"sent": sent}), how="inner").dropna( + subset=["sent"]) + n = len(joined) + if n < 8: + return {"n_hours": n} + s = joined["sent"] + half = n // 2 + first, second = joined.index[:half], joined.index[half:] + corr_past, corr_fwd, corr_split = {}, {}, {} + for k in NEWS_LAGS_H: + past = (df["open"] - df["open"].shift(k)).reindex(joined.index) + fwd = (df["close"].shift(-(k - 1)) - df["open"]).reindex(joined.index) + corr_past[k] = _corr(s, past) + corr_fwd[k] = _corr(s, fwd) + corr_split[k] = (_corr(s.loc[first], fwd.loc[first]), + _corr(s.loc[second], fwd.loc[second])) + day_sent = sent.groupby(sent.index.floor("D")).mean() + day = df.resample("1D").agg({"open": "first", "close": "last"}).dropna() + move = day["close"] - day["open"] + # .corr aligns on the index; shifting over the BAR index keeps + # "previous/next" meaning the previous/next day that actually traded. + daily = {"same": _corr(day_sent, move), + "prev": _corr(day_sent, move.shift(1)), + "next": _corr(day_sent, move.shift(-1))} + return {"n_hours": n, "corr_past": corr_past, "corr_fwd": corr_fwd, + "corr_fwd_split": corr_split, "daily": daily} + + +def news_extremes(rows: Sequence[LogRow], streams: Dict[str, List[dict]], + tf: str = "1h") -> dict: + """What the sentiment said at the window's high and low. + + Plus how long AFTER the top the first bearish hour arrived (and vice + versa at the bottom) — a turn called late is a trailing indicator. + """ + df = _stream_frame(streams, tf) + if df.empty or "high" not in df.columns or "low" not in df.columns: + return {} + sent = _hourly_sentiment(rows) + hi_ts = df["high"].idxmax() + lo_ts = df["low"].idxmin() + hi_px = float(df["high"].loc[hi_ts]) + lo_px = float(df["low"].loc[lo_ts]) + + def _at(ts): + v = sent.get(ts) + return None if v is None or (isinstance(v, float) and math.isnan(v)) \ + else float(v) + + def _first(from_ts, ref_px, sign): + for ts, val in sent.items(): + if ts < from_ts: + continue + if (val <= NEWS_BEAR) if sign < 0 else (val >= NEWS_BULL): + close = df["close"].asof(ts) + if close is None or (isinstance(close, float) + and math.isnan(close)): + continue + return {"lag_h": (ts - from_ts).total_seconds() / 3600.0, + "price_move": float(close) - ref_px} + return None + + return { + "high": {"ts": hi_ts.isoformat(), "price": hi_px, + "sentiment": _at(hi_ts)}, + "low": {"ts": lo_ts.isoformat(), "price": lo_px, + "sentiment": _at(lo_ts)}, + "bear_after_high": _first(hi_ts, hi_px, -1), + "bull_after_low": _first(lo_ts, lo_px, +1), + } + + +def news_outcome_buckets(rows: Sequence[LogRow], + events: Sequence[SignalEvent], + db_path: str) -> List[dict]: + """Resolved events bucketed by (direction, news bucket, macro stance). + + The macro persona is the only voice the 2026-09 scorecard found + informative, so it rides along as a third axis here. + """ + by_row = _row_index(events) + macro: Dict[int, str] = {} + con = open_readonly(db_path) + try: + for rid, takes in con.execute( + "SELECT id, panel_takes_json FROM signals" + " WHERE panel_takes_json IS NOT NULL ORDER BY id"): + ev = by_row.get(rid) + if ev is None or ev.representative.id in macro: + continue + try: + parsed = json.loads(takes) + except (TypeError, ValueError): + continue + stance = "none" + for t in parsed or []: + if isinstance(t, dict) and t.get("persona") == "macro": + stance = t.get("stance") or "none" + break + macro[ev.representative.id] = stance + finally: + con.close() + + agg: Dict[Tuple[str, str, str], List[float]] = {} + for ev in events: + if ev.r_headline is None: + continue + rep = ev.representative + s = rep.news_sentiment + if s is None: + bucket = "neutral" + elif s >= NEWS_BULL: + bucket = "bull" + elif s <= NEWS_BEAR: + bucket = "bear" + else: + bucket = "neutral" + direction = (rep.setup or {}).get("direction") or "?" + agg.setdefault((direction, bucket, macro.get(rep.id, "none")), + []).append(ev.r_headline) + return [{"direction": d, "bucket": b, "macro": m, "n": len(v), + "sum_r": sum(v), "mean_r": sum(v) / len(v), + "win_rate": sum(1 for x in v if x > 0) / len(v)} + for (d, b, m), v in sorted(agg.items())] + + +def _fmt_c(c: Optional[float], nd: int = 3) -> str: + return " n/a " if c is None else f"{c:+.{nd}f}" + + +def print_news(lead_lag: dict, extremes: dict, buckets: Sequence[dict], + tf: str = "1h") -> None: + print("\n── News sentiment (warning-only; hourly mean vs stored " + + tf + " bars) " + "─" * 10) + n = lead_lag.get("n_hours", 0) + if "corr_past" not in lead_lag: + print(f" only {n} joined hour(s) of logged sentiment — " + "need 8+ for a correlation read") + else: + print(f" {n} hours with a logged sentiment joined to a bar") + for k in NEWS_LAGS_H: + a, b = lead_lag["corr_fwd_split"].get(k, (None, None)) + print(f" past {k:2d}h {_fmt_c(lead_lag['corr_past'].get(k))}" + f" fwd {k:2d}h {_fmt_c(lead_lag['corr_fwd'].get(k))}" + f" (halves {_fmt_c(a, 2)} / {_fmt_c(b, 2)})") + d = lead_lag.get("daily") or {} + print(f" daily open->close: same day {_fmt_c(d.get('same'))}" + f" previous {_fmt_c(d.get('prev'))}" + f" next {_fmt_c(d.get('next'))}") + + for side, key, tag in (("high", "bear_after_high", "bearish"), + ("low", "bull_after_low", "bullish")): + info = (extremes or {}).get(side) + if not info: + continue + sv = info.get("sentiment") + head = (f" {side:4} {info['ts'][:16]}Z ${info['price']:.1f} " + f"sentiment {_fmt_c(sv, 2)}") + nxt = (extremes or {}).get(key) + if nxt: + print(f"{head} → first {tag} hour +{nxt['lag_h']:.0f} h later, " + f"price {nxt['price_move']:+.1f}") + else: + print(f"{head} → no {tag} hour logged after it") + + if buckets: + print(f" outcome buckets ({sum(b['n'] for b in buckets)} resolved " + "events):") + print(f" {'dir':5} {'news':7} {'macro':8} {'n':>3} {'total':>8} " + f"{'mean':>8} {'win':>5}") + for b in buckets: + print(f" {b['direction']:5} {b['bucket']:7} {b['macro']:8} " + f"{b['n']:3d} {_fmt_r(b['sum_r']):>8} " + f"{_fmt_r(b['mean_r']):>8} " + f"{100 * b['win_rate']:4.0f}%") + else: + print(" no resolved events to bucket yet") + print(" Caveat: sentiment ≈ trailing 24h return; buckets inherit the " + "trend confound.") + + def print_report( rows: List[LogRow], events: List[SignalEvent], @@ -892,8 +1151,15 @@ def main(argv: Optional[Sequence[str]] = None) -> int: ap.add_argument("--stored-candles", action="store_true", help="replay on the candle streams stored in the DB " "(no network) instead of provider history") + ap.add_argument("--news", action="store_true", + help="news-sentiment lead/lag + outcome buckets " + "(requires --stored-candles)") args = ap.parse_args(argv) + if args.news and not args.stored_candles: + print("--news requires --stored-candles") + return 2 + rows = load_rows(args.db) if not rows: print(f"No rows in {args.db}") @@ -923,6 +1189,10 @@ def main(argv: Optional[Sequence[str]] = None) -> int: for p in persona_scorecard(rows, events, args.db): print(f" {p['persona']:11} {p['stance']:8} n={p['n']:3d} " f"mean {p['mean_r']:+.3f}R win {100 * p['win_rate']:.0f}%") + if args.news: + print_news(news_lead_lag(rows, streams), + news_extremes(rows, streams), + news_outcome_buckets(rows, events, args.db)) print_parity(parity_report(rows)) if args.csv: export_csv(events, args.csv) diff --git a/backtest/research/news_sentiment_value_2026-09-04.md b/backtest/research/news_sentiment_value_2026-09-04.md new file mode 100644 index 0000000..9f06dff --- /dev/null +++ b/backtest/research/news_sentiment_value_2026-09-04.md @@ -0,0 +1,129 @@ +# News sentiment vs. price — does the news score carry information? — 2026-09-04 + +**Source evidence:** `logs/signals.db` 2026-08-13 → 2026-09-03 (14,683 rows, 13,947 with +a price; candle snapshots from 08-18, full-shape headline blobs from 08-19), read with +`mode=ro`. A continuous H1/M15/M5 candle stream was rebuilt from the stored 14-bar +`candles_json` snapshots (H1: 293 bars, 08-18 05:00 → 09-03 22:00). Headlines were +resolved through `news_snapshots` (4,054 distinct headlines, 2,024 gold-tagged). +Outcomes are the repo's own advisory model (`advisory_r_multiple` on the 144 `RESOLVED` +rows, R at $0.65). One-year context from the Oanda H1 parquet +(`backtest/cache/oanda_XAU_USD_1h_20250814_20260814.parquet`). No backtest: the +backtester runs with news off, and no headline history exists beyond these 3 weeks, so a +replay cannot test news. The same check reruns with +`python analyze_signal_log.py --stored-candles --news`. + +Question asked by the owner: the log reads bullish most of the time — what did the +indicators and the news say when gold fell, and is news scoring a waste? + +## Findings + +**The numeric news score is a rear-view mirror.** `news_sentiment` (= mean gold-headline +score − mean dollar-headline score over the last 12 h) tracks what gold did over the +*previous* day and says nothing stable about the next one. + +| hourly mean sentiment vs. gold return (n≈293 h) | Pearson | +|---|---| +| past 24 h | **+0.67** | +| past 8 h / 4 h / 1 h | +0.32 / +0.20 / +0.07 | +| next 1 h / 4 h / 8 h / 24 h | +0.05 / +0.10 / +0.21 / +0.14 | +| next 8 h, first half (08-18→25) / second half (08-26→09-03) | **−0.37 / +0.12** | +| next 24 h, first half / second half | −0.12 / −0.09 | +| daily mean vs same-day / previous-day / next-day move | +0.39 / +0.37 / **+0.03** | +| *change* in sentiment vs next 4 h / 8 h / 24 h | −0.09 / −0.03 / −0.01 | + +Partial correlation with the next 8 h after removing the past-24 h return is +0.26 pooled +but flips sign between halves. Alternative aggregates (gold-specific score only, dropping +price-report headlines, only headlines < 2 h old) show the same shape: past-24 h +correlation 0.59–0.82, no stable forward correlation. A naive "hold in the direction of +sentiment" rule made +$272 over 279 hours vs +$120 buy-and-hold, but trading the *change* +in sentiment lost −$200: the gain is trend persistence (sentiment ≈ trailing return), not +news. + +**Why the log reads bullish.** 62% of rows have sentiment ≥ +0.1, 21% ≤ −0.1. Component +means: gold headlines +0.158, dollar headlines +0.048. The window was a 16% August rally, +so the average headline was bullish because price had been rising. Of the gold-tagged +headlines, 42% merely restate the chart (price-up reports 489, mean +0.37; price-down 366, +mean −0.18) and 18% are long-horizon forecast pieces ("$5,400 target", "$50,000/oz"; 60% +positive, 21% of all positive score mass). The per-headline LLM labels are correct — every +regex-flagged "mis-score" was a correctly scored "yields fall → gold up" headline — which +matches the 78/78 golden set of 2026-08-06. The averaging, the 12 h window and the +headline universe are what destroy the information. + +**What news and indicators said on the way down** (top $4,697 on 08-25 00:00 UTC, low +$4,283 on 09-02 03:00; an 8.8% drop in a week): + +| moment | price | news sentiment | engine (M15) | +|---|---|---|---| +| top, 08-25 00:00 | 4,697 | +0.30, rising to **+0.44** two hours after the top (window high) | ranging, composite +5.9; 9 BUY setups that day: −7.4R, 11% win | +| 08-26/27 | −$60 | still +0.15…+0.37; first bearish hour 08-26 20:00, **44 h after the top**, price already −$102 | trending_down 134/138 rows, composite −4.6/−7.8; SELL setups 0/3 on 08-27 | +| 08-28 Warsh speech 13:00–16:00 | −$122 in 3 h, −$140 day | +0.24…+0.30 overnight, **0.00 at 13:00**, −0.17 only at 16:00; summary all morning: "a hawkish tone could pressure gold, dovish hints may lift it" | composite −6.2; SELL setups 4/4, +3.3R | +| 09-01 | −$128 day | −0.04…−0.20, "gold under pressure" all day (correct, describing the move) | trending_down 275 rows, composite −12.2, 51 SELL rows; SELL setups 7/7, +3.5R | +| low, 09-02 03:00 | 4,283 | −0.16 at the low, −0.19 six hours later; first bullish hour 09-02 19:00, **16 h after the low**, price already +$108 | SELL setups at the bottom 0/2, −2.05R | + +Sentiment peaked at the top and troughed at the bottom. The engine also lagged (it is +trend-following) but flipped regime and composite within hours; its SELL setups won on the +trend days and lost at the turns, the same as its BUYs. + +**Outcomes** (144 RESOLVED, +37.8R): BUY 121 / +43.2R / 70% win; SELL 23 / −5.4R / 48%. + +| direction × sentiment bucket (≥+0.15 bull, ≤−0.05 bear) | n | R | mean | win | +|---|---|---|---|---| +| BUY, bullish news | 81 | +32.0 | +0.40 | 73% | +| BUY, neutral | 34 | +10.6 | +0.31 | 68% | +| BUY, bearish news | 6 | +0.65 | +0.11 | 50% | +| SELL, bearish news | 14 | +0.13 | +0.01 | 71% | +| SELL, neutral | 5 | −1.46 | −0.29 | 20% | +| SELL, bullish news | 4 | −4.08 | −1.02 | 0% | + +The splits are the trend confound again ("BUY with bullish news" = "BUY in an uptrend"). +Cleanest counter-example: 08-25, the most bullish news day of the drop period (+0.25), +9 BUYs at the top lost −7.4R. corr(sentiment, R): BUY +0.10, SELL −0.45 (n=23). + +**Macro persona** (reads the same headlines plus high-impact events): macro=support 51 +BUYs → +32.6R, 90% win; macro=neutral 16 → −3.2R, 38%. Within bullish-sentiment BUYs: +support 42 → +25.2R (88%) vs neutral 7 → −2.9R (29%). On 08-25 all 7 panel rows were +macro=neutral ("awaiting PCE, mixed") while the numeric aggregate said +0.25 bullish. +Confounded with regime (64 of 84 supports were trending_up) and n≈16 on the informative +side. Not enough to promote or demote; the standing rule (re-score at ~300 outcomes) +applies. + +**Event risk** cannot be evaluated here: only 4 event windows in the sample +(08-19 17:00, 08-26 12:00, 08-28 13:00, 09-01 13:00), none produced a setup. The standing +evidence remains the 2026-07-11 audit (event gates would cost +4.6…+7.2R/yr). + +**One-year context:** daily moves ≤ −$100 happened 17 times in the year to 2026-08-14; +15 of 47 rolling 3-week windows contained a ≥ 8% drawdown. This sample is one ordinary +rally-then-drop regime. + +**Simulated 4 h window + neutral drop** (the change shipped with this note, on 11,136 +full-shape scans): never leaves a scan empty (median 19 directional headlines, 8 +gold-specific; worst hour 02:00 UTC has < 3 gold headlines 28% of the time), the aggregate +correlates 0.78 with the old one and becomes bolder (share |score| ≥ 0.3 rises 17% → 41%), +with the same lack of forward information (next-24 h corr +0.20; next-8 h halves +−0.22 / −0.10). + +## Method + +- Hourly join convention: a scan in hour H (boundary + 10 s) sees the bar closed at H, + i.e. bar H's open; forward returns run from bar H's open, past returns end there. +- Correlations are Pearson on hourly means (no scipy); halves split at 2026-08-26 00:00. +- Headline classes by regex on the title (price-up / price-down / forecast-style); the + gold-specific score is `score_by_currency.XAU` where present, else `score`. +- Outcome buckets use the representative row's `news_sentiment`; macro stance from + `panel_takes_json`. +- Everything above reproduces from the DB alone; nothing depends on live feeds. + +## Decisions + +- `sentiment_affects_signals` and `event_risk_caps_signals` stay `False`. No gate is + built in either direction (follow or fade): 3 weeks, one regime. +- The headline labelling is not the problem; the pool and the averaging are. Shipped the + same day: `headline_max_age_seconds` 12 h → 4 h with undated items dropped, and + `get_fundamental_context` drops headlines whose every relevant score is inside ±0.1 + (`_is_neutral_headline`). The market-summary prompt now writes for someone who has + never traded, sends headlines newest-first with the platform's own gold direction, and + caps at 400 chars. **`news_sentiment` rows from the deploy date onward are a mean over + directional ≤ 4 h headlines — log comparisons must split at that date.** +- Keep the macro persona running and logging; re-check with `--news` and the persona + scorecard at ~300 outcomes. +- Refuted hypothesis, do not re-propose without new data: "news sentiment leads price". diff --git a/config.py b/config.py index 335026b..635aa8f 100755 --- a/config.py +++ b/config.py @@ -172,8 +172,12 @@ class NewsConfig: # into the danger window while a stale cache says it's clear. context_cache_ttl_seconds: int = 600 # Maximum age of RSS headlines accepted for scoring. Older headlines - # are discarded before keyword analysis and LLM enrichment. - headline_max_age_seconds: int = 43200 # 12 hours + # (and headlines with no parsable publish time) are discarded before + # keyword analysis and LLM enrichment. 12 h → 4 h on 2026-09-04: the + # 3-week signal log showed the 12 h mean was a trailing echo of the + # previous day's price move (see backtest/research/ + # news_sentiment_value_2026-09-04.md). Display/warning-only either way. + headline_max_age_seconds: int = 14400 # 4 hours rss_feeds: List[str] = field(default_factory=lambda: [ "https://investinglive.com/feed/", diff --git a/deploy/README-dashboard.md b/deploy/README-dashboard.md index 9068238..ed2a09d 100644 --- a/deploy/README-dashboard.md +++ b/deploy/README-dashboard.md @@ -100,6 +100,11 @@ the public page, accepted with these guards baked into the PHP — the id must match a headline in the published news.json (arbitrary text can never reach the LLM), each headline's summary is cached forever in `ai/` (≤1 API call per headline, ever), and a file-based daily cap fails **closed** on I/O errors. +Since 2026-09-04 the engine publishes only headlines up to 4 h old whose +score leans one way (neutral items are dropped), so a headline leaves the +whitelist sooner: a dialog left open past that point returns `unknown_id` +("This headline has left the live feed"); summaries already cached in `ai/` +stay reachable. Requirements: `php` + the curl extension (`php -m | grep curl`). diff --git a/docs/beginner_guide.html b/docs/beginner_guide.html index 400e32b..b113031 100644 --- a/docs/beginner_guide.html +++ b/docs/beginner_guide.html @@ -86,6 +86,7 @@
/scan/autoscan/status/news/news/cache · /clearcache/help