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:
19
prod/bingx/__init__.py
Normal file
19
prod/bingx/__init__.py
Normal 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",
|
||||
]
|
||||
897
prod/bingx/characterization.py
Normal file
897
prod/bingx/characterization.py
Normal 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
71
prod/bingx/config.py
Normal 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
225
prod/bingx/data_client.py
Normal 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
44
prod/bingx/data_config.py
Normal 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."
|
||||
)
|
||||
47
prod/bingx/data_factories.py
Normal file
47
prod/bingx/data_factories.py
Normal 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
128
prod/bingx/dns_cache.py
Normal 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
22
prod/bingx/enums.py
Normal 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
2338
prod/bingx/execution.py
Normal file
File diff suppressed because it is too large
Load Diff
38
prod/bingx/factories.py
Normal file
38
prod/bingx/factories.py
Normal 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
226
prod/bingx/friction.py
Normal 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
151
prod/bingx/health.py
Normal 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
|
||||
80
prod/bingx/instrument_provider.py
Normal file
80
prod/bingx/instrument_provider.py
Normal 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
357
prod/bingx/journal.py
Normal 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
83
prod/bingx/leverage.py
Normal 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
139
prod/bingx/market_stream.py
Normal 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
183
prod/bingx/observer.py
Normal 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
106
prod/bingx/rate_limits.py
Normal 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
|
||||
30
prod/bingx/reconciliation.py
Normal file
30
prod/bingx/reconciliation.py
Normal 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)
|
||||
126
prod/bingx/sandbox_status.py
Normal file
126
prod/bingx/sandbox_status.py
Normal 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
80
prod/bingx/schemas.py
Normal 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
48
prod/bingx/signing.py
Normal 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
71
prod/bingx/sizing_mode.py
Normal 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
31
prod/bingx/urls.py
Normal 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
152
prod/bingx/websocket.py
Normal 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
|
||||
0
prod/clean_arch/adapters/__init__.py
Normal file
0
prod/clean_arch/adapters/__init__.py
Normal file
57
prod/clean_arch/adapters/eigen_scan_normalizer.py
Normal file
57
prod/clean_arch/adapters/eigen_scan_normalizer.py
Normal 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,
|
||||
}
|
||||
0
prod/clean_arch/core/__init__.py
Normal file
0
prod/clean_arch/core/__init__.py
Normal file
185
prod/clean_arch/core/trading_engine.py
Normal file
185
prod/clean_arch/core/trading_engine.py
Normal 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)
|
||||
49
prod/clean_arch/dita/__init__.py
Normal file
49
prod/clean_arch/dita/__init__.py
Normal 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",
|
||||
]
|
||||
118
prod/clean_arch/dita/account.py
Normal file
118
prod/clean_arch/dita/account.py
Normal 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 {},
|
||||
)
|
||||
132
prod/clean_arch/dita/intent.py
Normal file
132
prod/clean_arch/dita/intent.py
Normal 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}"
|
||||
32
prod/clean_arch/dita/observability.py
Normal file
32
prod/clean_arch/dita/observability.py
Normal 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}"
|
||||
|
||||
139
prod/clean_arch/dita/trade.py
Normal file
139
prod/clean_arch/dita/trade.py
Normal 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)))
|
||||
95
prod/clean_arch/dita_v2/__init__.py
Normal file
95
prod/clean_arch/dita_v2/__init__.py
Normal 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",
|
||||
]
|
||||
337
prod/clean_arch/dita_v2/_build_pink_bodies.py
Normal file
337
prod/clean_arch/dita_v2/_build_pink_bodies.py
Normal 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")
|
||||
170
prod/clean_arch/dita_v2/_build_pink_extended.py
Normal file
170
prod/clean_arch/dita_v2/_build_pink_extended.py
Normal 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")
|
||||
1244
prod/clean_arch/dita_v2/_gen_test.py
Normal file
1244
prod/clean_arch/dita_v2/_gen_test.py
Normal file
File diff suppressed because it is too large
Load Diff
217
prod/clean_arch/dita_v2/control.py
Normal file
217
prod/clean_arch/dita_v2/control.py
Normal 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)
|
||||
563
prod/clean_arch/dita_v2/exec_router.py
Normal file
563
prod/clean_arch/dita_v2/exec_router.py
Normal 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),
|
||||
}
|
||||
438
prod/clean_arch/dita_v2/gen2.py
Normal file
438
prod/clean_arch/dita_v2/gen2.py
Normal 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]}")
|
||||
688
prod/clean_arch/dita_v2/gen_live_tests.py
Normal file
688
prod/clean_arch/dita_v2/gen_live_tests.py
Normal 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]}")
|
||||
102
prod/clean_arch/dita_v2/journal.py
Normal file
102
prod/clean_arch/dita_v2/journal.py
Normal 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()),
|
||||
}
|
||||
8
prod/clean_arch/dita_v2/kernel.py
Normal file
8
prod/clean_arch/dita_v2/kernel.py
Normal 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"]
|
||||
|
||||
97
prod/clean_arch/dita_v2/projection.py
Normal file
97
prod/clean_arch/dita_v2/projection.py
Normal 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
|
||||
129
prod/clean_arch/dita_v2/real_control_plane.py
Normal file
129
prod/clean_arch/dita_v2/real_control_plane.py
Normal 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
|
||||
263
prod/clean_arch/dita_v2/real_zinc_plane.py
Normal file
263
prod/clean_arch/dita_v2/real_zinc_plane.py
Normal 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()
|
||||
378
prod/clean_arch/dita_v2/test_blue_parity.py
Normal file
378
prod/clean_arch/dita_v2/test_blue_parity.py
Normal 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"]))
|
||||
267
prod/clean_arch/dita_v2/test_exec_live_e2e.py
Normal file
267
prod/clean_arch/dita_v2/test_exec_live_e2e.py
Normal 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()
|
||||
605
prod/clean_arch/dita_v2/test_exec_router.py
Normal file
605
prod/clean_arch/dita_v2/test_exec_router.py
Normal 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()
|
||||
646
prod/clean_arch/dita_v2/test_exec_router_runtime.py
Normal file
646
prod/clean_arch/dita_v2/test_exec_router_runtime.py
Normal 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()
|
||||
460
prod/clean_arch/dita_v2/test_reset_and_seed.py
Normal file
460
prod/clean_arch/dita_v2/test_reset_and_seed.py
Normal 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)
|
||||
43
prod/clean_arch/dita_v2/utils.py
Normal file
43
prod/clean_arch/dita_v2/utils.py
Normal 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)
|
||||
135
prod/clean_arch/dita_v2/zinc_plane.py
Normal file
135
prod/clean_arch/dita_v2/zinc_plane.py
Normal 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
|
||||
5
prod/clean_arch/persistence/__init__.py
Normal file
5
prod/clean_arch/persistence/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Persistence helpers for clean-arch PINK/BLUE-compatible runtimes."""
|
||||
|
||||
from .pink_clickhouse import PinkClickHousePersistence
|
||||
|
||||
__all__ = ["PinkClickHousePersistence"]
|
||||
35
prod/clean_arch/policy/__init__.py
Normal file
35
prod/clean_arch/policy/__init__.py
Normal 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",
|
||||
]
|
||||
35
prod/clean_arch/policy/contracts.py
Normal file
35
prod/clean_arch/policy/contracts.py
Normal 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",
|
||||
]
|
||||
44
prod/clean_arch/policy/engine.py
Normal file
44
prod/clean_arch/policy/engine.py
Normal 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
|
||||
|
||||
0
prod/clean_arch/ports/__init__.py
Normal file
0
prod/clean_arch/ports/__init__.py
Normal file
118
prod/clean_arch/ports/data_feed.py
Normal file
118
prod/clean_arch/ports/data_feed.py
Normal 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
|
||||
74
prod/clean_arch/ports/execution.py
Normal file
74
prod/clean_arch/ports/execution.py
Normal 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
|
||||
|
||||
2
prod/clean_arch/runtime/__init__.py
Normal file
2
prod/clean_arch/runtime/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Runtime entrypoints for clean-arch DITA live loops."""
|
||||
|
||||
53
prod/clean_arch/runtime/runner_heartbeat.py
Normal file
53
prod/clean_arch/runtime/runner_heartbeat.py
Normal 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)
|
||||
32
prod/clean_arch/sim/__init__.py
Normal file
32
prod/clean_arch/sim/__init__.py
Normal 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",
|
||||
]
|
||||
202
prod/clean_arch/sim/fuzzer.py
Normal file
202
prod/clean_arch/sim/fuzzer.py
Normal 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,
|
||||
)
|
||||
1127
prod/clean_arch/sim/mock_stack.py
Normal file
1127
prod/clean_arch/sim/mock_stack.py
Normal file
File diff suppressed because it is too large
Load Diff
74
prod/clean_arch/sim/run.py
Normal file
74
prod/clean_arch/sim/run.py
Normal 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())
|
||||
Reference in New Issue
Block a user