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:
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()
|
||||
Reference in New Issue
Block a user