156 lines
5.9 KiB
Python
156 lines
5.9 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
tests/test_sdp_schema.py — SDP Schema QA Harness (System Finalization)
|
|
|
|
Validates every SDP action envelope against the ratified wire schema and
|
|
exercises the router's strict parse path. Stdlib only (unittest).
|
|
|
|
Run: python -m unittest tests.test_sdp_schema (or) python tests/test_sdp_schema.py
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
# --- make the Python/ package importable ---------------------------------
|
|
_PY = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
sys.path.insert(0, _PY)
|
|
|
|
# Route the router's TelemetryDB to a throwaway file before importing it.
|
|
_TMP_DB = os.path.join(tempfile.gettempdir(), "sdp_schema_test.db")
|
|
if os.path.exists(_TMP_DB):
|
|
os.remove(_TMP_DB)
|
|
os.environ["CENTAUR_DB_PATH"] = _TMP_DB
|
|
|
|
REQUIRED_KEYS = ("sdp_version", "timestamp", "symbol", "timeframe",
|
|
"action_type", "algorithmic_confidence_score", "payload")
|
|
VALID_ACTIONS = {"Heartbeat", "Tick_Harvest", "Setup_Detected",
|
|
"Trade_Opened", "Trade_Closed"}
|
|
|
|
|
|
def validate_envelope(msg):
|
|
"""Return (ok: bool, errors: list[str]) against the ratified SDP schema."""
|
|
errors = []
|
|
if not isinstance(msg, dict):
|
|
return False, ["not a JSON object"]
|
|
for key in REQUIRED_KEYS:
|
|
if key not in msg:
|
|
errors.append("missing key '%s'" % key)
|
|
if errors:
|
|
return False, errors
|
|
if not isinstance(msg["sdp_version"], str):
|
|
errors.append("sdp_version must be str")
|
|
if not isinstance(msg["timestamp"], str):
|
|
errors.append("timestamp must be str")
|
|
if not isinstance(msg["symbol"], str) or not msg["symbol"]:
|
|
errors.append("symbol must be non-empty str")
|
|
if not isinstance(msg["timeframe"], str):
|
|
errors.append("timeframe must be str")
|
|
if msg["action_type"] not in VALID_ACTIONS:
|
|
errors.append("unknown action_type '%s'" % msg["action_type"])
|
|
try:
|
|
score = float(msg["algorithmic_confidence_score"])
|
|
if not (0.0 <= score <= 100.0):
|
|
errors.append("confidence out of [0,100]")
|
|
except (TypeError, ValueError):
|
|
errors.append("confidence must be numeric")
|
|
if not isinstance(msg["payload"], dict):
|
|
errors.append("payload must be an object")
|
|
return (len(errors) == 0), errors
|
|
|
|
|
|
def envelope(action, payload, **overrides):
|
|
msg = {
|
|
"sdp_version": "1.0.0",
|
|
"timestamp": "2026-08-12T18:00:00.000Z",
|
|
"symbol": "XAUUSD",
|
|
"timeframe": "M15",
|
|
"action_type": action,
|
|
"algorithmic_confidence_score": 62.5,
|
|
"payload": payload,
|
|
}
|
|
msg.update(overrides)
|
|
return msg
|
|
|
|
|
|
class TestSdpSchema(unittest.TestCase):
|
|
"""The 5 ratified action envelopes must all pass schema validation."""
|
|
|
|
def test_all_actions_valid(self):
|
|
cases = [
|
|
envelope("Heartbeat", {"status": "alive"}),
|
|
envelope("Tick_Harvest", {"bid": 2458.4, "ask": 2458.6,
|
|
"spread_points": 20,
|
|
"tick_time": "2026-08-12T18:00:00.000Z",
|
|
"tick_volume": 12}),
|
|
envelope("Setup_Detected", {"setup_type": "OB_FVG_BULLISH",
|
|
"entry": 2458.5, "sl": 2450.0,
|
|
"tp": 2475.0, "risk_reward": 2.0},
|
|
historical_context=[{"swing_high": 2460.0, "swing_low": 2450.0,
|
|
"time": "2026-08-12T18:00:00.000Z"}]),
|
|
envelope("Trade_Opened", {"ticket": 987654, "direction": "buy",
|
|
"lot": 0.10, "entry_price": 2458.5,
|
|
"sl": 2450.0, "tp": 2475.0}),
|
|
envelope("Trade_Closed", {"ticket": 987654, "profit": 165.0,
|
|
"r_multiple": 1.75,
|
|
"initial_ai_score": 62.5}),
|
|
]
|
|
for msg in cases:
|
|
ok, errs = validate_envelope(msg)
|
|
self.assertTrue(ok, "envelope %s invalid: %s" % (msg["action_type"], errs))
|
|
|
|
def test_negative_cases(self):
|
|
# empty object -> missing required keys
|
|
ok, errs = validate_envelope({})
|
|
self.assertFalse(ok)
|
|
self.assertTrue(any("action_type" in e for e in errs))
|
|
self.assertTrue(any("payload" in e for e in errs))
|
|
|
|
# unknown action_type
|
|
bad_action = envelope("Heartbeat", {})
|
|
bad_action["action_type"] = "Something_Else"
|
|
self.assertFalse(validate_envelope(bad_action)[0])
|
|
|
|
# confidence out of range
|
|
bad_score = envelope("Heartbeat", {})
|
|
bad_score["algorithmic_confidence_score"] = 150.0
|
|
self.assertFalse(validate_envelope(bad_score)[0])
|
|
|
|
# payload must be an object
|
|
bad_payload = envelope("Heartbeat", {})
|
|
bad_payload["payload"] = [1, 2, 3]
|
|
self.assertFalse(validate_envelope(bad_payload)[0])
|
|
|
|
def test_roundtrip_via_router(self):
|
|
"""The router must accept real wire frames (CRLF and LF) and reject junk."""
|
|
from router.main import FrameReader, parse_sdp_frame
|
|
|
|
frame = json.dumps(envelope("Heartbeat", {"status": "alive"}))
|
|
|
|
# CRLF-terminated frame
|
|
reader = FrameReader()
|
|
frames = reader.feed((frame + "\r\n").encode("utf-8"))
|
|
self.assertEqual(len(frames), 1)
|
|
msg = parse_sdp_frame(frames[0])
|
|
self.assertIsNotNone(msg)
|
|
self.assertEqual(msg["action_type"], "Heartbeat")
|
|
|
|
# LF-only frame
|
|
frames = reader.feed((frame + "\n").encode("utf-8"))
|
|
self.assertEqual(len(frames), 1)
|
|
|
|
# fragmented frame across reads
|
|
reader2 = FrameReader()
|
|
data = (frame + "\n").encode("utf-8")
|
|
half = len(data) // 2
|
|
self.assertEqual(len(reader2.feed(data[:half])), 0)
|
|
self.assertEqual(len(reader2.feed(data[half:])), 1)
|
|
|
|
# malformed -> rejected, never raised
|
|
self.assertIsNone(parse_sdp_frame("{not json"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|