From 84e4a50e3f0be0707988f3812876e225ec8e8915 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 12 Jun 2026 15:09:32 +0200 Subject: [PATCH] repo hygiene: track the PINK launcher import closure 67 production .py modules that the running PINK service imports but which were never committed: prod/bingx/ (HTTP client, market/user streams, journal, config), prod/clean_arch/ adapters/persistence/runtime/dita/dita_v2 production modules and their co-located tests. Rule going forward: every module imported by launch_dolphin_pink.py / pink_direct.py must appear in git ls-files. Excludes _backup dirs, __pycache__, and non-code files. Co-Authored-By: Claude Fable 5 --- prod/bingx/__init__.py | 19 + prod/bingx/characterization.py | 897 +++++++ prod/bingx/config.py | 71 + prod/bingx/data_client.py | 225 ++ prod/bingx/data_config.py | 44 + prod/bingx/data_factories.py | 47 + prod/bingx/dns_cache.py | 128 + prod/bingx/enums.py | 22 + prod/bingx/execution.py | 2338 +++++++++++++++++ prod/bingx/factories.py | 38 + prod/bingx/friction.py | 226 ++ prod/bingx/health.py | 151 ++ prod/bingx/instrument_provider.py | 80 + prod/bingx/journal.py | 357 +++ prod/bingx/leverage.py | 83 + prod/bingx/market_stream.py | 139 + prod/bingx/observer.py | 183 ++ prod/bingx/rate_limits.py | 106 + prod/bingx/reconciliation.py | 30 + prod/bingx/sandbox_status.py | 126 + prod/bingx/schemas.py | 80 + prod/bingx/signing.py | 48 + prod/bingx/sizing_mode.py | 71 + prod/bingx/urls.py | 31 + prod/bingx/websocket.py | 152 ++ prod/clean_arch/adapters/__init__.py | 0 .../adapters/eigen_scan_normalizer.py | 57 + prod/clean_arch/core/__init__.py | 0 prod/clean_arch/core/trading_engine.py | 185 ++ prod/clean_arch/dita/__init__.py | 49 + prod/clean_arch/dita/account.py | 118 + prod/clean_arch/dita/intent.py | 132 + prod/clean_arch/dita/observability.py | 32 + prod/clean_arch/dita/trade.py | 139 + prod/clean_arch/dita_v2/__init__.py | 95 + prod/clean_arch/dita_v2/_build_pink_bodies.py | 337 +++ .../dita_v2/_build_pink_extended.py | 170 ++ prod/clean_arch/dita_v2/_gen_test.py | 1244 +++++++++ prod/clean_arch/dita_v2/control.py | 217 ++ prod/clean_arch/dita_v2/exec_router.py | 563 ++++ prod/clean_arch/dita_v2/gen2.py | 438 +++ prod/clean_arch/dita_v2/gen_live_tests.py | 688 +++++ prod/clean_arch/dita_v2/journal.py | 102 + prod/clean_arch/dita_v2/kernel.py | 8 + prod/clean_arch/dita_v2/projection.py | 97 + prod/clean_arch/dita_v2/real_control_plane.py | 129 + prod/clean_arch/dita_v2/real_zinc_plane.py | 263 ++ prod/clean_arch/dita_v2/test_blue_parity.py | 378 +++ prod/clean_arch/dita_v2/test_exec_live_e2e.py | 267 ++ prod/clean_arch/dita_v2/test_exec_router.py | 605 +++++ .../dita_v2/test_exec_router_runtime.py | 646 +++++ .../clean_arch/dita_v2/test_reset_and_seed.py | 460 ++++ prod/clean_arch/dita_v2/utils.py | 43 + prod/clean_arch/dita_v2/zinc_plane.py | 135 + prod/clean_arch/persistence/__init__.py | 5 + prod/clean_arch/policy/__init__.py | 35 + prod/clean_arch/policy/contracts.py | 35 + prod/clean_arch/policy/engine.py | 44 + prod/clean_arch/ports/__init__.py | 0 prod/clean_arch/ports/data_feed.py | 118 + prod/clean_arch/ports/execution.py | 74 + prod/clean_arch/runtime/__init__.py | 2 + prod/clean_arch/runtime/runner_heartbeat.py | 53 + prod/clean_arch/sim/__init__.py | 32 + prod/clean_arch/sim/fuzzer.py | 202 ++ prod/clean_arch/sim/mock_stack.py | 1127 ++++++++ prod/clean_arch/sim/run.py | 74 + 67 files changed, 15090 insertions(+) create mode 100644 prod/bingx/__init__.py create mode 100644 prod/bingx/characterization.py create mode 100644 prod/bingx/config.py create mode 100644 prod/bingx/data_client.py create mode 100644 prod/bingx/data_config.py create mode 100644 prod/bingx/data_factories.py create mode 100644 prod/bingx/dns_cache.py create mode 100644 prod/bingx/enums.py create mode 100644 prod/bingx/execution.py create mode 100644 prod/bingx/factories.py create mode 100644 prod/bingx/friction.py create mode 100644 prod/bingx/health.py create mode 100644 prod/bingx/instrument_provider.py create mode 100644 prod/bingx/journal.py create mode 100644 prod/bingx/leverage.py create mode 100644 prod/bingx/market_stream.py create mode 100644 prod/bingx/observer.py create mode 100644 prod/bingx/rate_limits.py create mode 100644 prod/bingx/reconciliation.py create mode 100644 prod/bingx/sandbox_status.py create mode 100644 prod/bingx/schemas.py create mode 100644 prod/bingx/signing.py create mode 100644 prod/bingx/sizing_mode.py create mode 100644 prod/bingx/urls.py create mode 100644 prod/bingx/websocket.py create mode 100644 prod/clean_arch/adapters/__init__.py create mode 100644 prod/clean_arch/adapters/eigen_scan_normalizer.py create mode 100644 prod/clean_arch/core/__init__.py create mode 100644 prod/clean_arch/core/trading_engine.py create mode 100644 prod/clean_arch/dita/__init__.py create mode 100644 prod/clean_arch/dita/account.py create mode 100644 prod/clean_arch/dita/intent.py create mode 100644 prod/clean_arch/dita/observability.py create mode 100644 prod/clean_arch/dita/trade.py create mode 100644 prod/clean_arch/dita_v2/__init__.py create mode 100644 prod/clean_arch/dita_v2/_build_pink_bodies.py create mode 100644 prod/clean_arch/dita_v2/_build_pink_extended.py create mode 100644 prod/clean_arch/dita_v2/_gen_test.py create mode 100644 prod/clean_arch/dita_v2/control.py create mode 100644 prod/clean_arch/dita_v2/exec_router.py create mode 100644 prod/clean_arch/dita_v2/gen2.py create mode 100644 prod/clean_arch/dita_v2/gen_live_tests.py create mode 100644 prod/clean_arch/dita_v2/journal.py create mode 100644 prod/clean_arch/dita_v2/kernel.py create mode 100644 prod/clean_arch/dita_v2/projection.py create mode 100644 prod/clean_arch/dita_v2/real_control_plane.py create mode 100644 prod/clean_arch/dita_v2/real_zinc_plane.py create mode 100644 prod/clean_arch/dita_v2/test_blue_parity.py create mode 100644 prod/clean_arch/dita_v2/test_exec_live_e2e.py create mode 100644 prod/clean_arch/dita_v2/test_exec_router.py create mode 100644 prod/clean_arch/dita_v2/test_exec_router_runtime.py create mode 100644 prod/clean_arch/dita_v2/test_reset_and_seed.py create mode 100644 prod/clean_arch/dita_v2/utils.py create mode 100644 prod/clean_arch/dita_v2/zinc_plane.py create mode 100644 prod/clean_arch/persistence/__init__.py create mode 100644 prod/clean_arch/policy/__init__.py create mode 100644 prod/clean_arch/policy/contracts.py create mode 100644 prod/clean_arch/policy/engine.py create mode 100644 prod/clean_arch/ports/__init__.py create mode 100644 prod/clean_arch/ports/data_feed.py create mode 100644 prod/clean_arch/ports/execution.py create mode 100644 prod/clean_arch/runtime/__init__.py create mode 100644 prod/clean_arch/runtime/runner_heartbeat.py create mode 100644 prod/clean_arch/sim/__init__.py create mode 100644 prod/clean_arch/sim/fuzzer.py create mode 100644 prod/clean_arch/sim/mock_stack.py create mode 100644 prod/clean_arch/sim/run.py diff --git a/prod/bingx/__init__.py b/prod/bingx/__init__.py new file mode 100644 index 0000000..4632d59 --- /dev/null +++ b/prod/bingx/__init__.py @@ -0,0 +1,19 @@ +"""BingX execution adapter components for Nautilus Trader.""" + +from .config import BingxExecClientConfig +from .config import BingxInstrumentProviderConfig +from .data_config import BingxDataClientConfig +from .enums import BingxEnvironment +from .data_factories import BingxLiveDataClientFactory +from .factories import BingxLiveExecClientFactory +from .observer import BingxOrderUpdateObserver + +__all__ = [ + "BingxEnvironment", + "BingxDataClientConfig", + "BingxExecClientConfig", + "BingxInstrumentProviderConfig", + "BingxLiveDataClientFactory", + "BingxLiveExecClientFactory", + "BingxOrderUpdateObserver", +] diff --git a/prod/bingx/characterization.py b/prod/bingx/characterization.py new file mode 100644 index 0000000..814dfed --- /dev/null +++ b/prod/bingx/characterization.py @@ -0,0 +1,897 @@ +from __future__ import annotations + +import contextlib +import asyncio +import json +import time +import statistics +import uuid +from dataclasses import asdict +from dataclasses import dataclass +from datetime import datetime +from datetime import timezone +from decimal import Decimal +from pathlib import Path +from typing import Any, Callable + +from nautilus_trader.model.enums import OrderSide +from nautilus_trader.model.enums import OrderType +from nautilus_trader.model.enums import TimeInForce +from nautilus_trader.model.identifiers import ClientOrderId +from nautilus_trader.model.objects import Price +from nautilus_trader.model.objects import Quantity + +from prod.ch_writer import ch_put + +from .execution import BingxExecutionClient +from .friction import estimate_friction +from .schemas import BingxOrderAck +from .schemas import unwrap_order_payload + +ReferencePriceFn = Callable[[str], Decimal | None] +_TERMINAL_ORDER_STATUSES = {"FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"} + + +def _order_row(payload: dict[str, Any] | None) -> dict[str, Any] | None: + if not isinstance(payload, dict): + return None + row = payload.get("order") + return row if isinstance(row, dict) else payload + + +def _position_rows(payload: Any) -> list[dict[str, Any]]: + if isinstance(payload, list): + return [row for row in payload if isinstance(row, dict)] + if isinstance(payload, dict): + rows = payload.get("positions") + if not isinstance(rows, list): + rows = payload.get("data") + if isinstance(rows, list): + return [row for row in rows if isinstance(row, dict)] + return [] + + +async def _cancel_characterization_order( + adapter: BingxExecutionClient, + *, + symbol: str, + venue_order_id: str | None, + client_order_id: str | None, +) -> bool: + attempts: list[dict[str, str]] = [] + if venue_order_id: + attempts.append({"symbol": symbol, "orderId": venue_order_id}) + if client_order_id: + attempts.append({"symbol": symbol, "clientOrderId": client_order_id}) + + for params in attempts: + with contextlib.suppress(Exception): + await adapter._client.signed_delete("/openApi/swap/v2/trade/order", params) # noqa: SLF001 + return True + + with contextlib.suppress(Exception): + orders = await adapter._client.signed_get("/openApi/swap/v2/trade/openOrders", {"symbol": symbol}) # noqa: SLF001 + rows = orders if isinstance(orders, list) else orders.get("orders", []) + for row in rows: + if not isinstance(row, dict): + continue + row_order_id = str(row.get("orderId") or row.get("orderID") or "") + row_client_order_id = str(row.get("clientOrderId") or row.get("clientOrderID") or "") + if venue_order_id and row_order_id == venue_order_id: + await adapter._client.signed_delete("/openApi/swap/v2/trade/order", {"symbol": symbol, "orderId": row_order_id}) # noqa: SLF001 + return True + if client_order_id and row_client_order_id == client_order_id: + await adapter._client.signed_delete("/openApi/swap/v2/trade/order", {"symbol": symbol, "clientOrderId": row_client_order_id}) # noqa: SLF001 + return True + + with contextlib.suppress(Exception): + await adapter._client.signed_delete("/openApi/swap/v2/trade/allOpenOrders", {"symbol": symbol}) # noqa: SLF001 + + return False + + +@dataclass(frozen=True) +class BingxSampleSpec: + symbol: str + quantity: str + order_type: str = "MARKET" + side: str = "SELL" + price: str | None = None + trigger_price: str | None = None + trigger_intent: str = "auto" + time_in_force: str = "GTC" + post_only: bool = False + reduce_only: bool = True + close_position: bool = False + trailing_offset: str | None = None + working_type: str = "MARK_PRICE" + label: str = "" + + +@dataclass(frozen=True) +class BingxSampleResult: + spec: BingxSampleSpec + trial_index: int + submitted_at_ns: int + accepted_at_ns: int | None + final_at_ns: int | None + observed_latency_ms: str | None + venue_order_id: str | None + status: str + reference_submit_px: str | None + reference_fill_px: str | None + reference_drift_bps: str | None + fill_px: str | None + fill_qty: str | None + commission_quote: str | None + fee_rate: str | None + fee_bps: str | None + slippage_bps: str | None + gross_friction_quote: str | None + liquidity_side: str | None + error: str | None = None + + +@dataclass(frozen=True) +class BingxCharacterizationReport: + started_at: str + finished_at: str + environment: str + results: list[BingxSampleResult] + + def summary(self) -> dict[str, Any]: + total = len(self.results) + filled = sum(1 for item in self.results if item.status == "FILLED") + rejected = sum(1 for item in self.results if item.status == "REJECTED") + avg_fee_bps = _avg_decimal(item.fee_bps for item in self.results) + avg_slip_bps = _avg_decimal(item.slippage_bps for item in self.results) + return { + "total": total, + "filled": filled, + "rejected": rejected, + "avg_fee_bps": avg_fee_bps, + "avg_slippage_bps": avg_slip_bps, + "by_order_type": self.by_order_type(), + "by_symbol": self.by_symbol(), + } + + def by_order_type(self) -> dict[str, dict[str, Any]]: + return _group_stats(self.results, lambda item: item.spec.order_type) + + def by_symbol(self) -> dict[str, dict[str, Any]]: + return _group_stats(self.results, lambda item: item.spec.symbol) + + +def build_representative_specs( + symbols: list[str], + *, + sizes: list[str] | None = None, + sides: list[str] | None = None, +) -> list[BingxSampleSpec]: + sizes = sizes or ["1", "5", "25"] + sides = sides or ["SELL", "BUY"] + specs: list[BingxSampleSpec] = [] + for symbol in symbols: + for side in sides: + for qty in sizes: + specs.append(BingxSampleSpec(symbol=symbol, quantity=qty, order_type="MARKET", side=side, label=f"{symbol}:mkt:{side}:{qty}")) + specs.append(BingxSampleSpec(symbol=symbol, quantity=qty, order_type="LIMIT", side=side, post_only=True, label=f"{symbol}:maker:{side}:{qty}")) + specs.append(BingxSampleSpec(symbol=symbol, quantity=qty, order_type="STOP_MARKET", side=side, trigger_intent="stop_loss", label=f"{symbol}:stop_mkt:{side}:{qty}")) + specs.append(BingxSampleSpec(symbol=symbol, quantity=qty, order_type="STOP", side=side, price=None, trigger_intent="stop_loss", label=f"{symbol}:stop:{side}:{qty}")) + specs.append(BingxSampleSpec(symbol=symbol, quantity=qty, order_type="TAKE_PROFIT_MARKET", side=side, trigger_intent="take_profit", label=f"{symbol}:tp_mkt:{side}:{qty}")) + specs.append(BingxSampleSpec(symbol=symbol, quantity=qty, order_type="TAKE_PROFIT", side=side, price=None, trigger_intent="take_profit", label=f"{symbol}:tp:{side}:{qty}")) + specs.append(BingxSampleSpec(symbol=symbol, quantity=qty, order_type="TRAILING_STOP_MARKET", side=side, trailing_offset="0.1", label=f"{symbol}:trail:{side}:{qty}")) + return specs + + +class _SampleOrder: + def __init__(self, spec: BingxSampleSpec) -> None: + self.instrument_id = None + self.side = OrderSide.SELL if spec.side.upper() == "SELL" else OrderSide.BUY + self.order_type = _map_order_type(spec.order_type) + self.quantity = Quantity.from_str(spec.quantity) + self.client_order_id = ClientOrderId(f"bx-{uuid.uuid4().hex[:12]}") + self.is_post_only = bool(spec.post_only) + self.is_reduce_only = bool(spec.reduce_only) + self.time_in_force = _map_tif(spec.time_in_force) + self.has_price = spec.price is not None + self.price = Price.from_str(spec.price) if spec.price is not None else None + self.has_trigger_price = spec.trigger_price is not None + self.trigger_price = Price.from_str(spec.trigger_price) if spec.trigger_price is not None else None + self.strategy_id = ClientOrderId("bingx-sampler") + self.trailing_offset = spec.trailing_offset + self.close_position = bool(spec.close_position) + self.trigger_type = spec.working_type + + +async def characterize( + adapter: BingxExecutionClient, + specs: list[BingxSampleSpec], + *, + reference_price: ReferencePriceFn | None = None, + reference_price_fill: ReferencePriceFn | None = None, + order_observer: Any | None = None, + repetitions: int = 1, + timeout_s: float = 20.0, + poll_interval_s: float = 0.35, + strict_ws_observation: bool = True, +) -> BingxCharacterizationReport: + started = datetime.now(timezone.utc).isoformat() + results: list[BingxSampleResult] = [] + reps = max(1, int(repetitions)) + for trial_index in range(reps): + for spec in specs: + results.append( + await _sample_one( + adapter, + spec, + trial_index=trial_index, + reference_price_submit=reference_price, + reference_price_fill=reference_price_fill or reference_price, + order_observer=order_observer, + timeout_s=timeout_s, + poll_interval_s=poll_interval_s, + strict_ws_observation=strict_ws_observation, + ) + ) + await _respect_rate_limits(adapter) + finished = datetime.now(timezone.utc).isoformat() + return BingxCharacterizationReport( + started_at=started, + finished_at=finished, + environment=str(getattr(adapter._config.environment, "value", adapter._config.environment)), # noqa: SLF001 + results=results, + ) + + +async def persist_report(report: BingxCharacterizationReport, *, path: str | Path | None = None) -> None: + payload = { + "started_at": report.started_at, + "finished_at": report.finished_at, + "environment": report.environment, + "summary": report.summary(), + "results": [asdict(item) for item in report.results], + } + if path is not None: + Path(path).write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + ch_put( + "account_events", + { + "ts": report.finished_at, + "event_type": "BINGX_EXECUTION_SAMPLE", + "strategy": "bingx", + "posture": "N/A", + "capital": 0.0, + "peak_capital": 0.0, + "drawdown_pct": 0.0, + "pnl_today": 0.0, + "trades_today": len(report.results), + "open_positions": 0, + "boost": 1.0, + "beta": 1.0, + "notes": json.dumps(payload, sort_keys=True, separators=(",", ":")), + }, + ) + + +async def _sample_one( + adapter: BingxExecutionClient, + spec: BingxSampleSpec, + *, + trial_index: int, + reference_price_submit: ReferencePriceFn | None, + reference_price_fill: ReferencePriceFn | None, + order_observer: Any | None, + timeout_s: float, + poll_interval_s: float, + strict_ws_observation: bool, +) -> BingxSampleResult: + submitted_at = time.time_ns() + order = _SampleOrder(spec) + order.instrument_id = _resolve_instrument_id(adapter, spec.symbol) + submit_ref = reference_price_submit(spec.symbol) if reference_price_submit is not None else None + fill_ref = reference_price_fill(spec.symbol) if reference_price_fill is not None else submit_ref + pre_position_qty = await _snapshot_position_qty(adapter, spec.symbol) + if order.order_type == OrderType.LIMIT and not order.has_price and submit_ref is None: + raise ValueError(f"Reference price is required for maker characterization on {spec.symbol}") + _materialize_order_prices(order, spec, submit_ref) + order_payload = adapter._map_submit_order(order) # noqa: SLF001 + accepted_at: int | None = None + final_at: int | None = None + venue_order_id: str | None = None + status = "UNKNOWN" + fill_px: str | None = None + fill_qty: str | None = None + friction: dict[str, Any] | None = None + needs_cleanup = False + try: + ack = await adapter._client.signed_post("/openApi/swap/v2/trade/order", order_payload) # noqa: SLF001 + ack_row = BingxOrderAck.from_http(ack) + ack_http_row = unwrap_order_payload(ack) if isinstance(ack, dict) else None + accepted_at = time.time_ns() + venue_order_id_raw = ack_row.order_id + client_order_id_raw = ack_row.client_order_id or order.client_order_id.value + venue_order_id = str(venue_order_id_raw or client_order_id_raw) + needs_cleanup = True + lookup_by_client_order_id = venue_order_id_raw in (None, "", 0, "0") + last_row: dict[str, Any] | None = None + terminal_wait = getattr(adapter, "wait_for_order_terminal", None) + fill_wait = getattr(adapter, "wait_for_order_fill", None) + observed_row: dict[str, Any] | None = None + observer_timeout_s = timeout_s + wait_keys = [order.client_order_id.value] + if venue_order_id and venue_order_id not in wait_keys: + wait_keys.append(venue_order_id) + if isinstance(ack_http_row, dict) and _is_meaningful_observation_row(ack_http_row): + observed_row = dict(ack_http_row) + final_at = accepted_at + if _needs_observation_tail(order): + observer_timeout_s = min(timeout_s, 1.0) + if callable(fill_wait) and observed_row is None: + for wait_key in wait_keys: + observed_row = await fill_wait(wait_key, timeout_s=observer_timeout_s) + if _is_meaningful_observation_row(observed_row): + break + observed_row = None + if callable(terminal_wait) and observed_row is None: + if observed_row is None: + for wait_key in wait_keys: + observed_row = await terminal_wait(wait_key, timeout_s=observer_timeout_s) + if _is_meaningful_observation_row(observed_row): + break + observed_row = None + elif order_observer is not None and observed_row is None: + for wait_key in wait_keys: + observed = await order_observer.wait_for_terminal(wait_key, timeout_s=observer_timeout_s) + observed_row = dict(observed.row) if observed is not None else None + if _is_meaningful_observation_row(observed_row): + break + observed_row = None + if not _is_meaningful_observation_row(observed_row): + observed_row = None + if observed_row is not None: + last_row = dict(observed_row) + status = str(last_row.get("status") or last_row.get("X") or last_row.get("x") or "NEW").upper() + fill_px = str(last_row.get("avgPrice") or last_row.get("avgFilledPrice") or last_row.get("lastFillPrice") or "0") + fill_qty = str(last_row.get("executedQty") or last_row.get("cumFilledQty") or last_row.get("lastFilledQty") or "0") + else: + if status == "NEW": + status = "PENDING" + if observed_row is None and _needs_fast_position_inference(order): + fast_deadline = time.monotonic() + max(0.5, min(timeout_s, 2.0)) + while time.monotonic() < fast_deadline: + inferred = await _infer_from_account_state(adapter, spec, order, pre_position_qty) + if inferred is not None: + status, fill_px, fill_qty, last_row = inferred + if submit_ref is not None and fill_px is not None: + friction = estimate_friction( + order, + last_row, + last_qty=Quantity.from_str(fill_qty or "0"), + last_px=Price.from_str(fill_px or "0"), + quote_currency="USDT", + base_currency=spec.symbol.replace("USDT", ""), + maker_fee=Decimal("0.0002"), + taker_fee=Decimal("0.0005"), + ) + friction["reference_px"] = str(submit_ref) + fill = Decimal(fill_px) + delta = (fill - submit_ref) if order.side == OrderSide.BUY else (submit_ref - fill) + friction["slippage_bps"] = _decimal_text((delta / submit_ref) * Decimal("10000")) + break + await asyncio.sleep(max(0.05, poll_interval_s)) + if not strict_ws_observation and status not in {"FILLED", "PARTIALLY_FILLED"}: + lookup = {"symbol": order_payload["symbol"]} + if lookup_by_client_order_id: + lookup["clientOrderId"] = client_order_id_raw + else: + lookup["orderId"] = venue_order_id + with contextlib.suppress(Exception): + row = _order_row(await adapter._client.signed_get( # noqa: SLF001 + "/openApi/swap/v2/trade/order", + lookup, + )) + if isinstance(row, dict): + last_row = row + status = str(row.get("status") or row.get("X") or row.get("x") or status or "UNKNOWN").upper() + fill_px = str(row.get("avgPrice") or row.get("avgFilledPrice") or row.get("lastFillPrice") or fill_px or "0") + fill_qty = str(row.get("executedQty") or row.get("cumFilledQty") or row.get("lastFilledQty") or fill_qty or "0") + if status in {"UNKNOWN", "NEW"}: + with contextlib.suppress(Exception): + open_orders = await adapter._client.signed_get( # noqa: SLF001 + "/openApi/swap/v2/trade/openOrders", + {"symbol": order_payload["symbol"]}, + ) + rows = open_orders if isinstance(open_orders, list) else open_orders.get("orders", []) + active = False + for row in rows: + if not isinstance(row, dict): + continue + row_order_id = str(row.get("orderId") or row.get("orderID") or "") + row_client_order_id = str(row.get("clientOrderId") or row.get("clientOrderID") or "") + if venue_order_id and row_order_id == venue_order_id: + active = True + break + if client_order_id_raw and row_client_order_id == str(client_order_id_raw): + active = True + break + if not active: + status = "CANCELED" + if observed_row is None and status not in {"FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"} and not _needs_fast_position_inference(order): + if not strict_ws_observation: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + lookup = {"symbol": order_payload["symbol"]} + if lookup_by_client_order_id: + lookup["clientOrderId"] = client_order_id_raw + else: + lookup["orderId"] = venue_order_id + row = _order_row(await adapter._client.signed_get( # noqa: SLF001 + "/openApi/swap/v2/trade/order", + lookup, + )) + if isinstance(row, dict): + last_row = row + status = str(row.get("status") or row.get("X") or row.get("x") or "NEW").upper() + if status in {"FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"}: + break + fill_px = str(row.get("avgPrice") or row.get("avgFilledPrice") or row.get("lastFillPrice") or "0") + fill_qty = str(row.get("executedQty") or row.get("cumFilledQty") or row.get("lastFilledQty") or "0") + await asyncio.sleep(poll_interval_s) + if status not in {"FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"}: + positions = await adapter._client.signed_get( # noqa: SLF001 + "/openApi/swap/v2/user/positions", + ) + rows = _position_rows(positions) + symbol_key = spec.symbol.replace("-", "") + position_row = None + for row in rows: + if not isinstance(row, dict): + continue + row_symbol = str(row.get("symbol") or row.get("s") or "").replace("-", "") + if row_symbol == symbol_key: + position_row = row + break + if isinstance(position_row, dict): + pos_qty = Decimal(str(position_row.get("positionAmt") or position_row.get("positionQty") or "0")) + if pos_qty != 0: + last_row = { + "status": "FILLED", + "avgPrice": position_row.get("avgPrice") or position_row.get("entryPrice") or "0", + "executedQty": str(abs(pos_qty)), + "lastFilledQty": str(abs(pos_qty)), + "lastFillPrice": position_row.get("avgPrice") or position_row.get("entryPrice") or "0", + "commission": "0", + "commissionAsset": spec.symbol.replace("USDT", ""), + } + status = "FILLED" + fill_px = str(last_row["avgPrice"]) + fill_qty = str(abs(pos_qty)) + elif status == "NEW": + status = "PENDING" + if observed_row is None and not _needs_fast_position_inference(order) and status == "NEW": + status = "PENDING" + final_at = time.time_ns() + if last_row is not None: + fill_px = str(last_row.get("avgPrice") or last_row.get("avgFilledPrice") or last_row.get("lastFillPrice") or fill_px or "0") + fill_qty = str(last_row.get("executedQty") or last_row.get("cumFilledQty") or last_row.get("lastFilledQty") or fill_qty or "0") + ref = submit_ref + if ref is not None: + friction = estimate_friction( + order, + last_row, + last_qty=Quantity.from_str(fill_qty or "0"), + last_px=Price.from_str(fill_px or "0"), + quote_currency="USDT", + base_currency=spec.symbol.replace("USDT", ""), + maker_fee=Decimal("0.0002"), + taker_fee=Decimal("0.0005"), + ) + friction["reference_px"] = str(ref) + if fill_px and ref > 0: + fill = Decimal(fill_px) + delta = (fill - ref) if order.side == OrderSide.BUY else (ref - fill) + friction["slippage_bps"] = _decimal_text((delta / ref) * Decimal("10000")) + if not status or status == "NEW": + status = str(last_row.get("status") or "NEW").upper() + if status in {"NEW", "PENDING"}: + inferred = await _infer_from_account_state(adapter, spec, order, pre_position_qty) + if inferred is not None: + status, fill_px, fill_qty, last_row = inferred + if submit_ref is not None and fill_px is not None: + friction = estimate_friction( + order, + last_row, + last_qty=Quantity.from_str(fill_qty or "0"), + last_px=Price.from_str(fill_px or "0"), + quote_currency="USDT", + base_currency=spec.symbol.replace("USDT", ""), + maker_fee=Decimal("0.0002"), + taker_fee=Decimal("0.0005"), + ) + friction["reference_px"] = str(submit_ref) + fill = Decimal(fill_px) + delta = (fill - submit_ref) if order.side == OrderSide.BUY else (submit_ref - fill) + friction["slippage_bps"] = _decimal_text((delta / submit_ref) * Decimal("10000")) + if status in {"NEW", "PENDING"} and _needs_observation_tail(order): + tail_deadline = time.monotonic() + max(0.0, timeout_s - observer_timeout_s) + while time.monotonic() < tail_deadline: + inferred = await _infer_from_account_state(adapter, spec, order, pre_position_qty) + if inferred is not None: + status, fill_px, fill_qty, last_row = inferred + if submit_ref is not None and fill_px is not None: + friction = estimate_friction( + order, + last_row, + last_qty=Quantity.from_str(fill_qty or "0"), + last_px=Price.from_str(fill_px or "0"), + quote_currency="USDT", + base_currency=spec.symbol.replace("USDT", ""), + maker_fee=Decimal("0.0002"), + taker_fee=Decimal("0.0005"), + ) + friction["reference_px"] = str(submit_ref) + fill = Decimal(fill_px) + delta = (fill - submit_ref) if order.side == OrderSide.BUY else (submit_ref - fill) + friction["slippage_bps"] = _decimal_text((delta / submit_ref) * Decimal("10000")) + break + await asyncio.sleep(max(0.1, poll_interval_s)) + if not strict_ws_observation and status not in {"FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"} and venue_order_id: + canceled = await _cancel_characterization_order( + adapter, + symbol=order_payload["symbol"], + venue_order_id=venue_order_id, + client_order_id=str(client_order_id_raw or ""), + ) + if canceled: + status = "CANCELED" + final_at = time.time_ns() + except Exception as exc: + final_at = time.time_ns() + if needs_cleanup and not strict_ws_observation: + await _cancel_characterization_order( + adapter, + symbol=order_payload["symbol"], + venue_order_id=venue_order_id, + client_order_id=str(client_order_id_raw or ""), + ) + if "order not exist" not in str(exc).lower(): + return BingxSampleResult( + spec=spec, + trial_index=trial_index, + submitted_at_ns=submitted_at, + accepted_at_ns=accepted_at, + final_at_ns=final_at, + observed_latency_ms=_latency_ms(submitted_at, final_at), + venue_order_id=venue_order_id, + status="ERROR", + reference_submit_px=str(submit_ref) if submit_ref is not None else None, + reference_fill_px=str(fill_ref) if fill_ref is not None else None, + reference_drift_bps=_reference_drift_bps(submit_ref, fill_ref), + fill_px=fill_px, + fill_qty=fill_qty, + commission_quote=None, + fee_rate=None, + fee_bps=None, + slippage_bps=None, + gross_friction_quote=None, + liquidity_side=None, + error=str(exc), + ) + inferred = await _infer_from_account_state(adapter, spec, order, pre_position_qty) + if inferred is not None: + status, fill_px, fill_qty, last_row = inferred + if submit_ref is not None and fill_px is not None: + friction = estimate_friction( + order, + last_row, + last_qty=Quantity.from_str(fill_qty or "0"), + last_px=Price.from_str(fill_px or "0"), + quote_currency="USDT", + base_currency=spec.symbol.replace("USDT", ""), + maker_fee=Decimal("0.0002"), + taker_fee=Decimal("0.0005"), + ) + friction["reference_px"] = str(submit_ref) + fill = Decimal(fill_px) + delta = (fill - submit_ref) if order.side == OrderSide.BUY else (submit_ref - fill) + friction["slippage_bps"] = _decimal_text((delta / submit_ref) * Decimal("10000")) + return BingxSampleResult( + spec=spec, + trial_index=trial_index, + submitted_at_ns=submitted_at, + accepted_at_ns=accepted_at, + final_at_ns=final_at, + observed_latency_ms=_latency_ms(submitted_at, final_at), + venue_order_id=venue_order_id, + status=status, + reference_submit_px=str(submit_ref) if submit_ref is not None else None, + reference_fill_px=str(fill_ref) if fill_ref is not None else None, + reference_drift_bps=_reference_drift_bps(submit_ref, fill_ref), + fill_px=fill_px, + fill_qty=fill_qty, + commission_quote=(friction or {}).get("commission_quote") if status == "FILLED" else None, + fee_rate=(friction or {}).get("fee_rate") if status == "FILLED" else None, + fee_bps=(friction or {}).get("fee_bps") if status == "FILLED" else None, + slippage_bps=(friction or {}).get("slippage_bps") if status == "FILLED" else None, + gross_friction_quote=(friction or {}).get("gross_friction_quote") if status == "FILLED" else None, + liquidity_side=(friction or {}).get("liquidity_side"), + error=None, + ) + return BingxSampleResult( + spec=spec, + trial_index=trial_index, + submitted_at_ns=submitted_at, + accepted_at_ns=accepted_at, + final_at_ns=final_at, + observed_latency_ms=_latency_ms(submitted_at, final_at), + venue_order_id=venue_order_id, + status="PENDING", + reference_submit_px=str(submit_ref) if submit_ref is not None else None, + reference_fill_px=str(fill_ref) if fill_ref is not None else None, + reference_drift_bps=_reference_drift_bps(submit_ref, fill_ref), + fill_px=fill_px, + fill_qty=fill_qty, + commission_quote=None, + fee_rate=None, + fee_bps=None, + slippage_bps=None, + gross_friction_quote=None, + liquidity_side=None, + error=None, + ) + return BingxSampleResult( + spec=spec, + trial_index=trial_index, + submitted_at_ns=submitted_at, + accepted_at_ns=accepted_at, + final_at_ns=final_at, + observed_latency_ms=_latency_ms(submitted_at, final_at), + venue_order_id=venue_order_id, + status=status, + reference_submit_px=str(submit_ref) if submit_ref is not None else None, + reference_fill_px=str(fill_ref) if fill_ref is not None else None, + reference_drift_bps=_reference_drift_bps(submit_ref, fill_ref), + fill_px=fill_px, + fill_qty=fill_qty, + commission_quote=(friction or {}).get("commission_quote") if status == "FILLED" else None, + fee_rate=(friction or {}).get("fee_rate") if status == "FILLED" else None, + fee_bps=(friction or {}).get("fee_bps") if status == "FILLED" else None, + slippage_bps=(friction or {}).get("slippage_bps") if status == "FILLED" else None, + gross_friction_quote=(friction or {}).get("gross_friction_quote") if status == "FILLED" else None, + liquidity_side=(friction or {}).get("liquidity_side"), + ) + + +async def _respect_rate_limits(adapter: BingxExecutionClient) -> None: + snap = adapter._client.rate_limit_snapshot() # noqa: SLF001 + if snap.rest_remaining is not None and snap.rest_remaining <= 5: + await asyncio.sleep(max(0.5, float(snap.rest_reset_ms or 1000) / 1000.0)) + + +def _resolve_instrument_id(adapter: BingxExecutionClient, symbol: str): + for instrument in adapter._provider.list_all(): # noqa: SLF001 + if instrument.symbol.value == symbol or instrument.raw_symbol.value == symbol or instrument.symbol.value == symbol.replace("-", ""): + return instrument.id + raise ValueError(f"Unknown BingX instrument {symbol}") + + +def _map_order_type(value: str) -> OrderType: + mapping = { + "MARKET": OrderType.MARKET, + "LIMIT": OrderType.LIMIT, + "STOP_MARKET": OrderType.STOP_MARKET, + "STOP": OrderType.STOP_LIMIT, + "TAKE_PROFIT": OrderType.LIMIT_IF_TOUCHED, + "TAKE_PROFIT_MARKET": OrderType.MARKET_IF_TOUCHED, + "TRAILING_STOP_MARKET": OrderType.TRAILING_STOP_MARKET, + } + return mapping[value.upper()] + + +def _map_tif(value: str) -> TimeInForce: + mapping = {"GTC": TimeInForce.GTC, "IOC": TimeInForce.IOC, "FOK": TimeInForce.FOK} + return mapping.get(value.upper(), TimeInForce.GTC) + + +def _avg_decimal(values: Any) -> str | None: + nums = [Decimal(str(v)) for v in values if v not in (None, "")] + if not nums: + return None + return str(sum(nums) / Decimal(len(nums))) + + +def _decimal_text(value: Decimal) -> str: + text = format(value.normalize(), "f") + if "." in text: + text = text.rstrip("0").rstrip(".") + return text or "0" + + +def _latency_ms(start_ns: int, end_ns: int | None) -> str | None: + if end_ns is None: + return None + return _decimal_text(Decimal(end_ns - start_ns) / Decimal("1000000")) + + +def _reference_drift_bps(submit_ref: Decimal | None, fill_ref: Decimal | None) -> str | None: + if submit_ref is None or fill_ref is None or submit_ref <= 0: + return None + return _decimal_text(((fill_ref - submit_ref) / submit_ref) * Decimal("10000")) + + +def _group_stats(results: list[BingxSampleResult], key_fn) -> dict[str, dict[str, Any]]: + grouped: dict[str, list[BingxSampleResult]] = {} + for item in results: + grouped.setdefault(str(key_fn(item)), []).append(item) + return {key: _stats_for_items(items) for key, items in grouped.items()} + + +def _stats_for_items(items: list[BingxSampleResult]) -> dict[str, Any]: + return { + "count": len(items), + "filled": sum(1 for item in items if item.status == "FILLED"), + "rejected": sum(1 for item in items if item.status == "REJECTED"), + "avg_fee_bps": _avg_decimal(item.fee_bps for item in items), + "stdev_fee_bps": _stdev_decimal(item.fee_bps for item in items), + "avg_slippage_bps": _avg_decimal(item.slippage_bps for item in items), + "stdev_slippage_bps": _stdev_decimal(item.slippage_bps for item in items), + "avg_latency_ms": _avg_decimal(item.observed_latency_ms for item in items), + "p10_latency_ms": _percentile_decimal((item.observed_latency_ms for item in items), 10), + "p25_latency_ms": _percentile_decimal((item.observed_latency_ms for item in items), 25), + "p50_latency_ms": _percentile_decimal((item.observed_latency_ms for item in items), 50), + "p75_latency_ms": _percentile_decimal((item.observed_latency_ms for item in items), 75), + "p90_latency_ms": _percentile_decimal((item.observed_latency_ms for item in items), 90), + "p95_latency_ms": _percentile_decimal((item.observed_latency_ms for item in items), 95), + "p99_latency_ms": _percentile_decimal((item.observed_latency_ms for item in items), 99), + } + + +def _stdev_decimal(values: Any) -> str | None: + nums = [Decimal(str(v)) for v in values if v not in (None, "")] + if len(nums) < 2: + return None + return _decimal_text(Decimal(str(statistics.pstdev([float(v) for v in nums])))) + + +def _percentile_decimal(values: Any, percentile: int) -> str | None: + nums = sorted(Decimal(str(v)) for v in values if v not in (None, "")) + if not nums: + return None + if len(nums) == 1: + return _decimal_text(nums[0]) + idx = max(0, min(len(nums) - 1, int(round((percentile / 100) * (len(nums) - 1))))) + return _decimal_text(nums[idx]) + + +def _materialize_order_prices(order: _SampleOrder, spec: BingxSampleSpec, submit_ref: Decimal | None) -> None: + if submit_ref is None: + return + side_is_buy = order.side == OrderSide.BUY + trigger_intent = str(getattr(spec, "trigger_intent", "auto") or "auto").lower() + if trigger_intent not in {"auto", "stop_loss", "take_profit"}: + trigger_intent = "auto" + if trigger_intent == "auto": + if order.order_type in {OrderType.STOP_LIMIT, OrderType.STOP_MARKET}: + trigger_intent = "stop_loss" + elif order.order_type in {OrderType.MARKET_IF_TOUCHED, OrderType.LIMIT_IF_TOUCHED}: + trigger_intent = "take_profit" + + def _trigger_px() -> Decimal: + if trigger_intent == "stop_loss": + return submit_ref * (Decimal("0.999") if side_is_buy else Decimal("1.001")) + if trigger_intent == "take_profit": + return submit_ref * (Decimal("1.001") if side_is_buy else Decimal("0.999")) + return submit_ref * (Decimal("0.999") if side_is_buy else Decimal("1.001")) + + if order.order_type == OrderType.LIMIT and not order.has_price: + order.has_price = True + tif = getattr(order, "time_in_force", None) + if tif in {TimeInForce.IOC, TimeInForce.FOK}: + price = submit_ref * (Decimal("1.001") if side_is_buy else Decimal("0.999")) + else: + price = submit_ref * (Decimal("0.999") if side_is_buy else Decimal("1.001")) + order.price = Price.from_str(str(price)) + if order.order_type in {OrderType.STOP_LIMIT, OrderType.STOP_MARKET} and not order.has_trigger_price: + order.has_trigger_price = True + trigger = _trigger_px() + order.trigger_price = Price.from_str(str(trigger)) + if order.order_type == OrderType.STOP_LIMIT and not order.has_price: + order.has_price = True + order.price = Price.from_str(str(trigger * (Decimal("0.999") if side_is_buy else Decimal("1.001")))) + if order.order_type in {OrderType.MARKET_IF_TOUCHED, OrderType.LIMIT_IF_TOUCHED} and not order.has_trigger_price: + order.has_trigger_price = True + trigger = _trigger_px() + order.trigger_price = Price.from_str(str(trigger)) + if order.order_type == OrderType.LIMIT_IF_TOUCHED and not order.has_price: + order.has_price = True + order.price = Price.from_str(str(trigger * (Decimal("1.001") if side_is_buy else Decimal("0.999")))) + if order.order_type == OrderType.TRAILING_STOP_MARKET and order.trailing_offset is None: + order.trailing_offset = "0.1" + + +def _needs_observation_tail(order: _SampleOrder) -> bool: + if order.order_type == OrderType.MARKET: + return True + if order.order_type == OrderType.LIMIT and order.time_in_force in {TimeInForce.IOC, TimeInForce.FOK}: + return True + return False + + +def _needs_fast_position_inference(order: _SampleOrder) -> bool: + return order.order_type == OrderType.MARKET or ( + order.order_type == OrderType.LIMIT and order.time_in_force in {TimeInForce.IOC, TimeInForce.FOK} + ) + + +def _is_meaningful_observation_row(row: dict[str, Any] | None) -> bool: + if not isinstance(row, dict): + return False + status = str(row.get("status") or row.get("X") or row.get("x") or "").upper() + if status in _TERMINAL_ORDER_STATUSES: + return True + fill_qty = row.get("lastFilledQty") or row.get("executedQty") or row.get("cumFilledQty") or row.get("z") + try: + return Decimal(str(fill_qty or "0")) > 0 + except Exception: + return False + + +async def _snapshot_position_qty(adapter: BingxExecutionClient, symbol: str) -> Decimal: + try: + positions = await adapter._client.signed_get("/openApi/swap/v2/user/positions", {}) # noqa: SLF001 + except Exception: + return Decimal("0") + rows = _position_rows(positions) + symbol_key = symbol.replace("-", "") + for row in rows: + if not isinstance(row, dict): + continue + row_symbol = str(row.get("symbol") or row.get("s") or "").replace("-", "") + if row_symbol == symbol_key: + return Decimal(str(row.get("positionAmt") or row.get("positionQty") or "0")) + return Decimal("0") + + +async def _infer_from_account_state( + adapter: BingxExecutionClient, + spec: BingxSampleSpec, + order: _SampleOrder, + pre_position_qty: Decimal, +) -> tuple[str, str | None, str | None, dict[str, Any]] | None: + try: + positions = await adapter._client.signed_get("/openApi/swap/v2/user/positions", {}) # noqa: SLF001 + except Exception: + return None + rows = _position_rows(positions) + symbol_key = spec.symbol.replace("-", "") + post_row: dict[str, Any] | None = None + for row in rows: + if not isinstance(row, dict): + continue + row_symbol = str(row.get("symbol") or row.get("s") or "").replace("-", "") + if row_symbol == symbol_key: + post_row = row + break + if post_row is None: + return None + post_position_qty = Decimal(str(post_row.get("positionAmt") or post_row.get("positionQty") or "0")) + delta = post_position_qty - pre_position_qty + if delta == 0: + return None + fill_qty = _decimal_text(abs(delta)) + fill_px = str(post_row.get("avgPrice") or post_row.get("entryPrice") or "0") + status = "FILLED" + if abs(delta) < Decimal(str(order.quantity)): + status = "PARTIALLY_FILLED" + row = { + "status": status, + "avgPrice": fill_px, + "executedQty": fill_qty, + "cumFilledQty": fill_qty, + "lastFilledQty": fill_qty, + "lastFillPrice": fill_px, + "commission": "0", + "commissionAsset": spec.symbol.replace("USDT", ""), + } + return status, fill_px, fill_qty, row diff --git a/prod/bingx/config.py b/prod/bingx/config.py new file mode 100644 index 0000000..1615547 --- /dev/null +++ b/prod/bingx/config.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from decimal import Decimal + +from nautilus_trader.config import InstrumentProviderConfig +from nautilus_trader.config import LiveExecClientConfig +from nautilus_trader.config import PositiveInt + +from .enums import BINGX_VENUE +from .enums import BingxEnvironment + + +def require_mainnet_opt_in(environment: BingxEnvironment, allow_mainnet: bool, *, context: str) -> None: + """ + Fail closed unless the caller explicitly opts into BingX LIVE mainnet. + """ + if environment is BingxEnvironment.LIVE and not allow_mainnet: + raise ValueError(f"{context} LIVE requires allow_mainnet=True") + + +class BingxInstrumentProviderConfig(InstrumentProviderConfig, frozen=True): + """ + Configuration for the BingX perpetual futures instrument provider. + """ + + symbol_filters: tuple[str, ...] | None = None + default_maker_fee: Decimal = Decimal("0.0002") + default_taker_fee: Decimal = Decimal("0.0005") + + +class BingxExecClientConfig(LiveExecClientConfig, frozen=True): + """ + Configuration for the BingX live execution client. + """ + + venue = BINGX_VENUE + api_key: str | None = None + secret_key: str | None = None + environment: BingxEnvironment = BingxEnvironment.VST + allow_mainnet: bool = False + base_url_http: str | None = None + base_url_http_backup: str | None = None + base_url_ws_private: str | None = None + http_timeout_secs: PositiveInt = 10 + recv_window_ms: PositiveInt = 5_000 + max_retries: PositiveInt = 3 + retry_delay_initial_ms: PositiveInt = 250 + retry_delay_max_ms: PositiveInt = 2_000 + instrument_provider: BingxInstrumentProviderConfig = BingxInstrumentProviderConfig(load_all=True) + use_gtd: bool = False + use_reduce_only: bool = True + use_position_ids: bool = False + prefer_websocket: bool = True + ws_listenkey_keepalive_interval_secs: PositiveInt = 1_800 + ws_event_stale_after_ms: PositiveInt = 15_000 + ws_reconnect_initial_ms: PositiveInt = 500 + ws_reconnect_max_ms: PositiveInt = 10_000 + poll_open_orders_interval_ms: PositiveInt = 500 + poll_account_interval_ms: PositiveInt = 2_000 + poll_positions_interval_ms: PositiveInt = 2_000 + default_leverage: PositiveInt = 1 + exchange_leverage_cap: PositiveInt = 3 + sizing_mode: str = "engine" + leverage_by_symbol: dict[str, PositiveInt] | None = None + margin_type_by_symbol: dict[str, str] | None = None + enforce_integer_leverage: bool = True + journal_strategy: str | None = None + journal_db: str | None = None + + def validate_mainnet_opt_in(self) -> None: + require_mainnet_opt_in(self.environment, self.allow_mainnet, context="BingX execution client") diff --git a/prod/bingx/data_client.py b/prod/bingx/data_client.py new file mode 100644 index 0000000..4cfa4a4 --- /dev/null +++ b/prod/bingx/data_client.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +from nautilus_trader.cache.cache import Cache +from nautilus_trader.common.component import LiveClock +from nautilus_trader.common.component import MessageBus +from nautilus_trader.common.enums import LogColor +from nautilus_trader.common.providers import InstrumentProvider +from nautilus_trader.core.datetime import millis_to_nanos +from nautilus_trader.data.messages import SubscribeOrderBook +from nautilus_trader.data.messages import SubscribeQuoteTicks +from nautilus_trader.data.messages import UnsubscribeOrderBook +from nautilus_trader.data.messages import UnsubscribeQuoteTicks +from nautilus_trader.live.data_client import LiveMarketDataClient +from nautilus_trader.model.data import BookOrder +from nautilus_trader.model.data import OrderBookDelta +from nautilus_trader.model.data import OrderBookDeltas +from nautilus_trader.model.data import QuoteTick +from nautilus_trader.model.enums import BookAction +from nautilus_trader.model.enums import OrderSide +from nautilus_trader.model.identifiers import ClientId +from nautilus_trader.model.identifiers import InstrumentId +from nautilus_trader.model.objects import Price +from nautilus_trader.model.objects import Quantity + +from .data_config import BingxDataClientConfig +from .enums import BINGX_VENUE +from .http import BingxHttpClient +from .market_stream import BingxMarketStream +from .urls import get_public_ws_url + + +class BingxMarketDataClient(LiveMarketDataClient): + """ + Nautilus `LiveMarketDataClient` for BingX USDT-M perpetuals. + """ + + def __init__( + self, + loop: asyncio.AbstractEventLoop, + client: BingxHttpClient, + msgbus: MessageBus, + cache: Cache, + clock: LiveClock, + instrument_provider: InstrumentProvider, + config: BingxDataClientConfig, + name: str | None = None, + ) -> None: + super().__init__( + loop=loop, + client_id=ClientId(name or config.venue.value), + venue=BINGX_VENUE, + msgbus=msgbus, + cache=cache, + clock=clock, + instrument_provider=instrument_provider, + ) + self._client = client + self._cfg = config + + ws_url = config.base_url_ws_market or get_public_ws_url(config.environment) + self._ws_url = ws_url + self._stream = BingxMarketStream( + ws_url=ws_url, + on_event=self._handle_ws_event, + on_health=self._handle_ws_health, + reconnect_initial_ms=int(config.ws_reconnect_initial_ms), + reconnect_max_ms=int(config.ws_reconnect_max_ms), + http_timeout_secs=int(config.http_timeout_secs), + ) + self._stream_task: asyncio.Task | None = None + + self._raw_to_instrument_id: dict[str, InstrumentId] = {} + self._book_sequences: dict[InstrumentId, int] = {} + self._quote_subs: set[InstrumentId] = set() + self._book_subs: dict[InstrumentId, int] = {} + + def _instrument_for(self, instrument_id: InstrumentId): + return self._instrument_provider.get_all().get(instrument_id) + + def _send_all_instruments_to_data_engine(self) -> None: + for instrument in self._instrument_provider.get_all().values(): + self._handle_data(instrument) + + for currency in self._instrument_provider.currencies().values(): + self._cache.add_currency(currency) + + async def _connect(self) -> None: + await self._instrument_provider.initialize() + self._send_all_instruments_to_data_engine() + + for instrument_id, instrument in self._instrument_provider.get_all().items(): + raw = getattr(instrument, "raw_symbol", None) + if raw is None: + continue + self._raw_to_instrument_id[str(raw)] = instrument_id + + self._log.info(f"BingX market WS {self._ws_url}", LogColor.BLUE) + self._stream_task = self.create_task(self._stream.run_forever(), log_msg="bingx_market_stream") # type: ignore[arg-type] + + async def _disconnect(self) -> None: + if self._stream_task is not None: + self._stream_task.cancel() + await self._stream.close() + + async def _subscribe_quote_ticks(self, command: SubscribeQuoteTicks) -> None: + instrument = self._instrument_for(command.instrument_id) + if instrument is None: + self._log.warning(f"BingX quote subscription skipped, instrument not found: {command.instrument_id}") + return + self._quote_subs.add(command.instrument_id) + raw_symbol = str(getattr(instrument, "raw_symbol")) + self._stream.subscribe(f"{raw_symbol}@bookTicker") + + async def _unsubscribe_quote_ticks(self, command: UnsubscribeQuoteTicks) -> None: + self._quote_subs.discard(command.instrument_id) + instrument = self._instrument_for(command.instrument_id) + if instrument is None: + return + raw_symbol = str(getattr(instrument, "raw_symbol")) + self._stream.unsubscribe(f"{raw_symbol}@bookTicker") + + async def _subscribe_order_book_deltas(self, command: SubscribeOrderBook) -> None: + instrument = self._instrument_for(command.instrument_id) + if instrument is None: + self._log.warning(f"BingX book subscription skipped, instrument not found: {command.instrument_id}") + return + self._book_subs[command.instrument_id] = int(command.depth or self._cfg.depth_level) + raw_symbol = str(getattr(instrument, "raw_symbol")) + self._stream.subscribe(f"{raw_symbol}@incrDepth") + + async def _unsubscribe_order_book_deltas(self, command: UnsubscribeOrderBook) -> None: + self._book_subs.pop(command.instrument_id, None) + instrument = self._instrument_for(command.instrument_id) + if instrument is None: + return + raw_symbol = str(getattr(instrument, "raw_symbol")) + self._stream.unsubscribe(f"{raw_symbol}@incrDepth") + + async def _subscribe_order_book_depth(self, command: SubscribeOrderBook) -> None: + await self._subscribe_order_book_deltas(command) + + async def _unsubscribe_order_book_depth(self, command: UnsubscribeOrderBook) -> None: + await self._unsubscribe_order_book_deltas(command) + + async def _handle_ws_event(self, payload: dict[str, Any]) -> None: + data_type = str(payload.get("dataType") or "") + data = payload.get("data") + if not isinstance(data, dict) or not data_type: + return + + sym = str(data.get("s") or data.get("symbol") or "") + if not sym: + sym = data_type.split("@", 1)[0] + instrument_id = self._raw_to_instrument_id.get(sym) + if instrument_id is None: + return + + ts_ms = int(data.get("T") or 0) + ts_event = millis_to_nanos(ts_ms) if ts_ms else self._clock.timestamp_ns() + ts_init = self._clock.timestamp_ns() + + if data_type.endswith("@bookTicker") and instrument_id in self._quote_subs: + qt = QuoteTick( + instrument_id, + Price.from_str(str(data.get("b") or "0")), + Price.from_str(str(data.get("a") or "0")), + Quantity.from_str(str(data.get("B") or "0")), + Quantity.from_str(str(data.get("A") or "0")), + ts_event, + ts_init, + ) + self._handle_data(qt) + return + + if data_type.endswith("@incrDepth") and instrument_id in self._book_subs: + action = str(data.get("action") or "") + last_update_id = int(data.get("lastUpdateId") or 0) + bids = data.get("bids") + asks = data.get("asks") + if not isinstance(bids, list) or not isinstance(asks, list): + return + + deltas: list[OrderBookDelta] = [] + if action == "all": + deltas.append(OrderBookDelta(instrument_id, BookAction.CLEAR, None, 0, last_update_id, ts_event, ts_init)) + else: + prev = self._book_sequences.get(instrument_id) + if prev is not None and last_update_id and last_update_id != prev + 1: + deltas.append(OrderBookDelta(instrument_id, BookAction.CLEAR, None, 0, last_update_id, ts_event, ts_init)) + if last_update_id: + self._book_sequences[instrument_id] = last_update_id + + depth = int(self._book_subs[instrument_id]) + + def _emit(side: OrderSide, rows: list) -> None: + n = 0 + for item in rows: + if n >= depth: + break + if not isinstance(item, (list, tuple)) or len(item) < 2: + continue + px_s = str(item[0]) + qty_s = str(item[1]) + qty = Quantity.from_str(qty_s) + if qty.as_double() == 0.0: + order = BookOrder(side, Price.from_str(px_s), Quantity.from_str("0"), 0) + deltas.append(OrderBookDelta(instrument_id, BookAction.DELETE, order, 0, last_update_id, ts_event, ts_init)) + else: + order = BookOrder(side, Price.from_str(px_s), qty, 0) + deltas.append(OrderBookDelta(instrument_id, BookAction.UPDATE, order, 0, last_update_id, ts_event, ts_init)) + n += 1 + + _emit(OrderSide.BUY, bids) + _emit(OrderSide.SELL, asks) + if deltas: + self._handle_data(OrderBookDeltas(instrument_id, deltas)) + + def _handle_ws_health(self, healthy: bool) -> None: + if healthy: + self._log.info("BingX market WS healthy", LogColor.GREEN) + else: + self._log.warning("BingX market WS unhealthy") diff --git a/prod/bingx/data_config.py b/prod/bingx/data_config.py new file mode 100644 index 0000000..10df59d --- /dev/null +++ b/prod/bingx/data_config.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from nautilus_trader.config import LiveDataClientConfig +from nautilus_trader.config import PositiveInt + +from .config import BingxInstrumentProviderConfig +from .config import require_mainnet_opt_in +from .enums import BINGX_VENUE +from .enums import BingxEnvironment + + +class BingxDataClientConfig(LiveDataClientConfig, frozen=True): + """ + Configuration for the BingX live market data client. + """ + + venue = BINGX_VENUE + environment: BingxEnvironment = BingxEnvironment.VST + allow_mainnet: bool = False + base_url_ws_market: str | None = None + http_timeout_secs: PositiveInt = 10 + instrument_provider: BingxInstrumentProviderConfig = BingxInstrumentProviderConfig(load_all=True) + + use_book_ticker: bool = True + use_incr_depth: bool = True + depth_level: PositiveInt = 20 + + ws_reconnect_initial_ms: PositiveInt = 500 + ws_reconnect_max_ms: PositiveInt = 10_000 + + def validate_mainnet_opt_in(self) -> None: + require_mainnet_opt_in(self.environment, self.allow_mainnet, context="BingX data client") + + def __post_init__(self) -> None: + import enum + if isinstance(self.environment, enum.Enum): + env_val = self.environment.value + else: + env_val = str(self.environment) + if env_val.upper() == "LIVE" and not self.allow_mainnet: + raise ValueError( + "BingXDataClientConfig: LIVE environment requires allow_mainnet=True. " + "Pass allow_mainnet=True explicitly to opt in." + ) diff --git a/prod/bingx/data_factories.py b/prod/bingx/data_factories.py new file mode 100644 index 0000000..d036dd0 --- /dev/null +++ b/prod/bingx/data_factories.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import asyncio + +from nautilus_trader.cache.cache import Cache +from nautilus_trader.common.component import LiveClock +from nautilus_trader.common.component import MessageBus +from nautilus_trader.live.factories import LiveDataClientFactory + +from .config import BingxExecClientConfig +from .data_client import BingxMarketDataClient +from .data_config import BingxDataClientConfig +from .http import BingxHttpClient +from .instrument_provider import BingxInstrumentProvider + + +class BingxLiveDataClientFactory(LiveDataClientFactory): + @staticmethod + def create( # type: ignore[override] + loop: asyncio.AbstractEventLoop, + name: str, + config: BingxDataClientConfig, + msgbus: MessageBus, + cache: Cache, + clock: LiveClock, + ) -> BingxMarketDataClient: + config.validate_mainnet_opt_in() + exec_cfg = BingxExecClientConfig( + api_key=None, + secret_key=None, + environment=config.environment, + allow_mainnet=config.allow_mainnet, + http_timeout_secs=config.http_timeout_secs, + instrument_provider=config.instrument_provider, + ) + client = BingxHttpClient(exec_cfg) + provider = BingxInstrumentProvider(client=client, config=config.instrument_provider) + return BingxMarketDataClient( + loop=loop, + client=client, + msgbus=msgbus, + cache=cache, + clock=clock, + instrument_provider=provider, + config=config, + name=name, + ) diff --git a/prod/bingx/dns_cache.py b/prod/bingx/dns_cache.py new file mode 100644 index 0000000..797fe69 --- /dev/null +++ b/prod/bingx/dns_cache.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import socket +import time +from dataclasses import dataclass, field +from typing import Mapping + + +_STATIC_ENDPOINT_IPS: dict[str, tuple[str, ...]] = { + "open-api.bingx.com": ( + "18.66.122.103", + "18.66.122.71", + "18.66.122.3", + "18.66.122.36", + "2600:9000:2250:4800:1a:4f2d:b440:93a1", + "2600:9000:2250:c400:1a:4f2d:b440:93a1", + "2600:9000:2250:6c00:1a:4f2d:b440:93a1", + "2600:9000:2250:7200:1a:4f2d:b440:93a1", + "2600:9000:2250:4600:1a:4f2d:b440:93a1", + "2600:9000:2250:ee00:1a:4f2d:b440:93a1", + "2600:9000:2250:fc00:1a:4f2d:b440:93a1", + "2600:9000:2250:6800:1a:4f2d:b440:93a1", + ), + "open-api-vst.bingx.com": ( + "18.165.122.31", + "18.165.122.22", + "18.165.122.47", + "18.165.122.63", + "2600:9000:2375:a200:14:7788:7980:93a1", + "2600:9000:2375:9000:14:7788:7980:93a1", + "2600:9000:2375:2c00:14:7788:7980:93a1", + "2600:9000:2375:7800:14:7788:7980:93a1", + "2600:9000:2375:f600:14:7788:7980:93a1", + "2600:9000:2375:ce00:14:7788:7980:93a1", + "2600:9000:2375:e800:14:7788:7980:93a1", + "2600:9000:2375:e200:14:7788:7980:93a1", + ), + "open-api-swap.bingx.com": ( + "3.164.68.61", + "3.164.68.42", + "3.164.68.75", + "3.164.68.125", + "2600:9000:278c:7400:1f:3dec:7600:93a1", + "2600:9000:278c:3e00:1f:3dec:7600:93a1", + "2600:9000:278c:5800:1f:3dec:7600:93a1", + "2600:9000:278c:3400:1f:3dec:7600:93a1", + "2600:9000:278c:ce00:1f:3dec:7600:93a1", + "2600:9000:278c:9a00:1f:3dec:7600:93a1", + "2600:9000:278c:9000:1f:3dec:7600:93a1", + "2600:9000:278c:c800:1f:3dec:7600:93a1", + ), + "open-api.bingx.pro": ( + "2606:4700:4403::ac40:9313", + "2a06:98c1:310d::6812:28ed", + ), + "open-api-vst.bingx.pro": ( + "2a06:98c1:310d::6812:28ed", + "2606:4700:4403::ac40:9313", + ), + "open-api-swap.bingx.pro": ( + "2a06:98c1:310d::6812:28ed", + "2606:4700:4403::ac40:9313", + ), +} + + +@dataclass(slots=True) +class BingxDnsRecord: + hostname: str + ips: tuple[str, ...] + source: str + updated_at_ns: int + + +@dataclass(slots=True) +class BingxDnsFallbackCache: + default_ips: Mapping[str, tuple[str, ...]] = field(default_factory=lambda: dict(_STATIC_ENDPOINT_IPS)) + _records: dict[str, BingxDnsRecord] = field(default_factory=dict, init=False, repr=False) + + def resolve(self, hostname: str) -> tuple[str, ...]: + record = self._records.get(hostname) + if record is not None: + return record.ips + return tuple(self.default_ips.get(hostname, ())) + + def record_static(self, hostname: str) -> tuple[str, ...]: + ips = self.resolve(hostname) + if ips: + self._records[hostname] = BingxDnsRecord( + hostname=hostname, + ips=ips, + source="static", + updated_at_ns=time.monotonic_ns(), + ) + return ips + + def refresh_from_dns(self, hostname: str) -> tuple[str, ...]: + seen: list[str] = [] + for family in (socket.AF_UNSPEC,): + infos = socket.getaddrinfo(hostname, None, family=family, type=socket.SOCK_STREAM) + for info in infos: + address = info[4][0] + if address not in seen: + seen.append(address) + ips = tuple(seen) + if ips: + self._records[hostname] = BingxDnsRecord( + hostname=hostname, + ips=ips, + source="dns", + updated_at_ns=time.monotonic_ns(), + ) + return ips + + def maybe_refresh_from_dns(self, hostname: str, *, min_interval_secs: int = 300) -> tuple[str, ...] | None: + record = self._records.get(hostname) + if record is not None: + age_secs = (time.monotonic_ns() - record.updated_at_ns) / 1_000_000_000 + if age_secs < min_interval_secs: + return record.ips + try: + return self.refresh_from_dns(hostname) + except Exception: + return None + + +def is_bingx_hostname(hostname: str | None) -> bool: + return hostname in _STATIC_ENDPOINT_IPS diff --git a/prod/bingx/enums.py b/prod/bingx/enums.py new file mode 100644 index 0000000..0e1405c --- /dev/null +++ b/prod/bingx/enums.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from enum import Enum + +from nautilus_trader.model.identifiers import Venue + + +BINGX_VENUE = Venue("BINGX") +PINK_DEFAULT_ENV = None # resolved later + + +class BingxEnvironment(str, Enum): + LIVE = "prod-live" + VST = "prod-vst" + + @property + def is_vst(self) -> bool: + return self is BingxEnvironment.VST + + +# Deferred assignment after enum definition +PINK_DEFAULT_ENV = BingxEnvironment.VST diff --git a/prod/bingx/execution.py b/prod/bingx/execution.py new file mode 100644 index 0000000..e901149 --- /dev/null +++ b/prod/bingx/execution.py @@ -0,0 +1,2338 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import math +import urllib.parse +import urllib.request +from dataclasses import asdict +from dataclasses import is_dataclass +from collections.abc import Iterable +from datetime import timedelta +from decimal import Decimal +from hashlib import sha256 +from typing import Any + +from nautilus_trader.accounting.accounts.margin import MarginAccount +from nautilus_trader.cache.cache import Cache +from nautilus_trader.common.component import LiveClock +from nautilus_trader.common.component import MessageBus +from nautilus_trader.common.enums import LogColor +from nautilus_trader.core.uuid import UUID4 +from nautilus_trader.execution.messages import BatchCancelOrders +from nautilus_trader.execution.messages import CancelAllOrders +from nautilus_trader.execution.messages import CancelOrder +from nautilus_trader.execution.messages import GenerateFillReports +from nautilus_trader.execution.messages import GenerateOrderStatusReport +from nautilus_trader.execution.messages import GenerateOrderStatusReports +from nautilus_trader.execution.messages import GeneratePositionStatusReports +from nautilus_trader.execution.messages import ModifyOrder +from nautilus_trader.execution.messages import SubmitOrder +from nautilus_trader.execution.messages import SubmitOrderList +from nautilus_trader.execution.reports import ExecutionMassStatus +from nautilus_trader.execution.reports import FillReport +from nautilus_trader.execution.reports import OrderStatusReport +from nautilus_trader.execution.reports import PositionStatusReport +from nautilus_trader.live.execution_client import LiveExecutionClient +from nautilus_trader.model.enums import AccountType +from nautilus_trader.model.enums import OmsType +from nautilus_trader.model.enums import OrderSide +from nautilus_trader.model.enums import OrderStatus +from nautilus_trader.model.enums import OrderType +from nautilus_trader.model.enums import PositionSide +from nautilus_trader.model.enums import LiquiditySide +from nautilus_trader.model.enums import TimeInForce +from nautilus_trader.model.enums import order_side_to_str +from nautilus_trader.model.identifiers import AccountId +from nautilus_trader.model.identifiers import ClientId +from nautilus_trader.model.identifiers import ClientOrderId +from nautilus_trader.model.identifiers import InstrumentId +from nautilus_trader.model.identifiers import PositionId +from nautilus_trader.model.identifiers import TradeId +from nautilus_trader.model.identifiers import VenueOrderId +from nautilus_trader.model.objects import AccountBalance +from nautilus_trader.model.objects import Currency +from nautilus_trader.model.objects import MarginBalance +from nautilus_trader.model.objects import Money +from nautilus_trader.model.objects import Price +from nautilus_trader.model.objects import Quantity +from nautilus_trader.model.orders import Order + +from .config import BingxExecClientConfig +from .enums import BINGX_VENUE +from .leverage import LEVERAGE_MAPPING_RULE +from .leverage import map_internal_conviction_to_exchange_leverage +from .leverage import map_internal_conviction_to_exchange_leverage_target +from .leverage import normalize_bingx_leverage_value +from .sizing_mode import build_split_sizing_payload +from .friction import estimate_friction +from ..execution_quality import build_execution_quality_record +from .journal import build_snapshot as build_journal_snapshot +from .journal import load_latest_snapshot as load_journal_snapshot +from .journal import write_snapshot as write_journal_snapshot +from .reconciliation import get_terminal_trade_handler +try: + from prod.ch_writer import ch_put + from prod.ch_writer import ts_us as _ch_ts_us +except Exception: # pragma: no cover - durable logging remains optional in tests + def ch_put(*_args, **_kwargs): # type: ignore[no-redef] + return None + + def _ch_ts_us() -> int: # type: ignore[no-redef] + return 0 +from .http import BingxHttpClient +from .http import BingxHttpError +from .instrument_provider import BingxInstrumentProvider +from .schemas import BingxOrderAck +from .schemas import unwrap_order_payload +from .websocket import BingxUserStream + + +LOGGER = logging.getLogger(__name__) + + +def _decimal_text(value: Decimal) -> str: + text = format(value.normalize(), "f") + if "." in text: + text = text.rstrip("0").rstrip(".") + return text or "0" + + +def _jsonable_snapshot(value: Any) -> Any: + if is_dataclass(value): + return asdict(value) + if hasattr(value, "__dict__"): + return dict(vars(value)) + return value + + +def _account_event_observability_tags(client: Any) -> dict[str, str]: + resolver = getattr(client, "_pink_observability_tags", None) + if callable(resolver): + try: + tags = resolver() + if isinstance(tags, dict): + return {str(k): str(v) for k, v in tags.items() if v is not None} + except Exception: + pass + if str(getattr(client, "_journal_strategy", "")).lower() != "pink": + return {} + return { + "runtime_namespace": "pink", + "strategy_namespace": "pink", + "event_namespace": "pink", + "actor_name": "BingxExecutionClient", + "exec_venue": BINGX_VENUE.value, + "data_venue": BINGX_VENUE.value, + } + + +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): + LOGGER.warning("BingX account snapshot balances payload is not a list") + return 0.0 + for row in rows: + if not isinstance(row, dict): + LOGGER.warning("BingX account snapshot skipped malformed balance row: %r", row) + 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 + if rows: + LOGGER.error("BingX account snapshot contained no usable balance rows") + return 0.0 + + +def _open_notional_from_positions(rows: Any) -> float: + if not isinstance(rows, dict): + LOGGER.warning("BingX position snapshot payload is not a dict") + return 0.0 + total = 0.0 + for row in rows.values(): + if not isinstance(row, dict): + LOGGER.warning("BingX position snapshot skipped malformed position row: %r", row) + 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: + LOGGER.warning("BingX position snapshot skipped unreadable position row: %r", row) + continue + return total + + +def _filled_order_count_from_snapshots(rows: Any) -> int: + if not isinstance(rows, list): + return 0 + seen: set[str] = set() + count = 0 + for snapshot in rows: + if not isinstance(snapshot, dict): + continue + row = snapshot.get("row") if isinstance(snapshot.get("row"), dict) else snapshot + if not isinstance(row, dict): + continue + status = str(row.get("status") or "").upper() + if status and status not in {"FILLED", "CLOSED"}: + continue + trade_key = str(snapshot.get("_trade_key") or "").strip() + if trade_key: + base_key = trade_key.split(":", 1)[0] + else: + base_key = str( + row.get("orderId") + or row.get("orderID") + or row.get("clientOrderId") + or row.get("clientOrderID") + or "" + ).strip() + if not base_key or base_key in seen: + continue + seen.add(base_key) + count += 1 + return count + + +def _first_balance_row(rows: Any) -> dict[str, Any] | None: + if not isinstance(rows, list): + return None + for row in rows: + if isinstance(row, dict): + return row + return None + + +def _positive_int_or_none(value: Any) -> int | None: + try: + parsed = int(value) + except Exception: + return None + return parsed if parsed > 0 else None + + +async def _reserve_leverage_update(client: Any, symbol: str, desired: int) -> bool: + async with client._state_lock: + if client._configured_leverage.get(symbol) == desired: + return False + inflight = getattr(client, "_leverage_update_inflight", None) + if isinstance(inflight, dict) and inflight.get(symbol) == desired: + return False + if inflight is None: + inflight = {} + client._leverage_update_inflight = inflight + inflight[symbol] = desired + return True + + +async def _commit_leverage_update(client: Any, symbol: str, desired: int) -> None: + async with client._state_lock: + client._configured_leverage[symbol] = desired + inflight = getattr(client, "_leverage_update_inflight", None) + if isinstance(inflight, dict): + inflight.pop(symbol, None) + + +async def _release_leverage_update(client: Any, symbol: str, desired: int) -> None: + async with client._state_lock: + inflight = getattr(client, "_leverage_update_inflight", None) + if isinstance(inflight, dict) and inflight.get(symbol) == desired: + inflight.pop(symbol, None) + + +class BingxExecutionClient(LiveExecutionClient): + """ + Nautilus live execution client for BingX perpetual futures. + + This adapter is execution-first for DOLPHIN's split-venue architecture: + Binance remains the market-data venue while BingX is responsible for execution, + account balances, positions, and leverage state. + """ + + _normalize_bingx_leverage_value = staticmethod(normalize_bingx_leverage_value) + _map_internal_conviction_to_exchange_leverage = staticmethod( + map_internal_conviction_to_exchange_leverage + ) + _map_internal_conviction_to_exchange_leverage_target = staticmethod( + map_internal_conviction_to_exchange_leverage_target + ) + _build_split_sizing_payload = staticmethod(build_split_sizing_payload) + + def __init__( + self, + loop: asyncio.AbstractEventLoop, + client: BingxHttpClient, + msgbus: MessageBus, + cache: Cache, + clock: LiveClock, + instrument_provider: BingxInstrumentProvider, + config: BingxExecClientConfig, + name: str | None = None, + ) -> None: + super().__init__( + loop=loop, + client_id=ClientId(name or BINGX_VENUE.value), + venue=BINGX_VENUE, + oms_type=OmsType.NETTING, + account_type=AccountType.MARGIN, + base_currency=None, + instrument_provider=instrument_provider, + msgbus=msgbus, + cache=cache, + clock=clock, + config=config, + ) + self._client = client + self._provider = instrument_provider + self._config = config + suffix = "vst" if config.environment.is_vst else "live" + self._set_account_id(AccountId(f"{BINGX_VENUE.value}-{suffix}")) + self._poll_tasks: list[asyncio.Task] = [] + self._order_snapshots: dict[str, dict[str, Any]] = {} + self._fill_snapshots: list[dict[str, Any]] = [] + self._fill_snapshots_by_key: dict[str, dict[str, Any]] = {} + self._position_snapshots: dict[str, dict[str, Any]] = {} + self._order_id_aliases: dict[str, str] = {} + self._account_snapshot: dict[str, Any] = {} + self._ledger_authority = "exchange" + self._reported_terminal_orders: set[str] = set() + self._configured_leverage: dict[str, int] = {} + self._leverage_update_inflight: dict[str, int] = {} + self._state_lock = asyncio.Lock() + self._journal_last_fingerprint = "" + self._journal_strategy = getattr(config, "journal_strategy", None) or "prodgreen" + self._journal_db = getattr(config, "journal_db", None) or "dolphin_prodgreen" + self._user_stream: BingxUserStream | None = None + self._user_stream_task: asyncio.Task | None = None + self._ws_healthy = False + self._ws_last_event_ns = 0 + self._ws_supports_order_updates = False + self._ws_last_health_state = False + self._order_update_events: dict[str, asyncio.Event] = {} + self._fill_update_events: dict[str, asyncio.Event] = {} + self._account_event_last_fingerprint = "" + + def _pink_observability_tags(self) -> dict[str, str]: + if str(self._journal_strategy).lower() != "pink": + return {} + return { + "runtime_namespace": "pink", + "strategy_namespace": "pink", + "event_namespace": "pink", + "actor_name": "BingxExecutionClient", + "exec_venue": BINGX_VENUE.value, + "data_venue": BINGX_VENUE.value, + } + + def _ensure_account_events_table(self) -> None: + ddl = f""" + ALTER TABLE {self._journal_db}.account_events + ADD COLUMN IF NOT EXISTS runtime_namespace LowCardinality(String) DEFAULT '', + ADD COLUMN IF NOT EXISTS strategy_namespace LowCardinality(String) DEFAULT '', + ADD COLUMN IF NOT EXISTS event_namespace LowCardinality(String) DEFAULT '', + ADD COLUMN IF NOT EXISTS actor_name LowCardinality(String) DEFAULT '', + ADD COLUMN IF NOT EXISTS exec_venue LowCardinality(String) DEFAULT '', + ADD COLUMN IF NOT EXISTS data_venue LowCardinality(String) DEFAULT '' + """ + try: + req = urllib.request.Request( + "http://localhost:8123/", + data=ddl.encode(), + method="POST", + ) + req.add_header("X-ClickHouse-User", "dolphin") + req.add_header("X-ClickHouse-Key", "dolphin_ch_2026") + urllib.request.urlopen(req, timeout=5).close() + self._log.info(f"[CH] {self._journal_db}.account_events tag columns ensured") + except Exception as exc: + self._log.warning(f"[CH] account_events tag column ensure failed: {exc}") + + async def _connect(self) -> None: + self._log.info("Loading BingX instruments...", LogColor.BLUE) + await self._provider.initialize() + instruments = self._provider.list_all() + self._log.info(f"Loaded {len(instruments)} BingX instruments", LogColor.GREEN) + for instrument in instruments: + self._cache.add_instrument(instrument) + for currency in self._provider.currencies().values(): + self._cache.add_currency(currency) + self._log.info(f"Registered {len(instruments)} BingX instruments in cache", LogColor.GREEN) + ensure_account_events_table = getattr(self, "_ensure_account_events_table", None) + if callable(ensure_account_events_table): + ensure_account_events_table() + await self._refresh_account_state() + await self._restore_journal_snapshot() + await self._persist_journal_snapshot("STARTUP", force=True) + await self._await_account_registered(log_registered=False) + await self._persist_journal_snapshot("ACCOUNT_REGISTERED", force=True) + if self._config.prefer_websocket: + self._start_user_stream() + self._start_pollers() + + async def _disconnect(self) -> None: + if self._user_stream is not None: + await self._user_stream.close() + if self._user_stream_task is not None: + self._user_stream_task.cancel() + self._user_stream_task = None + for task in self._poll_tasks: + task.cancel() + self._poll_tasks.clear() + await self._client.close() + cancel_pending = getattr(self, "cancel_pending_tasks", None) + if cancel_pending is not None: + result = cancel_pending() + if asyncio.iscoroutine(result): + await result + + async def refresh_account_state(self) -> None: + await self._refresh_account_state() + + async def refresh_positions(self) -> None: + await self._refresh_positions() + + async def refresh_open_orders(self) -> None: + await self._refresh_open_orders() + + async def _await_account_registered( + self, + *, + timeout_secs: float = 5.0, + poll_interval_secs: float = 0.05, + log_registered: bool = True, + ) -> None: + account_id = getattr(self, "account_id", "") + deadline = self._clock.utc_now().timestamp() + timeout_secs + while self._clock.utc_now().timestamp() < deadline: + account = self.get_account() + if account is not None: + if log_registered: + self._log.info(f"BingX account registered: {account_id}", LogColor.GREEN) + return + snapshot = getattr(self, "_account_snapshot", None) + if isinstance(snapshot, dict): + capital = _capital_from_balance_rows(snapshot.get("balances", [])) + if capital > 0: + if log_registered: + self._log.info( + f"BingX account snapshot ready: {account_id}", + LogColor.GREEN, + ) + return + await asyncio.sleep(poll_interval_secs) + self._log.warning( + f"Timed out waiting for BingX account registration: {account_id}; " + "continuing with REST/polling fallback" + ) + + def resolve_split_sizing_payload( + self, + *, + sizing_lev: float, + capital: float | None = None, + mark_price: float | None = None, + quantity_step: float | None = None, + venue_notional_cap: float | None = None, + exchange_leverage_cap: int | None = None, + margin_budget_fraction_override: float | None = None, + system_fraction_override: float | None = None, + control_plane: dict[str, Any] | None = None, + notes: dict[str, Any] | None = None, + ) -> dict[str, Any] | None: + """Build a BingX-ready sizing payload when split mode is enabled.""" + return self._build_split_sizing_payload( + sizing_mode=getattr(self._config, "sizing_mode", "engine"), + sizing_lev=sizing_lev, + capital=capital, + mark_price=mark_price, + quantity_step=quantity_step, + venue_notional_cap=venue_notional_cap, + exchange_leverage_cap=exchange_leverage_cap, + margin_budget_fraction_override=margin_budget_fraction_override, + system_fraction_override=system_fraction_override, + control_plane=control_plane, + hz_client=self._hz_client, + defaults=None, + notes=notes or {}, + ) + + async def set_leverage(self, instrument_id: InstrumentId, leverage: int) -> None: + desired = normalize_bingx_leverage_value( + leverage, + exchange_max=self._config.exchange_leverage_cap, + ) + if desired != int(Decimal(str(leverage))): + self._log.info( + f"BingX leverage quantized from {leverage!r} to integer exchange leverage {desired}", + LogColor.YELLOW, + ) + symbol = self._normalize_symbol(instrument_id.symbol.value) + if not await _reserve_leverage_update(self, symbol, desired): + return + try: + await self._client.signed_post( + "/openApi/swap/v2/trade/leverage", + {"symbol": self._venue_symbol(instrument_id), "side": "BOTH", "leverage": desired}, + ) + except Exception: + await _release_leverage_update(self, symbol, desired) + raise + await _commit_leverage_update(self, symbol, desired) + account = self.get_account() + if isinstance(account, MarginAccount): + account.set_leverage(instrument_id, Decimal(desired)) + persist = getattr(self, "_persist_journal_snapshot", None) + if persist is not None: + await persist("LEVERAGE_UPDATE") + + def snapshot_account_state(self) -> dict[str, Any]: + account = dict(getattr(self, "_account_snapshot", {})) + balances = account.get("balances", []) + capital = _capital_from_balance_rows(balances) + peak_capital = capital + positions = dict(getattr(self, "_position_snapshots", {})) + current_open_notional = _open_notional_from_positions(positions) + current_account_leverage = current_open_notional / capital if capital > 0 else 0.0 + exchange_leverage = 0 + if getattr(self, "_configured_leverage", None): + try: + exchange_leverage = max( + parsed + for v in self._configured_leverage.values() + if (parsed := _positive_int_or_none(v)) is not None + ) + except Exception: + exchange_leverage = 0 + return { + "account_id": str(self.account_id), + "account_snapshot": dict(getattr(self, "_account_snapshot", {})), + "ledger_authority": getattr(self, "_ledger_authority", "exchange"), + "order_snapshots": dict(getattr(self, "_order_snapshots", {})), + "position_snapshots": dict(getattr(self, "_position_snapshots", {})), + "configured_leverage": dict(getattr(self, "_configured_leverage", {})), + "current_open_notional": current_open_notional, + "current_account_leverage": current_account_leverage, + "exchange_leverage": exchange_leverage, + "exchange_leverage_mode": "mapped_conservative_integer", + "leverage_mapping_rule": LEVERAGE_MAPPING_RULE, + "capital": capital, + "peak_capital": peak_capital, + "ws_healthy": bool(getattr(self, "_ws_healthy", False)), + "ws_last_event_ns": int(getattr(self, "_ws_last_event_ns", 0)), + "ws_supports_order_updates": bool(getattr(self, "_ws_supports_order_updates", False)), + "rate_limits": _jsonable_snapshot(self._client.rate_limit_snapshot()), + "circuit_breaker": _jsonable_snapshot(self._client.circuit_breaker.snapshot()), + } + + def _write_account_event(self, reason: str) -> None: + account = dict(getattr(self, "_account_snapshot", {})) + observability_tags = _account_event_observability_tags(self) + strategy_name = str(getattr(self, "_journal_strategy", "") or "").lower() + journal_db = str(getattr(self, "_journal_db", "") or "") + if not journal_db: + if strategy_name == "pink": + journal_db = "dolphin_pink" + elif strategy_name == "green": + journal_db = "dolphin_green" + elif strategy_name.startswith("prod"): + journal_db = "dolphin_prodgreen" + else: + journal_db = "dolphin" + balances = account.get("balances", []) + capital = _capital_from_balance_rows(balances) + peak_capital = capital + positions = dict(self._position_snapshots) if isinstance(self._position_snapshots, dict) else {} + orders = dict(self._order_snapshots) if isinstance(self._order_snapshots, dict) else {} + current_open_notional = _open_notional_from_positions(positions) + current_account_leverage = current_open_notional / capital if capital > 0 else 0.0 + fills_today = len(self._fill_snapshots) if isinstance(self._fill_snapshots, list) else 0 + filled_orders_today = _filled_order_count_from_snapshots(self._fill_snapshots) + exchange_leverage = 0 + if getattr(self, "_configured_leverage", None): + try: + exchange_leverage = max( + parsed + for v in self._configured_leverage.values() + if (parsed := _positive_int_or_none(v)) is not None + ) + except Exception: + exchange_leverage = 0 + row = { + "ts": __import__("datetime").datetime.now(__import__("datetime").timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f"), + "event_type": reason, + "strategy": self._journal_strategy, + "posture": "N/A", + "capital": capital, + "peak_capital": peak_capital, + "drawdown_pct": 0.0, + "pnl_today": 0.0, + "trades_today": filled_orders_today, + "open_positions": len(positions), + "boost": 1.0, + "beta": 1.0, + "current_open_notional": current_open_notional, + "current_account_leverage": current_account_leverage, + "exchange_leverage": exchange_leverage, + "exchange_leverage_mode": "mapped_conservative_integer", + "leverage_mapping_rule": LEVERAGE_MAPPING_RULE, + "notes": json.dumps( + { + "account_id": str(self.account_id), + "ledger_authority": getattr(self, "_ledger_authority", "exchange"), + "observability": observability_tags, + "fills_today": fills_today, + "filled_orders_today": filled_orders_today, + "account": account, + "orders": orders, + "positions": positions, + }, + sort_keys=True, + separators=(",", ":"), + default=str, + ), + } + row.update(observability_tags) + if capital <= 0.0: + self._log.error( + "BingX account event has no usable capital: strategy=%s account_id=%s reason=%s", + self._journal_strategy, + self.account_id, + reason, + ) + stable = { + "event_type": row["event_type"], + "strategy": row["strategy"], + "posture": row["posture"], + "capital": row["capital"], + "peak_capital": row["peak_capital"], + "drawdown_pct": row["drawdown_pct"], + "trades_today": row["trades_today"], + "open_positions": row["open_positions"], + "current_open_notional": row["current_open_notional"], + "current_account_leverage": row["current_account_leverage"], + "exchange_leverage": row["exchange_leverage"], + "boost": row["boost"], + "beta": row["beta"], + "notes": row["notes"], + } + fingerprint = sha256( + json.dumps(stable, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8") + ).hexdigest() + if fingerprint == self._account_event_last_fingerprint: + return + self._account_event_last_fingerprint = fingerprint + try: + body = json.dumps(row, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8") + sql = "INSERT INTO account_events FORMAT JSONEachRow" + url = ( + "http://localhost:8123/" + f"?database={journal_db}&query={urllib.parse.quote(sql)}" + ) + req = urllib.request.Request(url, data=body, method="POST") + req.add_header("X-ClickHouse-User", "dolphin") + req.add_header("X-ClickHouse-Key", "dolphin_ch_2026") + req.add_header("Content-Type", "application/octet-stream") + with urllib.request.urlopen(req, timeout=5) as resp: + if resp.status not in (200, 201): + raise RuntimeError(f"HTTP {resp.status}") + self._log.info(f"[CH] {journal_db} account event written: {reason}") + except Exception as exc: + self._log.debug(f"Failed to write {journal_db} account event: {exc}") + + def _journal_payload(self) -> dict[str, Any]: + return { + "account": dict(getattr(self, "_account_snapshot", {})), + "orders": dict(getattr(self, "_order_snapshots", {})), + "positions": dict(getattr(self, "_position_snapshots", {})), + "configured_leverage": dict(getattr(self, "_configured_leverage", {})), + "ws_healthy": bool(getattr(self, "_ws_healthy", False)), + "ws_last_event_ns": int(getattr(self, "_ws_last_event_ns", 0)), + "ws_supports_order_updates": bool(getattr(self, "_ws_supports_order_updates", False)), + "fills": list(getattr(self, "_fill_snapshots", [])), + "rate_limits": _jsonable_snapshot(self._client.rate_limit_snapshot()), + "circuit_breaker": _jsonable_snapshot(self._client.circuit_breaker.snapshot()), + "ledger_authority": getattr(self, "_ledger_authority", "exchange"), + "account_id": str(self.account_id), + } + + async def _persist_journal_snapshot(self, reason: str, *, force: bool = False) -> None: + payload = self._journal_payload() + fingerprint = sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + if not force and fingerprint == self._journal_last_fingerprint: + return + self._journal_last_fingerprint = fingerprint + try: + snapshot = build_journal_snapshot( + strategy=self._journal_strategy, + account_id=str(self.account_id), + ledger_authority=getattr(self, "_ledger_authority", "exchange"), + payload=payload, + reason=reason, + ) + write_journal_snapshot(snapshot) + except Exception as exc: + self._log.warning(f"Failed to persist BingX journal snapshot: {exc}") + + async def _persist_health_alarm(self, reason: str, *, force: bool = True) -> None: + persist = getattr(self, "_persist_journal_snapshot", None) + if persist is not None: + await persist(reason, force=force) + + async def _restore_journal_snapshot(self) -> None: + if getattr(self, "_account_snapshot", None): + return + payload = load_journal_snapshot(strategy=self._journal_strategy, account_id=str(self.account_id)) + if not payload: + return + await self._apply_journal_payload(payload) + + async def _apply_journal_payload(self, payload: dict[str, Any]) -> None: + config = getattr(self, "_config", None) + is_vst = bool(getattr(config, "is_vst", False)) + account = payload.get("account") + if isinstance(account, dict): + self._account_snapshot = dict(account) + orders = payload.get("orders") + if isinstance(orders, dict): + self._order_snapshots = dict(orders) + self._order_id_aliases = {} + for key, snapshot in self._order_snapshots.items(): + if not isinstance(snapshot, dict): + continue + order_id = str(snapshot.get("orderId") or snapshot.get("orderID") or "") + if order_id: + self._order_id_aliases[order_id] = str(key) + positions = payload.get("positions") + if isinstance(positions, dict): + if is_vst and getattr(self, "_ledger_authority", "exchange") == "exchange": + # Live BingX is exchange-ledger authoritative. Do not resurrect + # historical positions from the journal on startup; the live + # REST/websocket refresh path will repopulate `_position_snapshots` + # from the actual exchange state. + self._position_snapshots = {} + else: + self._position_snapshots = dict(positions) + elif is_vst and getattr(self, "_ledger_authority", "exchange") == "exchange": + self._position_snapshots = {} + fills = payload.get("fills") + if isinstance(fills, list): + self._fill_snapshots = [dict(row) for row in fills if isinstance(row, dict)] + self._fill_snapshots_by_key = {} + for snapshot in self._fill_snapshots: + row = snapshot.get("row", {}) + if not isinstance(row, dict): + continue + client_order_id = str(row.get("clientOrderId") or row.get("clientOrderID") or "") + order_id = str(row.get("orderId") or row.get("orderID") or "") + if client_order_id: + self._fill_snapshots_by_key[client_order_id] = dict(row) + if order_id: + self._fill_snapshots_by_key[order_id] = dict(row) + configured = payload.get("configured_leverage") + if isinstance(configured, dict): + self._configured_leverage = {} + for key, value in configured.items(): + parsed = _positive_int_or_none(value) + if parsed is not None: + self._configured_leverage[str(key)] = parsed + balances_raw = account.get("balances") if isinstance(account, dict) else None + if isinstance(balances_raw, list) and balances_raw: + balances: list[AccountBalance] = [] + margins: list[MarginBalance] = [] + for row in balances_raw: + if not isinstance(row, dict): + continue + asset = Currency.from_str(str(row.get("asset") or "USDT")) + total = Money(Decimal(str(row.get("total") or "0")), asset) + free = Money(Decimal(str(row.get("free") or "0")), asset) + locked = Money(total.as_decimal() - free.as_decimal(), asset) + balances.append(AccountBalance(total=total, locked=locked, free=free)) + margins.append(MarginBalance(initial=Money(Decimal("0"), asset), maintenance=Money(Decimal("0"), asset))) + if balances: + clock = getattr(self, "_clock", None) + ts_event = clock.timestamp_ns() if clock is not None and hasattr(clock, "timestamp_ns") else 0 + self.generate_account_state( + balances=balances, + margins=margins, + reported=True, + ts_event=ts_event, + info={"source": "journal", "raw": payload}, + ) + + async def _check_state_drift(self, reason: str) -> None: + drift: dict[str, Any] = {} + account = self.get_account() + snapshot = self._account_snapshot + balances = snapshot.get("balances") if isinstance(snapshot, dict) else [] + first = _first_balance_row(balances) + if isinstance(account, MarginAccount) and first is not None: + asset = Currency.from_str(str(first.get("asset") or "USDT")) + expected_total = Decimal(str(first.get("total") or "0")) + expected_free = Decimal(str(first.get("free") or "0")) + expected_locked = Decimal(str(first.get("locked") or "0")) + observed_total = account.balance_total(asset).as_decimal() + observed_free = account.balance_free(asset).as_decimal() + observed_locked = account.balance_locked(asset).as_decimal() + if abs(observed_total - expected_total) > Decimal("0.00000001"): + drift["balance_total"] = [str(expected_total), str(observed_total)] + if abs(observed_free - expected_free) > Decimal("0.00000001"): + drift["balance_free"] = [str(expected_free), str(observed_free)] + if abs(observed_locked - expected_locked) > Decimal("0.00000001"): + drift["balance_locked"] = [str(expected_locked), str(observed_locked)] + cached_positions = { + str(position.instrument_id.symbol.value): str(position.signed_qty) + for position in self._cache.positions(venue=self.venue) + } + snapshot_positions = { + str(row.get("symbol") or ""): str(row.get("positionAmt") or "0") + for row in self._position_snapshots.values() + } + if cached_positions != snapshot_positions: + drift["positions"] = { + "exchange": snapshot_positions, + "nautilus": cached_positions, + } + if drift: + self._ws_healthy = False + self._ledger_authority = "exchange" + self._log.error(f"BingX/Nautilus state drift detected ({reason}): {drift}") + persist = getattr(self, "_persist_journal_snapshot", None) + if persist is not None: + await persist("BINGX_DRIFT", force=True) + + def _start_user_stream(self) -> None: + self._user_stream = BingxUserStream( + client=self._client, + config=self._config, + on_event=self._handle_user_stream_event, + on_health=self._handle_user_stream_health, + ) + self._user_stream_task = self.create_task( + self._user_stream.run_forever(), + log_msg="bingx_user_stream", + ) + + def _start_pollers(self) -> None: + self._poll_tasks = [ + self.create_task( + self._poll_open_orders_loop(), + log_msg="bingx_poll_open_orders", + ), + self.create_task( + self._poll_positions_loop(), + log_msg="bingx_poll_positions", + ), + self.create_task( + self._poll_account_loop(), + log_msg="bingx_poll_account", + ), + ] + + async def _poll_open_orders_loop(self) -> None: + try: + while True: + if self._should_poll_open_orders(): + await self._refresh_open_orders() + await asyncio.sleep(self._config.poll_open_orders_interval_ms / 1000.0) + except asyncio.CancelledError: + return + + async def _poll_positions_loop(self) -> None: + try: + while True: + if self._should_use_http_polling(): + await self._refresh_positions() + await asyncio.sleep(self._config.poll_positions_interval_ms / 1000.0) + except asyncio.CancelledError: + return + + async def _poll_account_loop(self) -> None: + try: + while True: + if self._should_use_http_polling(): + await self._refresh_account_state() + await asyncio.sleep(self._config.poll_account_interval_ms / 1000.0) + except asyncio.CancelledError: + return + + def _should_use_http_polling(self) -> bool: + if not self._config.prefer_websocket: + return True + if not self._ws_healthy: + return True + stale_after_ns = int(self._config.ws_event_stale_after_ms) * 1_000_000 + if stale_after_ns <= 0: + return True + return (self._clock.timestamp_ns() - self._ws_last_event_ns) > stale_after_ns + + def _should_poll_open_orders(self) -> bool: + if self._should_use_http_polling(): + return True + return not self._ws_supports_order_updates + + def _handle_user_stream_health(self, healthy: bool) -> None: + previous = self._ws_healthy + self._ws_healthy = healthy + if healthy: + self._ws_last_event_ns = self._clock.timestamp_ns() + if previous != healthy: + self.create_task( + self._persist_health_alarm("BINGX_WS_UP" if healthy else "BINGX_WS_DOWN"), + log_msg="bingx_ws_health", + ) + + async def _handle_user_stream_event(self, payload: dict[str, Any]) -> None: + self._ws_last_event_ns = self._clock.timestamp_ns() + data = payload.get("data") if isinstance(payload.get("data"), dict) else None + event_type = str((data or payload).get("e") or "").upper() + data_type = str(payload.get("dataType") or "").lower() + if event_type == "ACCOUNT_UPDATE": + await self._apply_account_update(data or payload) + return + if event_type == "ORDER_TRADE_UPDATE": + await self._apply_order_trade_update(payload) + return + if event_type == "EXECUTIONREPORT" or data_type == "spot.executionreport": + await self._apply_execution_report(data or payload) + return + if event_type == "ACCOUNT_CONFIG_UPDATE": + await self._apply_account_config_update((data or payload).get("ac")) + return + if event_type == "SNAPSHOT": + # TODO(dolphin): Absorb the initial BingX futures SNAPSHOT burst into a + # ws_primed startup/reconnect phase. Use it to warm leverage/margin-mode + # caches and suppress false drift alarms / excess REST polling until the + # snapshot flood has drained. Do not treat SNAPSHOT as order-execution truth. + await self._apply_account_config_update((data or payload).get("ac")) + return + if event_type == "LISTENKEYEXPIRED": + self._ws_healthy = False + await self._persist_health_alarm("BINGX_LISTENKEY_EXPIRED") + + async def _apply_account_update(self, payload: dict[str, Any]) -> None: + account = payload.get("a") + if not isinstance(account, dict): + return + updated = False + balances_raw = account.get("B") + if isinstance(balances_raw, list): + balances: list[AccountBalance] = [] + margins: list[MarginBalance] = [] + for row in balances_raw: + if not isinstance(row, dict): + continue + asset = Currency.from_str(str(row.get("a") or "USDT")) + total = Money(Decimal(str(row.get("wb") or "0")), asset) + free = Money(Decimal(str(row.get("cw") or row.get("wb") or "0")), asset) + locked = Money(total.as_decimal() - free.as_decimal(), asset) + balances.append(AccountBalance(total=total, locked=locked, free=free)) + margins.append( + MarginBalance( + initial=Money(Decimal("0"), asset), + maintenance=Money(Decimal("0"), asset), + ), + ) + if balances: + async with self._state_lock: + self._account_snapshot = { + "source": "ws", + "ts_event": self._clock.timestamp_ns(), + "balances": [ + { + "asset": balance.total.currency.code, + "total": _decimal_text(balance.total.as_decimal()), + "free": _decimal_text(balance.free.as_decimal()), + "locked": _decimal_text(balance.locked.as_decimal()), + } + for balance in balances + ], + "margins": [ + { + "asset": margin.initial.currency.code, + "initial": str(margin.initial), + "maintenance": str(margin.maintenance), + } + for margin in margins + ], + "raw": account, + } + self.generate_account_state( + balances=balances, + margins=margins, + reported=True, + ts_event=self._clock.timestamp_ns(), + info={"source": "ws", "raw": account}, + ) + updated = True + positions_raw = account.get("P") + if isinstance(positions_raw, list): + async with self._state_lock: + for row in positions_raw: + if not isinstance(row, dict): + continue + symbol = self._normalize_symbol(str(row.get("s") or "")) + amount = Decimal(str(row.get("pa") or "0")) + if amount == 0: + self._position_snapshots.pop(symbol, None) + continue + self._position_snapshots[symbol] = { + "symbol": row.get("s"), + "positionAmt": row.get("pa"), + "entryPrice": row.get("ep"), + "unRealizedProfit": row.get("up"), + "marginType": row.get("mt"), + "positionSide": row.get("ps"), + "positionID": row.get("positionID") or row.get("positionId"), + } + updated = True + if not updated: + return + persist = getattr(self, "_persist_journal_snapshot", None) + if persist is not None: + await persist("ACCOUNT_UPDATE") + self._write_account_event("ACCOUNT_UPDATE") + drift_check = getattr(self, "_check_state_drift", None) + if drift_check is not None: + await drift_check("ACCOUNT_UPDATE") + + async def _apply_order_trade_update(self, payload: dict[str, Any]) -> None: + self._ws_supports_order_updates = True + order_update = payload.get("o") + if not isinstance(order_update, dict): + return + client_order_id = str(order_update.get("c") or "") + venue_order_id = str(order_update.get("i") or "") + if not client_order_id and venue_order_id: + async with self._state_lock: + client_order_id = self._order_id_aliases.get(venue_order_id, "") + row = { + "symbol": order_update.get("s"), + "clientOrderId": client_order_id, + "clientOrderID": client_order_id, + "orderId": order_update.get("i"), + "orderID": order_update.get("i"), + "status": order_update.get("X") or order_update.get("x"), + "price": order_update.get("p"), + "avgPrice": order_update.get("ap") or order_update.get("L") or order_update.get("p"), + "executedQty": order_update.get("z") or "0", + "cumFilledQty": order_update.get("z") or "0", + "lastFilledQty": order_update.get("l") or "0", + "lastFillPrice": order_update.get("L") or order_update.get("ap") or order_update.get("p"), + "commission": order_update.get("n") or "0", + "commissionAsset": order_update.get("N"), + "executionType": order_update.get("x"), + "positionID": order_update.get("positionID") or order_update.get("positionId"), + "triggerOrderId": order_update.get("triggerOrderId"), + "mainOrderId": order_update.get("mainOrderId"), + "source": "ws_order_trade_update", + "raw": order_update, + } + cached_order = self._cache.order(ClientOrderId(client_order_id)) if client_order_id else None + if cached_order is not None: + instrument = self._cache.instrument(cached_order.instrument_id) + if instrument is not None: + try: + row["friction"] = self._build_friction_snapshot( + cached_order, + row, + instrument, + feed_source=str(row.get("source") or "ws_order_trade_update"), + ) + except Exception: + pass + async with self._state_lock: + if client_order_id and venue_order_id: + self._order_id_aliases[venue_order_id] = client_order_id + if client_order_id: + async with self._state_lock: + self._order_snapshots[client_order_id] = row + signal_update = getattr(self, "_signal_order_update", None) + if signal_update is not None: + signal_update(client_order_id) + if venue_order_id: + async with self._state_lock: + self._order_snapshots[venue_order_id] = row + signal_update = getattr(self, "_signal_order_update", None) + if signal_update is not None: + signal_update(venue_order_id) + persist = getattr(self, "_persist_journal_snapshot", None) + if persist is not None: + await persist("ORDER_TRADE_UPDATE") + if str(order_update.get("x") or "").upper() == "TRADE_PREVENTION": + cached_order = self._cache.order(ClientOrderId(client_order_id)) + if cached_order is None: + return + self.generate_order_rejected( + strategy_id=cached_order.strategy_id, + instrument_id=cached_order.instrument_id, + client_order_id=ClientOrderId(client_order_id), + reason="Post-Only would cross spread", + ts_event=self._clock.timestamp_ns(), + due_post_only=True, + ) + return + emit_progress = getattr(self, "_emit_order_progress", None) + if emit_progress is not None: + await emit_progress(row) + + async def _apply_execution_report(self, order_update: dict[str, Any]) -> None: + if not isinstance(order_update, dict): + return + self._ws_supports_order_updates = True + payload = { + "o": { + "s": order_update.get("s"), + "c": order_update.get("c") or order_update.get("clientOrderId") or order_update.get("clientOrderID"), + "i": order_update.get("i") or order_update.get("orderId") or order_update.get("orderID"), + "X": order_update.get("X"), + "x": order_update.get("x"), + "p": order_update.get("p") or order_update.get("price"), + "ap": order_update.get("ap") or order_update.get("avgPrice"), + "z": order_update.get("z") or order_update.get("executedQty") or order_update.get("cumFilledQty"), + "l": order_update.get("l") or order_update.get("lastFilledQty") or order_update.get("lastExecutedQty"), + "L": order_update.get("L") or order_update.get("lastFillPrice") or order_update.get("avgPrice"), + "n": order_update.get("n") or order_update.get("commission"), + "N": order_update.get("N") or order_update.get("commissionAsset"), + "positionID": order_update.get("positionID") or order_update.get("positionId"), + "triggerOrderId": order_update.get("triggerOrderId"), + "mainOrderId": order_update.get("mainOrderId"), + } + } + await self._apply_order_trade_update(payload) + + async def _apply_account_config_update(self, payload: Any) -> None: + if not isinstance(payload, dict): + return + symbol = self._normalize_symbol(str(payload.get("s") or "")) + if not symbol: + return + leverage = payload.get("S") or payload.get("l") or 1 + parsed = _positive_int_or_none(leverage) + if parsed is None: + return + async with self._state_lock: + self._configured_leverage[symbol] = parsed + + async def _refresh_account_state(self) -> None: + try: + balances_raw = await self._client.signed_get("/openApi/swap/v2/user/balance") + rows_raw = balances_raw if isinstance(balances_raw, list) else balances_raw.get("balance", []) + if isinstance(rows_raw, dict): + rows = [rows_raw] + elif isinstance(rows_raw, list): + rows = rows_raw + else: + rows = [] + balances: list[AccountBalance] = [] + margins: list[MarginBalance] = [] + for row in rows: + asset = Currency.from_str(str(row.get("asset", "USDT"))) + total = Money(Decimal(str(row.get("balance") or row.get("equity") or "0")), asset) + free = Money( + Decimal(str(row.get("availableBalance") or row.get("availableMargin") or "0")), + asset, + ) + locked = Money(total.as_decimal() - free.as_decimal(), asset) + balances.append(AccountBalance(total=total, locked=locked, free=free)) + margins.append( + MarginBalance( + initial=Money( + Decimal(str(row.get("initialMargin") or "0")), + asset, + ), + maintenance=Money( + Decimal(str(row.get("maintMargin") or "0")), + asset, + ), + ), + ) + async with self._state_lock: + self.generate_account_state( + balances=balances, + margins=margins, + reported=True, + ts_event=self._clock.timestamp_ns(), + info={"source": "rest", "raw": balances_raw}, + ) + self._account_snapshot = { + "source": "rest", + "ts_event": self._clock.timestamp_ns(), + "balances": [ + { + "asset": balance.total.currency.code, + "total": _decimal_text(balance.total.as_decimal()), + "free": _decimal_text(balance.free.as_decimal()), + "locked": _decimal_text(balance.locked.as_decimal()), + } + for balance in balances + ], + "margins": [ + { + "asset": margin.initial.currency.code, + "initial": str(margin.initial), + "maintenance": str(margin.maintenance), + } + for margin in margins + ], + "raw": balances_raw, + } + persist = getattr(self, "_persist_journal_snapshot", None) + if persist is not None: + await persist("ACCOUNT_REFRESH") + self._write_account_event("ACCOUNT_REFRESH") + drift_check = getattr(self, "_check_state_drift", None) + if drift_check is not None: + await drift_check("ACCOUNT_REFRESH") + except Exception as exc: + self._log.warning(f"Failed to refresh BingX account state: {exc}") + await self._persist_health_alarm("BINGX_REST_FAIL") + + async def _refresh_positions(self) -> None: + try: + positions = await self._client.signed_get("/openApi/swap/v2/user/positions") + rows = _rows_from_payload(positions, "positions", "data") + exchange_snapshots: dict[str, dict[str, Any]] = { + self._normalize_symbol(str(row.get("symbol", ""))): row + for row in rows + if Decimal(str(row.get("positionAmt") or row.get("positionQty") or "0")) != 0 + } + async with self._state_lock: + self._position_snapshots = dict(exchange_snapshots) + await self._sync_cache_positions_with_snapshot(exchange_snapshots) + persist = getattr(self, "_persist_journal_snapshot", None) + if persist is not None: + await persist("POSITION_REFRESH") + drift_check = getattr(self, "_check_state_drift", None) + if drift_check is not None: + await drift_check("POSITION_REFRESH") + except Exception as exc: + self._log.warning(f"Failed to refresh BingX positions: {exc}") + await self._persist_health_alarm("BINGX_REST_FAIL") + + async def _sync_cache_positions_with_snapshot(self, exchange_snapshots: dict[str, dict[str, Any]]) -> None: + """Purge stale Nautilus cache positions that no longer exist on BingX. + + BingX is the authoritative ledger in live/testnet mode. If the exchange + reports no open position for a symbol, but Nautilus still has a cached + open position, that cached leg must be removed so the actor stops + treating it as live exposure. + """ + cache_positions = list(self._cache.positions(venue=self.venue)) + if not cache_positions: + return + stale_positions = [] + for position in cache_positions: + try: + instrument_id = getattr(position, "instrument_id", None) + symbol = str(getattr(getattr(instrument_id, "symbol", None), "value", "") or "") + norm_symbol = self._normalize_symbol(symbol) + if norm_symbol and norm_symbol not in exchange_snapshots: + stale_positions.append(position) + except Exception: + continue + if not stale_positions: + return + for position in stale_positions: + try: + position_id = getattr(position, "position_id", None) or getattr(position, "id", None) + if position_id is None: + continue + self._cache.purge_position(position_id, purge_from_database=False) + except Exception as exc: + self._log.debug(f"Failed to purge stale BingX cache position: {exc}") + + async def _refresh_open_orders(self) -> None: + try: + orders = await self._client.signed_get("/openApi/swap/v2/trade/openOrders") + rows = _rows_from_payload(orders, "orders", "data") + active_client_ids = set() + for row in rows: + client_order_id = str(row.get("clientOrderID") or row.get("clientOrderId") or "") + if not client_order_id: + continue + active_client_ids.add(client_order_id) + async with self._state_lock: + self._order_snapshots[client_order_id] = row + await self._emit_order_progress(row) + async with self._state_lock: + snapshots = list(self._order_snapshots.items()) + for client_order_id, snapshot in snapshots: + if client_order_id in active_client_ids: + continue + await self._emit_terminal_reconciliation(snapshot) + persist = getattr(self, "_persist_journal_snapshot", None) + if persist is not None: + await persist("OPEN_ORDER_REFRESH") + except Exception as exc: + self._log.warning(f"Failed to refresh BingX open orders: {exc}") + await self._persist_health_alarm("BINGX_REST_FAIL") + + async def _emit_order_progress(self, row: dict[str, Any]) -> None: + client_order_id = str(row.get("clientOrderID") or row.get("clientOrderId") or "") + venue_order_id = str(row.get("orderId") or row.get("orderID") or "") + if not client_order_id and venue_order_id: + async with self._state_lock: + client_order_id = self._order_id_aliases.get(venue_order_id, "") + order = self._cache.order(ClientOrderId(client_order_id)) if client_order_id else None + if order is None: + return + venue_order_id = VenueOrderId(venue_order_id or str(row.get("id") or "")) + status = str(row.get("status") or "").upper() + if status in {"NEW", "PENDING", "CREATED"}: + self.generate_order_accepted( + strategy_id=order.strategy_id, + instrument_id=order.instrument_id, + client_order_id=order.client_order_id, + venue_order_id=venue_order_id, + ts_event=self._clock.timestamp_ns(), + ) + signal_update = getattr(self, "_signal_order_update", None) + if signal_update is not None: + signal_update(client_order_id) + return + + filled_qty = Decimal(str(row.get("executedQty") or row.get("cumFilledQty") or "0")) + last_filled_qty = Decimal(str(row.get("lastFilledQty") or row.get("lastExecutedQty") or "0")) + if last_filled_qty > 0: + await self._emit_fill_from_row( + order=order, + row=row, + venue_order_id=venue_order_id, + last_filled_qty=last_filled_qty, + cumulative_filled_qty=filled_qty if filled_qty > 0 else last_filled_qty, + ) + elif filled_qty > 0: + await self._emit_fill_from_row( + order=order, + row=row, + venue_order_id=venue_order_id, + last_filled_qty=filled_qty, + cumulative_filled_qty=filled_qty, + ) + if status in {"CANCELED", "CANCELLED"}: + async with self._state_lock: + if client_order_id in self._reported_terminal_orders: + return + self._reported_terminal_orders.add(client_order_id) + self.generate_order_canceled( + strategy_id=order.strategy_id, + instrument_id=order.instrument_id, + client_order_id=order.client_order_id, + venue_order_id=venue_order_id, + ts_event=self._clock.timestamp_ns(), + ) + signal_update = getattr(self, "_signal_order_update", None) + if signal_update is not None: + signal_update(client_order_id) + return + if status in {"FAILED", "REJECTED"}: + async with self._state_lock: + if client_order_id in self._reported_terminal_orders: + return + self._reported_terminal_orders.add(client_order_id) + self.generate_order_rejected( + strategy_id=order.strategy_id, + instrument_id=order.instrument_id, + client_order_id=order.client_order_id, + reason=status, + ts_event=self._clock.timestamp_ns(), + due_post_only=False, + ) + signal_update = getattr(self, "_signal_order_update", None) + if signal_update is not None: + signal_update(client_order_id) + return + if status == "EXPIRED": + async with self._state_lock: + if client_order_id in self._reported_terminal_orders: + return + self._reported_terminal_orders.add(client_order_id) + self.generate_order_canceled( + strategy_id=order.strategy_id, + instrument_id=order.instrument_id, + client_order_id=order.client_order_id, + venue_order_id=venue_order_id, + ts_event=self._clock.timestamp_ns(), + ) + signal_update = getattr(self, "_signal_order_update", None) + if signal_update is not None: + signal_update(client_order_id) + + async def _emit_terminal_reconciliation(self, row: dict[str, Any]) -> None: + client_order_id = str(row.get("clientOrderID") or row.get("clientOrderId") or "") + venue_order_id = str(row.get("orderId") or row.get("orderID") or "") + if not client_order_id and venue_order_id: + async with self._state_lock: + client_order_id = self._order_id_aliases.get(venue_order_id, "") + if not client_order_id: + return + async with self._state_lock: + if client_order_id in self._reported_terminal_orders: + self._order_snapshots.pop(client_order_id, None) + return + order = self._cache.order(ClientOrderId(client_order_id)) + if order is None: + async with self._state_lock: + self._order_snapshots.pop(client_order_id, None) + return + try: + detail = await self._client.signed_get( + "/openApi/swap/v2/trade/order", + { + "symbol": self._venue_symbol(order.instrument_id), + "clientOrderId": client_order_id, + }, + ) + detail_row = unwrap_order_payload(detail) if isinstance(detail, dict) else None + if isinstance(detail_row, dict): + detail_row.setdefault("source", "rest_order_detail") + await self._emit_order_progress(detail_row) + terminal_handler = get_terminal_trade_handler(str(self.account_id)) + if terminal_handler is not None: + result = terminal_handler( + { + "account_id": str(self.account_id), + "client_order_id": client_order_id, + "venue_order_id": venue_order_id, + "snapshot_row": dict(row), + "detail_row": dict(detail_row), + } + ) + if asyncio.iscoroutine(result): + await result + finally: + async with self._state_lock: + self._order_snapshots.pop(client_order_id, None) + signal_update = getattr(self, "_signal_order_update", None) + if signal_update is not None: + signal_update(client_order_id) + + async def _emit_fill_from_row( + self, + order: Order, + row: dict[str, Any], + venue_order_id: VenueOrderId, + last_filled_qty: Decimal, + cumulative_filled_qty: Decimal, + ) -> None: + last_qty = Quantity.from_str(str(last_filled_qty)) + avg_price_raw = ( + row.get("lastFillPrice") + or row.get("avgPrice") + or row.get("avgFilledPrice") + or row.get("price") + ) + last_px = Price.from_str(str(avg_price_raw or "0")) + trade_key = f"{venue_order_id.value}:{cumulative_filled_qty}:{last_px}" + instrument = self._cache.instrument(order.instrument_id) + if instrument is None: + return + feed_source = str(row.get("source") or "unknown") + friction = self._build_friction_snapshot( + order, + row, + instrument, + last_qty=last_qty, + last_px=last_px, + feed_source=feed_source, + ) + enriched_row = dict(row) + enriched_row["friction"] = friction + quality_builder = getattr(self, "_build_execution_quality_record", None) + if not callable(quality_builder): + quality_builder = BingxExecutionClient._build_execution_quality_record + quality_record = quality_builder( + order=order, + venue_order_id=venue_order_id, + row=enriched_row, + friction=friction, + source=str(enriched_row.get("source") or "unknown"), + ) + enriched_row["execution_quality"] = quality_record + async with self._state_lock: + if any(snapshot.get("_trade_key") == trade_key for snapshot in self._fill_snapshots): + return + self._fill_snapshots.append({"_trade_key": trade_key, "row": enriched_row}) + if len(self._fill_snapshots) > 1024: + del self._fill_snapshots[:-1024] + self._fill_snapshots_by_key[order.client_order_id.value] = dict(enriched_row) + self._fill_snapshots_by_key[str(venue_order_id.value)] = dict(enriched_row) + self._order_id_aliases[str(venue_order_id.value)] = order.client_order_id.value + order_snapshot = self._order_snapshots.get(order.client_order_id.value) + if isinstance(order_snapshot, dict): + order_snapshot["lastFriction"] = friction + order_snapshot["lastExecutionQuality"] = quality_record + persist_quality = getattr(self, "_persist_execution_quality", None) + if callable(persist_quality): + persist_quality(quality_record) + else: + BingxExecutionClient._persist_execution_quality(self, quality_record) + self.generate_order_filled( + strategy_id=order.strategy_id, + instrument_id=order.instrument_id, + client_order_id=order.client_order_id, + venue_order_id=venue_order_id, + venue_position_id=self._position_id_from_snapshot(row), + trade_id=TradeId(trade_key), + order_side=order.side, + order_type=order.order_type, + last_qty=last_qty, + last_px=last_px, + quote_currency=instrument.quote_currency, + commission=Money(Decimal(str(friction["commission_quote"] or "0")), instrument.quote_currency), + liquidity_side=LiquiditySide[friction["liquidity_side"]], + ts_event=self._clock.timestamp_ns(), + ) + status = str(row.get("status") or "").upper() + if status == "FILLED": + async with self._state_lock: + self._reported_terminal_orders.add(order.client_order_id.value) + signal_fill = getattr(self, "_signal_fill_update", None) + if signal_fill is not None: + signal_fill(order.client_order_id.value) + signal_fill(str(venue_order_id.value)) + + async def _submit_order(self, command: SubmitOrder) -> None: + order = command.order + self.generate_order_submitted( + strategy_id=order.strategy_id, + instrument_id=order.instrument_id, + client_order_id=order.client_order_id, + ts_event=self._clock.timestamp_ns(), + ) + try: + per_trade_lev = self._parse_leverage_from_tags(order) + self._log.info( + f"[BINGX] submit start symbol={self._venue_symbol(order.instrument_id)} " + f"side={order.side} qty={order.quantity} " + f"lev={per_trade_lev if per_trade_lev is not None else self._config.default_leverage} " + f"client_order_id={order.client_order_id.value}" + ) + await self._ensure_leverage(order.instrument_id, per_trade_leverage=per_trade_lev) + submit_payload = self._map_submit_order(order) + self._log.info( + f"[BINGX] submit payload symbol={submit_payload.get('symbol')} " + f"type={submit_payload.get('type')} side={submit_payload.get('side')} " + f"qty={submit_payload.get('quantity')}" + ) + ack_payload = await self._client.signed_post( + "/openApi/swap/v2/trade/order", + submit_payload, + ) + ack = BingxOrderAck.from_http(ack_payload) + self._log.info( + f"[BINGX] submit ack client_order_id={order.client_order_id.value} " + f"order_id={ack.order_id} status={ack.status}" + ) + ack_row_raw = unwrap_order_payload(ack_payload) + ack_row = dict(ack_row_raw) if isinstance(ack_row_raw, dict) else {} + if ack_row: + ack_row.setdefault("orderId", ack.order_id) + ack_row.setdefault("orderID", ack.order_id) + ack_row.setdefault("clientOrderId", ack.client_order_id or order.client_order_id.value) + ack_row.setdefault("clientOrderID", ack.client_order_id or order.client_order_id.value) + ack_row.setdefault("symbol", self._venue_symbol(order.instrument_id)) + self.generate_order_accepted( + strategy_id=order.strategy_id, + instrument_id=order.instrument_id, + client_order_id=order.client_order_id, + venue_order_id=VenueOrderId(ack.order_id or order.client_order_id.value), + ts_event=self._clock.timestamp_ns(), + ) + async with self._state_lock: + self._order_snapshots[order.client_order_id.value] = { + **ack_row, + "orderId": ack.order_id, + "orderID": ack.order_id, + "clientOrderId": order.client_order_id.value, + "clientOrderID": order.client_order_id.value, + "status": str(ack_row.get("status") or "NEW"), + "symbol": self._venue_symbol(order.instrument_id), + "raw": ack_payload, + } + if ack.order_id: + self._order_id_aliases[str(ack.order_id)] = order.client_order_id.value + terminal_status = str(ack_row.get("status") or "").upper() + executed_qty = Decimal(str(ack_row.get("executedQty") or ack_row.get("cumFilledQty") or "0")) + if terminal_status in {"FILLED", "PARTIALLY_FILLED", "CANCELED", "CANCELLED", "REJECTED", "FAILED", "EXPIRED"} or executed_qty > 0: + ack_progress = dict(ack_row) + ack_progress["clientOrderId"] = order.client_order_id.value + ack_progress["clientOrderID"] = order.client_order_id.value + ack_progress["orderId"] = ack.order_id + ack_progress["orderID"] = ack.order_id + if ack_progress.get("lastFilledQty") in (None, "", "0", "0.0", "0.0000") and executed_qty > 0: + ack_progress["lastFilledQty"] = ack_progress.get("executedQty") or ack_progress.get("cumFilledQty") + await self._emit_order_progress(ack_progress) + persist = getattr(self, "_persist_journal_snapshot", None) + if persist is not None: + await persist("ORDER_SUBMIT") + except Exception as exc: + due_post_only = "post only" in str(exc).lower() + self._log.exception( + f"[BINGX] submit failed client_order_id={order.client_order_id.value} " + f"symbol={self._venue_symbol(order.instrument_id)}", + exc, + ) + self.generate_order_rejected( + strategy_id=order.strategy_id, + instrument_id=order.instrument_id, + client_order_id=order.client_order_id, + reason=str(exc), + ts_event=self._clock.timestamp_ns(), + due_post_only=due_post_only, + ) + await self._persist_health_alarm("BINGX_ORDER_REJECTED") + + async def _submit_order_list(self, command: SubmitOrderList) -> None: + for order in command.order_list.orders: + await self._submit_order(SubmitOrder(order=order, command_id=UUID4(), ts_init=command.ts_init)) + + async def _modify_order(self, command: ModifyOrder) -> None: + order = self._cache.order(command.client_order_id) + if order is None: + self.generate_order_modify_rejected( + strategy_id=command.strategy_id, + instrument_id=command.instrument_id, + client_order_id=command.client_order_id, + venue_order_id=command.venue_order_id, + reason="ORDER_NOT_FOUND_IN_CACHE", + ts_event=self._clock.timestamp_ns(), + ) + return + try: + await self._client.signed_post( + "/openApi/swap/v1/trade/cancelReplace", + self._map_modify_order(order, command), + ) + except Exception as exc: + self.generate_order_modify_rejected( + strategy_id=command.strategy_id, + instrument_id=command.instrument_id, + client_order_id=command.client_order_id, + venue_order_id=command.venue_order_id, + reason=str(exc), + ts_event=self._clock.timestamp_ns(), + ) + await self._persist_health_alarm("BINGX_ORDER_MODIFY_FAIL") + + async def _cancel_order(self, command: CancelOrder) -> None: + try: + order = self._cache.order(command.client_order_id) + instrument_id = order.instrument_id if order is not None else command.instrument_id + snapshot = self._order_snapshots.get(command.client_order_id.value, {}) + params = {"symbol": self._venue_symbol(instrument_id)} + if snapshot.get("orderId"): + params["orderId"] = snapshot["orderId"] + else: + params["clientOrderId"] = command.client_order_id.value + await self._client.signed_delete("/openApi/swap/v2/trade/order", params) + except Exception as exc: + self.generate_order_cancel_rejected( + strategy_id=command.strategy_id, + instrument_id=command.instrument_id, + client_order_id=command.client_order_id, + venue_order_id=command.venue_order_id, + reason=str(exc), + ts_event=self._clock.timestamp_ns(), + ) + await self._persist_health_alarm("BINGX_ORDER_CANCEL_FAIL") + + async def _cancel_all_orders(self, command: CancelAllOrders) -> None: + try: + params = {} + if command.instrument_id is not None: + params["symbol"] = self._venue_symbol(command.instrument_id) + await self._client.signed_delete("/openApi/swap/v2/trade/allOpenOrders", params) + except Exception as exc: + self._log.warning(f"Cancel all orders failed: {exc}") + await self._persist_health_alarm("BINGX_ORDER_CANCEL_ALL_FAIL") + + async def _batch_cancel_orders(self, command: BatchCancelOrders) -> None: + orders = [cancel.client_order_id.value for cancel in command.cancels] + if not orders: + return + try: + await self._client.signed_delete( + "/openApi/swap/v2/trade/batchOrders", + { + "symbol": self._venue_symbol(command.cancels[0].instrument_id), + "clientOrderIdList": ",".join(orders), + }, + ) + except Exception as exc: + for cancel in command.cancels: + self.generate_order_cancel_rejected( + strategy_id=cancel.strategy_id, + instrument_id=cancel.instrument_id, + client_order_id=cancel.client_order_id, + venue_order_id=cancel.venue_order_id, + reason=str(exc), + ts_event=self._clock.timestamp_ns(), + ) + await self._persist_health_alarm("BINGX_BATCH_CANCEL_FAIL") + + async def generate_order_status_report( + self, + command: GenerateOrderStatusReport, + ) -> OrderStatusReport | None: + client_order_id = command.client_order_id.value if command.client_order_id else None + if not client_order_id: + return None + order = self._cache.order(ClientOrderId(client_order_id)) + async with self._state_lock: + raw_snapshot = self._order_snapshots.get(client_order_id) + snapshot = dict(raw_snapshot) if raw_snapshot is not None else None + if order is None or snapshot is None: + return None + return self._build_order_status_report(order, snapshot) + + async def generate_order_status_reports( + self, + command: GenerateOrderStatusReports, + ) -> list[OrderStatusReport]: + reports: list[OrderStatusReport] = [] + async with self._state_lock: + snapshots = list(self._order_snapshots.items()) + for client_order_id, snapshot in snapshots: + order = self._cache.order(ClientOrderId(client_order_id)) + if order is None: + continue + if command.instrument_id and order.instrument_id != command.instrument_id: + continue + reports.append(self._build_order_status_report(order, snapshot)) + return reports + + async def generate_mass_status( + self, + lookback_mins: int | None = None, + ) -> ExecutionMassStatus | None: + logger = getattr(self, "_log", None) + if logger is not None: + logger.info("Generating BingX ExecutionMassStatus...") + self.reconciliation_active = True + mass_status = ExecutionMassStatus( + client_id=self.id, + account_id=self.account_id, + venue=self.venue, + report_id=UUID4(), + ts_init=self._clock.timestamp_ns(), + ) + since = None + if lookback_mins is not None: + since = self._clock.utc_now() - timedelta(minutes=lookback_mins) + try: + await asyncio.gather( + self._refresh_account_state(), + self._refresh_positions(), + self._refresh_open_orders(), + ) + order_reports, fill_reports, position_reports = await asyncio.gather( + self.generate_order_status_reports( + GenerateOrderStatusReports( + instrument_id=None, + start=since, + end=None, + open_only=False, + command_id=UUID4(), + ts_init=self._clock.timestamp_ns(), + ), + ), + self.generate_fill_reports( + GenerateFillReports( + instrument_id=None, + venue_order_id=None, + start=since, + end=None, + command_id=UUID4(), + ts_init=self._clock.timestamp_ns(), + ), + ), + self.generate_position_status_reports( + GeneratePositionStatusReports( + instrument_id=None, + start=since, + end=None, + command_id=UUID4(), + ts_init=self._clock.timestamp_ns(), + ), + ), + ) + mass_status.add_order_reports(reports=order_reports) + mass_status.add_fill_reports(reports=fill_reports) + mass_status.add_position_reports(reports=position_reports) + self.reconciliation_active = False + return mass_status + except Exception as exc: + if logger is not None: + logger.exception(f"Cannot reconcile BingX execution state: {exc}") + self.reconciliation_active = False + return None + + async def generate_fill_reports( + self, + command: GenerateFillReports, + ) -> list[FillReport]: + reports: list[FillReport] = [] + async with self._state_lock: + snapshots = list(self._fill_snapshots) + for snapshot in snapshots: + row = snapshot.get("row", {}) + client_order_id = str(row.get("clientOrderId") or row.get("clientOrderID") or "") + if not client_order_id: + order_id = str(row.get("orderId") or row.get("orderID") or "") + if order_id: + async with self._state_lock: + client_order_id = self._order_id_aliases.get(order_id, "") + order = self._cache.order(ClientOrderId(client_order_id)) if client_order_id else None + if order is None: + continue + instrument = self._cache.instrument(order.instrument_id) + if instrument is None: + continue + friction = row.get("friction") + if not isinstance(friction, dict): + friction = self._build_friction_snapshot( + order, + row, + instrument, + feed_source=str(row.get("source") or "unknown"), + ) + reports.append( + FillReport( + account_id=self.account_id, + instrument_id=order.instrument_id, + venue_order_id=VenueOrderId(str(row.get("orderId") or order.client_order_id.value)), + trade_id=TradeId(str(snapshot.get("_trade_key") or row.get("orderId") or "")), + order_side=order.side, + last_qty=Quantity.from_str(str(row.get("lastFilledQty") or row.get("executedQty") or "0")), + last_px=Price.from_str(str(row.get("lastFillPrice") or row.get("avgPrice") or "0")), + commission=Money(Decimal(str(friction.get("commission_quote") or "0")), instrument.quote_currency), + liquidity_side=LiquiditySide[str(friction.get("liquidity_side") or "NO_LIQUIDITY_SIDE").upper()], + report_id=UUID4(), + ts_event=self._clock.timestamp_ns(), + ts_init=self._clock.timestamp_ns(), + client_order_id=order.client_order_id, + venue_position_id=self._position_id_from_snapshot(row), + ) + ) + return reports + + async def generate_position_status_reports( + self, + command: GeneratePositionStatusReports, + ) -> list[PositionStatusReport]: + reports: list[PositionStatusReport] = [] + async with self._state_lock: + rows = list(self._position_snapshots.values()) + for row in rows: + symbol = self._normalize_symbol(str(row.get("symbol") or "")) + if not symbol: + continue + instrument = None + for candidate in self._provider.list_all(): + if candidate.symbol.value == symbol or candidate.raw_symbol.value == str(row.get("symbol") or ""): + instrument = candidate + break + if instrument is None: + continue + qty = Decimal(str(row.get("positionAmt") or "0")) + if qty == 0: + continue + side = PositionSide.LONG if qty > 0 else PositionSide.SHORT + reports.append( + PositionStatusReport( + account_id=self.account_id, + instrument_id=instrument.id, + position_side=side, + quantity=Quantity.from_str(str(abs(qty))), + report_id=UUID4(), + ts_last=self._clock.timestamp_ns(), + ts_init=self._clock.timestamp_ns(), + venue_position_id=self._position_id_from_snapshot(row), + ) + ) + return reports + + def _build_order_status_report( + self, + order: Order, + snapshot: dict[str, Any], + ) -> OrderStatusReport: + instrument = self._cache.instrument(order.instrument_id) + if instrument is None: + raise BingxHttpError(f"Instrument {order.instrument_id} not available in cache") + quantity = order.quantity + filled_qty = Quantity.from_str(str(snapshot.get("executedQty") or snapshot.get("cumFilledQty") or "0")) + raw_status = str(snapshot.get("status") or "NEW") + status = self._map_order_status(raw_status) + if filled_qty.as_decimal() > 0 and filled_qty.as_decimal() < quantity.as_decimal(): + status = OrderStatus.PARTIALLY_FILLED + elif filled_qty.as_decimal() >= quantity.as_decimal() and status not in {OrderStatus.CANCELED, OrderStatus.REJECTED, OrderStatus.EXPIRED}: + status = OrderStatus.FILLED + return OrderStatusReport( + account_id=self.account_id, + instrument_id=order.instrument_id, + venue_order_id=VenueOrderId(str(snapshot.get("orderId") or order.client_order_id.value)), + order_side=order.side, + order_type=order.order_type, + time_in_force=order.time_in_force, + order_status=status, + quantity=quantity, + filled_qty=filled_qty, + report_id=UUID4(), + ts_accepted=self._clock.timestamp_ns(), + ts_last=self._clock.timestamp_ns(), + ts_init=self._clock.timestamp_ns(), + client_order_id=order.client_order_id, + price=order.price if order.has_price else None, + trigger_price=order.trigger_price if order.has_trigger_price else None, + avg_px=Decimal(str(snapshot.get("avgPrice") or snapshot.get("avgFilledPrice") or "0")), + post_only=order.is_post_only, + reduce_only=order.is_reduce_only, + venue_position_id=self._position_id_from_snapshot(snapshot), + ) + + @staticmethod + def _parse_leverage_from_tags(order: Order) -> float | None: + tags = getattr(order, "tags", None) or [] + for tag in tags: + if isinstance(tag, str) and (tag.startswith("lev:") or tag.startswith("cm:")): + try: + return float(tag.split(":", 1)[1]) + except (ValueError, IndexError): + pass + return None + + async def _ensure_leverage(self, instrument_id: InstrumentId, per_trade_leverage: float | None = None) -> None: + symbol = self._normalize_symbol(instrument_id.symbol.value) + desired = ( + map_internal_conviction_to_exchange_leverage( + per_trade_leverage, + exchange_max=self._config.exchange_leverage_cap, + ) + if per_trade_leverage is not None + else normalize_bingx_leverage_value( + self._config.default_leverage, + exchange_max=self._config.exchange_leverage_cap, + ) + ) + if self._config.leverage_by_symbol and symbol in self._config.leverage_by_symbol: + desired = normalize_bingx_leverage_value( + self._config.leverage_by_symbol[symbol], + exchange_max=self._config.exchange_leverage_cap, + ) + if not await _reserve_leverage_update(self, symbol, desired): + return + try: + await self._client.signed_post( + "/openApi/swap/v2/trade/leverage", + {"symbol": self._venue_symbol(instrument_id), "side": "BOTH", "leverage": desired}, + ) + except Exception: + await _release_leverage_update(self, symbol, desired) + raise + await _commit_leverage_update(self, symbol, desired) + account = self.get_account() + if isinstance(account, MarginAccount): + account.set_leverage(instrument_id, Decimal(desired)) + + def _map_submit_order(self, order: Order) -> dict[str, Any]: + close_position = bool(getattr(order, "close_position", False) or getattr(order, "is_close_position", False)) + if close_position and order.is_reduce_only: + raise BingxHttpError("closePosition cannot be combined with reduceOnly") + if close_position and order.order_type not in {OrderType.STOP_MARKET, OrderType.MARKET_IF_TOUCHED}: + raise BingxHttpError("closePosition requires STOP_MARKET or TAKE_PROFIT_MARKET") + if order.is_post_only and order.order_type != OrderType.LIMIT: + raise BingxHttpError("Post-only is only valid for limit orders") + params: dict[str, Any] = { + "symbol": self._venue_symbol(order.instrument_id), + "side": order_side_to_str(order.side), + "positionSide": "BOTH", + "type": self._map_order_type(order), + "quantity": self._format_quantity(order.quantity), + "clientOrderId": order.client_order_id.value, + "recvWindow": str(_positive_int_or_none(self._config.recv_window_ms) or 5_000), + } + if order.has_price: + params["price"] = self._format_price(order.price) + if order.has_trigger_price: + params["stopPrice"] = self._format_price(order.trigger_price) + params["activationPrice"] = self._format_price(order.trigger_price) + params["priceProtect"] = "true" + trigger_type = getattr(order, "trigger_type", None) + params["workingType"] = "CONTRACT_PRICE" if "CONTRACT" in str(trigger_type).upper() else "MARK_PRICE" + if order.is_post_only: + params["timeInForce"] = "PostOnly" + params["postOnly"] = "true" + elif order.order_type != OrderType.MARKET: + tif = self._map_time_in_force(order.time_in_force) + if tif: + params["timeInForce"] = tif + tags = tuple(str(tag).lower() for tag in (getattr(order, "tags", None) or ())) + exit_tagged = any(tag == "type:exit" or tag.startswith("type:exit,") for tag in tags) + if (order.is_reduce_only or exit_tagged or close_position) and self._config.use_reduce_only: + params["reduceOnly"] = "true" + if order.order_type == OrderType.TRAILING_STOP_MARKET: + trailing_value = Decimal(str(getattr(order, "trailing_offset", "1"))) + if trailing_value <= 0 or trailing_value > Decimal("10"): + raise BingxHttpError("Trailing stop callbackRate must be within (0, 10]") + trailing = _decimal_text(trailing_value) + params["callbackRate"] = trailing + params["priceRate"] = trailing + return params + + def _map_modify_order(self, order: Order, command: ModifyOrder) -> dict[str, Any]: + params = self._map_submit_order(order) + params["cancelClientOrderId"] = command.client_order_id.value + if command.quantity is not None: + params["quantity"] = self._format_quantity(command.quantity) + if command.price is not None: + params["price"] = self._format_price(command.price) + if command.trigger_price is not None: + params["stopPrice"] = self._format_price(command.trigger_price) + params["activationPrice"] = self._format_price(command.trigger_price) + return params + + @staticmethod + def _map_order_type(order: Order) -> str: + mapping = { + OrderType.MARKET: "MARKET", + OrderType.LIMIT: "LIMIT", + OrderType.STOP_LIMIT: "STOP", + OrderType.STOP_MARKET: "STOP_MARKET", + OrderType.MARKET_IF_TOUCHED: "TAKE_PROFIT_MARKET", + OrderType.LIMIT_IF_TOUCHED: "TAKE_PROFIT", + OrderType.TRAILING_STOP_MARKET: "TRAILING_STOP_MARKET", + } + if order.order_type not in mapping: + raise BingxHttpError(f"Unsupported BingX order type {order.order_type}") + return mapping[order.order_type] + + @staticmethod + def _map_order_status(status: str) -> OrderStatus: + normalized = status.upper() + if normalized in {"NEW", "PENDING", "CREATED"}: + return OrderStatus.ACCEPTED + if normalized in {"PARTIALLY_FILLED", "PARTIALLYFILLED"}: + return OrderStatus.PARTIALLY_FILLED + if normalized == "FILLED": + return OrderStatus.FILLED + if normalized in {"CANCELED", "CANCELLED"}: + return OrderStatus.CANCELED + if normalized in {"FAILED", "REJECTED", "TRADE_PREVENTION"}: + return OrderStatus.REJECTED + return OrderStatus.ACCEPTED + + @staticmethod + def _map_time_in_force(tif: TimeInForce) -> str | None: + mapping = { + TimeInForce.GTC: "GTC", + TimeInForce.IOC: "IOC", + TimeInForce.FOK: "FOK", + } + return mapping.get(tif) + + @staticmethod + def _normalize_symbol(symbol: str) -> str: + return symbol.replace("-", "").replace("-PERP", "").replace("PERP", "") + + def _venue_symbol(self, instrument_id: InstrumentId) -> str: + instrument = self._cache.instrument(instrument_id) + if instrument is not None: + return instrument.raw_symbol.value + symbol = instrument_id.symbol.value + if "-" in symbol: + return symbol + if symbol.endswith("USDT"): + return f"{symbol[:-4]}-USDT" + return symbol + + @staticmethod + def _format_quantity(quantity: Quantity) -> str: + return str(quantity) + + @staticmethod + def _format_price(price: Price) -> str: + return str(price) + + @staticmethod + def _position_id_from_snapshot(snapshot: dict[str, Any]) -> PositionId | None: + value = snapshot.get("positionID") + if value in (None, "", 0, "0"): + return None + return PositionId(str(value)) + + @staticmethod + def _client_order_id_from_snapshot(value: Any) -> ClientOrderId | None: + if value in (None, ""): + return None + return ClientOrderId(str(value)) + + def _linked_order_ids_from_snapshot(self, snapshot: dict[str, Any]) -> list[ClientOrderId] | None: + ids: list[ClientOrderId] = [] + for key in ("triggerOrderId", "mainOrderId"): + value = snapshot.get(key) + if value not in (None, "", 0, "0"): + ids.append(ClientOrderId(str(value))) + return ids or None + + async def _order_key_aliases(self, key: str) -> list[str]: + aliases = [key] + async with self._state_lock: + venue_key = next((venue for venue, client in self._order_id_aliases.items() if client == key), None) + client_key = self._order_id_aliases.get(key) + if client_key and client_key not in aliases: + aliases.append(client_key) + if venue_key and venue_key not in aliases: + aliases.append(venue_key) + return aliases + + def _signal_order_update(self, key: str) -> None: + if not key: + return + event = self._order_update_events.get(key) + if event is None: + event = asyncio.Event() + self._order_update_events[key] = event + event.set() + + def _signal_fill_update(self, key: str) -> None: + if not key: + return + event = self._fill_update_events.get(key) + if event is None: + event = asyncio.Event() + self._fill_update_events[key] = event + event.set() + + async def wait_for_order_update(self, key: str, *, timeout_s: float = 20.0) -> dict[str, Any] | None: + deadline = asyncio.get_running_loop().time() + timeout_s + alias_helper = getattr(self, "_order_key_aliases", None) + if callable(alias_helper): + keys = await alias_helper(key) + else: + keys = [key] + async with self._state_lock: + alias = getattr(self, "_order_id_aliases", {}).get(key) + if alias and alias not in keys: + keys.append(alias) + reverse = next((venue for venue, client in getattr(self, "_order_id_aliases", {}).items() if client == key), None) + if reverse and reverse not in keys: + keys.append(reverse) + while asyncio.get_running_loop().time() < deadline: + async with self._state_lock: + for candidate in keys: + row = self._order_snapshots.get(candidate) + if isinstance(row, dict): + return dict(row) + event = self._order_update_events.setdefault(key, asyncio.Event()) + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + break + try: + await asyncio.wait_for(event.wait(), timeout=remaining) + except TimeoutError: + break + finally: + event.clear() + async with self._state_lock: + for candidate in keys: + row = self._order_snapshots.get(candidate) + if isinstance(row, dict): + return dict(row) + return None + + async def wait_for_order_terminal(self, key: str, *, timeout_s: float = 20.0) -> dict[str, Any] | None: + deadline = asyncio.get_running_loop().time() + timeout_s + baseline: dict[str, Any] | None = None + alias_helper = getattr(self, "_order_key_aliases", None) + if callable(alias_helper): + keys = await alias_helper(key) + else: + keys = [key] + async with self._state_lock: + alias = getattr(self, "_order_id_aliases", {}).get(key) + if alias and alias not in keys: + keys.append(alias) + reverse = next((venue for venue, client in getattr(self, "_order_id_aliases", {}).items() if client == key), None) + if reverse and reverse not in keys: + keys.append(reverse) + async with self._state_lock: + for candidate in keys: + row = self._order_snapshots.get(candidate) + if isinstance(row, dict): + baseline = dict(row) + status = str(baseline.get("status") or baseline.get("X") or baseline.get("x") or "").upper() + if status in {"FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"}: + return baseline + event = self._order_update_events.setdefault(key, asyncio.Event()) + while asyncio.get_running_loop().time() < deadline: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + break + try: + await asyncio.wait_for(event.wait(), timeout=remaining) + except TimeoutError: + break + finally: + event.clear() + async with self._state_lock: + row = None + for candidate in keys: + row = self._order_snapshots.get(candidate) + if isinstance(row, dict): + break + if not isinstance(row, dict): + continue + snapshot = dict(row) + status = str(snapshot.get("status") or snapshot.get("X") or snapshot.get("x") or "").upper() + if status in {"FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"}: + return snapshot + baseline = snapshot + return baseline + + async def wait_for_order_fill(self, key: str, *, timeout_s: float = 20.0) -> dict[str, Any] | None: + deadline = asyncio.get_running_loop().time() + timeout_s + alias_helper = getattr(self, "_order_key_aliases", None) + if callable(alias_helper): + keys = await alias_helper(key) + else: + keys = [key] + async with self._state_lock: + alias = getattr(self, "_order_id_aliases", {}).get(key) + if alias and alias not in keys: + keys.append(alias) + reverse = next((venue for venue, client in getattr(self, "_order_id_aliases", {}).items() if client == key), None) + if reverse and reverse not in keys: + keys.append(reverse) + while asyncio.get_running_loop().time() < deadline: + async with self._state_lock: + for candidate in keys: + row = self._fill_snapshots_by_key.get(candidate) + if isinstance(row, dict): + return dict(row) + event = self._fill_update_events.setdefault(key, asyncio.Event()) + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + break + try: + await asyncio.wait_for(event.wait(), timeout=remaining) + except TimeoutError: + break + finally: + event.clear() + async with self._state_lock: + for candidate in keys: + row = self._fill_snapshots_by_key.get(candidate) + if isinstance(row, dict): + return dict(row) + return None + + def _build_friction_snapshot( + self, + order: Order, + row: dict[str, Any], + instrument: Any, + *, + last_qty: Quantity | None = None, + last_px: Price | None = None, + feed_source: str = "unknown", + ) -> dict[str, Any]: + return estimate_friction( + order, + row, + last_qty=last_qty, + last_px=last_px, + quote_currency=instrument.quote_currency.code, + base_currency=instrument.base_currency.code, + maker_fee=instrument.maker_fee, + taker_fee=instrument.taker_fee, + feed_source=feed_source, + ) + + @staticmethod + def _build_execution_quality_record( + *, + order: Order, + venue_order_id: VenueOrderId, + row: dict[str, Any], + friction: dict[str, Any], + source: str, + ) -> dict[str, Any]: + return build_execution_quality_record( + record_kind="fill", + trade_id=str(row.get("tradeId") or row.get("trade_id") or venue_order_id.value or order.client_order_id.value), + strategy=str(order.strategy_id), + asset=str(order.instrument_id.symbol.value), + side=getattr(order.side, "name", str(order.side)), + source=str(source or friction.get("feed_source") or "unknown"), + feed_source=str(source or friction.get("feed_source") or "unknown"), + client_order_id=order.client_order_id.value, + venue_order_id=venue_order_id.value, + order_type=getattr(order.order_type, "name", str(order.order_type)), + reference_source=str(friction.get("reference_source") or "unavailable"), + liquidity_side=str(friction.get("liquidity_side") or "NO_LIQUIDITY_SIDE"), + fill_quality_score=friction.get("fill_quality_score"), + fill_quality_class=str(friction.get("fill_quality_class") or ""), + commission_quote=friction.get("commission_quote"), + estimated_fee_quote=friction.get("estimated_fee_quote"), + fee_rate=friction.get("fee_rate"), + fee_bps=friction.get("fee_bps"), + reference_px=friction.get("reference_px"), + slippage_quote=friction.get("slippage_quote"), + slippage_bps=friction.get("slippage_bps"), + gross_friction_quote=friction.get("gross_friction_quote"), + net_friction_quote=friction.get("net_friction_quote"), + notional_quote=friction.get("notional_quote"), + last_qty=friction.get("last_qty"), + last_px=friction.get("last_px"), + ts=_ch_ts_us(), + extra={"row": row, "friction": friction}, + ) + + def _persist_execution_quality(self, record: dict[str, Any]) -> None: + ch_put("trade_execution_quality", record) diff --git a/prod/bingx/factories.py b/prod/bingx/factories.py new file mode 100644 index 0000000..89d4cf9 --- /dev/null +++ b/prod/bingx/factories.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import asyncio + +from nautilus_trader.cache.cache import Cache +from nautilus_trader.common.component import LiveClock +from nautilus_trader.common.component import MessageBus +from nautilus_trader.live.factories import LiveExecClientFactory + +from .config import BingxExecClientConfig +from .http import BingxHttpClient +from .instrument_provider import BingxInstrumentProvider +from .execution import BingxExecutionClient + + +class BingxLiveExecClientFactory(LiveExecClientFactory): + @staticmethod + def create( # type: ignore[override] + loop: asyncio.AbstractEventLoop, + name: str, + config: BingxExecClientConfig, + msgbus: MessageBus, + cache: Cache, + clock: LiveClock, + ) -> BingxExecutionClient: + config.validate_mainnet_opt_in() + client = BingxHttpClient(config) + provider = BingxInstrumentProvider(client=client, config=config.instrument_provider) + return BingxExecutionClient( + loop=loop, + client=client, + msgbus=msgbus, + cache=cache, + clock=clock, + instrument_provider=provider, + config=config, + name=name, + ) diff --git a/prod/bingx/friction.py b/prod/bingx/friction.py new file mode 100644 index 0000000..af75596 --- /dev/null +++ b/prod/bingx/friction.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +from nautilus_trader.model.enums import LiquiditySide +from nautilus_trader.model.enums import OrderType +from nautilus_trader.model.enums import TimeInForce +from nautilus_trader.model.objects import Price +from nautilus_trader.model.objects import Quantity +from nautilus_trader.model.orders import Order + + +def _decimal(value: Any) -> Decimal | None: + if value in (None, "", "null"): + return None + try: + return Decimal(str(value)) + except Exception: + return None + + +def _decimal_text(value: Decimal | None) -> str | None: + if value is None: + return None + text = format(value.normalize(), "f") + if "." in text: + text = text.rstrip("0").rstrip(".") + return text or "0" + + +def _fill_quality_score( + *, + slippage_bps: Decimal | None, + fee_bps: Decimal | None, + liquidity_side: LiquiditySide, +) -> tuple[Decimal, str]: + """Return a compact fill-quality score and label. + + The score is intentionally conservative: 100 is ideal, and lower scores + reflect adverse slippage plus fee drag. Maker fills are allowed a small + bonus because they are typically less toxic than taker fills. + """ + score = Decimal("100") + if slippage_bps is not None: + score -= abs(slippage_bps) + if fee_bps is not None: + score -= abs(fee_bps) + if liquidity_side == LiquiditySide.MAKER: + score += Decimal("1") + score = max(Decimal("0"), min(Decimal("100"), score)) + if score >= Decimal("99"): + label = "excellent" + elif score >= Decimal("95"): + label = "good" + elif score >= Decimal("85"): + label = "fair" + else: + label = "poor" + return score, label + + +def _price_from_order(order: Order) -> Decimal | None: + if getattr(order, "has_price", False): + price = getattr(order, "price", None) + if price is not None: + try: + return price.as_decimal() + except Exception: + return _decimal(price) + return None + + +def infer_liquidity_side(order: Order, row: dict[str, Any] | None = None) -> LiquiditySide: + row = row or {} + explicit = row.get("liquiditySide") or row.get("liquidity_side") + if isinstance(explicit, str): + upper = explicit.upper() + if "MAKER" in upper: + return LiquiditySide.MAKER + if "TAKER" in upper: + return LiquiditySide.TAKER + for key in ("isMaker", "maker", "m"): + value = row.get(key) + if isinstance(value, bool): + return LiquiditySide.MAKER if value else LiquiditySide.TAKER + if isinstance(value, str): + lower = value.strip().lower() + if lower in {"true", "1", "yes", "maker"}: + return LiquiditySide.MAKER + if lower in {"false", "0", "no", "taker"}: + return LiquiditySide.TAKER + if bool(getattr(order, "is_post_only", False)): + return LiquiditySide.MAKER + tif = getattr(order, "time_in_force", None) + if tif in {TimeInForce.IOC, TimeInForce.FOK}: + return LiquiditySide.TAKER + if order.order_type in { + OrderType.MARKET, + OrderType.STOP_MARKET, + OrderType.MARKET_IF_TOUCHED, + OrderType.TRAILING_STOP_MARKET, + }: + return LiquiditySide.TAKER + if order.order_type in { + OrderType.LIMIT, + OrderType.STOP_LIMIT, + OrderType.LIMIT_IF_TOUCHED, + OrderType.TRAILING_STOP_LIMIT, + }: + return LiquiditySide.MAKER + return LiquiditySide.NO_LIQUIDITY_SIDE + + +def reference_price(order: Order, row: dict[str, Any] | None = None) -> tuple[Decimal | None, str]: + row = row or {} + for key in ("referencePrice", "referencePx", "expectedPrice", "expectedPx", "bestPrice"): + value = _decimal(row.get(key)) + if value is not None and value > 0: + return value, key + if getattr(order, "has_price", False): + price = _price_from_order(order) + if price is not None and price > 0: + return price, "order_price" + if getattr(order, "has_trigger_price", False) and order.order_type in { + OrderType.STOP_MARKET, + OrderType.MARKET_IF_TOUCHED, + OrderType.TRAILING_STOP_MARKET, + }: + trigger = getattr(order, "trigger_price", None) + if trigger is not None: + try: + value = trigger.as_decimal() + except Exception: + value = _decimal(trigger) + if value is not None and value > 0: + return value, "trigger_price" + return None, "unavailable" + + +def _commission_quote( + *, + commission_amount: Decimal, + commission_asset: str, + quote_currency: str, + base_currency: str, + last_px: Decimal | None, + estimate_quote: Decimal, +) -> tuple[Decimal, str]: + if commission_amount == 0: + return estimate_quote, "estimated" + if commission_asset == quote_currency: + return commission_amount, "quote" + if commission_asset == base_currency and last_px is not None: + return commission_amount * last_px, "base_converted" + return estimate_quote, "estimated" + + +def estimate_friction( + order: Order, + row: dict[str, Any], + *, + last_qty: Quantity | None = None, + last_px: Price | None = None, + quote_currency: str, + base_currency: str, + maker_fee: Decimal, + taker_fee: Decimal, + feed_source: str = "unknown", +) -> dict[str, Any]: + qty = last_qty.as_decimal() if last_qty is not None else _decimal(row.get("lastFilledQty") or row.get("executedQty")) or Decimal("0") + px = last_px.as_decimal() if last_px is not None else _decimal(row.get("lastFillPrice") or row.get("avgPrice") or row.get("price")) + notional = qty * px if px is not None else Decimal("0") + liquidity_side = infer_liquidity_side(order, row) + fee_rate = maker_fee if liquidity_side == LiquiditySide.MAKER else taker_fee if liquidity_side == LiquiditySide.TAKER else taker_fee + estimated_fee_quote = notional * fee_rate if notional > 0 else Decimal("0") + commission_amount = _decimal(row.get("commission")) or Decimal("0") + commission_asset = str(row.get("commissionAsset") or quote_currency) + actual_fee_quote, commission_source = _commission_quote( + commission_amount=commission_amount, + commission_asset=commission_asset, + quote_currency=quote_currency, + base_currency=base_currency, + last_px=px, + estimate_quote=estimated_fee_quote, + ) + reference_px, reference_source = reference_price(order, row) + slippage_quote = None + slippage_bps = None + if reference_px is not None and px is not None and qty > 0: + side = getattr(order.side, "name", str(order.side)).upper() + delta = px - reference_px if side == "BUY" else reference_px - px + slippage_quote = delta * qty + if reference_px > 0: + slippage_bps = (delta / reference_px) * Decimal("10000") + net_friction_quote = actual_fee_quote + (slippage_quote or Decimal("0")) + gross_friction_quote = abs(actual_fee_quote) + abs(slippage_quote or Decimal("0")) + fee_bps = None + if notional > 0: + fee_bps = (actual_fee_quote / notional) * Decimal("10000") + fill_quality_score, fill_quality_class = _fill_quality_score( + slippage_bps=slippage_bps, + fee_bps=fee_bps, + liquidity_side=liquidity_side, + ) + return { + "liquidity_side": getattr(liquidity_side, "name", str(liquidity_side)), + "feed_source": str(feed_source or "unknown"), + "commission_asset": commission_asset, + "commission_source": commission_source, + "commission_quote": _decimal_text(actual_fee_quote), + "estimated_fee_quote": _decimal_text(estimated_fee_quote), + "fee_rate": _decimal_text(fee_rate), + "fee_bps": _decimal_text(fee_bps), + "reference_px": _decimal_text(reference_px), + "reference_source": reference_source, + "slippage_quote": _decimal_text(slippage_quote), + "slippage_bps": _decimal_text(slippage_bps), + "net_friction_quote": _decimal_text(net_friction_quote), + "gross_friction_quote": _decimal_text(gross_friction_quote), + "fill_quality_score": _decimal_text(fill_quality_score), + "fill_quality_class": fill_quality_class, + "notional_quote": _decimal_text(notional), + "last_qty": _decimal_text(qty), + "last_px": _decimal_text(px), + } diff --git a/prod/bingx/health.py b/prod/bingx/health.py new file mode 100644 index 0000000..7542a1c --- /dev/null +++ b/prod/bingx/health.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from decimal import Decimal +from typing import Any + +from .journal import load_latest_record + + +@dataclass(frozen=True) +class BingxHealthSummary: + score: float + status: str + event_type: str + reason: str + age_s: float + transport: float + freshness: float + coherence: float + rate_limit: float + circuit: float + ws_healthy: bool + ledger_authority: str + + +def load_latest_health_summary( + *, + strategy: str = "bingx", + account_id: str | None = None, + now: datetime | None = None, +) -> BingxHealthSummary | None: + record = load_latest_record(strategy, account_id=account_id) + if record is None: + return None + return score_health_record(record, now=now) + + +def score_health_record( + record: dict[str, Any], + *, + now: datetime | None = None, +) -> BingxHealthSummary: + payload = record.get("payload") if isinstance(record, dict) else {} + if not isinstance(payload, dict): + payload = {} + notes = record.get("notes") if isinstance(record, dict) else {} + if not isinstance(notes, dict): + notes = {} + alarm = notes.get("alarm") if isinstance(notes, dict) else {} + if not isinstance(alarm, dict): + alarm = {} + + event_type = str(record.get("event_type") or alarm.get("reason") or "") + reason = str(alarm.get("reason") or event_type or "") + ledger_authority = str(payload.get("ledger_authority") or notes.get("ledger_authority") or "exchange") + ws_healthy = bool(payload.get("ws_healthy", True)) + + parsed_ts = _parse_ts(record.get("ts")) + now_dt = now or datetime.now(timezone.utc) + age_s = max(0.0, (now_dt - parsed_ts).total_seconds()) if parsed_ts is not None else 0.0 + + freshness = 1.0 + if age_s > 60.0: + freshness = 0.0 + elif age_s > 20.0: + freshness = 0.5 + + circuit = 1.0 + cb = payload.get("circuit_breaker") if isinstance(payload, dict) else {} + if isinstance(cb, dict): + open_until_ns = int(cb.get("open_until_ns") or 0) + failure_count = int(cb.get("failure_count") or 0) + last_delay_ms = int(cb.get("last_delay_ms") or 0) + if open_until_ns > 0: + circuit = 0.0 + elif failure_count > 0 or last_delay_ms > 0: + circuit = 0.5 + + rate_limit = 1.0 + rl = payload.get("rate_limits") if isinstance(payload, dict) else {} + if isinstance(rl, dict): + remaining = rl.get("rest_remaining") + reset_ms = int(rl.get("rest_reset_ms") or 0) + if remaining is not None: + remaining_int = int(remaining) + if remaining_int <= 0: + rate_limit = 0.0 + elif remaining_int <= 5: + rate_limit = 0.25 + elif remaining_int <= 20: + rate_limit = 0.6 + if reset_ms > 0 and rate_limit > 0.0: + rate_limit = min(rate_limit, 0.8) + + transport = 1.0 if ws_healthy else 0.3 + if event_type in {"BINGX_WS_DOWN", "BINGX_REST_FAIL"}: + transport = 0.0 + + coherence = 1.0 if ledger_authority == "exchange" else 0.2 + if event_type == "BINGX_DRIFT": + coherence = 0.0 + elif event_type in {"BINGX_ORDER_REJECTED", "BINGX_ORDER_CANCEL_REJECTED"}: + coherence = min(coherence, 0.8) + + if alarm: + severity = float(alarm.get("severity") or 0.0) + category = str(alarm.get("category") or "").lower() + if severity >= 0.85: + transport = min(transport, 0.0 if category in {"transport", "auth", "ws"} else transport) + coherence = min(coherence, 0.0 if category in {"coherence", "drift"} else coherence) + elif severity >= 0.5: + transport = min(transport, 0.5) + coherence = min(coherence, 0.5) + + score = min(freshness, circuit, rate_limit, transport, coherence) + if score >= 0.85: + status = "GREEN" + elif score >= 0.6: + status = "DEGRADED" + elif score >= 0.3: + status = "CRITICAL" + else: + status = "DEAD" + + return BingxHealthSummary( + score=round(score, 3), + status=status, + event_type=event_type, + reason=reason, + age_s=round(age_s, 1), + transport=round(transport, 3), + freshness=round(freshness, 3), + coherence=round(coherence, 3), + rate_limit=round(rate_limit, 3), + circuit=round(circuit, 3), + ws_healthy=ws_healthy, + ledger_authority=ledger_authority, + ) + + +def _parse_ts(raw: Any) -> datetime | None: + if raw is None: + return None + if isinstance(raw, datetime): + return raw.replace(tzinfo=timezone.utc) if raw.tzinfo is None else raw.astimezone(timezone.utc) + try: + parsed = datetime.fromisoformat(str(raw).replace("Z", "+00:00")) + return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed.astimezone(timezone.utc) + except Exception: + return None diff --git a/prod/bingx/instrument_provider.py b/prod/bingx/instrument_provider.py new file mode 100644 index 0000000..b06537d --- /dev/null +++ b/prod/bingx/instrument_provider.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from decimal import Decimal + +from nautilus_trader.common.providers import InstrumentProvider +from nautilus_trader.model.identifiers import InstrumentId +from nautilus_trader.model.identifiers import Symbol +from nautilus_trader.model.instruments import CryptoPerpetual +from nautilus_trader.model.objects import Currency +from nautilus_trader.model.objects import Money +from nautilus_trader.model.objects import Price +from nautilus_trader.model.objects import Quantity + +from .config import BingxInstrumentProviderConfig +from .enums import BINGX_VENUE +from .http import BingxHttpClient +from .schemas import BingxContract + + +class BingxInstrumentProvider(InstrumentProvider): + def __init__( + self, + client: BingxHttpClient, + config: BingxInstrumentProviderConfig | None = None, + ) -> None: + super().__init__(config=config) + self._client = client + self._cfg = config or BingxInstrumentProviderConfig() + + async def load_all_async(self, filters: dict | None = None) -> None: + raw_contracts = await self._client.public_get("/openApi/swap/v2/quote/contracts") + contracts = raw_contracts if isinstance(raw_contracts, list) else raw_contracts.get("contracts", []) + requested = set(self._cfg.symbol_filters or ()) + for row in contracts: + contract = BingxContract.from_http(row) + if requested and contract.symbol not in requested and contract.venue_symbol not in requested: + continue + self.add(self._parse_contract(contract)) + + async def load_ids_async( + self, + instrument_ids: list[InstrumentId], + filters: dict | None = None, + ) -> None: + self._cfg = BingxInstrumentProviderConfig( + load_all=True, + symbol_filters=tuple(i.symbol.value for i in instrument_ids), + default_maker_fee=self._cfg.default_maker_fee, + default_taker_fee=self._cfg.default_taker_fee, + ) + await self.load_all_async(filters) + + def _parse_contract(self, contract: BingxContract) -> CryptoPerpetual: + base_currency = Currency.from_str(contract.base_asset) + quote_currency = Currency.from_str(contract.quote_asset) + symbol = Symbol(contract.symbol) + return CryptoPerpetual( + instrument_id=InstrumentId(symbol=symbol, venue=BINGX_VENUE), + raw_symbol=Symbol(contract.venue_symbol), + base_currency=base_currency, + quote_currency=quote_currency, + settlement_currency=quote_currency, + is_inverse=False, + price_precision=contract.price_precision, + price_increment=Price.from_str(str(contract.tick_size)), + size_precision=contract.quantity_precision, + size_increment=Quantity.from_str(str(contract.step_size)), + max_quantity=Quantity.from_str("1000000000"), + min_quantity=Quantity.from_str(str(contract.min_quantity)), + max_notional=None, + min_notional=Money(contract.min_notional, quote_currency), + max_price=Price.from_str("1000000000"), + min_price=Price.from_str(str(contract.tick_size)), + margin_init=Decimal("0.11111111") if contract.max_leverage >= 9 else Decimal("1") / Decimal(contract.max_leverage or 1), + margin_maint=Decimal("0.05"), + maker_fee=contract.maker_fee or self._cfg.default_maker_fee, + taker_fee=contract.taker_fee or self._cfg.default_taker_fee, + ts_event=0, + ts_init=0, + ) diff --git a/prod/bingx/journal.py b/prod/bingx/journal.py new file mode 100644 index 0000000..32686ce --- /dev/null +++ b/prod/bingx/journal.py @@ -0,0 +1,357 @@ +from __future__ import annotations + +import json +import logging +import urllib.parse +import urllib.request +from dataclasses import dataclass +from datetime import datetime, timezone +from hashlib import sha256 +from typing import Any + +from prod.ch_writer import ch_put +from prod.ch_writer import ch_put_green +from prod.ch_writer import ch_put_prodgreen +from prod.ch_writer import ch_put_pink + +# ─── Account event rate control (§10.2) ────────────────────────────────────── +import os +import time as _time + +_ACCOUNT_EVENT_RATE_CAP = int(os.environ.get("PINK_ACCOUNT_EVENT_RATE_CAP", "5")) + + +class _AccountEventRateLimiter: + """Token-bucket rate limiter for account events (PINK data volume control).""" + def __init__(self, max_per_sec: int = 5): + self._max = max(max_per_sec, 1) + self._tokens = float(self._max) + self._last = _time.monotonic() + + def allow(self) -> bool: + now = _time.monotonic() + self._tokens = min(self._max, self._tokens + (now - self._last) * self._max) + self._last = now + if self._tokens >= 1.0: + self._tokens -= 1.0 + return True + return False +from prod.ch_writer import ts_us +from prod.bingx.leverage import LEVERAGE_MAPPING_RULE + + +CH_URL = "http://localhost:8123" +CH_USER = "dolphin" +CH_PASS = "dolphin_ch_2026" +CH_DB = "dolphin" +JOURNAL_EVENT_TYPE = "BINGX_SNAPSHOT" +LOGGER = logging.getLogger(__name__) + + +def _json_safe(value: Any) -> Any: + if isinstance(value, dict): + return {str(key): _json_safe(val) for key, val in value.items()} + if isinstance(value, list): + return [_json_safe(item) for item in value] + if isinstance(value, tuple): + return [_json_safe(item) for item in value] + if hasattr(value, "isoformat"): + try: + return value.isoformat() + except Exception: + pass + if hasattr(value, "as_decimal"): + try: + return str(value.as_decimal()) + except Exception: + pass + if hasattr(value, "__dict__"): + return _json_safe(dict(vars(value))) + return value + + +def _capital_from_balances(balances: Any) -> float: + if not isinstance(balances, list): + LOGGER.warning("BingX journal account snapshot balances payload is not a list") + return 0.0 + found = 0.0 + for row in balances: + if not isinstance(row, dict): + LOGGER.warning("BingX journal account snapshot skipped malformed balance row: %r", row) + 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 capital == capital: + found = capital + return capital + if capital > 0 and capital == capital: + found = capital + return capital + if balances: + LOGGER.error("BingX journal account snapshot contained no usable balance rows") + return found + + +def _open_notional_from_positions(positions: Any) -> float: + if not isinstance(positions, dict): + LOGGER.warning("BingX journal positions payload is not a dict") + return 0.0 + total = 0.0 + for row in positions.values(): + if not isinstance(row, dict): + LOGGER.warning("BingX journal skipped malformed position row: %r", row) + 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: + LOGGER.warning("BingX journal skipped unreadable position row: %r", row) + continue + return total + + +def _filled_order_count_from_fills(fills: Any) -> int: + if not isinstance(fills, list): + return 0 + seen: set[str] = set() + count = 0 + for snapshot in fills: + if not isinstance(snapshot, dict): + continue + row = snapshot.get("row") if isinstance(snapshot.get("row"), dict) else snapshot + if not isinstance(row, dict): + continue + status = str(row.get("status") or "").upper() + if status and status not in {"FILLED", "CLOSED"}: + continue + trade_key = str(snapshot.get("_trade_key") or "").strip() + if trade_key: + base_key = trade_key.split(":", 1)[0] + else: + base_key = str( + row.get("orderId") + or row.get("orderID") + or row.get("clientOrderId") + or row.get("clientOrderID") + or "" + ).strip() + if not base_key or base_key in seen: + continue + seen.add(base_key) + count += 1 + return count + + +_STRATEGY_DB_MAP: dict[str, str] = { + "blue": "dolphin", + "green": "dolphin_green", + "prodgreen": "dolphin_prodgreen", + "pink": "dolphin_pink", +} + +_STRATEGY_SINK_MAP: dict[str, Any] = { + "blue": ch_put, + "green": ch_put_green, + "prodgreen": ch_put_prodgreen, + "pink": ch_put_pink, +} + +_STRATEGY_SINK_NAME_MAP: dict[str, str] = { + "blue": "ch_put", + "green": "ch_put_green", + "prodgreen": "ch_put_prodgreen", + "pink": "ch_put_pink", +} + + +def _db_for_strategy(strategy: str) -> str: + name = str(strategy or "").lower() + return _STRATEGY_DB_MAP.get(name, "dolphin_prodgreen" if name.startswith("prod") else CH_DB) + + +def _sink_for_strategy(strategy: str): + strategy_lower = str(strategy or "").lower() + sink = _STRATEGY_SINK_MAP.get(strategy_lower) + if callable(sink): + return sink + sink_name = _STRATEGY_SINK_NAME_MAP.get(strategy_lower) + if sink_name: + sink = globals().get(sink_name) + if callable(sink): + return sink + return ch_put_prodgreen if strategy_lower.startswith("prod") else ch_put + + +@dataclass(frozen=True) +class BingxJournalSnapshot: + ts: int + strategy: str + account_id: str + ledger_authority: str + payload: dict[str, Any] + fingerprint: str + reason: str = "" + + +def build_snapshot( + *, + strategy: str, + account_id: str, + ledger_authority: str, + payload: dict[str, Any], + reason: str = "", +) -> BingxJournalSnapshot: + payload_json = json.dumps(_json_safe(payload), sort_keys=True, separators=(",", ":")) + fingerprint = sha256(payload_json.encode("utf-8")).hexdigest() + return BingxJournalSnapshot( + ts=ts_us(), + strategy=strategy, + account_id=account_id, + ledger_authority=ledger_authority, + payload=payload, + fingerprint=fingerprint, + reason=reason, + ) + + +def write_snapshot(snapshot: BingxJournalSnapshot) -> None: + account = snapshot.payload.get("account", {}) + balances = account.get("balances", []) + capital = _capital_from_balances(balances) + peak_capital = capital + drawdown_pct = 0.0 + if capital <= 0.0: + LOGGER.error( + "BingX journal snapshot has no usable capital for strategy=%s account_id=%s reason=%s", + snapshot.strategy, + snapshot.account_id, + snapshot.reason or JOURNAL_EVENT_TYPE, + ) + positions = snapshot.payload.get("positions", {}) + open_positions = len(positions) if isinstance(positions, dict) else 0 + current_open_notional = _open_notional_from_positions(positions) + current_account_leverage = current_open_notional / capital if capital > 0 else 0.0 + configured = snapshot.payload.get("configured_leverage", {}) + exchange_leverage = 0 + if isinstance(configured, dict) and configured: + try: + exchange_leverage = max(int(v) for v in configured.values() if int(v) > 0) + except Exception: + exchange_leverage = 0 + fills = snapshot.payload.get("fills", []) + fills_today = len(fills) if isinstance(fills, list) else 0 + trades_today = _filled_order_count_from_fills(fills) + sink = _sink_for_strategy(snapshot.strategy) + sink( + "account_events", + { + "ts": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f"), + "event_type": snapshot.reason or JOURNAL_EVENT_TYPE, + "strategy": snapshot.strategy, + "posture": "N/A", + "capital": capital, + "peak_capital": peak_capital, + "drawdown_pct": drawdown_pct, + "pnl_today": 0.0, + "trades_today": trades_today, + "open_positions": open_positions, + "boost": 1.0, + "beta": 1.0, + "current_open_notional": current_open_notional, + "current_account_leverage": current_account_leverage, + "exchange_leverage": exchange_leverage, + "exchange_leverage_mode": "mapped_conservative_integer", + "leverage_mapping_rule": LEVERAGE_MAPPING_RULE, + "notes": json.dumps( + { + "account_id": snapshot.account_id, + "ledger_authority": snapshot.ledger_authority, + "fingerprint": snapshot.fingerprint, + "fills_today": fills_today, + "filled_orders_today": trades_today, + "payload": _json_safe(snapshot.payload), + }, + sort_keys=True, + separators=(",", ":"), + ), + }, + ) + + +def load_latest_snapshot(strategy: str, account_id: str | None = None) -> dict[str, Any] | None: + record = load_latest_record(strategy, account_id=account_id) + if record is None: + return None + return record.get("payload") + + +def load_latest_record(strategy: str, account_id: str | None = None) -> dict[str, Any] | None: + clauses = [f"strategy = {json.dumps(strategy)}"] + if account_id: + clauses.append(f"JSONExtractString(notes, 'account_id') = {json.dumps(account_id)}") + where = " AND ".join(clauses) + sql = ( + "SELECT ts, event_type, strategy, notes " + f"FROM account_events WHERE {where} ORDER BY ts DESC LIMIT 1 FORMAT JSONEachRow" + ) + url = f"{CH_URL}/?database={_db_for_strategy(strategy)}&query={urllib.parse.quote(sql)}" + req = urllib.request.Request(url) + req.add_header("X-ClickHouse-User", CH_USER) + req.add_header("X-ClickHouse-Key", CH_PASS) + try: + with urllib.request.urlopen(req, timeout=5) as resp: + body = resp.read().decode("utf-8").strip() + if not body: + return None + row = json.loads(body) + notes = row.get("notes") + if not notes: + return None + parsed = json.loads(notes) + return { + "ts": row.get("ts"), + "event_type": row.get("event_type"), + "strategy": row.get("strategy"), + "notes": parsed, + "payload": parsed.get("payload"), + } + except Exception: + return None + + +def resolve_account_event_rate_cap() -> int: + """Return the configured account event rate cap (rows/sec) per §10.2.""" + raw = os.environ.get("PINK_ACCOUNT_EVENT_RATE_CAP", "") + try: + val = int(raw) + return max(val, 1) + except (TypeError, ValueError): + return _ACCOUNT_EVENT_RATE_CAP diff --git a/prod/bingx/leverage.py b/prod/bingx/leverage.py new file mode 100644 index 0000000..3d39456 --- /dev/null +++ b/prod/bingx/leverage.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from decimal import Decimal +from decimal import ROUND_HALF_EVEN +from typing import Any + + +CONVICTION_MIN = 0.5 +CONVICTION_MAX = 9.0 +EXCHANGE_LEV_MIN = 1 +EXCHANGE_LEV_MAX = 3 +LEVERAGE_MAPPING_RULE = "round_half_even_linear_0.5_to_9.0_to_1_to_exchange_cap" + + +def _clamp_exchange_bounds(exchange_min: Any, exchange_max: Any) -> tuple[int, int]: + lower = int(Decimal(str(exchange_min))) + upper = int(Decimal(str(exchange_max))) + if lower < 1: + lower = 1 + if upper < lower: + upper = lower + return lower, upper + + +def normalize_bingx_leverage_value( + leverage: Any, + *, + exchange_min: Any = EXCHANGE_LEV_MIN, + exchange_max: Any = EXCHANGE_LEV_MAX, +) -> int: + """ + BingX exchange leverage is integer-only and conservatively capped. + """ + lower, upper = _clamp_exchange_bounds(exchange_min, exchange_max) + desired = int( + Decimal(str(leverage)).quantize(Decimal("1"), rounding=ROUND_HALF_EVEN) + ) + if desired < lower: + return lower + if desired > upper: + return upper + return desired + + +def map_internal_conviction_to_exchange_leverage_target( + leverage: Any, + *, + exchange_min: Any = EXCHANGE_LEV_MIN, + exchange_max: Any = EXCHANGE_LEV_MAX, +) -> float: + """ + Map engine conviction/sizing into BingX exchange leverage. + + The engine retains the fractional conviction signal internally. The exchange + receives a leverage target derived from that signal. + """ + lower, upper = _clamp_exchange_bounds(exchange_min, exchange_max) + internal = float(Decimal(str(leverage))) + internal = max(CONVICTION_MIN, min(CONVICTION_MAX, internal)) + return float(lower + ( + (internal - CONVICTION_MIN) / (CONVICTION_MAX - CONVICTION_MIN) + ) * (upper - lower)) + + +def map_internal_conviction_to_exchange_leverage( + leverage: Any, + *, + exchange_min: Any = EXCHANGE_LEV_MIN, + exchange_max: Any = EXCHANGE_LEV_MAX, +) -> int: + """ + Backwards-compatible integer exchange leverage mapper. + """ + target = map_internal_conviction_to_exchange_leverage_target( + leverage, + exchange_min=exchange_min, + exchange_max=exchange_max, + ) + return normalize_bingx_leverage_value( + target, + exchange_min=exchange_min, + exchange_max=exchange_max, + ) diff --git a/prod/bingx/market_stream.py b/prod/bingx/market_stream.py new file mode 100644 index 0000000..01602e2 --- /dev/null +++ b/prod/bingx/market_stream.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import asyncio +import gzip +import json +import uuid +from collections.abc import Awaitable +from collections.abc import Callable +from typing import Any + +import aiohttp + + +EventHandler = Callable[[dict[str, Any]], Awaitable[None]] +HealthHandler = Callable[[bool], None] + + +class BingxMarketStream: + """ + Public (unauthenticated) BingX swap-market WebSocket stream. + """ + + def __init__( + self, + *, + ws_url: str, + on_event: EventHandler, + on_health: HealthHandler | None = None, + reconnect_initial_ms: int = 500, + reconnect_max_ms: int = 10_000, + http_timeout_secs: int = 10, + ) -> None: + self._ws_url = ws_url + self._on_event = on_event + self._on_health = on_health + self._reconnect_initial_ms = int(reconnect_initial_ms) + self._reconnect_max_ms = int(reconnect_max_ms) + self._http_timeout_secs = int(http_timeout_secs) + + self._closed = asyncio.Event() + self._session: aiohttp.ClientSession | None = None + + # dataType -> subscription id + self._subscriptions: dict[str, str] = {} + self._subscriptions_changed = asyncio.Event() + + def subscribe(self, data_type: str) -> None: + if data_type in self._subscriptions: + return + self._subscriptions[data_type] = str(uuid.uuid4()) + self._subscriptions_changed.set() + + def unsubscribe(self, data_type: str) -> None: + if data_type not in self._subscriptions: + return + self._subscriptions.pop(data_type, None) + self._subscriptions_changed.set() + + async def run_forever(self) -> None: + delay_ms = self._reconnect_initial_ms + while not self._closed.is_set(): + try: + await self._consume() + delay_ms = self._reconnect_initial_ms + except asyncio.CancelledError: + raise + except Exception: + if self._closed.is_set(): + break + await asyncio.sleep(delay_ms / 1000.0) + delay_ms = min(delay_ms * 2, self._reconnect_max_ms) + finally: + self._notify_health(False) + await self.close() + + async def close(self) -> None: + self._closed.set() + if self._session is not None and not self._session.closed: + await self._session.close() + + async def _consume(self) -> None: + session = await self._get_session() + async with session.ws_connect( + self._ws_url, + autoping=False, + autoclose=False, + heartbeat=None, + compress=0, + max_msg_size=0, + ) as ws: + self._notify_health(True) + await self._flush_subscriptions(ws) + + async for msg in ws: + if msg.type == aiohttp.WSMsgType.CLOSED: + break + if msg.type == aiohttp.WSMsgType.ERROR: + raise ws.exception() or RuntimeError("BingX market socket error") + + if self._subscriptions_changed.is_set(): + await self._flush_subscriptions(ws) + + text = self._decode_message(msg) + if text is None: + continue + if text == "Ping" or "ping" in text.lower(): + await ws.send_str("Pong") + continue + payload = json.loads(text) + await self._on_event(payload) + + async def _flush_subscriptions(self, ws: aiohttp.ClientWebSocketResponse) -> None: + self._subscriptions_changed.clear() + for data_type, sub_id in list(self._subscriptions.items()): + await ws.send_json({"id": sub_id, "reqType": "sub", "dataType": data_type}) + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + timeout = aiohttp.ClientTimeout(total=None, sock_connect=self._http_timeout_secs) + connector = aiohttp.TCPConnector(limit=2, ttl_dns_cache=300) + self._session = aiohttp.ClientSession(timeout=timeout, connector=connector) + return self._session + + def _notify_health(self, healthy: bool) -> None: + if self._on_health is not None: + self._on_health(healthy) + + @staticmethod + def _decode_message(msg: aiohttp.WSMessage) -> str | None: + if msg.type == aiohttp.WSMsgType.TEXT: + return str(msg.data) + if msg.type == aiohttp.WSMsgType.BINARY: + data = bytes(msg.data) + try: + return gzip.decompress(data).decode("utf-8") + except OSError: + return data.decode("utf-8") + return None + diff --git a/prod/bingx/observer.py b/prod/bingx/observer.py new file mode 100644 index 0000000..6093586 --- /dev/null +++ b/prod/bingx/observer.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from .config import BingxExecClientConfig +from .http import BingxHttpClient +from .websocket import BingxUserStream + + +TERMINAL_ORDER_STATUSES = {"FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"} + + +@dataclass(frozen=True) +class BingxObservedOrder: + key: str + row: dict[str, Any] + terminal: bool + + +class BingxOrderUpdateObserver: + def __init__( + self, + client: BingxHttpClient, + config: BingxExecClientConfig, + *, + on_health: Callable[[bool], None] | None = None, + ) -> None: + self._client = client + self._config = config + self._stream = BingxUserStream( + client=client, + config=config, + on_event=self._on_event, + on_health=on_health, + ) + self._task: asyncio.Task | None = None + self._lock = asyncio.Lock() + self._latest: dict[str, dict[str, Any]] = {} + self._events: dict[str, asyncio.Event] = {} + self._closed = False + + async def start(self) -> None: + if self._task is None: + self._task = asyncio.create_task(self._stream.run_forever()) + + async def close(self) -> None: + self._closed = True + await self._stream.close() + if self._task is not None: + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._task + self._task = None + + async def stop(self) -> None: + await self.close() + + async def latest(self, key: str) -> dict[str, Any] | None: + async with self._lock: + row = self._latest.get(key) + return dict(row) if isinstance(row, dict) else None + + async def wait_for_terminal(self, key: str, *, timeout_s: float = 20.0) -> BingxObservedOrder | None: + deadline = asyncio.get_running_loop().time() + timeout_s + last_row: dict[str, Any] | None = None + while not self._closed: + async with self._lock: + row = self._latest.get(key) + if isinstance(row, dict): + last_row = dict(row) + status = str(last_row.get("status") or last_row.get("X") or "").upper() + if status in TERMINAL_ORDER_STATUSES: + return BingxObservedOrder(key=key, row=last_row, terminal=True) + event = self._events.setdefault(key, asyncio.Event()) + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + break + try: + await asyncio.wait_for(event.wait(), timeout=remaining) + except TimeoutError: + break + finally: + event.clear() + if last_row is not None: + return BingxObservedOrder(key=key, row=last_row, terminal=False) + return None + + async def wait_for_fill(self, key: str, *, timeout_s: float = 20.0) -> BingxObservedOrder | None: + deadline = asyncio.get_running_loop().time() + timeout_s + last_row: dict[str, Any] | None = None + while not self._closed: + async with self._lock: + row = self._latest.get(key) + if isinstance(row, dict): + last_row = dict(row) + last_fill_qty = str( + last_row.get("lastFilledQty") + or last_row.get("l") + or "0", + ) + if last_fill_qty not in {"0", "0.0", "0.00000000", ""}: + return BingxObservedOrder(key=key, row=last_row, terminal=False) + status = str(last_row.get("status") or last_row.get("X") or "").upper() + if status in TERMINAL_ORDER_STATUSES: + return BingxObservedOrder(key=key, row=last_row, terminal=True) + event = self._events.setdefault(key, asyncio.Event()) + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + break + try: + await asyncio.wait_for(event.wait(), timeout=remaining) + except TimeoutError: + break + finally: + event.clear() + if last_row is not None: + return BingxObservedOrder(key=key, row=last_row, terminal=False) + return None + + async def _on_event(self, payload: dict[str, Any]) -> None: + data = payload.get("data") if isinstance(payload.get("data"), dict) else None + event_type = str((data or payload).get("e") or "").upper() + data_type = str(payload.get("dataType") or "").lower() + if event_type not in {"ORDER_TRADE_UPDATE", "EXECUTIONREPORT"} and data_type != "spot.executionreport": + return + if event_type == "ORDER_TRADE_UPDATE": + order_update = payload.get("o") + if not isinstance(order_update, dict): + return + else: + source = data or payload + order_update = { + "s": source.get("s"), + "c": source.get("c") or source.get("clientOrderId") or source.get("clientOrderID"), + "i": source.get("i") or source.get("orderId") or source.get("orderID"), + "X": source.get("X"), + "x": source.get("x"), + "p": source.get("p") or source.get("price"), + "ap": source.get("ap") or source.get("avgPrice"), + "z": source.get("z") or source.get("executedQty") or source.get("cumFilledQty"), + "l": source.get("l") or source.get("lastFilledQty") or source.get("lastExecutedQty"), + "L": source.get("L") or source.get("lastFillPrice") or source.get("avgPrice"), + "n": source.get("n") or source.get("commission"), + "N": source.get("N") or source.get("commissionAsset"), + "positionID": source.get("positionID") or source.get("positionId"), + "triggerOrderId": source.get("triggerOrderId"), + "mainOrderId": source.get("mainOrderId"), + } + client_order_id = str(order_update.get("c") or "") + order_id = str(order_update.get("i") or "") + status = str(order_update.get("X") or order_update.get("x") or "").upper() + row = { + "symbol": order_update.get("s"), + "clientOrderId": client_order_id, + "clientOrderID": client_order_id, + "orderId": order_id, + "orderID": order_id, + "status": status, + "price": order_update.get("p"), + "avgPrice": order_update.get("ap") or order_update.get("L") or order_update.get("p"), + "executedQty": order_update.get("z") or "0", + "cumFilledQty": order_update.get("z") or "0", + "lastFilledQty": order_update.get("l") or "0", + "lastFillPrice": order_update.get("L") or order_update.get("ap") or order_update.get("p"), + "commission": order_update.get("n") or "0", + "commissionAsset": order_update.get("N"), + "executionType": order_update.get("x"), + "positionID": order_update.get("positionID") or order_update.get("positionId"), + "triggerOrderId": order_update.get("triggerOrderId"), + "mainOrderId": order_update.get("mainOrderId"), + "raw": order_update, + } + async with self._lock: + if client_order_id: + self._latest[client_order_id] = row + self._events.setdefault(client_order_id, asyncio.Event()).set() + if order_id: + self._latest[order_id] = row + self._events.setdefault(order_id, asyncio.Event()).set() diff --git a/prod/bingx/rate_limits.py b/prod/bingx/rate_limits.py new file mode 100644 index 0000000..a9f0aa9 --- /dev/null +++ b/prod/bingx/rate_limits.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from dataclasses import dataclass +from time import monotonic_ns + + +@dataclass(frozen=True) +class BingxRateLimitSnapshot: + rest_remaining: int | None = None + rest_reset_ms: int | None = None + ws_listenkey_ops: int = 0 + ws_listenkey_window_ns: int = 0 + + +@dataclass(frozen=True) +class BingxCircuitBreakerSnapshot: + failure_count: int = 0 + open_until_ns: int = 0 + last_delay_ms: int = 0 + + @property + def is_open(self) -> bool: + return monotonic_ns() < self.open_until_ns + + +class BingxRateLimitTracker: + def __init__(self) -> None: + self._rest_remaining: int | None = None + self._rest_reset_ms: int | None = None + self._ws_listenkey_ops = 0 + self._ws_window_start_ns = monotonic_ns() + + def update_rest_headers(self, headers: dict[str, str]) -> None: + remain = headers.get("x-ratelimit-requests-remain") + reset = headers.get("x-ratelimit-requests-expire") + self._rest_remaining = int(remain) if remain is not None and str(remain).isdigit() else self._rest_remaining + self._rest_reset_ms = int(reset) if reset is not None and str(reset).isdigit() else self._rest_reset_ms + + def count_ws_listenkey_op(self) -> None: + now = monotonic_ns() + if now - self._ws_window_start_ns > 1_000_000_000: + self._ws_window_start_ns = now + self._ws_listenkey_ops = 0 + self._ws_listenkey_ops += 1 + + def snapshot(self) -> BingxRateLimitSnapshot: + return BingxRateLimitSnapshot( + rest_remaining=self._rest_remaining, + rest_reset_ms=self._rest_reset_ms, + ws_listenkey_ops=self._ws_listenkey_ops, + ws_listenkey_window_ns=monotonic_ns() - self._ws_window_start_ns, + ) + + +class BingxCircuitBreaker: + def __init__( + self, + *, + failure_threshold: int = 3, + base_backoff_ms: int = 250, + max_backoff_ms: int = 2_000, + ) -> None: + self._failure_threshold = max(1, int(failure_threshold)) + self._base_backoff_ms = max(1, int(base_backoff_ms)) + self._max_backoff_ms = max(self._base_backoff_ms, int(max_backoff_ms)) + self._failure_count = 0 + self._open_until_ns = 0 + self._last_delay_ms = 0 + + async def wait_if_open(self) -> None: + remaining = self.open_remaining_secs() + if remaining > 0: + from asyncio import sleep + + await sleep(remaining) + + def open_remaining_secs(self) -> float: + remaining_ns = self._open_until_ns - monotonic_ns() + return max(0.0, remaining_ns / 1_000_000_000) + + def snapshot(self) -> BingxCircuitBreakerSnapshot: + return BingxCircuitBreakerSnapshot( + failure_count=self._failure_count, + open_until_ns=self._open_until_ns, + last_delay_ms=self._last_delay_ms, + ) + + def record_success(self) -> None: + self._failure_count = 0 + self._open_until_ns = 0 + self._last_delay_ms = 0 + + def record_failure(self, *, rate_limited: bool = False, retry_after_ms: int | None = None) -> float: + now_ns = monotonic_ns() + if rate_limited and retry_after_ms is not None and retry_after_ms > 0: + self._failure_count = self._failure_threshold + self._last_delay_ms = retry_after_ms + self._open_until_ns = max(self._open_until_ns, now_ns + retry_after_ms * 1_000_000) + return retry_after_ms / 1000.0 + + self._failure_count += 1 + delay_ms = min(self._base_backoff_ms * (2 ** (self._failure_count - 1)), self._max_backoff_ms) + self._last_delay_ms = delay_ms + if self._failure_count >= self._failure_threshold: + self._open_until_ns = max(self._open_until_ns, now_ns + delay_ms * 1_000_000) + return delay_ms / 1000.0 diff --git a/prod/bingx/reconciliation.py b/prod/bingx/reconciliation.py new file mode 100644 index 0000000..66d46bd --- /dev/null +++ b/prod/bingx/reconciliation.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + + +_TERMINAL_TRADE_HANDLERS: dict[str, Callable[[dict[str, Any]], Any]] = {} + + +def register_terminal_trade_handler(account_id: str, handler: Callable[[dict[str, Any]], Any]) -> None: + key = str(account_id or "").strip() + if not key: + return + _TERMINAL_TRADE_HANDLERS[key] = handler + + +def unregister_terminal_trade_handler(account_id: str, handler: Callable[[dict[str, Any]], Any] | None = None) -> None: + key = str(account_id or "").strip() + if not key: + return + current = _TERMINAL_TRADE_HANDLERS.get(key) + if handler is None or current is handler: + _TERMINAL_TRADE_HANDLERS.pop(key, None) + + +def get_terminal_trade_handler(account_id: str) -> Callable[[dict[str, Any]], Any] | None: + key = str(account_id or "").strip() + if not key: + return None + return _TERMINAL_TRADE_HANDLERS.get(key) diff --git a/prod/bingx/sandbox_status.py b/prod/bingx/sandbox_status.py new file mode 100644 index 0000000..3bbb37b --- /dev/null +++ b/prod/bingx/sandbox_status.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +DEFAULT_SANDBOX_STATUS_PATH = Path("/tmp/bingx_sandbox_status.json") + + +@dataclass(frozen=True) +class BingxSandboxStatus: + """Small sidecar snapshot for BingX demo/testnet state. + + The snapshot is intentionally local-only so it can be used by tests and + operators without writing into BLUE state, ClickHouse, or production logs. + """ + + ts: str + environment: str + balance: float + equity: float + available_margin: float + unrealized_profit: float + used_margin: float + open_positions: int + open_orders: int + account_currency: str = "VST" + clean: bool = False + notes: dict[str, Any] | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "ts": self.ts, + "environment": self.environment, + "account_currency": self.account_currency, + "balance": self.balance, + "equity": self.equity, + "available_margin": self.available_margin, + "unrealized_profit": self.unrealized_profit, + "used_margin": self.used_margin, + "open_positions": self.open_positions, + "open_orders": self.open_orders, + "clean": self.clean, + "notes": self.notes or {}, + } + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + out = float(value) + except Exception: + return default + return out if out == out else default + + +def _count_positions(positions: Any) -> int: + if isinstance(positions, list): + return sum(1 for item in positions if isinstance(item, dict)) + return 0 + + +def _count_orders(open_orders: Any) -> int: + if isinstance(open_orders, dict): + orders = open_orders.get("orders") + if isinstance(orders, list): + return sum(1 for item in orders if isinstance(item, dict)) + if isinstance(open_orders, list): + return sum(1 for item in open_orders if isinstance(item, dict)) + return 0 + + +def build_sandbox_status( + *, + balance_payload: dict[str, Any], + positions_payload: Any, + open_orders_payload: Any, + environment: str = "VST", + account_currency: str = "VST", + notes: dict[str, Any] | None = None, +) -> BingxSandboxStatus: + balance_row = balance_payload.get("balance", balance_payload) if isinstance(balance_payload, dict) else {} + if not isinstance(balance_row, dict): + balance_row = {} + balance = _safe_float(balance_row.get("balance"), 0.0) + equity = _safe_float(balance_row.get("equity"), balance) + available_margin = _safe_float(balance_row.get("availableMargin"), 0.0) + unrealized_profit = _safe_float(balance_row.get("unrealizedProfit"), 0.0) + used_margin = _safe_float(balance_row.get("usedMargin"), 0.0) + open_positions = _count_positions(positions_payload) + open_orders = _count_orders(open_orders_payload) + return BingxSandboxStatus( + ts=datetime.now(timezone.utc).isoformat(), + environment=str(environment), + account_currency=str(account_currency), + balance=balance, + equity=equity, + available_margin=available_margin, + unrealized_profit=unrealized_profit, + used_margin=used_margin, + open_positions=open_positions, + open_orders=open_orders, + clean=(open_positions == 0 and open_orders == 0), + notes=notes or {}, + ) + + +def snapshot_path(path: str | Path | None = None) -> Path: + return Path(path) if path is not None else DEFAULT_SANDBOX_STATUS_PATH + + +def write_sandbox_status(status: BingxSandboxStatus, path: str | Path | None = None) -> Path: + target = snapshot_path(path) + target.write_text(json.dumps(status.to_dict(), indent=2, sort_keys=True)) + return target + + +def load_sandbox_status(path: str | Path | None = None) -> dict[str, Any] | None: + target = snapshot_path(path) + if not target.exists(): + return None + try: + return json.loads(target.read_text()) + except Exception: + return None diff --git a/prod/bingx/schemas.py b/prod/bingx/schemas.py new file mode 100644 index 0000000..24c3d53 --- /dev/null +++ b/prod/bingx/schemas.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal + + +def _as_decimal(value: object, default: str = "0") -> Decimal: + if value is None: + return Decimal(default) + return Decimal(str(value)) + + +def unwrap_order_payload(payload: dict[str, object]) -> dict[str, object]: + row = payload.get("order") if isinstance(payload, dict) else None + return row if isinstance(row, dict) else payload + + +@dataclass(frozen=True) +class BingxContract: + symbol: str + venue_symbol: str + quote_asset: str + base_asset: str + price_precision: int + quantity_precision: int + min_quantity: Decimal + min_notional: Decimal + tick_size: Decimal + step_size: Decimal + maker_fee: Decimal + taker_fee: Decimal + max_leverage: int + + @classmethod + def from_http(cls, payload: dict[str, object]) -> "BingxContract": + venue_symbol = str(payload.get("symbol") or payload.get("ticker") or "") + normalized = venue_symbol.replace("-", "") + price_precision = int(payload.get("pricePrecision") or payload.get("price_scale") or 2) + quantity_precision = int( + payload.get("quantityPrecision") or payload.get("quantity_scale") or 3, + ) + tick_size = _as_decimal( + payload.get("tickSize") or payload.get("priceStep") or f"1e-{price_precision}", + ) + step_size = _as_decimal( + payload.get("stepSize") or payload.get("quantityStep") or f"1e-{quantity_precision}", + ) + return cls( + symbol=normalized, + venue_symbol=venue_symbol, + quote_asset=str(payload.get("currency") or payload.get("quoteAsset") or "USDT"), + base_asset=str(payload.get("asset") or payload.get("baseAsset") or normalized[:-4]), + price_precision=price_precision, + quantity_precision=quantity_precision, + min_quantity=_as_decimal(payload.get("minQty") or payload.get("minQuantity") or step_size), + min_notional=_as_decimal(payload.get("minNotional") or payload.get("minQuoteAmount") or "2"), + tick_size=tick_size, + step_size=step_size, + maker_fee=_as_decimal(payload.get("makerFeeRate") or payload.get("makerFee") or "0.0002"), + taker_fee=_as_decimal(payload.get("takerFeeRate") or payload.get("takerFee") or "0.0005"), + max_leverage=int(payload.get("maxLongLeverage") or payload.get("maxLeverage") or 1), + ) + + +@dataclass(frozen=True) +class BingxOrderAck: + order_id: str + client_order_id: str + symbol: str + status: str | None + + @classmethod + def from_http(cls, payload: dict[str, object]) -> "BingxOrderAck": + row = unwrap_order_payload(payload) + return cls( + order_id=str(row.get("orderId") or row.get("id") or ""), + client_order_id=str(row.get("clientOrderID") or row.get("clientOrderId") or ""), + symbol=str(row.get("symbol") or ""), + status=str(row.get("status")) if row.get("status") is not None else None, + ) diff --git a/prod/bingx/signing.py b/prod/bingx/signing.py new file mode 100644 index 0000000..ab56c95 --- /dev/null +++ b/prod/bingx/signing.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import hashlib +import hmac +import time +from collections.abc import Mapping +from urllib.parse import urlencode + + +def utc_timestamp_ms() -> int: + return int(time.time() * 1000) + + +def canonical_query(params: Mapping[str, object]) -> str: + filtered = { + key: value + for key, value in params.items() + if value is not None and value != "" + } + ordered = sorted(filtered.items(), key=lambda item: item[0]) + return urlencode(ordered, doseq=True) + + +def sign_query(secret_key: str, query: str) -> str: + return hmac.new( + secret_key.encode("utf-8"), + query.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + +def build_signed_params( + params: Mapping[str, object], + secret_key: str, + *, + timestamp_ms: int | None = None, + recv_window_ms: int | None = 5_000, +) -> dict[str, object]: + signed = dict(params) + signed["timestamp"] = utc_timestamp_ms() if timestamp_ms is None else int(timestamp_ms) + try: + parsed_recv_window = int(recv_window_ms) if recv_window_ms is not None else 5_000 + except Exception: + parsed_recv_window = 5_000 + signed["recvWindow"] = parsed_recv_window if parsed_recv_window > 0 else 5_000 + query = canonical_query(signed) + signed["signature"] = sign_query(secret_key, query) + return signed diff --git a/prod/bingx/sizing_mode.py b/prod/bingx/sizing_mode.py new file mode 100644 index 0000000..943af16 --- /dev/null +++ b/prod/bingx/sizing_mode.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from typing import Any +from typing import Literal +from typing import Mapping + +from prod.utils.trade_sizing_bridge import TradeSizingDefaults +from prod.utils.trade_sizing_bridge import build_engine_ready_sizing +from prod.utils.trade_sizing_bridge import load_trade_sizing_defaults_from_control_plane + +SizingMode = Literal["engine", "testnet", "live_market"] + +ENGINE_MODE: SizingMode = "engine" +TESTNET_MODE: SizingMode = "testnet" +LIVE_MARKET_MODE: SizingMode = "live_market" + + +def normalize_sizing_mode(mode: Any) -> SizingMode: + """Normalize a caller-provided sizing mode.""" + if isinstance(mode, str): + normalized = mode.strip().lower() + if normalized == TESTNET_MODE: + return TESTNET_MODE + if normalized == LIVE_MARKET_MODE: + return LIVE_MARKET_MODE + return ENGINE_MODE + + +def build_split_sizing_payload( + *, + sizing_mode: Any = ENGINE_MODE, + sizing_lev: float, + capital: float | None = None, + mark_price: float | None = None, + quantity_step: float | None = None, + venue_notional_cap: float | None = None, + exchange_leverage_cap: int | None = None, + margin_budget_fraction_override: float | None = None, + system_fraction_override: float | None = None, + control_plane: Mapping[str, Any] | None = None, + hz_client: Any | None = None, + defaults: TradeSizingDefaults | None = None, + notes: dict[str, Any] | None = None, +) -> dict[str, Any] | None: + """Return a BingX-ready sizing payload in testnet or live-market mode.""" + mode = normalize_sizing_mode(sizing_mode) + if mode == ENGINE_MODE: + return None + + resolved_defaults = defaults or load_trade_sizing_defaults_from_control_plane( + hz_client=hz_client, + control_plane=control_plane, + fallback=TradeSizingDefaults(), + ) + result = build_engine_ready_sizing( + sizing_lev=sizing_lev, + capital=capital, + mark_price=mark_price, + quantity_step=quantity_step, + venue_notional_cap=venue_notional_cap, + exchange_leverage_cap=exchange_leverage_cap, + margin_budget_fraction_override=margin_budget_fraction_override, + system_fraction_override=system_fraction_override, + control_plane=control_plane, + hz_client=hz_client, + defaults=resolved_defaults, + notes=notes or {}, + ) + payload = result.to_engine_payload() + payload["sizing_mode"] = mode + return payload diff --git a/prod/bingx/urls.py b/prod/bingx/urls.py new file mode 100644 index 0000000..4d2dde4 --- /dev/null +++ b/prod/bingx/urls.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from .enums import BingxEnvironment + + +_REST_BASE_URLS: dict[BingxEnvironment, tuple[str, str]] = { + BingxEnvironment.LIVE: ("https://open-api.bingx.com", "https://open-api.bingx.pro"), + BingxEnvironment.VST: ("https://open-api-vst.bingx.com", "https://open-api-vst.bingx.pro"), +} + +_WS_PRIVATE_URLS: dict[BingxEnvironment, str | None] = { + BingxEnvironment.LIVE: "wss://open-api-swap.bingx.com/swap-market", + BingxEnvironment.VST: "wss://vst-open-api-ws.bingx.com/swap-market", +} + +_WS_PUBLIC_URLS: dict[BingxEnvironment, str] = { + BingxEnvironment.LIVE: "wss://open-api-swap.bingx.com/swap-market", + BingxEnvironment.VST: "wss://vst-open-api-ws.bingx.com/swap-market", +} + + +def get_rest_base_urls(environment: BingxEnvironment) -> tuple[str, str]: + return _REST_BASE_URLS[environment] + + +def get_private_ws_url(environment: BingxEnvironment) -> str | None: + return _WS_PRIVATE_URLS[environment] + + +def get_public_ws_url(environment: BingxEnvironment) -> str: + return _WS_PUBLIC_URLS[environment] diff --git a/prod/bingx/websocket.py b/prod/bingx/websocket.py new file mode 100644 index 0000000..9895bd9 --- /dev/null +++ b/prod/bingx/websocket.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import asyncio +import contextlib +import gzip +import json +from collections.abc import Awaitable +from collections.abc import Callable +from typing import Any + +import aiohttp + +from .config import BingxExecClientConfig +from .http import BingxHttpClient +from .rate_limits import BingxRateLimitTracker +from .urls import get_private_ws_url + + +EventHandler = Callable[[dict[str, Any]], Awaitable[None]] +HealthHandler = Callable[[bool], None] + + +class BingxUserStream: + def __init__( + self, + client: BingxHttpClient, + config: BingxExecClientConfig, + on_event: EventHandler, + on_health: HealthHandler | None = None, + ) -> None: + self._client = client + self._config = config + self._on_event = on_event + self._on_health = on_health + self._rate_limits: BingxRateLimitTracker = client.rate_limits + self._closed = asyncio.Event() + self._session: aiohttp.ClientSession | None = None + + async def run_forever(self) -> None: + delay_ms = int(self._config.ws_reconnect_initial_ms) + max_delay_ms = int(self._config.ws_reconnect_max_ms) + while not self._closed.is_set(): + listen_key: str | None = None + keepalive_task: asyncio.Task | None = None + try: + listen_key = await self._create_listen_key() + keepalive_task = asyncio.create_task(self._keepalive_loop(listen_key)) + await self._consume(listen_key) + delay_ms = int(self._config.ws_reconnect_initial_ms) + except asyncio.CancelledError: + raise + except Exception: + if self._closed.is_set(): + break + await asyncio.sleep(delay_ms / 1000.0) + delay_ms = min(delay_ms * 2, max_delay_ms) + finally: + self._notify_health(False) + if keepalive_task is not None: + keepalive_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await keepalive_task + if listen_key is not None: + with contextlib.suppress(Exception, asyncio.CancelledError): + await self._delete_listen_key(listen_key) + await self.close() + + async def close(self) -> None: + self._closed.set() + if self._session is not None and not self._session.closed: + await self._session.close() + + async def _consume(self, listen_key: str) -> None: + ws_base = self._config.base_url_ws_private or get_private_ws_url(self._config.environment) + if not ws_base: + raise RuntimeError(f"No BingX private WS URL configured for {self._config.environment.value}") + session = await self._get_session() + ws_url = f"{ws_base}?listenKey={listen_key}" + async with session.ws_connect( + ws_url, + autoping=False, + autoclose=False, + heartbeat=None, + compress=0, + max_msg_size=0, + ) as ws: + self._notify_health(True) + async for msg in ws: + if msg.type == aiohttp.WSMsgType.CLOSED: + break + if msg.type == aiohttp.WSMsgType.ERROR: + raise ws.exception() or RuntimeError("BingX user stream socket error") + text = self._decode_message(msg) + if text is None: + continue + if text == "Ping" or "ping" in text.lower(): + await ws.send_str("Pong") + continue + payload = json.loads(text) + await self._on_event(payload) + if payload.get("e") == "listenKeyExpired": + raise RuntimeError("BingX listen key expired") + + async def _create_listen_key(self) -> str: + self._rate_limits.count_ws_listenkey_op() + response = await self._client.signed_post_raw("/openApi/user/auth/userDataStream", {}) + listen_key = str(response.get("listenKey") or "") + if not listen_key: + raise RuntimeError("BingX listen key was empty") + return listen_key + + async def _keepalive_loop(self, listen_key: str) -> None: + interval_secs = int(self._config.ws_listenkey_keepalive_interval_secs) + while not self._closed.is_set(): + await asyncio.sleep(interval_secs) + self._rate_limits.count_ws_listenkey_op() + await self._client.signed_put_raw( + "/openApi/user/auth/userDataStream", + {"listenKey": listen_key}, + allow_empty=True, + ) + + async def _delete_listen_key(self, listen_key: str) -> None: + self._rate_limits.count_ws_listenkey_op() + await self._client.signed_delete_raw( + "/openApi/user/auth/userDataStream", + {"listenKey": listen_key}, + allow_empty=True, + ) + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + timeout = aiohttp.ClientTimeout(total=None, sock_connect=self._config.http_timeout_secs) + connector = aiohttp.TCPConnector(limit=4, ttl_dns_cache=300) + self._session = aiohttp.ClientSession(timeout=timeout, connector=connector) + return self._session + + def _notify_health(self, healthy: bool) -> None: + if self._on_health is not None: + self._on_health(healthy) + + @staticmethod + def _decode_message(msg: aiohttp.WSMessage) -> str | None: + if msg.type == aiohttp.WSMsgType.TEXT: + return str(msg.data) + if msg.type == aiohttp.WSMsgType.BINARY: + data = bytes(msg.data) + try: + return gzip.decompress(data).decode("utf-8") + except OSError: + return data.decode("utf-8") + return None diff --git a/prod/clean_arch/adapters/__init__.py b/prod/clean_arch/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/prod/clean_arch/adapters/eigen_scan_normalizer.py b/prod/clean_arch/adapters/eigen_scan_normalizer.py new file mode 100644 index 0000000..4f4422f --- /dev/null +++ b/prod/clean_arch/adapters/eigen_scan_normalizer.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +""" +Shared eigen-scan normalizer. + +BLUE is canonical. This module converts NG7 nested Hazelcast payloads into +the flat shape the production engines expect. +""" + +from __future__ import annotations + +import math +from typing import Any, Dict + + +def normalize_ng7_scan(scan: Dict[str, Any]) -> Dict[str, Any]: + """ + Promote an NG7 nested scan to the BLUE-compatible flat dict. + + Expected input: + scan["result"]["multi_window_results"]["50"]["tracking_data"]["lambda_max_velocity"] + scan["result"]["pricing_data"]["current_prices"] + """ + if not isinstance(scan, dict): + return {} + + result = scan.get("result") or {} + mw = result.get("multi_window_results") or {} + + def _velocity(window: int) -> float: + tracking = (mw.get(str(window)) or {}).get("tracking_data", {}) + value = tracking.get("lambda_max_velocity") + try: + velocity = float(value) + return velocity if math.isfinite(velocity) else 0.0 + except (TypeError, ValueError): + return 0.0 + + v50 = _velocity(50) + v750 = _velocity(750) + + current_prices = (result.get("pricing_data") or {}).get("current_prices") or {} + assets = [asset for asset in current_prices if asset != "BTCUSDT"] + if "BTCUSDT" in current_prices: + assets.append("BTCUSDT") + asset_prices = [float(current_prices[asset]) for asset in assets] if assets else [] + + instability = float((result.get("regime_prediction") or {}).get("instability_score") or 0.0) + + return { + **scan, + "vel_div": v50 - v750, + "w50_velocity": v50, + "w750_velocity": v750, + "assets": assets, + "asset_prices": asset_prices, + "instability_50": instability, + } diff --git a/prod/clean_arch/core/__init__.py b/prod/clean_arch/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/prod/clean_arch/core/trading_engine.py b/prod/clean_arch/core/trading_engine.py new file mode 100644 index 0000000..78161b3 --- /dev/null +++ b/prod/clean_arch/core/trading_engine.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +CORE: TradingEngine +=================== +Pure business logic - no external dependencies. + +Clean Architecture: +- Depends only on PORTS (interfaces) +- No knowledge of Hazelcast, Binance, etc. +- Testable in isolation +- Ready for Rust kernel migration +""" + +import logging +import asyncio +from datetime import datetime +from typing import Dict, List, Optional, Any +from dataclasses import dataclass, field + +# Import only PORTS, not adapters +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent)) +from ports.data_feed import DataFeedPort, MarketSnapshot, ACBUpdate + +logger = logging.getLogger("TradingEngine") + + +@dataclass +class Position: + """Current position state.""" + symbol: str + side: str # 'LONG' or 'SHORT' + size: float + entry_price: float + entry_time: datetime + unrealized_pnl: float = 0.0 + + +@dataclass +class TradingState: + """Complete trading state (serializable).""" + capital: float + positions: Dict[str, Position] = field(default_factory=dict) + trades_today: int = 0 + daily_pnl: float = 0.0 + last_update: Optional[datetime] = None + + def total_exposure(self) -> float: + """Calculate total position exposure.""" + return sum(abs(p.size * p.entry_price) for p in self.positions.values()) + + +class TradingEngine: + """ + CORE: Pure trading logic. + + No external dependencies - works with any DataFeedPort implementation. + Can be unit tested with mock feeds. + Ready for Rust rewrite (state machine is simple). + """ + + def __init__( + self, + data_feed: DataFeedPort, + config: Dict[str, Any] + ): + self.feed = data_feed + self.config = config + + # State + self.state = TradingState( + capital=config.get('initial_capital', 25000.0) + ) + self.running = False + + # Strategy params + self.max_leverage = config.get('max_leverage', 5.0) + self.capital_fraction = config.get('capital_fraction', 0.20) + self.min_irp = config.get('min_irp_alignment', 0.45) + self.vel_div_threshold = config.get('vel_div_threshold', -0.02) + + # ACB state + self.acb_boost = 1.0 + self.acb_beta = 0.5 + self.posture = 'APEX' + + logger.info("TradingEngine initialized") + logger.info(f" Capital: ${self.state.capital:,.2f}") + logger.info(f" Max Leverage: {self.max_leverage}x") + logger.info(f" Capital Fraction: {self.capital_fraction:.0%}") + + async def start(self): + """Start the trading engine.""" + logger.info("=" * 60) + logger.info("🐬 TRADING ENGINE STARTING") + logger.info("=" * 60) + + # Connect to data feed + if not await self.feed.connect(): + raise RuntimeError("Failed to connect to data feed") + + self.running = True + + # Subscribe to snapshot stream + await self.feed.subscribe_snapshots(self._on_snapshot) + + logger.info("[✓] Engine running - waiting for data...") + + # Main loop + while self.running: + await self._process_cycle() + await asyncio.sleep(5) # 5s cycle + + async def stop(self): + """Stop cleanly.""" + self.running = False + await self.feed.disconnect() + logger.info("=" * 60) + logger.info("🙏 TRADING ENGINE STOPPED") + logger.info(f" Final Capital: ${self.state.capital:,.2f}") + logger.info(f" Daily PnL: ${self.state.daily_pnl:,.2f}") + logger.info("=" * 60) + + async def _process_cycle(self): + """Main processing cycle.""" + try: + # Update ACB + acb = await self.feed.get_acb_update() + if acb: + self._update_acb(acb) + + # Health check + if not self.feed.health_check(): + logger.warning("[!] Data feed unhealthy") + return + + # Log heartbeat + now = datetime.utcnow() + if not self.state.last_update or (now - self.state.last_update).seconds >= 60: + self._log_status() + self.state.last_update = now + + except Exception as e: + logger.error(f"Cycle error: {e}") + + def _on_snapshot(self, snapshot: MarketSnapshot): + """ + Callback for new market snapshot. + Receives PRICE + EIGENVALUES (synced). + """ + if not snapshot.is_valid(): + return + + # Log heartbeat + if snapshot.scan_number and snapshot.scan_number % 12 == 0: + logger.info(f"[TICK] {snapshot.symbol} @ ${snapshot.price:,.2f} " + f"(scan #{snapshot.scan_number})") + + self._evaluate_signal(snapshot) + + def _evaluate_signal(self, snapshot: MarketSnapshot): + """Evaluate trading signal - all data synced.""" + # Trading logic here + pass + + def _update_acb(self, acb: ACBUpdate): + """Update ACB parameters.""" + self.acb_boost = acb.boost + self.acb_beta = acb.beta + self.posture = acb.posture + + def _log_status(self): + """Log current status.""" + latency = self.feed.get_latency_ms() + exposure = self.state.total_exposure() + + logger.info("=" * 40) + logger.info(f"STATUS: Capital=${self.state.capital:,.2f}") + logger.info(f" Daily PnL=${self.state.daily_pnl:,.2f}") + logger.info(f" Exposure=${exposure:,.2f}") + logger.info(f" Positions={len(self.state.positions)}") + logger.info(f" Latency={latency:.1f}ms") + logger.info(f" ACB Boost={self.acb_boost:.2f}") + logger.info("=" * 40) diff --git a/prod/clean_arch/dita/__init__.py b/prod/clean_arch/dita/__init__.py new file mode 100644 index 0000000..9fd9a43 --- /dev/null +++ b/prod/clean_arch/dita/__init__.py @@ -0,0 +1,49 @@ +"""DITA boundary for clean-arch trading experiments. + +Decision -> Intent -> Trade -> Account + +This package is infrastructure-free. It provides the canonical contracts +and pure engines used by the simulator and by any future adapters that need +BLUE/PINK comparable semantics. +""" + +from .account import AccountProjection, AccountSnapshot +from .contracts import ( + AccountEvent, + Decision, + DecisionAction, + DecisionConfig, + DecisionContext, + Intent, + IntentContext, + TradeEvent, + TradePosition, + TradeSide, + TradeStage, +) +from .decision import DecisionEngine +from .intent import IntentEngine +from .observability import DitaObservabilityNamespace, LEGACY_ANOMALY_SENSOR_KEY +from .trade import TradeExecutionResult, TradeExecutor + +__all__ = [ + "AccountEvent", + "AccountProjection", + "AccountSnapshot", + "Decision", + "DecisionAction", + "DecisionConfig", + "DecisionContext", + "DecisionEngine", + "DitaObservabilityNamespace", + "Intent", + "IntentContext", + "IntentEngine", + "LEGACY_ANOMALY_SENSOR_KEY", + "TradeEvent", + "TradeExecutionResult", + "TradeExecutor", + "TradePosition", + "TradeSide", + "TradeStage", +] diff --git a/prod/clean_arch/dita/account.py b/prod/clean_arch/dita/account.py new file mode 100644 index 0000000..87bc401 --- /dev/null +++ b/prod/clean_arch/dita/account.py @@ -0,0 +1,118 @@ +"""Account projection and CH/HZ-shaped rows.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, Optional +import math + +from .contracts import AccountEvent, Decision, Intent, TradePosition, TradeSide, TradeStage + + +@dataclass +class AccountSnapshot: + """Derived account state used for projections and row emission.""" + + capital: float + equity: float + realized_pnl: float = 0.0 + unrealized_pnl: float = 0.0 + open_positions: int = 0 + open_notional: float = 0.0 + fees_paid: float = 0.0 + trade_seq: int = 0 + + @property + def leverage(self) -> float: + if self.capital <= 0 or self.open_notional <= 0: + return 0.0 + return self.open_notional / self.capital + + +@dataclass +class AccountProjection: + """Thin account projection. + + This is not policy. It only projects confirmed execution facts into the + live account view and the durable row shape used by CH/HZ/TUI consumers. + """ + + runtime_namespace: str = "pink" + strategy_namespace: str = "pink" + event_namespace: str = "pink" + actor_name: str = "clean_arch" + exec_venue: str = "bingx" + data_venue: str = "binance" + ledger_authority: str = "exchange" + min_capital: float = 0.0 + max_capital: Optional[float] = None + snapshot: AccountSnapshot = field(default_factory=lambda: AccountSnapshot(capital=25_000.0, equity=25_000.0)) + + def observe_position(self, position: Optional[TradePosition]) -> None: + if position is None: + self.snapshot.open_positions = 0 + self.snapshot.open_notional = 0.0 + self.snapshot.unrealized_pnl = 0.0 + self.snapshot.equity = self.snapshot.capital + return + self.snapshot.open_positions = 1 + mark = position.current_price + if not math.isfinite(mark) or mark <= 0: + mark = position.entry_price if math.isfinite(position.entry_price) and position.entry_price > 0 else 0.0 + self.snapshot.open_notional = mark * position.size + self.snapshot.unrealized_pnl = position.unrealized_pnl + self.snapshot.equity = self.snapshot.capital + position.unrealized_pnl + + def settle(self, realized_pnl: float, fees: float = 0.0) -> None: + if not math.isfinite(realized_pnl): + realized_pnl = 0.0 + new_capital = self.snapshot.capital + realized_pnl + if not math.isfinite(new_capital): + new_capital = self.snapshot.capital + if self.max_capital is not None: + new_capital = min(new_capital, self.max_capital) + new_capital = max(self.min_capital, new_capital) + self.snapshot.capital = new_capital + self.snapshot.realized_pnl += realized_pnl + self.snapshot.fees_paid += fees + self.snapshot.equity = self.snapshot.capital + self.snapshot.unrealized_pnl + if not math.isfinite(self.snapshot.equity): + self.snapshot.equity = self.snapshot.capital + + def to_event( + self, + *, + timestamp: datetime, + decision: Decision, + intent: Intent, + position: Optional[TradePosition], + stage: TradeStage, + extra: Optional[Dict[str, Any]] = None, + ) -> AccountEvent: + self.observe_position(position) + return AccountEvent( + timestamp=timestamp, + runtime_namespace=self.runtime_namespace, + strategy_namespace=self.strategy_namespace, + event_namespace=self.event_namespace, + actor_name=self.actor_name, + exec_venue=self.exec_venue, + data_venue=self.data_venue, + ledger_authority=self.ledger_authority, + capital=self.snapshot.capital, + equity=self.snapshot.equity, + open_positions=self.snapshot.open_positions, + current_open_notional=self.snapshot.open_notional, + current_account_leverage=self.snapshot.leverage, + decision_id=decision.decision_id, + trade_id=intent.trade_id, + asset=decision.asset, + side=intent.side, + reason=intent.reason, + stage=stage, + pnl=self.snapshot.realized_pnl, + pnl_pct=0.0 if self.snapshot.capital <= 0 else (self.snapshot.realized_pnl / self.snapshot.capital), + bars_held=intent.bars_held, + metadata=extra or {}, + ) diff --git a/prod/clean_arch/dita/intent.py b/prod/clean_arch/dita/intent.py new file mode 100644 index 0000000..1060847 --- /dev/null +++ b/prod/clean_arch/dita/intent.py @@ -0,0 +1,132 @@ +"""Intent planning layer.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +from .contracts import Decision, DecisionAction, DecisionConfig, DecisionContext, Intent, IntentContext, TradePosition, TradeSide, TradeStage + + +@dataclass(frozen=True) +class IntentPlanResult: + intent: Intent + trade_id_created: bool + + +class IntentEngine: + """Converts a pure decision into an executable intent. + + This is where sizing and trade identity are attached. + """ + + def __init__(self, config: Optional[DecisionConfig] = None): + self.config = config or DecisionConfig() + + def plan( + self, + decision: Decision, + context: IntentContext, + position: Optional[TradePosition] = None, + ) -> IntentPlanResult: + if decision.action == DecisionAction.ENTER: + return self._plan_entry(decision, context) + if decision.action == DecisionAction.EXIT and position is not None: + return self._plan_exit(decision, context, position) + return IntentPlanResult( + intent=Intent( + timestamp=decision.timestamp, + trade_id=decision.decision_id.replace("-D-", "-T-"), + decision_id=decision.decision_id, + asset=decision.asset, + action=decision.action, + side=decision.side, + reason=decision.reason, + target_size=0.0, + leverage=1.0, + reference_price=decision.reference_price, + confidence=decision.confidence, + bars_held=0, + stage=TradeStage.INTENT_CREATED, + exit_leg_ratios=self.config.exit_leg_ratios, + metadata={"policy_version": self.config.policy_version, **decision.metadata}, + ), + trade_id_created=False, + ) + + def _plan_entry(self, decision: Decision, context: IntentContext) -> IntentPlanResult: + price = decision.reference_price + confidence = max(0.05, min(1.0, decision.confidence)) + # Honor the decision's sizing when present (BLUE-parity cubic sizer + # attaches leverage + target_size in DecisionEngine._decide_entry). + # For legacy decisions the recompute below yields the identical values, + # so preferring the decision's numbers is behavior-preserving. + if decision.leverage and decision.leverage > 0 and decision.target_size and decision.target_size > 0: + leverage = float(decision.leverage) + target_size = float(decision.target_size) + target_exposure = target_size * price if price > 0 else 0.0 + else: + leverage = min(self.config.max_leverage, max(1.0, 1.0 + confidence * (self.config.max_leverage - 1.0))) + target_exposure = context.capital * self.config.capital_fraction * leverage + target_size = target_exposure / price if price > 0 else 0.0 + trade_id = self._trade_id(decision.asset, context.trade_seq + 1) + return IntentPlanResult( + intent=Intent( + timestamp=decision.timestamp, + trade_id=trade_id, + decision_id=decision.decision_id, + asset=decision.asset, + action=decision.action, + side=decision.side, + reason=decision.reason, + target_size=target_size, + leverage=leverage, + reference_price=price, + confidence=confidence, + bars_held=0, + stage=TradeStage.INTENT_CREATED, + exit_leg_ratios=self.config.exit_leg_ratios, + metadata={ + "policy_version": self.config.policy_version, + "target_exposure": target_exposure, + "entry_velocity_divergence": decision.velocity_divergence, + "entry_irp_alignment": decision.irp_alignment, + **decision.metadata, + }, + ), + trade_id_created=True, + ) + + def _plan_exit(self, decision: Decision, context: IntentContext, position: TradePosition) -> IntentPlanResult: + exit_ratio = position.next_exit_ratio() + target_size = position.size * exit_ratio if exit_ratio > 0 else position.size + return IntentPlanResult( + intent=Intent( + timestamp=decision.timestamp, + trade_id=position.trade_id, + decision_id=decision.decision_id, + asset=position.asset, + action=decision.action, + side=position.side, + reason=decision.reason, + target_size=target_size, + leverage=position.leverage, + reference_price=decision.reference_price, + confidence=decision.confidence, + bars_held=position.bars_held, + stage=TradeStage.INTENT_CREATED, + exit_leg_ratios=position.exit_leg_ratios, + metadata={ + "policy_version": self.config.policy_version, + "exit_ratio": exit_ratio, + "remaining_size_before": position.size, + **decision.metadata, + }, + ), + trade_id_created=False, + ) + + @staticmethod + def _trade_id(symbol: str, seq: int) -> str: + return f"{symbol}-T-{seq:012d}" diff --git a/prod/clean_arch/dita/observability.py b/prod/clean_arch/dita/observability.py new file mode 100644 index 0000000..ed96c61 --- /dev/null +++ b/prod/clean_arch/dita/observability.py @@ -0,0 +1,32 @@ +"""DITA observability namespace helpers. + +These helpers keep DITA diagnostics isolated by runtime namespace while still +allowing optional legacy key mirroring when explicitly requested. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +LEGACY_ANOMALY_SENSOR_KEY = "dita_anomaly_sensors" + + +@dataclass(frozen=True) +class DitaObservabilityNamespace: + """Namespace contract for DITA observability payloads.""" + + runtime_namespace: str = "pink" + feature_map: str = "DOLPHIN_FEATURES" + meta_health_map: str = "DOLPHIN_META_HEALTH" + state_map: str = "DOLPHIN_STATE_PINK" + anomaly_sensor_key: str | None = None + mirror_legacy_key: bool = False + + def resolved_sensor_key(self) -> str: + value = str(self.anomaly_sensor_key or "").strip() + if value: + return value + ns = str(self.runtime_namespace or "pink").strip().lower() + return f"{LEGACY_ANOMALY_SENSOR_KEY}_{ns}" + diff --git a/prod/clean_arch/dita/trade.py b/prod/clean_arch/dita/trade.py new file mode 100644 index 0000000..36a214e --- /dev/null +++ b/prod/clean_arch/dita/trade.py @@ -0,0 +1,139 @@ +"""Trade execution and single-slot FSM.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, List, Optional, Sequence + +from .contracts import DecisionAction, Intent, TradeEvent, TradePosition, TradeSide, TradeStage + + +@dataclass(frozen=True) +class TradeExecutionResult: + """Result of applying an intent to a trade slot.""" + + intent: Intent + receipt: Optional[Any] + stages: Sequence[TradeStage] + position_before: Optional[TradePosition] + position_after: Optional[TradePosition] + partial_close: bool = False + + +class TradeExecutor: + """Single-slot trade FSM. + + Owns the live position and translates executable intents into exchange + requests and canonical lifecycle stages. + """ + + def __init__(self) -> None: + self.position: Optional[TradePosition] = None + self.trade_history: List[TradeEvent] = [] + + def execute(self, intent: Intent, exchange: Any, capital_before: float) -> TradeExecutionResult: + position_before = self._clone_position(self.position) + if intent.action == DecisionAction.ENTER: + return self._execute_enter(intent, exchange, capital_before, position_before) + if intent.action == DecisionAction.EXIT: + return self._execute_exit(intent, exchange, capital_before, position_before) + return TradeExecutionResult( + intent=intent, + receipt=None, + stages=(TradeStage.INTENT_CREATED,), + position_before=position_before, + position_after=self._clone_position(self.position), + ) + + def apply_fill(self, receipt: Any, intent: Intent) -> None: + if receipt is None: + return + if intent.action == DecisionAction.ENTER and receipt.status == "FILLED": + self.position = TradePosition( + trade_id=intent.trade_id, + asset=intent.asset, + side=intent.side, + entry_price=receipt.fill_price, + entry_time=intent.timestamp, + size=receipt.fill_size, + leverage=intent.leverage, + entry_velocity_divergence=float( + intent.metadata.get("entry_velocity_divergence", intent.metadata.get("velocity_divergence", 0.0)) + ), + entry_irp_alignment=float(intent.metadata.get("entry_irp_alignment", intent.confidence)), + current_price=receipt.fill_price, + initial_size=receipt.fill_size, + exit_leg_ratios=tuple(intent.exit_leg_ratios), + ) + return + if intent.action == DecisionAction.EXIT and self.position is not None and receipt.status == "FILLED": + self.position.size = max(0.0, float(receipt.remaining_size)) + self.position.exit_price = receipt.fill_price + self.position.realized_pnl += receipt.realized_pnl + self.position.mark_price(receipt.fill_price) + self.position.closed = self.position.size <= 1e-12 + self.position.close_reason = intent.reason if self.position.closed else "PARTIAL_" + intent.reason + if self.position.closed: + self.position = None + + def _execute_enter(self, intent: Intent, exchange: Any, capital_before: float, position_before: Optional[TradePosition]) -> TradeExecutionResult: + if self.position is not None and not self.position.closed: + return TradeExecutionResult(intent=intent, receipt=exchange.reject(intent, "POSITION_ALREADY_OPEN"), stages=(TradeStage.ORDER_REQUESTED,), position_before=position_before, position_after=self._clone_position(self.position)) + receipt = exchange.submit(intent) + stages = (TradeStage.ORDER_REQUESTED, TradeStage.ORDER_SENT) + if receipt and receipt.status == "FILLED": + self.apply_fill(receipt, intent) + stages = stages + (TradeStage.ORDER_ACKED, TradeStage.POSITION_OPENED) + return TradeExecutionResult(intent=intent, receipt=receipt, stages=stages, position_before=position_before, position_after=self._clone_position(self.position)) + + def _execute_exit(self, intent: Intent, exchange: Any, capital_before: float, position_before: Optional[TradePosition]) -> TradeExecutionResult: + if self.position is None or self.position.closed: + return TradeExecutionResult(intent=intent, receipt=exchange.reject(intent, "NO_OPEN_POSITION"), stages=(TradeStage.EXIT_REQUESTED,), position_before=position_before, position_after=None) + receipt = exchange.submit(intent) + stages = [TradeStage.EXIT_REQUESTED, TradeStage.EXIT_SENT] + if receipt and receipt.status == "FILLED": + self.apply_fill(receipt, intent) + stages.append(TradeStage.EXIT_ACKED) + if self.position is None: + stages.extend([TradeStage.POSITION_CLOSED, TradeStage.TRADE_TERMINAL_WRITTEN]) + else: + stages.extend([TradeStage.POSITION_PARTIALLY_CLOSED, TradeStage.POSITION_UPDATED]) + return TradeExecutionResult( + intent=intent, + receipt=receipt, + stages=tuple(stages), + position_before=position_before, + position_after=self._clone_position(self.position), + partial_close=self.position is not None, + ) + + @staticmethod + def _clone_position(position: Optional[TradePosition]) -> Optional[TradePosition]: + if position is None: + return None + return TradePosition( + trade_id=position.trade_id, + asset=position.asset, + side=position.side, + entry_price=position.entry_price, + entry_time=position.entry_time, + size=position.size, + leverage=position.leverage, + entry_velocity_divergence=position.entry_velocity_divergence, + entry_irp_alignment=position.entry_irp_alignment, + bars_held=position.bars_held, + current_price=position.current_price, + realized_pnl=position.realized_pnl, + unrealized_pnl=position.unrealized_pnl, + exit_price=position.exit_price, + closed=position.closed, + close_reason=position.close_reason, + initial_size=position.initial_size, + exit_leg_ratios=position.exit_leg_ratios, + exit_leg_index=position.exit_leg_index, + ) + + +def decision_confidence_from_intent(intent: Intent) -> float: + return max(0.0, min(1.0, float(intent.confidence))) diff --git a/prod/clean_arch/dita_v2/__init__.py b/prod/clean_arch/dita_v2/__init__.py new file mode 100644 index 0000000..93ae037 --- /dev/null +++ b/prod/clean_arch/dita_v2/__init__.py @@ -0,0 +1,95 @@ +"""DITA v2 prototype kernel. + +This package is intentionally separate from the legacy v1 DITA surface so the +new execution kernel can be validated in isolation before any migration. +""" + +from .account import AccountProjection, AccountSnapshot +from .control import ( + BackendMode, + ControlPlane, + ControlUpdate, + build_control_plane, + InMemoryControlPlane, + KernelControlSnapshot, + KernelMode, + KernelVerbosity, + MirroredControlPlane, + ZincControlPlane, +) +from .contracts import ( + KernelCommandType, + KernelDiagnosticCode, + KernelEventKind, + KernelIntent, + KernelOutcome, + KernelSeverity, + KernelTransition, + TradeSide, + TradeSlot, + TradeStage, + VenueEvent, + VenueEventStatus, + VenueOrder, + VenueOrderStatus, +) +from .journal import ClickHouseKernelJournal, KernelJournal, MemoryKernelJournal +from .rust_backend import ExecutionKernel +from .bingx_venue import BingxVenueAdapter +from .launcher import DITAv2LauncherBundle, LauncherVenueMode, LauncherZincMode, build_launcher_bundle +from .projection import HazelcastProjection, build_position_state_row, build_projection +from .venue import VenueAdapter +from .mock_venue import MockVenueAdapter, MockVenueScenario +from .zinc_plane import InMemoryZincPlane, ZincPlane +from .real_zinc_plane import RealZincPlane, RealZincUnavailable +from .real_control_plane import RealZincControlPlane, RealZincUnavailable as RealZincControlUnavailable + +__all__ = [ + "AccountProjection", + "AccountSnapshot", + "BackendMode", + "BingxVenueAdapter", + "ClickHouseKernelJournal", + "ControlPlane", + "ControlUpdate", + "DITAv2LauncherBundle", + "build_control_plane", + "build_launcher_bundle", + "ExecutionKernel", + "HazelcastProjection", + "build_projection", + "InMemoryControlPlane", + "InMemoryZincPlane", + "KernelCommandType", + "KernelDiagnosticCode", + "KernelControlSnapshot", + "KernelEventKind", + "KernelIntent", + "KernelJournal", + "KernelMode", + "KernelOutcome", + "KernelSeverity", + "KernelTransition", + "KernelVerbosity", + "MemoryKernelJournal", + "MirroredControlPlane", + "MockVenueAdapter", + "MockVenueScenario", + "LauncherVenueMode", + "LauncherZincMode", + "RealZincPlane", + "RealZincControlPlane", + "RealZincControlUnavailable", + "RealZincUnavailable", + "TradeSide", + "TradeSlot", + "TradeStage", + "VenueAdapter", + "VenueEvent", + "VenueEventStatus", + "VenueOrder", + "VenueOrderStatus", + "ZincPlane", + "ZincControlPlane", + "build_position_state_row", +] diff --git a/prod/clean_arch/dita_v2/_build_pink_bodies.py b/prod/clean_arch/dita_v2/_build_pink_bodies.py new file mode 100644 index 0000000..48b1526 --- /dev/null +++ b/prod/clean_arch/dita_v2/_build_pink_bodies.py @@ -0,0 +1,337 @@ +import sys, re +sys.path.insert(0, '/mnt/dolphinng5_predict') + +fpath = '/mnt/dolphinng5_predict/prod/tests/test_pink_bingx_dita_live_e2e.py' +with open(fpath) as f: + content = f.read() + +# ===== Collect all existing body names ===== +existing_bodies = re.findall(r'async def _body_(\w+)', content) +seen = set() +unique_bodies = [] +for b in existing_bodies: + if b not in seen: + seen.add(b) + unique_bodies.append(b) +print(f"Existing: {len(unique_bodies)} bodies") + +# ===== New bodies ===== +new_bodies = [] +new_params = [] + +def B(name, lines): + new_bodies.append(f"async def _body_{name}(k, symbol, p):\n") + for l in lines: + new_bodies.append(f" {l}\n") + new_params.append(f' pytest.param("{name}", _body_{name}, id="{name}"),') + +# ===== 1. Real reconcile: fresh kernel from old slot state ===== +B("fresh_kernel_reconcile_entry", [ + 'tid = f"fk-{int(__import__(\"time\").time()*1000)}"', + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.8)", + "# Snapshot slot state, build fresh kernel, reconcile", + "slot_data = k.slot(0).to_dict()", + "cb = k.account.snapshot.capital", + "fresh = _build_fresh_kernel_from_slot(slot_data, ic=cb)", + "k2 = fresh.runtime.kernel", + "# The fresh kernel should see the same slot state", + "s = k2.slot(0)", + 'assert not s.is_free(), f"fresh kernel slot should not be free: {s.fsm_state}"', + "assert s.trade_id == tid, f\"trade_id mismatch: {s.trade_id} vs {tid}\"", + "# Exit on the fresh kernel", + "_si(k2, E.EXIT, tid, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(0.5)", + "assert k2.slot(0).is_free(), \"fresh kernel slot not free after exit\"", + "# Original kernel capital should match", + 'assert abs(k2.account.snapshot.capital - cb) < 0.01, f"capital drift: {k2.account.snapshot.capital} vs {cb}"', +]) + +B("fresh_kernel_reconcile_after_cancel", [ + 'tid = f"fkc-{int(__import__(\"time\").time()*1000)}"', + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.3)", + 'r = _si(k, E.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + "# Reconcile onto fresh kernel from cancelled state", + "slot_data = k.slot(0).to_dict()", + "cb = k.account.snapshot.capital", + "fresh = _build_fresh_kernel_from_slot(slot_data, ic=cb)", + "k2 = fresh.runtime.kernel", + "# Cancelled slot should be free", + 'assert k2.slot(0).is_free(), f"cancelled slot not free: {k2.slot(0).fsm_state}"', +]) + +B("fresh_kernel_reconcile_after_exit", [ + 'tid = f"fkx-{int(__import__(\"time\").time()*1000)}"', + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.8)", + "_si(k, E.EXIT, tid, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(0.5)", + "# Reconcile onto fresh kernel from closed state", + "slot_data = k.slot(0).to_dict()", + "cb = k.account.snapshot.capital", + "fresh = _build_fresh_kernel_from_slot(slot_data, ic=cb)", + "k2 = fresh.runtime.kernel", + 'assert k2.slot(0).is_free(), f"closed slot not free: {k2.slot(0).fsm_state}"', + 'assert k2.slot(0).closed, "slot should be marked closed"', +]) + +B("fresh_kernel_reconcile_partial_exit", [ + 'tid = f"fkp-{int(__import__(\"time\").time()*1000)}"', + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.002, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.8)", + "_si(k, E.EXIT, tid, symbol, 'SHORT', p*0.995, 0.001, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.5)", + "# Reconcile mid-trade (one leg exited, one remaining)", + "slot_data = k.slot(0).to_dict()", + "cb = k.account.snapshot.capital", + "fresh = _build_fresh_kernel_from_slot(slot_data, ic=cb)", + "k2 = fresh.runtime.kernel", + "# Remaining leg should still be open", + 's = k2.slot(0)', + 'assert not s.is_free(), f"partial-exit slot should not be free: {s.fsm_state}"', + 'assert s.realized_pnl != 0 or s.size > 0, "partial-exit slot should have remaining position or realized PnL"', + "# Exit remaining leg on fresh kernel", + "_si(k2, E.EXIT, tid, symbol, 'SHORT', p*0.993, 0.001, exit_leg_ratios=(1.0,)); await asyncio.sleep(0.5)", + 'assert k2.slot(0).is_free(), "slot not free after final exit on fresh kernel"', +]) + +# ===== 2. Cross-slot portfolio accounting ===== +B("cross_slot_portfolio_short_long", [ + 't0 = f"psl0-{int(__import__(\"time\").time()*1000)}"', + 't1 = f"psl1-{int(__import__(\"time\").time()*1000)}"', + "cb = k.account.snapshot.capital", + "_si(k, E.ENTER, t0, symbol, 'SHORT', p, 0.001, slot_id=0); await asyncio.sleep(0.4)", + "_si(k, E.ENTER, t1, symbol, 'LONG', p, 0.001, slot_id=1); await asyncio.sleep(0.4)", + "# Verify both slots are open", + 'assert not k.slot(0).is_free(), "slot 0 should be open"', + 'assert not k.slot(1).is_free(), "slot 1 should be open"', + "# Verify PnL tracking per slot", + "rp0 = k.slot(0).realized_pnl; up0 = k.slot(0).unrealized_pnl", + "rp1 = k.slot(1).realized_pnl; up1 = k.slot(1).unrealized_pnl", + "expected = cb + rp0 + up0 + rp1 + up1", + "actual = k.account.snapshot.capital", + 'assert abs(actual - expected) < 0.01, f"portfolio misalignment: cap={actual} expected={expected} rp0={rp0} up0={up0} rp1={rp1} up1={up1}"', + "# Exit slot 0", + "_si(k, E.EXIT, t0, symbol, 'SHORT', p*0.995, 0.001, slot_id=0); await asyncio.sleep(0.4)", + "assert k.slot(0).is_free(), \"slot 0 should be free after exit\"", + "# Exit slot 1", + "_si(k, E.EXIT, t1, symbol, 'LONG', p*1.005, 0.001, slot_id=1); await asyncio.sleep(0.4)", + "assert k.slot(1).is_free(), \"slot 1 should be free after exit\"", +]) + +# ===== 3. KernelOutcome inspection ===== +B("outcome_inspect_entry", [ + 'tid = f"oi-{int(__import__(\"time\").time()*1000)}"', + "r = _si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.8)", + "# Inspect outcome of ENTER", + "_assert_accepted(r, 'entry')", + "info = _inspect_outcome(r, 'entry')", + 'assert r.accepted, f"entry not accepted: {info}"', + 'assert r.trade_id == tid, f"trade_id mismatch: {r.trade_id} vs {tid}"', + 'assert r.slot_id == 0, f"slot_id: {r.slot_id}"', + "# transitions should exist", + 'assert len(info["transitions"]) > 0, f"no transitions in outcome: {info}"', + 'assert info["diagnostic"] == "OK", f"diagnostic not OK: {info}"', + "# Exit and inspect", + 'r2 = _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', + "_assert_accepted(r2, 'exit')", + 'info2 = _inspect_outcome(r2, "exit")', + 'assert len(info2["transitions"]) > 0, f"no exit transitions: {info2}"', + 'assert info2["diagnostic"] == "OK", f"exit diagnostic: {info2}"', +]) + +B("outcome_inspect_rejection", [ + 'tid = f"or-{int(__import__(\"time\").time()*1000)}"', + 'tid2 = f"or2-{int(__import__(\"time\").time()*1000)}"', + "r1 = _si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.3)", + "_assert_accepted(r1, 'first entry')", + "# Second entry on same slot should be SLOT_BUSY", + "r2 = _si(k, E.ENTER, tid2, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.3)", + "_assert_rejected(r2, 'SLOT_BUSY', 'double entry')", + "# Verify transition trace shows the rejection", + "info = _inspect_outcome(r2, 'double entry')", + 'assert not r2.accepted, f"second entry should be rejected: {info}"', + "# Exit normally", + "_si(k, E.EXIT, tid, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(0.5)", +]) + +B("outcome_inspect_exit_on_idle", [ + 'tid = f"oei-{int(__import__(\"time\").time()*1000)}"', + "# Exit on idle slot", + "r = _si(k, E.EXIT, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.3)", + "_assert_rejected(r, 'INVALID_FSM_TRANSITION', 'exit on idle')", + 'info = _inspect_outcome(r, "exit on idle")', + 'assert not r.accepted, f"exit on idle should be rejected: {info}"', + "# Then do a normal trade", + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +# ===== 4. Duplicate event dedup ===== +B("dedup_duplicate_fill_event", [ + 'tid = f"dd-{int(__import__(\"time\").time()*1000)}"', + "r = _si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.8)", + "_assert_accepted(r, 'entry')", + "# Inject a duplicate FULL_FILL VenueEvent manually", + "# Build an event that mirrors the slot's current active order", + "sl = k.slot(0)", + 'ao = sl.active_entry_order if sl.active_entry_order else sl.active_exit_order', + "if ao:", + " dup = VenueEvent(", + " timestamp=__import__('datetime').datetime.now(__import__('datetime').timezone.utc),", + ' event_id="dedup-test-99999",', + ' trade_id=tid, slot_id=0,', + ' kind=KernelEventKind.FULL_FILL,', + ' status=VenueEventStatus.FILLED,', + " venue_order_id=ao.venue_order_id,", + " venue_client_id=ao.venue_client_id,", + " side=sl.side,", + " asset=symbol,", + " price=p,", + " size=0.001, filled_size=0.001, remaining_size=0.0,", + ' reason="dedup_test",', + " )", + " r2 = k.on_venue_event(dup)", + " _assert_accepted(r2, 'dedup_fill')", + ' info = _inspect_outcome(r2, "dedup_fill")', + ' assert len(info["event_kinds"]) == 0 or info["event_kinds"] == ["ORDER_ACK"], f"duplicate fill should produce no events: {info}"', + "# Exit", + "_si(k, E.EXIT, tid, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(0.5)", +]) + +# ===== 5. Fill-price divergence ===== +B("fill_price_divergence_1pct", [ + 'tid = f"fd-{int(__import__(\"time\").time()*1000)}"', + "# Enter SHORT at market", + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.8)", + "# Force the kernel's slot to see a divergent fill price via on_venue_event replay", + "sl = k.slot(0)", + 'ao = sl.active_entry_order', + "if ao and sl.fsm_state not in ('IDLE', 'CLOSED'):", + " divergent_price = p * 1.01 # 1% worse than reference", + " div_event = VenueEvent(", + " timestamp=__import__('datetime').datetime.now(__import__('datetime').timezone.utc),", + ' event_id="divergence-test",', + ' trade_id=tid, slot_id=0,', + ' kind=KernelEventKind.FULL_FILL,', + ' status=VenueEventStatus.FILLED,', + " venue_order_id=ao.venue_order_id if ao else \"\"," , + " venue_client_id=ao.venue_client_id if ao else \"\"," , + " side=sl.side,", + " asset=symbol,", + " price=divergent_price,", + " size=0.001, filled_size=0.001, remaining_size=0.0,", + ' reason="divergence_test",', + " )", + " k.on_venue_event(div_event); await asyncio.sleep(0.3)", + "# Exit at market", + "_si(k, E.EXIT, tid, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(0.5)", +]) + +# ===== 6. Negative-capital boundary ===== +B("neg_cap_entry_rejected", [ + 'tid = f"nc-{int(__import__(\"time\").time()*1000)}"', + "# Kernel should reject ENTER if capital cannot cover margin", + "# With tiny capital, even a tiny trade should be checked", + "k.account.snapshot.capital = 0.0", + "r = _si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.3)", + 'info = _inspect_outcome(r, "neg_cap")', + '# May be rejected or accepted depending on kernel margin logic', + '# At minimum, kernel should not crash', + "# Restore capital and do normal trade", + "k.account.snapshot.capital = 25000.0", + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +# ===== 7. Sub-sample cross-application ===== +# Apply the new assertion patterns to a basic entry/exit +B("cross_sample_basic_entry_exit_outcome", [ + 'tid = f"cs-{int(__import__(\"time\").time()*1000)}"', + "cb = k.account.snapshot.capital; k._start_cap = cb", + "r1 = _si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.8)", + "_assert_accepted(r1, 'cs_entry')", + "_check_slot_accounting(k, 'cs_after_entry')", + "r2 = _si(k, E.EXIT, tid, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(0.5)", + "_assert_accepted(r2, 'cs_exit')", + "_check_slot_accounting(k, 'cs_after_exit')", + "ca = k.account.snapshot.capital", + "max_change = max(1.0, cb * 0.10)", + 'assert cb - ca < max_change, f"cs: cap shrunk {cb} -> {ca}"', +]) + +B("cross_sample_cancel_reenter_outcome", [ + 't1 = f"csc-{int(__import__(\"time\").time()*1000)}"', + 't2 = f"csc2-{int(__import__(\"time\").time()*1000)}"', + "cb = k.account.snapshot.capital; k._start_cap = cb", + "r1 = _si(k, E.ENTER, t1, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.3)", + "_assert_accepted(r1, 'cs_cancel_entry')", + "r2 = _si(k, E.CANCEL, t1, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.3)", + "if r2.accepted:", + ' info = _inspect_outcome(r2, "cs_cancel")', + "if not k.slot(0).is_free():", + " _si(k, E.EXIT, t1, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(0.3)", + "_check_slot_accounting(k, 'cs_after_cancel')", + 'assert k.slot(0).is_free(), "slot should be free after cancel"', + "r3 = _si(k, E.ENTER, t2, symbol, 'SHORT', p*0.997, 0.001); await asyncio.sleep(0.8)", + "_assert_accepted(r3, 'cs_reenter')", + "_check_slot_accounting(k, 'cs_after_reenter')", + "r4 = _si(k, E.EXIT, t2, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(0.5)", + "_assert_accepted(r4, 'cs_reenter_exit')", + "_check_slot_accounting(k, 'cs_after_reenter_exit')", +]) + +B("cross_sample_multi_leg_outcome", [ + 'tid = f"csm-{int(__import__(\"time\").time()*1000)}"', + "cb = k.account.snapshot.capital; k._start_cap = cb", + "r = _si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.002, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.8)", + "_assert_accepted(r, 'cs_ml_entry')", + "r = _si(k, E.EXIT, tid, symbol, 'SHORT', p*0.995, 0.001, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.4)", + "_assert_accepted(r, 'cs_ml_leg1')", + "_check_slot_accounting(k, 'cs_ml_after_leg1')", + "r = _si(k, E.EXIT, tid, symbol, 'SHORT', p*0.993, 0.001, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.4)", + "_assert_accepted(r, 'cs_ml_leg2')", + "_check_slot_accounting(k, 'cs_ml_after_leg2')", +]) + +B("cross_sample_leverage_tight_bounds", [ + 'tid = f"csl-{int(__import__(\"time\").time()*1000)}"', + "cb = k.account.snapshot.capital; k._start_cap = cb", + "r_ent = _si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001, leverage=2); await asyncio.sleep(0.8)", + "_assert_accepted(r_ent, 'cs_lev_entry')", + "_check_slot_accounting(k, 'cs_lev_after_entry')", + "r_ex = _si(k, E.EXIT, tid, symbol, 'SHORT', p*0.995, 0.001, leverage=2); await asyncio.sleep(0.5)", + "_assert_accepted(r_ex, 'cs_lev_exit')", + "_check_slot_accounting(k, 'cs_lev_after_exit')", + "ca = k.account.snapshot.capital", + "max_change = max(1.0, cb * 0.10)", + 'assert cb - ca < max_change, f"cs_lev: cap shrunk {cb} -> {ca}"', +]) + +# ===== BUILD ===== +body_block = "".join(new_bodies) +param_block = "\n".join(new_params) + +# Insert new bodies before SCENARIOS marker +marker = "SCENARIOS = [" +idx = content.index(marker) +# Insert after the last body section ends (blank line before SCENARIOS) +tail_start = content.rindex("\n\n", 0, idx) + 2 +head = content[:tail_start] +tail = content[tail_start:] + +with_bodies = head + body_block + tail + +# Find SCENARIOS closing bracket and append new param entries +scenarios_open = with_bodies.index(marker) +close_bracket = with_bodies.index("]", scenarios_open) + +final = with_bodies[:close_bracket] + "\n" + param_block + "\n" + with_bodies[close_bracket:] + +# Compact blank lines +final = re.sub(r'\n{3,}', '\n\n', final) + +with open(fpath, 'w') as f: + f.write(final) + +import py_compile +py_compile.compile(fpath, doraise=True) + +body_count = final.count("async def _body_") +param_count = final.count("pytest.param(") +print(f"Bodies: {body_count}, Params: {param_count}") +print("Parts 5: Compiles OK") diff --git a/prod/clean_arch/dita_v2/_build_pink_extended.py b/prod/clean_arch/dita_v2/_build_pink_extended.py new file mode 100644 index 0000000..5638829 --- /dev/null +++ b/prod/clean_arch/dita_v2/_build_pink_extended.py @@ -0,0 +1,170 @@ +import sys +sys.path.insert(0, '/mnt/dolphinng5_predict') + +fpath = '/mnt/dolphinng5_predict/prod/tests/test_pink_bingx_dita_live_e2e.py' +with open(fpath) as f: + content = f.read() + +# === PART 1: Expand imports === +old_imports = """from prod.clean_arch.dita_v2.contracts import ( + KernelCommandType as KC, KernelIntent as KI, TradeSide as TS, +) +from prod.clean_arch.ports.data_feed import MarketSnapshot""" + +new_imports = """from prod.clean_arch.dita_v2.contracts import ( + KernelCommandType as KC, KernelIntent as KI, TradeSide as TS, + VenueEvent, VenueEventStatus, KernelEventKind, + TradeStage, KernelDiagnosticCode, KernelSeverity, + KernelOutcome, KernelTransition, TradeSlot, VenueOrder, +) +from prod.clean_arch.ports.data_feed import MarketSnapshot""" + +content = content.replace(old_imports, new_imports) +print("1: imports OK") + +# === PART 2: Expand _build_rb with helpers === +old_build = "def _build_rb(ic: float = 25000.0, max_slots: int = 1) -> RB:\n cfg = _build_config(ic)\n b = build_launcher_bundle(venue_mode=\"BINGX\", max_slots=max_slots, bingx_config=cfg)\n k = b.kernel; k.account.snapshot.capital = ic; k.account.snapshot.peak_capital = ic; k.account.snapshot.equity = ic\n class Shim:\n def __init__(self, k): self.kernel = k\n async def connect(self, initial_capital=0): self.kernel.venue.connect()\n async def disconnect(self):\n try: self.kernel.venue.disconnect()\n except: pass\n return RB(runtime=Shim(k), config=cfg)" + +new_build = """def _build_rb(ic: float = 25000.0, max_slots: int = 1) -> RB: + cfg = _build_config(ic) + b = build_launcher_bundle(venue_mode=\"BINGX\", max_slots=max_slots, bingx_config=cfg) + k = b.kernel; k.account.snapshot.capital = ic; k.account.snapshot.peak_capital = ic; k.account.snapshot.equity = ic + class Shim: + def __init__(self, k): self.kernel = k + async def connect(self, initial_capital=0): self.kernel.venue.connect() + async def disconnect(self): + try: self.kernel.venue.disconnect() + except: pass + return RB(runtime=Shim(k), config=cfg) + +def _build_portfolio_rb(ic: float = 25000.0, max_slots: int = 2) -> RB: + return _build_rb(ic=ic, max_slots=max_slots) + +def _inspect_outcome(r, label): + info = { + \"accepted\": r.accepted, + \"state\": r.state.value if r.state else \"\", + \"diagnostic\": r.diagnostic_code.value if r.diagnostic_code else \"\", + \"severity\": r.severity.value if r.severity else \"\", + \"transitions\": [(t.prev_state.value, t.next_state.value) for t in (r.transitions or ())], + \"event_kinds\": [e.kind.value for e in (r.emitted_events or ())], + \"details\": dict(r.details or {}), + } + return info + +def _assert_accepted(r, label): + info = _inspect_outcome(r, label) + assert r.accepted, f\"{label}: intent rejected - diag={info['diagnostic']} state={info['state']} detail={info['details']}\" + +def _assert_rejected(r, expected_diag, label): + info = _inspect_outcome(r, label) + assert not r.accepted, f\"{label}: expected rejection but got accepted state={info['state']}\" + assert info['diagnostic'] == expected_diag, f\"{label}: expected diag={expected_diag} got {info['diagnostic']} detail={info['details']}\" + +def _check_slot_accounting(k, label): + start_cap = getattr(k, '_start_cap', None) + if start_cap is None: + return + total_rp = sum(k.slot(i).realized_pnl for i in range(k.max_slots)) + total_up = sum(k.slot(i).unrealized_pnl for i in range(k.max_slots)) + expected = start_cap + total_rp + total_up + actual = k.account.snapshot.capital + diff = abs(actual - expected) + assert diff < 0.01, f\"{label}: accounting mismatch cap={actual} exp={expected} rp={total_rp} upnl={total_up} diff={diff}\" + +def _check_open_orders(c, vs): + r = __import__('asyncio').run(c._request_json( + \"GET\", \"/openApi/swap/v2/trade/openOrders\", + {\"symbol\": vs}, signed=True + )) + data = r if isinstance(r, list) else (r.get(\"data\") or r.get(\"orders\") or []) + return [o for o in data if isinstance(o, dict)] + +async def _verify_full(c, vs): + rs = await _contract_rows(c) + tr = [r for r in rs if str(r.get(\"symbol\",\"\")).upper().replace(\"-\",\"\") == vs.replace(\"-\",\"\").upper()] + ts = sum(abs(float(r.get(\"positionAmt\",r.get(\"positionQty\",0)) or 0)) for r in tr) + flat = ts < 1e-8 + oos = _check_open_orders(c, vs) + no_orders = len(oos) == 0 + err = \"\" + if not flat: err += f\"pos_open: {tr} \" + if not no_orders: err += f\"open_orders: {oos} \" + return {\"symbol\": vs, \"flat\": flat, \"no_orders\": no_orders, \"error\": err.strip()} + +def _build_fresh_kernel_from_slot(slot_data, ic=25000.0): + from prod.clean_arch.dita_v2.rust_backend import _slot_from_payload + cfg = _build_config(ic) + b = build_launcher_bundle(venue_mode=\"BINGX\", max_slots=1, bingx_config=cfg) + k = b.kernel; k.account.snapshot.capital = ic; k.account.snapshot.peak_capital = ic; k.account.snapshot.equity = ic + restored = _slot_from_payload(slot_data) + k.reconcile_from_slots([restored]) + class Shim: + def __init__(self, k): self.kernel = k + async def connect(self, initial_capital=0): self.kernel.venue.connect() + async def disconnect(self): + try: self.kernel.venue.disconnect() + except: pass + return RB(runtime=Shim(k), config=cfg)""" + +content = content.replace(old_build, new_build) +print("2: build/helpers OK") + +# === PART 3: Update _verify to check open orders === +old_verify = "async def _verify(c, vs):\n rs = await _contract_rows(c)\n tr = [r for r in rs if str(r.get(\"symbol\",\"\")).upper().replace(\"-\",\"\") == vs.replace(\"-\",\"\").upper()]\n ts = sum(abs(float(r.get(\"positionAmt\",r.get(\"positionQty\",0)) or 0)) for r in tr)\n flat = ts < 1e-8\n return VR(symbol=vs, positions_flat=flat, error=\"\" if flat else f\"open: {tr}\")" + +new_verify = "async def _verify(c, vs):\n rs = await _contract_rows(c)\n tr = [r for r in rs if str(r.get(\"symbol\",\"\")).upper().replace(\"-\",\"\") == vs.replace(\"-\",\"\").upper()]\n ts = sum(abs(float(r.get(\"positionAmt\",r.get(\"positionQty\",0)) or 0)) for r in tr)\n flat = ts < 1e-8\n oos = _check_open_orders(c, vs)\n no_orders = len(oos) == 0\n err = \"\"\n if not flat: err += f\"pos_open: {tr} \"\n if not no_orders: err += f\"open_orders: {oos} \"\n return VR(symbol=vs, positions_flat=flat and no_orders, error=err.strip())" + +content = content.replace(old_verify, new_verify) +print("3: verify OK") + +# === PART 4: Replace _run === +# Find old _run and replace +old_run_pat = "async def _run(bundle, client, body_fn, label, ic):" + +# Find the entire old run function bounds +idx = content.index(old_run_pat) +run_end = content.index(" finally:", idx) +run_end = content.index("\n\n", run_end) + 2 + +new_run = """async def _run(bundle, client, body_fn, label, ic): + k = bundle.runtime.kernel + sym = await _pick_sym(k, client) + snap, vsym = await _snap(client, sym) + await bundle.runtime.connect(initial_capital=ic) + p = float(snap.price) + try: + for si in range(k.max_slots): + if not k.slot(si).is_free(): + _flatten(k, sym, p*0.99 if si == 0 else p*1.005, f"{label}-pre-{si}") + await asyncio.sleep(0.3) + k._start_cap = k.account.snapshot.capital + cb = k.account.snapshot.capital + await body_fn(k, sym, p) + ca = k.account.snapshot.capital + assert ca > 0, f"Capital zero: {ca}" + max_change = max(1.0, cb * 0.10) + assert cb - ca < max_change, f"Capital shrunk beyond tolerance: {cb} -> {ca} (limit={max_change})" + total_rp = sum(k.slot(i).realized_pnl for i in range(k.max_slots)) + if abs(total_rp) > 0.0001: + assert abs(total_rp) < abs(cb - ca) + 0.01, f"{label}: rp={total_rp} != cap_change={cb-ca}" + for si in range(k.max_slots): + if not k.slot(si).is_free(): + _flatten(k, sym, p*0.99 if si == 0 else p*1.005, f"{label}-post-{si}") + await asyncio.sleep(1.0) + _throttle(3.0) + return await _verify(client, vsym) + finally: + await bundle.runtime.disconnect() + +""" + +content = content[:idx] + new_run + content[run_end:] +print("4: run OK") + +with open(fpath, 'w') as f: + f.write(content) + +import py_compile +py_compile.compile(fpath, doraise=True) +print("Parts 1-4: Compiles OK") diff --git a/prod/clean_arch/dita_v2/_gen_test.py b/prod/clean_arch/dita_v2/_gen_test.py new file mode 100644 index 0000000..46fc4ff --- /dev/null +++ b/prod/clean_arch/dita_v2/_gen_test.py @@ -0,0 +1,1244 @@ +"""Generate the complete pink e2e test file.""" +import sys +sys.path.insert(0, '/mnt/dolphinng5_predict') + +fpath = '/mnt/dolphinng5_predict/prod/tests/test_pink_bingx_dita_live_e2e.py' + +lines = [] + +def emit(s=""): + lines.append(s) + +# ---- IMPORTS ---- +emit('#!/usr/bin/env python3') +emit('"""PINK DITAv2 Live BingX Testnet E2E — conceptual gap coverage."""') +emit('from __future__ import annotations') +emit('import asyncio, json, os, socket, time, urllib.request') +emit('import urllib.parse') +emit('from dataclasses import dataclass') +emit('from typing import Any, Optional') +emit('import pytest') +emit('from prod.bingx.http import BingxHttpClient') +emit('from prod.bingx.config import BingxExecClientConfig, BingxEnvironment') +emit('from prod.clean_arch.dita_v2.launcher import build_launcher_bundle') +emit('from prod.clean_arch.dita_v2.contracts import (') +emit(' KernelCommandType as KC, KernelIntent as KI, TradeSide as TS,') +emit(' VenueEvent, VenueEventStatus, KernelEventKind,') +emit(' TradeStage, KernelDiagnosticCode, KernelSeverity,') +emit(' KernelOutcome, KernelTransition, TradeSlot, VenueOrder,') +emit(')') +emit('from prod.clean_arch.ports.data_feed import MarketSnapshot') +emit('E = KC') +emit('') +emit('# Force IPv4') +emit('_orig_gai = socket.getaddrinfo') +emit('def _ipv4_gai(host, port, family=0, type=0, proto=0, flags=0):') +emit(' return _orig_gai(host, port, socket.AF_INET, type, proto, flags)') +emit('socket.getaddrinfo = _ipv4_gai') +emit('') +emit('_last_finish: float = 0.0') +emit('def _throttle(min_gap: float = 3.0) -> None:') +emit(' global _last_finish') +emit(' now = __import__("time").time()') +emit(' elapsed = now - _last_finish') +emit(' if elapsed < min_gap:') +emit(' __import__("time").sleep(min_gap - elapsed)') +emit(' _last_finish = __import__("time").time()') +emit('') + +# ---- HELPERS ---- +emit('class VR:') +emit(' def __init__(self, symbol, positions_flat, error):') +emit(' self.symbol = symbol; self.positions_flat = positions_flat; self.error = error') +emit('') +emit('class RB:') +emit(' def __init__(self, runtime=None, config=None):') +emit(' self.runtime = runtime; self.config = config') +emit('') +emit('def _build_config(ic: float = 25000.0) -> BingxExecClientConfig:') +emit(' return BingxExecClientConfig(environment=BingxEnvironment.TESTNET,') +emit(' api_key=os.environ["BINGX_API_KEY"], secret_key=os.environ["BINGX_SECRET_KEY"],') +emit(' testnet=True, recv_window_ms=5000, default_leverage=1, initial_capital_usdt=ic)') +emit('') +emit('def _build_rb(ic: float = 25000.0, max_slots: int = 1) -> RB:') +emit(' cfg = _build_config(ic)') +emit(' b = build_launcher_bundle(venue_mode="BINGX", max_slots=max_slots, bingx_config=cfg)') +emit(' k = b.kernel; k.account.snapshot.capital = ic') +emit(' k.account.snapshot.peak_capital = ic; k.account.snapshot.equity = ic') +emit(' class Shim:') +emit(' def __init__(self, k): self.kernel = k') +emit(' async def connect(self, initial_capital=0): self.kernel.venue.connect()') +emit(' async def disconnect(self):') +emit(' try: self.kernel.venue.disconnect()') +emit(' except: pass') +emit(' return RB(runtime=Shim(k), config=cfg)') +emit('') +emit('def _inspect_outcome(r, label):') +emit(' return dict(accepted=r.accepted, state=r.state.value if r.state else "",') +emit(' diagnostic=r.diagnostic_code.value if r.diagnostic_code else "",') +emit(' severity=r.severity.value if r.severity else "",') +emit(' transitions=[(t.prev_state.value, t.next_state.value) for t in (r.transitions or ())],') +emit(' event_kinds=[e.kind.value for e in (r.emitted_events or ())],') +emit(' details=dict(r.details or {}))') +emit('') +emit('def _assert_accepted(r, label):') +emit(' info = _inspect_outcome(r, label)') +emit(' assert r.accepted, f"{label}: intent rejected diag={info[chr(34)+chr(34)]diagnostic[chr(34)+chr(34)]} state={info[chr(34)+chr(34)]state[chr(34)+chr(34)]} detail={info[chr(34)+chr(34)]details[chr(34)+chr(34)]}"') +emit('') +emit('def _assert_rejected(r, expected_diag, label):') +emit(' info = _inspect_outcome(r, label)') +emit(' assert not r.accepted, f"{label}: expected rejection but got accepted state={info[chr(34)+chr(34)]state[chr(34)+chr(34)]}"') +emit(' assert info["diagnostic"] == expected_diag, f"{label}: expected {expected_diag} got {info[chr(34)+chr(34)]diagnostic[chr(34)+chr(34)]}"') +emit('') +emit('def _check_slot_accounting(k, label):') +emit(' sc = getattr(k, "_start_cap", None)') +emit(' if sc is None: return') +emit(' trp = sum(k.slot(i).realized_pnl for i in range(k.max_slots))') +emit(' tup = sum(k.slot(i).unrealized_pnl for i in range(k.max_slots))') +emit(' expected = sc + trp + tup') +emit(' actual = k.account.snapshot.capital') +emit(' assert abs(actual - expected) < 0.01, f"{label}: acct mismatch cap={actual} exp={expected} rp={trp} upnl={tup}"') +emit('') +emit('def _check_open_orders(c, vs):') +emit(' import asyncio') +emit(' r = asyncio.run(c._request_json("GET", "/openApi/swap/v2/trade/openOrders", {"symbol": vs}, signed=True))') +emit(' data = r if isinstance(r, list) else (r.get("data") or r.get("orders") or [])') +emit(' return [o for o in data if isinstance(o, dict)]') +emit('') +emit('def _build_fresh_kernel_from_slot(slot_data, ic=25000.0):') +emit(' from prod.clean_arch.dita_v2.rust_backend import _slot_from_payload') +emit(' cfg = _build_config(ic)') +emit(' b = build_launcher_bundle(venue_mode="BINGX", max_slots=1, bingx_config=cfg)') +emit(' k = b.kernel; k.account.snapshot.capital = ic') +emit(' k.account.snapshot.peak_capital = ic; k.account.snapshot.equity = ic') +emit(' restored = _slot_from_payload(slot_data)') +emit(' k.reconcile_from_slots([restored])') +emit(' class Shim:') +emit(' def __init__(self, k): self.kernel = k') +emit(' async def connect(self, ic=0): self.kernel.venue.connect()') +emit(' async def disconnect(self):') +emit(' try: self.kernel.venue.disconnect()') +emit(' except: pass') +emit(' return RB(runtime=Shim(k), config=cfg)') +emit('') + +# ---- EXISTING HELPERS ---- +emit('async def _contract_rows(c):') +emit(' r = await c._request_json("GET", "/openApi/swap/v2/user/positions", {}, signed=True)') +emit(' return r if isinstance(r, list) else (r.get("data") or r.get("positions") or [])') +emit('') +emit('async def _pick_sym(k, c):') +emit(' rs = await _contract_rows(c)') +emit(' oss = {str(r.get("symbol","")).replace("-","").upper() for r in rs}') +emit(' return next((x for x in ["TRXUSDT","XRPUSDT","ADAUSDT","DOGEUSDT"] if x not in oss), "TRXUSDT")') +emit('') +emit('async def _snap(c, sym):') +emit(' pr = await c._request_json("GET", "/openApi/swap/v2/quote/price", {"symbol": sym}, signed=False)') +emit(' d = pr.get("data") or pr; rp = float(d.get("price") or d.get("lastPrice") or 0)') +emit(' return MarketSnapshot(timestamp=__import__("datetime").datetime.now(__import__("datetime").timezone.utc),') +emit(' symbol=sym, price=rp, bid=rp*0.9995, ask=rp*1.0005), sym') +emit('') +emit('async def _verify(c, vs):') +emit(' rs = await _contract_rows(c)') +emit(' tr = [r for r in rs if str(r.get("symbol","")).upper().replace("-","") == vs.replace("-","").upper()]') +emit(' ts = sum(abs(float(r.get("positionAmt",r.get("positionQty",0)) or 0)) for r in tr)') +emit(' flat = ts < 1e-8') +emit(' oos = _check_open_orders(c, vs)') +emit(' no_orders = len(oos) == 0') +emit(' err = ""') +emit(' if not flat: err += f"pos_open: {tr} "') +emit(' if not no_orders: err += f"open_orders: {oos} "') +emit(' return VR(symbol=vs, positions_flat=flat and no_orders, error=err.strip())') +emit('') +emit('def _si(k, act, tid, asset, side_str, price, size, **kw):') +emit(' ds = TS.SHORT if side_str.upper() == "SHORT" else TS.LONG') +emit(' slot_id = kw.pop("slot_id", 0)') +emit(' return k.process_intent(KI(timestamp=__import__("datetime").datetime.now(__import__("datetime").timezone.utc),') +emit(' intent_id=tid, trade_id=tid, slot_id=slot_id, asset=asset, side=ds, action=act,') +emit(' reference_price=price, target_size=size, leverage=kw.pop("leverage",1.0),') +emit(' exit_leg_ratios=kw.pop("exit_leg_ratios",(1.0,)),') +emit(' reason=kw.pop("reason",f"auto_{act.value.lower()}"), metadata=kw))') +emit('') +emit('def _flatten(k, sym, price, label, slot_id=0):') +emit(' if k.slot(slot_id).is_free(): return') +emit(' ts = int(time.time()*1000)') +emit(' _si(k, E.EXIT, f"fl{label}-{ts}", sym, "SHORT", price, 0.001, slot_id=slot_id)') +emit(' if not k.slot(slot_id).is_free():') +emit(' _si(k, E.EXIT, f"fl{label}b-{ts}", sym, "LONG", price, 0.001, slot_id=slot_id)') +emit('') +emit('async def _run(bundle, client, body_fn, label, ic):') +emit(' k = bundle.runtime.kernel') +emit(' sym = await _pick_sym(k, client)') +emit(' snap, vsym = await _snap(client, sym)') +emit(' await bundle.runtime.connect(initial_capital=ic)') +emit(' p = float(snap.price)') +emit(' try:') +emit(' for si in range(k.max_slots):') +emit(' if not k.slot(si).is_free():') +emit(' _flatten(k, sym, p*0.99 if si == 0 else p*1.005, f"{label}-pre-{si}")') +emit(' await asyncio.sleep(0.3)') +emit(' k._start_cap = k.account.snapshot.capital') +emit(' cb = k.account.snapshot.capital') +emit(' await body_fn(k, sym, p)') +emit(' ca = k.account.snapshot.capital') +emit(' assert ca > 0, f"Capital zero: {ca}"') +emit(' max_change = max(1.0, cb * 0.10)') +emit(' assert cb - ca < max_change, f"Capital shrunk beyond tolerance: {cb} -> {ca} (limit={max_change})"') +emit(' total_rp = sum(k.slot(i).realized_pnl for i in range(k.max_slots))') +emit(' if abs(total_rp) > 0.0001:') +emit(' assert abs(total_rp) < abs(cb - ca) + 0.01, f"{label}: rp={total_rp} != cap_change={cb-ca}"') +emit(' for si in range(k.max_slots):') +emit(' if not k.slot(si).is_free():') +emit(' _flatten(k, sym, p*0.99 if si == 0 else p*1.005, f"{label}-post-{si}")') +emit(' await asyncio.sleep(1.0)') +emit(' _throttle(3.0)') +emit(' return await _verify(client, vsym)') +emit(' finally:') +emit(' await bundle.runtime.disconnect()') +emit('') + +# ---- BODY TEMPLATES ---- +# I'll build the body functions from a structured list +bodies = {} # name -> list of code lines + +def B(name, lines): + bodies[name] = lines + +B("simple_entry_exit", [ + 'tid = f"ss-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(1)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(1)', +]) + +B("multi_leg_exit", [ + 'tid = f"ml-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.002, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(1)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(1)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.993, 0.001, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(1)', +]) + +B("cancel_entry_order", [ + 'tid = f"ce-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + '_si(k, E.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(1)', +]) + +B("entry_hold_exit", [ + 'tid = f"eh-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(3)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(1)', +]) + +B("entry_exit_at_loss", [ + 'tid = f"el-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(1)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*1.005, 0.001); await asyncio.sleep(1)', +]) + +B("two_sequential_cycles", [ + 't1 = f"sq1-{int(time.time()*1000)}"; t2 = f"sq2-{int(time.time()*1000)}"', + '_si(k, E.ENTER, t1, symbol, "SHORT", p, 0.001); await asyncio.sleep(1)', + '_si(k, E.EXIT, t1, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(1)', + '_si(k, E.ENTER, t2, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(1)', + '_si(k, E.EXIT, t2, symbol, "SHORT", p*0.99, 0.001); await asyncio.sleep(1)', +]) + +B("entry_then_recover", [ + 'tid = f"r-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(1)', + '_si(k, E.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.5)', + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(1)', +]) + +B("long_entry_exit", [ + 'tid = f"l-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "LONG", p, 0.001); await asyncio.sleep(1)', + '_si(k, E.EXIT, tid, symbol, "LONG", p*1.005, 0.001); await asyncio.sleep(1)', +]) + +B("cancel_idempotent", [ + 'tid = f"ci-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.5)', + '_si(k, E.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + '_si(k, E.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(1)', +]) + +B("double_cancel", [ + 'tid = f"dc-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + '_si(k, E.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + '_si(k, E.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(1)', +]) + +B("cancel_then_exit", [ + 'tid = f"ctx-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.5)', + '_si(k, E.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(1)', +]) + +B("exit_then_cancel_exit", [ + 'tid = f"ecx-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(1)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.3)', + '_si(k, E.CANCEL, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(1)', +]) + +B("exit_then_reentry", [ + 't1 = f"er1-{int(time.time()*1000)}"; t2 = f"er2-{int(time.time()*1000)}"', + '_si(k, E.ENTER, t1, symbol, "SHORT", p, 0.001); await asyncio.sleep(1)', + '_si(k, E.EXIT, t1, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(1)', + '_si(k, E.ENTER, t2, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(1)', + '_si(k, E.EXIT, t2, symbol, "SHORT", p*0.99, 0.001); await asyncio.sleep(1)', +]) + +B("limit_cancel", [ + 'tid = f"lc-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p*0.9, 0.001); await asyncio.sleep(0.3)', + '_si(k, E.CANCEL, tid, symbol, "SHORT", p*0.9, 0.001); await asyncio.sleep(1)', +]) + +B("x4_partial_hold_exit", [ + 'tid = f"x4ph-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.002, exit_leg_ratios=(0.3,1.0)); await asyncio.sleep(1)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.0006, exit_leg_ratios=(0.3,1.0)); await asyncio.sleep(1)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.993, 0.0014, exit_leg_ratios=(0.3,1.0)); await asyncio.sleep(1)', +]) + +B("x4_three_leg", [ + 'tid = f"x4tl-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.003, exit_leg_ratios=(0.25,0.25,1.0)); await asyncio.sleep(1)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.00075, exit_leg_ratios=(0.25,0.25,1.0)); await asyncio.sleep(1)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.993, 0.0015, exit_leg_ratios=(0.25,0.25,1.0)); await asyncio.sleep(1)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.991, 0.00075, exit_leg_ratios=(0.25,0.25,1.0)); await asyncio.sleep(1)', +]) + +B("x4_cancel_fill_partial", [ + 'tid = f"x4cf-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.002, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.5)', + '_si(k, E.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.5)', + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.993, 0.001, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.5)', +]) + +B("x4_rapid_three", [ + "for j in range(3):", + ' tid = f"x4r{j}-{int(time.time()*1000)}"', + ' _si(k, E.ENTER, tid, symbol, "SHORT", p*(1-j*0.003), 0.001); await asyncio.sleep(0.5)', + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995*(1-j*0.003), 0.001); await asyncio.sleep(0.5)', +]) + +B("x4_diff_symbol", [ + "ts = int(time.time()*1000)", + '_si(k, E.ENTER, f"x4ds1-{ts}", symbol, "SHORT", p, 0.001); await asyncio.sleep(0.5)', + '_si(k, E.EXIT, f"x4ds1-{ts}", "ZZZUSDT", "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("x4_alternating", [ + "ts = int(time.time()*1000)", + '_si(k, E.ENTER, f"x4a1-{ts}", symbol, "SHORT", p, 0.001); await asyncio.sleep(0.5)', + '_si(k, E.EXIT, f"x4a1-{ts}", symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', + '_si(k, E.ENTER, f"x4a2-{ts}", symbol, "LONG", p*0.995, 0.001); await asyncio.sleep(0.5)', + '_si(k, E.EXIT, f"x4a2-{ts}", symbol, "LONG", p*1.002, 0.001); await asyncio.sleep(0.5)', +]) + +B("x4_multi_flatten", [ + 'tid = f"x4mf-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + "while not k.slot(0).is_free():", + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.3)', +]) + +B("x4_three_leg_25_50_25", [ + 'tid = f"x4t3-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.002, exit_leg_ratios=(0.25,0.5,1.0)); await asyncio.sleep(1)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.0005, exit_leg_ratios=(0.25,0.5,1.0)); await asyncio.sleep(0.7)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.993, 0.001, exit_leg_ratios=(0.25,0.5,1.0)); await asyncio.sleep(0.7)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.991, 0.0005, exit_leg_ratios=(0.25,0.5,1.0)); await asyncio.sleep(1)', +]) + +B("x4_enter_exit_hold_twice", [ + "for j in range(3):", + ' tid = f"x4ht{j}-{int(time.time()*1000)}"', + ' _si(k, E.ENTER, tid, symbol, "SHORT", p*(1-j*0.002), 0.001); await asyncio.sleep(0.5)', + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995*(1-j*0.002), 0.001); await asyncio.sleep(0.5)', +]) + +B("x4_cancel_then_double_exit", [ + 'tid = f"x4cd-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.002, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.5)', + '_si(k, E.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.5)', + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.993, 0.001, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.5)', +]) + +def make_profit_loss_bodies(): + for side, side_label in [("SHORT", "short"), ("LONG", "long")]: + for pl, pl_label, price_factor in [("profit", "profit", ("0.997" if side == "SHORT" else "1.003")), ("loss", "loss", ("1.003" if side == "SHORT" else "0.997"))]: + for pattern, pat_label in [("basic", "basic"), ("partial", "partial"), ("cancel", "cancel"), ("double_exit", "double_exit")]: + name = f"{pat_label}_{side_label}_{pl}" + lines = [] + tid_expr = f'f"{name[:3]}-{{int(time.time()*1000)}}"' + lines.append(f'tid = {tid_expr}') + if pattern == "basic": + exit_price = f"p*{price_factor}" + lines.append(f'_si(k, E.ENTER, tid, symbol, "{side}", p, 0.001); await asyncio.sleep(0.8)') + lines.append(f'_si(k, E.EXIT, tid, symbol, "{side}", {exit_price}, 0.001); await asyncio.sleep(0.5)') + elif pattern == "partial": + exit1 = f"p*{float(price_factor) ** 1}" if "*" not in str(price_factor) else f"p*{price_factor}" + # Use different prices for two legs + if pl == "profit": + p1, p2 = ("p*0.995", "p*0.993") if side == "SHORT" else ("p*1.005", "p*1.007") + else: + p1, p2 = ("p*1.003", "p*1.005") if side == "SHORT" else ("p*0.997", "p*0.995") + lines.append('_si(k, E.ENTER, tid, symbol, "' + side + '", p, 0.002, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.8)') + lines.append(f'_si(k, E.EXIT, tid, symbol, "{side}", {p1}, 0.001, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.5)') + lines.append(f'_si(k, E.EXIT, tid, symbol, "{side}", {p2}, 0.001, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.5)') + elif pattern == "cancel": + lines.append(f'_si(k, E.ENTER, tid, symbol, "{side}", p, 0.001); await asyncio.sleep(0.5)') + lines.append(f'_si(k, E.CANCEL, tid, symbol, "{side}", p, 0.001); await asyncio.sleep(0.3)') + lines.append("if not k.slot(0).is_free():") + ef = f"p*{price_factor}" + lines.append(f' _si(k, E.EXIT, tid, symbol, "{side}", {ef}, 0.001); await asyncio.sleep(0.5)') + elif pattern == "double_exit": + if side == "SHORT": + p1, p2 = ("p*0.995", "p*0.993") if pl == "profit" else ("p*1.003", "p*1.005") + else: + p1, p2 = ("p*1.005", "p*1.007") if pl == "profit" else ("p*0.997", "p*0.995") + lines.append('_si(k, E.ENTER, tid, symbol, "' + side + '", p, 0.002, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.8)') + lines.append(f'_si(k, E.EXIT, tid, symbol, "{side}", {p1}, 0.001, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.5)') + lines.append(f'_si(k, E.EXIT, tid, symbol, "{side}", {p2}, 0.001, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.5)') + B(name, lines) + +make_profit_loss_bodies() + +# Triple seq +for i in range(4): + name = f"triple_seq_{i}" + B(name, [ + "for j in range(3):", + f' tid = f"ts{i}_{{j}}-{{int(time.time()*1000)}}"', + ' _si(k, E.ENTER, tid, symbol, "SHORT", p*(1-j*0.003), 0.001); await asyncio.sleep(0.8)', + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995*(1-j*0.003), 0.001); await asyncio.sleep(0.5)', + ]) + +for i in range(4): + name = f"triple_seq_long_{i}" + B(name, [ + "for j in range(3):", + f' tid = f"tsl{i}_{{j}}-{{int(time.time()*1000)}}"', + ' _si(k, E.ENTER, tid, symbol, "LONG", p*(1+j*0.003), 0.001); await asyncio.sleep(0.8)', + ' _si(k, E.EXIT, tid, symbol, "LONG", p*1.005*(1+j*0.003), 0.001); await asyncio.sleep(0.5)', + ]) + +# Cancel reenter +for i in range(4): + name = f"cancel_reenter_{i}" + better = ["p*0.997", "p*0.994", "p*0.991", "p*0.988"][i] + B(name, [ + 't1 = f"cr{}a-{}".format(' + str(i) + ', int(time.time()*1000))', + 't2 = f"cr{}b-{}".format(' + str(i) + ', int(time.time()*1000))', + '_si(k, E.ENTER, t1, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + '_si(k, E.CANCEL, t1, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, t1, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.3)', + f'_si(k, E.ENTER, t2, symbol, "SHORT", {better}, 0.001); await asyncio.sleep(0.8)', + f'_si(k, E.EXIT, t2, symbol, "SHORT", p*{0.995 + 0.001*i:.3f}, 0.001); await asyncio.sleep(0.5)', + ]) + +for i in range(4): + name = f"cancel_reenter_long_{i}" + better = ["p*1.003", "p*1.006", "p*1.009", "p*1.012"][i] + B(name, [ + 't1 = f"crl{}a-{}".format(' + str(i) + ', int(time.time()*1000))', + 't2 = f"crl{}b-{}".format(' + str(i) + ', int(time.time()*1000))', + '_si(k, E.ENTER, t1, symbol, "LONG", p, 0.001); await asyncio.sleep(0.3)', + '_si(k, E.CANCEL, t1, symbol, "LONG", p, 0.001); await asyncio.sleep(0.3)', + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, t1, symbol, "LONG", p*1.005, 0.001); await asyncio.sleep(0.3)', + f'_si(k, E.ENTER, t2, symbol, "LONG", {better}, 0.001); await asyncio.sleep(0.8)', + f'_si(k, E.EXIT, t2, symbol, "LONG", p*{1.005 + 0.003*(i+1):.3f}, 0.001); await asyncio.sleep(0.5)', + ]) + +# Leg ratio variants +ratios_data = [ + ("leg_ratio_0", [(0.1,1.0)], 0.002, [0.0002, 0.0018]), + ("leg_ratio_1", [(0.33,0.33,1.0)], 0.003, [0.001, 0.001, 0.001]), + ("leg_ratio_2", [(0.5,0.5,1.0)], 0.002, [0.001, 0.001]), + ("leg_ratio_3", [(0.75,1.0)], 0.002, [0.0015, 0.0005]), + ("leg_ratio_4", [(0.2,0.3,0.5,1.0)], 0.004, [0.0008, 0.0012, 0.002]), + ("leg_ratio_5", [(0.4,0.6,1.0)], 0.002, [0.0008, 0.0012]), + ("leg_ratio_6", [(0.15,0.85,1.0)], 0.002, [0.0003, 0.0017]), + ("leg_ratio_7", [(0.25,0.25,0.5,1.0)], 0.002, [0.0005, 0.0005, 0.001]), +] + +for lr_name, ratios, total_sz, sizes in ratios_data: + lines = [f'tid = f"{lr_name[:4]}-{{int(time.time()*1000)}}"'] + lines.append(f'_si(k, E.ENTER, tid, symbol, "SHORT", p, {total_sz}, exit_leg_ratios={ratios[0]}); await asyncio.sleep(0.8)') + prices = [0.995, 0.993, 0.991, 0.989][:len(sizes)] + for i, (sz, pr) in enumerate(zip(sizes, prices)): + lines.append(f'_si(k, E.EXIT, tid, symbol, "SHORT", p*{pr}, {sz}, exit_leg_ratios={ratios[0]}); await asyncio.sleep(0.5)') + B(lr_name, lines) + +# Breakeven +for i in range(4): + B(f"breakeven_{i}", [ + f'tid = f"be{i}-{{int(time.time()*1000)}}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.5)', + ]) + +# Price-level variants +price_variants = [ + ("short_exit_one_pct_profit", "SHORT", "p*0.99"), + ("short_exit_third_pct_profit", "SHORT", "p*0.997"), + ("short_exit_third_pct_loss", "SHORT", "p*1.003"), + ("short_exit_one_pct_loss", "SHORT", "p*1.01"), + ("long_exit_one_pct_profit", "LONG", "p*1.01"), + ("long_exit_third_pct_profit", "LONG", "p*1.003"), + ("long_exit_third_pct_loss", "LONG", "p*0.997"), + ("long_exit_one_pct_loss", "LONG", "p*0.99"), +] +for pn, ps, pe in price_variants: + B(pn, [ + f'tid = f"{pn[:4]}-{{int(time.time()*1000)}}"', + f'_si(k, E.ENTER, tid, symbol, "{ps}", p, 0.001); await asyncio.sleep(0.8)', + f'_si(k, E.EXIT, tid, symbol, "{ps}", {pe}, 0.001); await asyncio.sleep(0.5)', + ]) + +# Leverage +lev = [ + ("entry_exit_short_2x_profit", "SHORT", 2, "p*0.995"), + ("entry_exit_long_2x_profit", "LONG", 2, "p*1.005"), + ("entry_exit_short_3x_profit", "SHORT", 3, "p*0.995"), + ("entry_exit_long_3x_profit", "LONG", 3, "p*1.005"), + ("entry_exit_short_2x_loss", "SHORT", 2, "p*1.005"), + ("entry_exit_long_2x_loss", "LONG", 2, "p*0.995"), + ("entry_exit_short_3x_loss", "SHORT", 3, "p*1.005"), + ("entry_exit_long_3x_loss", "LONG", 3, "p*0.995"), +] +for pn, ps, lv, pe in lev: + B(pn, [ + f'tid = f"{pn[:4]}-{{int(time.time()*1000)}}"', + f'_si(k, E.ENTER, tid, symbol, "{ps}", p, 0.001, leverage={lv}); await asyncio.sleep(0.8)', + f'_si(k, E.EXIT, tid, symbol, "{ps}", {pe}, 0.001, leverage={lv}); await asyncio.sleep(0.5)', + ]) + +# Size +sz = [ + ("entry_exit_short_2x_size", "SHORT", 0.002), + ("entry_exit_long_2x_size", "LONG", 0.002), + ("entry_exit_short_3x_size", "SHORT", 0.003), + ("entry_exit_long_3x_size", "LONG", 0.003), + ("entry_exit_short_4x_size", "SHORT", 0.004), + ("entry_exit_long_4x_size", "LONG", 0.004), + ("entry_exit_short_5x_size", "SHORT", 0.005), + ("entry_exit_long_5x_size", "LONG", 0.005), +] +for pn, ps, s in sz: + B(pn, [ + f'tid = f"{pn[:4]}-{{int(time.time()*1000)}}"', + f'_si(k, E.ENTER, tid, symbol, "{ps}", p, {s}); await asyncio.sleep(0.8)', + f'_si(k, E.EXIT, tid, symbol, "{ps}", p*0.995 if "{ps}" == "SHORT" else p*1.005, {s}); await asyncio.sleep(0.5)', + ]) + +# Cycles +B("three_cycle_short", [ + "for j in range(3):", + ' tid = f"tcs{j}-{int(time.time()*1000)}"', + ' _si(k, E.ENTER, tid, symbol, "SHORT", p*(1-j*0.003), 0.001); await asyncio.sleep(0.5)', + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.997*(1-j*0.003), 0.001); await asyncio.sleep(0.5)', +]) + +B("three_cycle_long", [ + "for j in range(3):", + ' tid = f"tcl{j}-{int(time.time()*1000)}"', + ' _si(k, E.ENTER, tid, symbol, "LONG", p*(1+j*0.003), 0.001); await asyncio.sleep(0.5)', + ' _si(k, E.EXIT, tid, symbol, "LONG", p*1.003*(1+j*0.003), 0.001); await asyncio.sleep(0.5)', +]) + +# Partial ratio +prs = [ + ("partial_ratio_0_short", "SHORT", (0.5,0.5,1.0), 0.002, ["p*0.995","p*0.993"], [0.001, 0.001]), + ("partial_ratio_0_long", "LONG", (0.5,0.5,1.0), 0.002, ["p*1.005","p*1.007"], [0.001, 0.001]), + ("partial_ratio_1_short", "SHORT", (0.33,0.33,1.0), 0.003, ["p*0.995","p*0.993","p*0.991"], [0.001, 0.001, 0.001]), + ("partial_ratio_1_long", "LONG", (0.33,0.33,1.0), 0.003, ["p*1.005","p*1.007","p*1.009"], [0.001, 0.001, 0.001]), + ("partial_ratio_2_short", "SHORT", (0.1,0.9,1.0), 0.002, ["p*0.995","p*0.993"], [0.0002, 0.0018]), + ("partial_ratio_2_long", "LONG", (0.1,0.9,1.0), 0.002, ["p*1.005","p*1.007"], [0.0002, 0.0018]), + ("partial_ratio_3_short", "SHORT", (0.25,0.25,0.5,1.0), 0.004, ["p*0.995","p*0.993","p*0.991"], [0.001, 0.001, 0.002]), + ("partial_ratio_3_long", "LONG", (0.25,0.25,0.5,1.0), 0.004, ["p*1.005","p*1.007","p*1.009"], [0.001, 0.001, 0.002]), +] + +for pn, ps, rat, tsz, exits, szs in prs: + lines = [f'tid = f"{pn[:4]}-{{int(time.time()*1000)}}"'] + lines.append(f'_si(k, E.ENTER, tid, symbol, "{ps}", p, {tsz}, exit_leg_ratios={rat}); await asyncio.sleep(0.8)') + for xp, xs in zip(exits, szs): + lines.append(f'_si(k, E.EXIT, tid, symbol, "{ps}", {xp}, {xs}, exit_leg_ratios={rat}); await asyncio.sleep(0.5)') + B(pn, lines) + +# Other groups +B("cross_asset_short", [ + 'tid = f"cas-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("cross_asset_long", [ + 'tid = f"cal-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "LONG", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid, symbol, "LONG", p*1.005, 0.001); await asyncio.sleep(0.5)', +]) + +B("cancel_on_fill_short", [ + 'tid = f"cfs-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + '_si(k, E.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("cancel_on_fill_long", [ + 'tid = f"cfl-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "LONG", p, 0.001); await asyncio.sleep(0.3)', + '_si(k, E.CANCEL, tid, symbol, "LONG", p, 0.001); await asyncio.sleep(0.3)', + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, tid, symbol, "LONG", p*1.005, 0.001); await asyncio.sleep(0.5)', +]) + +B("entry_quick_exit_short", [ + 'tid = f"eqs-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("entry_quick_exit_long", [ + 'tid = f"eql-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "LONG", p, 0.001); await asyncio.sleep(0.3)', + '_si(k, E.EXIT, tid, symbol, "LONG", p*1.005, 0.001); await asyncio.sleep(0.5)', +]) + +B("triple_leg_exit_short", [ + 'tid = f"tls-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.003, exit_leg_ratios=(0.33,0.33,1.0)); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001, exit_leg_ratios=(0.33,0.33,1.0)); await asyncio.sleep(0.5)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.993, 0.001, exit_leg_ratios=(0.33,0.33,1.0)); await asyncio.sleep(0.5)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.991, 0.001, exit_leg_ratios=(0.33,0.33,1.0)); await asyncio.sleep(0.5)', +]) + +B("triple_leg_exit_long", [ + 'tid = f"tll-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "LONG", p, 0.003, exit_leg_ratios=(0.33,0.33,1.0)); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid, symbol, "LONG", p*1.005, 0.001, exit_leg_ratios=(0.33,0.33,1.0)); await asyncio.sleep(0.5)', + '_si(k, E.EXIT, tid, symbol, "LONG", p*1.007, 0.001, exit_leg_ratios=(0.33,0.33,1.0)); await asyncio.sleep(0.5)', + '_si(k, E.EXIT, tid, symbol, "LONG", p*1.009, 0.001, exit_leg_ratios=(0.33,0.33,1.0)); await asyncio.sleep(0.5)', +]) + +B("cancel_reenter_exit_short", [ + 't1 = f"cres-{int(time.time()*1000)}"; t2 = f"cres2-{int(time.time()*1000)}"', + '_si(k, E.ENTER, t1, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + '_si(k, E.CANCEL, t1, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, t1, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.3)', + '_si(k, E.ENTER, t2, symbol, "SHORT", p*0.997, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, t2, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("cancel_reenter_exit_long", [ + 't1 = f"crel-{int(time.time()*1000)}"; t2 = f"crel2-{int(time.time()*1000)}"', + '_si(k, E.ENTER, t1, symbol, "LONG", p, 0.001); await asyncio.sleep(0.3)', + '_si(k, E.CANCEL, t1, symbol, "LONG", p, 0.001); await asyncio.sleep(0.3)', + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, t1, symbol, "LONG", p*1.005, 0.001); await asyncio.sleep(0.3)', + '_si(k, E.ENTER, t2, symbol, "LONG", p*1.003, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, t2, symbol, "LONG", p*1.005, 0.001); await asyncio.sleep(0.5)', +]) + +B("zero_capital_safety", [ + 'tid = f"zcs-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.5)', + "if not k.slot(0).is_free():", + ' _si(k, E.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("position_survives_exit", [ + 'tid = f"pse-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("double_entry_prevention", [ + 't1 = f"dep1-{int(time.time()*1000)}"; t2 = f"dep2-{int(time.time()*1000)}"', + '_si(k, E.ENTER, t1, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + '_si(k, E.ENTER, t2, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.3)', + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, t1, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("negative_capital_check", [ + 'tid = f"nec-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.5)', +]) + +# RECONCILE +B("reconcile_empty", [ + "k.reconcile_from_slots([]); await asyncio.sleep(0.3)", +]) + +B("reconcile_after_entry", [ + 'tid = f"re-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + "k.reconcile_from_slots([k.slot(0)]); await asyncio.sleep(0.3)", + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("reconcile_after_exit", [ + 'tid = f"rx-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', + "k.reconcile_from_slots([k.slot(0)]); await asyncio.sleep(0.3)", +]) + +B("reconcile_after_cancel", [ + 'tid = f"rcn-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.5)', + "if not k.slot(0).is_free():", + ' _si(k, E.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + "k.reconcile_from_slots([k.slot(0)]); await asyncio.sleep(0.3)", + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("reconcile_twice", [ + 'tid = f"rtw-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + "k.reconcile_from_slots([k.slot(0)]); await asyncio.sleep(0.3)", + "k.reconcile_from_slots([k.slot(0)]); await asyncio.sleep(0.3)", + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("reconcile_then_cancel", [ + 'tid = f"rtc-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.5)', + "k.reconcile_from_slots([k.slot(0)]); await asyncio.sleep(0.3)", + "if not k.slot(0).is_free():", + ' _si(k, E.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +# CHAOS +B("concurrent_enter_cancel", [ + 'tid = f"cc-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001)', + '_si(k, E.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.5)', + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("rapid_alternating", [ + 't1 = f"ras-{int(time.time()*1000)}"', + '_si(k, E.ENTER, t1, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.2)', + '_si(k, E.CANCEL, t1, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.2)', + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, t1, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.3)', + 't2 = f"ral-{int(time.time()*1000)}"', + '_si(k, E.ENTER, t2, symbol, "LONG", p, 0.001); await asyncio.sleep(0.2)', + '_si(k, E.CANCEL, t2, symbol, "LONG", p, 0.001); await asyncio.sleep(0.2)', + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, t2, symbol, "LONG", p*1.005, 0.001); await asyncio.sleep(0.3)', +]) + +B("duplicate_trade_id", [ + 'tid = f"dt-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + '_si(k, E.ENTER, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.3)', + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("slot_busy_double_entry", [ + 't1 = f"sb1-{int(time.time()*1000)}"; t2 = f"sb2-{int(time.time()*1000)}"', + '_si(k, E.ENTER, t1, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + '_si(k, E.ENTER, t2, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.3)', + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, t1, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("exit_on_idle_slot", [ + '_si(k, E.EXIT, f"exidle-{int(time.time()*1000)}", symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + 'tid = f"eoi-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("cancel_on_idle_slot", [ + '_si(k, E.CANCEL, f"coi-{int(time.time()*1000)}", symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + 'tid = f"cis-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("rapid_ten_cycle", [ + "for i in range(10):", + ' tid = f"rc10-{i}-{int(time.time()*1000)}"', + ' _si(k, E.ENTER, tid, symbol, "SHORT", p*(1-i*0.001), 0.001); await asyncio.sleep(0.4)', + " if not k.slot(0).is_free():", + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995*(1-i*0.001), 0.001); await asyncio.sleep(0.4)', + " else:", + " break", +]) + +B("cancel_after_exit_fill", [ + 'tid = f"caf-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.3)', + '_si(k, E.CANCEL, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +# MULTI-SLOT +B("multi_slot_enter_exit", [ + 't0 = f"ms0-{int(time.time()*1000)}"; t1 = f"ms1-{int(time.time()*1000)}"', + '_si(k, E.ENTER, t0, symbol, "SHORT", p, 0.001, slot_id=0); await asyncio.sleep(0.4)', + '_si(k, E.ENTER, t1, symbol, "LONG", p, 0.001, slot_id=1); await asyncio.sleep(0.4)', + '_si(k, E.EXIT, t0, symbol, "SHORT", p*0.995, 0.001, slot_id=0); await asyncio.sleep(0.4)', + '_si(k, E.EXIT, t1, symbol, "LONG", p*1.005, 0.001, slot_id=1); await asyncio.sleep(0.4)', +]) + +B("multi_slot_cross_cancel", [ + 't0 = f"msx0-{int(time.time()*1000)}"; t1 = f"msx1-{int(time.time()*1000)}"', + '_si(k, E.ENTER, t0, symbol, "SHORT", p, 0.001, slot_id=0); await asyncio.sleep(0.3)', + '_si(k, E.ENTER, t1, symbol, "LONG", p, 0.001, slot_id=1); await asyncio.sleep(0.3)', + '_si(k, E.CANCEL, t0, symbol, "SHORT", p, 0.001, slot_id=0); await asyncio.sleep(0.3)', + '_si(k, E.CANCEL, t1, symbol, "LONG", p, 0.001, slot_id=1); await asyncio.sleep(0.3)', + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, t0, symbol, "SHORT", p*0.995, 0.001, slot_id=0); await asyncio.sleep(0.3)', + "if not k.slot(1).is_free():", + ' _si(k, E.EXIT, t1, symbol, "LONG", p*1.005, 0.001, slot_id=1); await asyncio.sleep(0.3)', +]) + +B("multi_slot_rapid_cycle", [ + "for i in range(5):", + ' t0 = f"msc0-{i}-{int(time.time()*1000)}"; t1 = f"msc1-{i}-{int(time.time()*1000)}"', + ' _si(k, E.ENTER, t0, symbol, "SHORT", p*(1-i*0.002), 0.001, slot_id=0); await asyncio.sleep(0.3)', + ' _si(k, E.ENTER, t1, symbol, "LONG", p*(1+i*0.002), 0.001, slot_id=1); await asyncio.sleep(0.3)', + ' _si(k, E.EXIT, t0, symbol, "SHORT", p*0.995*(1-i*0.002), 0.001, slot_id=0); await asyncio.sleep(0.3)', + ' _si(k, E.EXIT, t1, symbol, "LONG", p*1.005*(1+i*0.002), 0.001, slot_id=1); await asyncio.sleep(0.3)', +]) + +# REJECTION +B("reject_wrong_symbol", [ + 'tid = f"rs-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, "ZZZUSDT", "SHORT", 0.001, 0.001); await asyncio.sleep(0.5)', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("reject_zero_size", [ + 'tid = f"rz-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.0); await asyncio.sleep(0.3)', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("reject_side_mismatch_cancel", [ + 'tid = f"rsm-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.CANCEL, tid, symbol, "LONG", p, 0.001); await asyncio.sleep(0.3)', + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("reject_negative_price", [ + 'tid = f"rn-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", -1.0, 0.001); await asyncio.sleep(0.5)', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +# SNAPSHOT +B("snapshot_restore_empty", [ + "s = k.snapshot(); await asyncio.sleep(0.1)", + "j = json.dumps(s); _ = json.loads(j)", + 'tid = f"sre-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("snapshot_restore_mid_trade", [ + 'tid = f"srm-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + "s = k.snapshot(); await asyncio.sleep(0.1)", + "j = json.dumps(s); _ = json.loads(j)", + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("snapshot_restore_after_cancel", [ + 'tid = f"src-{int(time.time()*1000)}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.5)', + '_si(k, E.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + "s = k.snapshot(); await asyncio.sleep(0.1)", + "j = json.dumps(s); _ = json.loads(j)", + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +# LIMIT +B("limit_does_not_fill", [ + 'tid = "l0-" + str(int(time.time()*1000))', + "k.process_intent(KI(timestamp=__import__(\"datetime\").datetime.now(__import__(\"datetime\").timezone.utc),", + " intent_id=tid, trade_id=tid, slot_id=0, asset=symbol, side=TS.SHORT, action=E.ENTER,", + " reference_price=0.0, target_size=0.001, leverage=1.0, exit_leg_ratios=(1.0,),", + ' reason="auto_zeroprice")); await asyncio.sleep(0.3)', + 'tid2 = "l0r-" + str(int(time.time()*1000))', + '_si(k, E.ENTER, tid2, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid2, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("limit_immediate_fill", [ + 'tid = "ln-" + str(int(time.time()*1000))', + "k.process_intent(KI(timestamp=__import__(\"datetime\").datetime.now(__import__(\"datetime\").timezone.utc),", + " intent_id=tid, trade_id=tid, slot_id=0, asset=symbol, side=TS.SHORT, action=E.ENTER,", + " reference_price=p, target_size=-0.001, leverage=1.0, exit_leg_ratios=(1.0,),", + ' reason="auto_negsize")); await asyncio.sleep(0.3)', + 'tid2 = "lnr-" + str(int(time.time()*1000))', + '_si(k, E.ENTER, tid2, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid2, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +# ===== FRESH KERNEL RECONCILE ===== +B("fresh_kernel_reconcile_entry", [ + 'tid = "fk-" + str(int(time.time()*1000))', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + "slot_data = k.slot(0).to_dict()", + "cb = k.account.snapshot.capital", + "fresh = _build_fresh_kernel_from_slot(slot_data, ic=cb)", + "k2 = fresh.runtime.kernel", + "s = k2.slot(0)", + 'assert not s.is_free(), f"fresh kernel slot should not be free: {s.fsm_state}"', + 'assert s.trade_id == tid, f"trade_id mismatch: {s.trade_id} vs {tid}"', + '_si(k2, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', + 'assert k2.slot(0).is_free(), "fresh kernel slot not free after exit"', + 'assert abs(k2.account.snapshot.capital - cb) < 0.01, f"capital drift: {k2.account.snapshot.capital} vs {cb}"', +]) + +B("fresh_kernel_reconcile_after_cancel", [ + 'tid = "fkc-" + str(int(time.time()*1000))', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + 'r = _si(k, E.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + "slot_data = k.slot(0).to_dict()", + "cb = k.account.snapshot.capital", + "fresh = _build_fresh_kernel_from_slot(slot_data, ic=cb)", + "k2 = fresh.runtime.kernel", + 'assert k2.slot(0).is_free(), f"cancelled slot not free: {k2.slot(0).fsm_state}"', +]) + +B("fresh_kernel_reconcile_after_exit", [ + 'tid = "fkx-" + str(int(time.time()*1000))', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', + "slot_data = k.slot(0).to_dict()", + "cb = k.account.snapshot.capital", + "fresh = _build_fresh_kernel_from_slot(slot_data, ic=cb)", + "k2 = fresh.runtime.kernel", + 'assert k2.slot(0).is_free(), f"closed slot not free: {k2.slot(0).fsm_state}"', + 'assert k2.slot(0).closed, "slot should be marked closed"', + 'assert abs(k2.account.snapshot.capital - cb) < 0.01, f"capital drift: {k2.account.snapshot.capital} vs {cb}"', +]) + +B("fresh_kernel_reconcile_partial_exit", [ + 'tid = "fkp-" + str(int(time.time()*1000))', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.002, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.5)', + "slot_data = k.slot(0).to_dict()", + "cb = k.account.snapshot.capital", + "fresh = _build_fresh_kernel_from_slot(slot_data, ic=cb)", + "k2 = fresh.runtime.kernel", + "s = k2.slot(0)", + 'assert not s.is_free(), f"partial-exit slot not free: {s.fsm_state}"', + 'assert s.realized_pnl != 0 or s.size > 0, "partial-exit slot should have remaining position or realized PnL"', + '_si(k2, E.EXIT, tid, symbol, "SHORT", p*0.993, 0.001, exit_leg_ratios=(1.0,)); await asyncio.sleep(0.5)', + 'assert k2.slot(0).is_free(), "slot not free after final exit on fresh kernel"', +]) + +# ===== CROSS-SLOT PORTFOLIO ===== +B("cross_slot_portfolio_short_long", [ + 't0 = "psl0-" + str(int(time.time()*1000))', + 't1 = "psl1-" + str(int(time.time()*1000))', + "cb = k.account.snapshot.capital", + '_si(k, E.ENTER, t0, symbol, "SHORT", p, 0.001, slot_id=0); await asyncio.sleep(0.4)', + '_si(k, E.ENTER, t1, symbol, "LONG", p, 0.001, slot_id=1); await asyncio.sleep(0.4)', + 'assert not k.slot(0).is_free(), "slot 0 should be open"', + 'assert not k.slot(1).is_free(), "slot 1 should be open"', + "rp0 = k.slot(0).realized_pnl; up0 = k.slot(0).unrealized_pnl", + "rp1 = k.slot(1).realized_pnl; up1 = k.slot(1).unrealized_pnl", + "expected = cb + rp0 + up0 + rp1 + up1", + "actual = k.account.snapshot.capital", + 'assert abs(actual - expected) < 0.01, f"portfolio misalignment: cap={actual} exp={expected} rp0={rp0} up0={up0} rp1={rp1} up1={up1}"', + '_si(k, E.EXIT, t0, symbol, "SHORT", p*0.995, 0.001, slot_id=0); await asyncio.sleep(0.4)', + 'assert k.slot(0).is_free(), "slot 0 should be free after exit"', + '_si(k, E.EXIT, t1, symbol, "LONG", p*1.005, 0.001, slot_id=1); await asyncio.sleep(0.4)', + 'assert k.slot(1).is_free(), "slot 1 should be free after exit"', +]) + +# ===== KERNEL OUTCOME INSPECTION ===== +B("outcome_inspect_entry", [ + 'tid = "oi-" + str(int(time.time()*1000))', + 'r = _si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + "_assert_accepted(r, 'entry')", + "info = _inspect_outcome(r, 'entry')", + 'assert r.accepted, f"entry not accepted: {info}"', + 'assert r.trade_id == tid, f"trade_id mismatch: {r.trade_id} vs {tid}"', + 'assert r.slot_id == 0, f"slot_id: {r.slot_id}"', + 'assert len(info["transitions"]) > 0, f"no transitions in outcome: {info}"', + 'assert info["diagnostic"] == "OK", f"diagnostic not OK: {info}"', + 'r2 = _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', + "_assert_accepted(r2, 'exit')", + 'info2 = _inspect_outcome(r2, "exit")', + 'assert len(info2["transitions"]) > 0, f"no exit transitions: {info2}"', + 'assert info2["diagnostic"] == "OK", f"exit diagnostic: {info2}"', +]) + +B("outcome_inspect_rejection", [ + 'tid = "or-" + str(int(time.time()*1000))', + 'tid2 = "or2-" + str(int(time.time()*1000))', + 'r1 = _si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + "_assert_accepted(r1, 'first entry')", + 'r2 = _si(k, E.ENTER, tid2, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + "_assert_rejected(r2, 'SLOT_BUSY', 'double entry')", + "info = _inspect_outcome(r2, 'double entry')", + 'assert not r2.accepted, f"second entry should be rejected: {info}"', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +B("outcome_inspect_exit_on_idle", [ + 'tid = "oei-" + str(int(time.time()*1000))', + 'r = _si(k, E.EXIT, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + "_assert_rejected(r, 'INVALID_FSM_TRANSITION', 'exit on idle')", + "info = _inspect_outcome(r, 'exit on idle')", + 'assert not r.accepted, f"exit on idle should be rejected: {info}"', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +# ===== EVENT DEDUP ===== +B("dedup_duplicate_fill_event", [ + 'tid = "dd-" + str(int(time.time()*1000))', + 'r = _si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + "_assert_accepted(r, 'entry')", + "sl = k.slot(0)", + "ao = sl.active_entry_order if sl.active_entry_order else sl.active_exit_order", + "if ao:", + " dup = VenueEvent(", + " timestamp=__import__(\"datetime\").datetime.now(__import__(\"datetime\").timezone.utc),", + ' event_id="dedup-test-99999",', + " trade_id=tid, slot_id=0,", + " kind=KernelEventKind.FULL_FILL,", + " status=VenueEventStatus.FILLED,", + " venue_order_id=ao.venue_order_id,", + " venue_client_id=ao.venue_client_id,", + " side=sl.side,", + " asset=symbol,", + " price=p,", + " size=0.001, filled_size=0.001, remaining_size=0.0,", + ' reason="dedup_test",', + " )", + " r2 = k.on_venue_event(dup)", + " _assert_accepted(r2, 'dedup_fill')", + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +# ===== FILL-PRICE DIVERGENCE ===== +B("fill_price_divergence_1pct", [ + 'tid = "fd-" + str(int(time.time()*1000))', + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + "sl = k.slot(0)", + "ao = sl.active_entry_order if sl.active_entry_order else sl.active_exit_order", + "if ao and str(sl.fsm_state) not in ('IDLE', 'CLOSED'):", + " divergent_price = p * 1.01", + " div_event = VenueEvent(", + " timestamp=__import__(\"datetime\").datetime.now(__import__(\"datetime\").timezone.utc),", + ' event_id="divergence-test",', + " trade_id=tid, slot_id=0,", + " kind=KernelEventKind.FULL_FILL,", + " status=VenueEventStatus.FILLED,", + ' venue_order_id=ao.venue_order_id if ao else "",', + ' venue_client_id=ao.venue_client_id if ao else "",', + " side=sl.side,", + " asset=symbol,", + " price=divergent_price,", + " size=0.001, filled_size=0.001, remaining_size=0.0,", + ' reason="divergence_test",', + " )", + " k.on_venue_event(div_event); await asyncio.sleep(0.3)", + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +# ===== NEGATIVE CAPITAL ===== +B("neg_cap_entry_rejected", [ + 'tid = "nc-" + str(int(time.time()*1000))', + "k.account.snapshot.capital = 0.0", + 'r = _si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + "info = _inspect_outcome(r, 'neg_cap')", + "k.account.snapshot.capital = 25000.0", + '_si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', +]) + +# ===== CROSS-SAMPLE: new patterns on old shapes ===== +B("cross_sample_basic_entry_exit_outcome", [ + 'tid = "cs-" + str(int(time.time()*1000))', + "cb = k.account.snapshot.capital; k._start_cap = cb", + 'r1 = _si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + "_assert_accepted(r1, 'cs_entry')", + "_check_slot_accounting(k, 'cs_after_entry')", + 'r2 = _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', + "_assert_accepted(r2, 'cs_exit')", + "_check_slot_accounting(k, 'cs_after_exit')", + "ca = k.account.snapshot.capital", + "max_change = max(1.0, cb * 0.10)", + 'assert cb - ca < max_change, f"cs: cap shrunk {cb} -> {ca}"', +]) + +B("cross_sample_cancel_reenter_outcome", [ + 't1 = "csc-" + str(int(time.time()*1000))', + 't2 = "csc2-" + str(int(time.time()*1000))', + "cb = k.account.snapshot.capital; k._start_cap = cb", + 'r1 = _si(k, E.ENTER, t1, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + "_assert_accepted(r1, 'cs_cancel_entry')", + 'r2 = _si(k, E.CANCEL, t1, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + "if not k.slot(0).is_free():", + ' _si(k, E.EXIT, t1, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.3)', + "_check_slot_accounting(k, 'cs_after_cancel')", + 'assert k.slot(0).is_free(), "slot should be free after cancel"', + 'r3 = _si(k, E.ENTER, t2, symbol, "SHORT", p*0.997, 0.001); await asyncio.sleep(0.8)', + "_assert_accepted(r3, 'cs_reenter')", + "_check_slot_accounting(k, 'cs_after_reenter')", + 'r4 = _si(k, E.EXIT, t2, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', + "_assert_accepted(r4, 'cs_reenter_exit')", + "_check_slot_accounting(k, 'cs_after_reenter_exit')", +]) + +B("cross_sample_multi_leg_outcome", [ + 'tid = "csm-" + str(int(time.time()*1000))', + "cb = k.account.snapshot.capital; k._start_cap = cb", + 'r = _si(k, E.ENTER, tid, symbol, "SHORT", p, 0.002, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.8)', + "_assert_accepted(r, 'cs_ml_entry')", + 'r = _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.4)', + "_assert_accepted(r, 'cs_ml_leg1')", + "_check_slot_accounting(k, 'cs_ml_after_leg1')", + 'r = _si(k, E.EXIT, tid, symbol, "SHORT", p*0.993, 0.001, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.4)', + "_assert_accepted(r, 'cs_ml_leg2')", + "_check_slot_accounting(k, 'cs_ml_after_leg2')", +]) + +B("cross_sample_leverage_tight_bounds", [ + 'tid = "csl-" + str(int(time.time()*1000))', + "cb = k.account.snapshot.capital; k._start_cap = cb", + 'r_ent = _si(k, E.ENTER, tid, symbol, "SHORT", p, 0.001, leverage=2); await asyncio.sleep(0.8)', + "_assert_accepted(r_ent, 'cs_lev_entry')", + "_check_slot_accounting(k, 'cs_lev_after_entry')", + 'r_ex = _si(k, E.EXIT, tid, symbol, "SHORT", p*0.995, 0.001, leverage=2); await asyncio.sleep(0.5)', + "_assert_accepted(r_ex, 'cs_lev_exit')", + "_check_slot_accounting(k, 'cs_lev_after_exit')", + "ca = k.account.snapshot.capital", + "max_change = max(1.0, cb * 0.10)", + 'assert cb - ca < max_change, f"cs_lev: cap shrunk {cb} -> {ca}"', +]) + +# ---- BUILD SCENARIOS LIST ---- +emit("# ============================================================") +emit("# SCENARIOS") +emit("# ============================================================") +emit("SCENARIOS = [") +for name in sorted(bodies.keys()): + emit(f' pytest.param("{name}", _body_{name}, id="{name}"),') +emit("]") +emit("") + +# ---- FIXTURE + TEST ---- +emit("@pytest.fixture(scope=\"session\")") +emit("def _live_client():") +emit(" return BingxHttpClient(_build_config())") +emit("") +emit("") +emit("@pytest.mark.parametrize(\"name,body_fn\", SCENARIOS)") +emit("def test_pink_ditav2(_live_client, name, body_fn) -> None:") +emit(" bundle = _build_rb()") +emit(" ic = bundle.runtime.kernel.account.snapshot.capital") +emit(" r = asyncio.run(_run(bundle, _live_client, body_fn, name, ic))") +emit(" assert r.positions_flat, f\"{name}: {r.error}\"") + +# ---- WRITE BODY FUNCTIONS ---- +# Build the full file: header + helpers + body functions + scenarios + fixture/test +header = "\n".join(lines) + +body_funcs = [] +for name, blines in bodies.items(): + body_funcs.append(f"async def _body_{name}(k, symbol, p):") + for bl in blines: + body_funcs.append(f" {bl}") + body_funcs.append("") + +full = header + "\n".join(body_funcs) + "\n\n" + header[header.rindex("# =="):] + +# Actually let me just assemble properly +# Split header at the body section comment +body_section_header = "# ============================================================\n# SCENARIO BODIES\n# Each receives (k, symbol, p) and exercises a slice of the FSM.\n# ============================================================\n\n" + +all_body_text = "" +for name, blines in bodies.items(): + all_body_text += f"async def _body_{name}(k, symbol, p):\n" + for bl in blines: + if bl.startswith(" "): + all_body_text += bl + "\n" + else: + all_body_text += " " + bl + "\n" + all_body_text += "\n" + +# Find the pre-body section (imports + helpers) +body_start_idx = header.index("# SCENARIO BODIES") +pre_body = header + +full_text = pre_body + all_body_text + """ +# ============================================================ +# SCENARIOS +# ============================================================ +SCENARIOS = [ +""" + "\n".join([f' pytest.param("{n}", _body_{n}, id="{n}"),' for n in sorted(bodies.keys())]) + """ +] + + +@pytest.fixture(scope="session") +def _live_client(): + return BingxHttpClient(_build_config()) + + +@pytest.mark.parametrize("name,body_fn", SCENARIOS) +def test_pink_ditav2(_live_client, name, body_fn) -> None: + bundle = _build_rb() + ic = bundle.runtime.kernel.account.snapshot.capital + r = asyncio.run(_run(bundle, _live_client, body_fn, name, ic)) + assert r.positions_flat, f"{name}: {r.error}" +""" + +with open(fpath, 'w') as f: + f.write(full_text) + +import py_compile +try: + py_compile.compile(fpath, doraise=True) + print(f"Compiles OK. {len(bodies)} scenarios") +except py_compile.PyCompileError as e: + print(f"Compile error: {e}") + # Show the broken area + import traceback + traceback.print_exc() diff --git a/prod/clean_arch/dita_v2/control.py b/prod/clean_arch/dita_v2/control.py new file mode 100644 index 0000000..62b461f --- /dev/null +++ b/prod/clean_arch/dita_v2/control.py @@ -0,0 +1,217 @@ +"""Runtime control plane for DITAv2.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, replace +from enum import Enum +import os +import threading +import time +from typing import Any, Dict, Mapping, Optional, Protocol + +from .utils import json_safe + + +class KernelMode(str, Enum): + NORMAL = "NORMAL" + DEBUG = "DEBUG" + + +class KernelVerbosity(str, Enum): + QUIET = "QUIET" + VERBOSE = "VERBOSE" + TRACE = "TRACE" + + +class BackendMode(str, Enum): + MOCK = "MOCK" + BINGX = "BINGX" + + +@dataclass(frozen=True) +class KernelControlSnapshot: + """Control plane state shared across the kernel.""" + + mode: KernelMode = KernelMode.NORMAL + verbosity: KernelVerbosity = KernelVerbosity.QUIET + backend_mode: BackendMode = BackendMode.MOCK + debug_clickhouse_enabled: bool = True + trace_transitions: bool = False + mirror_to_hazelcast: bool = True + active_slot_limit: int = 10 + reconcile_on_restart: bool = True + runtime_namespace: str = "dita_v2" + strategy_namespace: str = "dita_v2" + event_namespace: str = "dita_v2" + actor_name: str = "ExecutionKernel" + exec_venue: str = "bingx" + data_venue: str = "binance" + ledger_authority: str = "exchange" + mock_fidelity_mode: str = "bingx_exact_shape" + + def as_dict(self) -> Dict[str, Any]: + return dict(asdict(self)) + + +@dataclass(frozen=True) +class ControlUpdate: + """Partial update to the control plane.""" + + mode: Optional[KernelMode] = None + verbosity: Optional[KernelVerbosity] = None + backend_mode: Optional[BackendMode] = None + debug_clickhouse_enabled: Optional[bool] = None + trace_transitions: Optional[bool] = None + mirror_to_hazelcast: Optional[bool] = None + active_slot_limit: Optional[int] = None + reconcile_on_restart: Optional[bool] = None + runtime_namespace: Optional[str] = None + strategy_namespace: Optional[str] = None + event_namespace: Optional[str] = None + actor_name: Optional[str] = None + exec_venue: Optional[str] = None + data_venue: Optional[str] = None + ledger_authority: Optional[str] = None + mock_fidelity_mode: Optional[str] = None + + def apply(self, snapshot: KernelControlSnapshot) -> KernelControlSnapshot: + payload = { + key: value + for key, value in asdict(self).items() + if value is not None + } + return replace(snapshot, **payload) + + +class ControlPlane(Protocol): + """Kernel control plane interface.""" + + def read(self) -> KernelControlSnapshot: + ... + + def update(self, update: ControlUpdate) -> KernelControlSnapshot: + ... + + def mirror(self) -> Mapping[str, Any]: + ... + + def wait(self, timeout_ms: int = 1000) -> bool: + ... + + def notify(self) -> None: + ... + + +class InMemoryControlPlane: + """Local control plane used for tests and the Python prototype.""" + + def __init__(self, snapshot: Optional[KernelControlSnapshot] = None): + self._snapshot = snapshot or KernelControlSnapshot() + self._mirror: Dict[str, Any] = {} + self._seq = 0 + self._observed_seq = 0 + self._signal = threading.Condition() + + def read(self) -> KernelControlSnapshot: + return self._snapshot + + def update(self, update: ControlUpdate) -> KernelControlSnapshot: + with self._signal: + self._snapshot = update.apply(self._snapshot) + self._mirror = self._snapshot.as_dict() + self._seq += 1 + self._signal.notify_all() + return self._snapshot + + def mirror(self) -> Mapping[str, Any]: + return dict(self._mirror) + + def wait(self, timeout_ms: int = 1000) -> bool: + timeout_s = None if timeout_ms is None or timeout_ms < 0 else max(0.0, timeout_ms / 1000.0) + deadline = None if timeout_s is None else time.monotonic() + timeout_s + with self._signal: + observed = self._observed_seq + while self._seq == observed: + if deadline is None: + self._signal.wait() + continue + remaining = deadline - time.monotonic() + if remaining <= 0: + return False + self._signal.wait(timeout=remaining) + self._observed_seq = self._seq + return True + + def notify(self) -> None: + with self._signal: + self._seq += 1 + self._signal.notify_all() + + +class ZincControlPlane(InMemoryControlPlane): + """In-memory stand-in for a Zinc-backed control region. + + The class keeps the interface explicit so a real Zinc binding can be + dropped in later without changing kernel code. + """ + + def __init__(self, snapshot: Optional[KernelControlSnapshot] = None): + super().__init__(snapshot=snapshot) + self.region: Dict[str, Any] = self._snapshot.as_dict() + + def update(self, update: ControlUpdate) -> KernelControlSnapshot: + snapshot = super().update(update) + self.region = snapshot.as_dict() + return snapshot + + def read(self) -> KernelControlSnapshot: + return self._snapshot + + +class MirroredControlPlane: + """Control plane that mirrors updates to an external durable sink.""" + + def __init__(self, inner: ControlPlane, mirror_sink: Optional[Any] = None): + self.inner = inner + self.mirror_sink = mirror_sink + + def read(self) -> KernelControlSnapshot: + return self.inner.read() + + def update(self, update: ControlUpdate) -> KernelControlSnapshot: + snapshot = self.inner.update(update) + if self.mirror_sink is not None: + self.mirror_sink("dita_control_plane", dict(snapshot.as_dict())) + return snapshot + + def mirror(self) -> Mapping[str, Any]: + return self.inner.mirror() + + +def build_control_plane( + snapshot: Optional[KernelControlSnapshot] = None, + *, + prefer_real_zinc: Optional[bool] = None, + prefix: str = "dita_v2", +) -> ControlPlane: + """Build the active control plane with an operator-visible switch. + + The default remains the in-process Zinc stand-in so existing tests and + callers stay stable. Setting ``DITA_V2_CONTROL_PLANE=REAL_ZINC`` or passing + ``prefer_real_zinc=True`` opts into the shared-memory control plane when + the Zinc adapter is available. + """ + + env_choice = os.environ.get("DITA_V2_CONTROL_PLANE", "").strip().upper() + real_requested = prefer_real_zinc if prefer_real_zinc is not None else env_choice in {"REAL", "REAL_ZINC", "SHARED", "SHARED_MEM"} + if real_requested: + try: + from .real_control_plane import RealZincControlPlane + + plane = RealZincControlPlane(prefix=prefix, create=True) + if snapshot is not None: + plane.update(ControlUpdate(**{key: value for key, value in snapshot.as_dict().items()})) + return plane + except Exception: + pass + return ZincControlPlane(snapshot=snapshot) diff --git a/prod/clean_arch/dita_v2/exec_router.py b/prod/clean_arch/dita_v2/exec_router.py new file mode 100644 index 0000000..64cb782 --- /dev/null +++ b/prod/clean_arch/dita_v2/exec_router.py @@ -0,0 +1,563 @@ +"""DITAv2 Execution Router — the execution-style layer (SOR seed). + +Decides HOW an intent reaches the venue (taker MARKET vs post-only maker +LIMIT, quote price, TTL, miss policy) — never WHETHER (that is the alpha +layer's job and is not touched here). This module is the abstraction the +S3 "Smart Order Router" TODO in ``adapters/bingx_direct.py`` calls for: +``submit`` paths stay thin; policy lives here; future improvements (OBF +depth gating, price-impact models, TWAP/iceberg) plug in via hooks. + +Design rules (DO NOT WEAKEN): + + 1. Exits are NEVER skipped. A maker exit that misses its TTL is always + escalated to MARKET. Only entries may be skipped. + 2. One working order per slot. While an entry (or exit) quote is + working, duplicate ENTER (or same-urgency EXIT) intents are + suppressed — this is the double-entry guard. An *urgent* exit + always preempts a working maker exit (cancel + MARKET). + 3. Bounded retries. ``entry_retries`` re-quotes maximum, then the + configured exhaust action (skip|market). No unbounded loops. + 4. Pure policy. This module does no I/O, no asyncio, no venue calls — + the runtime drives cancels/submits. That is what makes it testable + "to heavens and high back". + 5. Default config == legacy behavior (pure taker). With + ``DOLPHIN_PINK_EXEC_STYLE`` unset every plan is MARKET and the + registry stays empty. + +Hook points (each receives ``(plan_or_event, ctx)`` and may return a +replacement ``ExecutionPlan`` from ``pre_submit``; exceptions are isolated +and logged, never propagated to the trading path): + + - ``pre_plan`` — observe/adjust planning inputs + - ``pre_submit`` — last-look mutation of the plan (e.g. depth gate) + - ``on_working`` — a maker quote was registered as working + - ``on_fill`` — a working quote filled (or immediate fill) + - ``on_miss`` — a working entry expired and a miss action was taken + - ``on_escalate`` — a working exit expired / was preempted to MARKET + - ``on_cancel`` — a working quote was cancelled + +Configuration (env, parsed once by ``ExecConfig.from_env()``): + + DOLPHIN_PINK_EXEC_STYLE taker|maker_entry|maker_exit|maker_both [taker] + DOLPHIN_PINK_MAKER_ENTRY_TTL_S float seconds quote lifetime [8.0] + DOLPHIN_PINK_MAKER_EXIT_TTL_S float seconds quote lifetime [5.0] + DOLPHIN_PINK_MAKER_ENTRY_MISS skip|retry|market [skip] + DOLPHIN_PINK_MAKER_ENTRY_RETRIES int max re-quotes when MISS=retry [1] + DOLPHIN_PINK_MAKER_RETRY_EXHAUST skip|market after retries spent [skip] + DOLPHIN_PINK_MAKER_OFFSET_TICKS int quote distance from reference [1] + DOLPHIN_PINK_MAKER_MAX_SPREAD_BPS float; spread wider than this → taker [5.0] + DOLPHIN_PINK_POST_ONLY 0|1 send PostOnly TIF on maker quotes [1] + DOLPHIN_PINK_TICK_SIZE_ per-symbol tick override (e.g. _BTCUSDT) + +Maker-eligible exit reasons: TAKE_PROFIT only. CATASTROPHIC_LOSS, +MAX_HOLD, MEAN_REVERSION and anything unrecognised are urgent → MARKET. +""" + +from __future__ import annotations + +import logging +import os +import time +from dataclasses import dataclass, field, replace +from typing import Any, Callable, Dict, List, Optional, Tuple + +LOGGER = logging.getLogger("dita_v2.exec_router") + +# Exit reasons that tolerate a resting reduce-only quote. Everything else +# (stops, max-hold, mean-reversion flips, reconcile-driven closes) demands +# immediacy and is executed as taker MARKET regardless of style. +MAKER_EXIT_REASONS = frozenset({"TAKE_PROFIT"}) + +VALID_STYLES = ("taker", "maker_entry", "maker_exit", "maker_both") +VALID_MISS = ("skip", "retry", "market") +VALID_EXHAUST = ("skip", "market") + +HOOK_STAGES = ( + "pre_plan", "pre_submit", "on_working", "on_fill", + "on_miss", "on_escalate", "on_cancel", +) + +# Tick sizes from the BingX characterization sweep +# (prod/docs/BingX_FILL_CHARACTERIZATION_AND_ADVANTAGES.md §Precision). +DEFAULT_TICKS: Dict[str, float] = { + "BTCUSDT": 0.1, + "ETHUSDT": 0.01, + "AAVEUSDT": 0.01, + "SOLUSDT": 0.001, + "XRPUSDT": 0.0001, + "DOGEUSDT": 0.00001, + "SHIBUSDT": 1e-9, + "YFIUSDT": 0.01, + "XAUTUSDT": 0.1, + "ADAUSDT": 0.0001, + "TRXUSDT": 0.00001, + "ALGOUSDT": 0.0001, +} +_FALLBACK_TICK_FRACTION = 1e-5 # unknown symbol: ~0.1 bp of price + + +def _env_float(name: str, default: float, lo: float, hi: float) -> float: + raw = os.environ.get(name) + if raw is None or not str(raw).strip(): + return default + try: + val = float(str(raw).strip()) + except Exception: + LOGGER.warning("exec_router: bad %s=%r — using default %s", name, raw, default) + return default + if not (lo <= val <= hi): + clamped = min(max(val, lo), hi) + LOGGER.warning("exec_router: %s=%s outside [%s, %s] — clamped to %s", + name, val, lo, hi, clamped) + return clamped + return val + + +def _env_int(name: str, default: int, lo: int, hi: int) -> int: + return int(_env_float(name, float(default), float(lo), float(hi))) + + +def _env_choice(name: str, default: str, choices: Tuple[str, ...]) -> str: + raw = str(os.environ.get(name, default) or default).strip().lower() + if raw not in choices: + LOGGER.warning("exec_router: %s=%r not in %s — using %r", name, raw, choices, default) + return default + return raw + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.environ.get(name) + if raw is None or not str(raw).strip(): + return default + return str(raw).strip().lower() in ("1", "true", "yes", "on") + + +@dataclass(frozen=True) +class ExecConfig: + """Validated execution-policy configuration. Frozen: build once at boot.""" + + style: str = "taker" + entry_ttl_s: float = 8.0 + exit_ttl_s: float = 5.0 + entry_miss: str = "skip" + entry_retries: int = 1 + retry_exhaust: str = "skip" + offset_ticks: int = 1 + max_spread_bps: float = 5.0 + post_only: bool = True + tick_overrides: Dict[str, float] = field(default_factory=dict) + + @property + def maker_entry(self) -> bool: + return self.style in ("maker_entry", "maker_both") + + @property + def maker_exit(self) -> bool: + return self.style in ("maker_exit", "maker_both") + + @classmethod + def from_env(cls) -> "ExecConfig": + ticks: Dict[str, float] = {} + for key, raw in os.environ.items(): + if key.startswith("DOLPHIN_PINK_TICK_SIZE_"): + sym = key[len("DOLPHIN_PINK_TICK_SIZE_"):].upper() + try: + val = float(raw) + if val > 0: + ticks[sym] = val + except Exception: + LOGGER.warning("exec_router: bad tick override %s=%r", key, raw) + return cls( + style=_env_choice("DOLPHIN_PINK_EXEC_STYLE", "taker", VALID_STYLES), + entry_ttl_s=_env_float("DOLPHIN_PINK_MAKER_ENTRY_TTL_S", 8.0, 0.5, 300.0), + exit_ttl_s=_env_float("DOLPHIN_PINK_MAKER_EXIT_TTL_S", 5.0, 0.5, 300.0), + entry_miss=_env_choice("DOLPHIN_PINK_MAKER_ENTRY_MISS", "skip", VALID_MISS), + entry_retries=_env_int("DOLPHIN_PINK_MAKER_ENTRY_RETRIES", 1, 0, 10), + retry_exhaust=_env_choice("DOLPHIN_PINK_MAKER_RETRY_EXHAUST", "skip", VALID_EXHAUST), + offset_ticks=_env_int("DOLPHIN_PINK_MAKER_OFFSET_TICKS", 1, 0, 100), + max_spread_bps=_env_float("DOLPHIN_PINK_MAKER_MAX_SPREAD_BPS", 5.0, 0.0, 1000.0), + post_only=_env_bool("DOLPHIN_PINK_POST_ONLY", True), + tick_overrides=ticks, + ) + + +@dataclass(frozen=True) +class ExecutionPlan: + """How one intent should be executed. Produced by the router, consumed + by the runtime, forwarded to the venue via KernelIntent fields/metadata.""" + + order_type: str = "MARKET" # "MARKET" | "LIMIT" + limit_price: float = 0.0 + post_only: bool = False + ttl_s: float = 0.0 # 0 = no TTL management (taker) + is_maker: bool = False + action: str = "ENTER" # "ENTER" | "EXIT" + reason: str = "taker_default" # provenance for logs/persistence + suppress: bool = False # True → do not submit (dup guard) + metadata: Dict[str, Any] = field(default_factory=dict) + + def sane(self) -> bool: + if self.order_type not in ("MARKET", "LIMIT"): + return False + if self.order_type == "LIMIT" and not (self.limit_price > 0.0): + return False + return True + + +@dataclass +class WorkingOrder: + """Runtime-registered maker quote awaiting fill or TTL.""" + + trade_id: str + asset: str + side: str # "SHORT" | "LONG" (position side) + action: str # "ENTER" | "EXIT" + plan: ExecutionPlan + submitted_at: float # monotonic clock + deadline: float + retries_left: int + base_trade_id: str # original id before retry suffixes + retry_n: int = 0 + + +class MissAction: + SKIP = "skip" + RETRY = "retry" + MARKET = "market" + + +class ExecutionRouter: + """Pure-policy execution router with a working-order registry. + + The runtime asks ``plan_entry``/``plan_exit`` before each kernel + submission, registers maker quotes via ``register_working``, polls + ``expired`` from its TTL loop, and reports outcomes back via + ``note_fill``/``note_cancel``. All venue I/O stays in the runtime. + """ + + def __init__(self, config: Optional[ExecConfig] = None, *, + logger: Any = LOGGER, clock: Callable[[], float] = time.monotonic): + self.config = config or ExecConfig() + self.logger = logger + self.clock = clock + self._working: Dict[str, WorkingOrder] = {} # trade_id → WorkingOrder + self._hooks: Dict[str, List[Callable]] = {s: [] for s in HOOK_STAGES} + self.counters: Dict[str, int] = { + "plans_entry": 0, "plans_exit": 0, + "maker_entries": 0, "maker_exits": 0, + "taker_entries": 0, "taker_exits": 0, + "suppressed_dup_enter": 0, "suppressed_dup_exit": 0, + "spread_gate_taker": 0, + "entry_miss_skip": 0, "entry_miss_retry": 0, "entry_miss_market": 0, + "exit_escalations": 0, "fills_working": 0, "cancels": 0, + "hook_errors": 0, + } + + # ── hooks ──────────────────────────────────────────────────────────────── + + def register_hook(self, stage: str, fn: Callable) -> Callable[[], None]: + """Register ``fn`` at ``stage``; returns an unregister callable.""" + if stage not in self._hooks: + raise ValueError(f"unknown hook stage {stage!r}; valid: {HOOK_STAGES}") + self._hooks[stage].append(fn) + + def _unregister() -> None: + try: + self._hooks[stage].remove(fn) + except ValueError: + pass + return _unregister + + def _run_hooks(self, stage: str, payload: Any, ctx: Dict[str, Any]) -> Any: + """Run hooks; a ``pre_submit`` hook may return a replacement plan. + Hook exceptions are isolated — the trading path must never die in + a plugin.""" + out = payload + for fn in list(self._hooks.get(stage, ())): + try: + ret = fn(out, dict(ctx)) + if stage == "pre_submit" and isinstance(ret, ExecutionPlan): + if ret.sane(): + out = ret + else: + self.logger.warning( + "exec_router: hook %r returned insane plan — ignored", fn) + except Exception as exc: + self.counters["hook_errors"] += 1 + self.logger.warning("exec_router: hook %r failed at %s: %s", fn, stage, exc) + return out + + # ── pricing ────────────────────────────────────────────────────────────── + + def tick_size(self, asset: str) -> float: + sym = str(asset or "").upper() + if sym in self.config.tick_overrides: + return self.config.tick_overrides[sym] + return DEFAULT_TICKS.get(sym, 0.0) + + def maker_price(self, *, asset: str, order_side: str, reference_price: float) -> float: + """Quote price that rests on the book on our side of the touch. + + ``order_side`` is the ORDER side ("SELL"/"BUY"), not the position + side. SELL rests at/above reference; BUY rests at/below. Post-only + rejects any residual cross, so quoting at the touch is safe. + """ + ref = float(reference_price) + if not (ref > 0.0): + return 0.0 + tick = self.tick_size(asset) + if tick <= 0.0: + tick = ref * _FALLBACK_TICK_FRACTION + off = self.config.offset_ticks * tick + if str(order_side).upper() == "SELL": + return ref + off + return max(tick, ref - off) + + @staticmethod + def order_side(action: str, position_side: str) -> str: + """Map (action, position side) → order side, mirroring the adapter.""" + pos = str(position_side).upper() + if str(action).upper() == "EXIT": + return "SELL" if pos == "LONG" else "BUY" + return "BUY" if pos == "LONG" else "SELL" + + # ── planning ───────────────────────────────────────────────────────────── + + def _spread_allows_maker(self, spread_bps: Optional[float]) -> bool: + if spread_bps is None: + return True # no OBF data — quote anyway; post-only caps the risk + return float(spread_bps) <= self.config.max_spread_bps + + def plan_entry(self, *, trade_id: str, asset: str, position_side: str, + reference_price: float, + spread_bps: Optional[float] = None) -> ExecutionPlan: + """Plan an ENTER execution. Never raises; falls back to MARKET.""" + self.counters["plans_entry"] += 1 + ctx = {"trade_id": trade_id, "asset": asset, "side": position_side, + "reference_price": reference_price, "spread_bps": spread_bps, + "action": "ENTER"} + self._run_hooks("pre_plan", None, ctx) + + # Double-entry guard: a working entry means the slot is spoken for. + for wo in self._working.values(): + if wo.action == "ENTER": + self.counters["suppressed_dup_enter"] += 1 + return ExecutionPlan(action="ENTER", suppress=True, + reason=f"working_entry_exists:{wo.trade_id}") + + plan = ExecutionPlan(action="ENTER", reason="taker_default") + if self.config.maker_entry and reference_price > 0.0: + if not self._spread_allows_maker(spread_bps): + self.counters["spread_gate_taker"] += 1 + plan = ExecutionPlan(action="ENTER", + reason=f"spread_gate:{spread_bps}bps") + else: + side = self.order_side("ENTER", position_side) + px = self.maker_price(asset=asset, order_side=side, + reference_price=reference_price) + if px > 0.0: + plan = ExecutionPlan( + order_type="LIMIT", limit_price=px, + post_only=self.config.post_only, + ttl_s=self.config.entry_ttl_s, is_maker=True, + action="ENTER", reason="maker_entry", + ) + plan = self._run_hooks("pre_submit", plan, ctx) + if plan.is_maker: + self.counters["maker_entries"] += 1 + elif not plan.suppress: + self.counters["taker_entries"] += 1 + return plan + + def plan_exit(self, *, trade_id: str, asset: str, position_side: str, + reference_price: float, reason: str, + spread_bps: Optional[float] = None) -> ExecutionPlan: + """Plan an EXIT execution. + + RULE 1: exits are never skipped. A non-maker-eligible reason, a bad + reference price, or a wide spread all degrade to MARKET — never to + suppression, except the duplicate-guard case where a maker exit for + the SAME trade is already working and the new reason is equally + non-urgent (the resting quote IS the exit in flight). + """ + self.counters["plans_exit"] += 1 + urgent = str(reason or "").upper() not in MAKER_EXIT_REASONS + ctx = {"trade_id": trade_id, "asset": asset, "side": position_side, + "reference_price": reference_price, "spread_bps": spread_bps, + "action": "EXIT", "reason": reason, "urgent": urgent} + self._run_hooks("pre_plan", None, ctx) + + wo = self._working.get(trade_id) + if wo is not None and wo.action == "EXIT": + if not urgent: + # Same-trade maker exit already resting → nothing to add. + self.counters["suppressed_dup_exit"] += 1 + return ExecutionPlan(action="EXIT", suppress=True, + reason="working_exit_exists") + # Urgent reason preempts the resting quote: runtime must cancel + # the working order, then submit this MARKET plan. + self.counters["exit_escalations"] += 1 + plan = ExecutionPlan(action="EXIT", reason=f"escalate:{reason}", + metadata={"preempt_working": True}) + return self._run_hooks("pre_submit", plan, ctx) + + plan = ExecutionPlan(action="EXIT", reason=f"taker_exit:{reason}") + if (self.config.maker_exit and not urgent and reference_price > 0.0 + and self._spread_allows_maker(spread_bps)): + side = self.order_side("EXIT", position_side) + px = self.maker_price(asset=asset, order_side=side, + reference_price=reference_price) + if px > 0.0: + plan = ExecutionPlan( + order_type="LIMIT", limit_price=px, + post_only=self.config.post_only, + ttl_s=self.config.exit_ttl_s, is_maker=True, + action="EXIT", reason="maker_exit:TAKE_PROFIT", + ) + plan = self._run_hooks("pre_submit", plan, ctx) + if plan.suppress: + # RULE 1 enforcement against plugins: only the dup-guard branches + # above may suppress an exit; a hook returning suppress is + # overridden to MARKET so a position can never be stranded. + plan = ExecutionPlan(action="EXIT", reason="hook_suppress_overridden_market") + if not plan.sane(): + # Hard floor: an exit must reach the venue. Insane plan → MARKET. + plan = ExecutionPlan(action="EXIT", reason="sanity_fallback_market") + if plan.is_maker: + self.counters["maker_exits"] += 1 + elif not plan.suppress: + self.counters["taker_exits"] += 1 + return plan + + # ── working-order registry ─────────────────────────────────────────────── + + def register_working(self, *, trade_id: str, asset: str, position_side: str, + plan: ExecutionPlan, + base_trade_id: Optional[str] = None, + retry_n: int = 0) -> WorkingOrder: + now = self.clock() + wo = WorkingOrder( + trade_id=trade_id, asset=asset, side=str(position_side).upper(), + action=plan.action, plan=plan, submitted_at=now, + deadline=now + max(0.5, plan.ttl_s), + retries_left=self.config.entry_retries if plan.action == "ENTER" else 0, + base_trade_id=base_trade_id or trade_id, retry_n=retry_n, + ) + if retry_n > 0: + wo.retries_left = max(0, self.config.entry_retries - retry_n) + self._working[trade_id] = wo + self._run_hooks("on_working", wo, {"trade_id": trade_id}) + return wo + + def working(self, trade_id: str) -> Optional[WorkingOrder]: + return self._working.get(trade_id) + + def working_orders(self) -> List[WorkingOrder]: + return list(self._working.values()) + + def has_working_entry(self) -> bool: + return any(wo.action == "ENTER" for wo in self._working.values()) + + def expired(self, now: Optional[float] = None) -> List[WorkingOrder]: + t = self.clock() if now is None else now + return [wo for wo in self._working.values() if t >= wo.deadline] + + def note_fill(self, trade_id: str) -> None: + wo = self._working.pop(trade_id, None) + if wo is not None: + self.counters["fills_working"] += 1 + self._run_hooks("on_fill", wo, {"trade_id": trade_id}) + + def note_cancel(self, trade_id: str) -> None: + wo = self._working.pop(trade_id, None) + if wo is not None: + self.counters["cancels"] += 1 + self._run_hooks("on_cancel", wo, {"trade_id": trade_id}) + + def clear_working(self, trade_id: str) -> None: + self._working.pop(trade_id, None) + + # ── miss / escalation policy ───────────────────────────────────────────── + + def entry_miss_action(self, wo: WorkingOrder) -> str: + """Decide what to do with an expired working ENTRY (after the runtime + has cancelled the quote). Returns a ``MissAction``. + + retry policy: up to ``entry_retries`` fresh quotes, then + ``retry_exhaust`` (skip|market). ``entry_miss`` skip|market apply + immediately with no re-quote. + """ + mode = self.config.entry_miss + if mode == "skip": + self.counters["entry_miss_skip"] += 1 + action = MissAction.SKIP + elif mode == "market": + self.counters["entry_miss_market"] += 1 + action = MissAction.MARKET + else: # retry + if wo.retries_left > 0: + self.counters["entry_miss_retry"] += 1 + action = MissAction.RETRY + elif self.config.retry_exhaust == "market": + self.counters["entry_miss_market"] += 1 + action = MissAction.MARKET + else: + self.counters["entry_miss_skip"] += 1 + action = MissAction.SKIP + self._run_hooks("on_miss", wo, {"action": action}) + return action + + def retry_plan(self, wo: WorkingOrder, *, reference_price: float) -> Tuple[str, ExecutionPlan]: + """Fresh quote for a retried entry. Returns (new_trade_id, plan). + New trade_id guarantees clientOrderId uniqueness on the venue and a + clean kernel FSM lifecycle for the re-quote.""" + n = wo.retry_n + 1 + new_tid = f"{wo.base_trade_id}-r{n}" + side = self.order_side("ENTER", wo.side) + px = self.maker_price(asset=wo.asset, order_side=side, + reference_price=reference_price) + plan = ExecutionPlan( + order_type="LIMIT", limit_price=px, + post_only=self.config.post_only, + ttl_s=self.config.entry_ttl_s, is_maker=True, + action="ENTER", reason=f"maker_entry_retry_{n}", + metadata={"retry_n": n, "base_trade_id": wo.base_trade_id}, + ) + if not plan.sane(): + plan = ExecutionPlan(action="ENTER", reason="retry_price_insane_market", + metadata={"retry_n": n, "base_trade_id": wo.base_trade_id}) + return new_tid, plan + + def market_fallback_plan(self, wo: WorkingOrder) -> Tuple[str, ExecutionPlan]: + """MARKET fallback after a missed/escalated quote. + + ENTER: fresh trade_id (``-m`` suffix) — the cancelled quote's + lifecycle is closed; the fallback is a new order. + EXIT: SAME trade_id — the exit must stay attached to the open + position's lifecycle in the kernel FSM. + """ + if wo.action == "ENTER": + new_tid = f"{wo.base_trade_id}-m" + self._run_hooks("on_escalate", wo, {"to": "MARKET"}) + return new_tid, ExecutionPlan( + action="ENTER", reason="entry_miss_market_fallback", + metadata={"base_trade_id": wo.base_trade_id}) + self.counters["exit_escalations"] += 1 + self._run_hooks("on_escalate", wo, {"to": "MARKET"}) + return wo.trade_id, ExecutionPlan( + action="EXIT", reason="exit_ttl_market_fallback", + metadata={"base_trade_id": wo.base_trade_id}) + + # ── observability ──────────────────────────────────────────────────────── + + def snapshot(self) -> Dict[str, Any]: + return { + "style": self.config.style, + "working": [ + {"trade_id": w.trade_id, "action": w.action, "asset": w.asset, + "age_s": round(self.clock() - w.submitted_at, 3), + "retry_n": w.retry_n} + for w in self._working.values() + ], + "counters": dict(self.counters), + } diff --git a/prod/clean_arch/dita_v2/gen2.py b/prod/clean_arch/dita_v2/gen2.py new file mode 100644 index 0000000..d1dc25b --- /dev/null +++ b/prod/clean_arch/dita_v2/gen2.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""Write the complete 68-test live e2e file. Bodies receive (k, symbol, p) where p is a float.""" +import ast, os + +SCENARIOS = [] # (name, code_lines) + +def S(name, lines): + SCENARIOS.append((name, lines)) + +# ---- Original 9 ---- +S("simple_entry_exit", [ + "tid = f's-{int(time.time()*1000)}'", + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(1)", + "_si(k, E.EXIT, tid, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(1)", +]) +S("multi_leg_exit", [ + "tid = f'ml-{int(time.time()*1000)}'", + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.002, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(1)", + "_si(k, E.EXIT, tid, symbol, 'SHORT', p*0.995, 0.001, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(1)", + "_si(k, E.EXIT, tid, symbol, 'SHORT', p*0.993, 0.001, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(1)", +]) +S("cancel_entry_order", [ + "tid = f'ce-{int(time.time()*1000)}'", + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.3)", + "_si(k, E.CANCEL, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(1)", +]) +S("entry_hold_exit", [ + "tid = f'h-{int(time.time()*1000)}'", + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(3)", + "_si(k, E.EXIT, tid, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(1)", +]) +S("entry_exit_at_loss", [ + "tid = f'l-{int(time.time()*1000)}'", + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(1)", + "_si(k, E.EXIT, tid, symbol, 'SHORT', p*1.005, 0.001); await asyncio.sleep(1)", +]) +S("two_sequential_cycles", [ + "t1 = f'2c1-{int(time.time()*1000)}'; t2 = f'2c2-{int(time.time()*1000)}'", + "_si(k, E.ENTER, t1, symbol, 'SHORT', p, 0.001); await asyncio.sleep(1)", + "_si(k, E.EXIT, t1, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(1)", + "_si(k, E.ENTER, t2, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(1)", + "_si(k, E.EXIT, t2, symbol, 'SHORT', p*0.99, 0.001); await asyncio.sleep(1)", +]) +S("entry_then_recover", [ + "tid = f'r-{int(time.time()*1000)}'", + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(1)", + "await bundle.runtime.disconnect()", + "await bundle.runtime.connect(initial_capital=k.account.snapshot.capital)", + "await asyncio.sleep(1)", +]) +S("long_entry_exit", [ + "tid = f'ln-{int(time.time()*1000)}'", + "_si(k, E.ENTER, tid, symbol, 'LONG', p, 0.001); await asyncio.sleep(1)", + "_si(k, E.EXIT, tid, symbol, 'LONG', p*1.005, 0.001); await asyncio.sleep(1)", +]) + +# ---- Cancel combos ---- +S("cancel_idempotent", [ + "tid = f'ci-{int(time.time()*1000)}'", + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.5)", + "_si(k, E.CANCEL, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.3)", + "_si(k, E.CANCEL, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(1)", +]) +S("double_cancel", [ + "tid = f'dc-{int(time.time()*1000)}'", + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.3)", + "_si(k, E.CANCEL, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.3)", + "_si(k, E.CANCEL, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(1)", +]) +S("cancel_then_exit", [ + "tid = f'ctx-{int(time.time()*1000)}'", + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.5)", + "_si(k, E.CANCEL, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.3)", + "if not k.slot(0).is_free():", + " _si(k, E.EXIT, tid, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(1)", +]) +S("exit_then_cancel_exit", [ + "tid = f'exc-{int(time.time()*1000)}'", + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(1)", + "_si(k, E.EXIT, tid, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(0.3)", + "_si(k, E.CANCEL, tid, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(1)", +]) +S("exit_then_reentry", [ + "t1 = f'er1-{int(time.time()*1000)}'; t2 = f'er2-{int(time.time()*1000)}'", + "_si(k, E.ENTER, t1, symbol, 'SHORT', p, 0.001); await asyncio.sleep(1)", + "_si(k, E.EXIT, t1, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(0.3)", + "_si(k, E.ENTER, t2, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(1)", +]) +S("limit_cancel", [ + "tid = f'lc-{int(time.time()*1000)}'", + "_si(k, E.ENTER, tid, symbol, 'SHORT', p*0.9, 0.001); await asyncio.sleep(0.5)", + "_si(k, E.CANCEL, tid, symbol, 'SHORT', p*0.9, 0.001); await asyncio.sleep(1)", +]) + +# ---- X4 ---- +S("x4_partial_hold_exit", [ + "tid = f'ph-{int(time.time()*1000)}'; sz = 0.003", + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, sz, exit_leg_ratios=(0.3,1.0)); await asyncio.sleep(1)", + "_si(k, E.EXIT, tid, symbol, 'SHORT', p*0.995, sz*0.3, exit_leg_ratios=(0.3,1.0)); await asyncio.sleep(1)", + "_si(k, E.EXIT, tid, symbol, 'SHORT', p*0.993, sz*0.7, exit_leg_ratios=(0.3,1.0)); await asyncio.sleep(1)", +]) +S("x4_three_leg", [ + "tid = f'3l-{int(time.time()*1000)}'; sz = 0.004", + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, sz, exit_leg_ratios=(0.25,0.25,1.0)); await asyncio.sleep(1)", + "_si(k, E.EXIT, tid, symbol, 'SHORT', p*0.995, sz*0.25, exit_leg_ratios=(0.25,0.25,1.0)); await asyncio.sleep(1)", + "_si(k, E.EXIT, tid, symbol, 'SHORT', p*0.993, sz*0.25, exit_leg_ratios=(0.25,0.25,1.0)); await asyncio.sleep(1)", + "_si(k, E.EXIT, tid, symbol, 'SHORT', p*0.99, sz*0.5, exit_leg_ratios=(0.25,0.25,1.0)); await asyncio.sleep(1)", +]) +S("x4_cancel_fill_partial", [ + "tid = f'cfp-{int(time.time()*1000)}'", + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.002); await asyncio.sleep(0.5)", + "_si(k, E.CANCEL, tid, symbol, 'SHORT', p, 0.002); await asyncio.sleep(0.3)", + "if not k.slot(0).is_free():", + " _si(k, E.EXIT, tid, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(0.5)", + "if not k.slot(0).is_free():", + " _si(k, E.EXIT, tid, symbol, 'SHORT', p*0.993, 0.001); await asyncio.sleep(1)", +]) +S("x4_rapid_three", [ + "for i in range(3):", + " tid = f'r3-{i}-{int(time.time()*1000)}'", + " _si(k, E.ENTER, tid, symbol, 'SHORT', p*(1-i*0.005), 0.001); await asyncio.sleep(0.8)", + " _si(k, E.EXIT, tid, symbol, 'SHORT', p*0.995*(1-i*0.005), 0.001); await asyncio.sleep(0.8)", +]) +S("x4_diff_symbol", [ + "tid = f'ds-{int(time.time()*1000)}'", + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(1)", + "sym2 = 'BTCUSDT' if symbol != 'BTCUSDT' else 'ETHUSDT'", + "_si(k, E.EXIT, tid, sym2, 'SHORT', p, 0.001); await asyncio.sleep(0.5)", +]) +S("x4_alternating", [ + "t1 = f'as1-{int(time.time()*1000)}'; t2 = f'as2-{int(time.time()*1000)}'", + "_si(k, E.ENTER, t1, symbol, 'SHORT', p, 0.001); await asyncio.sleep(1)", + "sym2 = 'BTCUSDT' if symbol != 'BTCUSDT' else 'ETHUSDT'", + "try:", + " p2 = float(json.loads(urllib.request.urlopen('https://open-api-vst.bingx.com/openApi/swap/v2/quote/price?symbol='+sym2.replace('USDT','-USDT'), timeout=5).read())['data']['price'])", + "except: p2 = p", + "_si(k, E.ENTER, t2, sym2, 'LONG', p2, 0.001); await asyncio.sleep(1)", + "_si(k, E.EXIT, t1, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(1)", + "_si(k, E.EXIT, t2, sym2, 'LONG', p2*1.005, 0.001); await asyncio.sleep(1)", +]) +S("x4_multi_flatten", [ + "tid = f'mf-{int(time.time()*1000)}'", + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(1)", + "for i in range(3):", + " if k.slot(0).is_free(): break", + " _flatten(k, symbol, p*0.99, f'mf{i}'); await asyncio.sleep(0.5)", +]) +S("x4_three_leg_25_50_25", [ + "tid = f'x4a-{int(time.time()*1000)}'; sz = 0.004", + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, sz, exit_leg_ratios=(0.25,0.5,1.0)); await asyncio.sleep(1)", + "_si(k, E.EXIT, tid, symbol, 'SHORT', p*0.995, sz*0.25, exit_leg_ratios=(0.25,0.5,1.0)); await asyncio.sleep(1)", + "_si(k, E.EXIT, tid, symbol, 'SHORT', p*0.993, sz*0.5, exit_leg_ratios=(0.25,0.5,1.0)); await asyncio.sleep(1)", + "_si(k, E.EXIT, tid, symbol, 'SHORT', p*0.99, sz*0.25, exit_leg_ratios=(0.25,0.5,1.0)); await asyncio.sleep(1)", +]) +S("x4_enter_exit_hold_twice", [ + "t1 = f'x4b1-{int(time.time()*1000)}'", + "_si(k, E.ENTER, t1, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.5)", + "_si(k, E.EXIT, t1, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(0.5)", + "t2 = f'x4b2-{int(time.time()*1000)}'", + "_si(k, E.ENTER, t2, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(0.5)", + "_si(k, E.EXIT, t2, symbol, 'SHORT', p*0.99, 0.001); await asyncio.sleep(0.5)", + "t3 = f'x4b3-{int(time.time()*1000)}'", + "_si(k, E.ENTER, t3, symbol, 'SHORT', p*0.99, 0.001); await asyncio.sleep(0.5)", + "_si(k, E.EXIT, t3, symbol, 'SHORT', p*0.985, 0.001); await asyncio.sleep(0.5)", +]) +S("x4_cancel_then_double_exit", [ + "tid = f'x4c-{int(time.time()*1000)}'; sz = 0.002", + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, sz, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.5)", + "_si(k, E.CANCEL, tid, symbol, 'SHORT', p, sz); await asyncio.sleep(0.3)", + "if not k.slot(0).is_free():", + " _si(k, E.EXIT, tid, symbol, 'SHORT', p*0.995, sz*0.5, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.5)", + "if not k.slot(0).is_free():", + " _si(k, E.EXIT, tid, symbol, 'SHORT', p*0.993, sz*0.5, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.5)", +]) + +# ---- 2 sides x 2 profit x 4 patterns = 16 doubled ---- +for side, side_str, ep in [("short","SHORT",0.995), ("long","LONG",1.005)]: + for prof, pname, xp in [(True,"profit",ep), (False,"loss",1/ep)]: + for pat, pat_suffix, lines in [ + ("basic", "", [ + f"_si(k, E.ENTER, tid, symbol, '{side_str}', p, 0.001); await asyncio.sleep(0.8)", + f"_si(k, E.EXIT, tid, symbol, '{side_str}', p*{xp}, 0.001); await asyncio.sleep(0.8)", + ]), + ("partial", "_partial", [ + "sz = 0.002", + f"_si(k, E.ENTER, tid, symbol, '{side_str}', p, sz, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.8)", + f"_si(k, E.EXIT, tid, symbol, '{side_str}', p*{ep}, sz*0.5, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.8)", + f"_si(k, E.EXIT, tid, symbol, '{side_str}', p*{xp}, sz*0.5, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.8)", + ]), + ("cancel", "_cancel", [ + f"_si(k, E.ENTER, tid, symbol, '{side_str}', p, 0.001); await asyncio.sleep(0.3)", + f"_si(k, E.CANCEL, tid, symbol, '{side_str}', p, 0.001); await asyncio.sleep(0.3)", + "if not k.slot(0).is_free():", + f" _si(k, E.EXIT, tid, symbol, '{side_str}', p*{xp}, 0.001); await asyncio.sleep(0.8)", + ]), + ("double_exit", "_double_exit", [ + f"_si(k, E.ENTER, tid, symbol, '{side_str}', p, 0.001); await asyncio.sleep(0.8)", + f"_si(k, E.EXIT, tid, symbol, '{side_str}', p*{xp}, 0.001); await asyncio.sleep(0.3)", + "if not k.slot(0).is_free():", + f" _si(k, E.EXIT, tid, symbol, '{side_str}', p*{xp}*0.995, 0.001); await asyncio.sleep(0.5)", + ]), + ]: + pfx = f"{pat[0]}{side[0]}{chr(112) if prof else chr(108)}" + S(f"{pat}_{side}_{pname}", [ + f"tid = f'{pfx}-{{{{int(time.time()*1000)}}}}'", + *lines, + ]) + +# ---- Triple seq x 4 SHORT + 4 LONG ---- +for i in range(4): + S(f"triple_seq_{i}", [ + "for j in range(3):", + f" tid = f'ts{i}-j-{{{{int(time.time()*1000)}}}}'", + " _si(k, E.ENTER, tid, symbol, 'SHORT', p*(1-j*0.003), 0.001); await asyncio.sleep(0.7)", + " _si(k, E.EXIT, tid, symbol, 'SHORT', p*0.995*(1-j*0.003), 0.001); await asyncio.sleep(0.7)", + ]) +for i in range(4): + S(f"triple_seq_long_{i}", [ + "for j in range(3):", + f" tid = f'tsl{i}-j-{{{{int(time.time()*1000)}}}}'", + " _si(k, E.ENTER, tid, symbol, 'LONG', p*(1+j*0.003), 0.001); await asyncio.sleep(0.7)", + " _si(k, E.EXIT, tid, symbol, 'LONG', p*1.005*(1+j*0.003), 0.001); await asyncio.sleep(0.7)", + ]) + +# ---- Cancel+reenter x 4 SHORT + 4 LONG ---- +for i in range(4): + S(f"cancel_reenter_{i}", [ + f"t1 = f'cr{i}a-{{{{int(time.time()*1000)}}}}'; t2 = f'cr{i}b-{{{{int(time.time()*1000)}}}}'", + "_si(k, E.ENTER, t1, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.3)", + "_si(k, E.CANCEL, t1, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.3)", + "_si(k, E.ENTER, t2, symbol, 'SHORT', p*0.995, 0.001); await asyncio.sleep(0.8)", + "if not k.slot(0).is_free():", + " _si(k, E.EXIT, t2, symbol, 'SHORT', p*0.99, 0.001); await asyncio.sleep(0.5)", + ]) +for i in range(4): + S(f"cancel_reenter_long_{i}", [ + f"t1 = f'crl{i}a-{{{{int(time.time()*1000)}}}}'; t2 = f'crl{i}b-{{{{int(time.time()*1000)}}}}'", + "_si(k, E.ENTER, t1, symbol, 'LONG', p, 0.001); await asyncio.sleep(0.3)", + "_si(k, E.CANCEL, t1, symbol, 'LONG', p, 0.001); await asyncio.sleep(0.3)", + "_si(k, E.ENTER, t2, symbol, 'LONG', p*1.005, 0.001); await asyncio.sleep(0.8)", + "if not k.slot(0).is_free():", + " _si(k, E.EXIT, t2, symbol, 'LONG', p*1.01, 0.001); await asyncio.sleep(0.5)", + ]) + +# ---- Leg ratios x 8 ---- +for i, ratios in enumerate([ + (0.1,1.0), (0.33,0.33,1.0), (0.5,0.5,1.0), (0.75,1.0), + (0.2,0.3,0.5,1.0), (0.4,0.6,1.0), (0.15,0.85,1.0), (0.25,0.25,0.5,1.0), +]): + rat_str = ",".join(str(r) for r in ratios) + code = [f"tid = f'lr{i}-{{{{int(time.time()*1000)}}}}'; sz = 0.004", + f"_si(k, E.ENTER, tid, symbol, 'SHORT', p, sz, exit_leg_ratios=({rat_str})); await asyncio.sleep(1)"] + for leg in range(len(ratios) - 1): + r = ratios[leg] + code.append(f"_si(k, E.EXIT, tid, symbol, 'SHORT', p*0.995*(1-{leg}*0.002), sz*{r}, exit_leg_ratios=({rat_str})); await asyncio.sleep(0.8)") + code.append(f"_si(k, E.EXIT, tid, symbol, 'SHORT', p*0.99, sz*{ratios[-1]}, exit_leg_ratios=({rat_str})); await asyncio.sleep(0.8)") + S(f"leg_ratio_{i}", code) + +# ---- Breakeven x 4 ---- +for i in range(4): + S(f"breakeven_{i}", [ + f"tid = f'be{i}-{{{{int(time.time()*1000)}}}}'", + "_si(k, E.ENTER, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.8)", + "_si(k, E.EXIT, tid, symbol, 'SHORT', p, 0.001); await asyncio.sleep(0.8)", + ]) + +# ===================================================================== +# Assemble +# ===================================================================== +HEADER = '''#!/usr/bin/env python3 +"""PINK DITAv2 Live BingX Testnet E2E — 68 combinatorial scenarios. + +Kernel-direct tests: bodies receive (k, symbol, p). Capital integrity +asserted. Exchange state confirmed flat. +""" + +from __future__ import annotations + +import asyncio, json, os, socket, time, urllib.request +import urllib.parse +from dataclasses import dataclass +from typing import Any, Optional + +import pytest +from prod.bingx.http import BingxHttpClient +from prod.bingx.config import BingxExecClientConfig, BingxEnvironment +from prod.clean_arch.dita_v2.launcher import build_launcher_bundle +from prod.clean_arch.dita_v2.contracts import ( + KernelCommandType as KC, KernelIntent as KI, TradeSide as TS, +) +from prod.clean_arch.ports.data_feed import MarketSnapshot + +E = KC + +# Force IPv4 for httpx (IPv6 resolution fails in this env) +_orig_gai = socket.getaddrinfo +def _ipv4_gai(host, port, family=0, type=0, proto=0, flags=0): + return _orig_gai(host, port, socket.AF_INET, type, proto, flags) +socket.getaddrinfo = _ipv4_gai + +# ---- env gates ---- +if not os.environ.get("BINGX_SMOKE_LIVE"): + pytest.skip("BINGX_SMOKE_LIVE not set", allow_module_level=True) +if not os.environ.get("BINGX_SMOKE_ALLOW_TRADE"): + pytest.skip("BINGX_SMOKE_ALLOW_TRADE not set", allow_module_level=True) +if not os.environ.get("PINK_DITA_E2E"): + pytest.skip("PINK_DITA_E2E not set", allow_module_level=True) + +# ---- helpers ---- +@dataclass +class VR: + symbol: str; positions_flat: bool = True; error: str = "" + +@dataclass +class RB: + runtime: Any; config: Any + +def _build_config(ic: float = 25000.0) -> BingxExecClientConfig: + return BingxExecClientConfig( + api_key=os.environ["BINGX_API_KEY"], secret_key=os.environ["BINGX_SECRET_KEY"], + environment=BingxEnvironment.VST, allow_mainnet=False, recv_window_ms=5000, + default_leverage=1, exchange_leverage_cap=3, prefer_websocket=False, + use_reduce_only=True, sizing_mode="testnet", journal_strategy="pink", + journal_db="dolphin_pink") + +def _build_rb(ic: float = 25000.0) -> RB: + cfg = _build_config(ic) + b = build_launcher_bundle(venue_mode="BINGX", max_slots=1, bingx_config=cfg) + k = b.kernel; k.account.snapshot.capital = ic; k.account.snapshot.peak_capital = ic; k.account.snapshot.equity = ic + class Shim: + def __init__(self, k): self.kernel = k + async def connect(self, initial_capital=0): self.kernel.venue.connect() + async def disconnect(self): + try: self.kernel.venue.disconnect() + except: pass + return RB(runtime=Shim(k), config=cfg) + +async def _contract_rows(c): + r = await c._request_json("GET", "/openApi/swap/v2/user/positions", {}, signed=True) + return r if isinstance(r, list) else (r.get("data") or r.get("positions") or []) + +async def _pick_sym(k, c): + rs = await _contract_rows(c) + oss = {str(r.get("symbol","")).replace("-","").upper() for r in rs} + sym = next((x for x in ["TRXUSDT","XRPUSDT","ADAUSDT","DOGEUSDT"] if x not in oss), "TRXUSDT") + return sym + +async def _snap(c, sym): + vs = sym[:3]+"-USDT" + pr = await c._request_json("GET", "/openApi/swap/v2/quote/price", {"symbol": vs}, signed=False) + d = pr.get("data") or pr; rp = float(d.get("price") or d.get("lastPrice") or 0) + return MarketSnapshot(timestamp=__import__("datetime").datetime.now(__import__("datetime").timezone.utc), + symbol=sym, price=rp, bid=rp*0.9995, ask=rp*1.0005), vs + +async def _verify(c, vs): + rs = await _contract_rows(c) + tr = [r for r in rs if str(r.get("symbol","")).upper().replace("-","") == vs.replace("-","").upper()] + ts = sum(abs(float(r.get("positionAmt",r.get("positionQty",0)) or 0)) for r in tr) + flat = ts < 1e-8 + return VR(symbol=vs, positions_flat=flat, error="" if flat else f"open: {tr}") + +def _si(k, act, tid, asset, side_str, price, size, **kw): + ds = TS.SHORT if side_str.upper() == "SHORT" else TS.LONG + return k.process_intent(KI( + timestamp=__import__("datetime").datetime.now(__import__("datetime").timezone.utc), + intent_id=tid, trade_id=tid, slot_id=0, asset=asset, side=ds, action=act, + reference_price=price, target_size=size, leverage=kw.pop("leverage",1.0), + exit_leg_ratios=kw.pop("exit_leg_ratios",(1.0,)), + reason=kw.pop("reason",f"auto_{act.value.lower()}"), metadata=kw)) + +def _flatten(k, sym, price, label): + if k.slot(0).is_free(): return + _si(k, E.EXIT, f"fl{label}-{int(time.time()*1000)}", sym, "SHORT", price, 0.001) + +async def _run(bundle, client, body_fn, label, ic): + k = bundle.runtime.kernel + sym = await _pick_sym(k, client) + snap, vsym = await _snap(client, sym) + await bundle.runtime.connect(initial_capital=ic) + p = float(snap.price) + try: + _flatten(k, sym, p, f"{label}-pre") + await asyncio.sleep(0.3) + cb = k.account.snapshot.capital + await body_fn(k, sym, p) + ca = k.account.snapshot.capital + assert ca > 0, f"Capital zero: {ca}" + assert ca < cb * 10, f"Capital bounds: {cb} -> {ca}" + if not k.slot(0).is_free(): + _flatten(k, sym, p*0.99, f"{label}-post") + await asyncio.sleep(1.0) + return await _verify(client, vsym) + finally: + await bundle.runtime.disconnect() +''' + +lines = [HEADER] + +# Scenario bodies +lines.append("\n# =====================================================================\n# Scenario bodies\n# =====================================================================\n") + +for name, code_lines in SCENARIOS: + lines.append(f"async def _body_{name}(k, symbol, p):") + for cl in code_lines: + lines.append(f" {cl}") + lines.append("") + +# Test functions +lines.append("\n# =====================================================================\n# Test functions\n# =====================================================================\n") +lines.append('''@pytest.fixture(scope="session") +def _live_client(): + return BingxHttpClient(_build_config()) +''') + +for name, _ in SCENARIOS: + lines.append(f''' +def test_pink_ditav2_{name}(_live_client) -> None: + bundle = _build_rb() + ic = bundle.runtime.kernel.account.snapshot.capital + r = asyncio.run(_run(bundle, _live_client, _body_{name}, "{name}", ic)) + assert r.positions_flat, name + ": " + r.error +''') + +full = '\n'.join(lines) + +try: + ast.parse(full) + count = full.count("def test_pink_ditav2_") + print(f"Syntax OK — {count} tests, {len(full)} chars") + out_path = os.path.join('/mnt/dolphinng5_predict', 'prod/tests/test_pink_bingx_dita_live_e2e.py') + with open(out_path, 'w') as f: + f.write(full) + print(f"Written OK ({count} tests)") +except SyntaxError as e: + print(f"Syntax error L{e.lineno}: {e.msg}") + fl = full.split('\n') + for i in range(max(0,e.lineno-5), min(len(fl), e.lineno+3)): + print(f" {i+1}: {fl[i]}") diff --git a/prod/clean_arch/dita_v2/gen_live_tests.py b/prod/clean_arch/dita_v2/gen_live_tests.py new file mode 100644 index 0000000..5e8b17e --- /dev/null +++ b/prod/clean_arch/dita_v2/gen_live_tests.py @@ -0,0 +1,688 @@ +#!/usr/bin/env python3 +"""Regenerate the complete PINK DITAv2 live BingX e2e test file from scratch.""" +import ast, os + +BASE = '/mnt/dolphinng5_predict' +OUT = os.path.join(BASE, 'prod/tests/test_pink_bingx_dita_live_e2e.py') + +# ===================================================================== +# Static prologue — imports, helpers, env check +# ===================================================================== +PROLOGUE = r'''#!/usr/bin/env python3 +"""PINK DITAv2 Live BingX Testnet E2E — combinatorial scenarios. + +Each test: + 1. Picks a live VST symbol with price + 2. Submits KernelIntent directly (bypasses DecisionEngine) + 3. Asserts capital integrity (positive, within bounds) + 4. Confirms exchange state is flat after exit +""" + +from __future__ import annotations + +import asyncio +import json +import os +import time +import urllib.parse +import urllib.request +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any, Optional + +import pytest +import requests +from prod.bingx.http import BingxHttpClient +from prod.bingx.config import BingxExecClientConfig, BingxEnvironment +from prod.bingx.schemas import BingxContract +from prod.clean_arch.dita_v2.launcher import build_launcher_bundle +from prod.clean_arch.dita_v2.contracts import ( + KernelCommandType, + KernelDiagnosticCode, + KernelIntent, + KernelOutcome, + TradeSide, +) +from prod.clean_arch.ports.data_feed import MarketSnapshot +from prod.clean_arch.dita import DecisionConfig, DecisionEngine, IntentEngine +from prod.clean_arch.runtime.pink_direct import PinkDirectRuntime +from prod.clean_arch.projection import build_projection +from prod.clean_arch.adapters.hazelcast_feed import HazelcastDataFeed + +# ---- env gates ---- +if not os.environ.get("BINGX_SMOKE_LIVE"): + pytest.skip("BINGX_SMOKE_LIVE not set — skipping live tests", allow_module_level=True) +if not os.environ.get("BINGX_SMOKE_ALLOW_TRADE"): + pytest.skip("BINGX_SMOKE_ALLOW_TRADE not set — skipping live trade tests", allow_module_level=True) +if not os.environ.get("PINK_DITA_E2E"): + pytest.skip("PINK_DITA_E2E not set — skipping PINK DITAv2 e2e tests", allow_module_level=True) + +_INTER_TEST_DELAY_S = 3.0 + +def _wait_for_quota() -> None: + """Block until the exchange rate-limit quota allows a burst.""" + time.sleep(_INTER_TEST_DELAY_S) + +def _normalize(symbol: str) -> str: + return symbol.replace("-", "").upper() + +async def _contract_rows(client: BingxHttpClient) -> list[dict]: + url = "https://open-api-vst.bingx.com/openApi/swap/v2/user/positions" + rows = await client._request_json("GET", url, {}, signed=True) + data = rows if isinstance(rows, list) else (rows.get("data") or rows.get("positions") or []) + return data + +async def _build_live_snapshot(client: BingxHttpClient, vsymbol: str) -> MarketSnapshot: + vsym_dash = vsymbol.replace("USDT", "-USDT") + price_resp = await client._request_json("GET", "https://open-api-vst.bingx.com/openApi/swap/v2/quote/price", {"symbol": vsym_dash}, signed=False) + d = price_resp.get("data") or price_resp + raw_price = d.get("price") or d.get("lastPrice") or 0 + price = Decimal(str(raw_price)) + return MarketSnapshot( + timestamp=time.time(), price=price, bid=price * Decimal("0.9995"), + ask=price * Decimal("1.0005"), volume=Decimal("0"), + ) + +@dataclass +class _VerificationResult: + symbol: str + positions_flat: bool = True + error: str = "" + +async def _query_exchange_positions(client: BingxHttpClient, venue_symbol: str) -> list[dict]: + """Fetch live positions from BingX and return rows for venue_symbol.""" + rows = _contract_rows(client) + return [r for r in rows if str(r.get("symbol", "")).upper().replace("-", "") == venue_symbol.replace("-", "").upper()] + +async def _verify_exchange_state( + client: BingxHttpClient, venue_symbol: str, expect_open: bool = False, +) -> _VerificationResult: + pos_rows = await _query_exchange_positions(client, venue_symbol) + total_size = sum(abs(float(r.get("positionAmt", r.get("positionQty", 0)) or 0)) for r in pos_rows) + flat = total_size < 1e-8 + if expect_open and flat: + return _VerificationResult(symbol=venue_symbol, positions_flat=False, error="expected open position but flat") + if not expect_open and not flat: + return _VerificationResult(symbol=venue_symbol, positions_flat=False, error=f"expected flat but open: {pos_rows}") + return _VerificationResult(symbol=venue_symbol, positions_flat=True) + +@dataclass +class _RuntimeBundle: + runtime: PinkDirectRuntime + config: BingxExecClientConfig + +def _build_bingx_config(initial_capital: float) -> BingxExecClientConfig: + return BingxExecClientConfig( + api_key=os.environ["BINGX_API_KEY"], + secret_key=os.environ["BINGX_SECRET_KEY"], + environment=BingxEnvironment.VST, + allow_mainnet=False, + recv_window_ms=5000, + default_leverage=1, + exchange_leverage_cap=3, + prefer_websocket=False, + use_reduce_only=True, + sizing_mode="testnet", + journal_strategy="pink", + journal_db="dolphin_pink", + ) + +def _build_runtime_bundle(initial_capital: float) -> _RuntimeBundle: + """Build a direct kernel bundle.""" + cfg = _build_bingx_config(initial_capital) + bundle = build_launcher_bundle(venue_mode="BINGX", max_slots=1, bingx_config=cfg) + k = bundle.kernel + k.account.snapshot.capital = initial_capital + k.account.snapshot.peak_capital = initial_capital + k.account.snapshot.equity = initial_capital + return _RuntimeBundle(runtime=_RuntimeShim(kernel=k), config=cfg) + +class _RuntimeShim: + """Minimal runtime wrapper — exposes .kernel + sync connect/disconnect.""" + def __init__(self, kernel): self.kernel = kernel + async def connect(self, initial_capital=0): self.kernel.venue.connect() + async def disconnect(self): + try: self.kernel.venue.disconnect() + except Exception: pass + +def _build_full_runtime(initial_capital: float) -> PinkDirectRuntime: + """Build a fully wired PinkDirectRuntime (data feed, engine, persistence).""" + cfg = _build_bingx_config(initial_capital) + bundle = build_launcher_bundle(venue_mode="BINGX", max_slots=1, bingx_config=cfg) + feed = HazelcastDataFeed( + prefix="dita_v2", + hz_client=build_projection(prefer_real_hazelcast=False), + ) + engine = DecisionEngine(DecisionConfig(initial_capital=initial_capital)) + intent_engine = IntentEngine(initial_capital=initial_capital) + rt = PinkDirectRuntime( + data_feed=feed, kernel=bundle.kernel, + decision_engine=engine, intent_engine=intent_engine, + ) + rt.kernel.account.snapshot.capital = initial_capital + rt.kernel.account.snapshot.peak_capital = initial_capital + rt.kernel.account.snapshot.equity = initial_capital + return rt + +async def _pick_live_symbol( + kernel: Any, client: BingxHttpClient, +) -> tuple[str, MarketSnapshot, str]: + """Pick a live VST symbol that isn't already in a position.""" + pos_rows = _contract_rows(client) + open_syms = set() + for r in pos_rows: + sym = str(r.get("symbol", "")).replace("-", "").upper() + if sym: + open_syms.add(sym) + candidates = ["TRXUSDT", "XRPUSDT", "ADAUSDT", "DOGEUSDT"] + preferred = [c for c in candidates if c not in open_syms] + sym = preferred[0] if preferred else candidates[0] + vsym = sym[:3] + "-USDT" if sym.endswith("USDT") and len(sym) > 6 else sym[:3] + "-USDT" + snap = _build_live_snapshot(client, vsym) + return sym, snap, vsym + +def _submit_intent_direct( + kernel: Any, + action: KernelCommandType, + trade_id: str, + asset: str, + side_str: str, + price: float, + size: float, + **kw, +) -> KernelOutcome: + ds = TradeSide.SHORT if side_str.upper() == "SHORT" else TradeSide.LONG + intent = KernelIntent( + timestamp=__import__("datetime").datetime.now(__import__("datetime").timezone.utc), + intent_id=trade_id, + trade_id=trade_id, + slot_id=0, + asset=asset, + side=ds, + action=action, + reference_price=price, + target_size=size, + leverage=kw.pop("leverage", 1.0), + exit_leg_ratios=kw.pop("exit_leg_ratios", (1.0,)), + reason=kw.pop("reason", f"auto_{action.value.lower()}"), + metadata=kw, + ) + return kernel.process_intent(intent) + +def _flatten_via_kernel_intent(kernel: Any, symbol: str, price: float, label: str) -> None: + """Flatten slot 0 by submitting an EXIT intent at the given price. + No-op if already flat.""" + if kernel.slot(0).is_free(): + return + tid = f"flat-{label}-{int(time.time() * 1000)}" + side = TradeSide.SHORT + intent = KernelIntent( + timestamp=__import__("datetime").datetime.now(__import__("datetime").timezone.utc), + intent_id=tid, + trade_id=tid, + slot_id=0, + asset=symbol, + side=side, + action=KernelCommandType.EXIT, + reference_price=price, + target_size=0.001, + leverage=1.0, + exit_leg_ratios=(1.0,), + reason=f"flatten_{label}", + ) + kernel.process_intent(intent) + +async def _flatten_live_position(client: BingxHttpClient, symbol: str) -> None: + """Emergency raw flatten via REST if kernel can't.""" + pass + +async def _run_pink_live_roundtrip( + bundle: _RuntimeBundle, client: BingxHttpClient, +) -> tuple[KernelOutcome, Optional[KernelOutcome], Optional[KernelOutcome]]: + """Original roundtrip test entry → partial/monitor → flatten.""" + kernel = bundle.runtime.kernel + symbol, snap, vsym = await _pick_live_symbol(kernel, client) + price = float(snap.price) + await bundle.runtime.connect(initial_capital=25000.0) + try: + _flatten_via_kernel_intent(kernel, symbol, price, "roundtrip-pre") + await asyncio.sleep(0.3) + tid = f"rt-{int(time.time() * 1000)}" + entry = _submit_intent_direct(kernel, KernelCommandType.ENTER, tid, symbol, "SHORT", price, 0.001) + await asyncio.sleep(1.0) + monitor = None + if not kernel.slot(0).is_free(): + _submit_intent_direct(kernel, KernelCommandType.CANCEL, tid, symbol, "SHORT", price, 0.001) + await asyncio.sleep(0.3) + flatt = None + if not kernel.slot(0).is_free(): + flatt = _submit_intent_direct(kernel, KernelCommandType.EXIT, tid, symbol, "SHORT", price * 0.995, 0.001) + await asyncio.sleep(1.0) + if not kernel.slot(0).is_free(): + _flatten_via_kernel_intent(kernel, symbol, price * 0.99, "roundtrip-post") + await asyncio.sleep(1.0) + return entry, monitor, flatt + finally: + await bundle.runtime.disconnect() + +async def _run_pink_live_recovery( + bundle: _RuntimeBundle, client: BingxHttpClient, +) -> dict: + """Recovery test: enter, disconnect, reconnect, verify capital preserved.""" + kernel = bundle.runtime.kernel + symbol, snap, vsym = await _pick_live_symbol(kernel, client) + price = float(snap.price) + await bundle.runtime.connect(initial_capital=25000.0) + try: + _flatten_via_kernel_intent(kernel, symbol, price, "recovery-pre") + await asyncio.sleep(0.3) + _submit_intent_direct(kernel, KernelCommandType.ENTER, tid := f"r-{int(time.time() * 1000)}", symbol, "SHORT", price, 0.001) + await asyncio.sleep(1.0) + await bundle.runtime.disconnect() + await bundle.runtime.connect(initial_capital=25000.0) + await asyncio.sleep(1.0) + if not kernel.slot(0).is_free(): + _flatten_via_kernel_intent(kernel, symbol, price * 0.99, "recovery-post") + await asyncio.sleep(1.0) + return {"capital": kernel.account.snapshot.capital, "peak": kernel.account.snapshot.peak_capital} + finally: + await bundle.runtime.disconnect() +''' # end PROLOGUE + +# ===================================================================== +# Scenario runner + shortcut +# ===================================================================== +RUNNER = ''' +# ===================================================================== +# Generic runner & shortcut +# ===================================================================== + +async def _run_scenario(bundle, client, body_fn, label, initial_capital): + k = bundle.runtime.kernel + symbol, snap, vsym = await _pick_live_symbol(k, client) + await bundle.runtime.connect(initial_capital=initial_capital) + try: + _flatten_via_kernel_intent(k, symbol, float(snap.price), f"{label}-pre") + await asyncio.sleep(0.3) + _cap_before = k.account.snapshot.capital + await body_fn(bundle, client, symbol, snap) + _cap_after = k.account.snapshot.capital + assert _cap_after > 0, f"Capital went to zero: {_cap_after}" + assert _cap_after < _cap_before * 10, f"Capital growth beyond bounds: {_cap_before} -> {_cap_after}" + if not k.slot(0).is_free(): + _flatten_via_kernel_intent(k, symbol, float(snap.price) * 0.99, f"{label}-post") + await asyncio.sleep(1.0) + return await _verify_exchange_state(client, vsym, expect_open=False) + finally: + await bundle.runtime.disconnect() + + +def _si(kernel, action, trade_id, asset, side_str, price, size, **kw): + ds = TradeSide.SHORT if side_str.upper() == "SHORT" else TradeSide.LONG + return kernel.process_intent(KernelIntent( + timestamp=__import__("datetime").datetime.now(__import__("datetime").timezone.utc), + intent_id=trade_id, trade_id=trade_id, slot_id=0, asset=asset, + side=ds, action=action, reference_price=price, target_size=size, + leverage=kw.pop("leverage", 1.0), + exit_leg_ratios=kw.pop("exit_leg_ratios", (1.0,)), + reason=kw.pop("reason", f"auto_{action.value.lower()}"), + metadata=kw, + )) +''' + +# ===================================================================== +# Build scenario bodies + tests +# ===================================================================== +scenarios = [] # (name, code_lines) + +def S(name, code_lines): + scenarios.append((name, list(code_lines))) + +# --- Original 9 --- +S("simple_entry_exit", [ + 'tid = f"s-{int(time.time()*1000)}"; p = float(snap.price)', + '_si(k, KernelCommandType.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(1)', + '_si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(1)', +]) +S("multi_leg_exit", [ + 'tid = f"ml-{int(time.time()*1000)}"; p = float(snap.price)', + '_si(k, KernelCommandType.ENTER, tid, symbol, "SHORT", p, 0.002, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(1)', + '_si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*0.995, 0.001, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(1)', + '_si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*0.993, 0.001, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(1)', +]) +S("cancel_entry_order", [ + 'tid = f"ce-{int(time.time()*1000)}"; p = float(snap.price)', + '_si(k, KernelCommandType.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + '_si(k, KernelCommandType.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(1)', +]) +S("entry_hold_exit", [ + 'tid = f"h-{int(time.time()*1000)}"; p = float(snap.price)', + '_si(k, KernelCommandType.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(3)', + '_si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(1)', +]) +S("entry_exit_at_loss", [ + 'tid = f"l-{int(time.time()*1000)}"; p = float(snap.price)', + '_si(k, KernelCommandType.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(1)', + '_si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*1.005, 0.001); await asyncio.sleep(1)', +]) +S("two_sequential_cycles", [ + 'p = float(snap.price)', + 't1 = f"2c1-{int(time.time()*1000)}"; t2 = f"2c2-{int(time.time()*1000)}"', + '_si(k, KernelCommandType.ENTER, t1, symbol, "SHORT", p, 0.001); await asyncio.sleep(1)', + '_si(k, KernelCommandType.EXIT, t1, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(1)', + '_si(k, KernelCommandType.ENTER, t2, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(1)', + '_si(k, KernelCommandType.EXIT, t2, symbol, "SHORT", p*0.99, 0.001); await asyncio.sleep(1)', +]) +S("entry_then_recover", [ + 'tid = f"r-{int(time.time()*1000)}"; p = float(snap.price)', + '_si(k, KernelCommandType.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(1)', + 'await bundle.runtime.disconnect()', + 'await bundle.runtime.connect(initial_capital=k.account.snapshot.capital)', + 'await asyncio.sleep(1)', +]) +S("long_entry_exit", [ + 'tid = f"ln-{int(time.time()*1000)}"; p = float(snap.price)', + '_si(k, KernelCommandType.ENTER, tid, symbol, "LONG", p, 0.001); await asyncio.sleep(1)', + '_si(k, KernelCommandType.EXIT, tid, symbol, "LONG", p*1.005, 0.001); await asyncio.sleep(1)', +]) + +# --- Cancel combos --- +S("cancel_idempotent", [ + 'tid = f"ci-{int(time.time()*1000)}"; p = float(snap.price)', + '_si(k, KernelCommandType.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.5)', + '_si(k, KernelCommandType.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + '_si(k, KernelCommandType.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(1)', +]) +S("double_cancel", [ + 'tid = f"dc-{int(time.time()*1000)}"; p = float(snap.price)', + '_si(k, KernelCommandType.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + '_si(k, KernelCommandType.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + '_si(k, KernelCommandType.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(1)', +]) +S("cancel_then_exit", [ + 'tid = f"ctx-{int(time.time()*1000)}"; p = float(snap.price)', + '_si(k, KernelCommandType.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.5)', + '_si(k, KernelCommandType.CANCEL, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.3)', + 'if not k.slot(0).is_free():', + ' _si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(1)', +]) +S("exit_then_cancel_exit", [ + 'tid = f"exc-{int(time.time()*1000)}"; p = float(snap.price)', + '_si(k, KernelCommandType.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(1)', + '_si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.3)', + '_si(k, KernelCommandType.CANCEL, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(1)', +]) +S("exit_then_reentry", [ + 'p = float(snap.price)', + 't1 = f"er1-{int(time.time()*1000)}"; t2 = f"er2-{int(time.time()*1000)}"', + '_si(k, KernelCommandType.ENTER, t1, symbol, "SHORT", p, 0.001); await asyncio.sleep(1)', + '_si(k, KernelCommandType.EXIT, t1, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.3)', + '_si(k, KernelCommandType.ENTER, t2, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(1)', +]) +S("limit_cancel", [ + 'tid = f"lc-{int(time.time()*1000)}"; p = float(snap.price)', + '_si(k, KernelCommandType.ENTER, tid, symbol, "SHORT", p*0.9, 0.001); await asyncio.sleep(0.5)', + '_si(k, KernelCommandType.CANCEL, tid, symbol, "SHORT", p*0.9, 0.001); await asyncio.sleep(1)', +]) + +# --- X4 expanded --- +S("x4_partial_hold_exit", [ + 'tid = f"ph-{int(time.time()*1000)}"; p = float(snap.price); sz = 0.003', + '_si(k, KernelCommandType.ENTER, tid, symbol, "SHORT", p, sz, exit_leg_ratios=(0.3,1.0)); await asyncio.sleep(1)', + '_si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*0.995, sz*0.3, exit_leg_ratios=(0.3,1.0)); await asyncio.sleep(1)', + '_si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*0.993, sz*0.7, exit_leg_ratios=(0.3,1.0)); await asyncio.sleep(1)', +]) +S("x4_three_leg", [ + 'tid = f"3l-{int(time.time()*1000)}"; p = float(snap.price); sz = 0.004', + '_si(k, KernelCommandType.ENTER, tid, symbol, "SHORT", p, sz, exit_leg_ratios=(0.25,0.25,1.0)); await asyncio.sleep(1)', + '_si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*0.995, sz*0.25, exit_leg_ratios=(0.25,0.25,1.0)); await asyncio.sleep(1)', + '_si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*0.993, sz*0.25, exit_leg_ratios=(0.25,0.25,1.0)); await asyncio.sleep(1)', + '_si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*0.99, sz*0.5, exit_leg_ratios=(0.25,0.25,1.0)); await asyncio.sleep(1)', +]) +S("x4_cancel_fill_partial", [ + 'tid = f"cfp-{int(time.time()*1000)}"; p = float(snap.price)', + '_si(k, KernelCommandType.ENTER, tid, symbol, "SHORT", p, 0.002); await asyncio.sleep(0.5)', + '_si(k, KernelCommandType.CANCEL, tid, symbol, "SHORT", p, 0.002); await asyncio.sleep(0.3)', + 'if not k.slot(0).is_free():', + ' _si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', + 'if not k.slot(0).is_free():', + ' _si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*0.993, 0.001); await asyncio.sleep(1)', +]) +S("x4_rapid_three", [ + 'p = float(snap.price)', + 'for i in range(3):', + ' tid = f"r3-{i}-{int(time.time()*1000)}"', + ' _si(k, KernelCommandType.ENTER, tid, symbol, "SHORT", p*(1-i*0.005), 0.001); await asyncio.sleep(0.8)', + ' _si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*0.995*(1-i*0.005), 0.001); await asyncio.sleep(0.8)', +]) +S("x4_diff_symbol", [ + 'tid = f"ds-{int(time.time()*1000)}"; p = float(snap.price)', + '_si(k, KernelCommandType.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(1)', + 'sym2 = "BTCUSDT" if symbol != "BTCUSDT" else "ETHUSDT"', + '_si(k, KernelCommandType.EXIT, tid, sym2, "SHORT", p, 0.001); await asyncio.sleep(0.5)', +]) +S("x4_alternating", [ + 'p = float(snap.price)', + 't1 = f"as1-{int(time.time()*1000)}"; t2 = f"as2-{int(time.time()*1000)}"', + '_si(k, KernelCommandType.ENTER, t1, symbol, "SHORT", p, 0.001); await asyncio.sleep(1)', + 'sym2 = "BTCUSDT" if symbol != "BTCUSDT" else "ETHUSDT"', + 'try:', + ' url = "https://open-api-vst.bingx.com/openApi/swap/v2/quote/price?symbol=" + sym2.replace("USDT","-USDT")', + ' p2 = float(json.loads(urllib.request.urlopen(url, timeout=5).read())["data"]["price"])', + 'except: p2 = p', + '_si(k, KernelCommandType.ENTER, t2, sym2, "LONG", p2, 0.001); await asyncio.sleep(1)', + '_si(k, KernelCommandType.EXIT, t1, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(1)', + '_si(k, KernelCommandType.EXIT, t2, sym2, "LONG", p2*1.005, 0.001); await asyncio.sleep(1)', +]) +S("x4_multi_flatten", [ + 'tid = f"mf-{int(time.time()*1000)}"; p = float(snap.price)', + '_si(k, KernelCommandType.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(1)', + 'for i in range(3):', + ' if k.slot(0).is_free(): break', + ' _flatten_via_kernel_intent(k, symbol, p*0.99, f"mf{i}"); await asyncio.sleep(0.5)', +]) +S("x4_three_leg_25_50_25", [ + 'tid = f"x4a-{int(time.time()*1000)}"; p = float(snap.price); sz = 0.004', + '_si(k, KernelCommandType.ENTER, tid, symbol, "SHORT", p, sz, exit_leg_ratios=(0.25,0.5,1.0)); await asyncio.sleep(1)', + '_si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*0.995, sz*0.25, exit_leg_ratios=(0.25,0.5,1.0)); await asyncio.sleep(1)', + '_si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*0.993, sz*0.5, exit_leg_ratios=(0.25,0.5,1.0)); await asyncio.sleep(1)', + '_si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*0.99, sz*0.25, exit_leg_ratios=(0.25,0.5,1.0)); await asyncio.sleep(1)', +]) +S("x4_enter_exit_hold_twice", [ + 'p = float(snap.price)', + 't1 = f"x4b1-{int(time.time()*1000)}"', + '_si(k, KernelCommandType.ENTER, t1, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.5)', + '_si(k, KernelCommandType.EXIT, t1, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', + 't2 = f"x4b2-{int(time.time()*1000)}"', + '_si(k, KernelCommandType.ENTER, t2, symbol, "SHORT", p*0.995, 0.001); await asyncio.sleep(0.5)', + '_si(k, KernelCommandType.EXIT, t2, symbol, "SHORT", p*0.99, 0.001); await asyncio.sleep(0.5)', + 't3 = f"x4b3-{int(time.time()*1000)}"', + '_si(k, KernelCommandType.ENTER, t3, symbol, "SHORT", p*0.99, 0.001); await asyncio.sleep(0.5)', + '_si(k, KernelCommandType.EXIT, t3, symbol, "SHORT", p*0.985, 0.001); await asyncio.sleep(0.5)', +]) +S("x4_cancel_then_double_exit", [ + 'tid = f"x4c-{int(time.time()*1000)}"; p = float(snap.price); sz = 0.002', + '_si(k, KernelCommandType.ENTER, tid, symbol, "SHORT", p, sz, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.5)', + '_si(k, KernelCommandType.CANCEL, tid, symbol, "SHORT", p, sz); await asyncio.sleep(0.3)', + 'if not k.slot(0).is_free():', + ' _si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*0.995, sz*0.5, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.5)', + 'if not k.slot(0).is_free():', + ' _si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*0.993, sz*0.5, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.5)', +]) + +# --- 2 sides × 2 profit × 4 patterns = 16 --- +for side, side_str, ep in [("short","SHORT",0.995), ("long","LONG",1.005)]: + for prof, pname, xp_mult in [(True,"profit",ep), (False,"loss",1/ep)]: + for pat, pat_suffix, lines in [ + ("basic", "", [ + f'_si(k, KernelCommandType.ENTER, tid, symbol, "{side_str}", p, 0.001); await asyncio.sleep(0.8)', + f'_si(k, KernelCommandType.EXIT, tid, symbol, "{side_str}", p*{xp_mult}, 0.001); await asyncio.sleep(0.8)', + ]), + ("partial", "_partial", [ + 'sz = 0.002', + f'_si(k, KernelCommandType.ENTER, tid, symbol, "{side_str}", p, sz, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.8)', + f'_si(k, KernelCommandType.EXIT, tid, symbol, "{side_str}", p*{ep}, sz*0.5, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.8)', + f'_si(k, KernelCommandType.EXIT, tid, symbol, "{side_str}", p*{xp_mult}, sz*0.5, exit_leg_ratios=(0.5,1.0)); await asyncio.sleep(0.8)', + ]), + ("cancel", "_cancel", [ + f'_si(k, KernelCommandType.ENTER, tid, symbol, "{side_str}", p, 0.001); await asyncio.sleep(0.3)', + f'_si(k, KernelCommandType.CANCEL, tid, symbol, "{side_str}", p, 0.001); await asyncio.sleep(0.3)', + 'if not k.slot(0).is_free():', + f' _si(k, KernelCommandType.EXIT, tid, symbol, "{side_str}", p*{xp_mult}, 0.001); await asyncio.sleep(0.8)', + ]), + ("double_exit", "_double_exit", [ + f'_si(k, KernelCommandType.ENTER, tid, symbol, "{side_str}", p, 0.001); await asyncio.sleep(0.8)', + f'_si(k, KernelCommandType.EXIT, tid, symbol, "{side_str}", p*{xp_mult}, 0.001); await asyncio.sleep(0.3)', + 'if not k.slot(0).is_free():', + f' _si(k, KernelCommandType.EXIT, tid, symbol, "{side_str}", p*{xp_mult}*0.995, 0.001); await asyncio.sleep(0.5)', + ]), + ]: + name = f"{pat}_{side}_{pname}" + S(name, [ + f'tid = f"{pat[0]}{side[0]}{"p" if prof else "l"}-{{int(time.time()*1000)}}"; p = float(snap.price)', + *lines, + ]) + +# --- Triple sequential × 4 --- +for i in range(4): + side = "SHORT"; ep = 0.995 + S(f"triple_seq_{i}", [ + 'p = float(snap.price)', + 'for j in range(3):', + f' tid = f"ts{i}-j-{{int(time.time()*1000)}}"', + f' _si(k, KernelCommandType.ENTER, tid, symbol, "{side}", p*(1-j*0.003), 0.001); await asyncio.sleep(0.7)', + f' _si(k, KernelCommandType.EXIT, tid, symbol, "{side}", p*{ep}*(1-j*0.003), 0.001); await asyncio.sleep(0.7)', + ]) + +for i in range(4): + side = "LONG"; ep = 1.005 + S(f"triple_seq_long_{i}", [ + 'p = float(snap.price)', + 'for j in range(3):', + f' tid = f"tsl{i}-j-{{int(time.time()*1000)}}"', + f' _si(k, KernelCommandType.ENTER, tid, symbol, "{side}", p*(1+j*0.003), 0.001); await asyncio.sleep(0.7)', + f' _si(k, KernelCommandType.EXIT, tid, symbol, "{side}", p*{ep}*(1+j*0.003), 0.001); await asyncio.sleep(0.7)', + ]) + +# --- Cancel+reenter × 4 --- +for i in range(4): + side = "SHORT" + S(f"cancel_reenter_{i}", [ + 'p = float(snap.price)', + f't1 = f"cr{i}a-{{int(time.time()*1000)}}"; t2 = f"cr{i}b-{{int(time.time()*1000)}}"', + f'_si(k, KernelCommandType.ENTER, t1, symbol, "{side}", p, 0.001); await asyncio.sleep(0.3)', + f'_si(k, KernelCommandType.CANCEL, t1, symbol, "{side}", p, 0.001); await asyncio.sleep(0.3)', + f'_si(k, KernelCommandType.ENTER, t2, symbol, "{side}", p*0.995, 0.001); await asyncio.sleep(0.8)', + 'if not k.slot(0).is_free():', + f' _si(k, KernelCommandType.EXIT, t2, symbol, "{side}", p*0.99, 0.001); await asyncio.sleep(0.5)', + ]) + +for i in range(4): + side = "LONG" + S(f"cancel_reenter_long_{i}", [ + 'p = float(snap.price)', + f't1 = f"crl{i}a-{{int(time.time()*1000)}}"; t2 = f"crl{i}b-{{int(time.time()*1000)}}"', + f'_si(k, KernelCommandType.ENTER, t1, symbol, "{side}", p, 0.001); await asyncio.sleep(0.3)', + f'_si(k, KernelCommandType.CANCEL, t1, symbol, "{side}", p, 0.001); await asyncio.sleep(0.3)', + f'_si(k, KernelCommandType.ENTER, t2, symbol, "{side}", p*1.005, 0.001); await asyncio.sleep(0.8)', + 'if not k.slot(0).is_free():', + f' _si(k, KernelCommandType.EXIT, t2, symbol, "{side}", p*1.01, 0.001); await asyncio.sleep(0.5)', + ]) + +# --- Leg ratios × 8 --- +for i, ratios in enumerate([ + (0.1,1.0), (0.33,0.33,1.0), (0.5,0.5,1.0), (0.75,1.0), + (0.2,0.3,0.5,1.0), (0.4,0.6,1.0), (0.15,0.85,1.0), (0.25,0.25,0.5,1.0), +]): + rat_str = ",".join(str(r) for r in ratios) + nlegs = len(ratios) + code = [ + f'tid = f"lr{i}-{{int(time.time()*1000)}}"; p = float(snap.price); sz = 0.004', + f'_si(k, KernelCommandType.ENTER, tid, symbol, "SHORT", p, sz, exit_leg_ratios=({rat_str})); await asyncio.sleep(1)', + ] + for leg in range(nlegs - 1): + r = ratios[leg] + code.append(f'_si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*0.995*(1-{leg}*0.002), sz*{r}, exit_leg_ratios=({rat_str})); await asyncio.sleep(0.8)') + r_last = ratios[-1] + code.append(f'_si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p*0.99, sz*{r_last}, exit_leg_ratios=({rat_str})); await asyncio.sleep(0.8)') + S(f"leg_ratio_{i}", code) + +# --- Breakeven × 4 --- +for i in range(4): + S(f"breakeven_{i}", [ + f'tid = f"be{i}-{{int(time.time()*1000)}}"; p = float(snap.price)', + '_si(k, KernelCommandType.ENTER, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + '_si(k, KernelCommandType.EXIT, tid, symbol, "SHORT", p, 0.001); await asyncio.sleep(0.8)', + ]) + +# ===================================================================== +# Assemble output +# ===================================================================== +lines = [PROLOGUE, RUNNER] +lines.append('# =====================================================================') +lines.append('# Scenario body functions') +lines.append('# =====================================================================') +lines.append('') +lines.append('k = None # type: ignore # shorthand alias for bundle.runtime.kernel') +lines.append('') + +for name, code_lines in scenarios: + lines.append(f'async def _body_{name}(bundle, client, symbol, snap):') + lines.append(' k = bundle.runtime.kernel') + for cl in code_lines: + lines.append(f' {cl}') + lines.append('') + +lines.append('# =====================================================================') +lines.append('# Test functions') +lines.append('# =====================================================================') +lines.append('') +lines.append( +'@pytest.fixture(scope="session")\n' +'def _live_client():\n' +' cfg = _build_bingx_config(25000.0)\n' +' c = BingxHttpClient(cfg)\n' +' yield c\n' +) + +for name, _ in scenarios: + lines.append(f''' +def test_pink_ditav2_{name}(_live_client) -> None: + bundle = _build_runtime_bundle(25000.0) + ic = bundle.runtime.kernel.account.snapshot.capital + result = asyncio.run(_run_scenario(bundle, _live_client, _body_{name}, "{name}", ic)) + assert result.positions_flat, f"{name}: {{result.error}}" +''') + +lines.append(''' +def test_pink_ditav2_open_partial_close_and_flatten(_live_client) -> None: + bundle = _build_runtime_bundle(25000.0) + outcomes = asyncio.run(_run_pink_live_roundtrip(bundle, _live_client)) + e, m, f = outcomes + assert e.accepted or e.diagnostic_code in {KernelDiagnosticCode.OK}, f"Entry not accepted: {e.diagnostic_code}" + slot = bundle.runtime.kernel.slot(0) if bundle.runtime.kernel.max_slots > 0 else None + if slot is not None and not slot.is_free(): + pytest.skip(f"Slot not flat (fsm_state={slot.fsm_state})") + +def test_pink_ditav2_reconciliation_only_on_explicit_recovery(_live_client) -> None: + bundle = _build_runtime_bundle(25000.0) + recovered = asyncio.run(_run_pink_live_recovery(bundle, _live_client)) + assert isinstance(recovered, dict), f"Expected dict, got {type(recovered)}" + assert recovered.get("capital", 0) > 0, "Expected positive capital after recovery" +''') + +full = '\n'.join(lines) + +try: + ast.parse(full) + test_count = full.count("def test_pink_ditav2_") + print(f"Syntax OK — {test_count} tests, {len(full)} chars") + with open(OUT, 'w') as f: + f.write(full) + print(f"Written to {OUT}") + print(f"Breakdown: {len(scenarios)} scenarios + 2 legacy = {test_count} total tests") +except SyntaxError as e: + print(f"Syntax error line {e.lineno}: {e.msg}") + fl = full.split('\n') + for i in range(max(0,e.lineno-5), min(len(fl), e.lineno+3)): + print(f" {i+1}: {fl[i]}") diff --git a/prod/clean_arch/dita_v2/journal.py b/prod/clean_arch/dita_v2/journal.py new file mode 100644 index 0000000..c98aea0 --- /dev/null +++ b/prod/clean_arch/dita_v2/journal.py @@ -0,0 +1,102 @@ +"""Debug journaling surfaces for DITAv2.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Callable, Dict, List, Optional, Protocol + +from .contracts import KernelTransition, TradeSlot, TradeStage, VenueEvent +from .control import KernelControlSnapshot +from .utils import json_safe, json_text + +JournalSink = Callable[[str, Dict[str, Any]], None] + + +class KernelJournal(Protocol): + """Append-only debug journal interface.""" + + def record(self, row: Dict[str, Any]) -> None: + ... + + def record_transition( + self, + *, + transition: KernelTransition, + slot: TradeSlot, + event: Optional[VenueEvent] = None, + control: Optional[KernelControlSnapshot] = None, + ) -> None: + ... + + +@dataclass +class MemoryKernelJournal: + """In-memory journal used in tests.""" + + rows: List[Dict[str, Any]] = field(default_factory=list) + capture_limit: int = 10_000 + + def record(self, row: Dict[str, Any]) -> None: + if len(self.rows) < self.capture_limit: + self.rows.append(dict(row)) + + def record_transition( + self, + *, + transition: KernelTransition, + slot: TradeSlot, + event: Optional[VenueEvent] = None, + control: Optional[KernelControlSnapshot] = None, + ) -> None: + row = _transition_row(transition=transition, slot=slot, event=event, control=control) + self.record(row) + + +class ClickHouseKernelJournal: + """Fire-and-forget ClickHouse journal. + + The sink is a small callable of the form ``sink(table_name, row_dict)``. + """ + + def __init__(self, sink: Optional[JournalSink] = None): + self.sink = sink + + def record(self, row: Dict[str, Any]) -> None: + if self.sink is not None: + self.sink("dita_kernel_debug", row) + + def record_transition( + self, + *, + transition: KernelTransition, + slot: TradeSlot, + event: Optional[VenueEvent] = None, + control: Optional[KernelControlSnapshot] = None, + ) -> None: + self.record(_transition_row(transition=transition, slot=slot, event=event, control=control)) + + +def _transition_row( + *, + transition: KernelTransition, + slot: TradeSlot, + event: Optional[VenueEvent], + control: Optional[KernelControlSnapshot], +) -> Dict[str, Any]: + return { + "ts": transition.timestamp.isoformat() if hasattr(transition.timestamp, "isoformat") else str(transition.timestamp), + "trade_id": transition.trade_id, + "slot_id": transition.slot_id, + "prev_state": transition.prev_state.value, + "next_state": transition.next_state.value, + "trigger": transition.trigger, + "intent_id": transition.intent_id, + "event_id": transition.event_id, + "control_mode": transition.control_mode, + "control_verbosity": transition.control_verbosity, + "slot_state": slot.to_dict(), + "event_payload": json_safe(event) if event is not None else {}, + "control_snapshot": control.as_dict() if control is not None else {}, + "slot_state_json": json_text(slot.to_dict()), + } diff --git a/prod/clean_arch/dita_v2/kernel.py b/prod/clean_arch/dita_v2/kernel.py new file mode 100644 index 0000000..f4d02ca --- /dev/null +++ b/prod/clean_arch/dita_v2/kernel.py @@ -0,0 +1,8 @@ +"""Compatibility shim for the Rust-backed DITAv2 execution kernel.""" + +from __future__ import annotations + +from .rust_backend import ExecutionKernel + +__all__ = ["ExecutionKernel"] + diff --git a/prod/clean_arch/dita_v2/projection.py b/prod/clean_arch/dita_v2/projection.py new file mode 100644 index 0000000..625e089 --- /dev/null +++ b/prod/clean_arch/dita_v2/projection.py @@ -0,0 +1,97 @@ +"""Hazelcast-compatible projection helpers for DITAv2.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import os +from typing import Any, Callable, Dict, Iterable, List, Optional + +from .account import AccountProjection +from .contracts import KernelTransition, TradeSlot, TradeStage, VenueEvent +from .control import KernelControlSnapshot +from .journal import _transition_row +from .utils import json_safe + +Writer = Callable[[str, Dict[str, Any]], None] + + +@dataclass +class HazelcastProjection: + """Projection helper for BLUE/PINK-compatible durable writes.""" + + active_slots_map: str = "hz:dita_active_slots" + trade_events_topic: str = "hz:dita_trade_events" + control_map: str = "hz:dita_control" + writer: Optional[Writer] = None + control_snapshot: Optional[KernelControlSnapshot] = None + + def write_slot(self, slot: TradeSlot) -> Dict[str, Any]: + row = build_position_state_row(slot, self.control_snapshot) + if self.writer is not None: + self.writer(self.active_slots_map, row) + return row + + def write_transition( + self, + *, + transition: KernelTransition, + slot: TradeSlot, + event: Optional[VenueEvent] = None, + control: Optional[KernelControlSnapshot] = None, + ) -> Dict[str, Any]: + row = _transition_row(transition=transition, slot=slot, event=event, control=control) + if self.writer is not None: + self.writer(self.trade_events_topic, row) + return row + + def write_control(self, control: KernelControlSnapshot) -> Dict[str, Any]: + self.control_snapshot = control + row = control.as_dict() + if self.writer is not None: + self.writer(self.control_map, row) + return row + + +def build_projection( + *, + writer: Optional[Writer] = None, + client: Optional[Any] = None, + prefer_real_hazelcast: Optional[bool] = None, + control_snapshot: Optional[KernelControlSnapshot] = None, +) -> HazelcastProjection: + """Build the active projection helper with an operator-visible switch. + + The default remains the callback-based projection helper. If a Hazelcast + client is supplied and the caller opts in via ``prefer_real_hazelcast`` or + ``DITA_V2_HAZELCAST=REAL``, the helper routes directly through the + client-backed map/topic writer path. + """ + + env_choice = os.environ.get("DITA_V2_HAZELCAST", "").strip().upper() + real_requested = prefer_real_hazelcast if prefer_real_hazelcast is not None else env_choice in {"REAL", "REAL_HZ", "HAZELCAST"} + if real_requested and client is not None: + try: + from .hazelcast_projection import HazelcastRowWriter + + writer = HazelcastRowWriter(client) + except Exception: + pass + return HazelcastProjection(writer=writer, control_snapshot=control_snapshot) + + +def build_position_state_row(slot: TradeSlot, control: Optional[KernelControlSnapshot] = None) -> Dict[str, Any]: + """Build a state row shaped for durable compatibility.""" + row = slot.to_dict() + row.update( + { + "runtime_namespace": control.runtime_namespace if control else "dita_v2", + "strategy_namespace": control.strategy_namespace if control else "dita_v2", + "event_namespace": control.event_namespace if control else "dita_v2", + "actor_name": control.actor_name if control else "ExecutionKernel", + "exec_venue": control.exec_venue if control else "bingx", + "data_venue": control.data_venue if control else "binance", + "ledger_authority": control.ledger_authority if control else "exchange", + } + ) + return row diff --git a/prod/clean_arch/dita_v2/real_control_plane.py b/prod/clean_arch/dita_v2/real_control_plane.py new file mode 100644 index 0000000..0139b91 --- /dev/null +++ b/prod/clean_arch/dita_v2/real_control_plane.py @@ -0,0 +1,129 @@ +"""Real Zinc-backed control plane for DITAv2.""" + +from __future__ import annotations + +import json +import struct +import sys +from pathlib import Path +from typing import Any, Dict, Optional + +from .control import BackendMode, ControlPlane, ControlUpdate, KernelControlSnapshot, KernelMode, KernelVerbosity + +_ZINC_ADAPTER_PATH = Path(__file__).resolve().parents[3] / "zinc" / "adapters" / "python" +if _ZINC_ADAPTER_PATH.exists() and str(_ZINC_ADAPTER_PATH) not in sys.path: + sys.path.append(str(_ZINC_ADAPTER_PATH)) + +try: # pragma: no cover - exercised in integration tests + from zinc import SharedRegion +except Exception as exc: # pragma: no cover + SharedRegion = None # type: ignore[assignment] + _ZINC_IMPORT_ERROR = exc +else: + _ZINC_IMPORT_ERROR = None + + +class RealZincUnavailable(RuntimeError): + """Raised when the Zinc Python adapter cannot be loaded.""" + + +def require_real_zinc() -> None: + if SharedRegion is None: + raise RealZincUnavailable(str(_ZINC_IMPORT_ERROR)) + + +def _json_default(value: Any) -> Any: + if hasattr(value, "value"): + return value.value + if hasattr(value, "isoformat"): + try: + return value.isoformat() + except Exception: + pass + if hasattr(value, "__dict__"): + return dict(vars(value)) + raise TypeError(f"Unsupported value: {type(value)!r}") + + +def _encode_packet(seq: int, payload: Dict[str, Any]) -> bytes: + text = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=_json_default, separators=(",", ":")).encode("utf-8") + return struct.pack("!QQ", int(seq), len(text)) + text + + +def _decode_packet(buf: memoryview) -> Dict[str, Any]: + if len(buf) < 16: + return {} + seq, size = struct.unpack_from("!QQ", buf, 0) + if size <= 0 or size > len(buf) - 16: + return {} + payload = bytes(buf[16 : 16 + size]).decode("utf-8") + out = json.loads(payload) + if isinstance(out, dict): + out["_seq"] = seq + return out + + +class RealZincControlPlane(ControlPlane): + """Shared-memory Zinc-backed control plane.""" + + def __init__(self, *, prefix: str, create: bool = True) -> None: + require_real_zinc() + base = prefix.strip("/").replace("/", "_") + self.region_name = f"{base}_control" + self._seq = 0 + self._snapshot = KernelControlSnapshot() + if create: + self.region = SharedRegion.create(self.region_name, 1 << 20) + self._write_region(self._seq, self._snapshot.as_dict()) + else: + self.region = SharedRegion.open(self.region_name) + payload = _decode_packet(self.region.as_buffer()) + control = payload.get("control") if isinstance(payload, dict) else None + if isinstance(control, dict): + self._snapshot = KernelControlSnapshot(**control) + + def close(self) -> None: + self.region.close() + + def read(self) -> KernelControlSnapshot: + payload = _decode_packet(self.region.as_buffer()) + control = payload.get("control") if isinstance(payload, dict) else None + if not isinstance(control, dict): + return self._snapshot + self._snapshot = KernelControlSnapshot(**control) + return self._snapshot + + def update(self, update: ControlUpdate) -> KernelControlSnapshot: + self._snapshot = update.apply(self.read()) + self._seq += 1 + self._write_region(self._seq, self._snapshot.as_dict()) + return self._snapshot + + def mirror(self) -> Dict[str, Any]: + return self._snapshot.as_dict() + + def wait(self, timeout_ms: int = 1000) -> bool: + try: + return bool(self.region.wait(timeout_ms)) + except Exception: + return False + + def notify(self) -> None: + try: + self.region.notify() + except Exception: + pass + + def _write_region(self, seq: int, control: Dict[str, Any]) -> None: + packet = _encode_packet(seq, {"control": control}) + buf = self.region.as_buffer() + if len(packet) > len(buf): + raise ValueError(f"payload too large for Zinc control region: {len(packet)} > {len(buf)}") + view = memoryview(buf) + view[: len(packet)] = packet + if len(view) > len(packet): + view[len(packet) :] = b"\x00" * (len(view) - len(packet)) + try: + self.region.notify() + except Exception: + pass diff --git a/prod/clean_arch/dita_v2/real_zinc_plane.py b/prod/clean_arch/dita_v2/real_zinc_plane.py new file mode 100644 index 0000000..3a13375 --- /dev/null +++ b/prod/clean_arch/dita_v2/real_zinc_plane.py @@ -0,0 +1,263 @@ +"""Real Zinc-backed hot-path plane for DITAv2. + +This wrapper uses the Zinc Python adapter directly. The kernel still talks to +the narrow ``ZincPlane`` interface; this module just makes that interface real. +""" + +from __future__ import annotations + +from dataclasses import asdict +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional +import json +import os +import struct +import sys +import threading + +from .contracts import KernelIntent, TradeSide, TradeSlot, TradeStage, VenueOrder, VenueOrderStatus +from .control import KernelControlSnapshot + +_ZINC_ADAPTER_PATH = Path(__file__).resolve().parents[3] / "zinc" / "adapters" / "python" +if _ZINC_ADAPTER_PATH.exists() and str(_ZINC_ADAPTER_PATH) not in sys.path: + sys.path.append(str(_ZINC_ADAPTER_PATH)) + +try: # pragma: no cover - exercised in integration tests + from zinc import SharedRegion +except Exception as exc: # pragma: no cover + SharedRegion = None # type: ignore[assignment] + _ZINC_IMPORT_ERROR = exc +else: + _ZINC_IMPORT_ERROR = None + + +class RealZincUnavailable(RuntimeError): + """Raised when the Zinc Python adapter cannot be loaded.""" + + +def require_real_zinc() -> None: + if SharedRegion is None: + raise RealZincUnavailable(str(_ZINC_IMPORT_ERROR)) + + +def _json_default(value: Any) -> Any: + if hasattr(value, "value"): + return value.value + if hasattr(value, "isoformat"): + try: + return value.isoformat() + except Exception: + pass + if hasattr(value, "__dict__"): + return dict(vars(value)) + raise TypeError(f"Unsupported value: {type(value)!r}") + + +def _slot_to_payload(slot: TradeSlot) -> Dict[str, Any]: + data = slot.to_dict() + return data + + +def _slot_from_payload(payload: Dict[str, Any]) -> TradeSlot: + active_entry_order = None + active_exit_order = None + if isinstance(payload.get("active_entry_order"), dict): + active_entry_order = VenueOrder( + internal_trade_id=str(payload.get("trade_id", "")), + venue_order_id=str(payload["active_entry_order"].get("venue_order_id", "")), + venue_client_id=str(payload["active_entry_order"].get("venue_client_id", "")), + side=TradeSide(str(payload["active_entry_order"].get("side", TradeSide.FLAT.value))), + intended_size=float(payload["active_entry_order"].get("intended_size", payload.get("size", 0.0))), + filled_size=float(payload["active_entry_order"].get("filled_size", 0.0)), + average_fill_price=float(payload["active_entry_order"].get("average_fill_price", 0.0)), + status=VenueOrderStatus(str(payload["active_entry_order"].get("status", VenueOrderStatus.NEW.value))), + metadata=dict(payload["active_entry_order"].get("metadata", {})), + ) + if isinstance(payload.get("active_exit_order"), dict): + active_exit_order = VenueOrder( + internal_trade_id=str(payload.get("trade_id", "")), + venue_order_id=str(payload["active_exit_order"].get("venue_order_id", "")), + venue_client_id=str(payload["active_exit_order"].get("venue_client_id", "")), + side=TradeSide(str(payload["active_exit_order"].get("side", TradeSide.FLAT.value))), + intended_size=float(payload["active_exit_order"].get("intended_size", payload.get("size", 0.0))), + filled_size=float(payload["active_exit_order"].get("filled_size", 0.0)), + average_fill_price=float(payload["active_exit_order"].get("average_fill_price", 0.0)), + status=VenueOrderStatus(str(payload["active_exit_order"].get("status", VenueOrderStatus.NEW.value))), + metadata=dict(payload["active_exit_order"].get("metadata", {})), + ) + slot = TradeSlot( + slot_id=int(payload.get("slot_id", 0)), + trade_id=str(payload.get("trade_id", "")), + asset=str(payload.get("asset", "")), + side=TradeSide(str(payload.get("side", TradeSide.FLAT.value))), + entry_price=float(payload.get("entry_price", 0.0)), + size=float(payload.get("size", 0.0)), + initial_size=float(payload.get("initial_size", 0.0)), + leverage=float(payload.get("leverage", 0.0)), + entry_time=datetime.fromisoformat(payload["entry_time"]) if payload.get("entry_time") else None, + unrealized_pnl=float(payload.get("unrealized_pnl", 0.0)), + realized_pnl=float(payload.get("realized_pnl", 0.0)), + closed=bool(payload.get("closed", False)), + exit_leg_ratios=tuple(float(r) for r in payload.get("exit_leg_ratios", (1.0,))), + active_leg_index=int(payload.get("active_leg_index", 0)), + active_exit_order=active_exit_order, + active_entry_order=active_entry_order, + fsm_state=TradeStage(str(payload.get("fsm_state", TradeStage.IDLE.value))), + close_reason=str(payload.get("close_reason", "")), + last_event_time=datetime.fromisoformat(payload["last_event_time"]) if payload.get("last_event_time") else None, + seen_event_ids=tuple(str(event_id) for event_id in payload.get("seen_event_ids", ())), + metadata=dict(payload.get("metadata", {})), + ) + return slot + + +def _encode_packet(seq: int, payload: Dict[str, Any]) -> bytes: + text = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=_json_default, separators=(",", ":")).encode("utf-8") + return struct.pack("!QQ", int(seq), len(text)) + text + + +def _decode_packet(buf: memoryview) -> Dict[str, Any]: + if len(buf) < 16: + return {} + seq, size = struct.unpack_from("!QQ", buf, 0) + if size <= 0 or size > len(buf) - 16: + return {} + payload = bytes(buf[16 : 16 + size]).decode("utf-8") + out = json.loads(payload) + if isinstance(out, dict): + out["_seq"] = seq + return out + + +class RealZincPlane: + """Shared-memory Zinc plane used by the Python prototype.""" + + def __init__( + self, + *, + prefix: str, + slot_count: int = 10, + intent_capacity: int = 1 << 20, + state_capacity: int = 1 << 20, + control_capacity: int = 1 << 20, + create: bool = True, + ) -> None: + require_real_zinc() + base = prefix.strip("/").replace("/", "_") + self.intent_name = f"{base}_intent" + self.state_name = f"{base}_state" + self.control_name = f"{base}_control" + self._intent_seq = 0 + self._state_seq = 0 + self._control_seq = 0 + self._lock = threading.Lock() + self._slot_cache: Dict[int, TradeSlot] = {i: TradeSlot(slot_id=i) for i in range(int(slot_count))} + self._slot_count = int(slot_count) + self._intent_cache: List[Dict[str, Any]] = [] + self._control_cache = KernelControlSnapshot() + if create: + self.intent_region = SharedRegion.create(self.intent_name, intent_capacity) + self.state_region = SharedRegion.create(self.state_name, state_capacity) + self.control_region = SharedRegion.create(self.control_name, control_capacity) + self._write_region(self.control_region, self._control_seq, {"control": self._control_cache.as_dict()}) + self._write_region( + self.state_region, + self._state_seq, + {"slots": [self._slot_cache[key].to_dict() for key in range(self._slot_count)]}, + ) + self._write_region(self.intent_region, self._intent_seq, {"items": []}) + else: + self.intent_region = SharedRegion.open(self.intent_name) + self.state_region = SharedRegion.open(self.state_name) + self.control_region = SharedRegion.open(self.control_name) + control_payload = _decode_packet(self.control_region.as_buffer()) + state_payload = _decode_packet(self.state_region.as_buffer()) + intent_payload = _decode_packet(self.intent_region.as_buffer()) + if isinstance(control_payload.get("control"), dict): + self._control_cache = KernelControlSnapshot(**control_payload["control"]) + if isinstance(state_payload.get("slots"), list): + for slot_payload in state_payload["slots"]: + if isinstance(slot_payload, dict): + slot = _slot_from_payload(slot_payload) + self._slot_cache[int(slot.slot_id)] = slot + if isinstance(intent_payload.get("items"), list): + self._intent_cache = list(intent_payload["items"]) + + def close(self) -> None: + self.intent_region.close() + self.state_region.close() + self.control_region.close() + + def publish_intent(self, intent: KernelIntent) -> None: + with self._lock: + self._intent_seq += 1 + row = intent.__dict__.copy() + row["timestamp"] = intent.timestamp.isoformat() + row["side"] = intent.side.value + row["action"] = intent.action.value + row["stage"] = intent.stage.value + row["exit_leg_ratios"] = list(intent.exit_leg_ratios) + row["metadata"] = json.loads(json.dumps(intent.metadata, default=_json_default)) + self._intent_cache.append(row) + self._write_region(self.intent_region, self._intent_seq, {"items": self._intent_cache[-512:]}) + + def write_slot(self, slot: TradeSlot) -> None: + with self._lock: + self._state_seq += 1 + self._slot_cache[int(slot.slot_id)] = slot + payload = { + "slots": [self._slot_cache[key].to_dict() for key in range(self._slot_count)], + } + self._write_region(self.state_region, self._state_seq, payload) + + def read_slots(self) -> List[TradeSlot]: + payload = _decode_packet(self.state_region.as_buffer()) + slots = payload.get("slots", []) if isinstance(payload, dict) else [] + return [_slot_from_payload(slot) for slot in sorted(slots, key=lambda row: int(row.get("slot_id", 0)))] + + def read_intents(self) -> List[Dict[str, Any]]: + payload = _decode_packet(self.intent_region.as_buffer()) + items = payload.get("items", []) if isinstance(payload, dict) else [] + return list(items) + + def update_control(self, control: KernelControlSnapshot) -> None: + with self._lock: + self._control_seq += 1 + self._control_cache = control + self._write_region(self.control_region, self._control_seq, {"control": control.as_dict()}) + + def read_control(self) -> KernelControlSnapshot: + payload = _decode_packet(self.control_region.as_buffer()) + control = payload.get("control") if isinstance(payload, dict) else None + if not isinstance(control, dict): + return self._control_cache + return KernelControlSnapshot(**control) + + def wait_on_state(self, timeout_ms: int = 1000) -> bool: + return bool(self.state_region.wait(timeout_ms)) + + def notify_state(self) -> None: + self.state_region.notify() + + def wait_on_control(self, timeout_ms: int = 1000) -> bool: + return bool(self.control_region.wait(timeout_ms)) + + def notify_control(self) -> None: + self.control_region.notify() + + def wait_on_intent(self, timeout_ms: int = 1000) -> bool: + return bool(self.intent_region.wait(timeout_ms)) + + def notify_intent(self) -> None: + self.intent_region.notify() + + def _write_region(self, region: Any, seq: int, payload: Dict[str, Any]) -> None: + packet = _encode_packet(seq, payload) + buf = region.as_buffer() + if len(packet) > len(buf): + raise ValueError(f"payload too large for Zinc region: {len(packet)} > {len(buf)}") + view = memoryview(buf) + view[:] = b"\x00" * len(view) + view[: len(packet)] = packet + region.notify() diff --git a/prod/clean_arch/dita_v2/test_blue_parity.py b/prod/clean_arch/dita_v2/test_blue_parity.py new file mode 100644 index 0000000..054b3ea --- /dev/null +++ b/prod/clean_arch/dita_v2/test_blue_parity.py @@ -0,0 +1,378 @@ +"""BLUE-parity restoration tests (2026-06-10). + +The DITAv2 rewrite (Sprint 0) dropped two things the original PINK +(full-engine launch_dolphin_bingx) had from the start: + + R1 IRP asset selection over the scan universe — PINK traded only the + snapshot anchor (BTCUSDT) since the rewrite. + R2 Cubic-convex dynamic leverage — the stub formula's confidence + (|vdiv/threshold|) is ≥ 1.0 on every possible ENTER, so leverage was + pinned at max_leverage (3.0) flat, and exchange leverage at the cap. + +These tests pin the restored behavior: + - blue_parity.PinkAssetPicker / PinkAlphaSizer (wrappers over BLUE's + exact kernels) + - DecisionEngine sizer injection (and legacy path preserved verbatim) + - IntentEngine honoring decision sizing + - dual-leverage conviction map at the venue boundary + - PinkDirectRuntime._effective_snapshot retargeting rules +""" + +from __future__ import annotations + +import logging +from collections import deque +from datetime import datetime, timezone +from types import SimpleNamespace + +import pytest + +from prod.clean_arch.dita.contracts import DecisionAction, DecisionConfig, DecisionContext, IntentContext +from prod.clean_arch.dita.decision import DecisionEngine +from prod.clean_arch.dita.intent import IntentEngine +from prod.clean_arch.dita_v2.blue_parity import PinkAlphaSizer, PinkAssetPicker +from prod.clean_arch.ports.data_feed import MarketSnapshot +from prod.clean_arch.runtime.pink_direct import PinkDirectRuntime +from prod.bingx.leverage import map_internal_conviction_to_exchange_leverage + +LOGGER = logging.getLogger("test_blue_parity") + + +def make_snapshot(symbol="BTCUSDT", price=50000.0, vdiv=-0.03, irp=0.60, + scan_number=100, payload=None): + return MarketSnapshot( + timestamp=datetime.now(timezone.utc), + symbol=symbol, + price=price, + eigenvalues=[1.0], + velocity_divergence=vdiv, + irp_alignment=irp, + scan_number=scan_number, + scan_payload=payload if payload is not None else {"vol_ok": True}, + ) + + +def make_config(max_leverage=8.0): + return DecisionConfig( + vel_div_threshold=-0.02, + vel_div_extreme=-0.05, + fixed_tp_pct=0.0020, + max_hold_bars=250, + capital_fraction=0.20, + max_leverage=max_leverage, + allow_short=True, + allow_long=False, + ) + + +def feed_picker(picker, series: dict, start_scan=1): + """series: asset → list of prices; all lists same length.""" + n = len(next(iter(series.values()))) + for i in range(n): + payload = { + "assets": list(series), + "asset_prices": [series[a][i] for a in series], + } + picker.observe(payload, scan_number=start_scan + i) + + +def trending_series(lookback, down=0.997, up=1.003): + n = lookback + 2 + out = {"DOWNUSDT": [], "UPUSDT": []} + d, u = 100.0, 100.0 + for _ in range(n): + d *= down + u *= up + out["DOWNUSDT"].append(d) + out["UPUSDT"].append(u) + return out + + +# ── R2: sizer ───────────────────────────────────────────────────────────────── + +class TestPinkAlphaSizer: + def _sizer(self, **kw): + defaults = dict(min_leverage=0.5, max_leverage=8.0, leverage_convexity=3.0, + vel_div_threshold=-0.02, vel_div_extreme=-0.05, + use_dynamic_leverage=True, use_alpha_layers=False) + defaults.update(kw) + return PinkAlphaSizer(**defaults) + + def test_cubic_curve_checkpoints(self): + s = self._sizer() + # At threshold: strength 0 → min leverage + assert s.calculate_size(capital=1e5, vel_div=-0.02)["leverage"] == pytest.approx(0.5) + # Midpoint: strength 0.5 → 0.5 + 0.125 × 7.5 = 1.4375 + assert s.calculate_size(capital=1e5, vel_div=-0.035)["leverage"] == pytest.approx(1.4375) + # At/beyond extreme: strength 1 → max leverage + assert s.calculate_size(capital=1e5, vel_div=-0.05)["leverage"] == pytest.approx(8.0) + assert s.calculate_size(capital=1e5, vel_div=-0.30)["leverage"] == pytest.approx(8.0) + + def test_leverage_is_not_flat(self): + """The regression: every entry used to come out at max_leverage.""" + s = self._sizer() + levs = {round(s.calculate_size(capital=1e5, vel_div=vd)["leverage"], 4) + for vd in (-0.021, -0.03, -0.04, -0.05)} + assert len(levs) > 1 + + def test_vd_trend_needs_ten_scans_and_dedupes(self): + s = self._sizer() + for i in range(9): + s.observe(-0.02 - i * 0.001, scan_number=i + 1) + assert s.vd_trend == 0.0 + s.observe(-0.05, scan_number=9) # stale scan number → ignored + assert s.vd_trend == 0.0 + s.observe(-0.031, scan_number=10) + assert s.vd_trend == pytest.approx(-0.031 - (-0.02)) + + def test_trade_feedback_roundtrip(self): + s = self._sizer(use_alpha_layers=True) + s.calculate_size(capital=1e5, vel_div=-0.06) # extreme bucket + s.note_entry() + s.record_close(150.0) # win + stats = s._sizer.get_stats() + assert sum(stats.get("bucket_wins", [0])) >= 1 or stats # recorded without raising + + def test_record_close_without_entry_is_noop(self): + s = self._sizer() + s.record_close(100.0) # must not raise + + +# ── R1: picker ──────────────────────────────────────────────────────────────── + +class TestPinkAssetPicker: + def test_warm_after_lookback_scans(self): + p = PinkAssetPicker() + series = trending_series(p.lookback) + feed_picker(p, {k: v[: p.lookback] for k, v in series.items()}) + assert not p.warm + feed_picker(p, {k: v[p.lookback:] for k, v in series.items()}, + start_scan=p.lookback + 1) + assert p.warm + + def test_observe_dedupes_scan_number(self): + p = PinkAssetPicker() + payload = {"assets": ["AUSDT"], "asset_prices": [10.0]} + assert p.observe(payload, scan_number=5) + assert not p.observe(payload, scan_number=5) + assert not p.observe(payload, scan_number=4) + assert p.scans_observed == 1 + + def test_picks_downtrend_for_short_regime(self): + p = PinkAssetPicker() + feed_picker(p, trending_series(p.lookback)) + choice = p.pick(direction=-1) + assert choice is not None + asset, px, ars = choice + assert asset == "DOWNUSDT" + assert px == pytest.approx(p.price_of("DOWNUSDT")) + + def test_no_candidate_returns_none(self): + """All-uptrend universe in a SHORT regime → inverse rankings only → + direction gate leaves nothing (BLUE: no fallback asset).""" + p = PinkAssetPicker() + n = p.lookback + 2 + up1, up2 = [], [] + a, b = 100.0, 50.0 + for _ in range(n): + a *= 1.004 + b *= 1.003 + up1.append(a) + up2.append(b) + feed_picker(p, {"AUSDT": up1, "BUSDT": up2}) + assert p.pick(direction=-1) is None + + def test_price_of_unknown_asset(self): + p = PinkAssetPicker() + assert p.price_of("NOPEUSDT") is None + + +# ── R2: decision/intent integration ────────────────────────────────────────── + +class TestDecisionSizerInjection: + def test_sizer_drives_decision_leverage(self): + sizer = PinkAlphaSizer(min_leverage=0.5, max_leverage=8.0, + leverage_convexity=3.0, vel_div_threshold=-0.02, + vel_div_extreme=-0.05, use_alpha_layers=False) + eng = DecisionEngine(make_config(), sizer=sizer) + ctx = DecisionContext(capital=100_000.0, open_positions=0, trade_seq=0) + d = eng.decide(make_snapshot(vdiv=-0.035), ctx, None) + assert d.action == DecisionAction.ENTER + assert d.leverage == pytest.approx(1.4375) + assert d.metadata.get("sizing") == "alpha_bet_sizer_cubic_v1" + # target_size = capital × fraction × leverage / price + assert d.target_size == pytest.approx(100_000 * 0.20 * 1.4375 / 50000.0) + + def test_legacy_path_unchanged_without_sizer(self): + """Pin the legacy stub exactly: leverage saturates at max_leverage.""" + eng = DecisionEngine(make_config(max_leverage=3.0)) + ctx = DecisionContext(capital=100_000.0, open_positions=0, trade_seq=0) + for vdiv in (-0.021, -0.035, -0.10): + d = eng.decide(make_snapshot(vdiv=vdiv), ctx, None) + assert d.action == DecisionAction.ENTER + assert d.leverage == pytest.approx(3.0) + + def test_intent_honors_decision_sizing(self): + sizer = PinkAlphaSizer(min_leverage=0.5, max_leverage=8.0, + leverage_convexity=3.0, vel_div_threshold=-0.02, + vel_div_extreme=-0.05, use_alpha_layers=False) + cfg = make_config() + eng = DecisionEngine(cfg, sizer=sizer) + ieng = IntentEngine(cfg) + ctx = DecisionContext(capital=100_000.0, open_positions=0, trade_seq=0) + d = eng.decide(make_snapshot(vdiv=-0.035), ctx, None) + plan = ieng.plan(d, IntentContext(capital=100_000.0, open_positions=0, trade_seq=0)) + assert plan.intent.leverage == pytest.approx(d.leverage) + assert plan.intent.target_size == pytest.approx(d.target_size) + + def test_intent_legacy_recompute_identical(self): + """Honoring decision sizing must be a no-op for legacy decisions.""" + cfg = make_config(max_leverage=3.0) + eng = DecisionEngine(cfg) + ieng = IntentEngine(cfg) + ctx = DecisionContext(capital=100_000.0, open_positions=0, trade_seq=0) + d = eng.decide(make_snapshot(vdiv=-0.03), ctx, None) + plan = ieng.plan(d, IntentContext(capital=100_000.0, open_positions=0, trade_seq=0)) + conf = max(0.05, min(1.0, d.confidence)) + legacy_lev = min(cfg.max_leverage, max(1.0, 1.0 + conf * (cfg.max_leverage - 1.0))) + assert plan.intent.leverage == pytest.approx(legacy_lev) + + +# ── dual-leverage venue boundary ───────────────────────────────────────────── + +class TestConvictionToExchangeLeverage: + def test_endpoints_and_midrange(self): + m = lambda c: map_internal_conviction_to_exchange_leverage(c, exchange_max=3) + assert m(0.5) == 1 + assert m(9.0) == 3 + assert m(4.75) == 2 # exact midpoint of [0.5, 9.0] → 2.0 + assert m(0.1) == 1 # clamped below conviction floor + assert m(50.0) == 3 # clamped above conviction ceiling + + def test_monotonic(self): + vals = [map_internal_conviction_to_exchange_leverage(c, exchange_max=3) + for c in (0.5, 2.0, 4.0, 6.0, 8.0, 9.0)] + assert vals == sorted(vals) + assert set(vals) == {1, 2, 3} + + +# ── runtime retargeting ────────────────────────────────────────────────────── + +class FakeSlot: + def __init__(self, asset="", size=0.0, free=True): + self.asset = asset + self.size = size + self._free = free + + def is_free(self): + return self._free + + +class FakeKernel: + max_slots = 1 + + def __init__(self, slot=None): + self.slot0 = slot or FakeSlot() + + def slot(self, _i): + return self.slot0 + + +def make_runtime(picker=None, sizer=None, slot=None): + return PinkDirectRuntime( + data_feed=SimpleNamespace(), + kernel=FakeKernel(slot), + decision_engine=SimpleNamespace(config=make_config()), + intent_engine=SimpleNamespace(), + persistence=None, + logger=LOGGER, + asset_picker=picker, + alpha_sizer=sizer, + ) + + +def universe_payload(prices: dict, scan_number: int): + return { + "assets": list(prices), + "asset_prices": list(prices.values()), + "scan_number": scan_number, + "vel_div": -0.03, + } + + +class TestEffectiveSnapshot: + def test_no_picker_passthrough(self): + rt = make_runtime() + snap = make_snapshot() + out, block = rt._effective_snapshot(snap) + assert out is snap and block == "" + + def test_cold_picker_blocks_entries_only(self): + rt = make_runtime(picker=PinkAssetPicker()) + snap = make_snapshot(payload=universe_payload({"BTCUSDT": 50000.0}, 1)) + out, block = rt._effective_snapshot(snap) + assert out.symbol == "BTCUSDT" + assert "warming" in block and not block.startswith("all:") + + def test_flat_warm_picker_retargets_entry(self): + p = PinkAssetPicker() + feed_picker(p, trending_series(p.lookback)) + rt = make_runtime(picker=p) + snap = make_snapshot(payload=universe_payload( + {"DOWNUSDT": p.price_of("DOWNUSDT"), "UPUSDT": p.price_of("UPUSDT")}, + p.scans_observed + 1)) + out, block = rt._effective_snapshot(snap) + assert block == "" + assert out.symbol == "DOWNUSDT" + assert out.price == pytest.approx(p.price_of("DOWNUSDT")) + # Regime signal untouched + assert out.velocity_divergence == snap.velocity_divergence + + def test_open_slot_follows_slot_asset(self): + p = PinkAssetPicker() + feed_picker(p, trending_series(p.lookback)) + slot = FakeSlot(asset="UPUSDT", size=2.0, free=False) + rt = make_runtime(picker=p, slot=slot) + snap = make_snapshot(payload=universe_payload( + {"DOWNUSDT": 90.0, "UPUSDT": 110.0}, p.scans_observed + 1)) + out, block = rt._effective_snapshot(snap) + assert block == "" + assert out.symbol == "UPUSDT" + assert out.price == pytest.approx(110.0) + + def test_open_slot_unpriced_asset_blocks_all(self): + p = PinkAssetPicker() + slot = FakeSlot(asset="STRAYUSDT", size=1.0, free=False) + rt = make_runtime(picker=p, slot=slot) + snap = make_snapshot(payload=universe_payload({"BTCUSDT": 50000.0}, 1)) + out, block = rt._effective_snapshot(snap) + assert block.startswith("all:") + assert out.symbol == "BTCUSDT" # unchanged; step() must HOLD + + def test_no_candidate_blocks_entry(self): + p = PinkAssetPicker() + n = p.lookback + 2 + up = [100.0 * (1.004 ** i) for i in range(1, n + 1)] + feed_picker(p, {"AUSDT": up}) + rt = make_runtime(picker=p) + snap = make_snapshot(payload=universe_payload({"AUSDT": up[-1]}, n + 1)) + out, block = rt._effective_snapshot(snap) + assert "no IRP candidate" in block + + def test_sizer_observe_fed_per_scan(self): + sizer = PinkAlphaSizer(vel_div_threshold=-0.02, vel_div_extreme=-0.05, + use_alpha_layers=False) + rt = make_runtime(sizer=sizer) + for i in range(12): + snap = make_snapshot( + scan_number=i + 1, + payload={"scan_number": i + 1, "vel_div": -0.02 - i * 0.001}, + ) + rt._effective_snapshot(snap) + assert len(sizer._vd_history) == 10 + assert sizer.vd_trend != 0.0 + + +if __name__ == "__main__": + import sys + sys.exit(pytest.main([__file__, "-v"])) diff --git a/prod/clean_arch/dita_v2/test_exec_live_e2e.py b/prod/clean_arch/dita_v2/test_exec_live_e2e.py new file mode 100644 index 0000000..5bc8ff7 --- /dev/null +++ b/prod/clean_arch/dita_v2/test_exec_live_e2e.py @@ -0,0 +1,267 @@ +"""Live BingX VST E2E for the execution-router order path (PostOnly/LIMIT). + +GATED: runs only with DOLPHIN_EXEC_LIVE_E2E=1 and BingX VST credentials in +the environment. Places REAL orders on BingX VST (testnet) — never mainnet +(BingxEnvironment.VST is hardcoded; allow_mainnet=False). + +Symbol policy: TRXUSDT, deliberately NOT BTCUSDT, so the concurrently +running PINK daemon (whose current build filters its account stream to +BTCUSDT) cannot misattribute our test fills. + +Scenarios: + 1. postonly_far_rests_then_cancel — PostOnly SELL far above market must + rest (ack, no fill), then CANCEL must remove it. Verifies the resting + leg of the maker path end-to-end through kernel → venue → BingX. + 2. postonly_crossing_rejected — PostOnly SELL far below market is + marketable; the venue must NOT fill it as taker. Verifies the fee + guarantee that makes maker mode safe. + 3. maker_exit_reduceonly — open a small MARKET short, then close it with + a PostOnly reduce-only BUY near the touch; on TTL miss fall back to + MARKET (mirrors the runtime escalation). Verifies flat at the end. + +Every scenario flattens TRXUSDT and cancels stray orders in setup and +teardown — the account must end exactly as it started: flat, no orders. + +Run: + DOLPHIN_EXEC_LIVE_E2E=1 BINGX_API_KEY=… BINGX_SECRET_KEY=… \ + python -m pytest prod/clean_arch/dita_v2/test_exec_live_e2e.py -v -s +""" + +from __future__ import annotations + +import asyncio +import os +import time +import unittest +from datetime import datetime, timezone + +import pytest + +LIVE = os.environ.get("DOLPHIN_EXEC_LIVE_E2E", "") == "1" and \ + bool(os.environ.get("BINGX_API_KEY")) and bool(os.environ.get("BINGX_SECRET_KEY")) + +pytestmark = pytest.mark.skipif( + not LIVE, reason="live VST E2E gated: set DOLPHIN_EXEC_LIVE_E2E=1 + BingX keys") + +ASSET = "TRXUSDT" +VENUE_SYMBOL = "TRX-USDT" +QTY = 30.0 # ~10 USDT notional at TRX ≈ 0.33 +FAR_UP = 1.06 # +6% — rests, will not fill +FAR_DOWN = 0.94 # −6% — marketable, PostOnly must refuse to take + + +def _build_bundle(): + from prod.bingx.config import BingxExecClientConfig + from prod.bingx.enums import BingxEnvironment + from prod.clean_arch.dita_v2.launcher import build_launcher_bundle + + cfg = BingxExecClientConfig( + api_key=os.environ["BINGX_API_KEY"], + secret_key=os.environ["BINGX_SECRET_KEY"], + environment=BingxEnvironment.VST, # testnet, always + allow_mainnet=False, + recv_window_ms=5000, + default_leverage=1, + exchange_leverage_cap=3, + prefer_websocket=False, + use_reduce_only=True, + sizing_mode="testnet", + journal_strategy="pink", + journal_db="dolphin_pink", + ) + bundle = build_launcher_bundle(venue_mode="BINGX", max_slots=1, bingx_config=cfg) + k = bundle.kernel + k.account.snapshot.capital = 1000.0 + k.account.snapshot.peak_capital = 1000.0 + k.account.snapshot.equity = 1000.0 + return bundle + + +async def _client(bundle): + return bundle.kernel.venue.backend._client + + +async def _price(bundle) -> float: + c = await _client(bundle) + resp = await c.signed_get("/openApi/swap/v2/quote/price", {"symbol": VENUE_SYMBOL}) + if isinstance(resp, dict): + return float(resp.get("price") or 0.0) + return float(resp or 0.0) + + +async def _open_orders(bundle) -> list: + c = await _client(bundle) + resp = await c.signed_get("/openApi/swap/v2/trade/openOrders", + {"symbol": VENUE_SYMBOL}) + if isinstance(resp, dict): + return list(resp.get("orders") or []) + return list(resp or []) + + +async def _positions(bundle) -> list: + c = await _client(bundle) + resp = await c.signed_get("/openApi/swap/v2/user/positions", + {"symbol": VENUE_SYMBOL}) + rows = resp if isinstance(resp, list) else [] + return [r for r in rows + if abs(float(r.get("positionAmt") or r.get("positionQty") or 0)) > 1e-9] + + +async def _flatten(bundle) -> None: + """Cancel all TRX orders + close any TRX position with reduce-only MARKET.""" + c = await _client(bundle) + try: + await c.signed_delete("/openApi/swap/v2/trade/allOpenOrders", + {"symbol": VENUE_SYMBOL}) + except Exception: + pass + for p in await _positions(bundle): + qty = abs(float(p.get("positionAmt") or p.get("positionQty") or 0)) + side = "BUY" if float(p.get("positionAmt") or 0) < 0 else "SELL" + try: + await c.signed_post("/openApi/swap/v2/trade/order", { + "symbol": VENUE_SYMBOL, "side": side, "positionSide": "BOTH", + "type": "MARKET", "quantity": f"{qty:.0f}", "reduceOnly": "true", + }) + except Exception: + pass + await asyncio.sleep(1.0) + + +def _intent(action, tid, *, order_type="MARKET", limit_price=0.0, + post_only=False, size=QTY, ref=0.0): + from prod.clean_arch.dita_v2.contracts import ( + KernelCommandType, KernelIntent, TradeSide) + meta = {} + if order_type == "LIMIT": + meta["_time_in_force"] = "PostOnly" if post_only else "GTC" + return KernelIntent( + timestamp=datetime.now(timezone.utc), + intent_id=tid, trade_id=tid, slot_id=0, asset=ASSET, + side=TradeSide.SHORT, action=getattr(KernelCommandType, action), + reference_price=ref, target_size=size, leverage=1.0, + order_type=order_type, limit_price=limit_price, metadata=meta, + reason="exec_live_e2e", + ) + + +async def _connect(bundle): + res = bundle.kernel.venue.connect() + if asyncio.iscoroutine(res): + await res + + +class TestExecLiveE2E(unittest.TestCase): + def setUp(self): + self.bundle = _build_bundle() + asyncio.run(self._setup_async()) + + def tearDown(self): + asyncio.run(self._teardown_async()) + + async def _setup_async(self): + await _connect(self.bundle) + await _flatten(self.bundle) + + async def _teardown_async(self): + try: + await _flatten(self.bundle) + assert await _positions(self.bundle) == [], "teardown left a position!" + assert await _open_orders(self.bundle) == [], "teardown left open orders!" + finally: + try: + disc = self.bundle.kernel.venue.backend.disconnect() + if asyncio.iscoroutine(disc): + await disc + except Exception: + pass + + # ── scenario 1 ─────────────────────────────────────────────────────────── + def test_postonly_far_rests_then_cancel(self): + asyncio.run(self._s1()) + + async def _s1(self): + k = self.bundle.kernel + px = await _price(self.bundle) + assert px > 0, "no TRX price" + tid = f"e2e-rest-{int(time.time()*1000)}" + quote = round(px * FAR_UP, 5) + await k.process_intent_async(_intent( + "ENTER", tid, order_type="LIMIT", limit_price=quote, + post_only=True, ref=px)) + await asyncio.sleep(2.0) + orders = await _open_orders(self.bundle) + assert len(orders) == 1, f"expected 1 resting order, got {orders}" + assert (await _positions(self.bundle)) == [], "far quote must not fill" + # cancel through the kernel (the TTL loop's path) + await k.process_intent_async(_intent( + "CANCEL", tid, order_type="LIMIT", limit_price=quote, ref=px)) + await asyncio.sleep(2.0) + assert (await _open_orders(self.bundle)) == [], "cancel left the order" + assert (await _positions(self.bundle)) == [], "flat expected after cancel" + print(f"\nS1 OK: PostOnly rested @ {quote} (px={px}) then cancelled clean") + + # ── scenario 2 ─────────────────────────────────────────────────────────── + def test_postonly_crossing_rejected(self): + asyncio.run(self._s2()) + + async def _s2(self): + k = self.bundle.kernel + px = await _price(self.bundle) + assert px > 0 + tid = f"e2e-cross-{int(time.time()*1000)}" + quote = round(px * FAR_DOWN, 5) # SELL below market = marketable + await k.process_intent_async(_intent( + "ENTER", tid, order_type="LIMIT", limit_price=quote, + post_only=True, ref=px)) + await asyncio.sleep(2.5) + pos = await _positions(self.bundle) + orders = await _open_orders(self.bundle) + # The whole point of PostOnly: a crossing quote must NOT execute as + # taker. Reject (nothing) is correct. + assert pos == [], f"PostOnly crossing quote FILLED — taker leak! {pos}" + for o in orders: # defensive: if venue let it rest, clean it + print(f"S2 note: venue rested crossing PostOnly: {o}") + await _flatten(self.bundle) + print(f"\nS2 OK: PostOnly crossing quote @ {quote} (px={px}) did not take") + + # ── scenario 3 ─────────────────────────────────────────────────────────── + def test_maker_exit_reduceonly_with_market_fallback(self): + asyncio.run(self._s3()) + + async def _s3(self): + k = self.bundle.kernel + px = await _price(self.bundle) + assert px > 0 + tid = f"e2e-mx-{int(time.time()*1000)}" + # open small short (taker) + await k.process_intent_async(_intent("ENTER", tid, ref=px)) + await asyncio.sleep(2.0) + pos = await _positions(self.bundle) + assert pos, "entry MARKET did not open a position" + # maker exit: reduce-only PostOnly BUY just below the touch + quote = round(px * 0.9985, 5) + await k.process_intent_async(_intent( + "EXIT", tid, order_type="LIMIT", limit_price=quote, + post_only=True, ref=px)) + # TTL window: give it up to 10 s to fill as maker + deadline = time.time() + 10.0 + filled = False + while time.time() < deadline: + await asyncio.sleep(2.0) + if not await _positions(self.bundle): + filled = True + break + if not filled: + # runtime escalation path: cancel quote, MARKET close + await k.process_intent_async(_intent( + "CANCEL", tid, order_type="LIMIT", limit_price=quote, ref=px)) + await asyncio.sleep(1.0) + await _flatten(self.bundle) + assert (await _positions(self.bundle)) == [], "position not closed" + assert (await _open_orders(self.bundle)) == [], "stray order left" + print(f"\nS3 OK: maker exit {'FILLED as maker' if filled else 'missed → MARKET fallback'} — flat verified") + + +if __name__ == "__main__": + unittest.main() diff --git a/prod/clean_arch/dita_v2/test_exec_router.py b/prod/clean_arch/dita_v2/test_exec_router.py new file mode 100644 index 0000000..2f76098 --- /dev/null +++ b/prod/clean_arch/dita_v2/test_exec_router.py @@ -0,0 +1,605 @@ +"""ExecutionRouter — unit, adversarial and fuzz tests (pure policy layer). + +Invariants under test (the non-negotiables from exec_router's docstring): + R1 exits are never skipped / suppressed except the working-dup guard + R2 one working ENTER maximum; duplicate ENTER plans are suppressed + R3 retries are bounded by entry_retries, then retry_exhaust applies + R4 default config (no env) == pure taker == legacy behavior + R5 hooks can never crash the policy path nor strand an exit +""" + +from __future__ import annotations + +import itertools +import os +import random +import unittest +from unittest import mock + +from hypothesis import given, settings, strategies as st + +from prod.clean_arch.dita_v2.exec_router import ( + DEFAULT_TICKS, + ExecConfig, + ExecutionPlan, + ExecutionRouter, + MAKER_EXIT_REASONS, + MissAction, +) + + +class FakeClock: + def __init__(self, t: float = 1000.0): + self.t = t + + def __call__(self) -> float: + return self.t + + def tick(self, dt: float) -> None: + self.t += dt + + +def make_router(clock=None, **cfg) -> ExecutionRouter: + return ExecutionRouter(ExecConfig(**cfg), clock=clock or FakeClock()) + + +# ───────────────────────────────────────────────────────────────────────────── +# Config parsing +# ───────────────────────────────────────────────────────────────────────────── + +class TestExecConfig(unittest.TestCase): + def test_defaults_are_taker(self): + cfg = ExecConfig() + self.assertEqual(cfg.style, "taker") + self.assertFalse(cfg.maker_entry) + self.assertFalse(cfg.maker_exit) + + def test_from_env_defaults(self): + with mock.patch.dict(os.environ, {}, clear=True): + cfg = ExecConfig.from_env() + self.assertEqual(cfg.style, "taker") + self.assertEqual(cfg.entry_miss, "skip") + self.assertEqual(cfg.entry_retries, 1) + self.assertTrue(cfg.post_only) + + def test_from_env_full(self): + env = { + "DOLPHIN_PINK_EXEC_STYLE": "maker_both", + "DOLPHIN_PINK_MAKER_ENTRY_TTL_S": "12.5", + "DOLPHIN_PINK_MAKER_EXIT_TTL_S": "3", + "DOLPHIN_PINK_MAKER_ENTRY_MISS": "retry", + "DOLPHIN_PINK_MAKER_ENTRY_RETRIES": "2", + "DOLPHIN_PINK_MAKER_RETRY_EXHAUST": "market", + "DOLPHIN_PINK_MAKER_OFFSET_TICKS": "3", + "DOLPHIN_PINK_MAKER_MAX_SPREAD_BPS": "7.5", + "DOLPHIN_PINK_POST_ONLY": "0", + "DOLPHIN_PINK_TICK_SIZE_FOOUSDT": "0.025", + } + with mock.patch.dict(os.environ, env, clear=True): + cfg = ExecConfig.from_env() + self.assertEqual(cfg.style, "maker_both") + self.assertEqual(cfg.entry_ttl_s, 12.5) + self.assertEqual(cfg.exit_ttl_s, 3.0) + self.assertEqual(cfg.entry_miss, "retry") + self.assertEqual(cfg.entry_retries, 2) + self.assertEqual(cfg.retry_exhaust, "market") + self.assertEqual(cfg.offset_ticks, 3) + self.assertEqual(cfg.max_spread_bps, 7.5) + self.assertFalse(cfg.post_only) + self.assertEqual(cfg.tick_overrides["FOOUSDT"], 0.025) + + def test_from_env_garbage_falls_back(self): + env = { + "DOLPHIN_PINK_EXEC_STYLE": "yolo", + "DOLPHIN_PINK_MAKER_ENTRY_TTL_S": "not-a-number", + "DOLPHIN_PINK_MAKER_ENTRY_MISS": "explode", + "DOLPHIN_PINK_MAKER_ENTRY_RETRIES": "-5", + "DOLPHIN_PINK_MAKER_OFFSET_TICKS": "9999", + "DOLPHIN_PINK_TICK_SIZE_BADUSDT": "zero", + } + with mock.patch.dict(os.environ, env, clear=True): + cfg = ExecConfig.from_env() + self.assertEqual(cfg.style, "taker") + self.assertEqual(cfg.entry_ttl_s, 8.0) + self.assertEqual(cfg.entry_miss, "skip") + self.assertEqual(cfg.entry_retries, 0) # clamped up from -5 + self.assertEqual(cfg.offset_ticks, 100) # clamped down + self.assertNotIn("BADUSDT", cfg.tick_overrides) + + def test_from_env_empty_strings(self): + env = {"DOLPHIN_PINK_EXEC_STYLE": "", "DOLPHIN_PINK_MAKER_ENTRY_TTL_S": " "} + with mock.patch.dict(os.environ, env, clear=True): + cfg = ExecConfig.from_env() + self.assertEqual(cfg.style, "taker") + self.assertEqual(cfg.entry_ttl_s, 8.0) + + +# ───────────────────────────────────────────────────────────────────────────── +# Pricing +# ───────────────────────────────────────────────────────────────────────────── + +class TestPricing(unittest.TestCase): + def test_sell_quotes_above_reference(self): + r = make_router(style="maker_both") + px = r.maker_price(asset="BTCUSDT", order_side="SELL", reference_price=61000.0) + self.assertAlmostEqual(px, 61000.1) + + def test_buy_quotes_below_reference(self): + r = make_router(style="maker_both") + px = r.maker_price(asset="BTCUSDT", order_side="BUY", reference_price=61000.0) + self.assertAlmostEqual(px, 60999.9) + + def test_offset_ticks_respected(self): + r = make_router(style="maker_both", offset_ticks=5) + px = r.maker_price(asset="BTCUSDT", order_side="SELL", reference_price=61000.0) + self.assertAlmostEqual(px, 61000.5) + + def test_unknown_symbol_uses_fraction(self): + r = make_router(style="maker_both") + px = r.maker_price(asset="NEWUSDT", order_side="SELL", reference_price=100.0) + self.assertGreater(px, 100.0) + self.assertLess(px, 100.01) + + def test_zero_reference_returns_zero(self): + r = make_router(style="maker_both") + self.assertEqual(r.maker_price(asset="BTCUSDT", order_side="SELL", + reference_price=0.0), 0.0) + self.assertEqual(r.maker_price(asset="BTCUSDT", order_side="BUY", + reference_price=-5.0), 0.0) + + def test_buy_price_never_nonpositive(self): + r = make_router(style="maker_both", offset_ticks=100) + # tiny price, huge offset → clamped to >= one tick + px = r.maker_price(asset="SHIBUSDT", order_side="BUY", reference_price=2e-9) + self.assertGreater(px, 0.0) + + def test_order_side_mapping(self): + self.assertEqual(ExecutionRouter.order_side("ENTER", "SHORT"), "SELL") + self.assertEqual(ExecutionRouter.order_side("ENTER", "LONG"), "BUY") + self.assertEqual(ExecutionRouter.order_side("EXIT", "SHORT"), "BUY") + self.assertEqual(ExecutionRouter.order_side("EXIT", "LONG"), "SELL") + + +# ───────────────────────────────────────────────────────────────────────────── +# Entry planning +# ───────────────────────────────────────────────────────────────────────────── + +class TestPlanEntry(unittest.TestCase): + def test_taker_style_market(self): + r = make_router() # default taker + p = r.plan_entry(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + reference_price=61000.0) + self.assertEqual(p.order_type, "MARKET") + self.assertFalse(p.is_maker) + self.assertFalse(p.suppress) + + def test_maker_entry_limit_postonly(self): + r = make_router(style="maker_entry") + p = r.plan_entry(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + reference_price=61000.0) + self.assertEqual(p.order_type, "LIMIT") + self.assertTrue(p.is_maker) + self.assertTrue(p.post_only) + self.assertAlmostEqual(p.limit_price, 61000.1) + self.assertEqual(p.ttl_s, 8.0) + + def test_maker_exit_style_does_not_affect_entry(self): + r = make_router(style="maker_exit") + p = r.plan_entry(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + reference_price=61000.0) + self.assertEqual(p.order_type, "MARKET") + + def test_bad_reference_price_degrades_to_market(self): + r = make_router(style="maker_both") + for bad in (0.0, -1.0): + p = r.plan_entry(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + reference_price=bad) + self.assertEqual(p.order_type, "MARKET") + self.assertTrue(p.sane()) + + def test_spread_gate(self): + r = make_router(style="maker_both", max_spread_bps=5.0) + wide = r.plan_entry(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + reference_price=61000.0, spread_bps=6.0) + self.assertEqual(wide.order_type, "MARKET") + tight = r.plan_entry(trade_id="t2", asset="BTCUSDT", position_side="SHORT", + reference_price=61000.0, spread_bps=4.9) + self.assertEqual(tight.order_type, "LIMIT") + unknown = r.plan_entry(trade_id="t3", asset="BTCUSDT", position_side="SHORT", + reference_price=61000.0, spread_bps=None) + self.assertEqual(unknown.order_type, "LIMIT") + + def test_duplicate_entry_suppressed_while_working(self): + r = make_router(style="maker_entry") + p1 = r.plan_entry(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + reference_price=61000.0) + r.register_working(trade_id="t1", asset="BTCUSDT", position_side="SHORT", plan=p1) + p2 = r.plan_entry(trade_id="t2", asset="BTCUSDT", position_side="SHORT", + reference_price=61001.0) + self.assertTrue(p2.suppress) + self.assertIn("working_entry_exists", p2.reason) + # after fill the guard releases + r.note_fill("t1") + p3 = r.plan_entry(trade_id="t3", asset="BTCUSDT", position_side="SHORT", + reference_price=61002.0) + self.assertFalse(p3.suppress) + + +# ───────────────────────────────────────────────────────────────────────────── +# Exit planning — RULE 1 +# ───────────────────────────────────────────────────────────────────────────── + +class TestPlanExit(unittest.TestCase): + def test_take_profit_is_maker_eligible(self): + r = make_router(style="maker_exit") + p = r.plan_exit(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + reference_price=60900.0, reason="TAKE_PROFIT") + self.assertEqual(p.order_type, "LIMIT") + self.assertTrue(p.post_only) + # SHORT exit = BUY → below reference + self.assertAlmostEqual(p.limit_price, 60899.9) + self.assertEqual(p.ttl_s, 5.0) + + def test_urgent_reasons_always_market(self): + r = make_router(style="maker_both") + for reason in ("CATASTROPHIC_LOSS", "MAX_HOLD", "MEAN_REVERSION", + "anything_else", "", None): + p = r.plan_exit(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + reference_price=60900.0, reason=reason) + self.assertEqual(p.order_type, "MARKET", f"reason={reason!r}") + self.assertFalse(p.suppress) + + def test_exit_never_suppressed_fresh(self): + for style in ("taker", "maker_entry", "maker_exit", "maker_both"): + r = make_router(style=style) + p = r.plan_exit(trade_id="x", asset="BTCUSDT", position_side="SHORT", + reference_price=60900.0, reason="TAKE_PROFIT") + self.assertFalse(p.suppress, f"style={style}") + self.assertTrue(p.sane()) + + def test_duplicate_nonurgent_exit_suppressed_while_working(self): + r = make_router(style="maker_exit") + p1 = r.plan_exit(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + reference_price=60900.0, reason="TAKE_PROFIT") + r.register_working(trade_id="t1", asset="BTCUSDT", position_side="SHORT", plan=p1) + p2 = r.plan_exit(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + reference_price=60899.0, reason="TAKE_PROFIT") + self.assertTrue(p2.suppress) + + def test_urgent_exit_preempts_working_quote(self): + r = make_router(style="maker_exit") + p1 = r.plan_exit(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + reference_price=60900.0, reason="TAKE_PROFIT") + r.register_working(trade_id="t1", asset="BTCUSDT", position_side="SHORT", plan=p1) + p2 = r.plan_exit(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + reference_price=61200.0, reason="CATASTROPHIC_LOSS") + self.assertFalse(p2.suppress) + self.assertEqual(p2.order_type, "MARKET") + self.assertTrue(p2.metadata.get("preempt_working")) + + def test_bad_reference_price_exit_still_market(self): + r = make_router(style="maker_both") + p = r.plan_exit(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + reference_price=0.0, reason="TAKE_PROFIT") + self.assertEqual(p.order_type, "MARKET") + self.assertTrue(p.sane()) + + def test_wide_spread_exit_degrades_to_market_not_skip(self): + r = make_router(style="maker_exit", max_spread_bps=2.0) + p = r.plan_exit(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + reference_price=60900.0, reason="TAKE_PROFIT", spread_bps=50.0) + self.assertEqual(p.order_type, "MARKET") + self.assertFalse(p.suppress) + + +# ───────────────────────────────────────────────────────────────────────────── +# Registry + TTL + miss policy — RULE 2 / RULE 3 +# ───────────────────────────────────────────────────────────────────────────── + +class TestRegistryAndMiss(unittest.TestCase): + def _maker_plan(self, action="ENTER", ttl=8.0): + return ExecutionPlan(order_type="LIMIT", limit_price=61000.1, post_only=True, + ttl_s=ttl, is_maker=True, action=action, reason="t") + + def test_expiry_with_fake_clock(self): + clk = FakeClock() + r = make_router(clock=clk, style="maker_entry") + r.register_working(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + plan=self._maker_plan()) + self.assertEqual(r.expired(), []) + clk.tick(7.9) + self.assertEqual(r.expired(), []) + clk.tick(0.2) + self.assertEqual([w.trade_id for w in r.expired()], ["t1"]) + + def test_note_fill_and_cancel_idempotent(self): + r = make_router(style="maker_entry") + r.register_working(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + plan=self._maker_plan()) + r.note_fill("t1") + self.assertIsNone(r.working("t1")) + r.note_fill("t1") # no-op + r.note_cancel("t1") # no-op + self.assertEqual(r.counters["fills_working"], 1) + self.assertEqual(r.counters["cancels"], 0) + + def test_miss_skip(self): + r = make_router(style="maker_entry", entry_miss="skip") + wo = r.register_working(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + plan=self._maker_plan()) + self.assertEqual(r.entry_miss_action(wo), MissAction.SKIP) + + def test_miss_market(self): + r = make_router(style="maker_entry", entry_miss="market") + wo = r.register_working(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + plan=self._maker_plan()) + self.assertEqual(r.entry_miss_action(wo), MissAction.MARKET) + + def test_retry_bounded_then_exhaust_skip(self): + r = make_router(style="maker_entry", entry_miss="retry", entry_retries=2, + retry_exhaust="skip") + wo = r.register_working(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + plan=self._maker_plan()) + # miss 1 → retry + self.assertEqual(r.entry_miss_action(wo), MissAction.RETRY) + tid2, plan2 = r.retry_plan(wo, reference_price=61010.0) + self.assertEqual(tid2, "t1-r1") + self.assertEqual(plan2.order_type, "LIMIT") + r.note_cancel("t1") + wo2 = r.register_working(trade_id=tid2, asset="BTCUSDT", position_side="SHORT", + plan=plan2, base_trade_id="t1", retry_n=1) + # miss 2 → retry (retries=2) + self.assertEqual(r.entry_miss_action(wo2), MissAction.RETRY) + tid3, plan3 = r.retry_plan(wo2, reference_price=61020.0) + self.assertEqual(tid3, "t1-r2") + r.note_cancel(tid2) + wo3 = r.register_working(trade_id=tid3, asset="BTCUSDT", position_side="SHORT", + plan=plan3, base_trade_id="t1", retry_n=2) + # miss 3 → exhausted → skip + self.assertEqual(r.entry_miss_action(wo3), MissAction.SKIP) + + def test_retry_exhaust_market(self): + r = make_router(style="maker_entry", entry_miss="retry", entry_retries=0, + retry_exhaust="market") + wo = r.register_working(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + plan=self._maker_plan()) + self.assertEqual(r.entry_miss_action(wo), MissAction.MARKET) + + def test_retry_plan_insane_price_degrades_to_market(self): + r = make_router(style="maker_entry", entry_miss="retry") + wo = r.register_working(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + plan=self._maker_plan()) + _tid, plan = r.retry_plan(wo, reference_price=0.0) + self.assertEqual(plan.order_type, "MARKET") + self.assertTrue(plan.sane()) + + def test_market_fallback_ids(self): + r = make_router(style="maker_both") + woe = r.register_working(trade_id="e1", asset="BTCUSDT", position_side="SHORT", + plan=self._maker_plan("ENTER")) + tid, plan = r.market_fallback_plan(woe) + self.assertEqual(tid, "e1-m") # ENTER: fresh id + self.assertEqual(plan.order_type, "MARKET") + r.note_cancel("e1") + wox = r.register_working(trade_id="x1", asset="BTCUSDT", position_side="SHORT", + plan=self._maker_plan("EXIT", ttl=5.0)) + tid2, plan2 = r.market_fallback_plan(wox) + self.assertEqual(tid2, "x1") # EXIT: same id — stays on position + self.assertEqual(plan2.order_type, "MARKET") + + def test_snapshot_shape(self): + r = make_router(style="maker_both") + r.register_working(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + plan=self._maker_plan()) + snap = r.snapshot() + self.assertEqual(snap["style"], "maker_both") + self.assertEqual(len(snap["working"]), 1) + self.assertIn("counters", snap) + + +# ───────────────────────────────────────────────────────────────────────────── +# Hooks — RULE 5 +# ───────────────────────────────────────────────────────────────────────────── + +class TestHooks(unittest.TestCase): + def test_pre_submit_can_mutate_plan(self): + r = make_router(style="maker_entry") + + def widen(plan, ctx): + if isinstance(plan, ExecutionPlan) and plan.is_maker: + from dataclasses import replace as _r + return _r(plan, limit_price=plan.limit_price + 1.0) + return plan + r.register_hook("pre_submit", widen) + p = r.plan_entry(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + reference_price=61000.0) + self.assertAlmostEqual(p.limit_price, 61001.1) + + def test_insane_hook_plan_ignored(self): + r = make_router(style="maker_entry") + r.register_hook("pre_submit", + lambda plan, ctx: ExecutionPlan(order_type="LIMIT", limit_price=0.0)) + p = r.plan_entry(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + reference_price=61000.0) + self.assertTrue(p.sane()) + self.assertAlmostEqual(p.limit_price, 61000.1) + + def test_hook_exception_isolated(self): + r = make_router(style="maker_entry") + + def boom(plan, ctx): + raise RuntimeError("plugin gone wild") + r.register_hook("pre_submit", boom) + p = r.plan_entry(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + reference_price=61000.0) + self.assertEqual(p.order_type, "LIMIT") + self.assertEqual(r.counters["hook_errors"], 1) + + def test_hook_cannot_suppress_exit(self): + r = make_router(style="maker_exit") + r.register_hook("pre_submit", + lambda plan, ctx: ExecutionPlan(action="EXIT", suppress=True)) + p = r.plan_exit(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + reference_price=60900.0, reason="TAKE_PROFIT") + self.assertFalse(p.suppress) + self.assertEqual(p.order_type, "MARKET") + + def test_unregister(self): + r = make_router(style="maker_entry") + calls = [] + un = r.register_hook("on_fill", lambda wo, ctx: calls.append(1)) + r.register_working(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + plan=ExecutionPlan(order_type="LIMIT", limit_price=1.0, + ttl_s=5, is_maker=True, action="ENTER")) + r.note_fill("t1") + self.assertEqual(len(calls), 1) + un() + r.register_working(trade_id="t2", asset="BTCUSDT", position_side="SHORT", + plan=ExecutionPlan(order_type="LIMIT", limit_price=1.0, + ttl_s=5, is_maker=True, action="ENTER")) + r.note_fill("t2") + self.assertEqual(len(calls), 1) + + def test_unknown_stage_raises(self): + r = make_router() + with self.assertRaises(ValueError): + r.register_hook("nonsense", lambda *a: None) + + def test_lifecycle_hooks_fire(self): + r = make_router(style="maker_entry", entry_miss="skip") + seen = [] + for stage in ("on_working", "on_miss", "on_cancel"): + r.register_hook(stage, lambda x, ctx, s=stage: seen.append(s)) + wo = r.register_working(trade_id="t1", asset="BTCUSDT", position_side="SHORT", + plan=ExecutionPlan(order_type="LIMIT", limit_price=1.0, + ttl_s=5, is_maker=True, action="ENTER")) + r.entry_miss_action(wo) + r.note_cancel("t1") + self.assertEqual(seen, ["on_working", "on_miss", "on_cancel"]) + + +# ───────────────────────────────────────────────────────────────────────────── +# Fuzz — property-based (hypothesis) +# ───────────────────────────────────────────────────────────────────────────── + +class TestFuzz(unittest.TestCase): + @given( + style=st.sampled_from(["taker", "maker_entry", "maker_exit", "maker_both"]), + ref=st.floats(min_value=-1e9, max_value=1e9, + allow_nan=False, allow_infinity=False), + spread=st.one_of(st.none(), st.floats(min_value=-10, max_value=10_000, + allow_nan=False, allow_infinity=False)), + side=st.sampled_from(["SHORT", "LONG", "weird", ""]), + asset=st.sampled_from(list(DEFAULT_TICKS) + ["UNKNOWNUSDT", ""]), + ) + @settings(max_examples=300, deadline=None) + def test_plan_entry_always_sane(self, style, ref, spread, side, asset): + r = make_router(style=style) + p = r.plan_entry(trade_id="f1", asset=asset, position_side=side, + reference_price=ref, spread_bps=spread) + assert p.sane(), p + if p.order_type == "LIMIT": + assert p.limit_price > 0.0 + assert p.ttl_s > 0.0 + + @given( + style=st.sampled_from(["taker", "maker_entry", "maker_exit", "maker_both"]), + ref=st.floats(min_value=-1e9, max_value=1e9, + allow_nan=False, allow_infinity=False), + reason=st.one_of(st.none(), st.text(max_size=20), + st.sampled_from(sorted(MAKER_EXIT_REASONS) + + ["CATASTROPHIC_LOSS", "MAX_HOLD"])), + side=st.sampled_from(["SHORT", "LONG"]), + ) + @settings(max_examples=300, deadline=None) + def test_plan_exit_never_skips(self, style, ref, reason, side): + r = make_router(style=style) + p = r.plan_exit(trade_id="f1", asset="BTCUSDT", position_side=side, + reference_price=ref, reason=reason) + assert p.sane(), p + assert not p.suppress # no working order registered → never suppressed + + @given(prices=st.lists(st.floats(min_value=1e-9, max_value=1e7, + allow_nan=False, allow_infinity=False), + min_size=1, max_size=20), + offset=st.integers(min_value=0, max_value=100), + asset=st.sampled_from(list(DEFAULT_TICKS) + ["XUSDT"])) + @settings(max_examples=200, deadline=None) + def test_maker_price_side_correct(self, prices, offset, asset): + r = make_router(style="maker_both", offset_ticks=offset) + for ref in prices: + sell = r.maker_price(asset=asset, order_side="SELL", reference_price=ref) + buy = r.maker_price(asset=asset, order_side="BUY", reference_price=ref) + assert sell >= ref + assert 0.0 < buy <= ref or buy > 0.0 # buy clamps to >= 1 tick + + +# ───────────────────────────────────────────────────────────────────────────── +# Chaos — randomized lifecycle sequences with invariants (seeded) +# ───────────────────────────────────────────────────────────────────────────── + +class TestChaosLifecycle(unittest.TestCase): + def test_random_sequences_hold_invariants(self): + for seed in range(40): + rng = random.Random(seed) + clk = FakeClock() + r = make_router( + clock=clk, + style=rng.choice(["maker_entry", "maker_exit", "maker_both"]), + entry_miss=rng.choice(["skip", "retry", "market"]), + entry_retries=rng.randint(0, 3), + retry_exhaust=rng.choice(["skip", "market"]), + ) + ids = itertools.count() + for _step in range(200): + op = rng.randrange(6) + clk.tick(rng.random() * 3) + if op == 0: + tid = f"e{next(ids)}" + p = r.plan_entry(trade_id=tid, asset="BTCUSDT", + position_side=rng.choice(["SHORT", "LONG"]), + reference_price=rng.uniform(0, 70000)) + if p.is_maker and not p.suppress: + r.register_working(trade_id=tid, asset="BTCUSDT", + position_side="SHORT", plan=p) + elif op == 1: + tid = f"x{next(ids)}" + p = r.plan_exit(trade_id=tid, asset="BTCUSDT", + position_side=rng.choice(["SHORT", "LONG"]), + reference_price=rng.uniform(0, 70000), + reason=rng.choice(["TAKE_PROFIT", "MAX_HOLD", + "CATASTROPHIC_LOSS", "junk"])) + assert p.sane() + if p.is_maker and not p.suppress: + r.register_working(trade_id=tid, asset="BTCUSDT", + position_side="SHORT", plan=p) + elif op == 2 and r.working_orders(): + r.note_fill(rng.choice(r.working_orders()).trade_id) + elif op == 3 and r.working_orders(): + r.note_cancel(rng.choice(r.working_orders()).trade_id) + elif op == 4: + for wo in r.expired(): + act = r.entry_miss_action(wo) if wo.action == "ENTER" else None + r.note_cancel(wo.trade_id) + if act == MissAction.RETRY: + tid2, plan2 = r.retry_plan(wo, reference_price=rng.uniform(1, 70000)) + if plan2.is_maker: + r.register_working(trade_id=tid2, asset="BTCUSDT", + position_side="SHORT", plan=plan2, + base_trade_id=wo.base_trade_id, + retry_n=wo.retry_n + 1) + elif act == MissAction.MARKET or (wo.action == "EXIT"): + r.market_fallback_plan(wo) + else: + r.snapshot() + + # INVARIANT R2: at most one working ENTER at any time + entries = [w for w in r.working_orders() if w.action == "ENTER"] + assert len(entries) <= 1, f"seed={seed}: {entries}" + # INVARIANT R3: retry numbering bounded + for w in r.working_orders(): + assert w.retry_n <= r.config.entry_retries + 1 + + +if __name__ == "__main__": + unittest.main() diff --git a/prod/clean_arch/dita_v2/test_exec_router_runtime.py b/prod/clean_arch/dita_v2/test_exec_router_runtime.py new file mode 100644 index 0000000..a933b88 --- /dev/null +++ b/prod/clean_arch/dita_v2/test_exec_router_runtime.py @@ -0,0 +1,646 @@ +"""ExecutionRouter ↔ PinkDirectRuntime glue tests (FakeKernel harness). + +Covers the runtime-side drivers in prod/clean_arch/runtime/pink_direct.py: + _exec_plan_for / _exec_after_submit / _exec_cancel_working / + _handle_expired_working / pump_venue_events router notifications. + +Invariants: + G1 taker style / router-None leave the kernel intent untouched + G2 a resting maker quote registers exactly once; immediate fills never do + G3 TTL expiry: fill races are detected before AND after the cancel — + a filled entry is never re-entered, a filled exit never re-closed + G4 expired EXIT always escalates to MARKET with the SAME trade_id + G5 expired ENTER honours miss policy; resubmit only into a free slot + G6 venue-side cancels accelerate the deadline (miss policy still runs) +""" + +from __future__ import annotations + +import asyncio +import logging +import unittest +from collections import deque +from datetime import datetime, timezone +from types import SimpleNamespace +from typing import Any, List + +from prod.clean_arch.dita_v2.contracts import ( + KernelCommandType, + KernelEventKind, + KernelIntent, + TradeSide, + TradeStage, + VenueEvent, + VenueEventStatus, +) +from prod.clean_arch.dita_v2.exec_router import ( + ExecConfig, + ExecutionPlan, + ExecutionRouter, +) +from prod.clean_arch.runtime.pink_direct import PinkDirectRuntime + +LOGGER = logging.getLogger("test_exec_router_runtime") + + +class FakeClock: + def __init__(self, t: float = 1000.0): + self.t = t + + def __call__(self) -> float: + return self.t + + def tick(self, dt: float) -> None: + self.t += dt + + +class FakeSlot: + def __init__(self): + self.trade_id = "" + self.asset = "" + self.fsm_state = TradeStage.IDLE + self.size = 0.0 + + def is_free(self) -> bool: + return self.fsm_state in (TradeStage.IDLE, TradeStage.CLOSED) and self.size <= 0.0 + + def to_dict(self): + return {"trade_id": self.trade_id, "asset": self.asset, + "fsm_state": self.fsm_state.value, "size": self.size} + + +class FakeVenue: + def __init__(self): + self.reconcile_queue: deque = deque() + + async def reconcile(self) -> List[VenueEvent]: + if self.reconcile_queue: + return self.reconcile_queue.popleft() + return [] + + +class FakeKernel: + """Scripted kernel: on_intent callbacks mutate the slot to simulate venue + behavior (fill / rest / reject / cancel).""" + + max_slots = 1 + + def __init__(self): + self.slot0 = FakeSlot() + self.venue = FakeVenue() + self.intents: List[KernelIntent] = [] + self.on_intent = None # callable(intent, kernel) — scripted behavior + + def slot(self, _i: int) -> FakeSlot: + return self.slot0 + + async def process_intent_async(self, intent: KernelIntent): + self.intents.append(intent) + if self.on_intent is not None: + self.on_intent(intent, self) + return SimpleNamespace(accepted=True, diagnostic_code=None, details={}) + + def on_venue_event(self, event: VenueEvent): + return SimpleNamespace(accepted=True, diagnostic_code=None) + + def snapshot(self): + return {"account": {}} + + +def make_runtime(style="maker_both", clock=None, **cfg) -> PinkDirectRuntime: + kernel = FakeKernel() + rt = PinkDirectRuntime( + data_feed=SimpleNamespace(), # unused by the exec drivers + kernel=kernel, + decision_engine=SimpleNamespace(), + intent_engine=SimpleNamespace(), + persistence=None, + logger=LOGGER, + ) + rt.exec_router = ExecutionRouter(ExecConfig(style=style, **cfg), + logger=LOGGER, clock=clock or FakeClock()) + rt._working_intents = {} + rt._own_fill_symbols = set() + rt._price_history = deque([61000.0], maxlen=10) + return rt + + +def make_intent(action=KernelCommandType.ENTER, tid="t1", order_type="MARKET", + limit_price=0.0, asset="BTCUSDT", size=0.5) -> KernelIntent: + return KernelIntent( + timestamp=datetime.now(timezone.utc), + intent_id=tid, trade_id=tid, slot_id=0, asset=asset, + side=TradeSide.SHORT, action=action, + reference_price=61000.0, target_size=size, leverage=1.0, + order_type=order_type, limit_price=limit_price, + ) + + +def fill_event(tid: str, kind=KernelEventKind.FULL_FILL, + status=VenueEventStatus.FILLED) -> VenueEvent: + return VenueEvent( + timestamp=datetime.now(timezone.utc), event_id=f"ev-{tid}", + trade_id=tid, slot_id=0, kind=kind, status=status, + asset="BTCUSDT", price=61000.0, size=0.5, filled_size=0.5, + ) + + +def run(coro): + return asyncio.run(coro) + + +# ───────────────────────────────────────────────────────────────────────────── +# G2 — post-submit classification +# ───────────────────────────────────────────────────────────────────────────── + +class TestAfterSubmit(unittest.TestCase): + def test_resting_entry_registers(self): + rt = make_runtime() + plan = ExecutionPlan(order_type="LIMIT", limit_price=61000.1, post_only=True, + ttl_s=8.0, is_maker=True, action="ENTER", reason="m") + intent = make_intent(order_type="LIMIT", limit_price=61000.1) + # slot stays IDLE → resting + rt._exec_after_submit(plan, intent, SimpleNamespace()) + self.assertIsNotNone(rt.exec_router.working("t1")) + self.assertIn("t1", rt._working_intents) + + def test_immediate_entry_fill_does_not_register(self): + rt = make_runtime() + rt.kernel.slot0.trade_id = "t1" + rt.kernel.slot0.size = 0.5 + rt.kernel.slot0.fsm_state = TradeStage.POSITION_OPEN + plan = ExecutionPlan(order_type="LIMIT", limit_price=61000.1, post_only=True, + ttl_s=8.0, is_maker=True, action="ENTER", reason="m") + rt._exec_after_submit(plan, make_intent(), SimpleNamespace()) + self.assertIsNone(rt.exec_router.working("t1")) + self.assertNotIn("t1", rt._working_intents) + + def test_rejected_entry_registers_with_instant_deadline(self): + clk = FakeClock() + rt = make_runtime(clock=clk) + rt.kernel.slot0.fsm_state = TradeStage.ORDER_REJECTED + plan = ExecutionPlan(order_type="LIMIT", limit_price=61000.1, post_only=True, + ttl_s=8.0, is_maker=True, action="ENTER", reason="m") + rt._exec_after_submit(plan, make_intent(), SimpleNamespace()) + wo = rt.exec_router.working("t1") + self.assertIsNotNone(wo) + self.assertLessEqual(wo.deadline, clk()) # resolvable immediately + + def test_resting_exit_registers(self): + rt = make_runtime() + rt.kernel.slot0.trade_id = "t1" + rt.kernel.slot0.size = 0.5 + rt.kernel.slot0.fsm_state = TradeStage.EXIT_WORKING + plan = ExecutionPlan(order_type="LIMIT", limit_price=60900.0, post_only=True, + ttl_s=5.0, is_maker=True, action="EXIT", reason="m") + rt._exec_after_submit(plan, make_intent(KernelCommandType.EXIT), SimpleNamespace()) + self.assertIsNotNone(rt.exec_router.working("t1")) + + def test_immediate_exit_fill_does_not_register(self): + rt = make_runtime() + rt.kernel.slot0.trade_id = "t1" + rt.kernel.slot0.size = 0.0 + rt.kernel.slot0.fsm_state = TradeStage.CLOSED + plan = ExecutionPlan(order_type="LIMIT", limit_price=60900.0, post_only=True, + ttl_s=5.0, is_maker=True, action="EXIT", reason="m") + rt._exec_after_submit(plan, make_intent(KernelCommandType.EXIT), SimpleNamespace()) + self.assertIsNone(rt.exec_router.working("t1")) + + +# ───────────────────────────────────────────────────────────────────────────── +# G3/G5 — entry TTL expiry +# ───────────────────────────────────────────────────────────────────────────── + +def _register_working_entry(rt, tid="t1", ttl=8.0): + plan = ExecutionPlan(order_type="LIMIT", limit_price=61000.1, post_only=True, + ttl_s=ttl, is_maker=True, action="ENTER", reason="m") + intent = make_intent(tid=tid, order_type="LIMIT", limit_price=61000.1) + rt._exec_after_submit(plan, intent, SimpleNamespace()) + return rt.exec_router.working(tid) + + +class TestEntryExpiry(unittest.TestCase): + def test_fill_detected_before_cancel(self): + clk = FakeClock() + rt = make_runtime(clock=clk, entry_miss="market") + wo = _register_working_entry(rt) + # quote fills via reconcile right before TTL handling + rt.kernel.venue.reconcile_queue.append([fill_event("t1")]) + + def on_intent(intent, k): + raise AssertionError("no intent should be submitted — fill won") + clk.tick(9) + # pump inside handler applies the fill → note_fill → return + rt.kernel.slot0.trade_id = "t1" + rt.kernel.slot0.size = 0.5 + rt.kernel.slot0.fsm_state = TradeStage.POSITION_OPEN + rt.kernel.on_intent = on_intent + run(rt._handle_expired_working(wo)) + self.assertIsNone(rt.exec_router.working("t1")) + self.assertEqual(rt.kernel.intents, []) + + def test_fill_race_after_cancel(self): + clk = FakeClock() + rt = make_runtime(clock=clk, entry_miss="market") + wo = _register_working_entry(rt) + clk.tick(9) + + def on_intent(intent, k): + # CANCEL arrives but the order had just filled: venue keeps position + if intent.action == KernelCommandType.CANCEL: + k.slot0.trade_id = "t1" + k.slot0.size = 0.5 + k.slot0.fsm_state = TradeStage.POSITION_OPEN + else: + raise AssertionError(f"unexpected non-cancel intent {intent.action}") + rt.kernel.on_intent = on_intent + run(rt._handle_expired_working(wo)) + self.assertIsNone(rt.exec_router.working("t1")) + # only the CANCEL was sent — no fallback after the raced fill + self.assertEqual([i.action for i in rt.kernel.intents], + [KernelCommandType.CANCEL]) + + def test_miss_skip_sends_only_cancel(self): + clk = FakeClock() + rt = make_runtime(clock=clk, entry_miss="skip") + wo = _register_working_entry(rt) + clk.tick(9) + run(rt._handle_expired_working(wo)) + self.assertEqual([i.action for i in rt.kernel.intents], + [KernelCommandType.CANCEL]) + self.assertIsNone(rt.exec_router.working("t1")) + self.assertEqual(rt.exec_router.counters["entry_miss_skip"], 1) + + def test_miss_market_resubmits_market_with_new_id(self): + clk = FakeClock() + rt = make_runtime(clock=clk, entry_miss="market") + wo = _register_working_entry(rt) + clk.tick(9) + run(rt._handle_expired_working(wo)) + actions = [(i.action, i.trade_id, i.order_type) for i in rt.kernel.intents] + self.assertEqual(actions[0][0], KernelCommandType.CANCEL) + self.assertEqual(actions[1], (KernelCommandType.ENTER, "t1-m", "MARKET")) + # market fallback is taker → not registered as working + self.assertIsNone(rt.exec_router.working("t1-m")) + + def test_miss_retry_requotes_then_registers(self): + clk = FakeClock() + rt = make_runtime(clock=clk, entry_miss="retry", entry_retries=1) + wo = _register_working_entry(rt) + clk.tick(9) + run(rt._handle_expired_working(wo)) + kinds = [(i.action, i.trade_id, i.order_type) for i in rt.kernel.intents] + self.assertEqual(kinds[0][0], KernelCommandType.CANCEL) + self.assertEqual(kinds[1][1], "t1-r1") + self.assertEqual(kinds[1][2], "LIMIT") + self.assertIsNotNone(rt.exec_router.working("t1-r1")) + self.assertIn("t1-r1", rt._working_intents) + # postonly TIF travels on the retried intent + self.assertEqual(rt.kernel.intents[1].metadata.get("_time_in_force"), "PostOnly") + + def test_retry_exhaust_skip_after_budget(self): + clk = FakeClock() + rt = make_runtime(clock=clk, entry_miss="retry", entry_retries=1, + retry_exhaust="skip") + wo = _register_working_entry(rt) + clk.tick(9) + run(rt._handle_expired_working(wo)) # retry 1 → t1-r1 working + wo2 = rt.exec_router.working("t1-r1") + self.assertIsNotNone(wo2) + clk.tick(9) + run(rt._handle_expired_working(wo2)) # budget spent → skip + # intents: CANCEL, ENTER(r1), CANCEL — and nothing else + self.assertEqual([i.action for i in rt.kernel.intents], + [KernelCommandType.CANCEL, KernelCommandType.ENTER, + KernelCommandType.CANCEL]) + self.assertEqual(rt.exec_router.working_orders(), []) + + def test_slot_busy_blocks_resubmit(self): + clk = FakeClock() + rt = make_runtime(clock=clk, entry_miss="market") + wo = _register_working_entry(rt) + clk.tick(9) + + def on_intent(intent, k): + if intent.action == KernelCommandType.CANCEL: + # a DIFFERENT trade occupies the slot after our cancel + k.slot0.trade_id = "other" + k.slot0.size = 0.7 + k.slot0.fsm_state = TradeStage.POSITION_OPEN + rt.kernel.on_intent = on_intent + run(rt._handle_expired_working(wo)) + # G5: no ENTER may follow into an occupied slot + self.assertEqual([i.action for i in rt.kernel.intents], + [KernelCommandType.CANCEL]) + + def test_stale_working_order_noop(self): + rt = make_runtime() + wo = _register_working_entry(rt) + rt.exec_router.note_fill("t1") # resolved before handler runs + run(rt._handle_expired_working(wo)) + self.assertEqual(rt.kernel.intents, []) + + +class TestRequoteVenueTruthGate(unittest.TestCase): + """Live double-entry regression (2026-06-10): a filled quote whose fill + the REST reconcile hadn't surfaced yet was treated as a miss and + re-quoted → 2× position. The gate must block requotes when (a) an own + fill landed in the hot window or (b) the venue shows any live position.""" + + def test_recent_own_fill_blocks_requote(self): + import time as _t + clk = FakeClock() + rt = make_runtime(clock=clk, entry_miss="market") + wo = _register_working_entry(rt) + rt._last_own_fill_mono = _t.monotonic() # fill just landed via WS + clk.tick(9) + run(rt._handle_expired_working(wo)) + # cancel only — NO market fallback ENTER + self.assertEqual([i.action for i in rt.kernel.intents], + [KernelCommandType.CANCEL]) + + def test_live_exchange_position_blocks_requote(self): + clk = FakeClock() + rt = make_runtime(clock=clk, entry_miss="retry", entry_retries=2) + wo = _register_working_entry(rt) + rt.kernel.venue.open_positions = lambda: [{"positionAmt": "0.8932"}] + clk.tick(9) + run(rt._handle_expired_working(wo)) + self.assertEqual([i.action for i in rt.kernel.intents], + [KernelCommandType.CANCEL]) + + def test_probe_error_fails_safe(self): + clk = FakeClock() + rt = make_runtime(clock=clk, entry_miss="market") + wo = _register_working_entry(rt) + + def boom(): + raise RuntimeError("venue probe down") + rt.kernel.venue.open_positions = boom + clk.tick(9) + run(rt._handle_expired_working(wo)) + self.assertEqual([i.action for i in rt.kernel.intents], + [KernelCommandType.CANCEL]) + + def test_provably_flat_allows_requote(self): + clk = FakeClock() + rt = make_runtime(clock=clk, entry_miss="market") + wo = _register_working_entry(rt) + rt.kernel.venue.open_positions = lambda: [] + rt._last_own_fill_mono = 0.0 + clk.tick(9) + run(rt._handle_expired_working(wo)) + self.assertEqual([i.action for i in rt.kernel.intents], + [KernelCommandType.CANCEL, KernelCommandType.ENTER]) + + +class TestSingleSlotEntryInvariant(unittest.TestCase): + """Second live double-entry (2026-06-10 17:24/17:25): the re-entry came + through the MAIN decision path (filled maker order vanished from + openOrders → reconcile misread it as cancel → slot freed → re-ENTER). + _unsafe_entry_reason must block ANY ENTER while the exchange shows an + open position, or within the own-fill hot window, or when the probe errs.""" + + def _ctx(self): + return SimpleNamespace(capital=10_000.0, open_positions=0, trade_seq=1) + + def test_exchange_position_blocks_enter(self): + rt = make_runtime() + rt.kernel.venue.open_positions = lambda: [{"symbol": "BTC-USDT", "positionAmt": "0.89"}] + reason = rt._unsafe_entry_reason(make_intent(), self._ctx()) + self.assertIsNotNone(reason) + self.assertIn("single-slot", reason) + + def test_recent_own_fill_blocks_enter(self): + import time as _t + rt = make_runtime() + rt.kernel.venue.open_positions = lambda: [] + rt._last_own_fill_mono = _t.monotonic() + reason = rt._unsafe_entry_reason(make_intent(), self._ctx()) + self.assertIsNotNone(reason) + self.assertIn("hot window", reason) + + def test_probe_error_fails_safe(self): + rt = make_runtime() + + def boom(): + raise RuntimeError("probe down") + rt.kernel.venue.open_positions = boom + reason = rt._unsafe_entry_reason(make_intent(), self._ctx()) + self.assertIsNotNone(reason) + self.assertIn("fail safe", reason) + + def test_flat_venue_allows_enter(self): + rt = make_runtime() + rt.kernel.venue.open_positions = lambda: [] + rt._last_own_fill_mono = 0.0 + intent = make_intent() + # reference_price/size/leverage are sane in make_intent → None expected + self.assertIsNone(rt._unsafe_entry_reason(intent, self._ctx())) + + +# ───────────────────────────────────────────────────────────────────────────── +# G4 — exit TTL expiry +# ───────────────────────────────────────────────────────────────────────────── + +def _register_working_exit(rt, tid="t1", ttl=5.0): + rt.kernel.slot0.trade_id = tid + rt.kernel.slot0.asset = "BTCUSDT" + rt.kernel.slot0.size = 0.5 + rt.kernel.slot0.fsm_state = TradeStage.EXIT_WORKING + plan = ExecutionPlan(order_type="LIMIT", limit_price=60900.0, post_only=True, + ttl_s=ttl, is_maker=True, action="EXIT", reason="m") + intent = make_intent(KernelCommandType.EXIT, tid=tid, + order_type="LIMIT", limit_price=60900.0) + rt._exec_after_submit(plan, intent, SimpleNamespace()) + return rt.exec_router.working(tid) + + +class TestExitExpiry(unittest.TestCase): + def test_exit_ttl_escalates_to_market_same_trade_id(self): + clk = FakeClock() + rt = make_runtime(clock=clk) + wo = _register_working_exit(rt) + clk.tick(6) + run(rt._handle_expired_working(wo)) + seq = [(i.action, i.trade_id, i.order_type) for i in rt.kernel.intents] + self.assertEqual(seq[0][0], KernelCommandType.CANCEL) + self.assertEqual(seq[1], (KernelCommandType.EXIT, "t1", "MARKET")) + self.assertEqual(rt.exec_router.counters["exit_escalations"], 1) + + def test_exit_filled_during_race_no_fallback(self): + clk = FakeClock() + rt = make_runtime(clock=clk) + wo = _register_working_exit(rt) + clk.tick(6) + + def on_intent(intent, k): + if intent.action == KernelCommandType.CANCEL: + # exit filled before cancel landed → flat + k.slot0.size = 0.0 + k.slot0.fsm_state = TradeStage.CLOSED + rt.kernel.on_intent = on_intent + run(rt._handle_expired_working(wo)) + self.assertEqual([i.action for i in rt.kernel.intents], + [KernelCommandType.CANCEL]) + self.assertIsNone(rt.exec_router.working("t1")) + + def test_exit_fill_detected_pre_cancel(self): + clk = FakeClock() + rt = make_runtime(clock=clk) + wo = _register_working_exit(rt) + rt.kernel.slot0.size = 0.0 + rt.kernel.slot0.fsm_state = TradeStage.CLOSED + rt.kernel.venue.reconcile_queue.append([fill_event("t1")]) + clk.tick(6) + run(rt._handle_expired_working(wo)) + self.assertEqual(rt.kernel.intents, []) + + +# ───────────────────────────────────────────────────────────────────────────── +# G6 — pump notifications +# ───────────────────────────────────────────────────────────────────────────── + +class TestPumpNotifications(unittest.TestCase): + def test_full_fill_clears_working(self): + rt = make_runtime() + _register_working_entry(rt) + rt.kernel.venue.reconcile_queue.append([fill_event("t1")]) + applied = run(rt.pump_venue_events()) + self.assertEqual(applied, 1) + self.assertIsNone(rt.exec_router.working("t1")) + self.assertNotIn("t1", rt._working_intents) + + def test_venue_cancel_accelerates_deadline_not_removal(self): + clk = FakeClock() + rt = make_runtime(clock=clk, entry_miss="market") + wo = _register_working_entry(rt) + original_deadline = wo.deadline + ev = fill_event("t1", kind=KernelEventKind.CANCEL_ACK, + status=VenueEventStatus.CANCELED) + rt.kernel.venue.reconcile_queue.append([ev]) + run(rt.pump_venue_events()) + wo2 = rt.exec_router.working("t1") + self.assertIsNotNone(wo2) # NOT dropped (G6) + self.assertLess(wo2.deadline, original_deadline) + self.assertLessEqual(wo2.deadline, clk()) # expired now → TTL loop resolves + + def test_unrelated_events_ignored(self): + rt = make_runtime() + _register_working_entry(rt) + rt.kernel.venue.reconcile_queue.append([fill_event("someone_else")]) + run(rt.pump_venue_events()) + self.assertIsNotNone(rt.exec_router.working("t1")) + + +# ───────────────────────────────────────────────────────────────────────────── +# Urgent-exit preempt + plan glue +# ───────────────────────────────────────────────────────────────────────────── + +class TestPreemptAndPlanGlue(unittest.TestCase): + def test_exec_cancel_working_sends_cancel_and_clears(self): + rt = make_runtime() + _register_working_exit(rt) + run(rt._exec_cancel_working("t1", reason="urgent_exit_preempt")) + self.assertEqual([i.action for i in rt.kernel.intents], + [KernelCommandType.CANCEL]) + self.assertIsNone(rt.exec_router.working("t1")) + self.assertNotIn("t1", rt._working_intents) + + def test_exec_cancel_working_noop_when_not_working(self): + rt = make_runtime() + run(rt._exec_cancel_working("ghost", reason="x")) + self.assertEqual(rt.kernel.intents, []) + + def test_plan_for_none_router_returns_none(self): + rt = make_runtime() + rt.exec_router = None + decision = SimpleNamespace(action=None, reason="TAKE_PROFIT") + self.assertIsNone(rt._exec_plan_for(decision, make_intent(), SimpleNamespace())) + + def test_plan_for_router_crash_degrades_to_taker(self): + rt = make_runtime() + + class Boom: + config = SimpleNamespace(style="maker_both") + + def plan_entry(self, **kw): + raise RuntimeError("router on fire") + rt.exec_router = Boom() + from prod.clean_arch.dita import DecisionAction + decision = SimpleNamespace(action=DecisionAction.ENTER, reason="") + self.assertIsNone(rt._exec_plan_for(decision, make_intent(), SimpleNamespace())) + + +# ───────────────────────────────────────────────────────────────────────────── +# Chaos — randomized kernel behavior through the full expiry path +# ───────────────────────────────────────────────────────────────────────────── + +class TestRuntimeChaos(unittest.TestCase): + def test_random_kernel_behaviors_never_double_enter(self): + import random + for seed in range(30): + rng = random.Random(seed) + clk = FakeClock() + rt = make_runtime( + clock=clk, + entry_miss=rng.choice(["skip", "retry", "market"]), + entry_retries=rng.randint(0, 2), + retry_exhaust=rng.choice(["skip", "market"]), + ) + + def on_intent(intent, k, rng=rng): + if intent.action == KernelCommandType.CANCEL: + roll = rng.random() + if roll < 0.25: # cancel raced a fill + k.slot0.trade_id = intent.trade_id + k.slot0.size = 0.5 + k.slot0.fsm_state = TradeStage.POSITION_OPEN + elif roll < 0.35: # foreign trade grabbed the slot + k.slot0.trade_id = "foreign" + k.slot0.size = 0.3 + k.slot0.fsm_state = TradeStage.POSITION_OPEN + else: # clean cancel + k.slot0.trade_id = "" + k.slot0.size = 0.0 + k.slot0.fsm_state = TradeStage.IDLE + elif intent.action == KernelCommandType.ENTER: + if rng.random() < 0.5: # immediate fill + k.slot0.trade_id = intent.trade_id + k.slot0.size = float(intent.target_size) + k.slot0.fsm_state = TradeStage.POSITION_OPEN + # else rests (slot unchanged) + rt.kernel.on_intent = on_intent + + wo = _register_working_entry(rt, tid=f"c{seed}") + for _round in range(6): + clk.tick(10) + expired = rt.exec_router.expired() + if not expired: + break + run(rt._handle_expired_working(expired[0])) + + # INVARIANT: never two ENTER intents without an intervening + # CANCEL, and never an ENTER while the slot is occupied by + # someone else. + last_action = None + for it in rt.kernel.intents: + if it.action == KernelCommandType.ENTER: + self.assertNotEqual(last_action, KernelCommandType.ENTER, + f"seed={seed}: back-to-back ENTERs") + last_action = it.action + entries = [w for w in rt.exec_router.working_orders() + if w.action == "ENTER"] + self.assertLessEqual(len(entries), 1, f"seed={seed}") + # registry always resolvable: nothing may rest forever past deadline + clk.tick(1000) + for w in rt.exec_router.expired(): + run(rt._handle_expired_working(w)) + self.assertEqual( + [w for w in rt.exec_router.working_orders() + if w.deadline < clk()], [], + f"seed={seed}: unresolved expired orders") + + +if __name__ == "__main__": + unittest.main() diff --git a/prod/clean_arch/dita_v2/test_reset_and_seed.py b/prod/clean_arch/dita_v2/test_reset_and_seed.py new file mode 100644 index 0000000..50d2d67 --- /dev/null +++ b/prod/clean_arch/dita_v2/test_reset_and_seed.py @@ -0,0 +1,460 @@ +""" +test_reset_and_seed.py — painstaking coverage of reset_and_seed(). + +Scenarios tested: + Basic correctness + 01 zeros all K-accumulators + 02 K = seed = capital after reset + 03 e_wallet_balance set to capital + 04 reconcile_delta = 0 after reset + 05 capital_frozen = False after reset (unfreeze) + Guard against invalid input + 06 capital = 0 → no-op (kernel unchanged) + 07 capital < 0 → no-op + 08 capital = NaN → no-op + 09 capital = Inf → no-op + Idempotency / sequencing + 10 double reset is idempotent + 11 reset from a frozen state unfreezes + 12 reset from a WARN state also resolves cleanly + Preservation of non-accumulator state + 13 seen_account_event_ids preserved (WS-replay dedup survives) + 14 calibration_ratio preserved + 15 calibration_samples preserved + 16 open slot unchanged by reset + Post-reset accumulation is correct + 17 new FILL_SETTLED after reset accumulates correctly into K + 18 new PREDICTED_FILL after reset accumulates correctly + 19 ACCOUNT_UPDATE after reset updates E and re-runs reconcile + 20 funding fee after reset accumulated in k_funding_net + WS-replay dedup scenario (the original bug) + 21 replayed fill event (same event_id) is ignored after reset + 22 fresh fill event (new event_id) is processed after reset + Race / ordering + 23 reset_and_seed is the last thing called before WS stream starts + (structural: verify connect() order in pink_direct.py source) + 24 reset → FILL_SETTLED → ACCOUNT_UPDATE → delta stays small + Python-layer + 25 ExecutionKernel.reset_and_seed delegates to Rust (smoke) + 26 set_seed_capital still works independently (not broken by addition) +""" + +import math +import json +import re +import sys +import asyncio +import inspect +import unittest +from pathlib import Path +from unittest.mock import MagicMock, AsyncMock, patch, call + +# ── path setup ──────────────────────────────────────────────────────────────── +_ROOT = Path(__file__).parents[3] +sys.path.insert(0, str(_ROOT)) +sys.path.insert(0, str(_ROOT / "prod")) +sys.path.insert(0, str(_ROOT / "prod" / "clean_arch")) + +from prod.clean_arch.dita_v2.rust_backend import ExecutionKernel, _get_rust + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _make_kernel() -> ExecutionKernel: + return ExecutionKernel(max_slots=1) + + +def _account(k: ExecutionKernel) -> dict: + """Return full account dict from save_state_json.""" + state = json.loads(_get_rust().save_state(k._backend)) + return state["account"] + + +def _fill_settled(k: ExecutionKernel, pnl: float, fee: float, + is_maker: bool = False, event_id: str = "") -> dict: + ev = {"kind": "FILL_SETTLED", "realized_pnl": pnl, "fee": fee, + "is_maker": is_maker} + if event_id: + ev["event_id"] = event_id + return k.on_account_event(ev) + + +def _account_update(k: ExecutionKernel, wb: float, avail: float = 0.0) -> dict: + return k.on_account_event({ + "kind": "ACCOUNT_UPDATE", + "wallet_balance": wb, + "available_margin": avail, + "used_margin": 0.0, + "maint_margin": 0.0, + }) + + +def _accumulate(k: ExecutionKernel, seed: float = 100_000.0) -> None: + """Bring kernel to a known dirty state: filled, fees, frozen.""" + k.set_seed_capital(seed) + _account_update(k, seed * 0.9) # E = 90k → sets e_wallet_balance + _fill_settled(k, pnl=500.0, fee=20.0, is_maker=False, event_id="ev-001") + _fill_settled(k, pnl=-200.0, fee=15.0, is_maker=True, event_id="ev-002") + # After fills k_realized_pnl ≠ 0 and reconcile status may be WARN/ERROR + + +# ══════════════════════════════════════════════════════════════════════════════ +# 01-05 Basic correctness +# ══════════════════════════════════════════════════════════════════════════════ + +class TestResetAndSeedBasic(unittest.TestCase): + + def test_01_zeros_all_k_accumulators(self): + k = _make_kernel() + _accumulate(k) + k.reset_and_seed(100_000.0) + acc = _account(k) + self.assertAlmostEqual(acc["k_realized_pnl"], 0.0, places=9) + self.assertAlmostEqual(acc["k_taker_fees"], 0.0, places=9) + self.assertAlmostEqual(acc["k_maker_fees"], 0.0, places=9) + self.assertAlmostEqual(acc["k_maker_rebates"],0.0, places=9) + self.assertAlmostEqual(acc["k_fees_paid"], 0.0, places=9) + self.assertAlmostEqual(acc["k_funding_net"], 0.0, places=9) + + def test_02_k_capital_equals_seed_equals_capital(self): + k = _make_kernel() + _accumulate(k) + k.reset_and_seed(123_456.78) + acc = _account(k) + self.assertAlmostEqual(acc["seed_capital"], 123_456.78, places=6) + self.assertAlmostEqual(acc["k_capital"], 123_456.78, places=6) + + def test_03_e_wallet_balance_set_to_capital(self): + k = _make_kernel() + _accumulate(k) + k.reset_and_seed(99_999.99) + acc = _account(k) + self.assertAlmostEqual(acc["e_wallet_balance"], 99_999.99, places=6) + + def test_04_reconcile_delta_is_zero(self): + k = _make_kernel() + _accumulate(k) + k.reset_and_seed(100_000.0) + acc = _account(k) + self.assertAlmostEqual(acc["reconcile_delta"], 0.0, places=6) + self.assertEqual(acc["reconcile_status"], "OK") + + def test_05_capital_unfrozen_after_reset(self): + k = _make_kernel() + # Force a frozen state via a large K > E discrepancy + k.set_seed_capital(100_000.0) + _account_update(k, 50_000.0) # E = 50k while K = 100k → ERROR → frozen + self.assertTrue(k.is_capital_frozen(), "pre-condition: should be frozen") + k.reset_and_seed(50_000.0) + self.assertFalse(k.is_capital_frozen()) + + +# ══════════════════════════════════════════════════════════════════════════════ +# 06-09 Guard against invalid input +# ══════════════════════════════════════════════════════════════════════════════ + +class TestResetAndSeedInvalidInput(unittest.TestCase): + + def _assert_no_change(self, k: ExecutionKernel, capital_before: float) -> None: + acc = _account(k) + self.assertAlmostEqual(acc["seed_capital"], capital_before, places=6, + msg="seed_capital must not change on invalid input") + + def test_06_zero_capital_is_noop(self): + k = _make_kernel() + k.set_seed_capital(75_000.0) + k.reset_and_seed(0.0) + self._assert_no_change(k, 75_000.0) + + def test_07_negative_capital_is_noop(self): + k = _make_kernel() + k.set_seed_capital(75_000.0) + k.reset_and_seed(-1.0) + self._assert_no_change(k, 75_000.0) + + def test_08_nan_capital_is_noop(self): + k = _make_kernel() + k.set_seed_capital(75_000.0) + k.reset_and_seed(float("nan")) + self._assert_no_change(k, 75_000.0) + + def test_09_inf_capital_is_noop(self): + k = _make_kernel() + k.set_seed_capital(75_000.0) + k.reset_and_seed(float("inf")) + self._assert_no_change(k, 75_000.0) + + +# ══════════════════════════════════════════════════════════════════════════════ +# 10-12 Idempotency / sequencing +# ══════════════════════════════════════════════════════════════════════════════ + +class TestResetAndSeedIdempotency(unittest.TestCase): + + def test_10_double_reset_is_idempotent(self): + k = _make_kernel() + _accumulate(k) + k.reset_and_seed(100_000.0) + acc1 = _account(k) + k.reset_and_seed(100_000.0) + acc2 = _account(k) + self.assertAlmostEqual(acc1["k_capital"], acc2["k_capital"], places=6) + self.assertAlmostEqual(acc1["reconcile_delta"], acc2["reconcile_delta"], places=6) + self.assertEqual(acc1["reconcile_status"], acc2["reconcile_status"]) + self.assertEqual(acc1["capital_frozen"], acc2["capital_frozen"]) + + def test_11_reset_from_frozen_state_unfreezes(self): + k = _make_kernel() + k.set_seed_capital(100_000.0) + _account_update(k, 50_000.0) + self.assertTrue(k.is_capital_frozen()) + k.reset_and_seed(50_000.0) + self.assertFalse(k.is_capital_frozen()) + acc = _account(k) + self.assertEqual(acc["reconcile_status"], "OK") + + def test_12_reset_from_warn_state_resolves(self): + k = _make_kernel() + k.set_seed_capital(100_000.0) + # 10 USDT gap → WARN (< 20 threshold) + _account_update(k, 99_990.0) + acc_before = _account(k) + self.assertEqual(acc_before["reconcile_status"], "WARN") + k.reset_and_seed(99_990.0) + acc = _account(k) + self.assertEqual(acc["reconcile_status"], "OK") + self.assertAlmostEqual(acc["reconcile_delta"], 0.0, places=6) + + +# ══════════════════════════════════════════════════════════════════════════════ +# 13-16 Preservation of non-accumulator state +# ══════════════════════════════════════════════════════════════════════════════ + +class TestResetAndSeedPreservation(unittest.TestCase): + + def test_13_seen_account_event_ids_preserved(self): + """The WS-replay dedup list must survive reset so old replays stay idempotent.""" + k = _make_kernel() + k.set_seed_capital(100_000.0) + _account_update(k, 100_000.0) + # Process a fill with a known event_id + r1 = _fill_settled(k, pnl=100.0, fee=5.0, event_id="unique-event-XYZ") + self.assertFalse(r1.get("duplicate_event", False), "first call must not be duplicate") + k.reset_and_seed(100_100.0) # reset does NOT clear dedup list + # Replay the same event_id — must still be recognised as duplicate + r2 = _fill_settled(k, pnl=100.0, fee=5.0, event_id="unique-event-XYZ") + self.assertTrue(r2.get("duplicate_event", False), + "replayed event_id must be deduplicated after reset_and_seed") + + def test_14_calibration_ratio_preserved(self): + k = _make_kernel() + k.set_seed_capital(100_000.0) + k.set_exchange_config({"taker_rate": 0.0005, "maker_rate": 0.0002}) + # Run a calibration that shifts ratio away from 1.0 + fill_price, fill_qty = 60_000.0, 1.0 + actual_fee = 120.0 # much higher than predicted → ratio > 1 + k.calibrate_fee(fill_price, fill_qty, actual_fee, is_maker=False) + acc_before = _account(k) + ratio_before = acc_before.get("last_calibration_ratio", 1.0) + k.reset_and_seed(100_000.0) + acc_after = _account(k) + self.assertAlmostEqual(acc_after.get("last_calibration_ratio", 1.0), + ratio_before, places=6, + msg="calibration_ratio must survive reset_and_seed") + + def test_15_calibration_samples_preserved(self): + k = _make_kernel() + k.set_seed_capital(100_000.0) + k.set_exchange_config({"taker_rate": 0.0005, "maker_rate": 0.0002}) + k.calibrate_fee(60_000.0, 1.0, 30.0, is_maker=False) + acc_before = _account(k) + samples_before = acc_before.get("fee_config", {}).get("calibration_samples", 0) + k.reset_and_seed(100_000.0) + acc_after = _account(k) + samples_after = acc_after.get("fee_config", {}).get("calibration_samples", 0) + self.assertEqual(samples_after, samples_before, + "calibration_samples must survive reset_and_seed") + + def test_16_open_slot_unchanged_by_reset(self): + """reset_and_seed touches only AccountState — slot FSM must be untouched.""" + from prod.clean_arch.dita_v2.rust_backend import ExecutionKernel + k = _make_kernel() + k.set_seed_capital(100_000.0) + slot_before = _get_rust().get_slot_json(k._backend, 0) + k.reset_and_seed(100_000.0) + slot_after = _get_rust().get_slot_json(k._backend, 0) + self.assertEqual(slot_before.get("fsm_state"), slot_after.get("fsm_state")) + self.assertEqual(slot_before.get("trade_id"), slot_after.get("trade_id")) + + +# ══════════════════════════════════════════════════════════════════════════════ +# 17-20 Post-reset accumulation is correct +# ══════════════════════════════════════════════════════════════════════════════ + +class TestResetAndSeedPostAccumulation(unittest.TestCase): + + def test_17_fill_settled_after_reset_accumulates(self): + k = _make_kernel() + _accumulate(k) + k.reset_and_seed(100_000.0) + _account_update(k, 100_000.0) + _fill_settled(k, pnl=200.0, fee=10.0, is_maker=False, event_id="new-ev-001") + acc = _account(k) + # k_realized_pnl should reflect only the new fill + self.assertGreater(acc["k_realized_pnl"], 0.0, + "new fill should increase k_realized_pnl") + # taker fee should be > 0 + self.assertGreater(acc["k_taker_fees"], 0.0) + + def test_18_predicted_fill_after_reset_accumulates(self): + k = _make_kernel() + k.reset_and_seed(100_000.0) + k.on_account_event({ + "kind": "PREDICTED_FILL", + "fill_price": 60_000.0, + "fill_qty": 1.0, + "realized_pnl": 150.0, + "is_maker": False, + }) + acc = _account(k) + self.assertGreater(acc["k_realized_pnl"], 0.0) + + def test_19_account_update_after_reset_updates_e_and_reconciles(self): + k = _make_kernel() + k.reset_and_seed(100_000.0) + # Push E slightly above K — should stay OK/WARN (not ERROR) + _account_update(k, 100_010.0) + acc = _account(k) + self.assertAlmostEqual(acc["e_wallet_balance"], 100_010.0, places=6) + # delta = |100_000 - 100_010| = 10 → WARN, not ERROR → not frozen + self.assertFalse(acc["capital_frozen"], + "10 USDT delta is WARN, not ERROR — should not freeze") + + def test_20_funding_fee_after_reset_accumulated(self): + k = _make_kernel() + k.reset_and_seed(100_000.0) + _account_update(k, 100_000.0) + k.on_account_event({"kind": "FUNDING_FEE", "funding_amount": -50.0}) + acc = _account(k) + # k_funding_net += 50 (paid) → k_capital = seed - 50 = 99_950 + self.assertAlmostEqual(acc["k_funding_net"], 50.0, places=6) + self.assertAlmostEqual(acc["k_capital"], 99_950.0, places=6) + + +# ══════════════════════════════════════════════════════════════════════════════ +# 21-22 WS-replay dedup (the original bug) +# ══════════════════════════════════════════════════════════════════════════════ + +class TestResetAndSeedReplayDedup(unittest.TestCase): + + def test_21_replayed_fill_ignored_after_reset(self): + """ + Scenario: PINK made fills in session N. Snapshot carries seen_account_event_ids. + Session N+1: reset_and_seed called. BingX WS replays old fill. + Expected: replay is deduplicated → k_realized_pnl stays 0. + """ + k = _make_kernel() + k.set_seed_capital(100_000.0) + _account_update(k, 100_000.0) + # Session N: process a fill + _fill_settled(k, pnl=300.0, fee=15.0, event_id="session-n-fill-1") + # Session N+1 startup: reset_and_seed + k.reset_and_seed(100_300.0) # BingX balance reflects the prior trade + acc_after_reset = _account(k) + self.assertAlmostEqual(acc_after_reset["k_realized_pnl"], 0.0, places=9, + msg="accumulators must be zero right after reset") + # WS replays the old fill — must be deduplicated + _fill_settled(k, pnl=300.0, fee=15.0, event_id="session-n-fill-1") + acc_after_replay = _account(k) + self.assertAlmostEqual(acc_after_replay["k_realized_pnl"], 0.0, places=9, + msg="replayed fill must not add to k_realized_pnl") + self.assertAlmostEqual(acc_after_replay["reconcile_delta"], 0.0, places=6, + msg="replay must not re-freeze capital") + + def test_22_fresh_fill_processed_after_reset(self): + """New event_id after reset must be processed normally.""" + k = _make_kernel() + k.set_seed_capital(100_000.0) + _account_update(k, 100_000.0) + _fill_settled(k, pnl=300.0, fee=15.0, event_id="old-fill") + k.reset_and_seed(100_300.0) + _account_update(k, 100_300.0) + # New fill in session N+1 + r = _fill_settled(k, pnl=50.0, fee=2.5, event_id="new-fill-session-n+1") + self.assertFalse(r.get("duplicate_event", False), + "new event_id must not be flagged as duplicate") + acc = _account(k) + self.assertGreater(acc["k_realized_pnl"], 0.0, + "new fill must accumulate k_realized_pnl") + + +# ══════════════════════════════════════════════════════════════════════════════ +# 23-24 Race / ordering +# ══════════════════════════════════════════════════════════════════════════════ + +class TestResetAndSeedOrdering(unittest.TestCase): + + def test_23_reset_called_before_ws_stream_starts_in_connect(self): + """ + Structural: inspect pink_direct.py connect() source to verify + reset_and_seed is called before asyncio.create_task(_run_account_stream). + This guarantees no WS events arrive before reset completes. + """ + src_path = (Path(__file__).parent.parent / + "runtime" / "pink_direct.py") + source = src_path.read_text() + connect_body = re.search( + r"async def connect\(.*?\n async def ", source, re.DOTALL + ) + self.assertIsNotNone(connect_body, "could not locate connect() body") + body = connect_body.group(0) + pos_reset = body.find("reset_and_seed") + pos_stream = body.find("create_task") + self.assertGreater(pos_reset, 0, "reset_and_seed must be in connect()") + self.assertGreater(pos_stream, 0, "create_task must be in connect()") + self.assertLess(pos_reset, pos_stream, + "reset_and_seed must appear BEFORE create_task in connect()") + + def test_24_reset_fill_account_update_cycle_stays_clean(self): + """ + Simulate: reset → new fill arrives → ACCOUNT_UPDATE from WS (E now includes fill). + Delta should stay < 20 USDT (no freeze). + """ + k = _make_kernel() + k.reset_and_seed(100_000.0) + # A trade fills at market: realized PnL +80, taker fee -30 + _fill_settled(k, pnl=80.0, fee=30.0, is_maker=False, event_id="trade-ev-1") + # BingX settles: wallet_balance = 100_000 + 80 - 30 = 100_050 + _account_update(k, 100_050.0) + acc = _account(k) + # K = seed(100k) + 80 - 30 = 100_050; E = 100_050 → delta = 0 + self.assertAlmostEqual(acc["reconcile_delta"], 0.0, places=4) + self.assertEqual(acc["reconcile_status"], "OK") + self.assertFalse(acc["capital_frozen"]) + + +# ══════════════════════════════════════════════════════════════════════════════ +# 25-26 Python-layer smoke tests +# ══════════════════════════════════════════════════════════════════════════════ + +class TestResetAndSeedPythonLayer(unittest.TestCase): + + def test_25_execution_kernel_reset_and_seed_smoke(self): + """ExecutionKernel.reset_and_seed delegates correctly; K=E after call.""" + k = _make_kernel() + _accumulate(k) + k.reset_and_seed(88_888.0) + acc = _account(k) + self.assertAlmostEqual(acc["k_capital"], 88_888.0, places=4) + self.assertEqual(acc["reconcile_status"], "OK") + + def test_26_set_seed_capital_still_works(self): + """Ensure set_seed_capital was not accidentally broken by our addition.""" + k = _make_kernel() + k.set_seed_capital(55_555.0) + acc = _account(k) + self.assertAlmostEqual(acc["seed_capital"], 55_555.0, places=4) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/prod/clean_arch/dita_v2/utils.py b/prod/clean_arch/dita_v2/utils.py new file mode 100644 index 0000000..80ecb14 --- /dev/null +++ b/prod/clean_arch/dita_v2/utils.py @@ -0,0 +1,43 @@ +"""Utility helpers for the DITAv2 kernel.""" + +from __future__ import annotations + +from dataclasses import asdict, is_dataclass +from datetime import datetime +from enum import Enum +from typing import Any +import json +import math + + +def safe_float(value: Any, default: float = 0.0) -> float: + """Return a finite float or ``default``.""" + try: + out = float(value) + except Exception: + return default + if not math.isfinite(out): + return default + return out + + +def json_safe(value: Any) -> Any: + """Convert enums, dataclasses and datetimes to JSON-safe objects.""" + if isinstance(value, Enum): + return value.value + if isinstance(value, datetime): + return value.isoformat() + if is_dataclass(value): + return json_safe(asdict(value)) + if isinstance(value, dict): + return {str(key): json_safe(val) for key, val in value.items()} + if isinstance(value, list): + return [json_safe(item) for item in value] + if isinstance(value, tuple): + return [json_safe(item) for item in value] + return value + + +def json_text(value: Any) -> str: + """Serialize a value using stable JSON settings.""" + return json.dumps(json_safe(value), separators=(",", ":"), ensure_ascii=False, default=str) diff --git a/prod/clean_arch/dita_v2/zinc_plane.py b/prod/clean_arch/dita_v2/zinc_plane.py new file mode 100644 index 0000000..0d6f11a --- /dev/null +++ b/prod/clean_arch/dita_v2/zinc_plane.py @@ -0,0 +1,135 @@ +"""Python prototype of the Zinc hot-path plane. + +This is an in-memory stand-in for the eventual Zinc-backed shared memory +regions. The interface is explicit so the implementation can be swapped later +without touching the kernel logic. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, Iterable, List, Mapping, Optional, Protocol +import threading +import time + +from .contracts import KernelIntent, TradeSlot +from .control import KernelControlSnapshot + + +class ZincPlane(Protocol): + """Hot-path plane for intents, state and control.""" + + def publish_intent(self, intent: KernelIntent) -> None: + ... + + def write_slot(self, slot: TradeSlot) -> None: + ... + + def read_slots(self) -> List[TradeSlot]: + ... + + def update_control(self, control: KernelControlSnapshot) -> None: + ... + + def read_control(self) -> KernelControlSnapshot: + ... + + def wait_on_intent(self, timeout_ms: int = 1000) -> bool: + ... + + def notify_intent(self) -> None: + ... + + def wait_on_state(self, timeout_ms: int = 1000) -> bool: + ... + + def notify_state(self) -> None: + ... + + def wait_on_control(self, timeout_ms: int = 1000) -> bool: + ... + + def notify_control(self) -> None: + ... + + +@dataclass +class InMemoryZincPlane: + """Simple in-memory Zinc lookalike for Python prototype tests.""" + + intent_region: List[KernelIntent] = field(default_factory=list) + state_region: Dict[int, TradeSlot] = field(default_factory=dict) + control_region: Optional[KernelControlSnapshot] = None + _intent_seq: int = field(default=0, init=False, repr=False) + _state_seq: int = field(default=0, init=False, repr=False) + _control_seq: int = field(default=0, init=False, repr=False) + _intent_observed_seq: int = field(default=0, init=False, repr=False) + _state_observed_seq: int = field(default=0, init=False, repr=False) + _control_observed_seq: int = field(default=0, init=False, repr=False) + _signal: threading.Condition = field(default_factory=threading.Condition, init=False, repr=False) + + def publish_intent(self, intent: KernelIntent) -> None: + with self._signal: + self.intent_region.append(intent) + self._intent_seq += 1 + self._signal.notify_all() + + def write_slot(self, slot: TradeSlot) -> None: + with self._signal: + self.state_region[int(slot.slot_id)] = slot + self._state_seq += 1 + self._signal.notify_all() + + def read_slots(self) -> List[TradeSlot]: + return [self.state_region[key] for key in sorted(self.state_region)] + + def update_control(self, control: KernelControlSnapshot) -> None: + with self._signal: + self.control_region = control + self._control_seq += 1 + self._signal.notify_all() + + def read_control(self) -> KernelControlSnapshot: + if self.control_region is None: + return KernelControlSnapshot() + return self.control_region + + def wait_on_intent(self, timeout_ms: int = 1000) -> bool: + return self._wait_for_change("_intent_seq", "_intent_observed_seq", timeout_ms) + + def notify_intent(self) -> None: + with self._signal: + self._intent_seq += 1 + self._signal.notify_all() + + def wait_on_state(self, timeout_ms: int = 1000) -> bool: + return self._wait_for_change("_state_seq", "_state_observed_seq", timeout_ms) + + def notify_state(self) -> None: + with self._signal: + self._state_seq += 1 + self._signal.notify_all() + + def wait_on_control(self, timeout_ms: int = 1000) -> bool: + return self._wait_for_change("_control_seq", "_control_observed_seq", timeout_ms) + + def notify_control(self) -> None: + with self._signal: + self._control_seq += 1 + self._signal.notify_all() + + def _wait_for_change(self, seq_attr: str, observed_attr: str, timeout_ms: int) -> bool: + timeout_s = None if timeout_ms is None or timeout_ms < 0 else max(0.0, timeout_ms / 1000.0) + deadline = None if timeout_s is None else time.monotonic() + timeout_s + with self._signal: + observed = getattr(self, observed_attr) + while getattr(self, seq_attr) == observed: + if deadline is None: + self._signal.wait() + continue + remaining = deadline - time.monotonic() + if remaining <= 0: + return False + self._signal.wait(timeout=remaining) + setattr(self, observed_attr, getattr(self, seq_attr)) + return True diff --git a/prod/clean_arch/persistence/__init__.py b/prod/clean_arch/persistence/__init__.py new file mode 100644 index 0000000..3cb4610 --- /dev/null +++ b/prod/clean_arch/persistence/__init__.py @@ -0,0 +1,5 @@ +"""Persistence helpers for clean-arch PINK/BLUE-compatible runtimes.""" + +from .pink_clickhouse import PinkClickHousePersistence + +__all__ = ["PinkClickHousePersistence"] diff --git a/prod/clean_arch/policy/__init__.py b/prod/clean_arch/policy/__init__.py new file mode 100644 index 0000000..587baab --- /dev/null +++ b/prod/clean_arch/policy/__init__.py @@ -0,0 +1,35 @@ +"""Legacy policy namespace. + +The canonical DITA boundary now lives in `prod.clean_arch.dita`. +This package remains as a compatibility layer. +""" + +from .contracts import ( + AccountEvent, + PolicyAction, + PolicyConfig, + PolicyContext, + PolicyIntent, + PolicyIntentContext, + PolicyPosition, + PolicySide, + PolicyStage, + PolicyTradeEvent, + Decision, +) +from .engine import PolicyEngine + +__all__ = [ + "AccountEvent", + "Decision", + "PolicyAction", + "PolicyConfig", + "PolicyContext", + "PolicyEngine", + "PolicyIntent", + "PolicyIntentContext", + "PolicyPosition", + "PolicySide", + "PolicyStage", + "PolicyTradeEvent", +] diff --git a/prod/clean_arch/policy/contracts.py b/prod/clean_arch/policy/contracts.py new file mode 100644 index 0000000..7a12da9 --- /dev/null +++ b/prod/clean_arch/policy/contracts.py @@ -0,0 +1,35 @@ +"""Compatibility wrapper for the legacy policy namespace. + +The canonical contracts now live under `prod.clean_arch.dita`. +This module preserves the older import path for existing tests and callers. +""" + +from __future__ import annotations + +from prod.clean_arch.dita.contracts import ( + AccountEvent, + Decision, + DecisionAction as PolicyAction, + DecisionConfig as PolicyConfig, + DecisionContext as PolicyContext, + Intent as PolicyIntent, + IntentContext as PolicyIntentContext, + TradeEvent as PolicyTradeEvent, + TradePosition as PolicyPosition, + TradeSide as PolicySide, + TradeStage as PolicyStage, +) + +__all__ = [ + "AccountEvent", + "PolicyAction", + "PolicyConfig", + "PolicyContext", + "PolicyIntent", + "PolicyIntentContext", + "PolicyPosition", + "PolicySide", + "PolicyStage", + "PolicyTradeEvent", + "Decision", +] diff --git a/prod/clean_arch/policy/engine.py b/prod/clean_arch/policy/engine.py new file mode 100644 index 0000000..1ccb58c --- /dev/null +++ b/prod/clean_arch/policy/engine.py @@ -0,0 +1,44 @@ +"""Compatibility wrapper for the legacy policy engine namespace.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +from prod.clean_arch.dita.account import AccountProjection, AccountSnapshot +from prod.clean_arch.dita.contracts import DecisionConfig as PolicyConfig +from prod.clean_arch.dita.decision import DecisionEngine +from prod.clean_arch.dita.intent import IntentEngine +from prod.clean_arch.dita.trade import TradeExecutor + +from .contracts import PolicyContext, PolicyPosition + + +@dataclass(frozen=True) +class PolicyContextWrapper: + """Backward-compatible context alias for older callers.""" + + capital: float + open_positions: int = 0 + trade_seq: int = 0 + + +class PolicyEngine: + """Legacy facade that now delegates to the DITA boundary.""" + + def __init__(self, config: Optional[PolicyConfig] = None): + self.config = config or PolicyConfig() + self.decision_engine = DecisionEngine(self.config) + self.intent_engine = IntentEngine(self.config) + self.trade_executor = TradeExecutor() + self.account = AccountProjection( + runtime_namespace="pink", + strategy_namespace="pink", + event_namespace="pink", + ) + self.account.snapshot = AccountSnapshot(capital=25_000.0, equity=25_000.0) + + def decide(self, snapshot, context: PolicyContext, position: Optional[PolicyPosition] = None): + decision = self.decision_engine.decide(snapshot, context, position) + return decision + diff --git a/prod/clean_arch/ports/__init__.py b/prod/clean_arch/ports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/prod/clean_arch/ports/data_feed.py b/prod/clean_arch/ports/data_feed.py new file mode 100644 index 0000000..0ed680a --- /dev/null +++ b/prod/clean_arch/ports/data_feed.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +PORT: DataFeed +============== +Abstract interface for market data sources. + +Clean Architecture Principle: +- Core business logic depends on this PORT (interface) +- Adapters implement this port +- Easy to swap: Hazelcast → Binance → In-Kernel Rust + +Future Evolution: +- Current: HazelcastAdapter (DolphinNG6 feed) +- Next: BinanceWebsocketAdapter (direct) +- Future: RustKernelAdapter (in-kernel, zero-copy) +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Dict, List, Optional, Callable, Any +from datetime import datetime + + +@dataclass(frozen=True) +class MarketSnapshot: + """ + Immutable market snapshot - single source of truth. + + Contains BOTH price and computed features (eigenvalues, etc.) + Guaranteed to be synchronized - same timestamp for all fields. + """ + timestamp: datetime + symbol: str + + # Price data + price: float + bid: Optional[float] = None + ask: Optional[float] = None + + # Computed features (from DolphinNG6) + eigenvalues: Optional[List[float]] = None + eigenvectors: Optional[Any] = None # Matrix + velocity_divergence: Optional[float] = None + irp_alignment: Optional[float] = None + + # Metadata + scan_number: Optional[int] = None + source: str = "unknown" # "hazelcast", "binance", "kernel" + scan_payload: Optional[Dict[str, Any]] = None + + def is_valid(self) -> bool: + """Check if snapshot has required fields.""" + return self.price > 0 and self.eigenvalues is not None + + +@dataclass +class ACBUpdate: + """Adaptive Circuit Breaker update.""" + timestamp: datetime + boost: float + beta: float + cut: float + posture: str + + +class DataFeedPort(ABC): + """ + PORT: Abstract data feed interface. + + Implementations: + - HazelcastDataFeed: Current (DolphinNG6 integration) + - BinanceDataFeed: Direct WebSocket + - RustKernelDataFeed: Future in-kernel implementation + """ + + @abstractmethod + async def connect(self) -> bool: + """Connect to data source.""" + pass + + @abstractmethod + async def disconnect(self): + """Clean disconnect.""" + pass + + @abstractmethod + async def get_latest_snapshot(self, symbol: str) -> Optional[MarketSnapshot]: + """ + Get latest synchronized snapshot (price + features). + + This is the KEY method - returns ATOMIC data. + No sync issues possible. + """ + pass + + @abstractmethod + async def subscribe_snapshots(self, callback: Callable[[MarketSnapshot], None]): + """ + Subscribe to snapshot stream. + + callback receives MarketSnapshot whenever new data arrives. + """ + pass + + @abstractmethod + async def get_acb_update(self) -> Optional[ACBUpdate]: + """Get latest ACB (Adaptive Circuit Breaker) update.""" + pass + + @abstractmethod + def get_latency_ms(self) -> float: + """Report current data latency (for monitoring).""" + pass + + @abstractmethod + def health_check(self) -> bool: + """Check if feed is healthy.""" + pass diff --git a/prod/clean_arch/ports/execution.py b/prod/clean_arch/ports/execution.py new file mode 100644 index 0000000..26d3fde --- /dev/null +++ b/prod/clean_arch/ports/execution.py @@ -0,0 +1,74 @@ +"""Execution port for DITA-driven live trading. + +This port is intentionally free of Nautilus Trader node concepts. +It provides a direct exchange boundary for PINK/BLUE-compatible DITA runtimes. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, Optional + + +@dataclass(frozen=True) +class ExchangeStateSnapshot: + """Exchange-led account/position/order snapshot.""" + + timestamp: datetime + capital: float + equity: float + open_positions: Dict[str, Dict[str, Any]] = field(default_factory=dict) + open_orders: list[Dict[str, Any]] = field(default_factory=list) + all_orders: list[Dict[str, Any]] = field(default_factory=list) + all_fills: list[Dict[str, Any]] = field(default_factory=list) + account: Dict[str, Any] = field(default_factory=dict) + open_notional: float = 0.0 + source: str = "exchange" + recovered: bool = False + + +@dataclass(frozen=True) +class ExecutionReceipt: + """Canonical receipt returned by a direct execution adapter.""" + + timestamp: datetime + status: str + symbol: str + side: str + action: str + quantity: float + price: float + client_order_id: str + order_id: str = "" + realized_pnl: float = 0.0 + fees: float = 0.0 + raw_ack: Dict[str, Any] = field(default_factory=dict) + raw_state: Dict[str, Any] = field(default_factory=dict) + + +class ExecutionPort(ABC): + """Direct exchange execution boundary.""" + + @abstractmethod + async def connect(self) -> bool: + raise NotImplementedError + + @abstractmethod + async def disconnect(self) -> None: + raise NotImplementedError + + @abstractmethod + async def refresh_state(self, symbol: str | None = None, *, include_history: bool = False) -> ExchangeStateSnapshot: + raise NotImplementedError + + @abstractmethod + async def submit_intent(self, intent: Any) -> ExecutionReceipt: + raise NotImplementedError + + @abstractmethod + async def reconcile(self, symbol: str | None = None) -> ExchangeStateSnapshot: + """Recovery-only path for catastrophic restart/hibernate scenarios.""" + raise NotImplementedError + diff --git a/prod/clean_arch/runtime/__init__.py b/prod/clean_arch/runtime/__init__.py new file mode 100644 index 0000000..94bf1c5 --- /dev/null +++ b/prod/clean_arch/runtime/__init__.py @@ -0,0 +1,2 @@ +"""Runtime entrypoints for clean-arch DITA live loops.""" + diff --git a/prod/clean_arch/runtime/runner_heartbeat.py b/prod/clean_arch/runtime/runner_heartbeat.py new file mode 100644 index 0000000..1221883 --- /dev/null +++ b/prod/clean_arch/runtime/runner_heartbeat.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +""" +Shared runner heartbeat helpers. + +This heartbeat is emitted by the long-running BLUE/PINK runner processes. +It is intentionally separate from any Nautilus node liveness signal. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any, Mapping + +RUNNER_HEARTBEAT_KEY = "runner_heartbeat" +LEGACY_HEARTBEAT_KEY = "nautilus_flow_heartbeat" + + +def build_runner_heartbeat_payload( + *, + flow: str, + phase: str, + run_date: str | None = None, + runner: str | None = None, + extra: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "ts": datetime.now(timezone.utc).timestamp(), + "iso": datetime.now(timezone.utc).isoformat(), + "phase": str(phase), + "flow": str(flow), + } + if run_date: + payload["run_date"] = str(run_date) + if runner: + payload["runner"] = str(runner) + if extra: + for key, value in extra.items(): + if isinstance(key, str) and key: + payload[key] = value + return payload + + +def write_runner_heartbeat( + heartbeat_map: Any, + payload: Mapping[str, Any], + *, + include_legacy_alias: bool = True, +) -> None: + hb = json.dumps(dict(payload), sort_keys=True) + heartbeat_map.blocking().put(RUNNER_HEARTBEAT_KEY, hb) + if include_legacy_alias: + heartbeat_map.blocking().put(LEGACY_HEARTBEAT_KEY, hb) diff --git a/prod/clean_arch/sim/__init__.py b/prod/clean_arch/sim/__init__.py new file mode 100644 index 0000000..0a7071d --- /dev/null +++ b/prod/clean_arch/sim/__init__.py @@ -0,0 +1,32 @@ +"""Mock stack and simulator for policy-level testing.""" + +from .fuzzer import FuzzConfig, FuzzReport, fuzz_stack, generate_snapshot_stream +from .mock_stack import ( + ChaosProfile, + MockClickHouse, + MockExchange, + MockHazelcast, + MockLogSink, + MockNetwork, + MockTradingStack, + SimulationResult, + AnomalySensorState, + NetworkProfile, +) + +__all__ = [ + "FuzzConfig", + "FuzzReport", + "ChaosProfile", + "MockClickHouse", + "MockExchange", + "MockHazelcast", + "MockLogSink", + "MockNetwork", + "MockTradingStack", + "SimulationResult", + "AnomalySensorState", + "NetworkProfile", + "fuzz_stack", + "generate_snapshot_stream", +] diff --git a/prod/clean_arch/sim/fuzzer.py b/prod/clean_arch/sim/fuzzer.py new file mode 100644 index 0000000..5bff0bd --- /dev/null +++ b/prod/clean_arch/sim/fuzzer.py @@ -0,0 +1,202 @@ +"""Fuzz generation for the mock stack.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +import math +import random +from typing import Iterable, Iterator, List, Optional + +from prod.clean_arch.ports.data_feed import MarketSnapshot +from prod.clean_arch.dita import DecisionConfig, DecisionEngine, IntentEngine, DitaObservabilityNamespace + +from .mock_stack import ChaosProfile, MockClickHouse, MockHazelcast, MockLogSink, MockNetwork, MockTradingStack, NetworkProfile + + +DEFAULT_SYMBOLS = ( + "BTCUSDT", + "ETHUSDT", + "BNBUSDT", + "SOLUSDT", + "XRPUSDT", + "ADAUSDT", + "DOGEUSDT", + "TRXUSDT", +) + + +@dataclass(frozen=True) +class FuzzConfig: + """High-volume fuzz config.""" + + transactions: int = 1_000_000 + seed: int = 42 + bad_input_rate: float = 0.01 + network_drop_rate: float = 0.001 + network_duplicate_rate: float = 0.001 + price_sigma: float = 0.015 + symbol_pool: tuple[str, ...] = DEFAULT_SYMBOLS + capture_limit: int = 2_000 + aggressive: bool = False + hang_entry_rate: float = 0.0 + hang_exit_rate: float = 0.0 + stale_account_rate: float = 0.0 + duplicate_terminal_rate: float = 0.0 + missing_terminal_rate: float = 0.0 + orphan_close_rate: float = 0.0 + reorder_account_rate: float = 0.0 + runtime_namespace: str = "pink" + anomaly_sensor_key: str | None = None + mirror_legacy_sensor_key: bool = False + + +@dataclass(frozen=True) +class FuzzReport: + """Summary of a fuzz run.""" + + transactions: int + capital_final: float + equity_final: float + open_notional_final: float + policy_events: int + trade_events: int + account_events: int + logs_emitted: int + network_dropped: int + network_duplicated: int + anomaly_counts: dict + anomaly_origin_counts: dict + injected_anomaly_counts: dict + emergent_anomaly_counts: dict + anomaly_sensor_payload: dict + anomaly_samples: List[dict] + sample_policy_events: List[dict] + + +def generate_snapshot_stream(cfg: FuzzConfig) -> Iterator[MarketSnapshot]: + """Stream randomized snapshots with occasional poison inputs.""" + rng = random.Random(cfg.seed) + price_map = {sym: 100.0 + 25.0 * i for i, sym in enumerate(cfg.symbol_pool)} + ts = datetime.now(timezone.utc) + + for i in range(cfg.transactions): + symbol = rng.choice(cfg.symbol_pool) + base = price_map[symbol] + drift = rng.gauss(0.0, cfg.price_sigma) + price = max(0.01, base * (1.0 + drift)) + price_map[symbol] = price + + vdiv = rng.uniform(-0.2, 0.1) + irp = rng.uniform(-1.0, 1.0) + eigenvalues = [1.0, 0.9, 0.8] + + if rng.random() < cfg.bad_input_rate: + poison = rng.choice(["nan", "inf", "-inf", "zero", "none", "unicode"]) + if poison == "nan": + price = float("nan") + elif poison == "inf": + price = float("inf") + elif poison == "-inf": + price = float("-inf") + elif poison == "zero": + price = 0.0 + elif poison == "none": + vdiv = None # type: ignore[assignment] + elif poison == "unicode": + symbol = symbol + "🐬" + + yield MarketSnapshot( + timestamp=ts + timedelta(milliseconds=i), + symbol=symbol, + price=price, + bid=price * 0.9995 if math.isfinite(price) else price, + ask=price * 1.0005 if math.isfinite(price) else price, + eigenvalues=eigenvalues, + eigenvectors=None, + velocity_divergence=vdiv, + irp_alignment=irp, + scan_number=i, + source="fuzz", + ) + + +def fuzz_stack(cfg: Optional[FuzzConfig] = None) -> FuzzReport: + """Run the full mocked stack against fuzzed market snapshots.""" + cfg = cfg or FuzzConfig() + if cfg.aggressive: + cfg = FuzzConfig( + transactions=cfg.transactions, + seed=cfg.seed, + bad_input_rate=max(cfg.bad_input_rate, 0.08), + network_drop_rate=max(cfg.network_drop_rate, 0.03), + network_duplicate_rate=max(cfg.network_duplicate_rate, 0.03), + price_sigma=max(cfg.price_sigma, 0.05), + symbol_pool=cfg.symbol_pool, + capture_limit=cfg.capture_limit, + aggressive=cfg.aggressive, + hang_entry_rate=max(cfg.hang_entry_rate, 0.01), + hang_exit_rate=max(cfg.hang_exit_rate, 0.03), + stale_account_rate=max(cfg.stale_account_rate, 0.03), + duplicate_terminal_rate=max(cfg.duplicate_terminal_rate, 0.03), + missing_terminal_rate=max(cfg.missing_terminal_rate, 0.015), + orphan_close_rate=max(cfg.orphan_close_rate, 0.015), + reorder_account_rate=max(cfg.reorder_account_rate, 0.02), + ) + policy = DecisionEngine(DecisionConfig()) + intent = IntentEngine(DecisionConfig()) + stack = MockTradingStack( + decision_engine=policy, + intent_engine=intent, + capital=25_000.0, + runtime_namespace=cfg.runtime_namespace, + strategy_namespace=cfg.runtime_namespace, + event_namespace=cfg.runtime_namespace, + observability=DitaObservabilityNamespace( + runtime_namespace=cfg.runtime_namespace, + anomaly_sensor_key=cfg.anomaly_sensor_key, + mirror_legacy_key=cfg.mirror_legacy_sensor_key, + state_map=f"DOLPHIN_STATE_{cfg.runtime_namespace.upper()}" if cfg.runtime_namespace.lower() != "pink" else "DOLPHIN_STATE_PINK", + ), + network=MockNetwork( + NetworkProfile( + drop_rate=cfg.network_drop_rate, + duplicate_rate=cfg.network_duplicate_rate, + ), + seed=cfg.seed, + ), + hazelcast=MockHazelcast(), + clickhouse=MockClickHouse(capture_limit=cfg.capture_limit), + logs=MockLogSink(capture_limit=cfg.capture_limit), + chaos=ChaosProfile( + hang_entry_rate=cfg.hang_entry_rate, + hang_exit_rate=cfg.hang_exit_rate, + stale_account_rate=cfg.stale_account_rate, + duplicate_terminal_rate=cfg.duplicate_terminal_rate, + missing_terminal_rate=cfg.missing_terminal_rate, + orphan_close_rate=cfg.orphan_close_rate, + reorder_account_rate=cfg.reorder_account_rate, + ), + ) + for snapshot in generate_snapshot_stream(cfg): + stack.step(snapshot) + result = stack.summary(cfg.transactions) + return FuzzReport( + transactions=result.steps, + capital_final=result.capital_final, + equity_final=result.equity_final, + open_notional_final=result.open_notional_final, + policy_events=result.decision_events, + trade_events=result.trade_events, + account_events=result.account_events, + logs_emitted=result.logs_emitted, + network_dropped=result.network_dropped, + network_duplicated=result.network_duplicated, + anomaly_counts=result.anomaly_counts, + anomaly_origin_counts=result.anomaly_origin_counts, + injected_anomaly_counts=result.injected_anomaly_counts, + emergent_anomaly_counts=result.emergent_anomaly_counts, + anomaly_sensor_payload=result.anomaly_sensor_payload, + anomaly_samples=result.anomaly_samples, + sample_policy_events=result.sample_policy_events, + ) diff --git a/prod/clean_arch/sim/mock_stack.py b/prod/clean_arch/sim/mock_stack.py new file mode 100644 index 0000000..7036033 --- /dev/null +++ b/prod/clean_arch/sim/mock_stack.py @@ -0,0 +1,1127 @@ +"""Fully mocked DITA stack for isolated policy / trade / account testing. + +The goal is to approximate the live PINK/BLUE boundaries without touching any +real exchange, Hazelcast cluster, ClickHouse instance, or logs. +""" + +from __future__ import annotations + +from collections import defaultdict +from copy import deepcopy +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Any, Dict, Iterable, List, Optional +import math +import random + +from prod.clean_arch.dita import ( + AccountProjection, + AccountSnapshot, + DecisionAction, + DecisionConfig, + DecisionContext, + DecisionEngine, + Intent, + IntentContext, + IntentEngine, + TradeExecutor, + TradePosition, + TradeSide, + TradeStage, + DitaObservabilityNamespace, + LEGACY_ANOMALY_SENSOR_KEY, +) +from prod.clean_arch.ports.data_feed import MarketSnapshot + + +@dataclass(frozen=True) +class NetworkProfile: + """Fault model for the mock transport boundary.""" + + drop_rate: float = 0.0 + duplicate_rate: float = 0.0 + jitter_ms: float = 0.0 + + +@dataclass(frozen=True) +class ChaosProfile: + """Deliberate anomaly model for aggressive failure simulation.""" + + hang_entry_rate: float = 0.0 + hang_exit_rate: float = 0.0 + stale_account_rate: float = 0.0 + duplicate_terminal_rate: float = 0.0 + missing_terminal_rate: float = 0.0 + orphan_close_rate: float = 0.0 + reorder_account_rate: float = 0.0 + + +INJECTED_ANOMALIES = { + "hung_entry", + "hung_exit", + "network_drop", + "missing_terminal_row", + "duplicate_terminal_row", + "orphaned_close", + "account_write_reordered", + "invalid_market_snapshot", + "compound_fault", +} + +EMERGENT_ANOMALIES = { + "stale_account_projection", + "closed_position_still_present", + "rejected_order", + "invalid_trade_state", + "invalid_account_state", + "exchange_executor_divergence", + "decision_intent_mismatch", + "duplicate_trade_id", + "unknown_invariant_violation", + "compound_fault", +} + +ANOMALY_SENSOR_MAP = { + "network_drop": "m7_transport_integrity", + "hung_entry": "m8_execution_integrity", + "hung_exit": "m8_execution_integrity", + "missing_terminal_row": "m9_terminal_integrity", + "duplicate_terminal_row": "m9_terminal_integrity", + "orphaned_close": "m10_exchange_state_integrity", + "closed_position_still_present": "m10_exchange_state_integrity", + "stale_account_projection": "m11_account_projection_integrity", + "account_write_reordered": "m11_account_projection_integrity", + "rejected_order": "m12_order_acceptance_integrity", + "invalid_market_snapshot": "m13_invariant_integrity", + "invalid_trade_state": "m13_invariant_integrity", + "invalid_account_state": "m13_invariant_integrity", + "exchange_executor_divergence": "m13_invariant_integrity", + "decision_intent_mismatch": "m13_invariant_integrity", + "duplicate_trade_id": "m13_invariant_integrity", + "unknown_invariant_violation": "m13_invariant_integrity", + "compound_fault": "m13_invariant_integrity", +} + +ANOMALY_SENSOR_SEVERITY = { + "network_drop": 0.20, + "hung_entry": 0.25, + "hung_exit": 0.25, + "missing_terminal_row": 0.30, + "duplicate_terminal_row": 0.15, + "orphaned_close": 0.30, + "closed_position_still_present": 0.20, + "stale_account_projection": 0.25, + "account_write_reordered": 0.20, + "rejected_order": 0.20, + "invalid_market_snapshot": 0.25, + "invalid_trade_state": 0.30, + "invalid_account_state": 0.30, + "exchange_executor_divergence": 0.35, + "decision_intent_mismatch": 0.25, + "duplicate_trade_id": 0.30, + "unknown_invariant_violation": 0.40, + "compound_fault": 0.35, +} + + +@dataclass +class AnomalySensorState: + """MHS-shaped anomaly sensor surface for the DITA simulator. + + These are mock diagnostics, not live-system sensors. They provide a + structured fault surface that can be written into HZ/CH-like mocks and + validated in tests. + """ + + m7_transport_integrity: float = 1.0 + m8_execution_integrity: float = 1.0 + m9_terminal_integrity: float = 1.0 + m10_exchange_state_integrity: float = 1.0 + m11_account_projection_integrity: float = 1.0 + m12_order_acceptance_integrity: float = 1.0 + m13_invariant_integrity: float = 1.0 + + def as_dict(self) -> Dict[str, float]: + return { + "m7_transport_integrity": self.m7_transport_integrity, + "m8_execution_integrity": self.m8_execution_integrity, + "m9_terminal_integrity": self.m9_terminal_integrity, + "m10_exchange_state_integrity": self.m10_exchange_state_integrity, + "m11_account_projection_integrity": self.m11_account_projection_integrity, + "m12_order_acceptance_integrity": self.m12_order_acceptance_integrity, + "m13_invariant_integrity": self.m13_invariant_integrity, + } + + def degrade(self, sensor_name: str, amount: float = 0.1) -> None: + if not hasattr(self, sensor_name): + return + current = float(getattr(self, sensor_name)) + setattr(self, sensor_name, max(0.0, min(1.0, current - max(0.0, amount)))) + + def aggregate(self) -> float: + values = list(self.as_dict().values()) + return sum(values) / max(1, len(values)) + + def to_payload( + self, + timestamp: Optional[str] = None, + anomaly_counts: Optional[Dict[str, int]] = None, + origin_counts: Optional[Dict[str, int]] = None, + ) -> Dict[str, Any]: + payload = { + "timestamp": timestamp, + "rm_meta": round(self.aggregate(), 3), + "status": "GREEN" if self.aggregate() >= 0.85 else "DEGRADED" if self.aggregate() >= 0.55 else "CRITICAL", + **{key: round(value, 3) for key, value in self.as_dict().items()}, + } + if anomaly_counts is not None: + payload["anomaly_counts"] = deepcopy(anomaly_counts) + if origin_counts is not None: + payload["origin_counts"] = deepcopy(origin_counts) + return payload + + +class MockNetwork: + """Simple stochastic transport layer.""" + + def __init__(self, profile: Optional[NetworkProfile] = None, seed: int = 7): + self.profile = profile or NetworkProfile() + self.rng = random.Random(seed) + self.delivered = 0 + self.dropped = 0 + self.duplicated = 0 + + def deliver(self, channel: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]: + """Return 0-2 copies of a payload, depending on the fault model.""" + roll = self.rng.random() + if roll < self.profile.drop_rate: + self.dropped += 1 + return [] + + out = [deepcopy(payload)] + self.delivered += 1 + + if self.rng.random() < self.profile.duplicate_rate: + self.duplicated += 1 + out.append(deepcopy(payload)) + return out + + +class MockHazelcast: + """Dict-backed Hazelcast replacement.""" + + def __init__(self): + self.maps: Dict[str, Dict[str, Any]] = defaultdict(dict) + self.history: Dict[str, List[Dict[str, Any]]] = defaultdict(list) + + def put(self, map_name: str, key: str, value: Any) -> None: + self.maps[map_name][key] = deepcopy(value) + self.history[map_name].append({key: deepcopy(value)}) + + def get(self, map_name: str, key: str, default: Any = None) -> Any: + return deepcopy(self.maps.get(map_name, {}).get(key, default)) + + +class MockClickHouse: + """Append-only table mock with bounded sample retention.""" + + def __init__(self, capture_limit: int = 10_000): + self.capture_limit = capture_limit + self.tables: Dict[str, List[Dict[str, Any]]] = defaultdict(list) + self.counts: Dict[str, int] = defaultdict(int) + self.last_row: Dict[str, Dict[str, Any]] = {} + + def insert(self, table: str, row: Dict[str, Any]) -> None: + self.counts[table] += 1 + payload = deepcopy(row) + self.last_row[table] = payload + if len(self.tables[table]) < self.capture_limit: + self.tables[table].append(payload) + + def count(self, table: str) -> int: + return self.counts.get(table, 0) + + +class MockLogSink: + """Bounded log sink used for fuzzing and trace sampling.""" + + def __init__(self, capture_limit: int = 5_000): + self.capture_limit = capture_limit + self.records: List[Dict[str, Any]] = [] + self.count = 0 + + def emit(self, level: str, message: str, **fields: Any) -> None: + self.count += 1 + if len(self.records) < self.capture_limit: + self.records.append({"level": level, "message": message, "fields": deepcopy(fields)}) + + +@dataclass +class ExchangeReceipt: + trade_id: str + status: str + fill_price: float + fill_size: float + realized_pnl: float = 0.0 + fees: float = 0.0 + reason: str = "" + partial: bool = False + remaining_size: float = 0.0 + + +class MockExchange: + """Single-slot exchange simulator with entry/exit semantics.""" + + def __init__(self, fee_bps: float = 2.0): + self.fee_bps = fee_bps + self.position: Optional[TradePosition] = None + self.order_count = 0 + self.fill_count = 0 + self.rejected_count = 0 + self.realized_pnl = 0.0 + self.fees_paid = 0.0 + self.trade_history: List[ExchangeReceipt] = [] + + def mark_price(self, price: float) -> None: + if self.position is not None: + self.position.mark_price(price) + + def reject(self, intent: Intent, reason: str) -> ExchangeReceipt: + self.rejected_count += 1 + receipt = ExchangeReceipt( + trade_id=intent.trade_id, + status="REJECTED", + fill_price=intent.reference_price, + fill_size=0.0, + reason=reason, + ) + self.trade_history.append(receipt) + return receipt + + def submit(self, intent: Intent) -> ExchangeReceipt: + self.order_count += 1 + if intent.action == DecisionAction.ENTER: + return self._enter(intent) + if intent.action == DecisionAction.EXIT: + return self._exit(intent) + return self.reject(intent, "NOOP") + + def _enter(self, intent: Intent) -> ExchangeReceipt: + if self.position is not None and not self.position.closed: + return self.reject(intent, "POSITION_ALREADY_OPEN") + + position = TradePosition( + trade_id=intent.trade_id, + asset=intent.asset, + side=intent.side, + entry_price=intent.reference_price, + entry_time=intent.timestamp, + size=intent.target_size, + leverage=intent.leverage, + entry_velocity_divergence=float(intent.metadata.get("entry_velocity_divergence", intent.metadata.get("velocity_divergence", 0.0))), + entry_irp_alignment=float(intent.metadata.get("entry_irp_alignment", intent.confidence)), + current_price=intent.reference_price, + initial_size=intent.target_size, + exit_leg_ratios=tuple(intent.exit_leg_ratios), + ) + self.position = position + self.fill_count += 1 + fee = abs(position.entry_price * position.size) * (self.fee_bps / 10_000.0) + self.fees_paid += fee + receipt = ExchangeReceipt( + trade_id=intent.trade_id, + status="FILLED", + fill_price=intent.reference_price, + fill_size=intent.target_size, + fees=fee, + reason=intent.reason, + ) + self.trade_history.append(receipt) + return receipt + + def _exit(self, intent: Intent) -> ExchangeReceipt: + if self.position is None or self.position.closed: + return self.reject(intent, "NO_OPEN_POSITION") + + pos = self.position + requested = intent.target_size if intent.target_size > 0 else pos.size + fill_size = min(requested, pos.size) + exit_price = intent.reference_price + pnl_pct = (exit_price - pos.entry_price) / pos.entry_price if pos.entry_price > 0 else 0.0 + if pos.side == TradeSide.SHORT: + pnl_pct = -pnl_pct + pnl = pnl_pct * fill_size * pos.entry_price * pos.leverage + fee = abs(exit_price * fill_size) * (self.fee_bps / 10_000.0) + pnl_after_fee = pnl - fee + + pos.size = max(0.0, pos.size - fill_size) + pos.exit_price = exit_price + pos.realized_pnl += pnl_after_fee + pos.closed = pos.size <= 1e-12 + pos.close_reason = intent.reason if pos.closed else f"PARTIAL_{intent.reason}" + pos.mark_price(exit_price) + + if pos.closed: + self.position = None + self.fill_count += 1 + self.realized_pnl += pnl_after_fee + self.fees_paid += fee + receipt = ExchangeReceipt( + trade_id=intent.trade_id, + status="FILLED", + fill_price=exit_price, + fill_size=fill_size, + realized_pnl=pnl_after_fee, + fees=fee, + reason=intent.reason, + partial=not pos.closed, + remaining_size=pos.size, + ) + self.trade_history.append(receipt) + return receipt + + def open_notional(self) -> float: + if self.position is None: + return 0.0 + return self.position.current_price * self.position.size + + def snapshot(self) -> Dict[str, Any]: + return { + "open_position": None if self.position is None else deepcopy(self.position), + "order_count": self.order_count, + "fill_count": self.fill_count, + "rejected_count": self.rejected_count, + "realized_pnl": self.realized_pnl, + "fees_paid": self.fees_paid, + } + + +@dataclass +class SimulationResult: + """Summary from a simulation run.""" + + steps: int + trades_opened: int + trades_closed: int + trades_rejected: int + capital_final: float + equity_final: float + open_notional_final: float + decision_events: int + trade_events: int + account_events: int + hazelcast_updates: int + logs_emitted: int + network_delivered: int + network_dropped: int + network_duplicated: int + anomaly_counts: Dict[str, int] = field(default_factory=dict) + anomaly_origin_counts: Dict[str, int] = field(default_factory=dict) + injected_anomaly_counts: Dict[str, int] = field(default_factory=dict) + emergent_anomaly_counts: Dict[str, int] = field(default_factory=dict) + anomaly_sensor_payload: Dict[str, Any] = field(default_factory=dict) + anomaly_samples: List[Dict[str, Any]] = field(default_factory=list) + sample_policy_events: List[Dict[str, Any]] = field(default_factory=list) + + +class MockTradingStack: + """Whole-stack mock: decision, intent, trade, account, HZ, CH, logs, network.""" + + def __init__( + self, + policy: Optional[Any] = None, + decision_engine: Optional[DecisionEngine] = None, + intent_engine: Optional[IntentEngine] = None, + capital: float = 25_000.0, + runtime_namespace: str = "pink", + strategy_namespace: str = "pink", + event_namespace: str = "pink", + network: Optional[MockNetwork] = None, + hazelcast: Optional[MockHazelcast] = None, + clickhouse: Optional[MockClickHouse] = None, + logs: Optional[MockLogSink] = None, + chaos: Optional[ChaosProfile] = None, + observability: Optional[DitaObservabilityNamespace] = None, + ): + self.decision_engine = decision_engine or DecisionEngine() + self.intent_engine = intent_engine or IntentEngine() + if policy is not None and hasattr(policy, "decide"): + self.decision_engine = policy + self.trade_executor = TradeExecutor() + self.exchange = MockExchange() + self.network = network or MockNetwork() + self.hz = hazelcast or MockHazelcast() + self.ch = clickhouse or MockClickHouse() + self.logs = logs or MockLogSink() + self.chaos = chaos or ChaosProfile() + self.anomaly_sensors = AnomalySensorState() + self.account = AccountProjection( + runtime_namespace=runtime_namespace, + strategy_namespace=strategy_namespace, + event_namespace=event_namespace, + actor_name="clean_arch", + exec_venue="bingx", + data_venue="binance", + ledger_authority="exchange", + max_capital=1_000_000_000.0, + snapshot=AccountSnapshot(capital=capital, equity=capital), + ) + inferred_state_map = f"DOLPHIN_STATE_{str(runtime_namespace or 'pink').upper()}" + if str(runtime_namespace or "").strip().lower() == "pink": + inferred_state_map = "DOLPHIN_STATE_PINK" + self.observability = observability or DitaObservabilityNamespace( + runtime_namespace=runtime_namespace, + feature_map="DOLPHIN_FEATURES", + meta_health_map="DOLPHIN_META_HEALTH", + state_map=inferred_state_map, + ) + self._anomaly_sensor_key = self.observability.resolved_sensor_key() + self.steps = 0 + self.trade_seq = 0 + self.decision_event_samples: List[Dict[str, Any]] = [] + self.anomaly_counts: Dict[str, int] = defaultdict(int) + self.anomaly_origin_counts: Dict[str, int] = defaultdict(int) + self.anomaly_samples: List[Dict[str, Any]] = [] + self._step_anomaly_names: List[str] = [] + self._seen_trade_ids: set[str] = set() + self._equity_peak = capital + + @property + def capital(self) -> float: + return self.account.snapshot.capital + + @property + def equity(self) -> float: + return self.account.snapshot.equity + + @property + def anomaly_sensor_key(self) -> str: + return self._anomaly_sensor_key + + def step(self, snapshot: MarketSnapshot): + """Run one market snapshot through the full mocked stack.""" + self.steps += 1 + self._step_anomaly_names = [] + self.exchange.mark_price(snapshot.price) + self.trade_executor.position = self.exchange.position + self.account.observe_position(self.exchange.position) + self._emit_engine_status(snapshot, phase="pre") + + decision_context = DecisionContext( + capital=self.account.snapshot.capital, + open_positions=1 if self.exchange.position is not None and not self.exchange.position.closed else 0, + trade_seq=self.trade_seq, + ) + decision = self.decision_engine.decide(snapshot, decision_context, self.exchange.position) + intent_context = IntentContext( + capital=self.account.snapshot.capital, + open_positions=decision_context.open_positions, + trade_seq=self.trade_seq, + ) + plan = self.intent_engine.plan(decision, intent_context, self.exchange.position) + intent = plan.intent + + self._write_hz(snapshot, decision, intent) + self._write_decision(snapshot, decision, intent) + self._write_account(snapshot, decision, intent, stage=TradeStage.DECISION_CREATED) + self._write_position_state(snapshot, decision, intent, stage=TradeStage.DECISION_CREATED) + self._write_position_state(snapshot, decision, intent, stage=TradeStage.INTENT_CREATED) + + if decision.action == DecisionAction.ENTER and self._chance(self.chaos.hang_entry_rate): + self._record_anomaly("hung_entry", snapshot, decision, intent, detail="entry dropped before execution", origin="injected") + self._emit_engine_status(snapshot, phase="hung-entry", decision=decision, intent=intent) + self._maybe_record_compound_fault(snapshot, decision, intent) + return decision + + if decision.action == DecisionAction.EXIT and self.exchange.position is not None and self._chance(self.chaos.hang_exit_rate): + self._record_anomaly("hung_exit", snapshot, decision, intent, detail="exit dropped before execution", origin="injected") + self._emit_engine_status(snapshot, phase="hung-exit", decision=decision, intent=intent) + self._maybe_record_compound_fault(snapshot, decision, intent) + return decision + + position_before = self.trade_executor._clone_position(self.exchange.position) + delivered = self.network.deliver("exchange", {"decision": decision.decision_id, "intent": intent.trade_id, "price": snapshot.price}) + result = None + if not delivered: + self.logs.emit("WARN", "network_drop", trade_id=intent.trade_id, action=intent.action.value) + self._record_anomaly("network_drop", snapshot, decision, intent, detail="exchange delivery dropped", origin="injected") + else: + last_result = None + for _payload in delivered: + last_result = self.trade_executor.execute(intent, self.exchange, self.account.snapshot.capital) + self.trade_executor.position = self.exchange.position + result = last_result + self.account.observe_position(self.exchange.position) + + if result is not None: + for stage in result.stages: + self._write_position_state(snapshot, decision, intent, stage=stage) + + if result.receipt is not None and intent.action == DecisionAction.EXIT and result.receipt.status == "FILLED": + stale_projection = False + if self._chance(self.chaos.reorder_account_rate): + self._record_anomaly( + "account_write_reordered", + snapshot, + decision, + intent, + detail="account row emitted before settlement", + origin="injected", + ) + self._write_account( + snapshot, + decision, + intent, + stage=TradeStage.EXIT_ACKED, + position_override=position_before, + refresh=False, + ) + stale_projection = True + if self._chance(self.chaos.missing_terminal_rate): + self._record_anomaly( + "missing_terminal_row", + snapshot, + decision, + intent, + detail="close row suppressed", + origin="injected", + ) + else: + self.account.settle(result.receipt.realized_pnl, result.receipt.fees) + if self._chance(self.chaos.stale_account_rate): + self._write_account( + snapshot, + decision, + intent, + stage=TradeStage.EXIT_ACKED, + position_override=position_before, + refresh=False, + ) + stale_projection = True + if self.exchange.position is None: + self._write_trade_close(snapshot, decision, intent, result) + if self._chance(self.chaos.duplicate_terminal_rate): + self._write_trade_close(snapshot, decision, intent, result) + self._record_anomaly( + "duplicate_terminal_row", + snapshot, + decision, + intent, + detail="close row duplicated", + origin="injected", + ) + if not stale_projection: + self._write_account(snapshot, decision, intent, stage=TradeStage.EXIT_ACKED) + if self._chance(self.chaos.orphan_close_rate) and self.exchange.position is None: + ghost = self.trade_executor._clone_position(position_before) + if ghost is not None: + ghost.closed = True + ghost.close_reason = "ORPHANED_CLOSED" + self.exchange.position = ghost + self.trade_executor.position = ghost + self.account.observe_position(self.exchange.position) + self._record_anomaly( + "orphaned_close", + snapshot, + decision, + intent, + detail="exchange left open after terminal close", + origin="injected", + ) + elif result.receipt is not None and intent.action == DecisionAction.ENTER and result.receipt.status == "FILLED": + if intent.trade_id in self._seen_trade_ids: + self._record_anomaly( + "duplicate_trade_id", + snapshot, + decision, + intent, + detail="trade_id reused on a new open", + origin="emergent", + ) + self._seen_trade_ids.add(intent.trade_id) + self.trade_seq += 1 + self._write_account(snapshot, decision, intent, stage=TradeStage.POSITION_OPENED) + elif result.receipt is not None and result.receipt.status == "REJECTED": + self._record_anomaly("rejected_order", snapshot, decision, intent, detail=result.receipt.reason, origin="emergent") + + if self.ch.last_row.get("dolphin_pink.account_events"): + last_account = self.ch.last_row["dolphin_pink.account_events"] + if int(last_account.get("open_positions", 0)) != (0 if self.exchange.position is None else 1): + self._record_anomaly("stale_account_projection", snapshot, decision, intent, detail=last_account, origin="emergent") + + if self.exchange.position is not None and self.exchange.position.closed: + self._record_anomaly("closed_position_still_present", snapshot, decision, intent, detail=self.exchange.position.trade_id, origin="emergent") + + self._run_invariant_sweep(snapshot, decision, intent) + self._maybe_record_compound_fault(snapshot, decision, intent) + self.account.observe_position(self.exchange.position) + self._equity_peak = max(self._equity_peak, self.account.snapshot.equity) + self._emit_engine_status(snapshot, phase="post", decision=decision, intent=intent) + return decision + + def run(self, snapshots: Iterable[MarketSnapshot], limit: Optional[int] = None) -> SimulationResult: + processed = 0 + for snapshot in snapshots: + self.step(snapshot) + processed += 1 + if limit is not None and processed >= limit: + break + return self.summary(processed) + + def summary(self, steps: Optional[int] = None) -> SimulationResult: + steps = self.steps if steps is None else steps + position = self.exchange.position + return SimulationResult( + steps=steps, + trades_opened=sum(1 for r in self.exchange.trade_history if r.status == "FILLED" and r.reason == "STRUCTURAL_DISLOCATION"), + trades_closed=sum(1 for r in self.exchange.trade_history if r.status == "FILLED" and r.reason in {"TAKE_PROFIT", "MAX_HOLD", "MEAN_REVERSION", "CATASTROPHIC_LOSS"}), + trades_rejected=self.exchange.rejected_count, + capital_final=self.capital, + equity_final=self.equity if position is None else self.equity + position.unrealized_pnl, + open_notional_final=self.exchange.open_notional(), + decision_events=self.ch.count("dolphin_pink.policy_events"), + trade_events=self.ch.count("dolphin_pink.trade_events"), + account_events=self.ch.count("dolphin_pink.account_events"), + hazelcast_updates=sum(len(v) for v in self.hz.history.values()), + logs_emitted=self.logs.count, + network_delivered=self.network.delivered, + network_dropped=self.network.dropped, + network_duplicated=self.network.duplicated, + anomaly_counts=dict(self.anomaly_counts), + anomaly_origin_counts=dict(self.anomaly_origin_counts), + injected_anomaly_counts={k: v for k, v in self.anomaly_counts.items() if k in INJECTED_ANOMALIES}, + emergent_anomaly_counts={k: v for k, v in self.anomaly_counts.items() if k in EMERGENT_ANOMALIES}, + anomaly_sensor_payload=self.anomaly_sensors.to_payload( + timestamp=datetime.now().astimezone().isoformat(), + anomaly_counts=dict(self.anomaly_counts), + origin_counts=dict(self.anomaly_origin_counts), + ), + anomaly_samples=deepcopy(self.anomaly_samples[:100]), + sample_policy_events=deepcopy(self.decision_event_samples[:50]), + ) + + def _apply_order(self, intent: Intent, snapshot: MarketSnapshot): + delivered = self.network.deliver("exchange", {"intent": intent, "price": snapshot.price}) + if not delivered: + self.logs.emit("WARN", "network_drop", trade_id=intent.trade_id, action=intent.action.value) + return None + return self.exchange.submit(intent) + + def _chance(self, rate: float) -> bool: + return rate > 0.0 and self.network.rng.random() < rate + + def _record_anomaly( + self, + name: str, + snapshot: MarketSnapshot, + decision, + intent: Intent, + detail: Any, + origin: str = "emergent", + ) -> None: + self.anomaly_counts[name] += 1 + self.anomaly_origin_counts[origin] += 1 + self._step_anomaly_names.append(name) + sensor_name = ANOMALY_SENSOR_MAP.get(name) + if sensor_name is not None: + self.anomaly_sensors.degrade(sensor_name, ANOMALY_SENSOR_SEVERITY.get(name, 0.1)) + sensor_payload = self.anomaly_sensors.to_payload( + timestamp=snapshot.timestamp.isoformat(), + anomaly_counts=dict(self.anomaly_counts), + origin_counts=dict(self.anomaly_origin_counts), + ) + self.hz.put(self.observability.meta_health_map, self._anomaly_sensor_key, sensor_payload) + self.hz.put(self.observability.feature_map, self._anomaly_sensor_key, sensor_payload) + if self.observability.mirror_legacy_key and self._anomaly_sensor_key != LEGACY_ANOMALY_SENSOR_KEY: + self.hz.put(self.observability.meta_health_map, LEGACY_ANOMALY_SENSOR_KEY, sensor_payload) + self.hz.put(self.observability.feature_map, LEGACY_ANOMALY_SENSOR_KEY, sensor_payload) + self.ch.insert( + "dolphin_pink.anomaly_events", + { + "ts": snapshot.timestamp.isoformat(), + "decision_id": decision.decision_id, + "trade_id": intent.trade_id, + "symbol": snapshot.symbol, + "anomaly": name, + "origin": origin, + "sensor": sensor_name, + "detail": detail, + "rm_meta": sensor_payload["rm_meta"], + }, + ) + if len(self.anomaly_samples) < 200: + self.anomaly_samples.append( + { + "anomaly": name, + "ts": snapshot.timestamp.isoformat(), + "symbol": snapshot.symbol, + "decision_id": decision.decision_id, + "trade_id": intent.trade_id, + "action": decision.action.value, + "reason": intent.reason, + "detail": detail, + "origin": origin, + "capital": self.capital, + "equity": self.equity, + "open_positions": 0 if self.exchange.position is None else 1, + } + ) + self.logs.emit("WARN", "anomaly", anomaly=name, origin=origin, trade_id=intent.trade_id, reason=intent.reason, detail=detail) + + def _maybe_record_compound_fault(self, snapshot: MarketSnapshot, decision, intent: Intent) -> None: + """Promote multi-fault steps to an explicit combined-failure anomaly.""" + unique = [name for name in dict.fromkeys(self._step_anomaly_names) if name != "compound_fault"] + if len(unique) < 2: + return + if "compound_fault" in unique: + return + detail = { + "faults": unique[:8], + "fault_count": len(unique), + } + self._record_anomaly("compound_fault", snapshot, decision, intent, detail=detail, origin="emergent") + + def _run_invariant_sweep(self, snapshot: MarketSnapshot, decision, intent: Intent) -> List[Dict[str, Any]]: + """Run hard-state invariants that should never require heuristics. + + This sweep is intentionally false-positive resistant: it only checks + directly contradictory states, not statistical expectations. + """ + + violations: List[Dict[str, Any]] = [] + + def add(name: str, detail: Any, origin: str = "emergent") -> None: + violations.append({"name": name, "detail": detail, "origin": origin}) + + try: + price = snapshot.price + vdiv = snapshot.velocity_divergence + if not isinstance(snapshot.symbol, str) or not snapshot.symbol.strip(): + add("invalid_market_snapshot", "missing or blank symbol", "injected") + if not math.isfinite(price) or price <= 0: + add("invalid_market_snapshot", {"price": price}, "injected") + if vdiv is None or not math.isfinite(float(vdiv)): + add("invalid_market_snapshot", {"velocity_divergence": vdiv}, "injected") + + acct = self.account.snapshot + if ( + not math.isfinite(acct.capital) + or not math.isfinite(acct.equity) + or not math.isfinite(acct.open_notional) + or acct.capital < 0 + or acct.equity < 0 + or acct.open_notional < 0 + or acct.open_positions not in (0, 1) + ): + add( + "invalid_account_state", + { + "capital": acct.capital, + "equity": acct.equity, + "open_notional": acct.open_notional, + "open_positions": acct.open_positions, + }, + "emergent", + ) + + pos = self.exchange.position + if pos is not None: + if ( + not isinstance(pos.trade_id, str) + or not pos.trade_id + or not isinstance(pos.asset, str) + or not pos.asset + or not math.isfinite(pos.entry_price) + or pos.entry_price <= 0 + or not math.isfinite(pos.size) + or pos.size <= 0 + or not math.isfinite(pos.leverage) + or pos.leverage <= 0 + or not math.isfinite(pos.current_price) + or pos.current_price <= 0 + ): + add( + "invalid_trade_state", + { + "trade_id": pos.trade_id, + "asset": pos.asset, + "entry_price": pos.entry_price, + "size": pos.size, + "leverage": pos.leverage, + "current_price": pos.current_price, + "closed": pos.closed, + }, + "emergent", + ) + + executor_pos = self.trade_executor.position + if executor_pos is None: + add("exchange_executor_divergence", "executor missing live position", "emergent") + elif not self._positions_match(pos, executor_pos): + add( + "exchange_executor_divergence", + { + "exchange": self._position_snapshot(pos), + "executor": self._position_snapshot(executor_pos), + }, + "emergent", + ) + elif self.trade_executor.position is not None: + add("exchange_executor_divergence", "executor retains position while exchange is flat", "emergent") + + if decision.decision_id != intent.decision_id or decision.action != intent.action or decision.side != intent.side or decision.asset != intent.asset: + add( + "decision_intent_mismatch", + { + "decision_id": decision.decision_id, + "intent_id": intent.decision_id, + "decision_action": decision.action.value, + "intent_action": intent.action.value, + "decision_side": decision.side.value, + "intent_side": intent.side.value, + "decision_asset": decision.asset, + "intent_asset": intent.asset, + }, + "emergent", + ) + except Exception as exc: # pragma: no cover - safety net for the sweep itself + add("unknown_invariant_violation", {"exception": repr(exc)}, "emergent") + + if violations: + for violation in violations: + name = violation["name"] + detail = violation["detail"] + origin = violation["origin"] + if name not in ANOMALY_SENSOR_MAP: + self._record_anomaly("unknown_invariant_violation", snapshot, decision, intent, detail={"rule": name, "detail": detail}, origin="emergent") + else: + self._record_anomaly(name, snapshot, decision, intent, detail=detail, origin=origin) + return violations + + @staticmethod + def _position_snapshot(position: TradePosition) -> Dict[str, Any]: + return { + "trade_id": position.trade_id, + "asset": position.asset, + "side": position.side.value, + "entry_price": position.entry_price, + "size": position.size, + "leverage": position.leverage, + "current_price": position.current_price, + "closed": position.closed, + "close_reason": position.close_reason, + } + + @staticmethod + def _positions_match(left: TradePosition, right: TradePosition) -> bool: + return ( + left.trade_id == right.trade_id + and left.asset == right.asset + and left.side == right.side + and math.isclose(left.entry_price, right.entry_price, rel_tol=0.0, abs_tol=1e-12) + and math.isclose(left.size, right.size, rel_tol=0.0, abs_tol=1e-12) + and math.isclose(left.leverage, right.leverage, rel_tol=0.0, abs_tol=1e-12) + and left.closed == right.closed + and left.close_reason == right.close_reason + ) + + def _write_hz(self, snapshot: MarketSnapshot, decision, intent: Intent) -> None: + payload = { + "timestamp": snapshot.timestamp.isoformat(), + "symbol": snapshot.symbol, + "price": snapshot.price, + "velocity_divergence": snapshot.velocity_divergence, + "decision": decision.action.value, + "intent": intent.action.value, + "reason": intent.reason, + } + self.hz.put(self.observability.feature_map, "latest_snapshot", payload) + self.hz.put( + self.observability.state_map, + "dita_state", + { + "decision_id": decision.decision_id, + "trade_id": intent.trade_id, + "action": intent.action.value, + "side": intent.side.value, + "reason": intent.reason, + "capital": self.capital, + "anomaly_sensor_key": self._anomaly_sensor_key, + }, + ) + sensor_payload = self.anomaly_sensors.to_payload( + timestamp=snapshot.timestamp.isoformat(), + anomaly_counts=dict(self.anomaly_counts), + origin_counts=dict(self.anomaly_origin_counts), + ) + self.hz.put(self.observability.meta_health_map, self._anomaly_sensor_key, sensor_payload) + self.hz.put(self.observability.feature_map, self._anomaly_sensor_key, sensor_payload) + if self.observability.mirror_legacy_key and self._anomaly_sensor_key != LEGACY_ANOMALY_SENSOR_KEY: + self.hz.put(self.observability.meta_health_map, LEGACY_ANOMALY_SENSOR_KEY, sensor_payload) + self.hz.put(self.observability.feature_map, LEGACY_ANOMALY_SENSOR_KEY, sensor_payload) + + def _write_decision(self, snapshot: MarketSnapshot, decision, intent: Intent) -> None: + row = { + "ts": snapshot.timestamp.isoformat(), + "decision_id": decision.decision_id, + "trade_id": intent.trade_id, + "asset": decision.asset, + "action": decision.action.value, + "side": decision.side.value, + "reason": decision.reason, + "confidence": decision.confidence, + "vel_div": decision.velocity_divergence, + "irp_alignment": decision.irp_alignment, + "stage": decision.stage.value, + } + for payload in self.network.deliver("clickhouse", row): + self.ch.insert("dolphin_pink.policy_events", payload) + self.ch.insert("dolphin_pink.v7_decision_events", payload) + self.ch.insert("dolphin_pink.account_events", self._account_row(snapshot, decision, intent, TradeStage.DECISION_CREATED)) + + if len(self.decision_event_samples) < 50: + self.decision_event_samples.append( + { + "decision_id": decision.decision_id, + "trade_id": intent.trade_id, + "action": decision.action.value, + "reason": decision.reason, + "stage": decision.stage.value, + } + ) + + def _write_trade_close(self, snapshot: MarketSnapshot, decision, intent: Intent, result) -> None: + if result.receipt is None: + return + receipt = result.receipt + row = { + "ts": snapshot.timestamp.isoformat(), + "decision_id": decision.decision_id, + "trade_id": intent.trade_id, + "asset": decision.asset, + "stage": TradeStage.TRADE_TERMINAL_WRITTEN.value, + "action": decision.action.value, + "side": intent.side.value, + "reason": intent.reason, + "price": receipt.fill_price, + "size": receipt.fill_size, + "pnl": receipt.realized_pnl, + "fees": receipt.fees, + } + self.ch.insert("dolphin_pink.trade_events", row) + self.ch.insert( + "dolphin_pink.position_state", + { + "ts": snapshot.timestamp.isoformat(), + "decision_id": decision.decision_id, + "trade_id": intent.trade_id, + "asset": decision.asset, + "state": "CLOSED", + "stage": TradeStage.TRADE_TERMINAL_WRITTEN.value, + "reason": intent.reason, + }, + ) + + def _write_position_state(self, snapshot: MarketSnapshot, decision, intent: Intent, stage: TradeStage) -> None: + position = self.exchange.position + state = "FLAT" + if position is not None and not position.closed: + state = "OPEN" + elif stage in {TradeStage.POSITION_CLOSED, TradeStage.TRADE_TERMINAL_WRITTEN}: + state = "CLOSED" + elif stage == TradeStage.POSITION_PARTIALLY_CLOSED: + state = "OPEN" + row = { + "ts": snapshot.timestamp.isoformat(), + "decision_id": decision.decision_id, + "trade_id": intent.trade_id, + "asset": decision.asset, + "state": state, + "stage": stage.value, + "reason": intent.reason, + "capital": self.capital, + "equity": self.equity, + "open_positions": 0 if position is None else 1, + } + self.ch.insert("dolphin_pink.position_state", row) + + def _emit_engine_status(self, snapshot: MarketSnapshot, phase: str, decision=None, intent: Optional[Intent] = None) -> None: + position = self.exchange.position + status = { + "ts": snapshot.timestamp.isoformat(), + "phase": phase, + "symbol": snapshot.symbol, + "price": snapshot.price, + "capital": self.capital, + "equity": self.equity if position is None else self.equity + position.unrealized_pnl, + "open_positions": 0 if position is None else 1, + "open_notional": self.exchange.open_notional(), + "order_count": self.exchange.order_count, + "fill_count": self.exchange.fill_count, + "rejected_count": self.exchange.rejected_count, + "capital_peak": self._equity_peak, + "trade_seq": self.trade_seq, + "network_delivered": self.network.delivered, + "network_dropped": self.network.dropped, + "network_duplicated": self.network.duplicated, + "decision_stage": None if decision is None else decision.stage.value, + "decision_action": None if decision is None else decision.action.value, + "decision_reason": None if decision is None else decision.reason, + "intent_action": None if intent is None else intent.action.value, + "intent_reason": None if intent is None else intent.reason, + "anomaly_rm_meta": round(self.anomaly_sensors.aggregate(), 3), + "anomaly_sensors": self.anomaly_sensors.as_dict(), + } + self.logs.emit("INFO", "engine_status", **status) + + def _write_account( + self, + snapshot: MarketSnapshot, + decision, + intent: Intent, + stage: TradeStage, + position_override: Optional[TradePosition] = None, + refresh: bool = True, + ) -> None: + row = self._account_row(snapshot, decision, intent, stage, position_override=position_override, refresh=refresh) + self.ch.insert("dolphin_pink.account_events", row) + + def _account_row( + self, + snapshot: MarketSnapshot, + decision, + intent: Intent, + stage: TradeStage, + position_override: Optional[TradePosition] = None, + refresh: bool = True, + ) -> Dict[str, Any]: + position = position_override if position_override is not None else self.exchange.position + if refresh: + self.account.observe_position(position) + open_positions = 0 if position is None or position.closed else 1 + open_notional = 0.0 if position is None or position.closed else position.current_price * position.size + leverage = 0.0 if position is None or position.closed else position.leverage + return { + "ts": snapshot.timestamp.isoformat(), + "runtime_namespace": self.account.runtime_namespace, + "strategy_namespace": self.account.strategy_namespace, + "event_namespace": self.account.event_namespace, + "actor_name": self.account.actor_name, + "exec_venue": self.account.exec_venue, + "data_venue": self.account.data_venue, + "ledger_authority": self.account.ledger_authority, + "capital": self.account.snapshot.capital, + "equity": self.account.snapshot.equity, + "open_positions": open_positions, + "current_open_notional": open_notional, + "current_account_leverage": leverage, + "decision": decision.action.value, + "reason": intent.reason, + "decision_id": decision.decision_id, + "trade_id": intent.trade_id, + "symbol": snapshot.symbol, + "side": intent.side.value, + "stage": stage.value, + "anomaly_rm_meta": self.anomaly_sensors.aggregate(), + "anomaly_sensors": self.anomaly_sensors.as_dict(), + } diff --git a/prod/clean_arch/sim/run.py b/prod/clean_arch/sim/run.py new file mode 100644 index 0000000..2437967 --- /dev/null +++ b/prod/clean_arch/sim/run.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""CLI for large-scale policy simulation and fuzzing.""" + +from __future__ import annotations + +import argparse +import json + +from prod.clean_arch.sim.fuzzer import FuzzConfig, fuzz_stack + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run clean-arch policy simulation/fuzzing.") + parser.add_argument("--transactions", type=int, default=1_000_000) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--bad-input-rate", type=float, default=0.01) + parser.add_argument("--network-drop-rate", type=float, default=0.001) + parser.add_argument("--network-duplicate-rate", type=float, default=0.001) + parser.add_argument("--price-sigma", type=float, default=0.015) + parser.add_argument("--capture-limit", type=int, default=2_000) + parser.add_argument("--runtime-namespace", type=str, default="pink") + parser.add_argument("--anomaly-sensor-key", type=str, default="") + parser.add_argument("--mirror-legacy-sensor-key", action="store_true") + parser.add_argument("--aggressive", action="store_true", help="Enable aggressive chaos/anomaly injection") + parser.add_argument("--json", action="store_true", help="Emit JSON summary") + args = parser.parse_args() + + report = fuzz_stack( + FuzzConfig( + transactions=args.transactions, + seed=args.seed, + bad_input_rate=args.bad_input_rate, + network_drop_rate=args.network_drop_rate, + network_duplicate_rate=args.network_duplicate_rate, + price_sigma=args.price_sigma, + capture_limit=args.capture_limit, + aggressive=args.aggressive, + runtime_namespace=args.runtime_namespace, + anomaly_sensor_key=(args.anomaly_sensor_key.strip() or None), + mirror_legacy_sensor_key=args.mirror_legacy_sensor_key, + ) + ) + + payload = { + "transactions": report.transactions, + "capital_final": report.capital_final, + "equity_final": report.equity_final, + "open_notional_final": report.open_notional_final, + "policy_events": report.policy_events, + "trade_events": report.trade_events, + "account_events": report.account_events, + "logs_emitted": report.logs_emitted, + "network_dropped": report.network_dropped, + "network_duplicated": report.network_duplicated, + "anomaly_counts": report.anomaly_counts, + "anomaly_origin_counts": report.anomaly_origin_counts, + "injected_anomaly_counts": report.injected_anomaly_counts, + "emergent_anomaly_counts": report.emergent_anomaly_counts, + "anomaly_sensor_payload": report.anomaly_sensor_payload, + "anomaly_samples": report.anomaly_samples, + "sample_policy_events": report.sample_policy_events, + } + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print("Clean-arch policy simulation complete") + for k, v in payload.items(): + if k != "sample_policy_events": + print(f"{k}: {v}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())