feat: add Quant OS A-share baseline
This commit is contained in:
@@ -0,0 +1,859 @@
|
||||
import datetime as dt
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from adapters.xttrader_live import (
|
||||
IdempotencyConflict,
|
||||
LiveOrderBlocked,
|
||||
NotConnectedError,
|
||||
ReconnectFailed,
|
||||
XtTraderLiveAdapter,
|
||||
map_order_status,
|
||||
)
|
||||
|
||||
|
||||
class Constants:
|
||||
STOCK_BUY = 23
|
||||
STOCK_SELL = 24
|
||||
FIX_PRICE = 11
|
||||
|
||||
|
||||
class FakeTrader:
|
||||
def __init__(self, connect_code=0, orders=None):
|
||||
self.connect_code = connect_code
|
||||
self.callback = None
|
||||
self.started = False
|
||||
self.stopped = False
|
||||
self.order_calls = []
|
||||
self.cancel_calls = []
|
||||
self.orders = list(orders or [])
|
||||
|
||||
def register_callback(self, callback):
|
||||
self.callback = callback
|
||||
|
||||
def start(self):
|
||||
self.started = True
|
||||
|
||||
def connect(self):
|
||||
return self.connect_code
|
||||
|
||||
def subscribe(self, account):
|
||||
self.account = account
|
||||
return 0
|
||||
|
||||
def stop(self):
|
||||
self.stopped = True
|
||||
|
||||
def query_stock_asset(self, account):
|
||||
del account
|
||||
return SimpleNamespace(
|
||||
account_id="test", cash=10_000, market_value=20_000, total_asset=30_000
|
||||
)
|
||||
|
||||
def query_stock_orders(self, account, cancelable_only=False):
|
||||
del account, cancelable_only
|
||||
return list(self.orders)
|
||||
|
||||
def query_stock_positions(self, account):
|
||||
del account
|
||||
return [
|
||||
SimpleNamespace(
|
||||
account_id="test",
|
||||
stock_code="600000.SH",
|
||||
volume=1000,
|
||||
can_use_volume=700,
|
||||
market_value=10_000,
|
||||
open_price=10,
|
||||
)
|
||||
]
|
||||
|
||||
def query_stock_trades(self, account):
|
||||
del account
|
||||
return []
|
||||
|
||||
def order_stock_async(self, *args):
|
||||
self.order_calls.append(args)
|
||||
return 101
|
||||
|
||||
def cancel_order_stock(self, account, order_id):
|
||||
self.cancel_calls.append((account, order_id))
|
||||
return 0
|
||||
|
||||
|
||||
def passing_live_decision(**overrides):
|
||||
decision = {
|
||||
"authorization_id": "auth-test-001",
|
||||
"snapshot_id": "snapshot-test-001",
|
||||
"as_of": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
"reconciliation_safe": True,
|
||||
"account_allowed": True,
|
||||
"within_notional_limit": True,
|
||||
"within_order_rate_limit": True,
|
||||
"operator_approved": True,
|
||||
"kill_switch_ready": True,
|
||||
"compliance_approved": True,
|
||||
}
|
||||
decision.update(overrides)
|
||||
return decision
|
||||
|
||||
|
||||
def adapter_for(trader, allow=False, live_guard=None):
|
||||
if allow is True and live_guard is None:
|
||||
live_guard = lambda intent: passing_live_decision()
|
||||
return XtTraderLiveAdapter(
|
||||
trader_factory=lambda: trader,
|
||||
account=SimpleNamespace(account_id="test"),
|
||||
constants=Constants,
|
||||
allow_live_orders=allow,
|
||||
live_guard=live_guard,
|
||||
)
|
||||
|
||||
|
||||
class XtTraderLiveAdapterTest(unittest.TestCase):
|
||||
def test_real_status_mapping_uses_domain_spelling(self):
|
||||
self.assertEqual(map_order_status(48), "NEW")
|
||||
self.assertEqual(map_order_status(50), "ACCEPTED")
|
||||
self.assertEqual(map_order_status(51), "CANCEL_PENDING")
|
||||
self.assertEqual(map_order_status(54), "CANCELLED")
|
||||
self.assertEqual(map_order_status(55), "PARTIALLY_FILLED")
|
||||
self.assertEqual(map_order_status(56), "FILLED")
|
||||
self.assertEqual(map_order_status(57), "REJECTED")
|
||||
self.assertEqual(map_order_status(999), "UNKNOWN")
|
||||
|
||||
def test_default_is_dry_run_and_idempotent_without_broker_call(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader)
|
||||
first = adapter.submit_order(
|
||||
client_order_id="decision-001",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
)
|
||||
second = adapter.submit_order(
|
||||
client_order_id="decision-001",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
)
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(first["state"], "DRY_RUN")
|
||||
self.assertEqual(trader.order_calls, [])
|
||||
|
||||
def test_allow_live_orders_requires_a_real_bool(self):
|
||||
trader = FakeTrader()
|
||||
for value in ("False", "true", 1, None):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaisesRegex(TypeError, "must be a bool"):
|
||||
adapter_for(trader, allow=value)
|
||||
self.assertEqual(trader.order_calls, [])
|
||||
|
||||
def test_live_mode_requires_callable_guard_but_dry_run_does_not(self):
|
||||
trader = FakeTrader()
|
||||
dry_run = XtTraderLiveAdapter(
|
||||
trader_factory=lambda: trader,
|
||||
account=SimpleNamespace(account_id="test"),
|
||||
constants=Constants,
|
||||
)
|
||||
self.assertFalse(dry_run.allow_live_orders)
|
||||
with self.assertRaisesRegex(TypeError, "requires a callable live_guard"):
|
||||
XtTraderLiveAdapter(
|
||||
trader_factory=lambda: trader,
|
||||
account=SimpleNamespace(account_id="test"),
|
||||
constants=Constants,
|
||||
allow_live_orders=True,
|
||||
)
|
||||
with self.assertRaisesRegex(TypeError, "live_guard must be callable"):
|
||||
XtTraderLiveAdapter(
|
||||
trader_factory=lambda: trader,
|
||||
account=SimpleNamespace(account_id="test"),
|
||||
constants=Constants,
|
||||
allow_live_orders=True,
|
||||
live_guard="allow",
|
||||
)
|
||||
|
||||
def test_idempotency_key_conflict_is_rejected(self):
|
||||
adapter = adapter_for(FakeTrader())
|
||||
adapter.submit_order(
|
||||
client_order_id="decision-001",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
)
|
||||
with self.assertRaises(IdempotencyConflict):
|
||||
adapter.submit_order(
|
||||
client_order_id="decision-001",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=200,
|
||||
limit_price=10.5,
|
||||
)
|
||||
|
||||
def test_non_finite_live_price_is_rejected_before_broker_call(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
for value in (float("nan"), float("inf"), float("-inf")):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaisesRegex(ValueError, "finite and positive"):
|
||||
adapter.submit_order(
|
||||
client_order_id="bad-price",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=value,
|
||||
confirm_live=True,
|
||||
)
|
||||
self.assertEqual(trader.order_calls, [])
|
||||
|
||||
def test_limit_price_rejects_bool_non_real_and_non_positive(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
for value in (True, False, "10.5", None, 10 + 0j, 0, -1):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaisesRegex(ValueError, "limit_price"):
|
||||
adapter.submit_order(
|
||||
client_order_id="strict-price",
|
||||
symbol="600000.SH",
|
||||
side="SELL",
|
||||
quantity=100,
|
||||
limit_price=value,
|
||||
confirm_live=True,
|
||||
)
|
||||
self.assertEqual(trader.order_calls, [])
|
||||
|
||||
def test_submit_confirm_live_requires_a_real_bool(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
for value in ("False", "true", 1, None):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaisesRegex(TypeError, "must be a bool"):
|
||||
adapter.submit_order(
|
||||
client_order_id="strict-confirm",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=value,
|
||||
)
|
||||
self.assertEqual(trader.order_calls, [])
|
||||
|
||||
def test_quantity_rejects_bool_fraction_float_and_non_positive(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
for value in (True, False, 100.0, 100.5, "100", None, 0, -100):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaisesRegex(ValueError, "positive integer"):
|
||||
adapter.submit_order(
|
||||
client_order_id="strict-quantity",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=value,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
self.assertEqual(trader.order_calls, [])
|
||||
|
||||
def test_real_order_requires_double_opt_in_and_remark_round_trip(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
with self.assertRaises(LiveOrderBlocked):
|
||||
adapter.submit_order(
|
||||
client_order_id="12345678901234567890",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
)
|
||||
result = adapter.submit_order(
|
||||
client_order_id="12345678901234567890",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
self.assertEqual(result["state"], "NEW")
|
||||
self.assertEqual(len(trader.order_calls), 1)
|
||||
remark = trader.order_calls[0][-1]
|
||||
self.assertEqual(remark, "q60:12345678901234567890")
|
||||
self.assertLessEqual(len(remark), 24)
|
||||
self.assertEqual(
|
||||
result["live_policy"]["authorization_id"], "auth-test-001"
|
||||
)
|
||||
|
||||
def test_live_guard_receives_normalized_submit_and_cancel_intents(self):
|
||||
intents = []
|
||||
|
||||
def guard(intent):
|
||||
intents.append(intent)
|
||||
return passing_live_decision(
|
||||
authorization_id=f"auth-{len(intents)}"
|
||||
)
|
||||
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader, allow=True, live_guard=guard)
|
||||
adapter.connect()
|
||||
result = adapter.submit_order(
|
||||
client_order_id="guard-intent",
|
||||
symbol="600000.SH",
|
||||
side="buy",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
adapter.cancel_order(88, confirm_live=True)
|
||||
self.assertEqual(
|
||||
intents,
|
||||
[
|
||||
{
|
||||
"action": "SUBMIT_ORDER",
|
||||
"account_id": "test",
|
||||
"client_order_id": "guard-intent",
|
||||
"symbol": "600000.XSHG",
|
||||
"side": "BUY",
|
||||
"quantity": 100,
|
||||
"limit_price": 10.5,
|
||||
"notional": 1050.0,
|
||||
"strategy_name": "quant60",
|
||||
},
|
||||
{
|
||||
"action": "CANCEL_ORDER",
|
||||
"account_id": "test",
|
||||
"broker_order_id": 88,
|
||||
"strategy_name": "quant60",
|
||||
},
|
||||
],
|
||||
)
|
||||
self.assertEqual(result["live_policy"]["authorization_id"], "auth-1")
|
||||
self.assertEqual(len(trader.order_calls), 1)
|
||||
self.assertEqual(len(trader.cancel_calls), 1)
|
||||
audit = adapter.live_authorization_audit
|
||||
self.assertEqual(len(audit), 2)
|
||||
self.assertEqual(audit[0]["decision"]["account_id"], "test")
|
||||
self.assertEqual(audit[1]["intent"]["action"], "CANCEL_ORDER")
|
||||
self.assertEqual(audit[1]["outcome"], "BROKER_ACCEPTED")
|
||||
|
||||
def test_live_guard_is_bound_to_a_non_empty_account_id(self):
|
||||
trader = FakeTrader()
|
||||
calls = []
|
||||
adapter = XtTraderLiveAdapter(
|
||||
trader_factory=lambda: trader,
|
||||
account=SimpleNamespace(account_id=" "),
|
||||
constants=Constants,
|
||||
allow_live_orders=True,
|
||||
live_guard=lambda intent: (
|
||||
calls.append(intent) or passing_live_decision()
|
||||
),
|
||||
)
|
||||
with self.assertRaisesRegex(NotConnectedError, "account_id"):
|
||||
adapter.connect()
|
||||
self.assertEqual(calls, [])
|
||||
self.assertEqual(trader.order_calls, [])
|
||||
|
||||
def test_live_guard_missing_false_non_bool_or_exception_blocks_submit(self):
|
||||
def raising_guard(intent):
|
||||
del intent
|
||||
raise RuntimeError("policy backend unavailable")
|
||||
|
||||
cases = [
|
||||
(
|
||||
"missing",
|
||||
lambda intent: {
|
||||
key: value
|
||||
for key, value in passing_live_decision().items()
|
||||
if key != "compliance_approved"
|
||||
},
|
||||
),
|
||||
(
|
||||
"false",
|
||||
lambda intent: passing_live_decision(
|
||||
reconciliation_safe=False
|
||||
),
|
||||
),
|
||||
(
|
||||
"non-bool",
|
||||
lambda intent: passing_live_decision(account_allowed=1),
|
||||
),
|
||||
(
|
||||
"empty-id",
|
||||
lambda intent: passing_live_decision(
|
||||
authorization_id=" "
|
||||
),
|
||||
),
|
||||
("exception", raising_guard),
|
||||
]
|
||||
for name, guard in cases:
|
||||
with self.subTest(name=name):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(
|
||||
trader, allow=True, live_guard=guard
|
||||
)
|
||||
adapter.connect()
|
||||
with self.assertRaises(LiveOrderBlocked):
|
||||
adapter.submit_order(
|
||||
client_order_id=f"blocked-{name}",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
self.assertEqual(trader.order_calls, [])
|
||||
|
||||
def test_live_guard_rejects_stale_naive_and_future_decisions(self):
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
cases = {
|
||||
"stale": (now - dt.timedelta(seconds=61)).isoformat(),
|
||||
"naive": now.replace(tzinfo=None).isoformat(),
|
||||
"future": (now + dt.timedelta(seconds=6)).isoformat(),
|
||||
}
|
||||
for name, as_of in cases.items():
|
||||
with self.subTest(name=name):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(
|
||||
trader,
|
||||
allow=True,
|
||||
live_guard=lambda intent, value=as_of: (
|
||||
passing_live_decision(as_of=value)
|
||||
),
|
||||
)
|
||||
adapter.connect()
|
||||
with self.assertRaises(LiveOrderBlocked):
|
||||
adapter.submit_order(
|
||||
client_order_id=f"clock-{name}",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
self.assertEqual(trader.order_calls, [])
|
||||
|
||||
def test_live_guard_failure_blocks_cancel_before_broker(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(
|
||||
trader,
|
||||
allow=True,
|
||||
live_guard=lambda intent: passing_live_decision(
|
||||
kill_switch_ready=False
|
||||
),
|
||||
)
|
||||
adapter.connect()
|
||||
with self.assertRaisesRegex(
|
||||
LiveOrderBlocked, "kill_switch_ready"
|
||||
):
|
||||
adapter.cancel_order(88, confirm_live=True)
|
||||
self.assertEqual(trader.cancel_calls, [])
|
||||
|
||||
def test_concurrent_same_client_id_submits_once(self):
|
||||
class BlockingTrader(FakeTrader):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.submit_entered = threading.Event()
|
||||
self.release_submit = threading.Event()
|
||||
|
||||
def order_stock_async(self, *args):
|
||||
self.order_calls.append(args)
|
||||
self.submit_entered.set()
|
||||
if not self.release_submit.wait(2):
|
||||
raise AssertionError("test did not release broker submit")
|
||||
return 101
|
||||
|
||||
trader = BlockingTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
results = []
|
||||
failures = []
|
||||
|
||||
def submit():
|
||||
try:
|
||||
results.append(
|
||||
adapter.submit_order(
|
||||
client_order_id="decision-001",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
)
|
||||
except BaseException as exc:
|
||||
failures.append(exc)
|
||||
|
||||
first = threading.Thread(target=submit)
|
||||
first.start()
|
||||
self.assertTrue(trader.submit_entered.wait(1))
|
||||
# The broker boundary is inside the idempotency critical section.
|
||||
acquired = adapter._lock.acquire(blocking=False)
|
||||
if acquired:
|
||||
adapter._lock.release()
|
||||
self.assertFalse(acquired)
|
||||
|
||||
second = threading.Thread(target=submit)
|
||||
second.start()
|
||||
trader.release_submit.set()
|
||||
first.join(2)
|
||||
second.join(2)
|
||||
self.assertFalse(first.is_alive())
|
||||
self.assertFalse(second.is_alive())
|
||||
self.assertEqual(failures, [])
|
||||
self.assertEqual(len(trader.order_calls), 1)
|
||||
self.assertEqual(len(results), 2)
|
||||
self.assertEqual(results[0], results[1])
|
||||
|
||||
def test_synchronous_order_error_matches_pre_submit_reservation(self):
|
||||
class SynchronousErrorTrader(FakeTrader):
|
||||
def order_stock_async(self, *args):
|
||||
self.order_calls.append(args)
|
||||
self.callback.on_order_error(
|
||||
SimpleNamespace(
|
||||
order_id=0,
|
||||
order_remark=args[-1],
|
||||
error_id=42,
|
||||
error_msg="synchronous rejection",
|
||||
)
|
||||
)
|
||||
return 101
|
||||
|
||||
trader = SynchronousErrorTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
result = adapter.submit_order(
|
||||
client_order_id="decision-001",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
self.assertEqual(result["state"], "REJECTED")
|
||||
self.assertEqual(result["error_id"], 42)
|
||||
self.assertEqual(len(trader.order_calls), 1)
|
||||
|
||||
def test_synchronous_order_error_by_seq_is_drained_from_orphans(self):
|
||||
class SynchronousErrorTrader(FakeTrader):
|
||||
def order_stock_async(self, *args):
|
||||
self.order_calls.append(args)
|
||||
self.callback.on_order_error(
|
||||
SimpleNamespace(
|
||||
order_id=None,
|
||||
order_remark="",
|
||||
seq=101,
|
||||
error_id=43,
|
||||
error_msg="fast seq rejection",
|
||||
)
|
||||
)
|
||||
return 101
|
||||
|
||||
trader = SynchronousErrorTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
result = adapter.submit_order(
|
||||
client_order_id="decision-002",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
self.assertEqual(result["state"], "REJECTED")
|
||||
self.assertEqual(result["error_id"], 43)
|
||||
self.assertEqual(adapter._orphan_order_errors, [])
|
||||
|
||||
def test_malformed_async_seq_never_leaves_submitting_reservation(self):
|
||||
class MalformedSeqTrader(FakeTrader):
|
||||
def __init__(self, seq):
|
||||
super().__init__()
|
||||
self.seq = seq
|
||||
|
||||
def order_stock_async(self, *args):
|
||||
self.order_calls.append(args)
|
||||
return self.seq
|
||||
|
||||
for index, seq in enumerate(("bad", 1.5, True, object())):
|
||||
with self.subTest(seq=repr(seq)):
|
||||
trader = MalformedSeqTrader(seq)
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
client_id = f"bad-seq-{index}"
|
||||
with self.assertRaisesRegex(
|
||||
Exception, "malformed async request sequence"
|
||||
):
|
||||
adapter.submit_order(
|
||||
client_order_id=client_id,
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
record = adapter.journal.get(client_id)
|
||||
self.assertEqual(record["state"], "UNKNOWN")
|
||||
self.assertNotEqual(record["state"], "SUBMITTING")
|
||||
self.assertEqual(len(trader.order_calls), 1)
|
||||
|
||||
def test_query_normalizes_position_and_disconnect_fails_fast(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader)
|
||||
adapter.connect()
|
||||
position = adapter.query_positions()[0]
|
||||
self.assertEqual(position["sellable"], 700)
|
||||
self.assertEqual(position["symbol"], "600000.XSHG")
|
||||
trader.callback.on_disconnected()
|
||||
with self.assertRaises(NotConnectedError):
|
||||
adapter.query_orders()
|
||||
|
||||
def test_connect_recovers_idempotency_from_broker_remark(self):
|
||||
broker_order = SimpleNamespace(
|
||||
account_id="test",
|
||||
order_id=88,
|
||||
order_sysid="sys-88",
|
||||
order_remark="q60:decision-001",
|
||||
stock_code="600000.SH",
|
||||
order_type=23,
|
||||
order_volume=100,
|
||||
traded_volume=0,
|
||||
price=10.5,
|
||||
traded_price=0,
|
||||
order_status=50,
|
||||
status_msg="",
|
||||
)
|
||||
trader = FakeTrader(orders=[broker_order])
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
recovered = adapter.submit_order(
|
||||
client_order_id="decision-001",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
self.assertEqual(recovered["broker_order_id"], 88)
|
||||
self.assertEqual(recovered["state"], "ACCEPTED")
|
||||
self.assertEqual(trader.order_calls, [])
|
||||
|
||||
def test_broker_payload_conflict_for_same_remark_fails_closed(self):
|
||||
orders = [
|
||||
SimpleNamespace(
|
||||
account_id="test",
|
||||
order_id=88 + index,
|
||||
order_sysid="sys",
|
||||
order_remark="q60:decision-001",
|
||||
stock_code="600000.SH",
|
||||
order_type=23,
|
||||
order_volume=quantity,
|
||||
traded_volume=0,
|
||||
price=10.5,
|
||||
traded_price=0,
|
||||
order_status=50,
|
||||
status_msg="",
|
||||
)
|
||||
for index, quantity in enumerate((100, 200))
|
||||
]
|
||||
adapter = adapter_for(FakeTrader(orders=orders), allow=True)
|
||||
with self.assertRaises(IdempotencyConflict):
|
||||
adapter.connect()
|
||||
self.assertNotEqual(adapter.state, "READY")
|
||||
|
||||
def test_order_error_updates_matching_journal_to_rejected(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
adapter.submit_order(
|
||||
client_order_id="decision-001",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
trader.callback.on_order_error(
|
||||
SimpleNamespace(
|
||||
order_id=999,
|
||||
order_remark="q60:decision-001",
|
||||
error_id=42,
|
||||
error_msg="rejected",
|
||||
)
|
||||
)
|
||||
record = adapter.journal.get("decision-001")
|
||||
self.assertEqual(record["state"], "REJECTED")
|
||||
self.assertEqual(record["error_id"], 42)
|
||||
|
||||
def test_cancel_none_is_not_reported_as_success(self):
|
||||
trader = FakeTrader()
|
||||
trader.cancel_order_stock = lambda account, order_id: None
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
with self.assertRaisesRegex(Exception, "cancel failed"):
|
||||
adapter.cancel_order(88, confirm_live=True)
|
||||
|
||||
def test_cancel_confirm_live_requires_a_real_bool(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
for value in ("False", "true", 1, None):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaisesRegex(TypeError, "must be a bool"):
|
||||
adapter.cancel_order(88, confirm_live=value)
|
||||
self.assertEqual(trader.cancel_calls, [])
|
||||
|
||||
def test_cancel_order_id_requires_exact_positive_integer(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
for value in (True, False, 88.0, 88.9, "88", None, 0, -1):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaisesRegex(
|
||||
ValueError, "positive integer"
|
||||
):
|
||||
adapter.cancel_order(value, confirm_live=True)
|
||||
self.assertEqual(trader.cancel_calls, [])
|
||||
|
||||
def test_stale_order_callback_cannot_regress_filled_terminal_state(self):
|
||||
filled = SimpleNamespace(
|
||||
account_id="test",
|
||||
order_id=88,
|
||||
order_sysid="sys-88",
|
||||
order_remark="q60:decision-001",
|
||||
stock_code="600000.SH",
|
||||
order_type=23,
|
||||
order_volume=100,
|
||||
traded_volume=100,
|
||||
price=10.5,
|
||||
traded_price=10.5,
|
||||
order_status=56,
|
||||
status_msg="filled",
|
||||
)
|
||||
stale = SimpleNamespace(
|
||||
account_id="test",
|
||||
order_id=88,
|
||||
order_sysid="sys-88",
|
||||
order_remark="q60:decision-001",
|
||||
stock_code="600000.SH",
|
||||
order_type=23,
|
||||
order_volume=100,
|
||||
traded_volume=0,
|
||||
price=10.5,
|
||||
traded_price=0,
|
||||
order_status=50,
|
||||
status_msg="stale accepted",
|
||||
)
|
||||
trader = FakeTrader(orders=[filled])
|
||||
adapter = adapter_for(trader)
|
||||
adapter.connect()
|
||||
# An exact duplicate is harmless and idempotent.
|
||||
trader.callback.on_stock_order(filled)
|
||||
with self.assertRaisesRegex(
|
||||
IdempotencyConflict, "cumulative fill regressed"
|
||||
):
|
||||
trader.callback.on_stock_order(stale)
|
||||
record = adapter.journal.get("decision-001")
|
||||
self.assertEqual(adapter.state, "DEGRADED")
|
||||
self.assertEqual(record["state"], "FILLED")
|
||||
self.assertEqual(record["filled_quantity"], 100)
|
||||
self.assertEqual(adapter._orders[88]["state"], "FILLED")
|
||||
|
||||
def test_configured_account_anchors_every_broker_query(self):
|
||||
class WrongAccountTrader(FakeTrader):
|
||||
def query_stock_asset(self, account):
|
||||
del account
|
||||
return SimpleNamespace(
|
||||
account_id="wrong",
|
||||
cash=10_000,
|
||||
market_value=20_000,
|
||||
total_asset=30_000,
|
||||
)
|
||||
|
||||
trader = WrongAccountTrader()
|
||||
adapter = adapter_for(trader)
|
||||
with self.assertRaisesRegex(
|
||||
NotConnectedError,
|
||||
"configured account",
|
||||
):
|
||||
adapter.connect()
|
||||
self.assertEqual(adapter.state, "FAILED")
|
||||
self.assertTrue(trader.stopped)
|
||||
|
||||
def test_trade_query_preserves_account_side_and_stable_trade_id(self):
|
||||
class TradeTrader(FakeTrader):
|
||||
def query_stock_trades(self, account):
|
||||
del account
|
||||
return [
|
||||
SimpleNamespace(
|
||||
account_id="test",
|
||||
traded_id="trade-1",
|
||||
order_id=88,
|
||||
stock_code="600000.SH",
|
||||
order_type=23,
|
||||
traded_volume=100,
|
||||
traded_price=10.5,
|
||||
traded_amount=1050,
|
||||
)
|
||||
]
|
||||
|
||||
adapter = adapter_for(TradeTrader())
|
||||
adapter.connect()
|
||||
self.assertEqual(
|
||||
adapter.query_trades(),
|
||||
[
|
||||
{
|
||||
"account_id": "test",
|
||||
"trade_id": "trade-1",
|
||||
"broker_order_id": 88,
|
||||
"symbol": "600000.XSHG",
|
||||
"side": "BUY",
|
||||
"quantity": 100,
|
||||
"price": 10.5,
|
||||
"amount": 1050.0,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
def test_reconnect_uses_fresh_instance_and_exhausts_bounded_attempts(self):
|
||||
created = []
|
||||
|
||||
def factory():
|
||||
trader = FakeTrader(connect_code=9)
|
||||
created.append(trader)
|
||||
return trader
|
||||
|
||||
adapter = XtTraderLiveAdapter(
|
||||
trader_factory=factory,
|
||||
account=SimpleNamespace(account_id="test"),
|
||||
constants=Constants,
|
||||
)
|
||||
with self.assertRaises(ReconnectFailed):
|
||||
adapter.reconnect(attempts=2)
|
||||
self.assertEqual(len(created), 2)
|
||||
self.assertEqual(adapter.state, "FAILED")
|
||||
|
||||
def test_bj_is_explicitly_outside_v1_live_adapter(self):
|
||||
adapter = adapter_for(FakeTrader())
|
||||
with self.assertRaises(ValueError):
|
||||
adapter.submit_order(
|
||||
client_order_id="bj",
|
||||
symbol="430001.BJ",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user