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:
+287
@@ -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
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user