408 lines
16 KiB
Python
408 lines
16 KiB
Python
#!/usr/bin/env python3
| |||
# -*- coding: utf-8 -*-
| |||
"""
| |||
router/main.py — Centaur Quant Architecture | Central Gateway (Python Router)
| |||
| |||
Role
| |||
----
| |||
Central TCP/IP gateway for the Universal Communication Bridge. The MQL5
| |||
Executor (CentaurQuant.mq5) streams SDP JSON frames into this process on
| |||
127.0.0.1:5555. This router:
| |||
| |||
1. Accepts persistent client connections (one thread per connection).
| |||
2. Reassembles frames terminated by '\n' or '\r\n' from raw TCP bytes.
| |||
3. Parses each frame STRICTLY with the stdlib `json` module.
| |||
4. Dispatches on the SDP "action_type" key:
| |||
Heartbeat / Tick_Harvest -> lightweight console log
| |||
Setup_Detected -> LIVE LLM evaluation (MarketAnalyzer) + reply
| |||
Trade_Opened / Closed -> prominent log + SQLite telemetry persist
| |||
5. Replies to Setup_Detected with the AI Advisory envelope:
| |||
{"algorithmic_confidence_score": <float>}\n
| |||
| |||
The reply is advisory ONLY (Anti-Veto Principle): the MT5 side may still
| |||
execute regardless of the score. MarketAnalyzer returns a neutral 40.0 on
| |||
any LLM failure/timeout, which triggers the MT5 quarter-risk path.
| |||
Trade_Opened/Trade_Closed are persisted by TelemetryDB (Track B), closing
| |||
the score -> execution -> outcome feedback loop for future calibration.
| |||
| |||
Robustness
| |||
----------
| |||
- Per-connection threads are daemonized; the server never dies with a client.
| |||
- ConnectionResetError / BrokenPipeError / empty reads are handled as a
| |||
clean disconnect; a reconnect is simply a new accept().
| |||
- Frames and the reassembly buffer are size-bounded to resist floods.
| |||
| |||
Standard library only: socket, threading, json, logging, argparse.
| |||
"""
| |||
| |||
import argparse
| |||
import json
| |||
import logging
| |||
import os
| |||
import socket
| |||
import sys
| |||
import threading
| |||
from typing import Any, Dict, List, Optional
| |||
| |||
# --- Track A: live LLM evaluation engine (Python/models/analyzer.py) -------
| |||
# Make the sibling packages importable whether run as a script or as a
| |||
# module: python router/main.py | python -m router.main
| |||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
| |||
from models.analyzer import MarketAnalyzer # noqa: E402
| |||
from database.telemetry import TelemetryDB # noqa: E402
| |||
| |||
# --------------------------------------------------------------------------
| |||
# Configuration
| |||
# --------------------------------------------------------------------------
| |||
HOST = "127.0.0.1" # default listen address (MT5 side connects here)
| |||
PORT = 5555 # default listen port (matches CentaurQuant.mq5 input)
| |||
BACKLOG = 5 # pending-connection queue depth
| |||
RECV_BUFFER_SIZE = 4096 # bytes pulled per recv() call
| |||
MAX_FRAME_SIZE = 1_048_576 # 1 MB hard cap per logical frame (flood guard)
| |||
ENCODING = "utf-8" # SDP frames are UTF-8 encoded
| |||
| |||
# --------------------------------------------------------------------------
| |||
# AI evaluation engine (Track A)
| |||
# Provider/model from env: PROVIDER=auto|openai|google, MODEL, *_API_KEY.
| |||
# Anti-Veto: analyze() returns 40.0 (quarter-risk trigger) on any failure.
| |||
# --------------------------------------------------------------------------
| |||
ANALYZER = MarketAnalyzer()
| |||
| |||
# --------------------------------------------------------------------------
| |||
# Telemetry persistence (Track B) — SQLite feedback-loop store.
| |||
# DB path overridable via env: CENTAUR_DB_PATH (default: ./centaur_telemetry.db)
| |||
# --------------------------------------------------------------------------
| |||
TELEMETRY = TelemetryDB(os.environ.get("CENTAUR_DB_PATH", "centaur_telemetry.db"))
| |||
| |||
# --------------------------------------------------------------------------
| |||
# Logging
| |||
# --------------------------------------------------------------------------
| |||
def configure_logging() -> None:
| |||
"""Console logging with timestamps; INFO shows the full SDP telemetry."""
| |||
logging.basicConfig(
| |||
level=logging.INFO,
| |||
format="%(asctime)s | %(levelname)-8s | %(message)s",
| |||
datefmt="%Y-%m-%d %H:%M:%S",
| |||
)
| |||
| |||
| |||
# --------------------------------------------------------------------------
| |||
# Frame reassembly (newline / CRLF framed protocol)
| |||
# --------------------------------------------------------------------------
| |||
class FrameReader:
| |||
"""
| |||
Incrementally reassembles '\n'-terminated text frames from raw bytes.
| |||
| |||
The MT5 transport (CSocketClient) writes frames terminated by CRLF
| |||
("\r\n") and the receiver tolerates bare "\n" — so we split on "\n"
| |||
and strip one trailing "\r" when present. Multiple frames may arrive
| |||
inside a single recv(); the remainder is kept buffered across reads.
| |||
"""
| |||
| |||
def __init__(self) -> None:
| |||
self._buffer = b""
| |||
| |||
def feed(self, data: bytes) -> List[str]:
| |||
"""Append raw bytes and return every COMPLETE frame they contain."""
| |||
self._buffer += data
| |||
frames: List[str] = []
| |||
| |||
while True:
| |||
idx = self._buffer.find(b"\n")
| |||
if idx == -1:
| |||
break # no complete frame yet — keep buffering
| |||
| |||
raw = self._buffer[:idx] # frame body (terminator removed)
| |||
self._buffer = self._buffer[idx + 1:] # consume terminator too
| |||
| |||
if raw.endswith(b"\r"):
| |||
raw = raw[:-1] # CRLF tolerance
| |||
| |||
if len(raw) > MAX_FRAME_SIZE:
| |||
logging.warning("Frame exceeds %d bytes — dropped.", MAX_FRAME_SIZE)
| |||
continue
| |||
| |||
try:
| |||
frames.append(raw.decode(ENCODING, errors="replace"))
| |||
except UnicodeDecodeError as exc: # pragma: no cover - defensive
| |||
logging.warning("Undecodable frame dropped: %s", exc)
| |||
| |||
# Flood guard: never let an unframed stream grow the buffer unbounded.
| |||
if len(self._buffer) > MAX_FRAME_SIZE:
| |||
logging.warning(
| |||
"Reassembly buffer exceeded %d bytes without a terminator — cleared.",
| |||
MAX_FRAME_SIZE,
| |||
)
| |||
self._buffer = b""
| |||
| |||
return frames
| |||
| |||
| |||
# --------------------------------------------------------------------------
| |||
# Strict SDP parsing
| |||
# --------------------------------------------------------------------------
| |||
def parse_sdp_frame(frame: str) -> Optional[Dict[str, Any]]:
| |||
"""
| |||
Strictly parse one SDP frame.
| |||
| |||
Returns a dict only when the frame is valid JSON *and* an object *and*
| |||
carries a string "action_type"; otherwise logs and returns None.
| |||
"""
| |||
try:
| |||
data = json.loads(frame)
| |||
except json.JSONDecodeError as exc:
| |||
logging.warning("Malformed JSON dropped: %s | frame=%.120r", exc, frame)
| |||
return None
| |||
| |||
if not isinstance(data, dict):
| |||
logging.warning("Non-object JSON dropped: %r", data)
| |||
return None
| |||
| |||
if not isinstance(data.get("action_type"), str):
| |||
logging.warning("Frame missing string 'action_type': %r", data)
| |||
return None
| |||
| |||
return data
| |||
| |||
| |||
# --------------------------------------------------------------------------
| |||
# Safe transport write (client may vanish between read and reply)
| |||
# --------------------------------------------------------------------------
| |||
def safe_send(conn: socket.socket, text: str) -> bool:
| |||
"""Best-effort frame write; never raises on a dead client."""
| |||
try:
| |||
conn.sendall(text.encode(ENCODING))
| |||
return True
| |||
except (ConnectionResetError, BrokenPipeError, OSError) as exc:
| |||
logging.warning("Send failed (client gone?): %s", exc)
| |||
return False
| |||
| |||
| |||
# --------------------------------------------------------------------------
| |||
# Action handlers
| |||
# --------------------------------------------------------------------------
| |||
def handle_heartbeat(msg: Dict[str, Any]) -> None:
| |||
"""Liveness signal — lightweight one-line log."""
| |||
logging.info(
| |||
"[Heartbeat] symbol=%s timeframe=%s",
| |||
msg.get("symbol", "?"),
| |||
msg.get("timeframe", "?"),
| |||
)
| |||
| |||
| |||
def handle_tick_harvest(msg: Dict[str, Any]) -> None:
| |||
"""One harvested tick — compact telemetry line."""
| |||
p = msg.get("payload") or {}
| |||
logging.info(
| |||
"[Tick_Harvest] %s %s bid=%s ask=%s spread_pts=%s tick_vol=%s",
| |||
msg.get("symbol", "?"),
| |||
msg.get("timeframe", "?"),
| |||
p.get("bid", "?"),
| |||
p.get("ask", "?"),
| |||
p.get("spread_points", "?"),
| |||
p.get("tick_volume", "?"),
| |||
)
| |||
| |||
| |||
def handle_setup_detected(msg: Dict[str, Any], conn: socket.socket) -> None:
| |||
"""
| |||
Setup proposal from the Executor.
| |||
| |||
Extracts symbol / setup_type / historical_context, runs the LIVE LLM
| |||
evaluation (MarketAnalyzer), and INSTANTLY replies with the AI Advisory
| |||
envelope:
| |||
{"algorithmic_confidence_score": <float>}\n
| |||
"""
| |||
p = msg.get("payload") or {}
| |||
symbol = msg.get("symbol", "?")
| |||
setup_type = p.get("setup_type", "?")
| |||
| |||
hist = msg.get("historical_context")
| |||
swing_count = len(hist) if isinstance(hist, list) else 0
| |||
| |||
logging.info(
| |||
"[Setup_Detected] %s | setup=%s | entry=%s sl=%s tp=%s | ctx_swings=%d",
| |||
symbol,
| |||
setup_type,
| |||
p.get("entry", "?"),
| |||
p.get("sl", "?"),
| |||
p.get("tp", "?"),
| |||
swing_count,
| |||
)
| |||
| |||
# --- live LLM evaluation (Anti-Veto: neutral 40.0 on failure/timeout) ---
| |||
result = ANALYZER.analyze(msg)
| |||
score = result["score"]
| |||
if result.get("used_fallback"):
| |||
logging.warning("[AI_Advisory] %s | fallback score %.2f used — %s",
| |||
symbol, score, result.get("fallback_reason", ""))
| |||
else:
| |||
logging.info("[AI_Advisory] %s | score=%.2f | reason='%s' | provider=%s | %d ms",
| |||
symbol, score, result.get("reason", ""),
| |||
result.get("provider", "?"), result.get("latency_ms", 0))
| |||
| |||
# --- AI Advisory envelope, newline-terminated (MT5 frames on '\n') ---
| |||
reply = json.dumps({"algorithmic_confidence_score": score}) + "\n"
| |||
if safe_send(conn, reply):
| |||
logging.info("[AI_Advisory] %s | score=%.2f -> sent to MT5", symbol, score)
| |||
else:
| |||
logging.warning(
| |||
"[AI_Advisory] %s | score=%.2f NOT delivered (client unreachable)",
| |||
symbol,
| |||
score,
| |||
)
| |||
| |||
| |||
def handle_trade_opened(msg: Dict[str, Any]) -> None:
| |||
"""Position opened — prominent execution record + SQLite persist."""
| |||
p = msg.get("payload") or {}
| |||
logging.info(
| |||
">>> [Trade_Opened] %s %s | ticket=%s %s lot=%s entry=%s sl=%s tp=%s | ai_score=%s",
| |||
msg.get("symbol", "?"),
| |||
msg.get("timeframe", "?"),
| |||
p.get("ticket", "?"),
| |||
p.get("direction", "?"),
| |||
p.get("lot", "?"),
| |||
p.get("entry_price", "?"),
| |||
p.get("sl", "?"),
| |||
p.get("tp", "?"),
| |||
msg.get("algorithmic_confidence_score", "?"),
| |||
)
| |||
# --- persist the execution record (Track B) ---
| |||
TELEMETRY.log_trade_opened(msg)
| |||
| |||
| |||
def handle_trade_closed(msg: Dict[str, Any]) -> None:
| |||
"""Position closed — the feedback-loop training signal + SQLite update."""
| |||
p = msg.get("payload") or {}
| |||
logging.info(
| |||
">>> [Trade_Closed] %s | ticket=%s profit=%s r_multiple=%s initial_ai_score=%s",
| |||
msg.get("symbol", "?"),
| |||
p.get("ticket", "?"),
| |||
p.get("profit", "?"),
| |||
p.get("r_multiple", "?"),
| |||
p.get("initial_ai_score", "?"),
| |||
)
| |||
# --- persist the closed outcome (Track B) ---
| |||
TELEMETRY.log_trade_closed(msg)
| |||
| |||
| |||
# --------------------------------------------------------------------------
| |||
# Dispatcher
| |||
# --------------------------------------------------------------------------
| |||
# Setup_Detected is handled separately (it needs the socket to reply).
| |||
_ACTIONS: Dict[str, Any] = {
| |||
"Heartbeat": handle_heartbeat,
| |||
"Tick_Harvest": handle_tick_harvest,
| |||
"Trade_Opened": handle_trade_opened,
| |||
"Trade_Closed": handle_trade_closed,
| |||
}
| |||
| |||
| |||
def dispatch(msg: Dict[str, Any], conn: socket.socket) -> None:
| |||
"""Route one parsed SDP frame by its action_type."""
| |||
action = msg.get("action_type")
| |||
| |||
if action == "Setup_Detected":
| |||
handle_setup_detected(msg, conn)
| |||
return
| |||
| |||
handler = _ACTIONS.get(action)
| |||
if handler is None:
| |||
logging.warning("[Router] unknown action_type=%r — frame ignored", action)
| |||
return
| |||
| |||
try:
| |||
handler(msg)
| |||
except Exception: # a broken handler must never kill the connection thread
| |||
logging.exception("[Router] handler '%s' raised — frame ignored", action)
| |||
| |||
| |||
# --------------------------------------------------------------------------
| |||
# Per-connection worker
| |||
# --------------------------------------------------------------------------
| |||
def handle_client(conn: socket.socket, addr: tuple) -> None:
| |||
"""
| |||
Serve one persistent MT5 connection until it disconnects.
| |||
| |||
Reads raw bytes, reassembles frames, parses and dispatches each one.
| |||
A reconnect from the MT5 side simply spawns a fresh thread via accept().
| |||
"""
| |||
host, port = addr[0], addr[1]
| |||
logging.info("[Gateway] client connected: %s:%d", host, port)
| |||
| |||
reader = FrameReader()
| |||
try:
| |||
while True:
| |||
data = conn.recv(RECV_BUFFER_SIZE)
| |||
if not data:
| |||
# Peer performed an orderly close — empty byte read.
| |||
logging.info("[Gateway] %s:%d closed connection (empty read)", host, port)
| |||
break
| |||
| |||
for frame in reader.feed(data):
| |||
msg = parse_sdp_frame(frame)
| |||
if msg is not None:
| |||
dispatch(msg, conn)
| |||
| |||
except ConnectionResetError as exc:
| |||
# MT5 terminal killed the socket (e.g., chart closed mid-session).
| |||
logging.warning("[Gateway] %s:%d connection reset: %s", host, port, exc)
| |||
except (BrokenPipeError, OSError) as exc:
| |||
logging.warning("[Gateway] %s:%d socket error: %s", host, port, exc)
| |||
except Exception:
| |||
# Last-resort guard: log and drop the connection, keep the server up.
| |||
logging.exception("[Gateway] %s:%d unexpected error — connection closed", host, port)
| |||
finally:
| |||
try:
| |||
conn.shutdown(socket.SHUT_RDWR)
| |||
except OSError:
| |||
pass # socket may already be closed by the peer
| |||
conn.close()
| |||
logging.info("[Gateway] %s:%d disconnected", host, port)
| |||
| |||
| |||
# --------------------------------------------------------------------------
| |||
# Server entry point
| |||
# --------------------------------------------------------------------------
| |||
def run_server(host: str, port: int) -> None:
| |||
"""Bind, listen, and accept client connections forever (Ctrl+C to stop)."""
| |||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
| |||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # fast rebind
| |||
server.bind((host, port))
| |||
server.listen(BACKLOG)
| |||
| |||
logging.info("=" * 72)
| |||
logging.info("Centaur Gateway listening on %s:%d", host, port)
| |||
logging.info("LLM backend: %s",
| |||
ANALYZER._backend.name if ANALYZER._backend else "NONE (fallback 40.0)")
| |||
logging.info("Telemetry DB: %s", TELEMETRY.path)
| |||
logging.info("Press Ctrl+C to stop.")
| |||
logging.info("=" * 72)
| |||
| |||
try:
| |||
while True:
| |||
conn, addr = server.accept()
| |||
# daemon thread: dies with the process; never blocks shutdown
| |||
thread = threading.Thread(target=handle_client, args=(conn, addr), daemon=True)
| |||
thread.start()
| |||
except KeyboardInterrupt:
| |||
logging.info("[Gateway] Ctrl+C received — shutting down.")
| |||
finally:
| |||
server.close()
| |||
TELEMETRY.close()
| |||
logging.info("[Gateway] server socket closed, telemetry flushed. Bye.")
| |||
| |||
| |||
def main() -> None:
| |||
parser = argparse.ArgumentParser(description="Centaur Quant — Central Gateway")
| |||
parser.add_argument("--host", default=HOST, help="listen address (default: %(default)s)")
| |||
parser.add_argument("--port", type=int, default=PORT, help="listen port (default: %(default)s)")
| |||
args = parser.parse_args()
| |||
| |||
configure_logging()
| |||
run_server(args.host, args.port)
| |||
| |||
| |||
if __name__ == "__main__":
| |||
main()
|