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 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-06-12 15:09:32 +02:00
parent c3a18f693a
commit 84e4a50e3f
67 changed files with 15090 additions and 0 deletions

19
prod/bingx/__init__.py Normal file
View File

@@ -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",
]

View File

@@ -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

71
prod/bingx/config.py Normal file
View File

@@ -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")

225
prod/bingx/data_client.py Normal file
View File

@@ -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")

44
prod/bingx/data_config.py Normal file
View File

@@ -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."
)

View File

@@ -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,
)

128
prod/bingx/dns_cache.py Normal file
View File

@@ -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

22
prod/bingx/enums.py Normal file
View File

@@ -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

2338
prod/bingx/execution.py Normal file

File diff suppressed because it is too large Load Diff

38
prod/bingx/factories.py Normal file
View File

@@ -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,
)

226
prod/bingx/friction.py Normal file
View File

@@ -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),
}

151
prod/bingx/health.py Normal file
View File

@@ -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

View File

@@ -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,
)

357
prod/bingx/journal.py Normal file
View File

@@ -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

83
prod/bingx/leverage.py Normal file
View File

@@ -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,
)

139
prod/bingx/market_stream.py Normal file
View File

@@ -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

183
prod/bingx/observer.py Normal file
View File

@@ -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()

106
prod/bingx/rate_limits.py Normal file
View File

@@ -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

View File

@@ -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)

View File

@@ -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

80
prod/bingx/schemas.py Normal file
View File

@@ -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,
)

48
prod/bingx/signing.py Normal file
View File

@@ -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

71
prod/bingx/sizing_mode.py Normal file
View File

@@ -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

31
prod/bingx/urls.py Normal file
View File

@@ -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]

152
prod/bingx/websocket.py Normal file
View File

@@ -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

View File

View File

@@ -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,
}

View File

View File

@@ -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)

View File

@@ -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",
]

View File

@@ -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 {},
)

View File

@@ -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}"

View File

@@ -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}"

View File

@@ -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)))

View File

@@ -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",
]

View File

@@ -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")

View File

@@ -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")

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View File

@@ -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_<SYMBOL> 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),
}

View File

@@ -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]}")

View File

@@ -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]}")

View File

@@ -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()),
}

View File

@@ -0,0 +1,8 @@
"""Compatibility shim for the Rust-backed DITAv2 execution kernel."""
from __future__ import annotations
from .rust_backend import ExecutionKernel
__all__ = ["ExecutionKernel"]

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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"]))

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1,5 @@
"""Persistence helpers for clean-arch PINK/BLUE-compatible runtimes."""
from .pink_clickhouse import PinkClickHousePersistence
__all__ = ["PinkClickHousePersistence"]

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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

View File

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,2 @@
"""Runtime entrypoints for clean-arch DITA live loops."""

View File

@@ -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)

View File

@@ -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",
]

View File

@@ -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,
)

File diff suppressed because it is too large Load Diff

View File

@@ -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())