1305 lines
48 KiB
Python
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
|