news: beginner-language summary, 4 h headline window, drop neutral headlines; --news log report
Verdict from the 3-week signal log (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; most bullish 2 h after the 08-25 top, bearish 44 h later; most bearish at the 09-02 low, bullish 16 h later). Per-headline LLM labels are correct; the 12 h pool and the averaging are what destroy the information. Both gating flags stay False. - news_engine: _LLM_SUMMARY_SYSTEM rewritten for someone who has never traded (no acronyms, two worked examples); summary payload newest-first with the platform's own gold direction; reply cap 300 -> 400 chars. - config: headline_max_age_seconds 43200 -> 14400; RSS items without a parsable pubDate are dropped (they used to be stamped "now" and never aged out). - news_engine.get_fundamental_context drops headlines whose every relevant score is inside +-0.1 (_is_neutral_headline) before the per-currency mean; news_sentiment from this date is a mean over directional <=4 h headlines (log comparisons split here). - static: dashboard copy for the 4 h pool; news_summary.php prompt gets the same style rule. - analyze_signal_log: --news (under --stored-candles) prints sentiment lead/lag with split halves, sentiment at the window high/low with flip lags, and outcome buckets by direction x news x macro stance; LogRow gains news_sentiment/event_risk; _row_index shared. - tests: 406 -> 414 (age window, undated drop, neutral filter, summary prompt/payload/cap, --news helpers, load_rows round-trip); RSS fixtures now carry a pubDate. - docs: user/beginner guides, CLAUDE.md, deploy README. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0169BgFtWjp1SEAa5392Q9jH
This commit is contained in:
+279
-9
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user