bed8b549ac
None of these touch the signal path. The 12-month M15 book (2024-03-31..2025-03-30, $0.65, cached Oanda bars) is byte-identical before and after (md5 c59d3e7e685c3c880b614640adfea74b, 375 trades), and signal/indicator logging to logs/signals.db is unchanged — verified end to end: per-TF indicators for all 3 TFs, 14 candles each, MTF and reasons all still persisted. agent_panel: the >=2-take quorum guards the CHAIR's synthesizer spend, but ran BEFORE the macro branch — so the shipped default (verdict_source="macro", which never calls the synthesizer) silently dropped a good macro verdict whenever the other three personas were slow. Quorum moved below the macro path. analyze_signal_log: _forward_return clamped to the last available bar when the horizon had not elapsed, reporting a near-zero "forward move" for every row logged within `hours` of the end of the data — on every run, blended into the hit-rate/mean-move aggregate. Now returns None. analyze_signal_log: persona_scorecard iterated raw rows through an index that maps EVERY member row of an event, so a re-logged scan appended the same event R twice. One vote per event, matching news_outcome_buckets. Measured inflation on the live DB: 1.03x. signal_log: dedup required a non-negative logged_at gap, but logged_at is stamped before the row wins the write lock (busy_timeout 5 s), so two processes logging one setup at a boundary can invert timestamp order against id order and seed a second event. Now abs(gap); same in backfill_seeds. Zero occurrences in the current 366 setup rows. advisory_outcome: the catch-all turned real evaluator faults into the same UNRESOLVABLE used for legitimate weekend gaps, with nothing in the log. Still fail-safe, now logged. llm_client: only the "cap" warn-once flag was ever reset, so in a daemon the second and every later network/http/parse outage logged at DEBUG only. Cleared on UTC-day roll and re-armed by a recovered call. dashboard.js: renderCards() redraws the selector for every timeframe but was gated on the SELECTED one changing, leaving the other buttons stale (up to 4 h on H4) while renderHero's rail already showed the new value. news_summary.php: atomic_write treated a short write (disk full) as success, publishing a truncated body the cache-first branch then served forever; and the daily cap was charged before the no-key check and before the stampede lock re-checked the cache, so a click burst could exhaust the 150/day budget with zero LLM calls. migrate_signal_log_news: logged_at is ISO with 'T' while SQLite's datetime() uses a space, so string-comparing them put the whole cutoff calendar day on the wrong side and it never pruned. NOTE: this now deletes one day more than before — it only runs when invoked by hand. Tests 414 -> 422 (new Module 28, one per fix). The existing "<2 valid takes" test was retargeted at the chair — the case it actually protects — rather than deleted, with new coverage for macro-alone. Not fixed, recorded for the owner: - Gate 6c's boost condition is textually identical to the C-grade definition, so every C in a "transitioning" regime is promoted to B before gate 6e reads it, making sell_min_quality's exclusion of "C" a no-op on M15/H4 (H1 and M5 are immune by other means). The backtester runs the same code, so every pinned book already includes this — changing it is a rule-1 revalidation event. - Safe-haven/geopolitical headlines score exactly 0.00 in the keyword fallback and are now dropped entirely by the ±0.1 neutral filter, contradicting the platform's own gold doctrine. Bites only when DeepSeek is capped/disabled/failing. Claude-Session: https://claude.ai/code/session_01Ah5EB1goi5inQmSd37Ne6a
178 lines
7.3 KiB
Python
178 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
migrate_signal_log_news.py — move inline news blobs into news_snapshots.
|
||
|
||
Legacy rows store the full RSS headline list (~33 KB) and calendar events
|
||
(~1.2 KB) inline on EVERY scan row; the same blob repeats ~5× because the
|
||
news caches outlive a candle. This script interns each distinct blob once
|
||
(content hash), stamps `headlines_hash` / `events_hash` on the row and
|
||
NULLs the inline columns. Readers use the `signals_with_news` view or
|
||
`signal_log.read_news`.
|
||
|
||
Idempotent and resumable (batched transactions). Run --vacuum with BOTH
|
||
live writers (telegram bot, dashboard publisher) stopped — VACUUM needs
|
||
exclusive access and ~1× the final size of free disk.
|
||
|
||
python scripts/migrate_signal_log_news.py --dry-run
|
||
python scripts/migrate_signal_log_news.py
|
||
python scripts/migrate_signal_log_news.py --vacuum # bots stopped
|
||
"""
|
||
import argparse
|
||
import hashlib
|
||
import os
|
||
import sqlite3
|
||
import sys
|
||
from datetime import datetime, timezone
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
|
||
import app_config # noqa: E402
|
||
from signal_log import SignalLogger # noqa: E402
|
||
|
||
|
||
def _hash(text: str) -> str:
|
||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||
|
||
|
||
def migrate(con: sqlite3.Connection, batch: int = 500) -> dict:
|
||
"""Intern legacy inline blobs; returns counters. Caller owns the
|
||
connection (schema must already exist — open a SignalLogger once)."""
|
||
stats = {"rows": 0, "headlines_interned": 0, "events_interned": 0}
|
||
now = datetime.now(timezone.utc).isoformat()
|
||
while True:
|
||
rows = con.execute(
|
||
"SELECT id, headlines_json, events_json FROM signals"
|
||
" WHERE (headlines_json IS NOT NULL AND headlines_hash IS NULL)"
|
||
" OR (events_json IS NOT NULL AND events_hash IS NULL)"
|
||
" ORDER BY id LIMIT ?", (batch,)).fetchall()
|
||
if not rows:
|
||
break
|
||
con.execute("BEGIN IMMEDIATE")
|
||
try:
|
||
for rowid, headlines, events in rows:
|
||
sets, params = [], []
|
||
if headlines is not None:
|
||
h = _hash(headlines)
|
||
cur = con.execute(
|
||
"INSERT OR IGNORE INTO news_snapshots"
|
||
" (hash, kind, body, first_seen, last_seen)"
|
||
" VALUES (?, 'headlines', ?, ?, ?)",
|
||
(h, headlines, now, now))
|
||
stats["headlines_interned"] += cur.rowcount
|
||
sets += ["headlines_hash=?", "headlines_json=NULL"]
|
||
params.append(h)
|
||
if events is not None:
|
||
h = _hash(events)
|
||
cur = con.execute(
|
||
"INSERT OR IGNORE INTO news_snapshots"
|
||
" (hash, kind, body, first_seen, last_seen)"
|
||
" VALUES (?, 'events', ?, ?, ?)",
|
||
(h, events, now, now))
|
||
stats["events_interned"] += cur.rowcount
|
||
sets += ["events_hash=?", "events_json=NULL"]
|
||
params.append(h)
|
||
params.append(rowid)
|
||
con.execute(
|
||
f"UPDATE signals SET {', '.join(sets)} WHERE id=?",
|
||
params)
|
||
stats["rows"] += 1
|
||
con.commit()
|
||
except Exception:
|
||
con.rollback()
|
||
raise
|
||
return stats
|
||
|
||
|
||
def prune_orphans(con: sqlite3.Connection) -> int:
|
||
cur = con.execute(
|
||
"DELETE FROM news_snapshots WHERE hash NOT IN"
|
||
" (SELECT headlines_hash FROM signals WHERE headlines_hash IS NOT NULL"
|
||
" UNION SELECT events_hash FROM signals WHERE events_hash IS NOT NULL)")
|
||
con.commit()
|
||
return cur.rowcount
|
||
|
||
|
||
def prune_news_older_than(con: sqlite3.Connection, days: int) -> int:
|
||
"""Drop the news reference (not the row) from rows older than N days,
|
||
then delete snapshots nothing references any more."""
|
||
# logged_at is ISO8601 with a 'T' and a +00:00 offset; SQLite's
|
||
# datetime() returns 'YYYY-MM-DD HH:MM:SS'. Comparing them as raw
|
||
# strings put every row from the cutoff CALENDAR DAY on the wrong
|
||
# side ('T' > ' '), so a full day never pruned. Normalise to the
|
||
# same shape first — all logged_at values are UTC (see _iso()).
|
||
cur = con.execute(
|
||
"UPDATE signals SET headlines_hash=NULL, events_hash=NULL"
|
||
" WHERE substr(replace(logged_at, 'T', ' '), 1, 19)"
|
||
" < datetime('now', ?)", (f"-{int(days)} days",))
|
||
con.commit()
|
||
prune_orphans(con)
|
||
return cur.rowcount
|
||
|
||
|
||
def main(argv=None) -> int:
|
||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
|
||
ap.add_argument("--db", default=None)
|
||
ap.add_argument("--dry-run", action="store_true")
|
||
ap.add_argument("--batch", type=int, default=500)
|
||
ap.add_argument("--prune-news-older-than", type=int, default=None,
|
||
metavar="DAYS")
|
||
ap.add_argument("--prune-orphans", action="store_true")
|
||
ap.add_argument("--vacuum", action="store_true",
|
||
help="checkpoint + VACUUM (all writers must be stopped)")
|
||
ap.add_argument("--no-backup", action="store_true")
|
||
args = ap.parse_args(argv)
|
||
path = args.db or app_config.signal_log_path()
|
||
if not os.path.exists(path):
|
||
print(f"no database at {path}")
|
||
return 1
|
||
size0 = os.path.getsize(path)
|
||
if args.dry_run:
|
||
con = sqlite3.connect(f"file:{os.path.abspath(path)}?mode=ro", uri=True)
|
||
n = con.execute(
|
||
"SELECT COUNT(*) FROM signals WHERE headlines_json IS NOT NULL"
|
||
" OR events_json IS NOT NULL").fetchone()[0]
|
||
d = con.execute(
|
||
"SELECT COUNT(DISTINCT headlines_json) FROM signals"
|
||
" WHERE headlines_json IS NOT NULL").fetchone()[0]
|
||
print(f"{n} rows carry inline news blobs ({d} distinct headline "
|
||
f"blobs); file size {size0 / 1e6:.1f} MB")
|
||
con.close()
|
||
return 0
|
||
if not args.no_backup:
|
||
backup = f"{path}.pre-news-{datetime.now(timezone.utc):%Y%m%d}"
|
||
if not os.path.exists(backup):
|
||
src = sqlite3.connect(path)
|
||
dst = sqlite3.connect(backup)
|
||
src.backup(dst)
|
||
dst.close()
|
||
src.close()
|
||
print(f"backup written: {backup}")
|
||
lg = SignalLogger(path) # applies the additive schema
|
||
lg.close()
|
||
con = sqlite3.connect(path)
|
||
con.execute("PRAGMA busy_timeout=10000")
|
||
try:
|
||
print("migrate:", migrate(con, batch=args.batch))
|
||
if args.prune_news_older_than is not None:
|
||
print("rows de-referenced:",
|
||
prune_news_older_than(con, args.prune_news_older_than))
|
||
if args.prune_orphans:
|
||
print("orphan snapshots removed:", prune_orphans(con))
|
||
if args.vacuum:
|
||
try:
|
||
con.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||
con.execute("VACUUM")
|
||
except sqlite3.OperationalError as exc:
|
||
print(f"VACUUM failed ({exc}) — stop the bot and dashboard "
|
||
"publisher first, then re-run with --vacuum")
|
||
return 2
|
||
finally:
|
||
con.close()
|
||
print(f"size {size0 / 1e6:.1f} MB -> {os.path.getsize(path) / 1e6:.1f} MB"
|
||
+ ("" if args.vacuum else " (run --vacuum to reclaim space)"))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|