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()
|