2026-07-11 19:20:22 +02:00
|
|
|
"""
|
|
|
|
|
UV Anomaly Sentinel — read-only auditor for the VIOLET UV namespace.
|
|
|
|
|
|
|
|
|
|
Implements 5 checks from arming-checklist §4:
|
|
|
|
|
(a) Venue order without matching BRIDGE exec_journal row
|
|
|
|
|
(b) BRIDGE exec_journal row without venue order
|
|
|
|
|
(c) Venue order without 'u-' prefix in clientOrderId
|
|
|
|
|
(d) CH write outside dolphin_uv.* from runner window
|
|
|
|
|
(e) tripwire_ok=false occurrences
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
uv_anomaly_sentinel.py # run all checks
|
|
|
|
|
uv_anomaly_sentinel.py --check a c # run subset
|
|
|
|
|
uv_anomaly_sentinel.py --verbose # show evidence rows
|
|
|
|
|
|
|
|
|
|
Exit code 0 = all PASS; 1 = any FAIL.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import hashlib
|
|
|
|
|
import hmac
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
import time
|
|
|
|
|
import urllib.error
|
|
|
|
|
import urllib.parse
|
|
|
|
|
import urllib.request
|
|
|
|
|
from collections.abc import Mapping
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
# ─── Environment ──────────────────────────────────────────────────────────
|
|
|
|
|
CH_URL = os.environ.get("CH_URL", "http://localhost:8123")
|
|
|
|
|
CH_USER = os.environ.get("CH_USER", "dolphin")
|
|
|
|
|
CH_PASS = os.environ.get("CH_PASS", "dolphin_ch_2026")
|
|
|
|
|
CH_DB_UV = os.environ.get("CH_DB_UV", "dolphin_uv")
|
|
|
|
|
CH_DB_VIOLET = os.environ.get("CH_DB_VIOLET", "dolphin_violet")
|
|
|
|
|
VST_BASE_URL = os.environ.get(
|
|
|
|
|
"VST_BASE_URL", "https://open-api-vst.bingx.com"
|
|
|
|
|
)
|
|
|
|
|
VST_API_KEY = os.environ.get("BINGX_API_KEY", "")
|
|
|
|
|
VST_SECRET_KEY = os.environ.get("BINGX_SECRET_KEY", "")
|
|
|
|
|
LOOKBACK_HOURS = int(os.environ.get("SENTINEL_LOOKBACK_HOURS", "24"))
|
|
|
|
|
EVIDENCE_LIMIT = int(os.environ.get("SENTINEL_EVIDENCE_LIMIT", "10"))
|
|
|
|
|
QUERY_TIMEOUT_S = int(os.environ.get("SENTINEL_QUERY_TIMEOUT_S", "15"))
|
2026-07-12 03:28:18 +02:00
|
|
|
# Known non-UV databases that the sentinel must NOT flag (BLUE OBF, etc.)
|
|
|
|
|
CH_ALLOWLIST_DBS = os.environ.get(
|
|
|
|
|
"CH_ALLOWLIST_DBS", "dolphin,dolphin_malkhut,default"
|
|
|
|
|
).split(",")
|
2026-07-11 19:20:22 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Data types ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class CheckResult:
|
|
|
|
|
check_id: str
|
|
|
|
|
description: str
|
|
|
|
|
passed: bool = True
|
|
|
|
|
evidence: list[str] = field(default_factory=list)
|
2026-07-12 03:28:18 +02:00
|
|
|
error: str = "" # IF non-empty, the check failed with an error (not a detection)
|
2026-07-11 19:20:22 +02:00
|
|
|
|
|
|
|
|
def add(self, line: str) -> None:
|
|
|
|
|
if len(self.evidence) < EVIDENCE_LIMIT:
|
|
|
|
|
self.evidence.append(line)
|
|
|
|
|
|
|
|
|
|
def summary(self) -> list[str]:
|
|
|
|
|
lines = [f" {'PASS' if self.passed else 'FAIL'} [{self.check_id}] {self.description}"]
|
2026-07-12 03:28:18 +02:00
|
|
|
if self.error:
|
|
|
|
|
lines.append(f" Error: {self.error}")
|
2026-07-11 19:20:22 +02:00
|
|
|
if not self.passed and self.evidence:
|
|
|
|
|
lines.append(" Evidence:")
|
|
|
|
|
for e in self.evidence:
|
|
|
|
|
lines.append(f" - {e}")
|
|
|
|
|
return lines
|
|
|
|
|
|
|
|
|
|
|
2026-07-12 03:28:18 +02:00
|
|
|
# ─── ClickHouse querier (read-only HTTP, error-tracked) ────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class CHQueryResult:
|
|
|
|
|
"""Result of a CH query — rows + error status."""
|
|
|
|
|
rows: list[dict[str, Any]]
|
|
|
|
|
error: str # empty = success; non-empty = error message
|
2026-07-11 19:20:22 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class CHQuerier:
|
2026-07-12 03:28:18 +02:00
|
|
|
"""Read-only ClickHouse HTTP client (JSONEachRow).
|
|
|
|
|
|
|
|
|
|
Every query method returns (rows, error): error is '' on success.
|
|
|
|
|
A caller that gets error != '' MUST report FAIL, never silent PASS.
|
|
|
|
|
"""
|
2026-07-11 19:20:22 +02:00
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
url: str = CH_URL,
|
|
|
|
|
user: str = CH_USER,
|
|
|
|
|
password: str = CH_PASS,
|
|
|
|
|
) -> None:
|
|
|
|
|
self._url = url.rstrip("/")
|
|
|
|
|
self._user = user
|
|
|
|
|
self._password = password
|
|
|
|
|
|
2026-07-12 03:28:18 +02:00
|
|
|
def query(self, sql: str, **kw: Any) -> CHQueryResult:
|
2026-07-11 19:20:22 +02:00
|
|
|
timeout = kw.pop("timeout_s", QUERY_TIMEOUT_S)
|
|
|
|
|
endpoint = f"{self._url}/?default_format=JSONEachRow"
|
|
|
|
|
data = sql.encode("utf-8")
|
|
|
|
|
req = urllib.request.Request(endpoint, data=data, method="POST")
|
|
|
|
|
b64 = __import__("base64")
|
|
|
|
|
auth_raw = f"{self._user}:{self._password}"
|
|
|
|
|
encoded = b64.b64encode(auth_raw.encode()).decode()
|
|
|
|
|
req.add_header("Authorization", f"Basic {encoded}")
|
|
|
|
|
req.add_header("Content-Type", "text/plain; charset=utf-8")
|
|
|
|
|
try:
|
|
|
|
|
resp = urllib.request.urlopen(req, timeout=timeout)
|
|
|
|
|
body = resp.read().decode("utf-8")
|
|
|
|
|
except urllib.error.HTTPError as exc:
|
2026-07-12 03:28:18 +02:00
|
|
|
detail = exc.read().decode()[:300]
|
|
|
|
|
return CHQueryResult([], f"CH HTTP {exc.code}: {detail}")
|
2026-07-11 19:20:22 +02:00
|
|
|
except OSError as exc:
|
2026-07-12 03:28:18 +02:00
|
|
|
return CHQueryResult([], f"CH connection error: {exc}")
|
2026-07-11 19:20:22 +02:00
|
|
|
if not body.strip():
|
2026-07-12 03:28:18 +02:00
|
|
|
return CHQueryResult([], "")
|
2026-07-11 19:20:22 +02:00
|
|
|
rows: list[dict[str, Any]] = []
|
|
|
|
|
for line in body.strip().split("\n"):
|
|
|
|
|
line = line.strip()
|
|
|
|
|
if not line:
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
rows.append(json.loads(line))
|
|
|
|
|
except json.JSONDecodeError:
|
2026-07-12 03:28:18 +02:00
|
|
|
return CHQueryResult([], f"CH non-JSON response line: {line[:100]}")
|
|
|
|
|
return CHQueryResult(rows, "")
|
2026-07-11 19:20:22 +02:00
|
|
|
|
2026-07-12 03:28:18 +02:00
|
|
|
def describe_table(self, table: str, db: str = CH_DB_UV) -> CHQueryResult:
|
2026-07-11 19:20:22 +02:00
|
|
|
return self.query(f"DESCRIBE TABLE {db}.{table}")
|
|
|
|
|
|
2026-07-12 03:28:18 +02:00
|
|
|
def table_exists(self, table: str, db: str = CH_DB_UV) -> CHQueryResult:
|
|
|
|
|
r = self.query(
|
2026-07-11 19:20:22 +02:00
|
|
|
f"SELECT 1 FROM system.tables "
|
|
|
|
|
f"WHERE database = '{db}' AND name = '{table}'"
|
|
|
|
|
)
|
2026-07-12 03:28:18 +02:00
|
|
|
if r.error:
|
|
|
|
|
return r
|
|
|
|
|
return CHQueryResult(r.rows, "")
|
2026-07-11 19:20:22 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── VST querier (read-only: open orders, positions) ───────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _canonical_query(params: Mapping[str, object]) -> str:
|
|
|
|
|
filtered = {k: v for k, v in params.items() if v is not None and v != ""}
|
|
|
|
|
ordered = sorted(filtered.items(), key=lambda x: x[0])
|
|
|
|
|
return urllib.parse.urlencode(ordered, doseq=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sign_query(secret: str, query: str) -> str:
|
|
|
|
|
return hmac.new(
|
|
|
|
|
secret.encode("utf-8"), query.encode("utf-8"), hashlib.sha256
|
|
|
|
|
).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_signed_params(
|
|
|
|
|
params: dict[str, object], secret: str
|
|
|
|
|
) -> dict[str, object]:
|
|
|
|
|
signed = dict(params)
|
|
|
|
|
signed["timestamp"] = int(time.time() * 1000)
|
|
|
|
|
signed["recvWindow"] = 5000
|
|
|
|
|
q = _canonical_query(signed)
|
|
|
|
|
signed["signature"] = _sign_query(secret, q)
|
|
|
|
|
return signed
|
|
|
|
|
|
|
|
|
|
|
2026-07-12 03:28:18 +02:00
|
|
|
@dataclass
|
|
|
|
|
class VSTResult:
|
|
|
|
|
"""Result of a VST API call — items + error status."""
|
|
|
|
|
items: list[dict[str, Any]]
|
|
|
|
|
error: str # empty = success; non-empty = error message
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 19:20:22 +02:00
|
|
|
class VSTQuerier:
|
|
|
|
|
"""Read-only VST venue client — open orders, positions, balance."""
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
base_url: str = VST_BASE_URL,
|
|
|
|
|
api_key: str = VST_API_KEY,
|
|
|
|
|
secret_key: str = VST_SECRET_KEY,
|
|
|
|
|
) -> None:
|
|
|
|
|
self._base = base_url.rstrip("/")
|
|
|
|
|
self._api_key = api_key
|
|
|
|
|
self._secret_key = secret_key
|
|
|
|
|
|
2026-07-12 03:28:18 +02:00
|
|
|
def _signed_get(self, path: str, params: dict[str, object] | None = None) -> VSTResult:
|
2026-07-11 19:20:22 +02:00
|
|
|
params = _build_signed_params(params or {}, self._secret_key)
|
|
|
|
|
qs = _canonical_query(params)
|
|
|
|
|
url = f"{self._base}{path}?{qs}"
|
|
|
|
|
req = urllib.request.Request(url, method="GET")
|
|
|
|
|
req.add_header("X-BX-APIKEY", self._api_key)
|
|
|
|
|
try:
|
|
|
|
|
resp = urllib.request.urlopen(req, timeout=QUERY_TIMEOUT_S)
|
|
|
|
|
body = resp.read().decode("utf-8")
|
|
|
|
|
except urllib.error.HTTPError as exc:
|
2026-07-12 03:28:18 +02:00
|
|
|
detail = exc.read().decode()[:300]
|
|
|
|
|
return VSTResult([], f"VST HTTP {exc.code}: {detail}")
|
2026-07-11 19:20:22 +02:00
|
|
|
except OSError as exc:
|
2026-07-12 03:28:18 +02:00
|
|
|
return VSTResult([], f"VST connection error: {exc}")
|
2026-07-11 19:20:22 +02:00
|
|
|
try:
|
|
|
|
|
parsed = json.loads(body)
|
|
|
|
|
except json.JSONDecodeError:
|
2026-07-12 03:28:18 +02:00
|
|
|
return VSTResult([], f"VST non-JSON response: {body[:200]}")
|
|
|
|
|
# BingX shape: {"code":0,"msg":"ok","data":{"orders":[...]}}
|
|
|
|
|
# openOrders → data.orders, positions → data.positions
|
|
|
|
|
code = parsed.get("code", -1) if isinstance(parsed, dict) else -1
|
|
|
|
|
if code != 0 and code != -1:
|
|
|
|
|
msg = parsed.get("msg", "unknown") if isinstance(parsed, dict) else "?"
|
|
|
|
|
return VSTResult([], f"VST API error code={code} msg={msg}")
|
|
|
|
|
data = parsed.get("data") if isinstance(parsed, dict) else None
|
|
|
|
|
if isinstance(data, dict):
|
|
|
|
|
items = data.get("orders") or data.get("positions") or []
|
|
|
|
|
elif isinstance(data, list):
|
|
|
|
|
items = data
|
2026-07-11 19:20:22 +02:00
|
|
|
else:
|
2026-07-12 03:28:18 +02:00
|
|
|
items = []
|
|
|
|
|
# Filter out non-dict items (empty wrappers, etc.)
|
|
|
|
|
clean = [r for r in items if isinstance(r, dict)]
|
|
|
|
|
return VSTResult(clean, "")
|
|
|
|
|
|
|
|
|
|
def open_orders(self) -> VSTResult:
|
|
|
|
|
return self._signed_get("/openApi/swap/v2/trade/openOrders")
|
2026-07-11 19:20:22 +02:00
|
|
|
|
2026-07-12 03:28:18 +02:00
|
|
|
def open_positions(self) -> VSTResult:
|
2026-07-11 19:20:22 +02:00
|
|
|
return self._signed_get("/openApi/swap/v2/user/positions")
|
|
|
|
|
|
2026-07-12 03:28:18 +02:00
|
|
|
def account_balance(self) -> VSTResult:
|
|
|
|
|
r = self._signed_get("/openApi/swap/v2/user/balance")
|
|
|
|
|
return r
|
2026-07-11 19:20:22 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Sentinel ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UvAnomalySentinel:
|
|
|
|
|
"""Runs the 5 UV anomaly checks against CH + VST."""
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
ch: CHQuerier | None = None,
|
|
|
|
|
vst: VSTQuerier | None = None,
|
|
|
|
|
enabled: set[str] | None = None,
|
|
|
|
|
lookback_hours: int = LOOKBACK_HOURS,
|
|
|
|
|
verbose: bool = False,
|
2026-07-12 03:28:18 +02:00
|
|
|
allowlist_dbs: list[str] | None = None,
|
2026-07-11 19:20:22 +02:00
|
|
|
) -> None:
|
|
|
|
|
self.ch = ch or CHQuerier()
|
|
|
|
|
self.vst = vst or VSTQuerier()
|
|
|
|
|
self.enabled = enabled or {"a", "b", "c", "d", "e"}
|
|
|
|
|
self.lookback_hours = lookback_hours
|
|
|
|
|
self.verbose = verbose
|
2026-07-12 03:28:18 +02:00
|
|
|
self.allowlist_dbs = allowlist_dbs or CH_ALLOWLIST_DBS
|
2026-07-11 19:20:22 +02:00
|
|
|
self.results: list[CheckResult] = []
|
|
|
|
|
|
|
|
|
|
def run(self) -> int:
|
|
|
|
|
checks: list[tuple[str, Any]] = [
|
|
|
|
|
("a", self._check_venue_without_journal),
|
|
|
|
|
("b", self._check_journal_without_venue),
|
|
|
|
|
("c", self._check_u_prefix),
|
|
|
|
|
("d", self._check_ch_write_outside_uv),
|
|
|
|
|
("e", self._check_tripwire_ok),
|
|
|
|
|
]
|
|
|
|
|
for cid, method in checks:
|
|
|
|
|
if cid not in self.enabled:
|
|
|
|
|
continue
|
|
|
|
|
self.results.append(method())
|
|
|
|
|
return self._report()
|
|
|
|
|
|
|
|
|
|
# ── Check implementations ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def _check_venue_without_journal(self) -> CheckResult:
|
|
|
|
|
"""(a) Venue order without matching BRIDGE exec_journal row."""
|
|
|
|
|
r = CheckResult("a", "Venue order ↔ BRIDGE exec_journal match")
|
2026-07-12 03:28:18 +02:00
|
|
|
|
|
|
|
|
# 1. Fetch venue open orders
|
|
|
|
|
vst_r = self.vst.open_orders()
|
|
|
|
|
if vst_r.error:
|
|
|
|
|
r.passed = False
|
|
|
|
|
r.error = vst_r.error
|
|
|
|
|
return r
|
|
|
|
|
orders = vst_r.items
|
2026-07-11 19:20:22 +02:00
|
|
|
if not orders:
|
|
|
|
|
return r
|
2026-07-12 03:28:18 +02:00
|
|
|
|
|
|
|
|
# 2. Check exec_journal exists
|
|
|
|
|
exists_r = self.ch.table_exists("exec_journal", CH_DB_UV)
|
|
|
|
|
if exists_r.error:
|
|
|
|
|
r.passed = False
|
|
|
|
|
r.error = exists_r.error
|
|
|
|
|
return r
|
|
|
|
|
if not exists_r.rows:
|
2026-07-11 19:20:22 +02:00
|
|
|
r.passed = False
|
2026-07-12 03:28:18 +02:00
|
|
|
r.add("exec_journal table not found in dolphin_uv — no journal to match against")
|
2026-07-11 19:20:22 +02:00
|
|
|
return r
|
2026-07-12 03:28:18 +02:00
|
|
|
|
|
|
|
|
# 3. Collect venue clientOrderIds
|
2026-07-11 19:20:22 +02:00
|
|
|
venue_ids: set[str] = set()
|
|
|
|
|
for o in orders:
|
|
|
|
|
cid = str(
|
|
|
|
|
o.get("clientOrderId")
|
|
|
|
|
or o.get("clientOrderID")
|
|
|
|
|
or o.get("c")
|
|
|
|
|
or ""
|
|
|
|
|
)
|
|
|
|
|
if cid:
|
|
|
|
|
venue_ids.add(cid)
|
|
|
|
|
if not venue_ids:
|
|
|
|
|
return r
|
2026-07-12 03:28:18 +02:00
|
|
|
|
|
|
|
|
# 4. Query exec_journal for matching u_prefix_client_id
|
2026-07-11 19:20:22 +02:00
|
|
|
ids_escaped = "','".join(venue_ids)
|
|
|
|
|
sql = (
|
2026-07-12 03:28:18 +02:00
|
|
|
f"SELECT u_prefix_client_id, trade_id "
|
|
|
|
|
f"FROM {CH_DB_UV}.exec_journal "
|
|
|
|
|
f"WHERE u_prefix_client_id IN ('{ids_escaped}')"
|
2026-07-11 19:20:22 +02:00
|
|
|
)
|
2026-07-12 03:28:18 +02:00
|
|
|
jr = self.ch.query(sql)
|
|
|
|
|
if jr.error:
|
|
|
|
|
r.passed = False
|
|
|
|
|
r.error = jr.error
|
|
|
|
|
return r
|
|
|
|
|
|
2026-07-11 19:20:22 +02:00
|
|
|
journal_ids: set[str] = set()
|
2026-07-12 03:28:18 +02:00
|
|
|
for row in jr.rows:
|
|
|
|
|
cid = str(row.get("u_prefix_client_id") or "")
|
2026-07-11 19:20:22 +02:00
|
|
|
if cid:
|
|
|
|
|
journal_ids.add(cid)
|
2026-07-12 03:28:18 +02:00
|
|
|
|
2026-07-11 19:20:22 +02:00
|
|
|
orphan = venue_ids - journal_ids
|
|
|
|
|
if orphan:
|
|
|
|
|
r.passed = False
|
|
|
|
|
for cid in sorted(orphan):
|
|
|
|
|
r.add(f"clientOrderId={cid} has no exec_journal row")
|
|
|
|
|
return r
|
|
|
|
|
|
|
|
|
|
def _check_journal_without_venue(self) -> CheckResult:
|
|
|
|
|
"""(b) BRIDGE exec_journal row without venue order."""
|
|
|
|
|
r = CheckResult("b", "BRIDGE exec_journal row ↔ venue order")
|
2026-07-12 03:28:18 +02:00
|
|
|
|
|
|
|
|
# 1. Check exec_journal exists
|
|
|
|
|
exists_r = self.ch.table_exists("exec_journal", CH_DB_UV)
|
|
|
|
|
if exists_r.error:
|
|
|
|
|
r.passed = False
|
|
|
|
|
r.error = exists_r.error
|
|
|
|
|
return r
|
|
|
|
|
if not exists_r.rows:
|
2026-07-11 19:20:22 +02:00
|
|
|
return r
|
2026-07-12 03:28:18 +02:00
|
|
|
|
|
|
|
|
# 2. Get recent journal rows (not suppressed = still live)
|
2026-07-11 19:20:22 +02:00
|
|
|
sql = (
|
2026-07-12 03:28:18 +02:00
|
|
|
f"SELECT u_prefix_client_id, trade_id, suppressed "
|
2026-07-11 19:20:22 +02:00
|
|
|
f"FROM {CH_DB_UV}.exec_journal "
|
2026-07-12 03:28:18 +02:00
|
|
|
f"WHERE timestamp >= now() - INTERVAL {self.lookback_hours} HOUR "
|
|
|
|
|
f"AND suppressed = 0 "
|
|
|
|
|
f"ORDER BY timestamp DESC"
|
2026-07-11 19:20:22 +02:00
|
|
|
)
|
2026-07-12 03:28:18 +02:00
|
|
|
jr = self.ch.query(sql)
|
|
|
|
|
if jr.error:
|
|
|
|
|
r.passed = False
|
|
|
|
|
r.error = jr.error
|
|
|
|
|
return r
|
|
|
|
|
if not jr.rows:
|
2026-07-11 19:20:22 +02:00
|
|
|
return r
|
2026-07-12 03:28:18 +02:00
|
|
|
|
2026-07-11 19:20:22 +02:00
|
|
|
journal_ids: set[str] = set()
|
2026-07-12 03:28:18 +02:00
|
|
|
for row in jr.rows:
|
|
|
|
|
cid = str(row.get("u_prefix_client_id") or "")
|
2026-07-11 19:20:22 +02:00
|
|
|
if cid:
|
|
|
|
|
journal_ids.add(cid)
|
2026-07-12 03:28:18 +02:00
|
|
|
|
|
|
|
|
# 3. Fetch venue open orders
|
|
|
|
|
vst_r = self.vst.open_orders()
|
|
|
|
|
if vst_r.error:
|
|
|
|
|
r.passed = False
|
|
|
|
|
r.error = vst_r.error
|
|
|
|
|
return r
|
|
|
|
|
|
2026-07-11 19:20:22 +02:00
|
|
|
venue_ids: set[str] = set()
|
2026-07-12 03:28:18 +02:00
|
|
|
for o in vst_r.items:
|
2026-07-11 19:20:22 +02:00
|
|
|
cid = str(
|
|
|
|
|
o.get("clientOrderId")
|
|
|
|
|
or o.get("clientOrderID")
|
|
|
|
|
or o.get("c")
|
|
|
|
|
or ""
|
|
|
|
|
)
|
|
|
|
|
if cid:
|
|
|
|
|
venue_ids.add(cid)
|
2026-07-12 03:28:18 +02:00
|
|
|
|
2026-07-11 19:20:22 +02:00
|
|
|
missing = journal_ids - venue_ids
|
|
|
|
|
if missing:
|
|
|
|
|
r.passed = False
|
|
|
|
|
for cid in sorted(missing):
|
2026-07-12 03:28:18 +02:00
|
|
|
r.add(f"exec_journal u_prefix_client_id={cid} not on venue")
|
2026-07-11 19:20:22 +02:00
|
|
|
return r
|
|
|
|
|
|
|
|
|
|
def _check_u_prefix(self) -> CheckResult:
|
|
|
|
|
"""(c) Venue order without 'u-' prefix in clientOrderId."""
|
|
|
|
|
r = CheckResult("c", "Venue order u- prefix on clientOrderId")
|
2026-07-12 03:28:18 +02:00
|
|
|
vst_r = self.vst.open_orders()
|
|
|
|
|
if vst_r.error:
|
|
|
|
|
r.passed = False
|
|
|
|
|
r.error = vst_r.error
|
|
|
|
|
return r
|
2026-07-11 19:20:22 +02:00
|
|
|
bad: list[str] = []
|
2026-07-12 03:28:18 +02:00
|
|
|
for o in vst_r.items:
|
2026-07-11 19:20:22 +02:00
|
|
|
cid = str(
|
|
|
|
|
o.get("clientOrderId")
|
|
|
|
|
or o.get("clientOrderID")
|
|
|
|
|
or o.get("c")
|
|
|
|
|
or ""
|
|
|
|
|
)
|
|
|
|
|
if cid and not cid.startswith("u-"):
|
|
|
|
|
tid = o.get("tradeId") or o.get("orderId") or "(no tradeId)"
|
|
|
|
|
bad.append(f"clientOrderId={cid} tradeId/orderId={tid}")
|
|
|
|
|
if bad:
|
|
|
|
|
r.passed = False
|
|
|
|
|
for b in bad:
|
|
|
|
|
r.add(b)
|
|
|
|
|
return r
|
|
|
|
|
|
|
|
|
|
def _check_ch_write_outside_uv(self) -> CheckResult:
|
|
|
|
|
"""(d) CH write outside dolphin_uv.* from runner window."""
|
|
|
|
|
r = CheckResult("d", "CH writes only within dolphin_uv.*")
|
2026-07-12 03:28:18 +02:00
|
|
|
col = "current_database"
|
|
|
|
|
# Exclude known non-UV databases (BLUE OBF writer, system, etc.)
|
|
|
|
|
allowed = [CH_DB_UV] + self.allowlist_dbs
|
|
|
|
|
db_filter = " AND ".join(f"{col} != '{db}'" for db in allowed)
|
2026-07-11 19:20:22 +02:00
|
|
|
sql = (
|
2026-07-12 03:28:18 +02:00
|
|
|
f"SELECT query, {col} AS db, event_time "
|
2026-07-11 19:20:22 +02:00
|
|
|
f"FROM system.query_log "
|
|
|
|
|
f"WHERE type = 'QueryFinish' "
|
|
|
|
|
f"AND query LIKE 'INSERT%' "
|
2026-07-12 03:28:18 +02:00
|
|
|
f"AND {db_filter} "
|
2026-07-11 19:20:22 +02:00
|
|
|
f"AND event_time >= now() - INTERVAL {self.lookback_hours} HOUR "
|
|
|
|
|
f"ORDER BY event_time DESC "
|
|
|
|
|
f"LIMIT {EVIDENCE_LIMIT}"
|
|
|
|
|
)
|
2026-07-12 03:28:18 +02:00
|
|
|
qr = self.ch.query(sql)
|
|
|
|
|
if qr.error:
|
|
|
|
|
r.passed = False
|
|
|
|
|
r.error = qr.error
|
|
|
|
|
return r
|
|
|
|
|
if qr.rows:
|
2026-07-11 19:20:22 +02:00
|
|
|
r.passed = False
|
2026-07-12 03:28:18 +02:00
|
|
|
for row in qr.rows:
|
|
|
|
|
db = row.get("db") or row.get("current_database") or "?"
|
2026-07-11 19:20:22 +02:00
|
|
|
q = (row.get("query") or "")[:120]
|
2026-07-12 03:28:18 +02:00
|
|
|
r.add(f"INSERT into {db}: {q}")
|
2026-07-11 19:20:22 +02:00
|
|
|
return r
|
|
|
|
|
|
|
|
|
|
def _check_tripwire_ok(self) -> CheckResult:
|
2026-07-12 03:28:18 +02:00
|
|
|
"""(e) tripwire_ok=false occurrences in anomaly_events.
|
|
|
|
|
|
|
|
|
|
Real schema: dolphin_violet.anomaly_events uses 'ts' (DateTime64(6)),
|
|
|
|
|
not 'timestamp'. Verified by DESCRIBE TABLE.
|
|
|
|
|
"""
|
2026-07-11 19:20:22 +02:00
|
|
|
r = CheckResult("e", "tripwire_ok=false occurrences")
|
|
|
|
|
sql = (
|
|
|
|
|
f"SELECT ts, decision_id, trade_id, symbol, sensor, detail "
|
|
|
|
|
f"FROM {CH_DB_VIOLET}.anomaly_events "
|
|
|
|
|
f"WHERE anomaly = 'tripwire' "
|
|
|
|
|
f"AND ts >= now() - INTERVAL {self.lookback_hours} HOUR "
|
|
|
|
|
f"ORDER BY ts DESC "
|
|
|
|
|
f"LIMIT {EVIDENCE_LIMIT}"
|
|
|
|
|
)
|
2026-07-12 03:28:18 +02:00
|
|
|
qr = self.ch.query(sql)
|
|
|
|
|
if qr.error:
|
|
|
|
|
r.passed = False
|
|
|
|
|
r.error = qr.error
|
|
|
|
|
return r
|
|
|
|
|
if qr.rows:
|
2026-07-11 19:20:22 +02:00
|
|
|
r.passed = False
|
2026-07-12 03:28:18 +02:00
|
|
|
for row in qr.rows:
|
2026-07-11 19:20:22 +02:00
|
|
|
ts = row.get("ts", "?")
|
|
|
|
|
tid = row.get("trade_id", "?")
|
|
|
|
|
det = (row.get("detail") or "")[:80]
|
|
|
|
|
r.add(f"ts={ts} trade_id={tid} detail={det}")
|
|
|
|
|
return r
|
|
|
|
|
|
|
|
|
|
# ── Reporting ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def _report(self) -> int:
|
|
|
|
|
passed = 0
|
|
|
|
|
total = len(self.results)
|
|
|
|
|
for r in self.results:
|
|
|
|
|
if r.passed:
|
|
|
|
|
passed += 1
|
|
|
|
|
for line in r.summary():
|
|
|
|
|
print(line)
|
|
|
|
|
ok = passed == total
|
|
|
|
|
print(f"\nResult: {'PASS' if ok else 'FAIL'} ({passed}/{total} checks passed)")
|
|
|
|
|
return 0 if ok else 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── CLI entrypoint ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
|
|
|
import argparse
|
|
|
|
|
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
description="UV Anomaly Sentinel — read-only auditor (§4 checks)"
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--check",
|
|
|
|
|
nargs="+",
|
|
|
|
|
choices=["a", "b", "c", "d", "e"],
|
|
|
|
|
default=["a", "b", "c", "d", "e"],
|
|
|
|
|
help="Which checks to run",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--verbose", "-v", action="store_true", help="Show evidence rows"
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--lookback",
|
|
|
|
|
type=int,
|
|
|
|
|
default=LOOKBACK_HOURS,
|
|
|
|
|
help=f"Lookback hours (default {LOOKBACK_HOURS})",
|
|
|
|
|
)
|
|
|
|
|
parsed = parser.parse_args(argv)
|
|
|
|
|
|
|
|
|
|
sentinel = UvAnomalySentinel(
|
|
|
|
|
enabled=set(parsed.check),
|
|
|
|
|
lookback_hours=parsed.lookback,
|
|
|
|
|
verbose=parsed.verbose,
|
|
|
|
|
)
|
|
|
|
|
return sentinel.run()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
sys.exit(main())
|