Files
quant-os/adapters/xttrader_live.py
T

1305 lines
48 KiB
Python

"""Guarded XtTrader live adapter.
The adapter is intentionally inert by default. Real submissions require both
``allow_live_orders=True`` at construction and ``confirm_live=True`` on every
submit/cancel call. Queries, callback normalization, idempotency and fresh
instance reconnects remain available independently.
"""
from __future__ import annotations
import datetime as dt
import hashlib
import importlib
import json
import math
import numbers
import operator
import os
import re
import tempfile
import threading
from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional
from quant60.portable_core import convert_symbol
class XtTraderAdapterError(RuntimeError):
"""Base adapter failure."""
class NotConnectedError(XtTraderAdapterError):
"""Operation requires a ready and subscribed QMT connection."""
class LiveOrderBlocked(XtTraderAdapterError):
"""Safety gate prevented a real broker mutation."""
class IdempotencyConflict(XtTraderAdapterError):
"""A client order id was reused for a different payload."""
class ReconnectFailed(XtTraderAdapterError):
"""Fresh-client reconnect attempts were exhausted."""
STATUS_BY_CODE = {
48: "NEW", # ORDER_UNREPORTED
49: "NEW", # ORDER_WAIT_REPORTING
50: "ACCEPTED", # ORDER_REPORTED
51: "CANCEL_PENDING", # ORDER_REPORTED_CANCEL
52: "CANCEL_PENDING", # ORDER_PARTSUCC_CANCEL
53: "CANCELLED", # ORDER_PART_CANCEL
54: "CANCELLED",
55: "PARTIALLY_FILLED",
56: "FILLED",
57: "REJECTED",
255: "UNKNOWN",
}
TERMINAL_STATES = {
"CANCELLED",
"FILLED",
"REJECTED",
}
_BROKER_STATE_TRANSITIONS = {
"SUBMITTING": {
"NEW",
"ACCEPTED",
"PARTIALLY_FILLED",
"FILLED",
"CANCEL_PENDING",
"CANCELLED",
"REJECTED",
"UNKNOWN",
},
"NEW": {
"NEW",
"ACCEPTED",
"PARTIALLY_FILLED",
"FILLED",
"CANCEL_PENDING",
"CANCELLED",
"REJECTED",
"UNKNOWN",
},
"ACCEPTED": {
"ACCEPTED",
"PARTIALLY_FILLED",
"FILLED",
"CANCEL_PENDING",
"CANCELLED",
"REJECTED",
"UNKNOWN",
},
"PARTIALLY_FILLED": {
"PARTIALLY_FILLED",
"FILLED",
"CANCEL_PENDING",
"CANCELLED",
"UNKNOWN",
},
"CANCEL_PENDING": {
"CANCEL_PENDING",
"PARTIALLY_FILLED",
"FILLED",
"CANCELLED",
"UNKNOWN",
},
"UNKNOWN": {
"NEW",
"ACCEPTED",
"PARTIALLY_FILLED",
"FILLED",
"CANCEL_PENDING",
"CANCELLED",
"REJECTED",
"UNKNOWN",
},
"FILLED": {"FILLED"},
"CANCELLED": {"CANCELLED"},
"REJECTED": {"REJECTED"},
}
# QMT remarks are commonly limited to 24 ASCII characters. Reserve four for
# ``q60:`` so broker round-trips preserve the full idempotency key.
_CLIENT_ID_RE = re.compile(r"^[A-Za-z0-9_.-]{1,20}$")
_REMARK_PREFIX = "q60:"
_LIVE_POLICY_FLAGS = (
"reconciliation_safe",
"account_allowed",
"within_notional_limit",
"within_order_rate_limit",
"operator_approved",
"kill_switch_ready",
"compliance_approved",
)
_MAX_POLICY_AGE_SECONDS = 60.0
_MAX_POLICY_FUTURE_SECONDS = 5.0
def map_order_status(code: Any) -> str:
"""Map the documented XtQuant order status constants to domain states."""
try:
return STATUS_BY_CODE[int(code)]
except (KeyError, TypeError, ValueError):
return "UNKNOWN"
def _field(obj: Any, names: Iterable[str], default: Any = None) -> Any:
for name in names:
if isinstance(obj, Mapping) and name in obj:
value = obj[name]
elif hasattr(obj, name):
value = getattr(obj, name)
else:
continue
if value is not None:
return value
return default
def _payload_hash(payload: Mapping[str, Any]) -> str:
encoded = json.dumps(
dict(payload),
ensure_ascii=True,
separators=(",", ":"),
sort_keys=True,
).encode("ascii")
return hashlib.sha256(encoded).hexdigest()
def _client_id_from_remark(remark: Any) -> Optional[str]:
text = str(remark or "")
if not text.startswith(_REMARK_PREFIX):
return None
value = text[len(_REMARK_PREFIX) :]
return value or None
def _strict_bool(value: Any, name: str) -> bool:
if type(value) is not bool:
raise TypeError(f"{name} must be a bool, not {type(value).__name__}")
return value
class IdempotencyJournal:
"""Thread-safe JSON journal; an omitted path gives an in-memory journal."""
def __init__(self, path: Optional[os.PathLike[str] | str] = None):
self.path = Path(path).expanduser().resolve() if path else None
self._lock = threading.RLock()
self._records: Dict[str, Dict[str, Any]] = {}
if self.path and self.path.exists():
with self.path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
if not isinstance(data, dict):
raise ValueError("idempotency journal must contain a JSON object")
self._records = data
def get(self, client_order_id: str) -> Optional[Dict[str, Any]]:
with self._lock:
record = self._records.get(client_order_id)
return dict(record) if record is not None else None
def put(self, client_order_id: str, record: Mapping[str, Any]) -> None:
with self._lock:
self._records[client_order_id] = dict(record)
self._persist()
def update(self, client_order_id: str, **updates: Any) -> Dict[str, Any]:
with self._lock:
record = dict(self._records.get(client_order_id, {}))
record.update(updates)
self._records[client_order_id] = record
self._persist()
return dict(record)
def find_by_seq(self, seq: Any) -> Optional[tuple[str, Dict[str, Any]]]:
with self._lock:
for client_id, record in self._records.items():
if record.get("request_seq") == seq:
return client_id, dict(record)
return None
def find_by_broker_order_id(
self, broker_order_id: Any
) -> Optional[tuple[str, Dict[str, Any]]]:
with self._lock:
for client_id, record in self._records.items():
if record.get("broker_order_id") == broker_order_id:
return client_id, dict(record)
return None
def _persist(self) -> None:
if self.path is None:
return
self.path.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(
self._records,
ensure_ascii=False,
indent=2,
sort_keys=True,
)
descriptor, temp_name = tempfile.mkstemp(
prefix=self.path.name + ".",
suffix=".tmp",
dir=str(self.path.parent),
)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_name, self.path)
finally:
if os.path.exists(temp_name):
os.unlink(temp_name)
class _CallbackProxy:
def __init__(self, adapter: "XtTraderLiveAdapter"):
self.adapter = adapter
def on_connected(self) -> None:
self.adapter._callback_connected()
def on_disconnected(self) -> None:
self.adapter._callback_disconnected()
def on_account_status(self, status: Any) -> None:
self.adapter._account_status = status
def on_stock_asset(self, asset: Any) -> None:
self.adapter._asset = asset
def on_stock_order(self, order: Any) -> None:
self.adapter._ingest_order(order)
def on_stock_trade(self, trade: Any) -> None:
self.adapter._ingest_trade(trade)
def on_stock_position(self, position: Any) -> None:
code = str(_field(position, ["stock_code"], ""))
if code:
snapshot = self.adapter._normalize_position(position)
self.adapter._positions[snapshot["symbol"]] = snapshot
def on_order_error(self, error: Any) -> None:
self.adapter._ingest_order_error(error)
def on_cancel_error(self, error: Any) -> None:
self.adapter._errors.append(
{
"kind": "cancel",
"order_id": _field(error, ["order_id"], None),
"error_id": _field(error, ["error_id"], None),
"message": str(_field(error, ["error_msg"], "")),
}
)
def on_order_stock_async_response(self, response: Any) -> None:
self.adapter._ingest_submit_response(response)
def on_cancel_order_stock_async_response(self, response: Any) -> None:
self.adapter._cancel_responses.append(
{
"order_id": _field(response, ["order_id"], None),
"result": _field(response, ["cancel_result"], None),
"seq": _field(response, ["seq"], None),
}
)
def on_smt_appointment_async_response(self, response: Any) -> None:
del response
class XtTraderLiveAdapter:
"""Normalized, reconnectable and idempotent XtTrader boundary."""
def __init__(
self,
*,
trader_factory: Callable[[], Any],
account: Any,
constants: Any,
allow_live_orders: bool = False,
live_guard: Optional[
Callable[[Mapping[str, Any]], Mapping[str, Any]]
] = None,
journal_path: Optional[os.PathLike[str] | str] = None,
strategy_name: str = "quant60",
):
self.allow_live_orders = _strict_bool(
allow_live_orders, "allow_live_orders"
)
if live_guard is not None and not callable(live_guard):
raise TypeError("live_guard must be callable")
if self.allow_live_orders and live_guard is None:
raise TypeError(
"allow_live_orders=True requires a callable live_guard"
)
self.live_guard = live_guard
self._trader_factory = trader_factory
self.account = account
self.constants = constants
self.strategy_name = str(strategy_name)
self.journal = IdempotencyJournal(journal_path)
self._lock = threading.RLock()
self._callback_lock = threading.RLock()
self._trader: Any = None
self._state = "NEW"
self._orders: Dict[Any, Dict[str, Any]] = {}
self._trades: Dict[Any, Dict[str, Any]] = {}
self._positions: Dict[str, Dict[str, Any]] = {}
self._asset: Any = None
self._account_status: Any = None
self._errors: List[Dict[str, Any]] = []
self._cancel_responses: List[Dict[str, Any]] = []
self._orphan_submit_responses: Dict[Any, Any] = {}
self._orphan_order_errors: List[Dict[str, Any]] = []
self._live_authorization_audit: List[Dict[str, Any]] = []
self._disconnect_seen = False
@classmethod
def from_xtquant(
cls,
*,
userdata_path: os.PathLike[str] | str,
session_id: int,
account_id: str,
allow_live_orders: bool = False,
live_guard: Optional[
Callable[[Mapping[str, Any]], Mapping[str, Any]]
] = None,
journal_path: Optional[os.PathLike[str] | str] = None,
strategy_name: str = "quant60",
) -> "XtTraderLiveAdapter":
"""Create a lazy real-runtime adapter without connecting or ordering."""
_strict_bool(allow_live_orders, "allow_live_orders")
if live_guard is not None and not callable(live_guard):
raise TypeError("live_guard must be callable")
if allow_live_orders and live_guard is None:
raise TypeError(
"allow_live_orders=True requires a callable live_guard"
)
try:
xttrader = importlib.import_module("xtquant.xttrader")
xttype = importlib.import_module("xtquant.xttype")
constants = importlib.import_module("xtquant.xtconstant")
except Exception as exc:
raise XtTraderAdapterError(f"xtquant import failed: {exc}") from exc
path = str(Path(userdata_path).expanduser())
account = xttype.StockAccount(account_id)
def factory() -> Any:
return xttrader.XtQuantTrader(path, int(session_id))
return cls(
trader_factory=factory,
account=account,
constants=constants,
allow_live_orders=allow_live_orders,
live_guard=live_guard,
journal_path=journal_path,
strategy_name=strategy_name,
)
@property
def state(self) -> str:
return self._state
@property
def errors(self) -> List[Dict[str, Any]]:
with self._callback_lock:
return [dict(item) for item in self._errors]
@property
def live_authorization_audit(self) -> List[Dict[str, Any]]:
with self._callback_lock:
return [
{
"intent": dict(item["intent"]),
"decision": dict(item["decision"]),
"outcome": item["outcome"],
}
for item in self._live_authorization_audit
]
def connect(self) -> None:
"""Create, start, connect, subscribe and verify a fresh trader."""
with self._lock:
if self._state == "READY":
return
self._connect_fresh()
def _connect_fresh(self) -> None:
self._dispose_trader()
self._state = "CONNECTING"
self._disconnect_seen = False
trader = self._trader_factory()
if trader is None:
self._state = "FAILED"
raise NotConnectedError("trader_factory returned None")
self._trader = trader
trader.register_callback(_CallbackProxy(self))
trader.start()
connect_code = trader.connect()
if connect_code != 0:
self._state = "FAILED"
self._dispose_trader()
raise NotConnectedError(f"XtTrader connect failed with code {connect_code}")
subscribe_code = trader.subscribe(self.account)
if subscribe_code != 0:
self._state = "FAILED"
self._dispose_trader()
raise NotConnectedError(
f"XtTrader account subscribe failed with code {subscribe_code}"
)
# Read-only broker truth is the readiness barrier, not merely TCP state.
asset = trader.query_stock_asset(self.account)
orders = trader.query_stock_orders(self.account)
if asset is None or orders is None or self._disconnect_seen:
self._state = "FAILED"
self._dispose_trader()
raise NotConnectedError(
"XtTrader connected but account snapshot query failed"
)
try:
self._require_query_account(asset, "asset")
self._asset = asset
for order in orders:
self._ingest_order(order)
except Exception:
self._state = "FAILED"
self._dispose_trader()
raise
self._state = "READY"
def reconnect(self, attempts: int = 1) -> None:
"""Reconnect with fresh XtTrader instances or fail before any order."""
if int(attempts) < 1:
raise ValueError("attempts must be at least one")
last_error: Optional[Exception] = None
for _ in range(int(attempts)):
try:
with self._lock:
self._connect_fresh()
return
except Exception as exc:
last_error = exc
self._state = "FAILED"
raise ReconnectFailed(
f"XtTrader reconnect failed after {attempts} fresh attempt(s): {last_error}"
) from last_error
def close(self) -> None:
with self._lock:
self._dispose_trader()
self._state = "CLOSED"
def _dispose_trader(self) -> None:
trader, self._trader = self._trader, None
if trader is not None:
try:
trader.stop()
except Exception:
pass
def _callback_connected(self) -> None:
if self._state not in {"READY", "CONNECTING"}:
self._state = "CONNECTED_UNVERIFIED"
def _callback_disconnected(self) -> None:
# Never auto-submit/reconnect from a callback. The caller must invoke
# reconnect and pass the account snapshot readiness barrier again.
self._disconnect_seen = True
self._state = "DISCONNECTED"
def _require_ready(self) -> Any:
if self._state != "READY" or self._trader is None:
raise NotConnectedError(
f"XtTrader is not ready (state={self._state}); no broker call made"
)
return self._trader
def _authorize_live_intent(
self, intent: Mapping[str, Any]
) -> Dict[str, Any]:
guard = self.live_guard
if not callable(guard):
raise LiveOrderBlocked(
"live mutation blocked: callable live_guard is unavailable"
)
normalized_intent = dict(intent)
try:
# The guard receives a copy so it cannot rewrite the account/action
# that the adapter subsequently binds into its audit evidence.
decision = guard(dict(normalized_intent))
except Exception as exc:
raise LiveOrderBlocked(
f"live_guard raised {type(exc).__name__}: {exc}"
) from exc
if not isinstance(decision, Mapping):
raise LiveOrderBlocked(
"live_guard must return a structured mapping"
)
account_id = normalized_intent.get("account_id")
if not isinstance(account_id, str) or not account_id.strip():
raise LiveOrderBlocked(
"live intent account_id must be a non-empty string"
)
normalized_intent["account_id"] = account_id.strip()
authorization_id = decision.get("authorization_id")
snapshot_id = decision.get("snapshot_id")
if not isinstance(authorization_id, str) or not authorization_id.strip():
raise LiveOrderBlocked(
"live_guard authorization_id must be a non-empty string"
)
if not isinstance(snapshot_id, str) or not snapshot_id.strip():
raise LiveOrderBlocked(
"live_guard snapshot_id must be a non-empty string"
)
raw_as_of = decision.get("as_of")
if isinstance(raw_as_of, dt.datetime):
as_of = raw_as_of
elif isinstance(raw_as_of, str):
value = raw_as_of.strip()
if value.endswith(("Z", "z")):
value = value[:-1] + "+00:00"
try:
as_of = dt.datetime.fromisoformat(value)
except ValueError as exc:
raise LiveOrderBlocked(
"live_guard as_of must be an ISO-8601 datetime"
) from exc
else:
raise LiveOrderBlocked(
"live_guard as_of must be a timezone-aware datetime"
)
if as_of.tzinfo is None or as_of.utcoffset() is None:
raise LiveOrderBlocked(
"live_guard as_of must be timezone-aware"
)
now = dt.datetime.now(dt.timezone.utc)
age_seconds = (
now - as_of.astimezone(dt.timezone.utc)
).total_seconds()
if age_seconds > _MAX_POLICY_AGE_SECONDS:
raise LiveOrderBlocked(
"live_guard decision is stale (older than 60 seconds)"
)
if age_seconds < -_MAX_POLICY_FUTURE_SECONDS:
raise LiveOrderBlocked(
"live_guard as_of is more than 5 seconds in the future"
)
for field in _LIVE_POLICY_FLAGS:
value = decision.get(field)
if type(value) is not bool or value is not True:
raise LiveOrderBlocked(
f"live_guard {field} must be exactly bool True"
)
normalized_decision = {
"authorization_id": authorization_id.strip(),
"snapshot_id": snapshot_id.strip(),
"as_of": as_of.astimezone(dt.timezone.utc).isoformat(),
"account_id": normalized_intent["account_id"],
**{field: True for field in _LIVE_POLICY_FLAGS},
}
with self._callback_lock:
self._live_authorization_audit.append(
{
"intent": normalized_intent,
"decision": normalized_decision,
"outcome": "AUTHORIZED",
}
)
return normalized_decision
def _record_live_authorization_outcome(
self, authorization_id: str, outcome: str
) -> None:
with self._callback_lock:
for item in reversed(self._live_authorization_audit):
if (
item["decision"].get("authorization_id")
== authorization_id
):
item["outcome"] = outcome
return
raise RuntimeError(
f"authorization audit record not found: {authorization_id}"
)
def _normalized_account_id(self) -> str:
value = _field(self.account, ["account_id"], None)
if not isinstance(value, str) or not value.strip():
raise LiveOrderBlocked(
"live mutation requires a non-empty account_id"
)
return value.strip()
def _require_query_account(self, item: Any, kind: str) -> str:
expected = str(
_field(self.account, ["account_id"], "") or ""
).strip()
observed = str(_field(item, ["account_id"], "") or "").strip()
if not expected or observed != expected:
self._state = "DEGRADED"
raise NotConnectedError(
f"{kind} account_id does not match the configured account"
)
return observed
def query_asset(self) -> Dict[str, Any]:
trader = self._require_ready()
raw = trader.query_stock_asset(self.account)
if raw is None:
self._state = "DEGRADED"
raise NotConnectedError("asset query returned None")
self._require_query_account(raw, "asset")
self._asset = raw
return {
"account_id": str(_field(raw, ["account_id"], "")),
"cash": float(_field(raw, ["cash"], 0.0) or 0.0),
"market_value": float(_field(raw, ["market_value"], 0.0) or 0.0),
"total_asset": float(_field(raw, ["total_asset"], 0.0) or 0.0),
}
def query_positions(self) -> List[Dict[str, Any]]:
trader = self._require_ready()
raw = trader.query_stock_positions(self.account)
if raw is None:
self._state = "DEGRADED"
raise NotConnectedError("positions query returned None")
positions = [self._normalize_position(item) for item in raw]
self._positions = {item["symbol"]: item for item in positions}
return positions
def query_orders(self, cancelable_only: bool = False) -> List[Dict[str, Any]]:
trader = self._require_ready()
raw = trader.query_stock_orders(self.account, bool(cancelable_only))
if raw is None:
self._state = "DEGRADED"
raise NotConnectedError("orders query returned None")
output = []
for item in raw:
output.append(self._ingest_order(item))
return output
def query_trades(self) -> List[Dict[str, Any]]:
trader = self._require_ready()
raw = trader.query_stock_trades(self.account)
if raw is None:
self._state = "DEGRADED"
raise NotConnectedError("trades query returned None")
return [self._ingest_trade(item) for item in raw]
def _normalize_position(self, item: Any) -> Dict[str, Any]:
self._require_query_account(item, "position")
raw_symbol = str(_field(item, ["stock_code"], ""))
return {
"account_id": str(_field(item, ["account_id"], "") or ""),
"symbol": convert_symbol(raw_symbol, "canonical") if raw_symbol else "",
"volume": int(_field(item, ["volume"], 0) or 0),
"sellable": int(_field(item, ["can_use_volume"], 0) or 0),
"market_value": float(_field(item, ["market_value"], 0.0) or 0.0),
"avg_price": float(_field(item, ["open_price", "avg_price"], 0.0) or 0.0),
}
def _normalize_order(self, item: Any) -> Dict[str, Any]:
self._require_query_account(item, "order")
order_type = int(_field(item, ["order_type"], 0) or 0)
buy_type = int(getattr(self.constants, "STOCK_BUY", 23))
sell_type = int(getattr(self.constants, "STOCK_SELL", 24))
side = "BUY" if order_type == buy_type else "SELL" if order_type == sell_type else "UNKNOWN"
remark = str(_field(item, ["order_remark"], "") or "")
raw_symbol = str(_field(item, ["stock_code"], ""))
return {
"account_id": str(_field(item, ["account_id"], "") or ""),
"broker_order_id": _field(item, ["order_id"], None),
"broker_system_id": str(_field(item, ["order_sysid"], "") or ""),
"client_order_id": _client_id_from_remark(remark),
"symbol": convert_symbol(raw_symbol, "canonical") if raw_symbol else "",
"side": side,
"quantity": int(_field(item, ["order_volume"], 0) or 0),
"filled_quantity": int(_field(item, ["traded_volume"], 0) or 0),
"limit_price": float(_field(item, ["price"], 0.0) or 0.0),
"average_fill_price": float(
_field(item, ["traded_price"], 0.0) or 0.0
),
"state": map_order_status(_field(item, ["order_status"], 255)),
"status_message": str(_field(item, ["status_msg"], "") or ""),
"order_remark": remark,
}
def _normalize_trade(self, item: Any) -> Dict[str, Any]:
self._require_query_account(item, "trade")
trade_id = _field(item, ["traded_id", "trade_id"], None)
raw_symbol = str(_field(item, ["stock_code"], ""))
order_type = int(_field(item, ["order_type"], 0) or 0)
buy_type = int(getattr(self.constants, "STOCK_BUY", 23))
sell_type = int(getattr(self.constants, "STOCK_SELL", 24))
side = (
"BUY"
if order_type == buy_type
else "SELL"
if order_type == sell_type
else "UNKNOWN"
)
return {
"account_id": str(_field(item, ["account_id"], "") or ""),
"trade_id": trade_id,
"broker_order_id": _field(item, ["order_id"], None),
"symbol": convert_symbol(raw_symbol, "canonical") if raw_symbol else "",
"side": side,
"quantity": int(_field(item, ["traded_volume"], 0) or 0),
"price": float(_field(item, ["traded_price"], 0.0) or 0.0),
"amount": float(_field(item, ["traded_amount"], 0.0) or 0.0),
}
def _ingest_order(self, raw: Any) -> Dict[str, Any]:
order = self._normalize_order(raw)
order_id = order["broker_order_id"]
quantity = order["quantity"]
filled_quantity = order["filled_quantity"]
if (
quantity <= 0
or filled_quantity < 0
or filled_quantity > quantity
or (
order["state"] == "FILLED"
and filled_quantity != quantity
)
or (
order["state"] == "PARTIALLY_FILLED"
and not 0 < filled_quantity < quantity
)
):
self._state = "DEGRADED"
raise IdempotencyConflict(
f"invalid broker order quantities/state for {order_id}"
)
prior_order = (
self._orders.get(order_id) if order_id is not None else None
)
client_id = order.get("client_order_id")
if prior_order is not None:
prior_client_id = prior_order.get("client_order_id")
if client_id is None:
client_id = prior_client_id
order["client_order_id"] = prior_client_id
immutable_fields = (
"symbol",
"side",
"quantity",
"limit_price",
)
changed = [
field
for field in immutable_fields
if prior_order.get(field) != order.get(field)
]
prior_system_id = prior_order.get("broker_system_id")
current_system_id = order.get("broker_system_id")
if (
prior_client_id
and client_id
and prior_client_id != client_id
):
changed.append("client_order_id")
if (
prior_system_id
and current_system_id
and prior_system_id != current_system_id
):
changed.append("broker_system_id")
if changed:
self._state = "DEGRADED"
raise IdempotencyConflict(
f"broker order {order_id} changed immutable "
f"identity/payload fields: {sorted(set(changed))}"
)
if filled_quantity < prior_order["filled_quantity"]:
self._state = "DEGRADED"
raise IdempotencyConflict(
f"broker order {order_id} cumulative fill regressed "
f"from {prior_order['filled_quantity']} "
f"to {filled_quantity}"
)
allowed = _BROKER_STATE_TRANSITIONS.get(
prior_order["state"], set()
)
if order["state"] not in allowed:
self._state = "DEGRADED"
raise IdempotencyConflict(
f"broker order {order_id} state regressed illegally "
f"from {prior_order['state']} to {order['state']}"
)
if order == prior_order:
return dict(prior_order)
if client_id:
existing = self.journal.get(client_id)
payload = {
"symbol": order["symbol"],
"side": order["side"],
"quantity": order["quantity"],
"limit_price": order["limit_price"],
}
payload_digest = _payload_hash(payload)
if existing is None:
existing = {
"client_order_id": client_id,
"payload": payload,
"payload_hash": payload_digest,
}
self.journal.put(client_id, existing)
elif existing.get("payload_hash") != payload_digest:
self._state = "DEGRADED"
raise IdempotencyConflict(
f"broker payload conflicts with journal for {client_id}"
)
prior_snapshot = existing.get("broker_snapshot")
prior_filled = int(existing.get("filled_quantity", 0) or 0)
if filled_quantity < prior_filled:
self._state = "DEGRADED"
raise IdempotencyConflict(
f"broker order {order_id} journal cumulative fill "
f"regressed from {prior_filled} to {filled_quantity}"
)
prior_state = existing.get("state")
if prior_snapshot is not None and prior_state is not None:
allowed = _BROKER_STATE_TRANSITIONS.get(
str(prior_state), set()
)
if order["state"] not in allowed:
self._state = "DEGRADED"
raise IdempotencyConflict(
f"broker order {order_id} journal state regressed "
f"illegally from {prior_state} to {order['state']}"
)
elif (
prior_state in TERMINAL_STATES
and order["state"] != prior_state
):
self._state = "DEGRADED"
raise IdempotencyConflict(
f"terminal journal state {prior_state} conflicts "
f"with broker state {order['state']}"
)
prior_broker_id = existing.get("broker_order_id")
if (
prior_broker_id not in {None, order_id}
and order_id is not None
):
self._state = "DEGRADED"
raise IdempotencyConflict(
f"multiple broker orders share client id {client_id}"
)
self.journal.update(
client_id,
broker_order_id=order_id,
state=order["state"],
filled_quantity=filled_quantity,
broker_snapshot=order,
)
if order_id is not None:
self._orders[order_id] = order
return dict(order)
def _ingest_trade(self, raw: Any) -> Dict[str, Any]:
trade = self._normalize_trade(raw)
key = trade["trade_id"]
if key is None:
key = (
trade["broker_order_id"],
trade["symbol"],
trade["quantity"],
trade["price"],
)
self._trades[key] = trade
return dict(trade)
def _apply_submit_response_locked(self, response: Any) -> None:
seq = _field(response, ["seq"], None)
found = self.journal.find_by_seq(seq)
if found is None:
# The callback may race ahead of order_stock_async returning seq.
self._orphan_submit_responses[seq] = response
return
client_id, unused_record = found
del unused_record
broker_id = _field(response, ["order_id"], None)
message = str(_field(response, ["error_msg"], "") or "")
state = "ACCEPTED" if broker_id not in {None, -1, 0} and not message else "REJECTED"
current = self.journal.get(client_id) or {}
updates = {
"broker_order_id": broker_id,
"error_message": message,
}
current_state = current.get("state")
allowed = _BROKER_STATE_TRANSITIONS.get(
str(current_state), set()
)
if current_state is not None and state not in allowed:
self._state = "DEGRADED"
updates["callback_state_conflict"] = (
f"submit response {state} after {current_state}"
)
else:
updates["state"] = state
self.journal.update(client_id, **updates)
def _ingest_submit_response(self, response: Any) -> None:
with self._callback_lock:
self._apply_submit_response_locked(response)
def _apply_order_error_locked(self, item: Mapping[str, Any]) -> bool:
client_id = item.get("client_order_id")
found = None
if client_id:
record = self.journal.get(str(client_id))
if record is not None:
found = (str(client_id), record)
order_id = item.get("order_id")
if found is None and order_id not in {None, -1, 0}:
found = self.journal.find_by_broker_order_id(order_id)
seq = item.get("seq")
if found is None and seq is not None:
found = self.journal.find_by_seq(seq)
if found is None:
return False
matched_client_id, record = found
if record is None:
return False
current_state = record.get("state")
allowed = _BROKER_STATE_TRANSITIONS.get(
str(current_state), set()
)
if current_state is not None and "REJECTED" not in allowed:
self._state = "DEGRADED"
self.journal.update(
matched_client_id,
callback_state_conflict=(
f"order error REJECTED after {current_state}"
),
error_id=item.get("error_id"),
error_message=item.get("message", ""),
)
return True
updates = {
"state": "REJECTED",
"error_id": item.get("error_id"),
"error_message": item.get("message", ""),
}
if order_id not in {None, -1, 0}:
updates["broker_order_id"] = order_id
self.journal.update(matched_client_id, **updates)
return True
def _ingest_order_error(self, error: Any) -> None:
remark = _field(error, ["order_remark"], "")
item = {
"kind": "order",
"order_id": _field(error, ["order_id"], None),
"seq": _field(error, ["seq"], None),
"client_order_id": _client_id_from_remark(remark),
"error_id": _field(error, ["error_id"], None),
"message": str(_field(error, ["error_msg"], "")),
}
with self._callback_lock:
self._errors.append(item)
if not self._apply_order_error_locked(item):
# XtTrader may invoke the callback synchronously before
# order_stock_async returns its request sequence.
self._orphan_order_errors.append(item)
def _drain_order_errors_locked(self) -> None:
remaining = []
for item in self._orphan_order_errors:
if not self._apply_order_error_locked(item):
remaining.append(item)
self._orphan_order_errors = remaining
def _existing_broker_order(
self, client_order_id: str, payload: Mapping[str, Any]
) -> Optional[Dict[str, Any]]:
for order in self.query_orders():
if order.get("client_order_id") != client_order_id:
continue
comparable = {
"symbol": order["symbol"],
"side": order["side"],
"quantity": order["quantity"],
"limit_price": order["limit_price"],
}
if _payload_hash(comparable) != _payload_hash(payload):
raise IdempotencyConflict(
f"broker already has {client_order_id} with another payload"
)
return order
return None
def submit_order(
self,
*,
client_order_id: str,
symbol: str,
side: str,
quantity: int,
limit_price: float,
confirm_live: bool = False,
) -> Dict[str, Any]:
"""Idempotently plan or asynchronously submit one limit order."""
confirmed_live = _strict_bool(confirm_live, "confirm_live")
if not _CLIENT_ID_RE.fullmatch(client_order_id):
raise ValueError(
"client_order_id must be 1-20 ASCII letters/digits/_.-"
)
normalized_symbol = str(symbol).upper()
if not re.fullmatch(r"\d{6}\.(SH|SZ)", normalized_symbol):
raise ValueError("symbol must use QMT 600000.SH/000001.SZ form")
normalized_side = str(side).upper()
if normalized_side not in {"BUY", "SELL"}:
raise ValueError("side must be BUY or SELL")
if isinstance(quantity, bool):
raise ValueError(
"A-share quantity must be a positive integer 100-share lot"
)
try:
volume = operator.index(quantity)
except TypeError as exc:
raise ValueError(
"A-share quantity must be a positive integer 100-share lot"
) from exc
if volume <= 0 or volume % 100:
raise ValueError(
"A-share quantity must be a positive integer 100-share lot"
)
if isinstance(limit_price, bool) or not isinstance(
limit_price, numbers.Real
):
raise ValueError(
"limit_price must be a non-bool real number"
)
price = float(limit_price)
if not math.isfinite(price) or price <= 0:
raise ValueError("limit_price must be finite and positive")
payload = {
"symbol": convert_symbol(normalized_symbol, "canonical"),
"side": normalized_side,
"quantity": volume,
"limit_price": price,
}
digest = _payload_hash(payload)
# The entire read/check/recover/reserve/submit/persist transaction is
# serialized. The journal's own lock only protects individual calls;
# it cannot by itself prevent two threads from both seeing "missing".
with self._lock:
existing = self.journal.get(client_order_id)
if existing:
if existing.get("payload_hash") != digest:
raise IdempotencyConflict(
f"client_order_id {client_order_id} has another payload"
)
return existing
if not self.allow_live_orders:
record = {
"client_order_id": client_order_id,
"payload": payload,
"payload_hash": digest,
"state": "DRY_RUN",
"broker_order_id": None,
}
self.journal.put(client_order_id, record)
return record
if not confirmed_live:
raise LiveOrderBlocked(
"real order requires confirm_live=True for this submission"
)
trader = self._require_ready()
broker_existing = self._existing_broker_order(
client_order_id, payload
)
if broker_existing is not None:
record = {
"client_order_id": client_order_id,
"payload": payload,
"payload_hash": digest,
"state": broker_existing["state"],
"broker_order_id": broker_existing["broker_order_id"],
"recovered_from_broker": True,
}
self.journal.put(client_order_id, record)
return record
order_type = (
getattr(self.constants, "STOCK_BUY", 23)
if normalized_side == "BUY"
else getattr(self.constants, "STOCK_SELL", 24)
)
price_type = getattr(self.constants, "FIX_PRICE", 11)
live_policy = self._authorize_live_intent(
{
"action": "SUBMIT_ORDER",
"account_id": self._normalized_account_id(),
"client_order_id": client_order_id,
"symbol": payload["symbol"],
"side": normalized_side,
"quantity": volume,
"limit_price": price,
"notional": price * volume,
"strategy_name": self.strategy_name,
}
)
reservation = {
"client_order_id": client_order_id,
"payload": payload,
"payload_hash": digest,
"state": "SUBMITTING",
"request_seq": None,
"broker_order_id": None,
"live_policy": live_policy,
}
# Persist before crossing the broker mutation boundary. A crash or
# callback can then never turn a duplicate retry into a new order.
self.journal.put(client_order_id, reservation)
try:
seq = trader.order_stock_async(
self.account,
normalized_symbol,
order_type,
volume,
price_type,
price,
self.strategy_name,
_REMARK_PREFIX + client_order_id,
)
except BaseException as exc:
current = self.journal.get(client_order_id) or reservation
if current.get("state") == "SUBMITTING":
self.journal.update(
client_order_id,
state="UNKNOWN",
error_message=(
f"order_stock_async raised "
f"{type(exc).__name__}: {exc}"
),
)
raise
try:
if isinstance(seq, bool):
raise TypeError("bool is not a request sequence")
request_seq = operator.index(seq)
except Exception as exc:
self.journal.update(
client_order_id,
state="UNKNOWN",
error_message=(
"XtTrader returned a malformed async request "
f"sequence: {seq!r}"
),
)
raise XtTraderAdapterError(
"XtTrader returned a malformed async request "
f"sequence: {seq!r}"
) from exc
if request_seq <= 0:
self.journal.update(
client_order_id,
state="REJECTED",
error_message=(
"XtTrader rejected async submit before "
f"acknowledgement: {seq}"
),
)
raise XtTraderAdapterError(
f"XtTrader rejected async submit before acknowledgement: {seq}"
)
with self._callback_lock:
self.journal.update(
client_order_id, request_seq=request_seq
)
raced_response = self._orphan_submit_responses.pop(
request_seq, None
)
if raced_response is not None:
self._apply_submit_response_locked(raced_response)
self._drain_order_errors_locked()
current = self.journal.get(client_order_id) or reservation
if current.get("state") == "SUBMITTING":
current = self.journal.update(
client_order_id, state="NEW"
)
return current
def cancel_order(
self, broker_order_id: int, *, confirm_live: bool = False
) -> int:
"""Cancel synchronously behind the same explicit live safety gates."""
confirmed_live = _strict_bool(confirm_live, "confirm_live")
if isinstance(broker_order_id, bool):
raise ValueError(
"broker_order_id must be a positive integer"
)
try:
normalized_order_id = operator.index(broker_order_id)
except TypeError as exc:
raise ValueError(
"broker_order_id must be a positive integer"
) from exc
if normalized_order_id <= 0:
raise ValueError(
"broker_order_id must be a positive integer"
)
if not self.allow_live_orders or not confirmed_live:
raise LiveOrderBlocked(
"real cancel requires allow_live_orders and confirm_live=True"
)
trader = self._require_ready()
live_policy = self._authorize_live_intent(
{
"action": "CANCEL_ORDER",
"account_id": self._normalized_account_id(),
"broker_order_id": normalized_order_id,
"strategy_name": self.strategy_name,
}
)
try:
result = trader.cancel_order_stock(
self.account, normalized_order_id
)
except BaseException:
self._record_live_authorization_outcome(
live_policy["authorization_id"],
"BROKER_CALL_EXCEPTION",
)
raise
if result != 0:
self._record_live_authorization_outcome(
live_policy["authorization_id"],
"BROKER_REJECTED",
)
raise XtTraderAdapterError(
f"XtTrader cancel failed for {normalized_order_id}: {result}"
)
self._record_live_authorization_outcome(
live_policy["authorization_id"],
"BROKER_ACCEPTED",
)
# A zero code accepts the cancel request; a later order snapshot or
# callback must still confirm CANCELLED before funds/quantity are reused.
return 0