971 lines
37 KiB
Python
971 lines
37 KiB
Python
|
|
"""Comprehensive test battery for all 13 CRITICAL DITAv2 flaws.
|
||
|
|
|
||
|
|
Each test verifies that the specific flaw exists (pre-fix) and would pass
|
||
|
|
once the flaw is addressed. Tests use the MockVenueAdapter to avoid
|
||
|
|
requiring live BingX connectivity.
|
||
|
|
|
||
|
|
Run with:
|
||
|
|
python -m pytest prod/clean_arch/dita_v2/test_flaws.py -v
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import sys
|
||
|
|
sys.path.insert(0, "/mnt/dolphinng5_predict")
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
from typing import Any, Dict, List
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from prod.clean_arch.dita_v2.contracts import (
|
||
|
|
KernelCommandType,
|
||
|
|
KernelDiagnosticCode,
|
||
|
|
KernelEventKind,
|
||
|
|
KernelIntent,
|
||
|
|
KernelOutcome,
|
||
|
|
KernelSeverity,
|
||
|
|
KernelTransition,
|
||
|
|
TradeSide,
|
||
|
|
TradeSlot,
|
||
|
|
TradeStage,
|
||
|
|
VenueEvent,
|
||
|
|
VenueEventStatus,
|
||
|
|
VenueOrder,
|
||
|
|
VenueOrderStatus,
|
||
|
|
)
|
||
|
|
from prod.clean_arch.dita_v2.mock_venue import MockVenueAdapter, MockVenueScenario
|
||
|
|
from prod.clean_arch.dita_v2.rust_backend import ExecutionKernel
|
||
|
|
from prod.clean_arch.dita_v2.account import AccountProjection
|
||
|
|
|
||
|
|
E = KernelCommandType
|
||
|
|
TS = TradeSide
|
||
|
|
|
||
|
|
|
||
|
|
def _mk_intent(
|
||
|
|
action: KernelCommandType = KernelCommandType.ENTER,
|
||
|
|
trade_id: str = "t1",
|
||
|
|
slot_id: int = 0,
|
||
|
|
asset: str = "BTCUSDT",
|
||
|
|
side: TradeSide = TradeSide.SHORT,
|
||
|
|
price: float = 100.0,
|
||
|
|
size: float = 1.0,
|
||
|
|
leverage: float = 1.0,
|
||
|
|
exit_leg_ratios: tuple = (1.0,),
|
||
|
|
**kw,
|
||
|
|
) -> KernelIntent:
|
||
|
|
return KernelIntent(
|
||
|
|
timestamp=datetime.now(timezone.utc),
|
||
|
|
intent_id=kw.pop("intent_id", trade_id),
|
||
|
|
trade_id=trade_id,
|
||
|
|
slot_id=slot_id,
|
||
|
|
asset=asset,
|
||
|
|
side=side,
|
||
|
|
action=action,
|
||
|
|
reference_price=price,
|
||
|
|
target_size=size,
|
||
|
|
leverage=leverage,
|
||
|
|
exit_leg_ratios=exit_leg_ratios,
|
||
|
|
reason=kw.pop("reason", f"auto_{action.value.lower()}"),
|
||
|
|
metadata=kw,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _mk_venue_event(
|
||
|
|
kind: KernelEventKind,
|
||
|
|
trade_id: str = "t1",
|
||
|
|
slot_id: int = 0,
|
||
|
|
side: TradeSide = TradeSide.SHORT,
|
||
|
|
asset: str = "BTCUSDT",
|
||
|
|
price: float = 100.0,
|
||
|
|
size: float = 1.0,
|
||
|
|
filled_size: float = 1.0,
|
||
|
|
remaining_size: float = 0.0,
|
||
|
|
event_id: str = "",
|
||
|
|
venue_order_id: str = "V-1",
|
||
|
|
venue_client_id: str = "t1:t1",
|
||
|
|
status: VenueEventStatus = VenueEventStatus.FILLED,
|
||
|
|
reason: str = "",
|
||
|
|
) -> VenueEvent:
|
||
|
|
return VenueEvent(
|
||
|
|
timestamp=datetime.now(timezone.utc),
|
||
|
|
event_id=event_id or f"ev-{kind.value}-{trade_id}",
|
||
|
|
trade_id=trade_id,
|
||
|
|
slot_id=slot_id,
|
||
|
|
kind=kind,
|
||
|
|
status=status,
|
||
|
|
venue_order_id=venue_order_id,
|
||
|
|
venue_client_id=venue_client_id,
|
||
|
|
side=side,
|
||
|
|
asset=asset,
|
||
|
|
price=price,
|
||
|
|
size=size,
|
||
|
|
filled_size=filled_size,
|
||
|
|
remaining_size=remaining_size,
|
||
|
|
reason=reason,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _fresh_kernel(
|
||
|
|
*,
|
||
|
|
scenario: MockVenueScenario = None,
|
||
|
|
max_slots: int = 2,
|
||
|
|
capital: float = 25000.0,
|
||
|
|
) -> ExecutionKernel:
|
||
|
|
venue = MockVenueAdapter(scenario=scenario or MockVenueScenario())
|
||
|
|
k = ExecutionKernel(max_slots=max_slots, venue=venue)
|
||
|
|
k.account.snapshot.capital = capital
|
||
|
|
k.account.snapshot.peak_capital = capital
|
||
|
|
k.account.snapshot.equity = capital
|
||
|
|
return k
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# FLAW 1: Entry-order cancellation is structurally broken
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
class TestFlaw1EntryCancel:
|
||
|
|
"""CANCEL intent for entry orders must work, not just exit orders."""
|
||
|
|
|
||
|
|
def test_cancel_entry_order_accepted_by_rust(self):
|
||
|
|
"""Rust kernel must accept CANCEL for an entry order in ENTRY_WORKING."""
|
||
|
|
k = _fresh_kernel(scenario=MockVenueScenario(partial_fill_ratio=0.0, emit_fill_on_submit=False))
|
||
|
|
r = k.process_intent(_mk_intent(action=E.ENTER, trade_id="ce1"))
|
||
|
|
assert r.accepted, f"ENTER rejected: {r.diagnostic_code}"
|
||
|
|
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.fsm_state in {TradeStage.ORDER_REQUESTED, TradeStage.ENTRY_WORKING}
|
||
|
|
|
||
|
|
cancel_result = k.process_intent(_mk_intent(action=E.CANCEL, trade_id="ce1"))
|
||
|
|
assert cancel_result.accepted, (
|
||
|
|
f"CANCEL for entry order should be accepted, got "
|
||
|
|
f"accepted={cancel_result.accepted} "
|
||
|
|
f"diag={cancel_result.diagnostic_code}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_cancel_entry_order_calls_venue_cancel(self):
|
||
|
|
"""Python bridge must call venue.cancel() on active_entry_order."""
|
||
|
|
scenario = MockVenueScenario(partial_fill_ratio=0.0, emit_fill_on_submit=False)
|
||
|
|
k = _fresh_kernel(scenario=scenario)
|
||
|
|
k.process_intent(_mk_intent(action=E.ENTER, trade_id="ce2"))
|
||
|
|
|
||
|
|
entry_order = k.slot(0).active_entry_order
|
||
|
|
assert entry_order is not None, "Entry order should be attached"
|
||
|
|
|
||
|
|
cancel_result = k.process_intent(_mk_intent(action=E.CANCEL, trade_id="ce2"))
|
||
|
|
assert cancel_result.accepted, f"CANCEL not accepted: {cancel_result.diagnostic_code}"
|
||
|
|
|
||
|
|
def test_cancel_entry_no_fill_returns_to_idle(self):
|
||
|
|
"""After cancelling an entry order that hasn't filled, slot must be IDLE."""
|
||
|
|
k = _fresh_kernel(scenario=MockVenueScenario(partial_fill_ratio=0.0, emit_fill_on_submit=False))
|
||
|
|
k.process_intent(_mk_intent(action=E.ENTER, trade_id="ce3"))
|
||
|
|
k.process_intent(_mk_intent(action=E.CANCEL, trade_id="ce3"))
|
||
|
|
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.is_free(), (
|
||
|
|
f"Slot should be free/IDLE after entry cancel, "
|
||
|
|
f"got state={slot.fsm_state} closed={slot.closed} "
|
||
|
|
f"entry_order={slot.active_entry_order} exit_order={slot.active_exit_order} "
|
||
|
|
f"size={slot.size}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_cancel_entry_with_partial_fill(self):
|
||
|
|
"""Cancel entry with partial fill should leave slot in correct state."""
|
||
|
|
k = _fresh_kernel(scenario=MockVenueScenario(partial_fill_ratio=0.5))
|
||
|
|
k.process_intent(_mk_intent(action=E.ENTER, trade_id="ce4", size=0.002))
|
||
|
|
slot_after = k._get_slot(0)
|
||
|
|
assert slot_after.size > 0, "Should have partial fill"
|
||
|
|
|
||
|
|
def test_cancel_entry_then_reenter(self):
|
||
|
|
"""After entry cancel, a new ENTER should succeed."""
|
||
|
|
k = _fresh_kernel(scenario=MockVenueScenario(partial_fill_ratio=0.0, emit_fill_on_submit=False))
|
||
|
|
k.process_intent(_mk_intent(action=E.ENTER, trade_id="ce5a"))
|
||
|
|
k.process_intent(_mk_intent(action=E.CANCEL, trade_id="ce5a"))
|
||
|
|
|
||
|
|
r = k.process_intent(_mk_intent(action=E.ENTER, trade_id="ce5b"))
|
||
|
|
assert r.accepted, f"Re-entry after cancel should succeed: {r.diagnostic_code}"
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# FLAW 2: Rust CANCEL_ACK has no entry-order reset path
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
class TestFlaw2CancelAckEntry:
|
||
|
|
"""CANCEL_ACK for entry orders must reset slot to IDLE."""
|
||
|
|
|
||
|
|
def test_cancel_ack_resets_entry_working_to_idle(self):
|
||
|
|
"""When CANCEL_ACK arrives for an entry order, slot goes IDLE."""
|
||
|
|
k = _fresh_kernel(scenario=MockVenueScenario(partial_fill_ratio=0.0, emit_fill_on_submit=False))
|
||
|
|
k.process_intent(_mk_intent(action=E.ENTER, trade_id="ca1"))
|
||
|
|
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.active_entry_order is not None
|
||
|
|
|
||
|
|
venue_order = slot.active_entry_order
|
||
|
|
ack = _mk_venue_event(
|
||
|
|
kind=KernelEventKind.CANCEL_ACK,
|
||
|
|
trade_id="ca1",
|
||
|
|
venue_order_id=venue_order.venue_order_id,
|
||
|
|
venue_client_id=venue_order.venue_client_id,
|
||
|
|
status=VenueEventStatus.CANCELED,
|
||
|
|
)
|
||
|
|
k.on_venue_event(ack)
|
||
|
|
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.fsm_state == TradeStage.IDLE, (
|
||
|
|
f"Slot should be IDLE after CANCEL_ACK on entry, got {slot.fsm_state}"
|
||
|
|
)
|
||
|
|
assert slot.active_entry_order is None, "Entry order should be cleared"
|
||
|
|
assert slot.trade_id == "", "Trade ID should be cleared"
|
||
|
|
assert slot.size == 0.0, "Size should be zero"
|
||
|
|
|
||
|
|
def test_cancel_ack_exit_still_works(self):
|
||
|
|
"""Existing exit-order CANCEL_ACK path must still work.
|
||
|
|
|
||
|
|
Deterministic setup: entry fills fully (POSITION_OPEN) but the exit only
|
||
|
|
partially fills, so the exit order stays live and the CANCEL_ACK exit
|
||
|
|
branch is genuinely exercised (no vacuous guard).
|
||
|
|
"""
|
||
|
|
k = _fresh_kernel(scenario=MockVenueScenario(exit_partial_fill_ratio=0.5))
|
||
|
|
k.process_intent(_mk_intent(action=E.ENTER, trade_id="ca2", size=0.002))
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.fsm_state == TradeStage.POSITION_OPEN, (
|
||
|
|
f"Entry should fill fully, got {slot.fsm_state}"
|
||
|
|
)
|
||
|
|
|
||
|
|
k.process_intent(_mk_intent(action=E.EXIT, trade_id="ca2", size=0.002))
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.active_exit_order is not None, (
|
||
|
|
"Exit order must remain live after a partial exit fill"
|
||
|
|
)
|
||
|
|
ack = _mk_venue_event(
|
||
|
|
kind=KernelEventKind.CANCEL_ACK,
|
||
|
|
trade_id="ca2",
|
||
|
|
venue_order_id=slot.active_exit_order.venue_order_id,
|
||
|
|
venue_client_id=slot.active_exit_order.venue_client_id,
|
||
|
|
status=VenueEventStatus.CANCELED,
|
||
|
|
)
|
||
|
|
k.on_venue_event(ack)
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.active_exit_order is None, "Exit order should be cleared by CANCEL_ACK"
|
||
|
|
assert slot.fsm_state == TradeStage.POSITION_OPEN, (
|
||
|
|
f"Exit cancel must return slot to POSITION_OPEN, got {slot.fsm_state}"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# FLAW 3: Outcome mixes pre/post-venue state
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
class TestFlaw3OutcomeConsistency:
|
||
|
|
"""process_intent outcome should have consistent state and transitions."""
|
||
|
|
|
||
|
|
def test_outcome_state_matches_actual_slot(self):
|
||
|
|
"""The outcome.state should reflect the final state after venue events."""
|
||
|
|
k = _fresh_kernel()
|
||
|
|
result = k.process_intent(_mk_intent(action=E.ENTER, trade_id="oc1"))
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert result.state == slot.fsm_state, (
|
||
|
|
f"Outcome state {result.state} != actual slot state {slot.fsm_state}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_outcome_transitions_includes_venue_events(self):
|
||
|
|
"""Transitions should include venue-event-triggered transitions."""
|
||
|
|
k = _fresh_kernel()
|
||
|
|
result = k.process_intent(_mk_intent(action=E.ENTER, trade_id="oc2"))
|
||
|
|
transition_triggers = [t.trigger for t in result.transitions]
|
||
|
|
assert len(result.transitions) >= 1, (
|
||
|
|
f"Should have at least 1 transition, got triggers: {transition_triggers}"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# FLAW 4: Multi-leg exit final leg can double-close
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
class TestFlaw4DoubleClose:
|
||
|
|
"""Multi-leg exit final leg should only close once."""
|
||
|
|
|
||
|
|
def test_single_close_after_final_leg(self):
|
||
|
|
"""After the last leg fills, slot.closed should be set exactly once."""
|
||
|
|
k = _fresh_kernel(scenario=MockVenueScenario())
|
||
|
|
k.process_intent(
|
||
|
|
_mk_intent(
|
||
|
|
action=E.ENTER,
|
||
|
|
trade_id="dc1",
|
||
|
|
size=0.002,
|
||
|
|
exit_leg_ratios=(0.5, 1.0),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
k.process_intent(
|
||
|
|
_mk_intent(
|
||
|
|
action=E.EXIT,
|
||
|
|
trade_id="dc1",
|
||
|
|
size=0.001,
|
||
|
|
exit_leg_ratios=(0.5, 1.0),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
k.process_intent(
|
||
|
|
_mk_intent(
|
||
|
|
action=E.EXIT,
|
||
|
|
trade_id="dc1",
|
||
|
|
size=0.001,
|
||
|
|
exit_leg_ratios=(1.0,),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.closed, "Slot should be closed after final leg"
|
||
|
|
assert slot.fsm_state == TradeStage.CLOSED
|
||
|
|
|
||
|
|
def test_no_extra_entry_order_clear_on_close(self):
|
||
|
|
"""After close via multi-leg, active_entry_order should be consistent."""
|
||
|
|
k = _fresh_kernel(scenario=MockVenueScenario())
|
||
|
|
k.process_intent(
|
||
|
|
_mk_intent(
|
||
|
|
action=E.ENTER,
|
||
|
|
trade_id="dc2",
|
||
|
|
size=0.002,
|
||
|
|
exit_leg_ratios=(0.5, 1.0),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
k.process_intent(
|
||
|
|
_mk_intent(
|
||
|
|
action=E.EXIT,
|
||
|
|
trade_id="dc2",
|
||
|
|
size=0.001,
|
||
|
|
exit_leg_ratios=(0.5, 1.0),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
k.process_intent(
|
||
|
|
_mk_intent(
|
||
|
|
action=E.EXIT,
|
||
|
|
trade_id="dc2",
|
||
|
|
size=0.001,
|
||
|
|
exit_leg_ratios=(1.0,),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.active_exit_order is None, "Exit order should be cleared"
|
||
|
|
assert slot.active_entry_order is None or slot.active_entry_order.status == VenueOrderStatus.FILLED
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# FLAW 5: Capital settlement only triggers on terminal states
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
class TestFlaw5CapitalSettleOnPartialFill:
|
||
|
|
"""Realized PnL should settle incrementally on partial fills."""
|
||
|
|
|
||
|
|
def test_partial_exit_settles_pnl_incrementally(self):
|
||
|
|
"""Exit fill must settle realized PnL into capital — EXACTLY.
|
||
|
|
|
||
|
|
This is the single most important invariant in DITAv2: capital is
|
||
|
|
the kernel account's authority and must move by precisely the
|
||
|
|
realized PnL of the fill (no balance-poll overwrite). The entry and
|
||
|
|
exit prices differ so realized PnL is strictly nonzero and the
|
||
|
|
capital-change assertion fires unconditionally (no vacuous guard).
|
||
|
|
"""
|
||
|
|
k = _fresh_kernel()
|
||
|
|
cap_before = k.account.snapshot.capital
|
||
|
|
|
||
|
|
# SHORT entry at 100.
|
||
|
|
k.process_intent(
|
||
|
|
_mk_intent(action=E.ENTER, trade_id="ps1", side=TradeSide.SHORT, price=100.0, size=0.002)
|
||
|
|
)
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.fsm_state == TradeStage.POSITION_OPEN
|
||
|
|
|
||
|
|
# Exit at 90 -> SHORT closes in profit, realized PnL strictly positive.
|
||
|
|
k.process_intent(
|
||
|
|
_mk_intent(action=E.EXIT, trade_id="ps1", side=TradeSide.SHORT, price=90.0, size=0.002)
|
||
|
|
)
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
|
||
|
|
assert slot.realized_pnl > 0.0, (
|
||
|
|
f"SHORT exit below entry must realize positive PnL, got {slot.realized_pnl}"
|
||
|
|
)
|
||
|
|
cap_after = k.account.snapshot.capital
|
||
|
|
# Single-authority invariant: capital moved by EXACTLY realized PnL.
|
||
|
|
assert abs((cap_after - cap_before) - slot.realized_pnl) < 1e-9, (
|
||
|
|
f"Capital delta {cap_after - cap_before} != realized_pnl {slot.realized_pnl} "
|
||
|
|
f"(before={cap_before} after={cap_after})"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# FLAW 6: _legacy_intent silently drops order_type and limit_price
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
class TestFlaw6LegacyIntentDrop:
|
||
|
|
"""_legacy_intent must preserve order_type and limit_price."""
|
||
|
|
|
||
|
|
def test_legacy_intent_preserves_order_type(self):
|
||
|
|
"""LegacyIntent conversion must include order_type."""
|
||
|
|
from prod.clean_arch.dita_v2.bingx_venue import BingxVenueAdapter
|
||
|
|
|
||
|
|
intent = _mk_intent(
|
||
|
|
action=E.ENTER,
|
||
|
|
trade_id="li1",
|
||
|
|
order_type="LIMIT",
|
||
|
|
limit_price=50000.0,
|
||
|
|
)
|
||
|
|
legacy = BingxVenueAdapter._legacy_intent(intent)
|
||
|
|
|
||
|
|
assert getattr(legacy, "order_type", None) == "LIMIT" or \
|
||
|
|
legacy.metadata.get("_order_type") == "LIMIT" or \
|
||
|
|
legacy.metadata.get("order_type") == "LIMIT", (
|
||
|
|
f"order_type not preserved in legacy intent. "
|
||
|
|
f"Legacy fields: {dir(legacy)}, metadata: {legacy.metadata}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_legacy_intent_preserves_limit_price(self):
|
||
|
|
"""LegacyIntent conversion must include limit_price."""
|
||
|
|
from prod.clean_arch.dita_v2.bingx_venue import BingxVenueAdapter
|
||
|
|
|
||
|
|
intent = _mk_intent(
|
||
|
|
action=E.ENTER,
|
||
|
|
trade_id="li2",
|
||
|
|
order_type="LIMIT",
|
||
|
|
limit_price=50000.0,
|
||
|
|
)
|
||
|
|
legacy = BingxVenueAdapter._legacy_intent(intent)
|
||
|
|
|
||
|
|
assert getattr(legacy, "limit_price", 0) == 50000.0 or \
|
||
|
|
legacy.metadata.get("_limit_price") == 50000.0 or \
|
||
|
|
legacy.metadata.get("limit_price") == 50000.0, (
|
||
|
|
f"limit_price not preserved in legacy intent. "
|
||
|
|
f"Legacy metadata: {legacy.metadata}"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# FLAW 7: Mock venue partial_fill_ratio applies to both entry and exit
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
class TestFlaw7MockVenueRatios:
|
||
|
|
"""Mock venue should support different ratios for entry vs exit."""
|
||
|
|
|
||
|
|
def test_entry_exit_different_ratios(self):
|
||
|
|
"""Entry can fill fully while exit fills partially."""
|
||
|
|
k = _fresh_kernel(scenario=MockVenueScenario(
|
||
|
|
entry_partial_fill_ratio=1.0,
|
||
|
|
exit_partial_fill_ratio=0.5,
|
||
|
|
))
|
||
|
|
r = k.process_intent(_mk_intent(action=E.ENTER, trade_id="mv1", size=0.002))
|
||
|
|
assert r.accepted
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.fsm_state == TradeStage.POSITION_OPEN, f"Entry should fill fully: {slot.fsm_state}"
|
||
|
|
|
||
|
|
def test_per_action_type_ratios(self):
|
||
|
|
"""entry_partial_fill_ratio and exit_partial_fill_ratio should work independently."""
|
||
|
|
scenario = MockVenueScenario(
|
||
|
|
entry_partial_fill_ratio=1.0,
|
||
|
|
exit_partial_fill_ratio=0.3,
|
||
|
|
)
|
||
|
|
k = _fresh_kernel(scenario=scenario)
|
||
|
|
k.process_intent(_mk_intent(action=E.ENTER, trade_id="mv2", size=0.001))
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.fsm_state == TradeStage.POSITION_OPEN
|
||
|
|
assert slot.size == 0.001
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# FLAW 8: Per-asset price precision helper does not exist
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
class TestFlaw8PricePrecision:
|
||
|
|
"""_format_price must exist for LIMIT order support."""
|
||
|
|
|
||
|
|
def test_format_price_exists_in_bingx_direct(self):
|
||
|
|
"""BingxDirectExecutionAdapter should have _format_price method."""
|
||
|
|
try:
|
||
|
|
from prod.clean_arch.adapters.bingx_direct import BingxDirectExecutionAdapter
|
||
|
|
assert hasattr(BingxDirectExecutionAdapter, "_format_price"), (
|
||
|
|
"_format_price method missing from BingxDirectExecutionAdapter"
|
||
|
|
)
|
||
|
|
except ImportError:
|
||
|
|
pytest.skip("bingx_direct not importable in this environment")
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# FLAW 9: Cancel path falls back to trade_id as symbol
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
class TestFlaw9CancelSymbolFallback:
|
||
|
|
"""Cancel should use correct asset, not trade_id as fallback symbol."""
|
||
|
|
|
||
|
|
def test_cancel_uses_slot_asset_not_trade_id(self):
|
||
|
|
"""When cancel is called, the asset should come from the slot, not trade_id."""
|
||
|
|
k = _fresh_kernel(scenario=MockVenueScenario(partial_fill_ratio=0.0, emit_fill_on_submit=False))
|
||
|
|
k.process_intent(_mk_intent(action=E.ENTER, trade_id="cs1", asset="TRXUSDT"))
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
|
||
|
|
# ACK-only (no fill) deterministically leaves the entry order live.
|
||
|
|
assert slot.active_entry_order is not None, (
|
||
|
|
"ACK-only entry must leave the entry order live for cancel-symbol fallback"
|
||
|
|
)
|
||
|
|
metadata = slot.active_entry_order.metadata
|
||
|
|
assert metadata.get("asset") == "TRXUSDT", (
|
||
|
|
f"Entry order metadata should contain asset. Got: {metadata}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_mock_venue_cancel_event_has_asset(self):
|
||
|
|
"""Mock venue cancel events should carry the correct asset."""
|
||
|
|
k = _fresh_kernel(scenario=MockVenueScenario(partial_fill_ratio=0.0, emit_fill_on_submit=False))
|
||
|
|
k.process_intent(_mk_intent(action=E.ENTER, trade_id="cs2", asset="XRPUSDT"))
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
order = slot.active_entry_order
|
||
|
|
assert order is not None
|
||
|
|
assert order.metadata.get("asset") is not None or order.metadata.get("slot_id") is not None
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# FLAW 10: Event dedup window is bounded at 64
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
class TestFlaw10EventDedup:
|
||
|
|
"""Event dedup window should be large enough for realistic workloads."""
|
||
|
|
|
||
|
|
def test_dedup_window_accepts_many_events(self):
|
||
|
|
"""A slot should handle > 64 events without dedup eviction."""
|
||
|
|
k = _fresh_kernel()
|
||
|
|
k.process_intent(_mk_intent(action=E.ENTER, trade_id="ed1"))
|
||
|
|
|
||
|
|
for i in range(70):
|
||
|
|
ev = _mk_venue_event(
|
||
|
|
kind=KernelEventKind.MARK_PRICE,
|
||
|
|
trade_id="ed1",
|
||
|
|
event_id=f"mp-{i:04d}",
|
||
|
|
price=100.0 + i * 0.01,
|
||
|
|
size=0.0,
|
||
|
|
filled_size=0.0,
|
||
|
|
)
|
||
|
|
k.on_venue_event(ev)
|
||
|
|
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert len(slot.seen_event_ids) >= 70, (
|
||
|
|
f"Expected >= 70 seen_event_ids, got {len(slot.seen_event_ids)}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_dedup_eviction_does_not_accept_old_event(self):
|
||
|
|
"""Evicted event IDs should still be rejected (with larger window)."""
|
||
|
|
k = _fresh_kernel()
|
||
|
|
k.process_intent(_mk_intent(action=E.ENTER, trade_id="ed2"))
|
||
|
|
|
||
|
|
for i in range(70):
|
||
|
|
ev = _mk_venue_event(
|
||
|
|
kind=KernelEventKind.MARK_PRICE,
|
||
|
|
trade_id="ed2",
|
||
|
|
event_id=f"mp2-{i:04d}",
|
||
|
|
price=100.0 + i * 0.01,
|
||
|
|
size=0.0,
|
||
|
|
filled_size=0.0,
|
||
|
|
)
|
||
|
|
k.on_venue_event(ev)
|
||
|
|
|
||
|
|
old_ev = _mk_venue_event(
|
||
|
|
kind=KernelEventKind.MARK_PRICE,
|
||
|
|
trade_id="ed2",
|
||
|
|
event_id="mp2-0000",
|
||
|
|
price=99.0,
|
||
|
|
size=0.0,
|
||
|
|
filled_size=0.0,
|
||
|
|
)
|
||
|
|
result = k.on_venue_event(old_ev)
|
||
|
|
assert result.diagnostic_code == KernelDiagnosticCode.DUPLICATE_EVENT, (
|
||
|
|
f"Old evicted event should still be deduplicated, "
|
||
|
|
f"got {result.diagnostic_code}"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# FLAW 11: Reconcile is a raw state override with no FSM validation
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
class TestFlaw11ReconcileValidation:
|
||
|
|
"""Reconcile should validate slot state consistency."""
|
||
|
|
|
||
|
|
def test_reconcile_rejects_position_open_with_zero_size(self):
|
||
|
|
"""Reconciling with POSITION_OPEN but zero size should be rejected."""
|
||
|
|
k = _fresh_kernel()
|
||
|
|
bad_slot = TradeSlot(
|
||
|
|
slot_id=0,
|
||
|
|
fsm_state=TradeStage.POSITION_OPEN,
|
||
|
|
size=0.0,
|
||
|
|
asset="BTCUSDT",
|
||
|
|
trade_id="bad1",
|
||
|
|
)
|
||
|
|
result = k.reconcile_from_slots([bad_slot])
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.fsm_state != TradeStage.POSITION_OPEN or slot.size > 0, (
|
||
|
|
f"Reconcile should reject POSITION_OPEN with size=0, "
|
||
|
|
f"got state={slot.fsm_state} size={slot.size}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_reconcile_rejects_idle_with_nonzero_size(self):
|
||
|
|
"""Reconciling with IDLE but nonzero size should be rejected."""
|
||
|
|
k = _fresh_kernel()
|
||
|
|
bad_slot = TradeSlot(
|
||
|
|
slot_id=0,
|
||
|
|
fsm_state=TradeStage.IDLE,
|
||
|
|
size=5.0,
|
||
|
|
asset="BTCUSDT",
|
||
|
|
trade_id="bad2",
|
||
|
|
)
|
||
|
|
result = k.reconcile_from_slots([bad_slot])
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.size == 0.0 or slot.fsm_state != TradeStage.IDLE, (
|
||
|
|
f"Reconcile should reject IDLE with size > 0, "
|
||
|
|
f"got state={slot.fsm_state} size={slot.size}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_reconcile_accepts_valid_slot(self):
|
||
|
|
"""Valid slot data should still reconcile correctly."""
|
||
|
|
k = _fresh_kernel()
|
||
|
|
k.process_intent(_mk_intent(action=E.ENTER, trade_id="rv1"))
|
||
|
|
slot_data = k._get_slot(0)
|
||
|
|
result = k.reconcile_from_slots([slot_data])
|
||
|
|
assert result.accepted
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# FLAW 12: Outcome transitions are incomplete — pre-venue only
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
class TestFlaw12OutcomeTransitions:
|
||
|
|
"""process_intent outcome transitions should include venue event transitions."""
|
||
|
|
|
||
|
|
def test_transitions_include_post_venue(self):
|
||
|
|
"""After a full entry cycle, transitions should include ORDER_ACK and FULL_FILL."""
|
||
|
|
k = _fresh_kernel()
|
||
|
|
result = k.process_intent(_mk_intent(action=E.ENTER, trade_id="ot1"))
|
||
|
|
triggers = [t.trigger for t in result.transitions]
|
||
|
|
assert any(t in triggers for t in ["ENTER_INTENT", "ORDER_ACK", "FULL_FILL"]), (
|
||
|
|
f"Transitions should include venue event triggers. Got: {triggers}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_transitions_count_matches_lifecycle(self):
|
||
|
|
"""Full entry lifecycle should produce multiple transitions."""
|
||
|
|
k = _fresh_kernel()
|
||
|
|
result = k.process_intent(_mk_intent(action=E.ENTER, trade_id="ot2"))
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.fsm_state in {TradeStage.POSITION_OPEN, TradeStage.ENTRY_WORKING}, (
|
||
|
|
f"Default full-fill entry must open the position, got {slot.fsm_state}"
|
||
|
|
)
|
||
|
|
assert len(result.transitions) >= 2, (
|
||
|
|
f"Full entry should produce >= 2 transitions "
|
||
|
|
f"(intent + venue ack/fill), got {len(result.transitions)}: "
|
||
|
|
f"{[t.trigger for t in result.transitions]}"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# FLAW 13: Unsettled realized PnL on re-entry
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
class TestFlaw13UnsettledPnlOnReentry:
|
||
|
|
"""Re-entry should not silently discard unrealized settled PnL."""
|
||
|
|
|
||
|
|
def test_reentry_after_full_close_no_pnl_loss(self):
|
||
|
|
"""After full close and settle, re-entry should not lose PnL."""
|
||
|
|
k = _fresh_kernel()
|
||
|
|
cap_before = k.account.snapshot.capital
|
||
|
|
|
||
|
|
k.process_intent(_mk_intent(action=E.ENTER, trade_id="rp1"))
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.fsm_state == TradeStage.POSITION_OPEN
|
||
|
|
|
||
|
|
k.process_intent(
|
||
|
|
_mk_intent(action=E.EXIT, trade_id="rp1", price=100.5)
|
||
|
|
)
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.is_free()
|
||
|
|
|
||
|
|
cap_after_first = k.account.snapshot.capital
|
||
|
|
|
||
|
|
k.process_intent(_mk_intent(action=E.ENTER, trade_id="rp2"))
|
||
|
|
k.process_intent(
|
||
|
|
_mk_intent(action=E.EXIT, trade_id="rp2", price=101.0)
|
||
|
|
)
|
||
|
|
|
||
|
|
cap_after_second = k.account.snapshot.capital
|
||
|
|
assert cap_after_second > 0, "Capital should remain positive"
|
||
|
|
assert abs(cap_after_second - cap_before) < cap_before * 0.5
|
||
|
|
|
||
|
|
def test_pnl_warning_on_unsettled_reentry(self):
|
||
|
|
"""Re-entry on a slot with unsettled PnL should at least warn."""
|
||
|
|
k = _fresh_kernel(scenario=MockVenueScenario())
|
||
|
|
k.process_intent(_mk_intent(action=E.ENTER, trade_id="rw1"))
|
||
|
|
k.process_intent(_mk_intent(action=E.EXIT, trade_id="rw1"))
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.is_free(), "Full close must free the slot for re-entry"
|
||
|
|
r = k.process_intent(_mk_intent(action=E.ENTER, trade_id="rw2"))
|
||
|
|
assert r.accepted, "Re-entry on a freed slot must be accepted"
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# REGRESSION: Existing behaviour must not break
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
class TestRegression:
|
||
|
|
"""Ensure existing happy-path scenarios still work."""
|
||
|
|
|
||
|
|
def test_basic_entry_exit(self):
|
||
|
|
k = _fresh_kernel()
|
||
|
|
cap_before = k.account.snapshot.capital
|
||
|
|
r1 = k.process_intent(_mk_intent(action=E.ENTER, trade_id="re1"))
|
||
|
|
assert r1.accepted
|
||
|
|
r2 = k.process_intent(_mk_intent(action=E.EXIT, trade_id="re1"))
|
||
|
|
assert r2.accepted
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.is_free()
|
||
|
|
|
||
|
|
def test_multi_leg_exit(self):
|
||
|
|
k = _fresh_kernel()
|
||
|
|
k.process_intent(
|
||
|
|
_mk_intent(action=E.ENTER, trade_id="re2", size=0.002, exit_leg_ratios=(0.5, 1.0))
|
||
|
|
)
|
||
|
|
k.process_intent(
|
||
|
|
_mk_intent(action=E.EXIT, trade_id="re2", size=0.001, exit_leg_ratios=(0.5, 1.0))
|
||
|
|
)
|
||
|
|
k.process_intent(
|
||
|
|
_mk_intent(action=E.EXIT, trade_id="re2", size=0.001, exit_leg_ratios=(1.0,))
|
||
|
|
)
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.is_free()
|
||
|
|
|
||
|
|
def test_slot_busy_rejection(self):
|
||
|
|
k = _fresh_kernel()
|
||
|
|
r1 = k.process_intent(_mk_intent(action=E.ENTER, trade_id="re3a"))
|
||
|
|
assert r1.accepted
|
||
|
|
r2 = k.process_intent(_mk_intent(action=E.ENTER, trade_id="re3b"))
|
||
|
|
assert not r2.accepted
|
||
|
|
assert r2.diagnostic_code == KernelDiagnosticCode.SLOT_BUSY
|
||
|
|
|
||
|
|
def test_exit_on_idle_rejected(self):
|
||
|
|
k = _fresh_kernel()
|
||
|
|
r = k.process_intent(_mk_intent(action=E.EXIT, trade_id="re4"))
|
||
|
|
assert not r.accepted
|
||
|
|
|
||
|
|
def test_reconcile_preserves_state(self):
|
||
|
|
k = _fresh_kernel()
|
||
|
|
k.process_intent(_mk_intent(action=E.ENTER, trade_id="re5"))
|
||
|
|
slot_data = k._get_slot(0)
|
||
|
|
k.reconcile_from_slots([slot_data])
|
||
|
|
slot_after = k._get_slot(0)
|
||
|
|
assert slot_after.trade_id == "re5"
|
||
|
|
|
||
|
|
def test_dedup_duplicate_event(self):
|
||
|
|
k = _fresh_kernel()
|
||
|
|
k.process_intent(_mk_intent(action=E.ENTER, trade_id="re6"))
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
dup = _mk_venue_event(
|
||
|
|
kind=KernelEventKind.FULL_FILL,
|
||
|
|
trade_id="re6",
|
||
|
|
event_id="dedup-regression",
|
||
|
|
price=100.0,
|
||
|
|
size=1.0,
|
||
|
|
filled_size=1.0,
|
||
|
|
)
|
||
|
|
k.on_venue_event(dup)
|
||
|
|
result = k.on_venue_event(dup)
|
||
|
|
assert result.diagnostic_code == KernelDiagnosticCode.DUPLICATE_EVENT
|
||
|
|
|
||
|
|
def test_ten_cycles_no_leak(self):
|
||
|
|
k = _fresh_kernel()
|
||
|
|
for i in range(10):
|
||
|
|
k.process_intent(_mk_intent(action=E.ENTER, trade_id=f"tc{i}"))
|
||
|
|
k.process_intent(_mk_intent(action=E.EXIT, trade_id=f"tc{i}"))
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.is_free()
|
||
|
|
assert k.account.snapshot.capital > 0
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# I15: CANCEL_REJECT must un-stick EXIT_WORKING slot
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
class TestI15CancelRejectUnstick:
|
||
|
|
"""CANCEL_REJECT on an exit order must clear active_exit_order and return
|
||
|
|
the slot to POSITION_OPEN so the algo can retry the exit."""
|
||
|
|
|
||
|
|
def _enter_to_position_open(self, k: ExecutionKernel, trade_id: str) -> None:
|
||
|
|
r = k.process_intent(_mk_intent(action=E.ENTER, trade_id=trade_id))
|
||
|
|
assert r.accepted, f"ENTER rejected: {r.diagnostic_code}"
|
||
|
|
slot = k._get_slot(0)
|
||
|
|
assert slot.fsm_state == TradeStage.POSITION_OPEN, (
|
||
|
|
f"Expected POSITION_OPEN after ENTER, got {slot.fsm_state}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_cancel_reject_exits_working_returns_to_position_open(self):
|
||
|
|
"""Core I15 regression: CANCEL_REJECT on EXIT_WORKING must unstick slot."""
|
||
|
|
# partial_fill_ratio=0 prevents fills on submit; fills are injected manually.
|
||
|
|
k_no_fill = _fresh_kernel(scenario=MockVenueScenario(partial_fill_ratio=0.0, emit_fill_on_submit=False))
|
||
|
|
k_no_fill.process_intent(_mk_intent(action=E.ENTER, trade_id="i15b"))
|
||
|
|
# Manually force POSITION_OPEN by injecting FULL_FILL
|
||
|
|
fill = _mk_venue_event(
|
||
|
|
kind=KernelEventKind.FULL_FILL,
|
||
|
|
trade_id="i15b",
|
||
|
|
event_id="fill-i15b",
|
||
|
|
price=100.0,
|
||
|
|
size=1.0,
|
||
|
|
filled_size=1.0,
|
||
|
|
)
|
||
|
|
k_no_fill.on_venue_event(fill)
|
||
|
|
slot = k_no_fill._get_slot(0)
|
||
|
|
assert slot.fsm_state == TradeStage.POSITION_OPEN, (
|
||
|
|
f"Setup failed: expected POSITION_OPEN, got {slot.fsm_state}"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Submit exit (no fill emitted) — slot enters EXIT_WORKING
|
||
|
|
k_no_fill.process_intent(_mk_intent(action=E.EXIT, trade_id="i15b"))
|
||
|
|
slot = k_no_fill._get_slot(0)
|
||
|
|
assert slot.fsm_state in (TradeStage.EXIT_WORKING, TradeStage.EXIT_REQUESTED, TradeStage.EXIT_SENT), (
|
||
|
|
f"Setup failed: expected an exit state, got {slot.fsm_state}"
|
||
|
|
)
|
||
|
|
assert slot.active_exit_order is not None, "Setup: active_exit_order should be set"
|
||
|
|
|
||
|
|
# Now deliver CANCEL_REJECT
|
||
|
|
cancel_rej = _mk_venue_event(
|
||
|
|
kind=KernelEventKind.CANCEL_REJECT,
|
||
|
|
trade_id="i15b",
|
||
|
|
event_id="cr-i15b",
|
||
|
|
status=VenueEventStatus.CANCELED,
|
||
|
|
)
|
||
|
|
result = k_no_fill.on_venue_event(cancel_rej)
|
||
|
|
|
||
|
|
slot = k_no_fill._get_slot(0)
|
||
|
|
assert slot.fsm_state == TradeStage.POSITION_OPEN, (
|
||
|
|
f"I15: slot must return to POSITION_OPEN after CANCEL_REJECT, got {slot.fsm_state}"
|
||
|
|
)
|
||
|
|
assert slot.active_exit_order is None, (
|
||
|
|
"I15: active_exit_order must be cleared by CANCEL_REJECT"
|
||
|
|
)
|
||
|
|
assert result.diagnostic_code == KernelDiagnosticCode.CANCEL_REJECTED
|
||
|
|
|
||
|
|
def test_after_cancel_reject_exit_can_be_resubmitted(self):
|
||
|
|
"""After CANCEL_REJECT un-sticks the slot, a new EXIT must be accepted."""
|
||
|
|
k = _fresh_kernel(scenario=MockVenueScenario(partial_fill_ratio=0.0, emit_fill_on_submit=False))
|
||
|
|
k.process_intent(_mk_intent(action=E.ENTER, trade_id="i15c"))
|
||
|
|
fill = _mk_venue_event(
|
||
|
|
kind=KernelEventKind.FULL_FILL,
|
||
|
|
trade_id="i15c",
|
||
|
|
event_id="fill-i15c",
|
||
|
|
price=100.0,
|
||
|
|
size=1.0,
|
||
|
|
filled_size=1.0,
|
||
|
|
)
|
||
|
|
k.on_venue_event(fill)
|
||
|
|
|
||
|
|
k.process_intent(_mk_intent(action=E.EXIT, trade_id="i15c"))
|
||
|
|
cancel_rej = _mk_venue_event(
|
||
|
|
kind=KernelEventKind.CANCEL_REJECT,
|
||
|
|
trade_id="i15c",
|
||
|
|
event_id="cr-i15c",
|
||
|
|
status=VenueEventStatus.CANCELED,
|
||
|
|
)
|
||
|
|
k.on_venue_event(cancel_rej)
|
||
|
|
|
||
|
|
# Slot is back to POSITION_OPEN — a new EXIT intent must be accepted
|
||
|
|
r = k.process_intent(_mk_intent(action=E.EXIT, trade_id="i15c"))
|
||
|
|
assert r.accepted, (
|
||
|
|
f"I15: retry EXIT after CANCEL_REJECT must be accepted, got {r.diagnostic_code}"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# O5: _run() thread-pool path must time out, not hang forever
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
class TestO5RunTimeout:
|
||
|
|
"""O5: BingxVenueAdapter._run() must raise TimeoutError instead of freezing
|
||
|
|
when the backend call exceeds the configured deadline."""
|
||
|
|
|
||
|
|
def test_run_raises_timeout_from_async_context(self, monkeypatch):
|
||
|
|
"""When called from inside an event loop and the backend is slow,
|
||
|
|
_run() must raise TimeoutError within the configured deadline."""
|
||
|
|
from prod.clean_arch.dita_v2.bingx_venue import BingxVenueAdapter
|
||
|
|
import asyncio
|
||
|
|
|
||
|
|
adapter = object.__new__(BingxVenueAdapter)
|
||
|
|
# Patch to a very short deadline so the test completes fast.
|
||
|
|
monkeypatch.setattr(BingxVenueAdapter, "_BACKEND_TIMEOUT_S", 0.15)
|
||
|
|
|
||
|
|
async def _slow_coroutine():
|
||
|
|
await asyncio.sleep(5.0)
|
||
|
|
return "never"
|
||
|
|
|
||
|
|
async def _run_from_async():
|
||
|
|
with pytest.raises(TimeoutError):
|
||
|
|
adapter._run(_slow_coroutine())
|
||
|
|
|
||
|
|
asyncio.run(_run_from_async())
|
||
|
|
|
||
|
|
def test_run_returns_normally_within_deadline(self, monkeypatch):
|
||
|
|
"""Fast backend calls must succeed and return their value."""
|
||
|
|
from prod.clean_arch.dita_v2.bingx_venue import BingxVenueAdapter
|
||
|
|
import asyncio
|
||
|
|
|
||
|
|
adapter = object.__new__(BingxVenueAdapter)
|
||
|
|
monkeypatch.setattr(BingxVenueAdapter, "_BACKEND_TIMEOUT_S", 2.0)
|
||
|
|
|
||
|
|
async def _fast_coroutine():
|
||
|
|
return 42
|
||
|
|
|
||
|
|
async def _run_from_async():
|
||
|
|
result = adapter._run(_fast_coroutine())
|
||
|
|
assert result == 42
|
||
|
|
|
||
|
|
asyncio.run(_run_from_async())
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# O1: _maybe_close() must not silently skip close from async context
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
class TestO1MaybeCloseAsyncSafe:
|
||
|
|
"""O1: _maybe_close() must run the coroutine even when called from an
|
||
|
|
async context (previously it swallowed RuntimeError and skipped close)."""
|
||
|
|
|
||
|
|
def test_maybe_close_from_sync_context(self):
|
||
|
|
"""Sync caller: asyncio.run() path must run the close coroutine."""
|
||
|
|
from prod.clean_arch.dita_v2.launcher import _maybe_close
|
||
|
|
|
||
|
|
closed = []
|
||
|
|
|
||
|
|
class _FakeAsync:
|
||
|
|
async def close(self) -> None:
|
||
|
|
closed.append(True)
|
||
|
|
|
||
|
|
_maybe_close(_FakeAsync())
|
||
|
|
assert closed == [True], "close() coroutine must run from sync context"
|
||
|
|
|
||
|
|
def test_maybe_close_from_async_context(self):
|
||
|
|
"""Async caller: thread-pool path must run the close coroutine without
|
||
|
|
raising RuntimeError (the old silent-skip bug)."""
|
||
|
|
from prod.clean_arch.dita_v2.launcher import _maybe_close
|
||
|
|
|
||
|
|
closed = []
|
||
|
|
|
||
|
|
class _FakeAsync:
|
||
|
|
async def close(self) -> None:
|
||
|
|
closed.append(True)
|
||
|
|
|
||
|
|
async def _caller():
|
||
|
|
_maybe_close(_FakeAsync())
|
||
|
|
|
||
|
|
asyncio.run(_caller())
|
||
|
|
assert closed == [True], "close() coroutine must run from async context"
|
||
|
|
|
||
|
|
def test_maybe_close_sync_method_still_works(self):
|
||
|
|
"""Non-coroutine close() must still be called (no regression)."""
|
||
|
|
from prod.clean_arch.dita_v2.launcher import _maybe_close
|
||
|
|
|
||
|
|
closed = []
|
||
|
|
|
||
|
|
class _FakeSync:
|
||
|
|
def close(self) -> None:
|
||
|
|
closed.append(True)
|
||
|
|
|
||
|
|
_maybe_close(_FakeSync())
|
||
|
|
assert closed == [True], "sync close() must still be called"
|