signal_log: persist every live scan (signal, grade, price, news) to SQLite

New signal_log.py logs each SignalReport to logs/signals.db via a
fail-safe hook in SignalAggregator.analyze_pair — the live-only entry
point used by the bot, daemon, CLI and dashboard. Backtester calls
analyze_from_dataframes directly, so live==backtest parity is preserved
(test-locked). Rows carry signal/grade/price/spread, MTF + trade-setup +
reasons/warnings JSON, and news-at-scan-time (calendar events + scored
headlines JSON via NewsEngine class-level caches).

Config: signal_log block (enabled, path; env SIGNAL_LOG_ENABLED/PATH),
source tagged per consumer (telegram/oanda_daemon/cli/dashboard).
381 tests pass (24 modules).
This commit is contained in:
Harwinder Sekhon
2026-08-13 10:24:49 -04:00
parent 4c34c4e23e
commit 4ffdd6bcb2
10 changed files with 685 additions and 7 deletions
+287
View File
@@ -9,6 +9,7 @@ import sys
import os
import time
import threading
import tempfile
import traceback
from datetime import datetime, timedelta, timezone
from unittest.mock import patch, MagicMock
@@ -7325,6 +7326,292 @@ def _():
ap.reset_panel_state()
print("\n═══ Module 24: signal_log.py (SQLite signal history) ═══")
def _make_log_report():
"""SignalReport with a full trade setup, MTF data, reasons + warnings."""
report = SignalReport(
symbol="XAU/USD",
display_name="XAU/USD",
timestamp="2026-08-01T12:00:00+00:00",
final_signal=SignalStrength.BUY,
final_score=0.72,
final_confidence=0.85,
mtf_agreement=True,
mtf_signals={Timeframe.M5: SignalStrength.STRONG_BUY,
Timeframe.M15: SignalStrength.BUY},
mtf_scores={Timeframe.M5: 0.6, Timeframe.M15: 0.7},
event_risk="caution",
event_description="NFP in 20m",
news_sentiment=-0.4,
news_description="Dovish Fed headlines",
news_summary="Gold supported by soft dollar.",
action_recommendation="BUY",
signal_quality="A",
market_regime="trending_up",
volatility_regime="normal",
exhaustion_count=0,
)
report.current_price = 2450.50
report.spread_estimate = 0.30
report.bullish_reasons = ["EMA trend up", "MTF agree"]
report.bearish_reasons = []
report.warnings = ["STALE DATA on H1 — signal may be unreliable"]
report.trade_setup = TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
entry_price=2450.50, stop_loss=2445.00, take_profit_1=2458.00,
take_profit_2=2468.00, risk_reward=1.6, risk_pips=5.5,
reward_pips=8.8, pip_value=1.0, strategy="XAU_PB",
trail_arm_r=0.70, trail_dist_r=0.85,
)
return report
def _make_log_context():
now = datetime.now(timezone.utc)
return FundamentalContext(
events_today=[EconomicEvent(
time=now + timedelta(minutes=20), currency="USD",
impact="high", event="Non-Farm Employment",
forecast="228K", previous="200K", actual=None)],
events_upcoming_1h=[],
high_impact_warning=True,
warning_message="High-impact event within 1h",
news_sentiment={"USD": -0.4},
news_headlines=[NewsSentiment(
headline="Fed signals rate hike", source="test",
timestamp=now, currencies_mentioned=["USD", "XAU"],
sentiment_score=1.0, keywords=["rate hike"])],
calendar_available=True,
llm_summary="Gold supported by soft dollar.",
)
@test("signal_log: schema creation + full roundtrip")
def _():
import sqlite3
import signal_log as slog
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
try:
lg = slog.SignalLogger(path)
report = _make_log_report()
context = _make_log_context()
lg.log(report, context=context, source="test", timeframe="M5")
lg.close()
conn = sqlite3.connect(path)
rows = conn.execute("SELECT * FROM signals").fetchall()
cur = conn.execute("SELECT * FROM signals")
cols = [c[0] for c in cur.description]
conn.close()
assert len(rows) == 1, "exactly one row written"
row = dict(zip(cols, rows[0]))
assert row["symbol"] == "XAU/USD"
assert row["timeframe"] == "M5"
assert row["source"] == "test"
assert row["signal"] == "BUY"
assert row["quality"] == "A"
assert row["action"] == "BUY"
assert row["price"] == 2450.50
assert row["spread"] == 0.30
assert row["market_regime"] == "trending_up"
assert row["event_risk"] == "caution"
assert row["news_sentiment"] == -0.4
mtf = json.loads(row["mtf_json"])
assert mtf["agreement"] is True
assert mtf["signals"][Timeframe.M15.value] == "BUY"
assert mtf["scores"][Timeframe.M5.value] == 0.6
setup = json.loads(row["setup_json"])
assert setup["strategy"] == "XAU_PB"
assert setup["entry"] == 2450.50 and setup["trail_arm_r"] == 0.70
reasons = json.loads(row["reasons_json"])
assert "MTF agree" in reasons["bullish"]
assert "STALE" in json.loads(row["warnings_json"])[0]
events = json.loads(row["events_json"])
assert events["high_impact_warning"] is True
assert events["events_today"][0]["event"] == "Non-Farm Employment"
assert events["events_today"][0]["surprise"] is None
headlines = json.loads(row["headlines_json"])
assert headlines[0]["headline"] == "Fed signals rate hike"
assert headlines[0]["score"] == 1.0
assert headlines[0]["currencies"] == ["USD", "XAU"]
finally:
slog.reset_loggers_for_tests()
os.remove(path)
@test("signal_log: null context → news JSON NULL, row still written")
def _():
import sqlite3
import signal_log as slog
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
try:
lg = slog.SignalLogger(path)
lg.log(_make_log_report(), context=None, source="test", timeframe="H1")
lg.close()
conn = sqlite3.connect(path)
row = conn.execute(
"SELECT events_json, headlines_json, news_summary, signal "
"FROM signals").fetchone()
conn.close()
assert row[0] is None and row[1] is None
assert row[2] == "Gold supported by soft dollar."
assert row[3] == "BUY"
finally:
slog.reset_loggers_for_tests()
os.remove(path)
@test("signal_log: module entry honors SIGNAL_LOG_ENABLED/PATH env")
def _():
import signal_log as slog
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
os.remove(path) # want a fresh file from SignalLogger
try:
os.environ["SIGNAL_LOG_PATH"] = path
os.environ.pop("SIGNAL_LOG_ENABLED", None)
slog.log_signal(_make_log_report(), source="cli", timeframe="H4")
assert os.path.exists(path), "enabled default must write the DB"
slog.reset_loggers_for_tests()
os.remove(path)
os.environ["SIGNAL_LOG_ENABLED"] = "false"
slog.log_signal(_make_log_report(), source="cli", timeframe="H4")
assert not os.path.exists(path), "disabled must not create the DB"
finally:
os.environ.pop("SIGNAL_LOG_PATH", None)
os.environ.pop("SIGNAL_LOG_ENABLED", None)
slog.reset_loggers_for_tests()
if os.path.exists(path):
os.remove(path)
@test("signal_log: unwritable path degrades to warning, never raises")
def _():
import signal_log as slog
# Route the DB through a plain file so makedirs fails
# (NotADirectoryError). log_signal must swallow it.
fd, blocker = tempfile.mkstemp()
os.close(fd)
try:
os.environ["SIGNAL_LOG_PATH"] = os.path.join(blocker, "sub", "x.db")
os.environ.pop("SIGNAL_LOG_ENABLED", None)
logging.disable(logging.CRITICAL) # silence the expected traceback
try:
slog.log_signal(_make_log_report(), source="test", timeframe="H1")
finally:
logging.disable(logging.NOTSET)
finally:
os.environ.pop("SIGNAL_LOG_PATH", None)
os.environ.pop("SIGNAL_LOG_ENABLED", None)
slog.reset_loggers_for_tests()
os.remove(blocker)
@test("signal_log: analyze_pair weekend early-return is logged")
def _():
import signal_log as slog
from signal_aggregator import _is_forex_weekend
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
os.remove(path)
try:
os.environ["SIGNAL_LOG_PATH"] = path
os.environ.pop("SIGNAL_LOG_ENABLED", None)
agg = SignalAggregator(PlatformConfig())
agg._get_news_engine = lambda: _NoNews()
with patch("signal_aggregator._is_forex_weekend", return_value=True):
report = agg.analyze_pair(symbol="XAU/USD", log_source="test")
assert report.final_signal == SignalStrength.NEUTRAL
assert report.signal_quality == "F"
import sqlite3
conn = sqlite3.connect(path)
row = conn.execute(
"SELECT source, signal, quality, timeframe FROM signals").fetchone()
conn.close()
assert row is not None, "weekend report must be persisted"
assert row[0] == "test"
assert row[1] == "NEUTRAL"
assert row[2] == "F"
assert row[3] == Timeframe.H1.value
finally:
os.environ.pop("SIGNAL_LOG_PATH", None)
os.environ.pop("SIGNAL_LOG_ENABLED", None)
slog.reset_loggers_for_tests()
if os.path.exists(path):
os.remove(path)
@test("signal_log parity: analyze_from_dataframes never logs")
def _():
import signal_log as slog
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
os.remove(path)
try:
os.environ["SIGNAL_LOG_PATH"] = path
os.environ.pop("SIGNAL_LOG_ENABLED", None)
agg = SignalAggregator(PlatformConfig())
agg.primary_timeframe = Timeframe.H1 # unused; keeps intent explicit
dfs = {Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44)}
report = agg.analyze_from_dataframes(
"XAU/USD", dfs, context=FundamentalContext(), use_news=False)
assert report.final_signal in (SignalStrength.NEUTRAL,
SignalStrength.BUY,
SignalStrength.SELL)
assert not os.path.exists(path), \
"the replayable core must never write the signal-history DB"
finally:
os.environ.pop("SIGNAL_LOG_PATH", None)
os.environ.pop("SIGNAL_LOG_ENABLED", None)
slog.reset_loggers_for_tests()
if os.path.exists(path):
os.remove(path)
@test("signal_log: _log_signal tolerates a failing news engine")
def _():
import signal_log as slog
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
os.remove(path)
try:
os.environ["SIGNAL_LOG_PATH"] = path
os.environ.pop("SIGNAL_LOG_ENABLED", None)
agg = SignalAggregator(PlatformConfig())
agg._get_news_engine = lambda: _BoomNews()
with patch("signal_aggregator._is_forex_weekend", return_value=True):
report = agg.analyze_pair(symbol="XAU/USD", log_source="test")
assert report.final_signal == SignalStrength.NEUTRAL
import sqlite3
conn = sqlite3.connect(path)
row = conn.execute(
"SELECT events_json, headlines_json FROM signals").fetchone()
conn.close()
assert row == (None, None), \
"row still written with NULL news when context fetch fails"
finally:
os.environ.pop("SIGNAL_LOG_PATH", None)
os.environ.pop("SIGNAL_LOG_ENABLED", None)
slog.reset_loggers_for_tests()
if os.path.exists(path):
os.remove(path)
class _NoNews:
def get_fundamental_context(self):
return FundamentalContext()
class _BoomNews:
def get_fundamental_context(self):
raise RuntimeError("news engine down")
# ══════════════════════════════════════════════════════════════════════
# SUMMARY
# ══════════════════════════════════════════════════════════════════════