L3 live validation surfaced a live-only defect: a working LIMIT order could not
be cancelled (MARKET never exercised cancel — synchronous fills).
Two coupled fixes:
- Rust FSM (lib.rs): propagate the venue's order id onto the active order for
ALL order types and event kinds (ACK/partial/full fill) whenever the exchange
provides one — orders are created at submit with an empty venue_order_id, so a
later cancel had no real id to reference. Only fills empty ids, never overwrites.
Requires recompiling libdita_v2_kernel.so.
- Backend (bingx_direct.py): add cancel(order) — a properly-signed DELETE by
orderId (clientOrderId fallback) with TRUTH-BASED confirmation: BingX can return
transient errors ("order not exist", dup-within-1s from an internal retry) even
when the order was removed, so the cancel succeeds iff the order is no longer
open on the venue. The venue adapter prefers this backend cancel over its raw
signed_delete fallback (which failed signature with an empty id).
Validated:
- Offline: 63 + new cancel-truth unit tests green (no regression post-recompile).
- Live VST: resting SHORT LIMIT (+5%) rests as ENTRY_WORKING, confirmed as a LIMIT
open order, cancel -> CANCEL_ACK -> IDLE, exchange flat (test_pink_limit_live.py).
- Live VST MARKET run-through re-validated post-recompile: PASS, exact capital
reconciliation, two-phase rows visible (ORDER_REQUESTED + ENTRY_FILLED/EXIT).
LIMIT remains execution-infra only; PINK policy stays MARKET. BLUE untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
504 lines
22 KiB
Python
504 lines
22 KiB
Python
"""Direct BingX execution adapter with no Nautilus Trader node dependency.
|
|
|
|
This adapter speaks BingX REST directly and keeps the exchange state
|
|
authoritative. It is intended for PINK live execution under the DITA boundary.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import math
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from decimal import Decimal, ROUND_DOWN
|
|
from typing import Any, Optional
|
|
|
|
from nautilus_trader.model.identifiers import InstrumentId
|
|
|
|
from prod.bingx.config import BingxExecClientConfig
|
|
from prod.bingx.config import BingxInstrumentProviderConfig
|
|
from prod.bingx.enums import BingxEnvironment
|
|
from prod.bingx.http import BingxHttpError
|
|
from prod.bingx.http import BingxHttpClient
|
|
from prod.bingx.instrument_provider import BingxInstrumentProvider
|
|
from prod.bingx.leverage import normalize_bingx_leverage_value
|
|
from prod.bingx.schemas import BingxOrderAck
|
|
from prod.bingx.schemas import unwrap_order_payload
|
|
from prod.clean_arch.dita import Intent, TradeSide, DecisionAction
|
|
from prod.clean_arch.ports.execution import ExchangeStateSnapshot
|
|
from prod.clean_arch.ports.execution import ExecutionReceipt
|
|
from prod.clean_arch.ports.execution import ExecutionPort
|
|
|
|
LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
def _rows_from_payload(payload: Any, *keys: str) -> list[dict[str, Any]]:
|
|
if isinstance(payload, list):
|
|
return [row for row in payload if isinstance(row, dict)]
|
|
if isinstance(payload, dict):
|
|
for key in keys:
|
|
rows = payload.get(key)
|
|
if isinstance(rows, list):
|
|
return [row for row in rows if isinstance(row, dict)]
|
|
return []
|
|
|
|
|
|
def _capital_from_balance_rows(rows: Any) -> float:
|
|
if not isinstance(rows, list):
|
|
return 0.0
|
|
for row in rows:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
capital = 0.0
|
|
for key in ("total", "balance", "equity", "availableMargin", "availableBalance", "walletBalance", "free"):
|
|
try:
|
|
capital = float(row.get(key, 0.0) or 0.0)
|
|
except Exception:
|
|
continue
|
|
if capital > 0 and math.isfinite(capital):
|
|
return capital
|
|
if capital > 0 and math.isfinite(capital):
|
|
return capital
|
|
return 0.0
|
|
|
|
|
|
def _position_notional_from_rows(rows: Any) -> float:
|
|
if not isinstance(rows, list):
|
|
return 0.0
|
|
total = 0.0
|
|
for row in rows:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
try:
|
|
qty = abs(
|
|
float(
|
|
row.get("positionAmt")
|
|
or row.get("positionQty")
|
|
or row.get("positionSize")
|
|
or row.get("quantity")
|
|
or row.get("pa")
|
|
or 0.0
|
|
)
|
|
)
|
|
if qty <= 0.0:
|
|
continue
|
|
notional = row.get("positionValue") or row.get("notional") or row.get("openNotional")
|
|
if notional is not None:
|
|
total += abs(float(notional or 0.0))
|
|
continue
|
|
entry = (
|
|
row.get("entryPrice")
|
|
or row.get("avgPrice")
|
|
or row.get("markPrice")
|
|
or row.get("avgEntryPrice")
|
|
or row.get("ep")
|
|
or row.get("ap")
|
|
or 0.0
|
|
)
|
|
total += qty * abs(float(entry or 0.0))
|
|
except Exception:
|
|
continue
|
|
return total
|
|
|
|
|
|
def _normalize_symbol(symbol: str) -> str:
|
|
return str(symbol or "").replace("-", "").replace("_", "").replace("/","").upper()
|
|
|
|
|
|
def _venue_symbol_from_asset(asset: str) -> str:
|
|
text = _normalize_symbol(asset)
|
|
if text.endswith("USDT"):
|
|
return f"{text[:-4]}-USDT"
|
|
return text
|
|
|
|
|
|
def _decimal_text(value: Decimal) -> str:
|
|
text = format(value.normalize(), "f")
|
|
if "." in text:
|
|
text = text.rstrip("0").rstrip(".")
|
|
return text or "0"
|
|
|
|
|
|
def _is_rate_limited_error(exc: Exception) -> bool:
|
|
message = str(exc)
|
|
lowered = message.lower()
|
|
return "100410" in message or "frequency limit" in lowered or "rate limit" in lowered
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BingxDirectExecutionConfig:
|
|
"""Execution-specific knobs for the direct adapter."""
|
|
|
|
environment: BingxEnvironment = BingxEnvironment.VST
|
|
allow_mainnet: bool = False
|
|
default_leverage: int = 1
|
|
exchange_leverage_cap: int = 3
|
|
recv_window_ms: int = 5_000
|
|
prefer_websocket: bool = False
|
|
use_reduce_only: bool = True
|
|
journal_strategy: str = "pink"
|
|
journal_db: str = "dolphin_pink"
|
|
instrument_provider: BingxInstrumentProviderConfig = BingxInstrumentProviderConfig(load_all=True)
|
|
|
|
|
|
class BingxDirectExecutionAdapter(ExecutionPort):
|
|
"""Direct BingX execution boundary with exchange-led state snapshots."""
|
|
|
|
def __init__(
|
|
self,
|
|
config: BingxExecClientConfig | BingxDirectExecutionConfig,
|
|
*,
|
|
client: BingxHttpClient | None = None,
|
|
provider: BingxInstrumentProvider | None = None,
|
|
) -> None:
|
|
if isinstance(config, BingxExecClientConfig):
|
|
self._config = BingxDirectExecutionConfig(
|
|
environment=config.environment,
|
|
allow_mainnet=config.allow_mainnet,
|
|
default_leverage=int(config.default_leverage),
|
|
exchange_leverage_cap=int(config.exchange_leverage_cap),
|
|
recv_window_ms=int(config.recv_window_ms),
|
|
prefer_websocket=bool(config.prefer_websocket),
|
|
use_reduce_only=bool(config.use_reduce_only),
|
|
journal_strategy=str(config.journal_strategy or "pink"),
|
|
journal_db=str(config.journal_db or "dolphin_pink"),
|
|
instrument_provider=config.instrument_provider,
|
|
)
|
|
http_config = config
|
|
else:
|
|
self._config = config
|
|
http_config = BingxExecClientConfig(
|
|
api_key="",
|
|
secret_key="",
|
|
environment=config.environment,
|
|
allow_mainnet=config.allow_mainnet,
|
|
prefer_websocket=config.prefer_websocket,
|
|
sizing_mode="testnet",
|
|
exchange_leverage_cap=config.exchange_leverage_cap,
|
|
use_reduce_only=config.use_reduce_only,
|
|
default_leverage=config.default_leverage,
|
|
recv_window_ms=config.recv_window_ms,
|
|
journal_strategy=config.journal_strategy,
|
|
journal_db=config.journal_db,
|
|
instrument_provider=config.instrument_provider,
|
|
)
|
|
self._client = client or BingxHttpClient(http_config)
|
|
self._provider = provider or BingxInstrumentProvider(client=self._client, config=self._config.instrument_provider)
|
|
self._log = LOGGER
|
|
self._client_order_run_id = uuid.uuid4().hex[:8]
|
|
self._entry_client_order_seq = 0
|
|
self._exit_client_order_seq = 0
|
|
self._state: ExchangeStateSnapshot | None = None
|
|
self._connected = False
|
|
|
|
@property
|
|
def state(self) -> ExchangeStateSnapshot | None:
|
|
return self._state
|
|
|
|
async def connect(self) -> bool:
|
|
await self._provider.initialize()
|
|
self._connected = True
|
|
self._state = await self.refresh_state()
|
|
return True
|
|
|
|
async def disconnect(self) -> None:
|
|
self._connected = False
|
|
await self._client.close()
|
|
|
|
def _resolve_instrument(self, asset: str):
|
|
normalized = _normalize_symbol(asset)
|
|
candidates = [
|
|
InstrumentId.from_str(f"{normalized}.BINGX"),
|
|
InstrumentId.from_str(f"{_venue_symbol_from_asset(asset)}.BINGX"),
|
|
]
|
|
for candidate in candidates:
|
|
instrument = self._provider.find(candidate)
|
|
if instrument is not None:
|
|
return instrument
|
|
for instrument in self._provider.list_all():
|
|
if _normalize_symbol(instrument.symbol.value) == normalized:
|
|
return instrument
|
|
if _normalize_symbol(instrument.raw_symbol.value) == normalized:
|
|
return instrument
|
|
return None
|
|
|
|
def _instrument_venue_symbol(self, asset: str) -> str:
|
|
instrument = self._resolve_instrument(asset)
|
|
if instrument is not None:
|
|
return str(instrument.raw_symbol.value)
|
|
return _venue_symbol_from_asset(asset)
|
|
|
|
def _instrument_step(self, asset: str) -> Decimal:
|
|
instrument = self._resolve_instrument(asset)
|
|
if instrument is not None:
|
|
try:
|
|
return Decimal(str(instrument.size_increment.as_decimal()))
|
|
except Exception:
|
|
pass
|
|
return Decimal("0.001")
|
|
|
|
def _format_quantity(self, asset: str, quantity: float) -> str:
|
|
step = self._instrument_step(asset)
|
|
if step <= 0:
|
|
return str(max(0.0, quantity))
|
|
value = Decimal(str(quantity))
|
|
quantized = (value / step).to_integral_value(rounding=ROUND_DOWN) * step
|
|
return _decimal_text(max(Decimal("0"), quantized))
|
|
|
|
def _instrument_tick(self, asset: str) -> Decimal:
|
|
instrument = self._resolve_instrument(asset)
|
|
if instrument is not None:
|
|
try:
|
|
tick = getattr(instrument, "price_increment", None)
|
|
if tick is not None:
|
|
return Decimal(str(tick.as_decimal()))
|
|
except Exception:
|
|
pass
|
|
return Decimal("0.01")
|
|
|
|
def _format_price(self, asset: str, price: float) -> str:
|
|
tick = self._instrument_tick(asset)
|
|
if tick <= 0:
|
|
return f"{price:.8f}".rstrip("0").rstrip(".")
|
|
value = Decimal(str(price))
|
|
quantized = (value / tick).to_integral_value(rounding=ROUND_DOWN) * tick
|
|
return _decimal_text(max(Decimal("0"), quantized))
|
|
|
|
async def _safe_get(self, endpoint: str, params: dict | None = None, *, fallback: Any = None) -> Any:
|
|
"""GET an endpoint, returning *fallback* on rate-limit errors."""
|
|
try:
|
|
return await self._client.signed_get(endpoint, params)
|
|
except BingxHttpError as exc:
|
|
message = str(exc)
|
|
if "100410" in message or "frequency limit" in message.lower():
|
|
LOGGER.debug("BingX %s rate-limited; continuing with empty snapshot", endpoint)
|
|
return fallback if fallback is not None else []
|
|
raise
|
|
|
|
async def _refresh_exchange_state(self, symbol: str | None = None, *, include_history: bool = False) -> ExchangeStateSnapshot:
|
|
"""Fetch exchange state with parallel HTTP calls.
|
|
|
|
The three primary calls (balance, positions, openOrders) are
|
|
independent and run concurrently via ``asyncio.gather``. Each has
|
|
its own rate-limit fallback so a single throttle does not block
|
|
the others. Historical calls (allOrders, allFillOrders) are gated
|
|
on ``include_history`` and also gathered.
|
|
"""
|
|
balance_task = self._safe_get("/openApi/swap/v2/user/balance")
|
|
positions_task = self._safe_get("/openApi/swap/v2/user/positions")
|
|
orders_task = self._safe_get("/openApi/swap/v2/trade/openOrders")
|
|
|
|
balance_payload, positions_payload, open_orders_payload = await asyncio.gather(
|
|
balance_task, positions_task, orders_task,
|
|
)
|
|
|
|
all_orders_payload: Any = []
|
|
all_fills_payload: Any = []
|
|
if include_history and symbol is not None:
|
|
venue_symbol = self._instrument_venue_symbol(symbol)
|
|
hist_tasks = asyncio.gather(
|
|
self._safe_get("/openApi/swap/v2/trade/allOrders", {"symbol": venue_symbol}),
|
|
self._safe_get("/openApi/swap/v2/trade/allFillOrders", {"symbol": venue_symbol}),
|
|
return_exceptions=True,
|
|
)
|
|
results = await hist_tasks
|
|
all_orders_payload = results[0] if not isinstance(results[0], Exception) else []
|
|
all_fills_payload = results[1] if not isinstance(results[1], Exception) else []
|
|
|
|
# Parse results (shared logic, same as before)
|
|
if isinstance(balance_payload, list):
|
|
balances = balance_payload
|
|
elif isinstance(balance_payload, dict):
|
|
rows_raw = balance_payload.get("balance") or balance_payload.get("balances") or balance_payload.get("data")
|
|
if isinstance(rows_raw, dict):
|
|
balances = [rows_raw]
|
|
elif isinstance(rows_raw, list):
|
|
balances = rows_raw
|
|
else:
|
|
balances = []
|
|
else:
|
|
balances = []
|
|
positions_rows = _rows_from_payload(positions_payload, "positions", "data")
|
|
positions: dict[str, dict[str, Any]] = {}
|
|
for row in positions_rows:
|
|
raw_symbol = str(row.get("symbol") or row.get("symbolName") or row.get("venueSymbol") or "")
|
|
key = _normalize_symbol(raw_symbol)
|
|
if not key:
|
|
continue
|
|
positions[key] = dict(row)
|
|
open_orders = _rows_from_payload(open_orders_payload, "orders", "data")
|
|
capital = _capital_from_balance_rows(balances)
|
|
open_notional = _position_notional_from_rows(positions_rows)
|
|
equity = capital
|
|
if open_notional > 0 and positions_rows:
|
|
equity = capital
|
|
snapshot = ExchangeStateSnapshot(
|
|
timestamp=datetime.now(timezone.utc),
|
|
capital=capital,
|
|
equity=equity,
|
|
open_positions=positions,
|
|
open_orders=[dict(row) for row in open_orders],
|
|
all_orders=[dict(row) for row in _rows_from_payload(all_orders_payload, "orders", "data")],
|
|
all_fills=[dict(row) for row in _rows_from_payload(all_fills_payload, "fills", "data")],
|
|
account={"balances": balances},
|
|
open_notional=open_notional,
|
|
source="bingx",
|
|
recovered=bool(include_history),
|
|
)
|
|
self._state = snapshot
|
|
return snapshot
|
|
|
|
async def refresh_state(self, symbol: str | None = None, *, include_history: bool = False) -> ExchangeStateSnapshot:
|
|
return await self._refresh_exchange_state(symbol, include_history=include_history)
|
|
|
|
async def submit_intent(self, intent: Intent) -> ExecutionReceipt:
|
|
symbol = self._instrument_venue_symbol(intent.asset)
|
|
if intent.action == DecisionAction.EXIT:
|
|
side = "SELL" if intent.side == TradeSide.LONG else "BUY"
|
|
else:
|
|
side = "BUY" if intent.side == TradeSide.LONG else "SELL"
|
|
# Entries must be free to open the slot; only exits are reduce-only.
|
|
reduce_only = bool(intent.action == DecisionAction.EXIT)
|
|
if reduce_only:
|
|
self._exit_client_order_seq += 1
|
|
client_order_id = f"pink:{self._client_order_run_id}:x{self._exit_client_order_seq:02d}"
|
|
else:
|
|
self._entry_client_order_seq += 1
|
|
client_order_id = f"pink:{self._client_order_run_id}:e{self._entry_client_order_seq:02d}"
|
|
leverage = normalize_bingx_leverage_value(
|
|
int(round(float(intent.leverage or self._config.default_leverage))),
|
|
exchange_max=self._config.exchange_leverage_cap,
|
|
)
|
|
try:
|
|
await self._client.signed_post(
|
|
"/openApi/swap/v2/trade/leverage",
|
|
{"symbol": symbol, "side": "BOTH", "leverage": leverage},
|
|
)
|
|
# Honor the order type forwarded by the venue adapter
|
|
# (bingx_venue._legacy_intent sets _order_type/_limit_price). MARKET
|
|
# is the default; a LIMIT carries a resting price + GTC and will not
|
|
# fill synchronously — the async-fill pump settles it later.
|
|
order_type = str((intent.metadata or {}).get("_order_type", "MARKET") or "MARKET").upper()
|
|
limit_price = float((intent.metadata or {}).get("_limit_price", 0.0) or 0.0)
|
|
is_limit = order_type == "LIMIT" and limit_price > 0.0
|
|
payload: dict[str, Any] = {
|
|
"symbol": symbol,
|
|
"side": side,
|
|
"positionSide": "BOTH",
|
|
"type": "LIMIT" if is_limit else "MARKET",
|
|
"quantity": self._format_quantity(intent.asset, intent.target_size),
|
|
"clientOrderId": client_order_id,
|
|
"recvWindow": str(int(self._config.recv_window_ms)),
|
|
}
|
|
if is_limit:
|
|
payload["price"] = self._format_price(intent.asset, limit_price)
|
|
payload["timeInForce"] = "GTC"
|
|
if reduce_only:
|
|
payload["reduceOnly"] = "true"
|
|
ack_payload = await self._client.signed_post("/openApi/swap/v2/trade/order", payload)
|
|
ack = BingxOrderAck.from_http(ack_payload if isinstance(ack_payload, dict) else {})
|
|
ack_row = dict(unwrap_order_payload(ack_payload)) if isinstance(ack_payload, dict) else {}
|
|
status = str(ack_row.get("status") or ack.status or "ACKED")
|
|
fill_price = 0.0
|
|
for key in ("avgPrice", "avgFilledPrice", "price", "lastFillPrice", "tradePrice"):
|
|
try:
|
|
value = float(ack_row.get(key) or 0.0)
|
|
except Exception:
|
|
value = 0.0
|
|
if value > 0:
|
|
fill_price = value
|
|
break
|
|
if fill_price <= 0 and self._state is not None:
|
|
# Use the last known exchange mark as a fallback for projected accounting.
|
|
fill_price = next((float(row.get("markPrice") or row.get("avgPrice") or 0.0) for row in self._state.open_positions.values() if float(row.get("markPrice") or row.get("avgPrice") or 0.0) > 0), 0.0)
|
|
except BingxHttpError as exc:
|
|
status = "RATE_LIMITED" if _is_rate_limited_error(exc) else "REJECTED"
|
|
ack_row = {
|
|
"status": status,
|
|
"msg": str(exc),
|
|
"symbol": symbol,
|
|
"clientOrderId": client_order_id,
|
|
}
|
|
fill_price = 0.0
|
|
ack = None
|
|
receipt = ExecutionReceipt(
|
|
timestamp=datetime.now(timezone.utc),
|
|
status=status,
|
|
symbol=symbol,
|
|
side=side,
|
|
action=intent.action.value,
|
|
quantity=float(intent.target_size or 0.0),
|
|
price=fill_price,
|
|
client_order_id=client_order_id,
|
|
order_id=str((ack.order_id if 'ack' in locals() and ack is not None else '') or ack_row.get("orderId") or ""),
|
|
raw_ack=ack_row,
|
|
raw_state=dict(self._state.account if self._state is not None else {}),
|
|
)
|
|
# Refresh from the venue so the direct runtime can use exchange-led state.
|
|
self._state = await self._refresh_exchange_state(intent.asset, include_history=True)
|
|
return receipt
|
|
|
|
async def cancel(self, order: Any, *, reason: str = "") -> dict[str, Any]:
|
|
"""Cancel a working order on the venue (resting LIMIT support).
|
|
|
|
Signs the DELETE with the same client used for order placement, keyed by
|
|
the venue orderId (propagated onto the slot order by the kernel on ACK)
|
|
with a clientOrderId fallback. Returns the raw BingX response for the
|
|
venue adapter to map into a CANCEL_ACK / CANCEL_REJECT event.
|
|
"""
|
|
asset = str((getattr(order, "metadata", None) or {}).get("asset") or "")
|
|
symbol = self._instrument_venue_symbol(asset) if asset else ""
|
|
params: dict[str, Any] = {
|
|
"symbol": symbol,
|
|
"recvWindow": str(int(self._config.recv_window_ms)),
|
|
}
|
|
venue_order_id = str(getattr(order, "venue_order_id", "") or "")
|
|
venue_client_id = str(getattr(order, "venue_client_id", "") or "")
|
|
if venue_order_id:
|
|
params["orderId"] = venue_order_id
|
|
elif venue_client_id:
|
|
params["clientOrderId"] = venue_client_id
|
|
else:
|
|
return {"status": "REJECTED", "msg": "no order id to cancel",
|
|
"orderId": venue_order_id, "clientOrderId": venue_client_id}
|
|
delete_resp: dict[str, Any] = {}
|
|
try:
|
|
resp = await self._client.signed_delete("/openApi/swap/v2/trade/order", params)
|
|
delete_resp = resp if isinstance(resp, dict) else {"status": "CANCELED"}
|
|
except BingxHttpError as exc:
|
|
delete_resp = {"status": "RATE_LIMITED" if _is_rate_limited_error(exc) else "ERROR", "msg": str(exc)}
|
|
|
|
# Truth-based confirmation: the cancel succeeded iff the order is no
|
|
# longer open on the venue. BingX can return transient errors (e.g.
|
|
# "order not exist", "same order number ... within 1 second" from an
|
|
# internal retry) even when the order was actually removed — so we trust
|
|
# exchange state, not the DELETE response.
|
|
still_open: bool | None = None
|
|
try:
|
|
oo = await self._client.signed_get("/openApi/swap/v2/trade/openOrders", {"symbol": symbol})
|
|
rows = oo if isinstance(oo, list) else (oo.get("data") or oo.get("orders") or [])
|
|
if isinstance(rows, dict):
|
|
rows = rows.get("orders") or []
|
|
ids = {str(r.get("orderId")) for r in rows if isinstance(r, dict)}
|
|
cids = {str(r.get("clientOrderId") or r.get("clientOrderID")) for r in rows if isinstance(r, dict)}
|
|
still_open = (venue_order_id in ids) if venue_order_id else (venue_client_id in cids)
|
|
except Exception:
|
|
still_open = None
|
|
|
|
if still_open is False:
|
|
return {"status": "CANCELED", "orderId": venue_order_id, "clientOrderId": venue_client_id}
|
|
if str(delete_resp.get("status", "")).upper() in {"CANCELED", "CANCELLED", "SUCCESS", "OK"}:
|
|
return {"status": "CANCELED", "orderId": venue_order_id, "clientOrderId": venue_client_id}
|
|
return {
|
|
"status": delete_resp.get("status", "REJECTED"),
|
|
"msg": delete_resp.get("msg", "cancel not confirmed"),
|
|
"orderId": venue_order_id, "clientOrderId": venue_client_id,
|
|
}
|
|
|
|
async def reconcile(self, symbol: str | None = None) -> ExchangeStateSnapshot:
|
|
# Recovery-only path: ask the venue for authoritative account/position/order state.
|
|
return await self._refresh_exchange_state(symbol, include_history=True)
|