chore: init qmt_bridge repo (HTTP+WS bridge, MCP endpoint, docs, references)
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import datetime
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.app import SignalTradingApp
|
||||
from bigqmt_signal_trader.models import AssetSnapshot, PositionSnapshot, TradeSignal
|
||||
|
||||
|
||||
def _signal(**kwargs):
|
||||
payload = {
|
||||
"signal_id": "sig-buy-001",
|
||||
"account_id": "test",
|
||||
"action": "BUY",
|
||||
"stock_code": "000001.SZ",
|
||||
"amount": 100,
|
||||
"price_type": "AUTO_LIMIT",
|
||||
"remark": "web_buy_command",
|
||||
"created_at": "2026-06-30 09:31:00",
|
||||
"expire_at": "2026-06-30 09:36:00",
|
||||
"schema_version": 1,
|
||||
}
|
||||
payload.update(kwargs)
|
||||
return TradeSignal.from_dict(payload)
|
||||
|
||||
|
||||
class FakeSignalSource:
|
||||
def __init__(self, items):
|
||||
self.items = items
|
||||
self.acked = []
|
||||
|
||||
def fetch(self, account_id, limit):
|
||||
return self.items[:limit]
|
||||
|
||||
def ack(self, signal):
|
||||
self.acked.append(signal.signal_id)
|
||||
|
||||
|
||||
class FakeMarketDataProvider:
|
||||
def get_ticks(self, codes):
|
||||
return {
|
||||
code: {
|
||||
"lastPrice": 10.0,
|
||||
"askPrice": [10.01, 10.02],
|
||||
"bidPrice": [9.99, 9.98],
|
||||
}
|
||||
for code in codes
|
||||
}
|
||||
|
||||
def get_instrument(self, code):
|
||||
return {"InstrumentStatus": 0, "UpStopPrice": 11.0, "DownStopPrice": 9.0}
|
||||
|
||||
|
||||
class FakePositionProvider:
|
||||
def __init__(self):
|
||||
self.positions = {
|
||||
"000001.SZ": PositionSnapshot(
|
||||
stock_code="000001.SZ",
|
||||
volume=1000,
|
||||
available=500,
|
||||
cost=9.5,
|
||||
)
|
||||
}
|
||||
|
||||
def get_positions(self, account_id):
|
||||
return self.positions
|
||||
|
||||
def get_asset(self, account_id):
|
||||
return AssetSnapshot(account_id=account_id, cash=100000.0, total_asset=200000.0)
|
||||
|
||||
|
||||
class FakeOrderGateway:
|
||||
def __init__(self):
|
||||
self.submitted = []
|
||||
|
||||
def submit(self, request):
|
||||
self.submitted.append(request)
|
||||
from bigqmt_signal_trader.models import OrderSubmitResult
|
||||
|
||||
return OrderSubmitResult(status="SUBMITTED", user_order_id="bq:sig:1")
|
||||
|
||||
def cancel(self, order_ref):
|
||||
return None
|
||||
|
||||
def query_orders(self, account_id, strategy_name):
|
||||
return []
|
||||
|
||||
def query_trades(self, account_id, strategy_name):
|
||||
return []
|
||||
|
||||
|
||||
class FakePositionSyncSink:
|
||||
def __init__(self):
|
||||
self.snapshots = []
|
||||
|
||||
def publish(self, snapshot):
|
||||
self.snapshots.append(snapshot)
|
||||
|
||||
|
||||
class FakeStateStore:
|
||||
def __init__(self, claim_result=True):
|
||||
self.claim_result = claim_result
|
||||
self.claimed = []
|
||||
self.submitted = []
|
||||
self.finished = []
|
||||
|
||||
def claim(self, signal, consumer_id):
|
||||
self.claimed.append((signal.signal_id, consumer_id))
|
||||
return self.claim_result
|
||||
|
||||
def mark_submitted(self, signal_id, result):
|
||||
self.submitted.append((signal_id, result.status))
|
||||
|
||||
def mark_finished(self, signal_id, status, message=""):
|
||||
self.finished.append((signal_id, status, message))
|
||||
|
||||
|
||||
class SignalTradingAppTest(unittest.TestCase):
|
||||
def test_tick_submits_buy_signal_with_replaceable_adapters(self):
|
||||
source = FakeSignalSource([_signal()])
|
||||
state = FakeStateStore()
|
||||
orders = FakeOrderGateway()
|
||||
sync = FakePositionSyncSink()
|
||||
app = SignalTradingApp(
|
||||
account_id="test",
|
||||
signal_source=source,
|
||||
market_data=FakeMarketDataProvider(),
|
||||
position_provider=FakePositionProvider(),
|
||||
order_gateway=orders,
|
||||
position_sync_sink=sync,
|
||||
state_store=state,
|
||||
consumer_id="consumer-a",
|
||||
)
|
||||
|
||||
app.tick(datetime.datetime(2026, 6, 30, 9, 31))
|
||||
|
||||
self.assertEqual(orders.submitted[0].stock_code, "000001.SZ")
|
||||
self.assertEqual(orders.submitted[0].volume, 100)
|
||||
self.assertEqual(state.submitted, [("sig-buy-001", "SUBMITTED")])
|
||||
self.assertEqual(source.acked, ["sig-buy-001"])
|
||||
self.assertEqual(sync.snapshots[0].account_id, "test")
|
||||
|
||||
def test_tick_sells_by_percentage_using_available_position(self):
|
||||
source = FakeSignalSource([
|
||||
_signal(
|
||||
signal_id="sig-sell-001",
|
||||
action="SELL",
|
||||
amount=None,
|
||||
percentage=50,
|
||||
remark="web_sell_command",
|
||||
)
|
||||
])
|
||||
orders = FakeOrderGateway()
|
||||
app = SignalTradingApp(
|
||||
account_id="test",
|
||||
signal_source=source,
|
||||
market_data=FakeMarketDataProvider(),
|
||||
position_provider=FakePositionProvider(),
|
||||
order_gateway=orders,
|
||||
position_sync_sink=FakePositionSyncSink(),
|
||||
state_store=FakeStateStore(),
|
||||
consumer_id="consumer-a",
|
||||
)
|
||||
|
||||
app.tick(datetime.datetime(2026, 6, 30, 9, 31))
|
||||
|
||||
self.assertEqual(orders.submitted[0].action, "SELL")
|
||||
self.assertEqual(orders.submitted[0].volume, 200)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,226 @@
|
||||
"""frozen_cash must survive the whole chain: QMT row -> AssetSnapshot -> RPC
|
||||
serialization -> client CompatObject, plus the Redis cached-asset fallback.
|
||||
|
||||
Field names follow MiniQMT's XtAsset(account_id, cash, frozen_cash,
|
||||
market_value, total_asset), where total_asset = cash + frozen_cash + market_value.
|
||||
"""
|
||||
|
||||
import datetime as _dt
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.adapters import position_bigqmt
|
||||
from bigqmt_signal_trader.adapters.position_bigqmt import BigQmtPositionProvider
|
||||
from bigqmt_signal_trader.adapters.position_sync_redis import RedisPositionSyncSink
|
||||
from bigqmt_signal_trader.models import AccountSnapshot, AssetSnapshot, PositionSnapshot
|
||||
from bigqmt_signal_trader.redis_rpc import to_jsonable
|
||||
from bigqmt_signal_trader.xtquant_compat import BigQmtXtTrader, StockAccount
|
||||
|
||||
|
||||
class Row:
|
||||
def __init__(self, **kwargs):
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
|
||||
def _provider(row):
|
||||
def query(account_id, account_type, detail_type):
|
||||
return [row] if detail_type in ("ACCOUNT", "ASSET") else []
|
||||
|
||||
return BigQmtPositionProvider(query)
|
||||
|
||||
|
||||
class AssetSnapshotModelTest(unittest.TestCase):
|
||||
def test_defaults_keep_existing_positional_callers_working(self):
|
||||
snapshot = AssetSnapshot("acct", 100.0, 1000.0)
|
||||
|
||||
self.assertEqual(snapshot.cash, 100.0)
|
||||
self.assertEqual(snapshot.total_asset, 1000.0)
|
||||
self.assertIsNone(snapshot.frozen_cash)
|
||||
self.assertIsNone(snapshot.market_value)
|
||||
|
||||
def test_carries_the_full_xtasset_field_set(self):
|
||||
snapshot = AssetSnapshot("acct", 100.0, 1000.0, frozen_cash=50.0, market_value=850.0)
|
||||
|
||||
self.assertEqual(snapshot.frozen_cash, 50.0)
|
||||
self.assertEqual(snapshot.market_value, 850.0)
|
||||
|
||||
|
||||
class QmtCollectionTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
position_bigqmt._missing_field_reported.clear()
|
||||
|
||||
def test_reads_frozen_cash_from_the_account_row(self):
|
||||
provider = _provider(
|
||||
Row(m_dAvailable=100.0, m_dBalance=1000.0, m_dFrozenCash=50.0, m_dInstrumentValue=850.0)
|
||||
)
|
||||
|
||||
asset = provider.get_asset("acct")
|
||||
|
||||
self.assertEqual(asset.cash, 100.0)
|
||||
self.assertEqual(asset.frozen_cash, 50.0)
|
||||
self.assertEqual(asset.market_value, 850.0)
|
||||
self.assertEqual(asset.total_asset, 1000.0)
|
||||
|
||||
def test_accepts_alternate_broker_spellings(self):
|
||||
for field in ("m_dFrozenCash", "m_dFrozen", "m_dFrozenBalance", "frozen_cash"):
|
||||
provider = _provider(Row(m_dAvailable=100.0, m_dBalance=1000.0, **{field: 50.0}))
|
||||
|
||||
self.assertEqual(provider.get_asset("acct").frozen_cash, 50.0, field)
|
||||
|
||||
def test_derived_market_value_excludes_frozen_cash(self):
|
||||
"""Without this, market value is overstated by the frozen amount."""
|
||||
provider = _provider(Row(m_dAvailable=100.0, m_dBalance=1000.0, m_dFrozenCash=50.0))
|
||||
|
||||
self.assertEqual(provider.get_asset("acct").market_value, 850.0)
|
||||
|
||||
def test_derivation_falls_back_when_frozen_is_absent(self):
|
||||
provider = _provider(Row(m_dAvailable=100.0, m_dBalance=1000.0))
|
||||
asset = provider.get_asset("acct")
|
||||
|
||||
self.assertIsNone(asset.frozen_cash)
|
||||
self.assertEqual(asset.market_value, 900.0) # legacy behaviour preserved
|
||||
|
||||
def test_missing_frozen_field_reports_what_the_row_actually_has(self):
|
||||
"""The ThinkTrader spelling is unverified offline; make it self-reporting
|
||||
instead of silently returning None forever."""
|
||||
import io
|
||||
import contextlib
|
||||
|
||||
provider = _provider(Row(m_dAvailable=100.0, m_dBalance=1000.0, m_dWhateverElse=1.0))
|
||||
buffer = io.StringIO()
|
||||
with contextlib.redirect_stdout(buffer):
|
||||
provider.get_asset("acct")
|
||||
output = buffer.getvalue()
|
||||
|
||||
self.assertIn("frozen_cash not found", output)
|
||||
self.assertIn("m_dWhateverElse", output) # names the real fields
|
||||
|
||||
def test_missing_field_is_reported_once_not_per_call(self):
|
||||
import io
|
||||
import contextlib
|
||||
|
||||
provider = _provider(Row(m_dAvailable=100.0, m_dBalance=1000.0))
|
||||
buffer = io.StringIO()
|
||||
with contextlib.redirect_stdout(buffer):
|
||||
for _ in range(5):
|
||||
provider.get_asset("acct")
|
||||
|
||||
self.assertEqual(buffer.getvalue().count("frozen_cash not found"), 1)
|
||||
|
||||
def test_empty_rows_still_degrade_to_all_none(self):
|
||||
provider = BigQmtPositionProvider(lambda *args: [])
|
||||
|
||||
asset = provider.get_asset("acct")
|
||||
|
||||
self.assertIsNone(asset.cash)
|
||||
self.assertIsNone(asset.frozen_cash)
|
||||
|
||||
|
||||
class RpcSerializationTest(unittest.TestCase):
|
||||
def test_to_jsonable_carries_frozen_cash_over_the_wire(self):
|
||||
snapshot = AssetSnapshot("acct", 100.0, 1000.0, frozen_cash=50.0, market_value=850.0)
|
||||
|
||||
payload = to_jsonable(snapshot)
|
||||
|
||||
self.assertEqual(payload["frozen_cash"], 50.0)
|
||||
self.assertEqual(payload["market_value"], 850.0)
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
def __init__(self):
|
||||
self.kv = {}
|
||||
self.streams = {}
|
||||
|
||||
def set(self, key, value):
|
||||
self.kv[key] = value
|
||||
|
||||
def setex(self, key, ttl_seconds, value):
|
||||
self.kv[key] = value
|
||||
|
||||
def xadd(self, key, fields, maxlen=None, approximate=None):
|
||||
self.streams.setdefault(key, []).append(fields)
|
||||
return b"1-0"
|
||||
|
||||
def publish(self, key, value):
|
||||
return 1
|
||||
|
||||
|
||||
class PositionSyncTest(unittest.TestCase):
|
||||
def test_cached_snapshot_includes_frozen_cash(self):
|
||||
redis_client = FakeRedis()
|
||||
RedisPositionSyncSink(redis_client).publish(
|
||||
AccountSnapshot(
|
||||
account_id="acct",
|
||||
asset=AssetSnapshot("acct", 100.0, 1000.0, frozen_cash=50.0, market_value=850.0),
|
||||
positions={"600000.SH": PositionSnapshot("600000.SH", 100, 100, 10.0, "PF")},
|
||||
reason="test",
|
||||
updated_at=_dt.datetime(2026, 7, 1, 9, 31),
|
||||
)
|
||||
)
|
||||
|
||||
payload = json.loads(redis_client.kv["bigqmt:positions:acct"])
|
||||
|
||||
self.assertEqual(payload["asset"]["frozen_cash"], 50.0)
|
||||
self.assertEqual(payload["asset"]["market_value"], 850.0)
|
||||
|
||||
|
||||
class ClientSurfaceTest(unittest.TestCase):
|
||||
"""The reported AttributeError: asset.frozen_cash must simply exist."""
|
||||
|
||||
def _trader(self, response):
|
||||
trader = BigQmtXtTrader(account_id="acct")
|
||||
trader.client.call = lambda method, params=None, account_id=None, **kw: response
|
||||
return trader
|
||||
|
||||
def test_frozen_cash_is_exposed(self):
|
||||
asset = self._trader(
|
||||
{"cash": 100.0, "total_asset": 1000.0, "frozen_cash": 50.0, "market_value": 850.0}
|
||||
).query_stock_asset(StockAccount("acct"))
|
||||
|
||||
self.assertEqual(asset.frozen_cash, 50.0)
|
||||
self.assertEqual(asset.cash, 100.0)
|
||||
self.assertEqual(asset.market_value, 850.0)
|
||||
self.assertEqual(asset.total_asset, 1000.0)
|
||||
|
||||
def test_frozen_cash_defaults_to_zero_not_missing(self):
|
||||
"""A server that predates this field must not resurrect the
|
||||
AttributeError, and callers do arithmetic on it."""
|
||||
asset = self._trader({"cash": 100.0, "total_asset": 1000.0}).query_stock_asset(
|
||||
StockAccount("acct")
|
||||
)
|
||||
|
||||
self.assertEqual(asset.frozen_cash, 0.0)
|
||||
self.assertEqual(asset.cash + asset.frozen_cash, 100.0)
|
||||
|
||||
def test_derived_market_value_excludes_frozen_cash(self):
|
||||
asset = self._trader(
|
||||
{"cash": 100.0, "total_asset": 1000.0, "frozen_cash": 50.0}
|
||||
).query_stock_asset(StockAccount("acct"))
|
||||
|
||||
self.assertEqual(asset.market_value, 850.0)
|
||||
|
||||
def test_server_market_value_wins_over_derivation(self):
|
||||
asset = self._trader(
|
||||
{"cash": 100.0, "total_asset": 1000.0, "frozen_cash": 50.0, "market_value": 111.0}
|
||||
).query_stock_asset(StockAccount("acct"))
|
||||
|
||||
self.assertEqual(asset.market_value, 111.0)
|
||||
|
||||
def test_components_reconstruct_total_asset(self):
|
||||
asset = self._trader(
|
||||
{"cash": 100.0, "total_asset": 1000.0, "frozen_cash": 50.0, "market_value": 850.0}
|
||||
).query_stock_asset(StockAccount("acct"))
|
||||
|
||||
self.assertAlmostEqual(
|
||||
asset.cash + asset.frozen_cash + asset.market_value, asset.total_asset
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,275 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.adapter_factory import build_app
|
||||
from bigqmt_signal_trader.adapters.market_bigqmt import BigQmtMarketDataProvider
|
||||
from bigqmt_signal_trader.adapters.order_bigqmt import BigQmtOrderGateway
|
||||
from bigqmt_signal_trader.adapters.position_bigqmt import BigQmtPositionProvider
|
||||
from bigqmt_signal_trader.models import OrderRef, OrderRequest
|
||||
|
||||
|
||||
class Obj:
|
||||
def __init__(self, **kwargs):
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
|
||||
class FakeContext:
|
||||
def __init__(self):
|
||||
self.tick_codes = []
|
||||
self.instrument_codes = []
|
||||
|
||||
def get_full_tick(self, codes):
|
||||
self.tick_codes.append(list(codes))
|
||||
return {codes[0]: {"lastPrice": 10.0}}
|
||||
|
||||
def get_instrumentdetail(self, code):
|
||||
self.instrument_codes.append(code)
|
||||
return {"InstrumentStatus": 0}
|
||||
|
||||
|
||||
class FakeMarketDataContext(FakeContext):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.market_calls = []
|
||||
|
||||
def get_market_data_ex(
|
||||
self,
|
||||
fields=None,
|
||||
stock_code=None,
|
||||
period="1d",
|
||||
start_time="",
|
||||
end_time="",
|
||||
count=-1,
|
||||
dividend_type="none",
|
||||
):
|
||||
self.market_calls.append(
|
||||
{
|
||||
"method": "get_market_data_ex",
|
||||
"fields": fields,
|
||||
"stock_code": stock_code,
|
||||
"period": period,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"count": count,
|
||||
"dividend_type": dividend_type,
|
||||
}
|
||||
)
|
||||
return {"600000.SH": {"close": [10.0]}}
|
||||
|
||||
|
||||
class FakeMarketDataFallbackContext(FakeContext):
|
||||
def get_market_data(self, fields=None, stock_code=None, period="1d", **kwargs):
|
||||
return {
|
||||
"method": "get_market_data",
|
||||
"fields": fields,
|
||||
"stock_code": stock_code,
|
||||
"period": period,
|
||||
"kwargs": kwargs,
|
||||
}
|
||||
|
||||
|
||||
class BigQmtAdaptersTest(unittest.TestCase):
|
||||
def test_market_provider_normalizes_codes_before_context_call(self):
|
||||
context = FakeContext()
|
||||
provider = BigQmtMarketDataProvider(context)
|
||||
|
||||
ticks = provider.get_ticks(["600000"])
|
||||
instrument = provider.get_instrument("sz000001")
|
||||
|
||||
self.assertIn("600000.SH", ticks)
|
||||
self.assertEqual(context.tick_codes, [["600000.SH"]])
|
||||
self.assertEqual(context.instrument_codes, ["000001.SZ"])
|
||||
self.assertEqual(instrument["InstrumentStatus"], 0)
|
||||
|
||||
def test_market_provider_passes_market_codes_to_full_tick(self):
|
||||
context = FakeContext()
|
||||
provider = BigQmtMarketDataProvider(context)
|
||||
|
||||
provider.get_ticks(["SH", "sz"])
|
||||
|
||||
self.assertEqual(context.tick_codes, [["SH", "SZ"]])
|
||||
|
||||
def test_market_provider_supports_bigqmt_market_data_ex_signature(self):
|
||||
context = FakeMarketDataContext()
|
||||
provider = BigQmtMarketDataProvider(context)
|
||||
|
||||
data = provider.get_market_data_ex(field_list=["close"], stock_list=["600000.SH"], count=1)
|
||||
|
||||
self.assertEqual(data["600000.SH"]["close"], [10.0])
|
||||
self.assertEqual(context.market_calls[0]["fields"], ["close"])
|
||||
self.assertEqual(context.market_calls[0]["stock_code"], ["600000.SH"])
|
||||
self.assertEqual(context.market_calls[0]["count"], 1)
|
||||
|
||||
def test_market_provider_falls_back_to_market_data_when_ex_is_missing(self):
|
||||
context = FakeMarketDataFallbackContext()
|
||||
provider = BigQmtMarketDataProvider(context)
|
||||
|
||||
data = provider.get_market_data_ex(field_list=["close"], stock_list=["600000.SH"], period="1m")
|
||||
|
||||
self.assertEqual(data["method"], "get_market_data")
|
||||
self.assertEqual(data["fields"], ["close"])
|
||||
self.assertEqual(data["stock_code"], ["600000.SH"])
|
||||
self.assertEqual(data["period"], "1m")
|
||||
|
||||
def test_position_provider_maps_qmt_position_objects(self):
|
||||
calls = []
|
||||
|
||||
def fake_query(account, account_type, detail_type, *args):
|
||||
calls.append((account, account_type, detail_type, args))
|
||||
if detail_type == "POSITION":
|
||||
return [
|
||||
Obj(
|
||||
m_strInstrumentID="510300",
|
||||
m_strExchangeID="SH",
|
||||
m_nVolume=1000,
|
||||
m_nCanUseVolume=800,
|
||||
m_dOpenPrice=3.456,
|
||||
m_dLastPrice=3.789,
|
||||
m_dMarketValue=3789.0,
|
||||
m_nFrozenVolume=200,
|
||||
m_nOnRoadVolume=10,
|
||||
m_nYesterdayVolume=900,
|
||||
m_strInstrumentName="ETF",
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
provider = BigQmtPositionProvider(fake_query)
|
||||
positions = provider.get_positions("acct")
|
||||
|
||||
self.assertEqual(calls[0], ("acct", "STOCK", "POSITION", ()))
|
||||
self.assertEqual(positions["510300.SH"].volume, 1000)
|
||||
self.assertEqual(positions["510300.SH"].available, 800)
|
||||
self.assertEqual(positions["510300.SH"].cost, 3.456)
|
||||
self.assertEqual(positions["510300.SH"].price, 3.789)
|
||||
self.assertEqual(positions["510300.SH"].market_value, 3789.0)
|
||||
self.assertEqual(positions["510300.SH"].frozen_volume, 200)
|
||||
self.assertEqual(positions["510300.SH"].on_road_volume, 10)
|
||||
self.assertEqual(positions["510300.SH"].yesterday_volume, 900)
|
||||
|
||||
def test_order_gateway_submit_uses_qmt_jq_trade_passorder_shape(self):
|
||||
calls = []
|
||||
|
||||
def fake_passorder(*args):
|
||||
calls.append(args)
|
||||
|
||||
context = object()
|
||||
gateway = BigQmtOrderGateway(context_info=context, passorder_func=fake_passorder)
|
||||
request = OrderRequest(
|
||||
signal_id="sig-001",
|
||||
account_id="acct",
|
||||
action="BUY",
|
||||
stock_code="600000",
|
||||
volume=300,
|
||||
price=10.12,
|
||||
price_type=44,
|
||||
strategy_name="bigqmt_signal_trader",
|
||||
remark="manual",
|
||||
)
|
||||
|
||||
result = gateway.submit(request)
|
||||
|
||||
self.assertEqual(result.status, "SUBMITTED")
|
||||
self.assertEqual(result.user_order_id, "manual")
|
||||
self.assertEqual(calls[0][0:9], (23, 1101, "acct", "600000.SH", 44, 10.12, 300, "bigqmt_signal_trader", 2))
|
||||
self.assertEqual(calls[0][9], result.user_order_id)
|
||||
self.assertIs(calls[0][10], context)
|
||||
|
||||
def test_order_gateway_cancel_and_query_orders(self):
|
||||
cancel_calls = []
|
||||
|
||||
def fake_cancel(*args):
|
||||
cancel_calls.append(args)
|
||||
return True
|
||||
|
||||
def fake_query(account, account_type, detail_type, strategy_name):
|
||||
self.assertEqual((account, account_type, detail_type, strategy_name), ("acct", "STOCK", "ORDER", "s"))
|
||||
return [
|
||||
Obj(
|
||||
m_strOrderSysID="ord1",
|
||||
m_strRemark="remark1",
|
||||
m_strInstrumentID="000001",
|
||||
m_strExchangeID="SZ",
|
||||
m_nOffsetFlag=49,
|
||||
m_nVolumeTotalOriginal=1000,
|
||||
m_nVolumeTraded=200,
|
||||
m_nOrderStatus=50,
|
||||
)
|
||||
]
|
||||
|
||||
context = object()
|
||||
gateway = BigQmtOrderGateway(
|
||||
context_info=context,
|
||||
account_id="acct",
|
||||
cancel_func=fake_cancel,
|
||||
get_trade_detail_data_func=fake_query,
|
||||
)
|
||||
|
||||
cancel_result = gateway.cancel(OrderRef("ord1"))
|
||||
orders = gateway.query_orders("acct", "s")
|
||||
|
||||
self.assertTrue(cancel_result.success)
|
||||
self.assertEqual(cancel_calls, [("ord1", "acct", "STOCK", context)])
|
||||
self.assertEqual(orders[0].stock_code, "000001.SZ")
|
||||
self.assertEqual(orders[0].action, "SELL")
|
||||
self.assertEqual(orders[0].traded_volume, 200)
|
||||
|
||||
def test_query_trades_without_strategy_omits_strategy_filter(self):
|
||||
calls = []
|
||||
|
||||
def fake_query(*args):
|
||||
calls.append(args)
|
||||
return [
|
||||
Obj(
|
||||
m_strTradeID="manual-trade-1",
|
||||
m_strOrderSysID="manual-order-1",
|
||||
m_strInstrumentID="600276",
|
||||
m_strExchangeID="SH",
|
||||
m_nOffsetFlag=48,
|
||||
m_nVolume=100,
|
||||
m_dPrice=54.76,
|
||||
m_strTradeTime="130524",
|
||||
m_strRemark="",
|
||||
)
|
||||
]
|
||||
|
||||
gateway = BigQmtOrderGateway(
|
||||
context_info=object(),
|
||||
get_trade_detail_data_func=fake_query,
|
||||
)
|
||||
|
||||
trades = gateway.query_trades_strict("acct", "")
|
||||
|
||||
self.assertEqual(calls, [("acct", "STOCK", "DEAL")])
|
||||
self.assertEqual(trades[0].trade_id, "manual-trade-1")
|
||||
self.assertEqual(trades[0].stock_code, "600276.SH")
|
||||
self.assertEqual(trades[0].action, "BUY")
|
||||
self.assertEqual(trades[0].volume, 100)
|
||||
self.assertEqual(trades[0].price, 54.76)
|
||||
|
||||
def test_factory_bigqmt_mode_wires_real_adapters(self):
|
||||
app = build_app(
|
||||
FakeContext(),
|
||||
{
|
||||
"mode": "bigqmt",
|
||||
"account_id": "acct",
|
||||
"qmt_api": {
|
||||
"passorder": lambda *args: None,
|
||||
"cancel": lambda *args: True,
|
||||
"get_trade_detail_data": lambda *args: [],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertIsInstance(app.market_data, BigQmtMarketDataProvider)
|
||||
self.assertIsInstance(app.position_provider, BigQmtPositionProvider)
|
||||
self.assertIsInstance(app.order_gateway, BigQmtOrderGateway)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.adapters.market_bigqmt import BigQmtMarketDataProvider
|
||||
|
||||
|
||||
class RawMarketContext:
|
||||
def __init__(self, payload=None):
|
||||
self.payload = payload or {}
|
||||
self.calls = []
|
||||
|
||||
def get_market_data_ex_ori(
|
||||
self,
|
||||
fields=None,
|
||||
stock_code=None,
|
||||
period="1d",
|
||||
start_time="",
|
||||
end_time="",
|
||||
count=-1,
|
||||
dividend_type="none",
|
||||
):
|
||||
self.calls.append(
|
||||
{
|
||||
"fields": fields,
|
||||
"stock_code": stock_code,
|
||||
"period": period,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"count": count,
|
||||
"dividend_type": dividend_type,
|
||||
}
|
||||
)
|
||||
return self.payload
|
||||
|
||||
def get_market_data_ex(self, *args, **kwargs):
|
||||
raise AssertionError("DataFrame-producing QMT API must not be called")
|
||||
|
||||
|
||||
class BigQmtRawMarketBridgeTest(unittest.TestCase):
|
||||
def test_market_data_ex_uses_raw_context_api(self):
|
||||
rows = [[1784014200000, 55.1], [1784014260000, 55.2]]
|
||||
context = RawMarketContext({"600276.SH": rows})
|
||||
provider = BigQmtMarketDataProvider(context)
|
||||
|
||||
data = provider.get_market_data_ex(
|
||||
field_list=["close"], stock_list=["600276.SH"], period="1m", count=2
|
||||
)
|
||||
|
||||
self.assertEqual("DataFrame", data["600276.SH"]["__bigqmt_type__"])
|
||||
self.assertEqual(["stime", "close"], data["600276.SH"]["columns"])
|
||||
self.assertEqual(rows, data["600276.SH"]["records"])
|
||||
self.assertEqual(["close"], context.calls[0]["fields"])
|
||||
self.assertEqual(["600276.SH"], context.calls[0]["stock_code"])
|
||||
|
||||
def test_market_data_ex_returns_empty_frame_for_requested_symbol(self):
|
||||
context = RawMarketContext({})
|
||||
provider = BigQmtMarketDataProvider(context)
|
||||
|
||||
data = provider.get_market_data_ex(
|
||||
field_list=["close"], stock_list=["600276.SH"], period="1m", count=2
|
||||
)
|
||||
|
||||
self.assertEqual([], data["600276.SH"]["records"])
|
||||
self.assertEqual(["stime", "close"], data["600276.SH"]["columns"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,38 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.code_utils import (
|
||||
normalize_stock_code,
|
||||
round_buy_volume,
|
||||
round_sell_volume,
|
||||
)
|
||||
|
||||
|
||||
class CodeUtilsTest(unittest.TestCase):
|
||||
def test_normalize_stock_code_accepts_common_formats(self):
|
||||
self.assertEqual(normalize_stock_code("600000"), "600000.SH")
|
||||
self.assertEqual(normalize_stock_code("000001"), "000001.SZ")
|
||||
self.assertEqual(normalize_stock_code("SZ000001"), "000001.SZ")
|
||||
self.assertEqual(normalize_stock_code("sh600000"), "600000.SH")
|
||||
self.assertEqual(normalize_stock_code("600000.SH"), "600000.SH")
|
||||
|
||||
def test_normalize_stock_code_keeps_etf_tradable(self):
|
||||
self.assertEqual(normalize_stock_code("510300"), "510300.SH")
|
||||
self.assertEqual(normalize_stock_code("159915"), "159915.SZ")
|
||||
|
||||
def test_round_buy_volume_by_lot(self):
|
||||
self.assertEqual(round_buy_volume("000001.SZ", 1234), 1200)
|
||||
self.assertEqual(round_buy_volume("688001.SH", 234), 200)
|
||||
|
||||
def test_round_sell_volume_keeps_all_when_sell_all(self):
|
||||
self.assertEqual(round_sell_volume("000001.SZ", 1234, sell_all=False), 1200)
|
||||
self.assertEqual(round_sell_volume("000001.SZ", 1234, sell_all=True), 1234)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,181 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.download_jobs import (
|
||||
_enc,
|
||||
current_key,
|
||||
job_key,
|
||||
pump_download_jobs,
|
||||
queue_key,
|
||||
read_download_status,
|
||||
submit_download_job,
|
||||
wait_download_job,
|
||||
)
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
def __init__(self):
|
||||
self.kv = {}
|
||||
self.lists = {}
|
||||
self.expired = []
|
||||
|
||||
def setex(self, key, ttl, value):
|
||||
self.kv[key] = value
|
||||
self.expired.append((key, ttl))
|
||||
return True
|
||||
|
||||
def set(self, key, value):
|
||||
self.kv[key] = value
|
||||
return True
|
||||
|
||||
def get(self, key):
|
||||
return self.kv.get(key)
|
||||
|
||||
def delete(self, key):
|
||||
self.kv.pop(key, None)
|
||||
return 1
|
||||
|
||||
def rpush(self, key, value):
|
||||
self.lists.setdefault(key, []).append(value)
|
||||
return len(self.lists[key])
|
||||
|
||||
def lpop(self, key):
|
||||
lst = self.lists.get(key) or []
|
||||
if not lst:
|
||||
return None
|
||||
return lst.pop(0)
|
||||
|
||||
def expire(self, key, ttl):
|
||||
self.expired.append((key, ttl))
|
||||
return True
|
||||
|
||||
|
||||
class FakeMarketData:
|
||||
def __init__(self, fail_on=None, sleep=0.0):
|
||||
self.data2_calls = []
|
||||
self.data_calls = []
|
||||
self.fail_on = fail_on
|
||||
self.sleep = sleep
|
||||
|
||||
def download_history_data2(self, stock_list, period, start_time, end_time, incrementally):
|
||||
if self.sleep:
|
||||
time.sleep(self.sleep)
|
||||
if self.fail_on and self.fail_on in stock_list:
|
||||
raise RuntimeError("boom")
|
||||
self.data2_calls.append(list(stock_list))
|
||||
|
||||
def download_history_data(self, code, period, start_time, end_time, incrementally):
|
||||
if self.sleep:
|
||||
time.sleep(self.sleep)
|
||||
if self.fail_on and code == self.fail_on:
|
||||
raise RuntimeError("boom")
|
||||
self.data_calls.append(code)
|
||||
|
||||
|
||||
class DownloadJobsTest(unittest.TestCase):
|
||||
def test_submit_queues_pending_job(self):
|
||||
r = FakeRedis()
|
||||
job = submit_download_job(r, "acct", ["600000.SH", "000001.SZ"], "1d", chunk_size=1)
|
||||
|
||||
self.assertEqual(job["state"], "pending")
|
||||
self.assertEqual(job["total"], 2)
|
||||
self.assertIn(_enc(job["job_id"]), r.lists[queue_key("acct")])
|
||||
self.assertEqual(read_download_status(r, "acct", job["job_id"])["state"], "pending")
|
||||
|
||||
def test_pump_completes_and_chunks_the_symbol_list(self):
|
||||
r = FakeRedis()
|
||||
md = FakeMarketData()
|
||||
submit_download_job(r, "acct", ["a", "b", "c", "d", "e"], "1d", chunk_size=2)
|
||||
|
||||
# max_wall_seconds=0 disables the budget, so one tick drains the whole job.
|
||||
res = pump_download_jobs(r, md, "acct", chunk_size=2, max_wall_seconds=0)
|
||||
|
||||
self.assertEqual(res["state"], "done")
|
||||
self.assertEqual(res["done"], 5)
|
||||
self.assertEqual(md.data2_calls, [["a", "b"], ["c", "d"], ["e"]])
|
||||
# current pointer cleared when the job finishes.
|
||||
self.assertIsNone(r.get(current_key("acct")))
|
||||
|
||||
def test_pump_spreads_across_ticks_under_wall_budget(self):
|
||||
r = FakeRedis()
|
||||
md = FakeMarketData(sleep=0.02)
|
||||
submit_download_job(r, "acct", ["a", "b", "c"], "1d", chunk_size=1)
|
||||
|
||||
res1 = pump_download_jobs(r, md, "acct", max_wall_seconds=0.005)
|
||||
res2 = pump_download_jobs(r, md, "acct", max_wall_seconds=0.005)
|
||||
res3 = pump_download_jobs(r, md, "acct", max_wall_seconds=0.005)
|
||||
|
||||
# One chunk per tick (each chunk exceeds the tiny budget), progress resumes.
|
||||
self.assertEqual((res1["state"], res1["done"]), ("running", 1))
|
||||
self.assertEqual((res2["state"], res2["done"]), ("running", 2))
|
||||
self.assertEqual((res3["state"], res3["done"]), ("done", 3))
|
||||
self.assertEqual(md.data_calls if md.data_calls else md.data2_calls, [["a"], ["b"], ["c"]])
|
||||
|
||||
def test_pump_marks_failed_and_clears_current(self):
|
||||
r = FakeRedis()
|
||||
md = FakeMarketData(fail_on="b")
|
||||
job = submit_download_job(
|
||||
r, "acct", ["a", "b", "c"], "1d", method="download_history_data", chunk_size=1
|
||||
)
|
||||
|
||||
res = pump_download_jobs(r, md, "acct", max_wall_seconds=0)
|
||||
|
||||
self.assertEqual(res["state"], "failed")
|
||||
self.assertEqual(md.data_calls, ["a"])
|
||||
status = read_download_status(r, "acct", job["job_id"])
|
||||
self.assertEqual(status["state"], "failed")
|
||||
self.assertTrue(status["error"])
|
||||
self.assertIsNone(r.get(current_key("acct")))
|
||||
|
||||
def test_pump_with_no_job_returns_none(self):
|
||||
self.assertIsNone(pump_download_jobs(FakeRedis(), FakeMarketData(), "acct"))
|
||||
|
||||
def test_wait_returns_terminal_status(self):
|
||||
r = FakeRedis()
|
||||
job = submit_download_job(r, "acct", ["a"], "1d", chunk_size=1)
|
||||
status = read_download_status(r, "acct", job["job_id"])
|
||||
status["state"] = "done"
|
||||
status["done"] = 1
|
||||
r.set(job_key("acct", job["job_id"]), _enc(json.dumps(status)))
|
||||
|
||||
res = wait_download_job(r, "acct", job["job_id"], wait_seconds=1, poll_interval_seconds=0.01)
|
||||
|
||||
self.assertEqual(res["state"], "done")
|
||||
|
||||
def test_stored_values_are_digit_free_and_compliance_safe(self):
|
||||
import re
|
||||
|
||||
from bigqmt_signal_trader.download_jobs import _dec
|
||||
|
||||
# The QMT redis compliance filter blocks a response only when it contains a
|
||||
# stock-code pattern (which requires digits). Encoded tokens are all letters.
|
||||
stock_re = re.compile(
|
||||
"(^|[^\\d])+([36]0[\\d]{4}|00(000[1-9]|[1-9][\\d]{3}|[\\d][1-9][\\d]{2}|[\\d]{2}[1-9][\\d]))([^\\d]|$)+"
|
||||
)
|
||||
blob = json.dumps({"stock_list": ["600000.SH", "300750.SZ", "000001.SZ"], "chunk_size": 1})
|
||||
self.assertTrue(stock_re.search(blob)) # plaintext WOULD trip the filter
|
||||
token = _enc(blob)
|
||||
self.assertTrue(all(not c.isdigit() for c in token), "encoded token must be digit-free")
|
||||
self.assertIsNone(stock_re.search(token), "encoded token must not match the stock-code filter")
|
||||
self.assertEqual(_dec(token), blob) # round-trips
|
||||
self.assertIsNone(_dec(None))
|
||||
self.assertIsNone(_dec(""))
|
||||
|
||||
# what actually lands in Redis on submit must also be digit-free
|
||||
r = FakeRedis()
|
||||
job = submit_download_job(r, "acct", ["600000.SH"], "1d", chunk_size=1)
|
||||
stored_blob = r.kv[job_key("acct", job["job_id"])]
|
||||
queued = r.lists[queue_key("acct")][0]
|
||||
self.assertTrue(all(not c.isdigit() for c in stored_blob))
|
||||
self.assertTrue(all(not c.isdigit() for c in queued))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,50 @@
|
||||
import glob
|
||||
import os
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
SRC = os.path.join(ROOT, "src")
|
||||
|
||||
|
||||
class EntryEncodingTest(unittest.TestCase):
|
||||
"""Guard against the QMT load crash: a file declaring ``#coding:gbk`` but
|
||||
containing non-GBK (e.g. UTF-8 Chinese) bytes fails to load under QMT's
|
||||
GBK-based Python with 'gbk codec can't decode ...'. Entry files loaded by the
|
||||
QMT editor must stay GBK-decodable (ASCII is the safe subset)."""
|
||||
|
||||
def test_gbk_declared_files_are_gbk_decodable(self):
|
||||
bad = []
|
||||
for path in glob.glob(os.path.join(SRC, "**", "*.py"), recursive=True):
|
||||
if "__pycache__" in path:
|
||||
continue
|
||||
data = open(path, "rb").read()
|
||||
first_line = data.split(b"\n", 1)[0].lower().replace(b" ", b"")
|
||||
if b"coding:gbk" not in first_line and b"coding=gbk" not in first_line:
|
||||
continue
|
||||
try:
|
||||
data.decode("gbk")
|
||||
except UnicodeDecodeError as exc:
|
||||
bad.append("%s (byte %d)" % (os.path.relpath(path, ROOT), exc.start))
|
||||
self.assertEqual(
|
||||
bad,
|
||||
[],
|
||||
"files declare #coding:gbk but are not GBK-decodable; QMT will fail to load them: %s" % bad,
|
||||
)
|
||||
|
||||
def test_qmt_loader_stops_previous_service_before_clearing_modules(self):
|
||||
"""A QMT strategy restart must release the old ZMQ port first."""
|
||||
path = os.path.join(SRC, "BIGQMT_REDIS_DRYRUN.py")
|
||||
with open(path, "r", encoding="gbk") as source_file:
|
||||
source = source_file.read()
|
||||
self.assertIn("def _stop_previous_rpc_service():", source)
|
||||
stop_call = source.index("\n_stop_previous_rpc_service()\n")
|
||||
clear_call = source.index("\n_clear_local_modules()\n")
|
||||
self.assertLess(
|
||||
stop_call,
|
||||
clear_call,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,633 @@
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.exec_events import (
|
||||
enrich_order_identity,
|
||||
format_raw_snapshot,
|
||||
normalize_cancel_error_event,
|
||||
normalize_order_error_event,
|
||||
normalize_order_event,
|
||||
remember_order_identity,
|
||||
normalize_trade_event,
|
||||
order_channel,
|
||||
order_error_channel,
|
||||
cancel_error_channel,
|
||||
publish_order_event,
|
||||
publish_trade_event,
|
||||
raw_field_snapshot,
|
||||
trade_channel,
|
||||
)
|
||||
from bigqmt_signal_trader.xtquant_compat import BigQmtXtTrader, XtQuantTraderCallback
|
||||
|
||||
|
||||
class FakeDeal:
|
||||
m_strAccountID = "acct"
|
||||
m_strInstrumentID = "600000.SH"
|
||||
m_dPrice = 10.5
|
||||
m_nVolume = 100
|
||||
m_strTradeID = "T1"
|
||||
m_strOrderSysID = "O1"
|
||||
m_strTradeTime = "2026-07-02 10:00:00"
|
||||
m_nDirection = 48
|
||||
m_dTradeAmount = 1050.0
|
||||
m_dComssion = 0.5
|
||||
|
||||
|
||||
class FakeOrder:
|
||||
m_strAccountID = "acct"
|
||||
m_strInstrumentID = "000001.SZ"
|
||||
m_nOrderStatus = 50
|
||||
m_nVolumeTotal = 200
|
||||
m_nVolumeTraded = 50
|
||||
m_dLimitPrice = 9.9
|
||||
m_strOrderSysID = "O2"
|
||||
m_nDirection = 49
|
||||
strategyName = "s1"
|
||||
m_strRemark = "remark-1"
|
||||
m_strOptName = "限价买入"
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
def __init__(self):
|
||||
self.xadds = []
|
||||
self.pubs = []
|
||||
self.kv = {}
|
||||
|
||||
def xadd(self, key, fields, maxlen=None, approximate=None):
|
||||
self.xadds.append((key, fields))
|
||||
return b"1-0"
|
||||
|
||||
def publish(self, key, value):
|
||||
self.pubs.append((key, value))
|
||||
return 1
|
||||
|
||||
def setex(self, key, _ttl, value):
|
||||
self.kv[key] = value
|
||||
return True
|
||||
|
||||
def get(self, key):
|
||||
return self.kv.get(key)
|
||||
|
||||
|
||||
class RecordingCallback(XtQuantTraderCallback):
|
||||
def __init__(self):
|
||||
self.orders = []
|
||||
self.trades = []
|
||||
self.order_errors = []
|
||||
self.cancel_errors = []
|
||||
self.async_responses = []
|
||||
self.cancel_async_responses = []
|
||||
self.account_statuses = []
|
||||
|
||||
def on_stock_order(self, order):
|
||||
self.orders.append(order)
|
||||
|
||||
def on_stock_trade(self, trade):
|
||||
self.trades.append(trade)
|
||||
|
||||
def on_order_error(self, order_error):
|
||||
self.order_errors.append(order_error)
|
||||
|
||||
def on_cancel_error(self, cancel_error):
|
||||
self.cancel_errors.append(cancel_error)
|
||||
|
||||
def on_order_stock_async_response(self, response):
|
||||
self.async_responses.append(response)
|
||||
|
||||
def on_cancel_order_stock_async_response(self, response):
|
||||
self.cancel_async_responses.append(response)
|
||||
|
||||
def on_account_status(self, status):
|
||||
self.account_statuses.append(status)
|
||||
|
||||
|
||||
class ExecEventsServerTest(unittest.TestCase):
|
||||
def test_normalize_trade_event_maps_thinktrader_fields(self):
|
||||
ev = normalize_trade_event(FakeDeal(), "acct")
|
||||
|
||||
self.assertEqual(ev["event_type"], "trade")
|
||||
self.assertEqual(ev["stock_code"], "600000.SH")
|
||||
self.assertEqual(ev["trade_id"], "T1")
|
||||
self.assertEqual(ev["order_sys_id"], "O1")
|
||||
self.assertEqual(ev["volume"], 100)
|
||||
self.assertEqual(ev["price"], 10.5)
|
||||
self.assertEqual(ev["action"], "BUY") # m_nDirection 48 -> buy
|
||||
self.assertEqual(ev["traded_at"], "2026-07-02 10:00:00")
|
||||
self.assertEqual(ev["commission"], 0.5)
|
||||
|
||||
def test_normalize_order_event_maps_thinktrader_fields(self):
|
||||
ev = normalize_order_event(FakeOrder(), "acct")
|
||||
|
||||
self.assertEqual(ev["event_type"], "order")
|
||||
self.assertEqual(ev["stock_code"], "000001.SZ")
|
||||
self.assertEqual(ev["order_sys_id"], "O2")
|
||||
self.assertEqual(ev["order_volume"], 200)
|
||||
self.assertEqual(ev["traded_volume"], 50)
|
||||
self.assertEqual(ev["status"], 50)
|
||||
self.assertEqual(ev["action"], "SELL") # m_nDirection 49 -> sell
|
||||
self.assertEqual(ev["strategy_name"], "s1")
|
||||
self.assertEqual(ev["remark"], "remark-1")
|
||||
self.assertEqual(ev["user_order_id"], "remark-1")
|
||||
self.assertEqual(ev["opt_name"], "限价买入")
|
||||
|
||||
def test_order_event_fills_strategy_from_remark_identity(self):
|
||||
class CallbackOrder:
|
||||
m_strAccountID = "acct"
|
||||
m_strInstrumentID = "159518"
|
||||
m_strRemark = "涨停价买入1手"
|
||||
m_strOptName = "限价买入"
|
||||
|
||||
redis_client = FakeRedis()
|
||||
remember_order_identity(redis_client, "acct", "涨停价买入1手", "rpc_test", "159518")
|
||||
ev = enrich_order_identity(redis_client, "acct", normalize_order_event(CallbackOrder(), "acct"))
|
||||
|
||||
self.assertEqual(ev["strategy_name"], "rpc_test")
|
||||
self.assertEqual(ev["remark"], "涨停价买入1手")
|
||||
|
||||
def test_publish_writes_stream_and_channel(self):
|
||||
r = FakeRedis()
|
||||
publish_trade_event(r, "acct", {"event_type": "trade", "trade_id": "T1"})
|
||||
|
||||
self.assertEqual(r.pubs[0][0], trade_channel("acct"))
|
||||
self.assertEqual(r.xadds[0][0], trade_channel("acct"))
|
||||
self.assertIn("T1", r.pubs[0][1])
|
||||
|
||||
publish_order_event(r, "acct", {"event_type": "order"})
|
||||
self.assertEqual(r.pubs[1][0], order_channel("acct"))
|
||||
|
||||
def test_arbitration_resolves_direction_offset_conflict_via_op_type(self):
|
||||
"""When m_nDirection and m_nOffsetFlag disagree (futures: sell+open),
|
||||
arbitration via m_nOpType picks the semantically correct field."""
|
||||
class Deal:
|
||||
m_strInstrumentID = "600000.SH"
|
||||
m_nDirection = 49 # EEntrustBS sell
|
||||
m_nOffsetFlag = 48 # offset 48 = 开仓 (open)
|
||||
m_nOpType = 24 # STOCK_SELL — arbiter confirms sell
|
||||
m_nVolume = 10
|
||||
m_dPrice = 1.0
|
||||
m_strTradeID = "X"
|
||||
|
||||
ev = normalize_trade_event(Deal(), "acct")
|
||||
|
||||
self.assertEqual(ev["action"], "SELL") # from direction via arbitration
|
||||
self.assertEqual(ev["direction"], 49) # direction field = m_nDirection
|
||||
self.assertEqual(ev["offset_flag"], 48) # raw offset preserved, not conflated
|
||||
|
||||
def test_arbitration_stock_sell_wrong_direction_fixed_by_op_type(self):
|
||||
"""Stock sell: m_nDirection=48 (bug: always 48), m_nOffsetFlag=49,
|
||||
m_nOpType=24 → arbitration picks offset (49→SELL)."""
|
||||
class SellOrder:
|
||||
m_strInstrumentID = "601398.SH"
|
||||
m_nDirection = 48 # QMT bug — always 48 in live callbacks
|
||||
m_nOffsetFlag = 49 # 平仓 = sell (correct)
|
||||
m_nOpType = 24 # STOCK_SELL (correct)
|
||||
m_nVolumeTotal = 100
|
||||
m_nVolumeTraded = 0
|
||||
m_dLimitPrice = 6.34
|
||||
m_strOrderSysID = "S123"
|
||||
|
||||
ev = normalize_order_event(SellOrder(), "acct")
|
||||
self.assertEqual(ev["action"], "SELL")
|
||||
self.assertEqual(ev["direction"], 49) # offset_flag wins via arbitration
|
||||
|
||||
def test_arbitration_stock_buy_agree(self):
|
||||
"""Stock buy: m_nDirection=48, m_nOffsetFlag=48 → agree → BUY."""
|
||||
class BuyOrder:
|
||||
m_strInstrumentID = "601398.SH"
|
||||
m_nDirection = 48
|
||||
m_nOffsetFlag = 48
|
||||
m_nOpType = 23
|
||||
m_nVolumeTotal = 100
|
||||
m_nVolumeTraded = 0
|
||||
m_dLimitPrice = 5.0
|
||||
m_strOrderSysID = "B456"
|
||||
|
||||
ev = normalize_order_event(BuyOrder(), "acct")
|
||||
self.assertEqual(ev["action"], "BUY")
|
||||
self.assertEqual(ev["direction"], 48)
|
||||
|
||||
def test_direction_zero_falls_back_to_offset(self):
|
||||
"""m_nDirection=0 is treated as absent; offset determines direction."""
|
||||
class SellOrder:
|
||||
m_strInstrumentID = "601398.SH"
|
||||
m_nDirection = 0
|
||||
m_nOffsetFlag = 49
|
||||
m_nVolumeTotal = 100
|
||||
m_nVolumeTraded = 0
|
||||
m_dLimitPrice = 6.34
|
||||
m_strOrderSysID = "S123"
|
||||
|
||||
ev = normalize_order_event(SellOrder(), "acct")
|
||||
self.assertEqual(ev["action"], "SELL")
|
||||
self.assertEqual(ev["direction"], 49)
|
||||
|
||||
def test_direction_none_falls_back_to_offset(self):
|
||||
"""m_nDirection=None → offset determines direction."""
|
||||
class BuyOrder:
|
||||
m_strInstrumentID = "601398.SH"
|
||||
m_nDirection = None
|
||||
m_nOffsetFlag = 48
|
||||
m_nVolumeTotal = 100
|
||||
m_nVolumeTraded = 0
|
||||
m_dLimitPrice = 5.0
|
||||
m_strOrderSysID = "B456"
|
||||
|
||||
ev = normalize_order_event(BuyOrder(), "acct")
|
||||
self.assertEqual(ev["action"], "BUY")
|
||||
self.assertEqual(ev["direction"], 48)
|
||||
|
||||
def test_pledge_direction_has_no_buy_sell_action(self):
|
||||
class Deal:
|
||||
m_strInstrumentID = "600000.SH"
|
||||
m_nDirection = 81 # 质押入库
|
||||
m_nVolume = 10
|
||||
m_dPrice = 1.0
|
||||
|
||||
ev = normalize_trade_event(Deal(), "acct")
|
||||
|
||||
self.assertEqual(ev["action"], "") # pledge is neither buy nor sell
|
||||
self.assertEqual(ev["direction"], 81) # raw direction preserved
|
||||
|
||||
def test_normalize_order_error_event_maps_fields(self):
|
||||
class OrderError:
|
||||
m_strAccountID = "acct"
|
||||
m_strInstrumentID = "600654.SH"
|
||||
m_strOrderSysID = "sys-err-1"
|
||||
m_nErrorID = 2147483647
|
||||
m_strErrorMsg = "废单"
|
||||
|
||||
ev = normalize_order_error_event(OrderError(), "acct")
|
||||
|
||||
self.assertEqual(ev["event_type"], "order_error")
|
||||
self.assertEqual(ev["account_id"], "acct")
|
||||
self.assertEqual(ev["stock_code"], "600654.SH")
|
||||
self.assertEqual(ev["order_sys_id"], "sys-err-1")
|
||||
self.assertEqual(ev["error_id"], 2147483647)
|
||||
self.assertEqual(ev["error_msg"], "废单")
|
||||
|
||||
def test_normalize_cancel_error_event_maps_fields(self):
|
||||
class CancelError:
|
||||
m_strAccountID = "acct"
|
||||
m_strInstrumentID = "600654.SH"
|
||||
m_strOrderSysID = "sys-cancel-1"
|
||||
m_nErrorID = 99
|
||||
m_strErrorMsg = "撤单失败"
|
||||
|
||||
ev = normalize_cancel_error_event(CancelError(), "acct")
|
||||
|
||||
self.assertEqual(ev["event_type"], "cancel_error")
|
||||
self.assertEqual(ev["account_id"], "acct")
|
||||
self.assertEqual(ev["order_sys_id"], "sys-cancel-1")
|
||||
self.assertEqual(ev["error_id"], 99)
|
||||
self.assertEqual(ev["error_msg"], "撤单失败")
|
||||
|
||||
def test_error_channels_are_account_scoped(self):
|
||||
self.assertTrue(order_error_channel("acct").endswith(":acct"))
|
||||
self.assertTrue(cancel_error_channel("acct").endswith(":acct"))
|
||||
|
||||
|
||||
class RawFieldSnapshotTest(unittest.TestCase):
|
||||
"""The snapshot exists to settle what live callbacks actually carry, so it
|
||||
must capture m_* and MiniQMT fields alike and never raise."""
|
||||
|
||||
def test_captures_thinktrader_and_miniqmt_fields(self):
|
||||
snap = raw_field_snapshot(FakeOrder())
|
||||
|
||||
self.assertIn("m_nDirection", snap)
|
||||
self.assertIn("49", snap["m_nDirection"])
|
||||
self.assertIn("int", snap["m_nDirection"])
|
||||
self.assertIn("m_strInstrumentID", snap)
|
||||
|
||||
def test_captures_miniqmt_style_object(self):
|
||||
class XtOrderLike:
|
||||
stock_code = "601398.SH"
|
||||
order_type = 24
|
||||
order_volume = 100
|
||||
|
||||
snap = raw_field_snapshot(XtOrderLike())
|
||||
|
||||
self.assertIn("24", snap["order_type"])
|
||||
self.assertIn("601398.SH", snap["stock_code"])
|
||||
|
||||
def test_captures_dict_payload(self):
|
||||
snap = raw_field_snapshot({"m_nOffsetFlag": 48, "order_type": 24})
|
||||
|
||||
self.assertIn("48", snap["m_nOffsetFlag"])
|
||||
self.assertIn("24", snap["order_type"])
|
||||
|
||||
def test_skips_callables_and_dunders(self):
|
||||
class WithMethod:
|
||||
m_nDirection = 49
|
||||
|
||||
def m_method(self):
|
||||
return 1
|
||||
|
||||
snap = raw_field_snapshot(WithMethod())
|
||||
|
||||
self.assertIn("m_nDirection", snap)
|
||||
self.assertNotIn("m_method", snap)
|
||||
|
||||
def test_unreadable_attribute_does_not_raise(self):
|
||||
class Exploding:
|
||||
m_nDirection = 49
|
||||
|
||||
@property
|
||||
def m_nOffsetFlag(self):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
snap = raw_field_snapshot(Exploding())
|
||||
|
||||
self.assertIn("m_nDirection", snap)
|
||||
self.assertIn("unreadable", snap["m_nOffsetFlag"])
|
||||
|
||||
def test_format_is_a_single_ascii_safe_line(self):
|
||||
line = format_raw_snapshot("order", FakeOrder())
|
||||
|
||||
self.assertNotIn("\n", line)
|
||||
self.assertTrue(line.startswith("[bigqmt_exec_raw] order"))
|
||||
self.assertIn("m_nDirection", line)
|
||||
|
||||
|
||||
class ExecEventsClientDispatchTest(unittest.TestCase):
|
||||
def _trader(self):
|
||||
trader = BigQmtXtTrader(account_id="acct")
|
||||
cb = RecordingCallback()
|
||||
trader.register_callback(cb)
|
||||
return trader, cb
|
||||
|
||||
def test_dispatch_trade_invokes_on_stock_trade(self):
|
||||
trader, cb = self._trader()
|
||||
event = {
|
||||
"event_type": "trade",
|
||||
"account_id": "acct",
|
||||
"stock_code": "600000.SH",
|
||||
"order_sys_id": "sys-1",
|
||||
"trade_id": "t-1",
|
||||
"volume": 100,
|
||||
"price": 10.5,
|
||||
"action": "BUY",
|
||||
"traded_at": "2026-07-02 10:00:00",
|
||||
}
|
||||
trader._dispatch_event(json.dumps(event).encode("utf-8"))
|
||||
|
||||
self.assertEqual(len(cb.trades), 1)
|
||||
trade = cb.trades[0]
|
||||
self.assertEqual(trade.stock_code, "600000.SH")
|
||||
self.assertEqual(trade.trade_id, "t-1")
|
||||
self.assertEqual(trade.traded_volume, 100)
|
||||
self.assertEqual(trade.traded_price, 10.5)
|
||||
self.assertEqual(trade.order_type, 23) # BUY -> STOCK_BUY
|
||||
|
||||
def test_dispatch_order_invokes_on_stock_order(self):
|
||||
trader, cb = self._trader()
|
||||
event = {
|
||||
"event_type": "order",
|
||||
"account_id": "acct",
|
||||
"stock_code": "000001.SZ",
|
||||
"order_sys_id": "sys-2",
|
||||
"order_volume": 200,
|
||||
"traded_volume": 50,
|
||||
"price": 9.9,
|
||||
"status": 50,
|
||||
"action": "SELL",
|
||||
}
|
||||
trader._dispatch_event(json.dumps(event).encode("utf-8"))
|
||||
|
||||
self.assertEqual(len(cb.orders), 1)
|
||||
order = cb.orders[0]
|
||||
self.assertEqual(order.stock_code, "000001.SZ")
|
||||
self.assertEqual(order.order_volume, 200)
|
||||
self.assertEqual(order.traded_volume, 50)
|
||||
self.assertEqual(order.order_status, 50)
|
||||
self.assertEqual(order.order_type, 24) # SELL -> STOCK_SELL
|
||||
|
||||
def test_dispatch_without_callback_is_noop(self):
|
||||
trader = BigQmtXtTrader(account_id="acct")
|
||||
# No callback registered; must not raise.
|
||||
trader._dispatch_event(json.dumps({"event_type": "trade"}).encode("utf-8"))
|
||||
|
||||
def test_dispatch_order_error_invokes_on_order_error(self):
|
||||
trader, cb = self._trader()
|
||||
event = {
|
||||
"event_type": "order_error",
|
||||
"account_id": "acct",
|
||||
"stock_code": "600654.SH",
|
||||
"order_sys_id": "sys-err-1",
|
||||
"error_id": 2147483647,
|
||||
"error_msg": "废单",
|
||||
}
|
||||
trader._dispatch_event(json.dumps(event).encode("utf-8"))
|
||||
|
||||
self.assertEqual(len(cb.order_errors), 1)
|
||||
err = cb.order_errors[0]
|
||||
self.assertEqual(err.order_id, "sys-err-1")
|
||||
self.assertEqual(err.error_id, 2147483647)
|
||||
self.assertEqual(err.error_msg, "废单")
|
||||
self.assertEqual(err.stock_code, "600654.SH")
|
||||
|
||||
def test_dispatch_cancel_error_invokes_on_cancel_error(self):
|
||||
trader, cb = self._trader()
|
||||
event = {
|
||||
"event_type": "cancel_error",
|
||||
"account_id": "acct",
|
||||
"stock_code": "600654.SH",
|
||||
"order_sys_id": "sys-cancel-1",
|
||||
"error_id": 99,
|
||||
"error_msg": "撤单失败",
|
||||
}
|
||||
trader._dispatch_event(json.dumps(event).encode("utf-8"))
|
||||
|
||||
self.assertEqual(len(cb.cancel_errors), 1)
|
||||
err = cb.cancel_errors[0]
|
||||
self.assertEqual(err.order_id, "sys-cancel-1")
|
||||
self.assertEqual(err.error_id, 99)
|
||||
self.assertEqual(err.error_msg, "撤单失败")
|
||||
|
||||
def _run_async(self, trader, result=None, raises=None):
|
||||
"""Submit one async order with order_stock_result stubbed, and wait.
|
||||
|
||||
order_stock_async is fire-and-forget since issue #50: it returns the seq
|
||||
without touching the network and the submit happens on a worker thread,
|
||||
so the callback assertions need the queue drained first. The stub is
|
||||
restored only after the worker is done with it.
|
||||
"""
|
||||
original = trader.order_stock_result
|
||||
|
||||
def fake(*args, **kwargs):
|
||||
if raises is not None:
|
||||
raise raises
|
||||
return result
|
||||
|
||||
trader.order_stock_result = fake
|
||||
try:
|
||||
seq = trader.order_stock_async("acct", "600654.SH", 23, 100, 11, 10.0, "s", "r")
|
||||
self.assertTrue(trader.wait_async_orders(timeout=5.0), "async order did not finish")
|
||||
finally:
|
||||
trader.order_stock_result = original
|
||||
return seq
|
||||
|
||||
def test_order_stock_async_returns_seq_without_submitting(self):
|
||||
"""issue #50: the seq must come back before any RPC happens."""
|
||||
trader, _cb = self._trader()
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def blocking(*args, **kwargs):
|
||||
started.set()
|
||||
release.wait(5.0)
|
||||
return {"order_sys_id": "sys-slow"}
|
||||
|
||||
trader.order_stock_result = blocking
|
||||
try:
|
||||
t0 = time.time()
|
||||
seq = trader.order_stock_async("acct", "600654.SH", 23, 100, 11, 10.0, "s", "r")
|
||||
elapsed = time.time() - t0
|
||||
|
||||
self.assertGreater(seq, 0)
|
||||
self.assertLess(elapsed, 0.2, "order_stock_async blocked for %.3fs" % elapsed)
|
||||
self.assertTrue(started.wait(5.0), "submit never ran on the worker")
|
||||
finally:
|
||||
release.set()
|
||||
trader.wait_async_orders(timeout=5.0)
|
||||
|
||||
def test_order_stock_async_fires_response_when_submitted(self):
|
||||
trader, cb = self._trader()
|
||||
seq = self._run_async(trader, result={"order_sys_id": "sys-ok-1", "user_order_id": "u-1"})
|
||||
|
||||
self.assertGreater(seq, 0)
|
||||
self.assertEqual(len(cb.async_responses), 1)
|
||||
resp = cb.async_responses[0]
|
||||
self.assertEqual(resp.order_id, "sys-ok-1")
|
||||
self.assertEqual(resp.account_id, "acct")
|
||||
self.assertEqual(resp.seq, seq)
|
||||
|
||||
def test_order_stock_async_requests_no_settlement_wait(self):
|
||||
"""The server must not hold the reply for the order id on this path."""
|
||||
trader, _cb = self._trader()
|
||||
seen = {}
|
||||
original = trader.order_stock_result
|
||||
|
||||
def fake(*args, **kwargs):
|
||||
seen.update(kwargs)
|
||||
return {"order_sys_id": "sys-1"}
|
||||
|
||||
trader.order_stock_result = fake
|
||||
try:
|
||||
trader.order_stock_async("acct", "600654.SH", 23, 100, 11, 10.0, "s", "r")
|
||||
trader.wait_async_orders(timeout=5.0)
|
||||
finally:
|
||||
trader.order_stock_result = original
|
||||
|
||||
self.assertIs(seen.get("wait_settlement"), False)
|
||||
|
||||
def test_order_stock_async_minus_one_fires_order_error(self):
|
||||
trader, cb = self._trader()
|
||||
seq = self._run_async(trader, result=-1) # MiniQMT: submit failed
|
||||
|
||||
self.assertGreater(seq, 0)
|
||||
self.assertEqual(len(cb.order_errors), 1)
|
||||
err = cb.order_errors[0]
|
||||
self.assertEqual(err.error_id, -1)
|
||||
self.assertEqual(err.stock_code, "600654.SH")
|
||||
self.assertEqual(err.seq, seq) # correlate the failure to the seq
|
||||
# No success response for a failed submit.
|
||||
self.assertEqual(len(cb.async_responses), 0)
|
||||
|
||||
def test_order_stock_async_submitted_without_sysid_fires_response_not_error(self):
|
||||
# issue #38: passorder 已提交但委托号还没分配到(order_sys_id 为空)时,
|
||||
# 必须回调成功响应而不是误报 on_order_error。issue #50 之后这是常态:
|
||||
# 异步路径不再等待委托号,它由 order_callback 推送。
|
||||
trader, cb = self._trader()
|
||||
seq = self._run_async(
|
||||
trader, result={"status": "SUBMITTED", "user_order_id": "u-1", "order_sys_id": ""})
|
||||
|
||||
self.assertGreater(seq, 0)
|
||||
self.assertEqual(len(cb.order_errors), 0)
|
||||
self.assertEqual(len(cb.async_responses), 1)
|
||||
resp = cb.async_responses[0]
|
||||
self.assertEqual(resp.order_id, "u-1") # 委托号未知时回退到 user_order_id
|
||||
self.assertEqual(resp.order_sys_id, "")
|
||||
|
||||
def test_order_stock_async_server_error_fires_order_error_with_reason(self):
|
||||
# server_error(委托没进系统)由 call() 转成异常后,async 必须把真实
|
||||
# 原因回调给 on_order_error(issue #38)。
|
||||
trader, cb = self._trader()
|
||||
seq = self._run_async(trader, raises=RuntimeError(
|
||||
"Big QMT order_stock server_error: passorder submitted but "
|
||||
"order not found in system (stock=600654.SH action=BUY price=10.00 "
|
||||
"volume=100). QMT may have silently rejected it."))
|
||||
|
||||
self.assertGreater(seq, 0)
|
||||
self.assertEqual(len(cb.async_responses), 0)
|
||||
self.assertEqual(len(cb.order_errors), 1)
|
||||
err = cb.order_errors[0]
|
||||
self.assertIn("not found in system", err.error_msg)
|
||||
self.assertEqual(err.stock_code, "600654.SH")
|
||||
|
||||
def test_async_orders_keep_submission_order(self):
|
||||
"""One worker, so responses arrive in the order the calls were made."""
|
||||
trader, cb = self._trader()
|
||||
original = trader.order_stock_result
|
||||
|
||||
def fake(*args, **kwargs):
|
||||
return {"order_sys_id": "sys-%s" % args[1]}
|
||||
|
||||
trader.order_stock_result = fake
|
||||
try:
|
||||
for code in ("A.SH", "B.SH", "C.SH"):
|
||||
trader.order_stock_async("acct", code, 23, 100, 11, 10.0, "s", "r")
|
||||
self.assertTrue(trader.wait_async_orders(timeout=5.0))
|
||||
finally:
|
||||
trader.order_stock_result = original
|
||||
|
||||
self.assertEqual([r.order_id for r in cb.async_responses],
|
||||
["sys-A.SH", "sys-B.SH", "sys-C.SH"])
|
||||
self.assertEqual([r.seq for r in cb.async_responses],
|
||||
sorted(r.seq for r in cb.async_responses))
|
||||
|
||||
def test_cancel_order_stock_async_fires_response(self):
|
||||
trader, cb = self._trader()
|
||||
original = trader.cancel_order_stock_sysid
|
||||
|
||||
def fake_cancel(account, market, sysid):
|
||||
return True
|
||||
|
||||
trader.cancel_order_stock_sysid = fake_cancel
|
||||
try:
|
||||
seq = trader.cancel_order_stock_sysid_async("acct", "SH", "sys-1")
|
||||
finally:
|
||||
trader.cancel_order_stock_sysid = original
|
||||
|
||||
self.assertGreater(seq, 0)
|
||||
self.assertEqual(len(cb.cancel_async_responses), 1)
|
||||
resp = cb.cancel_async_responses[0]
|
||||
self.assertTrue(resp.success)
|
||||
self.assertEqual(resp.order_sys_id, "sys-1")
|
||||
self.assertEqual(resp.account_id, "acct")
|
||||
self.assertEqual(resp.seq, seq)
|
||||
|
||||
def test_connect_and_subscribe_fire_account_status(self):
|
||||
trader, cb = self._trader()
|
||||
trader.client.account_id = "acct"
|
||||
# connect() calls ping via RPC — stub it.
|
||||
trader.client.call = lambda *a, **k: {"ok": True}
|
||||
trader.connect()
|
||||
trader.subscribe("acct")
|
||||
|
||||
self.assertEqual(len(cb.account_statuses), 2)
|
||||
status = cb.account_statuses[0]
|
||||
self.assertEqual(status.account_id, "acct")
|
||||
self.assertEqual(status.account_type, "STOCK")
|
||||
self.assertEqual(status.status, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,340 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader import formula_server as fs
|
||||
|
||||
|
||||
class BsonCodecTest(unittest.TestCase):
|
||||
"""The built-in codec is the no-dependency path; it must round-trip every
|
||||
type this wire carries and stay byte-compatible with pymongo's bson."""
|
||||
|
||||
def _round_trip(self, document):
|
||||
return fs._decode_document(fs._encode_document(document.items()), 0)[0]
|
||||
|
||||
def test_round_trips_scalars(self):
|
||||
doc = {"s": "平安银行", "i": 42, "big": 2 ** 40, "f": 10.29, "t": True, "f2": False, "n": None}
|
||||
|
||||
self.assertEqual(self._round_trip(doc), doc)
|
||||
|
||||
def test_round_trips_nested_containers(self):
|
||||
doc = {"func": "getMarketData", "params": {"fields": ["close", "volume"], "count": -1}}
|
||||
|
||||
self.assertEqual(self._round_trip(doc), doc)
|
||||
|
||||
def test_round_trips_the_actual_request_envelope(self):
|
||||
doc = {
|
||||
"func": "getMarketData",
|
||||
"params": {
|
||||
"fields": ["close"],
|
||||
"stockCodes": ["000001.SZ"],
|
||||
"startTime": "",
|
||||
"endTime": "",
|
||||
"period": "1d",
|
||||
"dividendType": "none",
|
||||
"count": 3,
|
||||
},
|
||||
}
|
||||
|
||||
self.assertEqual(self._round_trip(doc), doc)
|
||||
|
||||
def test_array_order_is_preserved(self):
|
||||
doc = {"codes": ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"]}
|
||||
|
||||
self.assertEqual(self._round_trip(doc)["codes"], doc["codes"])
|
||||
|
||||
def test_matches_pymongo_bson_when_available(self):
|
||||
try:
|
||||
import bson
|
||||
except ImportError:
|
||||
self.skipTest("pymongo bson not installed")
|
||||
doc = {"func": "getLastVolume", "params": {"stockCode": "000001.SZ", "n": 1.5}}
|
||||
|
||||
self.assertEqual(fs._encode_document(doc.items()), bson.BSON.encode(doc))
|
||||
self.assertEqual(fs._decode_document(bson.BSON.encode(doc), 0)[0], doc)
|
||||
|
||||
def test_unsupported_type_is_rejected(self):
|
||||
with self.assertRaises(TypeError):
|
||||
fs._encode_document({"bad": object()}.items())
|
||||
|
||||
|
||||
class AddressResolutionTest(unittest.TestCase):
|
||||
def test_reads_port_from_formulaserver_ini(self):
|
||||
import tempfile
|
||||
|
||||
root = tempfile.mkdtemp()
|
||||
ini_dir = os.path.join(root, "config", "formulaserver")
|
||||
os.makedirs(ini_dir)
|
||||
with open(os.path.join(ini_dir, "formulaserver.ini"), "w") as handle:
|
||||
handle.write("[server_formula]\naddress = 0.0.0.0:58600\n")
|
||||
|
||||
self.assertEqual(fs.read_formulaserver_port(root), 58600)
|
||||
self.assertEqual(fs.resolve_address({"qmt_root": root}), ("127.0.0.1", 58600))
|
||||
|
||||
def test_missing_ini_falls_back_to_default_port(self):
|
||||
self.assertIsNone(fs.read_formulaserver_port(os.path.join(ROOT, "no-such-dir")))
|
||||
host, port = fs.resolve_address({"qmt_root": os.path.join(ROOT, "no-such-dir")})
|
||||
|
||||
self.assertEqual((host, port), ("127.0.0.1", fs.DEFAULT_PORT))
|
||||
|
||||
def test_explicit_config_wins(self):
|
||||
self.assertEqual(
|
||||
fs.resolve_address({"host": "10.0.0.5", "port": 59999}), ("10.0.0.5", 59999)
|
||||
)
|
||||
|
||||
|
||||
class FakeClient(object):
|
||||
host = "127.0.0.1"
|
||||
port = 58600
|
||||
|
||||
def __init__(self, responses=None, error=None):
|
||||
self.responses = responses or {}
|
||||
self.error = error
|
||||
self.calls = []
|
||||
|
||||
def request(self, func, params=None):
|
||||
self.calls.append((func, dict(params or {})))
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.responses.get(func, {"result": None})
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class ParamTranslationTest(unittest.TestCase):
|
||||
def _router(self, responses=None, error=None):
|
||||
client = FakeClient(responses=responses, error=error)
|
||||
return fs.FormulaServerRouter(client=client), client
|
||||
|
||||
def test_instrument_aliases_the_misspelled_volume_fields(self):
|
||||
"""FormulaServer ships FloatVolumn/TotalVolumn; the xtdata SDK spells
|
||||
them FloatVolume/TotalVolume. Downstream reads the SDK spelling."""
|
||||
router, _ = self._router(
|
||||
{"getInstrumentDetail": {"result": {"FloatVolumn": 1.0, "TotalVolumn": 2.0}}}
|
||||
)
|
||||
|
||||
out = router.call("get_instrument", {"code": "000001.SZ"})
|
||||
|
||||
self.assertEqual(out["FloatVolume"], 1.0)
|
||||
self.assertEqual(out["TotalVolume"], 2.0)
|
||||
self.assertEqual(out["FloatVolumn"], 1.0) # raw key still present
|
||||
|
||||
def test_sector_normalizes_the_minus_one_sentinel(self):
|
||||
router, client = self._router({"getStockListInSector": {"result": ["600000.SH"]}})
|
||||
|
||||
router.call("get_stock_list_in_sector", {"sector_name": "沪深300", "real_timetag": -1})
|
||||
|
||||
self.assertEqual(client.calls[0][1], {"sectorName": "沪深300", "realtime": 0})
|
||||
|
||||
def test_market_data_refuses_adjusted_bars(self):
|
||||
"""dividendType is not honoured by the server; serving an adjusted
|
||||
request from here would hand back unadjusted prices silently."""
|
||||
router, client = self._router({"getMarketData": {"result": []}})
|
||||
|
||||
for dividend_type in ("front", "back", "front_ratio"):
|
||||
with self.assertRaises(fs.Unroutable):
|
||||
router.call(
|
||||
"get_market_data_ex",
|
||||
{
|
||||
"field_list": ["close"],
|
||||
"stock_list": ["000001.SZ"],
|
||||
"dividend_type": dividend_type,
|
||||
},
|
||||
)
|
||||
self.assertEqual(client.calls, [])
|
||||
|
||||
def test_market_data_allows_unadjusted(self):
|
||||
router, client = self._router({"getMarketData": {"result": []}})
|
||||
|
||||
router.call(
|
||||
"get_market_data_ex",
|
||||
{"field_list": ["close"], "stock_list": ["000001.SZ"], "dividend_type": "none"},
|
||||
)
|
||||
|
||||
self.assertEqual(client.calls[0][1]["dividendType"], "none")
|
||||
|
||||
def test_market_data_translates_flat_wire_shape(self):
|
||||
router, _ = self._router(
|
||||
{
|
||||
"getMarketData": {
|
||||
"result": [
|
||||
"000001.SZ",
|
||||
["20260703", ["close", 10.29, "volume", 863327.0]],
|
||||
"600000.SH",
|
||||
["20260703", ["close", 8.69, "volume", 695133.0]],
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
out = router.call(
|
||||
"get_market_data_ex",
|
||||
{"field_list": ["close", "volume"], "stock_list": ["000001.SZ", "600000.SH"]},
|
||||
)
|
||||
|
||||
self.assertEqual(out["000001.SZ"]["columns"], ["stime", "close", "volume"])
|
||||
self.assertEqual(
|
||||
out["000001.SZ"]["records"],
|
||||
[{"stime": "20260703", "close": 10.29, "volume": 863327.0}],
|
||||
)
|
||||
self.assertEqual(out["600000.SH"]["records"][0]["close"], 8.69)
|
||||
|
||||
def test_market_data_keeps_requested_codes_with_no_bars(self):
|
||||
router, _ = self._router({"getMarketData": {"result": []}})
|
||||
|
||||
out = router.call(
|
||||
"get_market_data_ex",
|
||||
{"field_list": ["close"], "stock_list": ["000001.SZ", "600000.SH"]},
|
||||
)
|
||||
|
||||
self.assertEqual(sorted(out), ["000001.SZ", "600000.SH"])
|
||||
self.assertEqual(out["000001.SZ"]["records"], [])
|
||||
|
||||
def test_missing_required_params_is_unroutable_not_a_crash(self):
|
||||
router, client = self._router()
|
||||
|
||||
with self.assertRaises(fs.Unroutable):
|
||||
router.call("get_instrument", {})
|
||||
self.assertEqual(client.calls, [])
|
||||
|
||||
|
||||
class FallbackBehaviourTest(unittest.TestCase):
|
||||
def test_unmapped_method_is_not_supported(self):
|
||||
router = fs.FormulaServerRouter(client=FakeClient())
|
||||
|
||||
self.assertFalse(router.supports("get_asset"))
|
||||
self.assertFalse(router.supports("submit_order"))
|
||||
self.assertFalse(router.supports("get_full_tick"))
|
||||
|
||||
def test_trading_dates_and_dividends_stay_on_rpc(self):
|
||||
"""Their FormulaServer params mean something different from ours."""
|
||||
router = fs.FormulaServerRouter(client=FakeClient())
|
||||
|
||||
self.assertFalse(router.supports("get_trading_dates"))
|
||||
self.assertFalse(router.supports("get_divid_factors"))
|
||||
self.assertFalse(router.supports("get_risk_free_rate"))
|
||||
|
||||
def test_transport_failure_trips_the_cooldown(self):
|
||||
router = fs.FormulaServerRouter(
|
||||
client=FakeClient(error=fs.FormulaServerUnavailable("down")),
|
||||
failure_cooldown_seconds=60,
|
||||
)
|
||||
|
||||
with self.assertRaises(fs.Unroutable):
|
||||
router.call("get_last_volume", {"stock": "000001.SZ"})
|
||||
# Breaker is open: no further attempts until the cooldown expires.
|
||||
self.assertFalse(router.supports("get_last_volume"))
|
||||
|
||||
def test_method_not_found_disables_only_that_method(self):
|
||||
router = fs.FormulaServerRouter(
|
||||
client=FakeClient(
|
||||
error=fs.FormulaServerError("nope", error_id=fs.ERROR_METHOD_NOT_FOUND)
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaises(fs.Unroutable):
|
||||
router.call("get_main_contract", {"code_market": "IF00.IF"})
|
||||
|
||||
self.assertFalse(router.supports("get_main_contract"))
|
||||
self.assertTrue(router.supports("get_last_volume")) # breaker not tripped
|
||||
|
||||
def test_disabled_router_supports_nothing(self):
|
||||
router = fs.build_router({"enabled": False})
|
||||
|
||||
for method in fs.SUPPORTED_METHODS:
|
||||
self.assertFalse(router.supports(method))
|
||||
|
||||
def test_enabled_accepts_string_flags(self):
|
||||
self.assertFalse(fs.build_router({"enabled": "false"}).enabled)
|
||||
self.assertFalse(fs.build_router({"enabled": "0"}).enabled)
|
||||
self.assertTrue(fs.build_router({"enabled": "true", "port": 1}).enabled)
|
||||
|
||||
|
||||
class ClientCallIntegrationTest(unittest.TestCase):
|
||||
"""BigQmtRpcClient.call must prefer the router and fall back cleanly."""
|
||||
|
||||
def _client(self, router):
|
||||
from bigqmt_signal_trader.xtquant_compat import BigQmtRpcClient
|
||||
|
||||
client = BigQmtRpcClient(account_id="acct")
|
||||
client._formula_router_instance = router
|
||||
return client
|
||||
|
||||
def test_routed_method_never_touches_rpc(self):
|
||||
router = fs.FormulaServerRouter(
|
||||
client=FakeClient({"getLastVolume": {"result": 123.0}})
|
||||
)
|
||||
client = self._client(router)
|
||||
|
||||
def explode(*args, **kwargs):
|
||||
raise AssertionError("RPC must not be used for a routed method")
|
||||
|
||||
client._transport = explode
|
||||
|
||||
self.assertEqual(client.call("get_last_volume", {"stock": "000001.SZ"}), 123.0)
|
||||
|
||||
def test_unroutable_falls_back_to_rpc(self):
|
||||
router = fs.FormulaServerRouter(
|
||||
client=FakeClient(error=fs.FormulaServerUnavailable("down"))
|
||||
)
|
||||
client = self._client(router)
|
||||
calls = []
|
||||
|
||||
class FakeTransport:
|
||||
def send_request(self, request, timeout):
|
||||
calls.append(request["method"])
|
||||
return {"ok": True, "data": "from-rpc"}
|
||||
|
||||
client._transport = lambda: FakeTransport()
|
||||
|
||||
self.assertEqual(client.call("get_last_volume", {"stock": "000001.SZ"}), "from-rpc")
|
||||
self.assertEqual(calls, ["get_last_volume"])
|
||||
|
||||
def test_unmapped_method_goes_straight_to_rpc(self):
|
||||
router = fs.FormulaServerRouter(client=FakeClient())
|
||||
client = self._client(router)
|
||||
calls = []
|
||||
|
||||
class FakeTransport:
|
||||
def send_request(self, request, timeout):
|
||||
calls.append(request["method"])
|
||||
return {"ok": True, "data": {"cash": 1.0}}
|
||||
|
||||
client._transport = lambda: FakeTransport()
|
||||
|
||||
self.assertEqual(client.call("get_asset", {}), {"cash": 1.0})
|
||||
self.assertEqual(calls, ["get_asset"])
|
||||
|
||||
def test_routed_dataframe_payload_is_restored_like_rpc(self):
|
||||
router = fs.FormulaServerRouter(
|
||||
client=FakeClient(
|
||||
{
|
||||
"getMarketData": {
|
||||
"result": ["000001.SZ", ["20260703", ["close", 10.29]]]
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
client = self._client(router)
|
||||
|
||||
out = client.call(
|
||||
"get_market_data_ex", {"field_list": ["close"], "stock_list": ["000001.SZ"]}
|
||||
)
|
||||
|
||||
frame = out["000001.SZ"]
|
||||
# _restore_jsonable rebuilds a DataFrame when pandas is present, and
|
||||
# degrades to the record list otherwise — same as the RPC path.
|
||||
if hasattr(frame, "columns"):
|
||||
self.assertEqual(list(frame.columns), ["stime", "close"])
|
||||
self.assertEqual(frame.iloc[0]["close"], 10.29)
|
||||
else:
|
||||
self.assertEqual(frame, [{"stime": "20260703", "close": 10.29}])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,114 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.full_tick_cache import (
|
||||
full_tick_demand_key,
|
||||
full_tick_request_id,
|
||||
read_full_tick_cache,
|
||||
refresh_full_tick_cache,
|
||||
request_full_tick_cache,
|
||||
)
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
def __init__(self):
|
||||
self.hashes = {}
|
||||
self.kv = {}
|
||||
self.deleted = []
|
||||
self.expired = []
|
||||
|
||||
def hset(self, key, field, value):
|
||||
self.hashes.setdefault(key, {})[field] = value
|
||||
return 1
|
||||
|
||||
def hgetall(self, key):
|
||||
return self.hashes.get(key, {})
|
||||
|
||||
def hdel(self, key, field):
|
||||
self.deleted.append((key, field))
|
||||
self.hashes.setdefault(key, {}).pop(field, None)
|
||||
return 1
|
||||
|
||||
def expire(self, key, seconds):
|
||||
self.expired.append((key, seconds))
|
||||
return True
|
||||
|
||||
def setex(self, key, seconds, value):
|
||||
self.kv[key] = value
|
||||
self.expired.append((key, seconds))
|
||||
return True
|
||||
|
||||
def get(self, key):
|
||||
return self.kv.get(key)
|
||||
|
||||
|
||||
class FakeContext:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def get_full_tick(self, codes):
|
||||
self.calls.append(list(codes))
|
||||
return {codes[0]: {"lastPrice": 10.0, "bidPrice": [9.9], "askPrice": [10.1]}}
|
||||
|
||||
|
||||
class FullTickCacheTest(unittest.TestCase):
|
||||
def test_request_then_refresh_writes_fresh_snapshot(self):
|
||||
redis_client = FakeRedis()
|
||||
context = FakeContext()
|
||||
|
||||
demand = request_full_tick_cache(redis_client, "acct", ["600000"], demand_ttl_seconds=10)
|
||||
refreshed = refresh_full_tick_cache(redis_client, context, "acct", cache_ttl_seconds=10)
|
||||
ticks = read_full_tick_cache(redis_client, "acct", ["600000.SH"], max_age_seconds=10)
|
||||
|
||||
self.assertEqual(demand["codes"], ["600000.SH"])
|
||||
self.assertEqual(refreshed, 1)
|
||||
self.assertEqual(context.calls, [["600000.SH"]])
|
||||
self.assertEqual(ticks["600000.SH"]["lastPrice"], 10.0)
|
||||
|
||||
def test_expired_demand_is_removed_without_refreshing(self):
|
||||
redis_client = FakeRedis()
|
||||
context = FakeContext()
|
||||
key = full_tick_demand_key("acct")
|
||||
request_id = full_tick_request_id(["600000.SH"])
|
||||
redis_client.hset(
|
||||
key,
|
||||
request_id,
|
||||
'{"request_id":"%s","codes":["600000.SH"],"requested_at_ts":1,"expires_at_ts":1}' % request_id,
|
||||
)
|
||||
|
||||
refreshed = refresh_full_tick_cache(redis_client, context, "acct", cache_ttl_seconds=10)
|
||||
|
||||
self.assertEqual(refreshed, 0)
|
||||
self.assertEqual(context.calls, [])
|
||||
self.assertIn((key, request_id), redis_client.deleted)
|
||||
|
||||
def test_refresh_kind_symbol_skips_market_demands(self):
|
||||
redis_client = FakeRedis()
|
||||
context = FakeContext()
|
||||
request_full_tick_cache(redis_client, "acct", ["600000"], demand_ttl_seconds=10)
|
||||
request_full_tick_cache(redis_client, "acct", ["SH", "SZ"], demand_ttl_seconds=10)
|
||||
|
||||
refreshed = refresh_full_tick_cache(redis_client, context, "acct", cache_ttl_seconds=10, kind="symbol")
|
||||
|
||||
self.assertEqual(refreshed, 1)
|
||||
self.assertEqual(context.calls, [["600000.SH"]])
|
||||
|
||||
def test_refresh_kind_market_skips_symbol_demands(self):
|
||||
redis_client = FakeRedis()
|
||||
context = FakeContext()
|
||||
request_full_tick_cache(redis_client, "acct", ["600000"], demand_ttl_seconds=10)
|
||||
request_full_tick_cache(redis_client, "acct", ["SH", "SZ"], demand_ttl_seconds=10)
|
||||
|
||||
refreshed = refresh_full_tick_cache(redis_client, context, "acct", cache_ttl_seconds=10, kind="market")
|
||||
|
||||
self.assertEqual(refreshed, 1)
|
||||
self.assertEqual(context.calls, [["SH", "SZ"]])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,348 @@
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.local_cache import LocalMarketCache
|
||||
|
||||
|
||||
def _has_pyarrow():
|
||||
try:
|
||||
import pyarrow # noqa: F401
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class LocalMarketCacheTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.dir, ignore_errors=True)
|
||||
|
||||
def test_write_read_merge_dedupe(self):
|
||||
import pandas as pd
|
||||
|
||||
c = LocalMarketCache(self.dir)
|
||||
c.write("600000.SH", "1d", pd.DataFrame({"stime": ["20260101", "20260102"], "close": [1.0, 2.0]}))
|
||||
# overlapping second write: 20260102 should be replaced (keep last), 20260103 appended
|
||||
c.write("600000.SH", "1d", pd.DataFrame({"stime": ["20260102", "20260103"], "close": [2.5, 3.0]}))
|
||||
|
||||
df = c.read("600000.SH", "1d")
|
||||
self.assertEqual(list(df["stime"]), ["20260101", "20260102", "20260103"])
|
||||
self.assertEqual(df[df["stime"] == "20260102"]["close"].iloc[0], 2.5)
|
||||
|
||||
def test_range_and_count_filters(self):
|
||||
import pandas as pd
|
||||
|
||||
c = LocalMarketCache(self.dir)
|
||||
c.write("X", "1d", pd.DataFrame({"stime": ["20260101", "20260102", "20260103"], "close": [1, 2, 3]}))
|
||||
|
||||
self.assertEqual(list(c.read("X", "1d", start_time="20260102")["stime"]), ["20260102", "20260103"])
|
||||
self.assertEqual(list(c.read("X", "1d", end_time="20260102")["stime"]), ["20260101", "20260102"])
|
||||
self.assertEqual(list(c.read("X", "1d", count=1)["stime"]), ["20260103"])
|
||||
self.assertIsNone(c.read("MISSING", "1d"))
|
||||
self.assertEqual(c.covered("X", "1d"), ("20260101", "20260103", 3))
|
||||
|
||||
def test_drops_zero_fill_placeholder_rows(self):
|
||||
import pandas as pd
|
||||
|
||||
c = LocalMarketCache(self.dir)
|
||||
df = pd.DataFrame(
|
||||
{"stime": ["20200101", "20200102", "20260701"], "close": [0.0, 0.0, 8.65], "open": [0.0, 0.0, 8.58]}
|
||||
)
|
||||
c.write("X", "1d", df)
|
||||
self.assertEqual(list(c.read("X", "1d")["stime"]), ["20260701"]) # 0-fill dropped
|
||||
|
||||
# an all-placeholder write must not create/overwrite a cache file
|
||||
self.assertEqual(c.write("Y", "1d", pd.DataFrame({"stime": ["20200101"], "close": [0.0]})), 0)
|
||||
self.assertIsNone(c.read("Y", "1d"))
|
||||
|
||||
def test_dividend_type_keeps_separate_caches(self):
|
||||
import pandas as pd
|
||||
|
||||
c = LocalMarketCache(self.dir)
|
||||
c.write("X", "1d", pd.DataFrame({"stime": ["20260101"], "close": [10.0]}), dividend_type="none")
|
||||
c.write("X", "1d", pd.DataFrame({"stime": ["20260101"], "close": [9.0]}), dividend_type="front")
|
||||
|
||||
self.assertEqual(c.read("X", "1d", dividend_type="none")["close"].iloc[0], 10.0)
|
||||
self.assertEqual(c.read("X", "1d", dividend_type="front")["close"].iloc[0], 9.0)
|
||||
self.assertIsNone(c.read("X", "1d", dividend_type="back"))
|
||||
|
||||
def test_pickle_format_roundtrip(self):
|
||||
import pandas as pd
|
||||
|
||||
c = LocalMarketCache(self.dir, fmt="pkl")
|
||||
c.write("X", "1d", pd.DataFrame({"stime": ["20260101", "20260102"], "close": [1.0, 2.0]}))
|
||||
self.assertTrue(c.path("X", "1d").endswith(".pkl"))
|
||||
self.assertEqual(list(c.read("X", "1d")["close"]), [1.0, 2.0])
|
||||
|
||||
@unittest.skipUnless(_has_pyarrow(), "pyarrow not installed")
|
||||
def test_parquet_format_roundtrip(self):
|
||||
import pandas as pd
|
||||
|
||||
c = LocalMarketCache(self.dir, fmt="parquet")
|
||||
c.write("X", "1d", pd.DataFrame({"stime": ["20260101", "20260102"], "close": [1.0, 2.0]}))
|
||||
self.assertTrue(c.path("X", "1d").endswith(".parquet"))
|
||||
self.assertEqual(list(c.read("X", "1d")["close"]), [1.0, 2.0])
|
||||
|
||||
@unittest.skipUnless(_has_pyarrow(), "pyarrow not installed")
|
||||
def test_migrates_pickle_to_parquet(self):
|
||||
import pandas as pd
|
||||
|
||||
LocalMarketCache(self.dir, fmt="pkl").write("X", "1d", pd.DataFrame({"stime": ["20260101"], "close": [1.0]}))
|
||||
pq = LocalMarketCache(self.dir, fmt="parquet")
|
||||
self.assertEqual(list(pq.read("X", "1d")["close"]), [1.0]) # reads the old pkl
|
||||
pq.write("X", "1d", pd.DataFrame({"stime": ["20260102"], "close": [2.0]}))
|
||||
self.assertTrue(os.path.isfile(pq.path("X", "1d"))) # parquet now exists
|
||||
self.assertFalse(os.path.isfile(pq.path("X", "1d")[:-8] + ".pkl")) # old pkl removed
|
||||
self.assertEqual(list(pq.read("X", "1d")["close"]), [1.0, 2.0]) # merged across formats
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, cache_dir, fallback_rpc=False):
|
||||
self.account_id = "acct"
|
||||
self.calls = []
|
||||
self.call_params = []
|
||||
self.local_cache_config = {"enabled": True, "dir": cache_dir, "fallback_rpc": fallback_rpc}
|
||||
|
||||
def _redis(self):
|
||||
return None
|
||||
|
||||
def call(self, method, params=None, account_id=None, timeout_seconds=None):
|
||||
self.calls.append(method)
|
||||
self.call_params.append((method, params))
|
||||
if method == "get_market_data_ex":
|
||||
import pandas as pd
|
||||
|
||||
codes = (params or {}).get("stock_list") or []
|
||||
return {c: pd.DataFrame({"stime": ["20260626", "20260629"], "close": [8.76, 8.73]}) for c in codes}
|
||||
if method == "download_history_data2":
|
||||
# Server-side raw download (raw bars + dividend factors).
|
||||
return True
|
||||
raise AssertionError("unexpected rpc: %s" % method)
|
||||
|
||||
|
||||
class LocalCacheClientTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.dir, ignore_errors=True)
|
||||
|
||||
def _xt(self, fallback_rpc=False):
|
||||
from bigqmt_signal_trader.xtquant_compat import BigQmtXtData
|
||||
|
||||
return BigQmtXtData(FakeClient(self.dir, fallback_rpc=fallback_rpc))
|
||||
|
||||
def test_download_caches_then_get_local_reads_without_rpc(self):
|
||||
xt = self._xt()
|
||||
progress = []
|
||||
res = xt.download_history_data2(["600000.SH", "000001.SZ"], "1d", callback=lambda d: progress.append(d))
|
||||
|
||||
self.assertEqual(res, {"finished": 2, "total": 2})
|
||||
self.assertEqual(len(progress), 2)
|
||||
self.assertEqual(progress[-1]["stockcode"], "000001.SZ")
|
||||
self.assertEqual(progress[-1]["finished"], 2)
|
||||
calls_after_download = list(xt.client.calls)
|
||||
|
||||
data = xt.get_local_data(stock_list=["600000.SH", "000001.SZ"], period="1d")
|
||||
|
||||
self.assertIn("600000.SH", data)
|
||||
self.assertIn("000001.SZ", data)
|
||||
self.assertEqual(list(data["600000.SH"]["close"]), [8.76, 8.73])
|
||||
# get_local_data must NOT issue any further RPC — pure local read.
|
||||
self.assertEqual(xt.client.calls, calls_after_download)
|
||||
|
||||
def test_get_market_data_ex_caches_through(self):
|
||||
xt = self._xt()
|
||||
# a plain live read must also populate the cache (cache-through)
|
||||
xt.get_market_data_ex(field_list=["close"], stock_list=["600000.SH"], period="1d")
|
||||
n = len(xt.client.calls)
|
||||
|
||||
data = xt.get_local_data(stock_list=["600000.SH"], period="1d")
|
||||
self.assertIn("600000.SH", data)
|
||||
self.assertEqual(len(xt.client.calls), n) # served from cache, no extra RPC
|
||||
|
||||
def test_get_local_miss_returns_empty_and_no_rpc(self):
|
||||
xt = self._xt()
|
||||
data = xt.get_local_data(stock_list=["600000.SH"], period="1d")
|
||||
self.assertEqual(data, {})
|
||||
self.assertEqual(xt.client.calls, [])
|
||||
|
||||
def test_get_local_fallback_rpc_fetches_and_caches(self):
|
||||
xt = self._xt(fallback_rpc=True)
|
||||
data = xt.get_local_data(stock_list=["600000.SH"], period="1d")
|
||||
self.assertIn("600000.SH", data)
|
||||
self.assertIn("get_market_data_ex", xt.client.calls) # fetched on miss
|
||||
# second read is served from cache — no new RPC
|
||||
n = len(xt.client.calls)
|
||||
xt.get_local_data(stock_list=["600000.SH"], period="1d")
|
||||
self.assertEqual(len(xt.client.calls), n)
|
||||
|
||||
|
||||
class AdjustedDownloadTest(unittest.TestCase):
|
||||
"""Adjusted (front/back) downloads must trigger the server-side raw
|
||||
download FIRST: Big QMT computes adjusted bars from raw bars + dividend
|
||||
factors, and without the server-side download the adjusted result is
|
||||
all zeros (verified live with 600654.SH)."""
|
||||
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.dir, ignore_errors=True)
|
||||
|
||||
def _xt(self):
|
||||
from bigqmt_signal_trader.xtquant_compat import BigQmtXtData
|
||||
|
||||
return BigQmtXtData(FakeClient(self.dir))
|
||||
|
||||
def test_front_download_triggers_server_side_raw_download_first(self):
|
||||
xt = self._xt()
|
||||
xt.download_history_data2(["600000.SH"], "1d", start_time="20200101", dividend_type="front")
|
||||
|
||||
# The server-side raw download must run BEFORE the adjusted pull.
|
||||
method_calls = [m for m, _ in xt.client.call_params]
|
||||
self.assertIn("download_history_data2", method_calls)
|
||||
self.assertIn("get_market_data_ex", method_calls)
|
||||
self.assertLess(
|
||||
method_calls.index("download_history_data2"),
|
||||
method_calls.index("get_market_data_ex"),
|
||||
"server-side raw download must precede the adjusted pull",
|
||||
)
|
||||
# The raw download carries the same codes/period/window.
|
||||
raw_call = next(p for m, p in xt.client.call_params if m == "download_history_data2")
|
||||
self.assertEqual(raw_call["stock_list"], ["600000.SH"])
|
||||
self.assertEqual(raw_call["period"], "1d")
|
||||
self.assertEqual(raw_call["start_time"], "20200101")
|
||||
|
||||
def test_none_download_still_triggers_server_side_download(self):
|
||||
"""issue #47: an unadjusted download used to skip the server RPC and
|
||||
only read what Big QMT already had -- a no-op that still reported
|
||||
{finished: N}. xtdata semantics are "populate the local QMT store", and
|
||||
callers (FormulaServer, get_local_data) depend on that actually happening."""
|
||||
xt = self._xt()
|
||||
xt.download_history_data2(["600000.SH"], "1d", dividend_type="none")
|
||||
|
||||
method_calls = [m for m, _ in xt.client.call_params]
|
||||
self.assertIn("download_history_data2", method_calls)
|
||||
self.assertIn("get_market_data_ex", method_calls)
|
||||
self.assertLess(
|
||||
method_calls.index("download_history_data2"),
|
||||
method_calls.index("get_market_data_ex"),
|
||||
"the download must precede the pull, or the pull reads stale data",
|
||||
)
|
||||
raw_call = next(p for m, p in xt.client.call_params if m == "download_history_data2")
|
||||
self.assertEqual(raw_call["stock_list"], ["600000.SH"])
|
||||
self.assertEqual(raw_call["period"], "1d")
|
||||
|
||||
def test_none_download_survives_server_download_failure(self):
|
||||
"""Same best-effort contract the adjusted path already had: a deployment
|
||||
without the QMT global must still get its bars."""
|
||||
xt = self._xt()
|
||||
original_call = xt.client.call
|
||||
|
||||
def failing_download(method, params=None, account_id=None, timeout_seconds=None):
|
||||
if method == "download_history_data2":
|
||||
raise RuntimeError("global not available")
|
||||
return original_call(method, params, account_id=account_id, timeout_seconds=timeout_seconds)
|
||||
|
||||
xt.client.call = failing_download
|
||||
result = xt.download_history_data2(["600000.SH"], "1d", dividend_type="none")
|
||||
|
||||
self.assertEqual(result["finished"], 1)
|
||||
self.assertIn("get_market_data_ex", [m for m, _ in xt.client.call_params])
|
||||
|
||||
def test_front_download_survives_server_download_failure(self):
|
||||
# Deployments without the QMT global must still get the adjusted pull
|
||||
# (best-effort raw download, never fatal).
|
||||
xt = self._xt()
|
||||
original_call = xt.client.call
|
||||
|
||||
def failing_download(method, params=None, account_id=None, timeout_seconds=None):
|
||||
if method == "download_history_data2":
|
||||
raise RuntimeError("global not available")
|
||||
return original_call(method, params, account_id=account_id, timeout_seconds=timeout_seconds)
|
||||
|
||||
xt.client.call = failing_download
|
||||
result = xt.download_history_data2(["600000.SH"], "1d", dividend_type="front")
|
||||
self.assertEqual(result, {"finished": 1, "total": 1}) # adjusted pull still ran
|
||||
|
||||
|
||||
class _AllZeroThenRealClient(FakeClient):
|
||||
"""First adjusted get_market_data_ex returns all-zero bars (server lacks
|
||||
raw data); after a server-side raw download, subsequent pulls are real."""
|
||||
|
||||
def __init__(self, cache_dir):
|
||||
super(_AllZeroThenRealClient, self).__init__(cache_dir)
|
||||
self._downloaded = False
|
||||
|
||||
def call(self, method, params=None, account_id=None, timeout_seconds=None):
|
||||
self.calls.append(method)
|
||||
self.call_params.append((method, params))
|
||||
import pandas as pd
|
||||
|
||||
if method == "download_history_data2":
|
||||
self._downloaded = True
|
||||
return True
|
||||
if method == "get_market_data_ex":
|
||||
codes = (params or {}).get("stock_list") or []
|
||||
if self._downloaded:
|
||||
return {c: pd.DataFrame({"stime": ["20260626", "20260629"], "close": [8.76, 8.73]}) for c in codes}
|
||||
# all-zero symptom: head zeros, last bar live
|
||||
return {c: pd.DataFrame({"stime": ["20260626", "20260629"], "close": [0.0, 8.73]}) for c in codes}
|
||||
raise AssertionError("unexpected rpc: %s" % method)
|
||||
|
||||
|
||||
class AdjustedReadSelfHealTest(unittest.TestCase):
|
||||
"""Reading adjusted bars that come back all-zero must self-heal:
|
||||
trigger a server-side raw download, wait, and retry once."""
|
||||
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.dir, ignore_errors=True)
|
||||
|
||||
def _xt(self):
|
||||
from bigqmt_signal_trader.xtquant_compat import BigQmtXtData
|
||||
|
||||
return BigQmtXtData(_AllZeroThenRealClient(self.dir))
|
||||
|
||||
def test_front_read_self_heals_all_zero_to_real(self):
|
||||
xt = self._xt()
|
||||
data = xt.get_market_data_ex(
|
||||
field_list=["close"], stock_list=["600000.SH"], period="1d",
|
||||
dividend_type="front",
|
||||
)
|
||||
# After self-heal the retry returns real (non-zero) bars.
|
||||
self.assertEqual(list(data["600000.SH"]["close"]), [8.76, 8.73])
|
||||
# The heal path must have triggered a server-side raw download.
|
||||
method_calls = [m for m, _ in xt.client.call_params]
|
||||
self.assertIn("download_history_data2", method_calls)
|
||||
# get_market_data_ex called twice: initial all-zero pull + retry.
|
||||
self.assertEqual(method_calls.count("get_market_data_ex"), 2)
|
||||
|
||||
def test_none_read_does_not_self_heal(self):
|
||||
xt = self._xt()
|
||||
data = xt.get_market_data_ex(
|
||||
field_list=["close"], stock_list=["600000.SH"], period="1d",
|
||||
dividend_type="none",
|
||||
)
|
||||
# none returns whatever the server sent (no heal, single pull).
|
||||
self.assertEqual(list(data["600000.SH"]["close"]), [0.0, 8.73])
|
||||
method_calls = [m for m, _ in xt.client.call_params]
|
||||
self.assertNotIn("download_history_data2", method_calls)
|
||||
self.assertEqual(method_calls.count("get_market_data_ex"), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,59 @@
|
||||
import datetime
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.models import SignalAction, SignalStatus, TradeSignal
|
||||
|
||||
|
||||
def _base_payload(**kwargs):
|
||||
payload = {
|
||||
"signal_id": "sig-001",
|
||||
"account_id": "test",
|
||||
"action": "BUY",
|
||||
"stock_code": "000001.SZ",
|
||||
"amount": 100,
|
||||
"price_type": "AUTO_LIMIT",
|
||||
"created_at": "2026-06-30 09:31:00",
|
||||
"expire_at": "2026-06-30 09:36:00",
|
||||
"schema_version": 1,
|
||||
}
|
||||
payload.update(kwargs)
|
||||
return payload
|
||||
|
||||
|
||||
class TradeSignalModelTest(unittest.TestCase):
|
||||
def test_trade_signal_requires_signal_id(self):
|
||||
payload = _base_payload()
|
||||
payload.pop("signal_id")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "signal_id"):
|
||||
TradeSignal.from_dict(payload)
|
||||
|
||||
def test_trade_signal_parses_buy_payload(self):
|
||||
signal = TradeSignal.from_dict(_base_payload())
|
||||
|
||||
self.assertEqual(signal.signal_id, "sig-001")
|
||||
self.assertEqual(signal.action, SignalAction.BUY)
|
||||
self.assertEqual(signal.status, SignalStatus.PENDING)
|
||||
self.assertEqual(signal.amount, 100)
|
||||
|
||||
def test_sell_signal_requires_amount_or_percentage(self):
|
||||
payload = _base_payload(action="SELL", amount=None, percentage=None)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "amount or percentage"):
|
||||
TradeSignal.from_dict(payload)
|
||||
|
||||
def test_expired_signal_is_detected(self):
|
||||
signal = TradeSignal.from_dict(_base_payload(expire_at="2026-06-30 09:32:00"))
|
||||
now = datetime.datetime(2026, 6, 30, 9, 32, 1)
|
||||
|
||||
self.assertTrue(signal.is_expired(now))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,36 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.adapters.order_dryrun import DryRunOrderGateway
|
||||
from bigqmt_signal_trader.models import OrderRequest
|
||||
|
||||
|
||||
class DryRunOrderGatewayTest(unittest.TestCase):
|
||||
def test_submit_records_request_without_real_order(self):
|
||||
gateway = DryRunOrderGateway()
|
||||
request = OrderRequest(
|
||||
signal_id="sig-001",
|
||||
account_id="test",
|
||||
action="BUY",
|
||||
stock_code="000001.SZ",
|
||||
volume=100,
|
||||
price=10.02,
|
||||
price_type="LIMIT",
|
||||
strategy_name="bigqmt_signal_trader",
|
||||
remark="web_buy_command",
|
||||
)
|
||||
|
||||
result = gateway.submit(request)
|
||||
|
||||
self.assertEqual(result.status, "DRY_RUN")
|
||||
self.assertEqual(gateway.submitted[0], request)
|
||||
self.assertIn("sig-001", result.user_order_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
"""issue #48 (order_time missing) and issue #47 (get_market_data_ex chunking).
|
||||
|
||||
Both are gaps between what Big QMT hands us and what the MiniQMT-shaped API
|
||||
promises: the ORDER row carries a submit time we never read, and a wide
|
||||
stock_list shares one RPC timeout so it either fits or loses everything.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.adapters import order_bigqmt
|
||||
from bigqmt_signal_trader.adapters.order_bigqmt import BigQmtOrderGateway, _order_time_seconds
|
||||
from bigqmt_signal_trader.models import OrderSnapshot
|
||||
from bigqmt_signal_trader.xtquant_compat import BigQmtXtData, BigQmtXtTrader, StockAccount
|
||||
|
||||
|
||||
class Row(object):
|
||||
def __init__(self, **kwargs):
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
|
||||
def _order_row(**overrides):
|
||||
base = dict(
|
||||
m_strOrderSysID="sys-1",
|
||||
m_strRemark="tag-1",
|
||||
m_strInstrumentID="601398",
|
||||
m_strExchangeID="SH",
|
||||
m_nOffsetFlag=48,
|
||||
m_nVolumeTotalOriginal=100,
|
||||
m_nVolumeTraded=0,
|
||||
m_nOrderStatus=50,
|
||||
m_dLimitPrice=7.66,
|
||||
m_strStrategyName="s1",
|
||||
)
|
||||
base.update(overrides)
|
||||
return Row(**base)
|
||||
|
||||
|
||||
def _gateway(rows):
|
||||
return BigQmtOrderGateway(
|
||||
context_info=None,
|
||||
passorder_func=None,
|
||||
cancel_func=None,
|
||||
get_trade_detail_data_func=lambda acct, atype, dtype, sname="": (
|
||||
list(rows) if dtype == "ORDER" else []
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class OrderTimeParsingTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
del order_bigqmt._missing_order_time_reported[:]
|
||||
|
||||
def _expected(self, text):
|
||||
return int(time.mktime(time.strptime(text, "%Y%m%d%H%M%S")))
|
||||
|
||||
def test_parses_split_date_and_time(self):
|
||||
row = _order_row(m_strInsertDate="20260819", m_strInsertTime="093015")
|
||||
self.assertEqual(_order_time_seconds(row), self._expected("20260819093015"))
|
||||
|
||||
def test_tolerates_separators(self):
|
||||
row = _order_row(m_strInsertDate="2026-08-19", m_strInsertTime="09:30:15")
|
||||
self.assertEqual(_order_time_seconds(row), self._expected("20260819093015"))
|
||||
|
||||
def test_drops_sub_second_precision(self):
|
||||
row = _order_row(m_strInsertDate="20260819", m_strInsertTime="09:30:15.123")
|
||||
self.assertEqual(_order_time_seconds(row), self._expected("20260819093015"))
|
||||
|
||||
def test_accepts_alternate_field_spellings(self):
|
||||
for date_field, time_field in (("m_strInsertDate", "m_strInsertTime"),
|
||||
("m_strOrderDate", "m_strOrderTime"),
|
||||
("insert_date", "insert_time")):
|
||||
row = _order_row(**{date_field: "20260819", time_field: "093015"})
|
||||
self.assertEqual(_order_time_seconds(row), self._expected("20260819093015"),
|
||||
"%s/%s" % (date_field, time_field))
|
||||
|
||||
def test_passes_through_an_epoch_value(self):
|
||||
stamp = self._expected("20260819093015")
|
||||
self.assertEqual(_order_time_seconds(_order_row(m_strInsertTime=stamp)), stamp)
|
||||
|
||||
def test_normalizes_millisecond_epoch(self):
|
||||
stamp = self._expected("20260819093015")
|
||||
self.assertEqual(_order_time_seconds(_order_row(m_strInsertTime=stamp * 1000)), stamp)
|
||||
|
||||
def test_missing_fields_yield_zero_not_epoch_start(self):
|
||||
"""0 means "not reported"; 1970-01-01 would look like a real timestamp."""
|
||||
self.assertEqual(_order_time_seconds(_order_row()), 0)
|
||||
|
||||
def test_missing_fields_report_what_the_row_actually_has(self):
|
||||
import contextlib
|
||||
import io as _io
|
||||
|
||||
buffer = _io.StringIO()
|
||||
with contextlib.redirect_stdout(buffer):
|
||||
_order_time_seconds(_order_row(m_strSomethingElse="x"))
|
||||
output = buffer.getvalue()
|
||||
|
||||
self.assertIn("order_time not found", output)
|
||||
self.assertIn("m_strSomethingElse", output)
|
||||
|
||||
def test_missing_field_reported_once_not_per_row(self):
|
||||
import contextlib
|
||||
import io as _io
|
||||
|
||||
buffer = _io.StringIO()
|
||||
with contextlib.redirect_stdout(buffer):
|
||||
for _ in range(5):
|
||||
_order_time_seconds(_order_row())
|
||||
|
||||
self.assertEqual(buffer.getvalue().count("order_time not found"), 1)
|
||||
|
||||
def test_unparseable_date_yields_zero(self):
|
||||
self.assertEqual(
|
||||
_order_time_seconds(_order_row(m_strInsertDate="oops", m_strInsertTime="093015")), 0)
|
||||
|
||||
|
||||
class OrderSnapshotTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
del order_bigqmt._missing_order_time_reported[:]
|
||||
|
||||
def test_defaults_keep_existing_positional_callers_working(self):
|
||||
snapshot = OrderSnapshot("sys", "tag", "601398.SH", "BUY", 100, 0, "50")
|
||||
self.assertEqual(snapshot.order_time, 0)
|
||||
|
||||
def test_gateway_fills_order_time(self):
|
||||
rows = [_order_row(m_strInsertDate="20260819", m_strInsertTime="093015")]
|
||||
order = _gateway(rows).query_orders("acct", "")[0]
|
||||
|
||||
self.assertEqual(order.order_time,
|
||||
int(time.mktime(time.strptime("20260819093015", "%Y%m%d%H%M%S"))))
|
||||
self.assertEqual(order.stock_code, "601398.SH")
|
||||
|
||||
|
||||
class ClientOrderTimeTest(unittest.TestCase):
|
||||
"""The reported symptom: XtOrder.order_time simply is not there."""
|
||||
|
||||
def _trader(self, payload):
|
||||
trader = BigQmtXtTrader(account_id="acct")
|
||||
trader.client.call = lambda method, params=None, account_id=None, **kw: payload
|
||||
return trader
|
||||
|
||||
def test_order_time_is_exposed(self):
|
||||
stamp = int(time.mktime(time.strptime("20260819093015", "%Y%m%d%H%M%S")))
|
||||
orders = self._trader([{
|
||||
"stock_code": "601398.SH", "action": "BUY", "order_sys_id": "sys-1",
|
||||
"volume": 100, "order_time": stamp,
|
||||
}]).query_stock_orders(StockAccount("acct"))
|
||||
|
||||
self.assertEqual(orders[0].order_time, stamp)
|
||||
|
||||
def test_missing_order_time_defaults_to_zero(self):
|
||||
"""A server predating this field must not raise AttributeError."""
|
||||
orders = self._trader([{
|
||||
"stock_code": "601398.SH", "action": "BUY", "order_sys_id": "sys-1", "volume": 100,
|
||||
}]).query_stock_orders(StockAccount("acct"))
|
||||
|
||||
self.assertEqual(orders[0].order_time, 0)
|
||||
|
||||
|
||||
class ChunkingClient(object):
|
||||
"""Records the code count of each request, and can fail chosen codes."""
|
||||
|
||||
def __init__(self, fail_codes=(), max_codes_per_call=None):
|
||||
self.account_id = "acct"
|
||||
self.batches = []
|
||||
self.local_cache_config = {"enabled": False}
|
||||
self.fail_codes = set(fail_codes)
|
||||
self.max_codes_per_call = max_codes_per_call
|
||||
|
||||
def _redis(self):
|
||||
return None
|
||||
|
||||
def call(self, method, params=None, account_id=None, timeout_seconds=None):
|
||||
codes = list((params or {}).get("stock_list") or [])
|
||||
self.batches.append(codes)
|
||||
if self.max_codes_per_call is not None and len(codes) > self.max_codes_per_call:
|
||||
raise TimeoutError("rpc timeout: %d codes" % len(codes))
|
||||
if self.fail_codes & set(codes):
|
||||
raise TimeoutError("rpc timeout")
|
||||
import pandas as pd
|
||||
|
||||
return {c: pd.DataFrame({"stime": ["20260819"], "close": [7.66]}) for c in codes}
|
||||
|
||||
|
||||
class MarketDataChunkingTest(unittest.TestCase):
|
||||
"""issue #47 comment: one request carries every code and one timeout, so a
|
||||
wide stock_list times out and loses the whole pull."""
|
||||
|
||||
def _xt(self, **kwargs):
|
||||
return BigQmtXtData(ChunkingClient(**kwargs))
|
||||
|
||||
def test_wide_list_is_split(self):
|
||||
xt = self._xt()
|
||||
codes = ["C%03d.SH" % i for i in range(250)]
|
||||
|
||||
data = xt.get_market_data_ex(stock_list=codes, period="1d")
|
||||
|
||||
self.assertEqual([len(b) for b in xt.client.batches], [100, 100, 50])
|
||||
self.assertEqual(len(data), 250)
|
||||
|
||||
def test_small_list_stays_a_single_request(self):
|
||||
xt = self._xt()
|
||||
xt.get_market_data_ex(stock_list=["600000.SH", "601398.SH"], period="1d")
|
||||
|
||||
self.assertEqual(len(xt.client.batches), 1)
|
||||
|
||||
def test_chunk_size_zero_restores_single_request(self):
|
||||
xt = self._xt()
|
||||
codes = ["C%03d.SH" % i for i in range(250)]
|
||||
|
||||
xt.get_market_data_ex(stock_list=codes, period="1d", chunk_size=0)
|
||||
|
||||
self.assertEqual(len(xt.client.batches), 1)
|
||||
|
||||
def test_a_wide_pull_that_would_time_out_now_succeeds(self):
|
||||
"""The actual report: the server cannot answer 250 codes at once."""
|
||||
xt = self._xt(max_codes_per_call=120)
|
||||
codes = ["C%03d.SH" % i for i in range(250)]
|
||||
|
||||
data = xt.get_market_data_ex(stock_list=codes, period="1d")
|
||||
|
||||
self.assertEqual(len(data), 250)
|
||||
|
||||
def test_one_failed_batch_does_not_lose_the_others(self):
|
||||
xt = self._xt(fail_codes=["C150.SH"])
|
||||
codes = ["C%03d.SH" % i for i in range(250)]
|
||||
|
||||
data = xt.get_market_data_ex(stock_list=codes, period="1d")
|
||||
|
||||
# The 100-code batch holding C150 is lost; the other 150 survive.
|
||||
self.assertEqual(len(data), 150)
|
||||
self.assertNotIn("C150.SH", data)
|
||||
self.assertIn("C000.SH", data)
|
||||
|
||||
def test_total_failure_raises_rather_than_returning_empty(self):
|
||||
xt = self._xt(max_codes_per_call=0)
|
||||
codes = ["C%03d.SH" % i for i in range(250)]
|
||||
|
||||
with self.assertRaises(TimeoutError):
|
||||
xt.get_market_data_ex(stock_list=codes, period="1d")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,41 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.price_engine import build_order_price
|
||||
|
||||
|
||||
class FakeMarketDataProvider:
|
||||
def get_ticks(self, codes):
|
||||
return {
|
||||
"000001.SZ": {
|
||||
"lastPrice": 10.0,
|
||||
"askPrice": [10.01, 10.02],
|
||||
"bidPrice": [9.99, 9.98],
|
||||
}
|
||||
}
|
||||
|
||||
def get_instrument(self, code):
|
||||
return {
|
||||
"InstrumentStatus": 0,
|
||||
"UpStopPrice": 11.0,
|
||||
"DownStopPrice": 9.0,
|
||||
}
|
||||
|
||||
|
||||
class PriceEngineTest(unittest.TestCase):
|
||||
def test_auto_buy_price_uses_ask2_when_better_than_markup(self):
|
||||
price = build_order_price(FakeMarketDataProvider(), "000001.SZ", "BUY")
|
||||
self.assertEqual(price, 10.02)
|
||||
|
||||
def test_auto_sell_price_uses_bid2_when_better_than_discount(self):
|
||||
price = build_order_price(FakeMarketDataProvider(), "000001.SZ", "SELL")
|
||||
self.assertEqual(price, 9.98)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,148 @@
|
||||
# coding: utf-8
|
||||
"""Unit tests that simulate real QMT failure modes (production edge cases).
|
||||
|
||||
The existing unit tests use happy-path mocks (FakeMarketData always returns
|
||||
{"close": [10.0]}). These tests instead simulate the failure modes users hit
|
||||
in production — so they are caught by the unit suite, not in production:
|
||||
|
||||
- get_positions / get_asset return EMPTY when QMT context is unbound
|
||||
- query_orders returns [] because strategy_name filtering mismatched
|
||||
- get_market_data_ex(dividend_type='front') returns ALL-ZERO bars when the
|
||||
server has no raw data downloaded
|
||||
- order_stock returns -1 (submit failed) -> must surface on_order_error
|
||||
- get_trade_detail_data ORDER/DEAL returns empty in some contexts
|
||||
|
||||
Each test asserts the CODE behaves correctly for that failure (degrades /
|
||||
self-heals / reports) — not merely that the call returns *something*.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.models import PositionSnapshot, AssetSnapshot
|
||||
from bigqmt_signal_trader.redis_rpc import BigQmtRpcHandlers
|
||||
from bigqmt_signal_trader.adapters.order_dryrun import DryRunOrderGateway
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fakes that simulate QMT failure modes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _EmptyPositionProvider:
|
||||
"""QMT context unbound -> POSITION/ACCOUNT queries return empty."""
|
||||
def get_positions(self, account_id):
|
||||
return {}
|
||||
|
||||
def get_asset(self, account_id):
|
||||
return AssetSnapshot(account_id=account_id, cash=None, total_asset=None)
|
||||
|
||||
|
||||
class _EmptyMarketData:
|
||||
"""QMT returns all-zero bars for adjusted reads with no raw data."""
|
||||
def get_market_data_ex(self, **kwargs):
|
||||
import pandas as pd
|
||||
return {"600654.SH": pd.DataFrame({"stime": ["20240101", "20240102"], "close": [0.0, 0.0]})}
|
||||
|
||||
|
||||
class _EmptyOrderGateway(DryRunOrderGateway):
|
||||
"""get_trade_detail_data(ORDER/DEAL) returns empty in some QMT contexts."""
|
||||
def query_orders(self, account_id, strategy_name):
|
||||
return []
|
||||
|
||||
def query_trades(self, account_id, strategy_name):
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ProductionFailureTest(unittest.TestCase):
|
||||
def _handlers(self, market_data=None, position_provider=None, order_gateway=None, allow_order_methods=False):
|
||||
return BigQmtRpcHandlers(
|
||||
account_id="acct",
|
||||
market_data=market_data or _EmptyMarketData(),
|
||||
position_provider=position_provider or _EmptyPositionProvider(),
|
||||
order_gateway=order_gateway or _EmptyOrderGateway(),
|
||||
allow_order_methods=allow_order_methods,
|
||||
)
|
||||
|
||||
# 1. 持仓查询返回空(上下文未绑定)—— 不应崩溃,返回空 dict
|
||||
def test_get_positions_empty_is_graceful(self):
|
||||
handlers = self._handlers()
|
||||
result = handlers.handle("get_positions", {})
|
||||
# Empty dict is a valid graceful result, not an exception.
|
||||
self.assertEqual(result, {})
|
||||
|
||||
def test_get_asset_empty_fields_are_none(self):
|
||||
handlers = self._handlers()
|
||||
result = handlers.handle("get_asset", {})
|
||||
# AssetSnapshot with None fields is valid (QMT context unbound).
|
||||
self.assertIsInstance(result, AssetSnapshot)
|
||||
self.assertIsNone(result.cash)
|
||||
|
||||
# 2. 委托查询返回空(strategy_name 不匹配 / ORDER 上下文无数据)
|
||||
def test_query_orders_empty_is_graceful(self):
|
||||
handlers = self._handlers()
|
||||
result = handlers.handle("query_orders", {})
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_query_trades_empty_is_graceful(self):
|
||||
handlers = self._handlers()
|
||||
result = handlers.handle("query_trades", {})
|
||||
self.assertEqual(result, [])
|
||||
|
||||
# 3. 复权全 0 —— 服务端返回全 0 时,结果应能被识别(客户端自愈逻辑在 compat 层)
|
||||
def test_front_market_data_all_zero_detectable(self):
|
||||
handlers = self._handlers()
|
||||
result = handlers.handle("get_market_data_ex", {
|
||||
"field_list": ["close"], "stock_list": ["600654.SH"],
|
||||
"period": "1d", "count": 2, "dividend_type": "front",
|
||||
})
|
||||
df = result["600654.SH"]
|
||||
closes = list(df["close"]) if hasattr(df, "columns") else df
|
||||
# The handler must NOT silently fix this — it returns the zeros so the
|
||||
# client self-heal logic (in xtquant_compat) can detect and retry.
|
||||
self.assertTrue(all(c == 0.0 for c in closes), "expected all-zero bars from unready server")
|
||||
|
||||
# 4. 下单失败(order_stock 返回 -1)—— 必须能感知,不是静默成功
|
||||
def test_submit_order_negative_one_means_failed(self):
|
||||
class FailingGateway(DryRunOrderGateway):
|
||||
def submit(self, request):
|
||||
from bigqmt_signal_trader.models import OrderSubmitResult
|
||||
return OrderSubmitResult(status="REJECTED", user_order_id="-1", order_sys_id=None, message="submit failed")
|
||||
|
||||
handlers = self._handlers(order_gateway=FailingGateway(), allow_order_methods=True)
|
||||
result = handlers.handle("submit_order", {
|
||||
"stock_code": "600654.SH", "action": "BUY", "volume": 100,
|
||||
"price": 3.0, "price_type": "LIMIT", "strategy_name": "test",
|
||||
})
|
||||
# The result must expose the failure, not look like a success.
|
||||
self.assertEqual(result.user_order_id, "-1")
|
||||
self.assertEqual(result.status, "REJECTED")
|
||||
|
||||
# 5. 委托/成交查询空 + strategy_name 为空字符串时应返回全部(Issue 修复验证)
|
||||
def test_query_orders_empty_strategy_returns_all(self):
|
||||
class TwoStrategyGateway(DryRunOrderGateway):
|
||||
def query_orders(self, account_id, strategy_name):
|
||||
# Simulate: only "" (empty) returns all; specific name returns subset
|
||||
if strategy_name == "":
|
||||
return ["order-A", "order-B", "order-C"]
|
||||
if strategy_name == "rpc_test":
|
||||
return ["order-A"]
|
||||
return []
|
||||
|
||||
handlers = self._handlers(order_gateway=TwoStrategyGateway())
|
||||
# Default (no strategy_name) should pass "" and get all 3
|
||||
all_orders = handlers.handle("query_orders", {})
|
||||
self.assertEqual(len(all_orders), 3)
|
||||
# Explicit filter
|
||||
filtered = handlers.handle("query_orders", {"strategy_name": "rpc_test"})
|
||||
self.assertEqual(len(filtered), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,231 @@
|
||||
# coding: utf-8
|
||||
"""qmt_launcher tests (issue #45).
|
||||
|
||||
The property that matters most is directory scoping: this machine runs several
|
||||
QMT installs side by side, so a name-only match would stop the wrong account's
|
||||
terminal. Process enumeration is stubbed so the tests never touch real ones.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader import qmt_launcher
|
||||
from bigqmt_signal_trader.qmt_launcher import (
|
||||
QmtLauncherError,
|
||||
close_qmt,
|
||||
find_qmt_processes,
|
||||
is_qmt_running,
|
||||
resolve_install_dir,
|
||||
)
|
||||
|
||||
|
||||
LEMO = os.path.normpath("D:/qmt_lemo/bin.x64")
|
||||
OTHER = os.path.normpath("D:/qmt_other/bin.x64")
|
||||
|
||||
FAKE_PROCESSES = [
|
||||
(100, "XtItClient.exe", os.path.join(LEMO, "XtItClient.exe")),
|
||||
(101, "miniquote.exe", os.path.join(LEMO, "miniquote.exe")),
|
||||
(200, "XtItClient.exe", os.path.join(OTHER, "XtItClient.exe")), # another account
|
||||
(300, "notepad.exe", os.path.join(LEMO, "notepad.exe")), # not ours
|
||||
(400, "XtItClient.exe", ""), # unreadable path
|
||||
]
|
||||
|
||||
|
||||
class ProcessStub(object):
|
||||
"""Patch process enumeration and record what gets terminated."""
|
||||
|
||||
def __init__(self, processes=None):
|
||||
self.processes = list(FAKE_PROCESSES if processes is None else processes)
|
||||
self.terminated = []
|
||||
self._orig_iter = None
|
||||
self._orig_term = None
|
||||
self._orig_isdir = None
|
||||
|
||||
def __enter__(self):
|
||||
self._orig_iter = qmt_launcher._iter_processes
|
||||
self._orig_term = qmt_launcher._terminate
|
||||
self._orig_isdir = os.path.isdir
|
||||
|
||||
def _terminate(pid, force=False):
|
||||
self.terminated.append((pid, force))
|
||||
self.processes = [p for p in self.processes if p[0] != pid]
|
||||
return True
|
||||
|
||||
qmt_launcher._iter_processes = lambda: list(self.processes)
|
||||
qmt_launcher._terminate = _terminate
|
||||
os.path.isdir = lambda p: True
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
qmt_launcher._iter_processes = self._orig_iter
|
||||
qmt_launcher._terminate = self._orig_term
|
||||
os.path.isdir = self._orig_isdir
|
||||
return False
|
||||
|
||||
|
||||
class ResolveInstallDirTest(unittest.TestCase):
|
||||
def test_accepts_root_bin_and_exe_paths(self):
|
||||
expected = os.path.normpath("D:/qmt_lemo/bin.x64")
|
||||
orig = os.path.isdir
|
||||
os.path.isdir = lambda p: os.path.basename(os.path.normpath(p)).lower() == "bin.x64"
|
||||
try:
|
||||
for given in ("D:/qmt_lemo", "D:/qmt_lemo/bin.x64",
|
||||
"D:/qmt_lemo/bin.x64/XtItClient.exe"):
|
||||
self.assertEqual(os.path.normpath(resolve_install_dir(given)), expected, given)
|
||||
finally:
|
||||
os.path.isdir = orig
|
||||
|
||||
def test_strips_surrounding_quotes(self):
|
||||
self.assertTrue(resolve_install_dir('"D:/qmt_lemo/bin.x64"').endswith("bin.x64"))
|
||||
|
||||
def test_empty_is_rejected(self):
|
||||
for value in ("", None, " "):
|
||||
with self.assertRaises(QmtLauncherError):
|
||||
resolve_install_dir(value)
|
||||
|
||||
|
||||
class FindProcessesTest(unittest.TestCase):
|
||||
def test_only_matches_the_requested_install(self):
|
||||
with ProcessStub():
|
||||
pids = sorted(p[0] for p in find_qmt_processes("D:/qmt_lemo"))
|
||||
self.assertEqual(pids, [100, 101]) # not 200 (other install)
|
||||
|
||||
def test_other_install_is_found_independently(self):
|
||||
with ProcessStub():
|
||||
pids = sorted(p[0] for p in find_qmt_processes("D:/qmt_other"))
|
||||
self.assertEqual(pids, [200])
|
||||
|
||||
def test_ignores_unrelated_process_names(self):
|
||||
with ProcessStub():
|
||||
names = [p[1] for p in find_qmt_processes("D:/qmt_lemo")]
|
||||
self.assertNotIn("notepad.exe", names)
|
||||
|
||||
def test_skips_processes_with_no_readable_path(self):
|
||||
"""Matching a QMT name with an unknown path would be a coin flip on
|
||||
which install it belongs to -- skip rather than guess."""
|
||||
with ProcessStub():
|
||||
pids = [p[0] for p in find_qmt_processes("D:/qmt_lemo")]
|
||||
self.assertNotIn(400, pids)
|
||||
|
||||
def test_is_qmt_running_reflects_scope(self):
|
||||
with ProcessStub():
|
||||
self.assertTrue(is_qmt_running("D:/qmt_lemo"))
|
||||
self.assertFalse(is_qmt_running("D:/qmt_unused"))
|
||||
|
||||
|
||||
class CloseQmtTest(unittest.TestCase):
|
||||
def test_closes_only_the_requested_install(self):
|
||||
with ProcessStub() as stub:
|
||||
closed = close_qmt("D:/qmt_lemo")
|
||||
self.assertEqual(closed, 2)
|
||||
self.assertEqual(sorted(pid for pid, _ in stub.terminated), [100, 101])
|
||||
self.assertNotIn(200, [pid for pid, _ in stub.terminated])
|
||||
|
||||
def test_terminates_gracefully_before_forcing(self):
|
||||
"""A hard kill skips the terminal's data flush and truncates the
|
||||
local K-line store."""
|
||||
with ProcessStub() as stub:
|
||||
close_qmt("D:/qmt_lemo")
|
||||
self.assertTrue(all(force is False for _, force in stub.terminated))
|
||||
|
||||
def test_escalates_to_force_when_terminate_is_ignored(self):
|
||||
with ProcessStub() as stub:
|
||||
def _stubborn(pid, force=False):
|
||||
stub.terminated.append((pid, force))
|
||||
if force:
|
||||
stub.processes = [p for p in stub.processes if p[0] != pid]
|
||||
return True
|
||||
|
||||
qmt_launcher._terminate = _stubborn
|
||||
close_qmt("D:/qmt_lemo", timeout_seconds=6.0, force_after_seconds=1.0)
|
||||
|
||||
self.assertTrue(any(force for _, force in stub.terminated))
|
||||
|
||||
def test_no_processes_is_not_an_error(self):
|
||||
with ProcessStub(processes=[]):
|
||||
self.assertEqual(close_qmt("D:/qmt_lemo"), 0)
|
||||
|
||||
|
||||
class ReadinessTest(unittest.TestCase):
|
||||
def test_wait_until_ready_raises_on_timeout(self):
|
||||
"""A scheduled restart must fail loudly, not hand the next step a
|
||||
terminal that never came up."""
|
||||
orig = qmt_launcher.port_is_listening
|
||||
qmt_launcher.port_is_listening = lambda *a, **k: False
|
||||
try:
|
||||
with self.assertRaises(QmtLauncherError):
|
||||
qmt_launcher.wait_until_ready(port=1, timeout_seconds=0.3, poll_interval=0.1)
|
||||
finally:
|
||||
qmt_launcher.port_is_listening = orig
|
||||
|
||||
def test_wait_until_ready_returns_elapsed_seconds(self):
|
||||
orig = qmt_launcher.port_is_listening
|
||||
qmt_launcher.port_is_listening = lambda *a, **k: True
|
||||
try:
|
||||
self.assertIsInstance(
|
||||
qmt_launcher.wait_until_ready(port=1, timeout_seconds=5.0), float)
|
||||
finally:
|
||||
qmt_launcher.port_is_listening = orig
|
||||
|
||||
|
||||
class OpenQmtTest(unittest.TestCase):
|
||||
def _stub_spawn(self):
|
||||
calls = []
|
||||
orig_spawn = qmt_launcher._spawn
|
||||
orig_ready = qmt_launcher.wait_until_ready
|
||||
qmt_launcher._spawn = lambda cmd, cwd=None, shell=False: calls.append((cmd, cwd, shell))
|
||||
qmt_launcher.wait_until_ready = lambda *a, **k: 1.0
|
||||
return calls, orig_spawn, orig_ready
|
||||
|
||||
def test_linkmini_mode_passes_the_passwordless_flag(self):
|
||||
calls, orig_spawn, orig_ready = self._stub_spawn()
|
||||
orig_isdir, orig_isfile = os.path.isdir, os.path.isfile
|
||||
os.path.isdir = lambda p: True
|
||||
os.path.isfile = lambda p: p.lower().endswith("xtminiqmt.exe")
|
||||
try:
|
||||
qmt_launcher.open_qmt("D:/qmt_lemo", mode="linkmini")
|
||||
finally:
|
||||
qmt_launcher._spawn, qmt_launcher.wait_until_ready = orig_spawn, orig_ready
|
||||
os.path.isdir, os.path.isfile = orig_isdir, orig_isfile
|
||||
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertIn("linkMini", calls[0][0])
|
||||
|
||||
def test_login_mode_without_credentials_is_rejected(self):
|
||||
calls, orig_spawn, orig_ready = self._stub_spawn()
|
||||
orig_isdir, orig_isfile = os.path.isdir, os.path.isfile
|
||||
os.path.isdir = lambda p: True
|
||||
os.path.isfile = lambda p: True
|
||||
try:
|
||||
with self.assertRaises(QmtLauncherError):
|
||||
qmt_launcher.open_qmt("D:/qmt_lemo", mode="login", credentials={})
|
||||
finally:
|
||||
qmt_launcher._spawn, qmt_launcher.wait_until_ready = orig_spawn, orig_ready
|
||||
os.path.isdir, os.path.isfile = orig_isdir, orig_isfile
|
||||
|
||||
def test_unknown_mode_is_rejected(self):
|
||||
orig = os.path.isdir
|
||||
os.path.isdir = lambda p: True
|
||||
try:
|
||||
with self.assertRaises(QmtLauncherError):
|
||||
qmt_launcher.open_qmt("D:/qmt_lemo", mode="teleport")
|
||||
finally:
|
||||
os.path.isdir = orig
|
||||
|
||||
def test_bat_mode_requires_an_existing_bat(self):
|
||||
orig = os.path.isdir
|
||||
os.path.isdir = lambda p: True
|
||||
try:
|
||||
with self.assertRaises(QmtLauncherError):
|
||||
qmt_launcher.open_qmt("D:/qmt_lemo", mode="bat", bat_path="D:/nope.bat")
|
||||
finally:
|
||||
os.path.isdir = orig
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.quote_push_channel import RedisQuotePushChannel
|
||||
from bigqmt_signal_trader.quote_subscription_manager import (
|
||||
QuoteSourceAdapter,
|
||||
QuoteSubscriptionManager,
|
||||
)
|
||||
|
||||
|
||||
class FakeQuoteSource(QuoteSourceAdapter):
|
||||
"""Captures the on_push callback so tests can fire big-QMT quote events."""
|
||||
|
||||
def __init__(self):
|
||||
self.subscriptions = {}
|
||||
self.unsubscribed = []
|
||||
self._next_handle = 0
|
||||
|
||||
def subscribe(self, codes, on_push):
|
||||
self._next_handle += 1
|
||||
handle = self._next_handle
|
||||
self.subscriptions[handle] = {"codes": list(codes), "on_push": on_push}
|
||||
return handle
|
||||
|
||||
def unsubscribe(self, handle):
|
||||
self.unsubscribed.append(handle)
|
||||
self.subscriptions.pop(handle, None)
|
||||
|
||||
def fire(self, handle, data):
|
||||
self.subscriptions[handle]["on_push"](data)
|
||||
|
||||
|
||||
class RecordingPublisher:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def __call__(self, topic, data):
|
||||
with self._lock:
|
||||
self.calls.append((topic, data))
|
||||
|
||||
|
||||
class OnPushWiringTest(unittest.TestCase):
|
||||
def test_on_push_forwards_to_publisher_with_combo_topic(self):
|
||||
source = FakeQuoteSource()
|
||||
publisher = RecordingPublisher()
|
||||
manager = QuoteSubscriptionManager(source, on_push_publisher=publisher)
|
||||
|
||||
result = manager.subscribe("clientA", "sub1", ["SH", "SZ"])
|
||||
handle = next(iter(source.subscriptions))
|
||||
source.fire(handle, {"000001.SZ": {"lastPrice": 10.5}})
|
||||
|
||||
self.assertEqual(len(publisher.calls), 1)
|
||||
topic, data = publisher.calls[0]
|
||||
self.assertEqual(topic, result["topic"])
|
||||
self.assertEqual(data["000001.SZ"]["lastPrice"], 10.5)
|
||||
|
||||
def test_on_push_ignored_without_publisher(self):
|
||||
source = FakeQuoteSource()
|
||||
manager = QuoteSubscriptionManager(source) # no publisher wired
|
||||
manager.subscribe("clientA", "sub1", ["SH"])
|
||||
handle = next(iter(source.subscriptions))
|
||||
# Must not raise even though nothing consumes the push.
|
||||
source.fire(handle, {"000001.SZ": {"lastPrice": 10.5}})
|
||||
|
||||
def test_on_push_concurrent_callbacks_are_serialized(self):
|
||||
source = FakeQuoteSource()
|
||||
publisher = RecordingPublisher()
|
||||
manager = QuoteSubscriptionManager(source, on_push_publisher=publisher)
|
||||
manager.subscribe("clientA", "sub1", ["SH"])
|
||||
handle = next(iter(source.subscriptions))
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=source.fire, args=(handle, {"000001.SZ": {"n": i}}))
|
||||
for i in range(20)
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
self.assertEqual(len(publisher.calls), 20)
|
||||
self.assertTrue(all(topic == "SH" for topic, _ in publisher.calls))
|
||||
|
||||
def test_push_stops_after_unsubscribe(self):
|
||||
source = FakeQuoteSource()
|
||||
publisher = RecordingPublisher()
|
||||
manager = QuoteSubscriptionManager(source, on_push_publisher=publisher)
|
||||
manager.subscribe("clientA", "sub1", ["SH"])
|
||||
handle = next(iter(source.subscriptions))
|
||||
manager.unsubscribe("clientA", "sub1")
|
||||
# Subscription torn down at the source; nothing left to fire.
|
||||
self.assertNotIn(handle, source.subscriptions)
|
||||
|
||||
def test_concurrent_subscribe_unsubscribe_reap_and_push(self):
|
||||
# Hammer the manager from the RPC thread (subscribe/unsubscribe), the
|
||||
# scheduler thread (reap) and the quote thread (on_push) at once. The
|
||||
# shared combo/sub-index state must stay consistent: no KeyError, no
|
||||
# publish to a torn-down combo, and the source ends fully unsubscribed.
|
||||
source = FakeQuoteSource()
|
||||
publisher = RecordingPublisher()
|
||||
clock = [0.0]
|
||||
manager = QuoteSubscriptionManager(
|
||||
source, heartbeat_timeout_seconds=30.0, time_func=lambda: clock[0],
|
||||
on_push_publisher=publisher,
|
||||
)
|
||||
|
||||
errors = []
|
||||
|
||||
def churn(i):
|
||||
try:
|
||||
for n in range(30):
|
||||
sub = "sub-%d-%d" % (i, n)
|
||||
manager.subscribe("client-%d" % i, sub, ["SH"])
|
||||
handle = next(iter(source.subscriptions), None)
|
||||
if handle is not None:
|
||||
source.fire(handle, {"x": n})
|
||||
manager.unsubscribe("client-%d" % i, sub)
|
||||
except Exception as exc: # noqa: BLE001 - surface any race as a failure
|
||||
errors.append(exc)
|
||||
|
||||
def reap():
|
||||
try:
|
||||
for _ in range(30):
|
||||
clock[0] += 1.0
|
||||
manager.reap_expired()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(exc)
|
||||
|
||||
workers = [threading.Thread(target=churn, args=(i,)) for i in range(4)]
|
||||
workers.append(threading.Thread(target=reap))
|
||||
for w in workers:
|
||||
w.start()
|
||||
for w in workers:
|
||||
w.join()
|
||||
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(len(source.subscriptions), 0)
|
||||
|
||||
|
||||
class OnPushToRedisChannelTest(unittest.TestCase):
|
||||
def test_manager_wired_to_real_channel_publishes(self):
|
||||
class FakeRedis:
|
||||
def __init__(self):
|
||||
self.messages = {}
|
||||
|
||||
def publish(self, channel, value):
|
||||
self.messages.setdefault(channel, []).append(value)
|
||||
return 1
|
||||
|
||||
redis_client = FakeRedis()
|
||||
channel = RedisQuotePushChannel(redis_client, account_id="acct")
|
||||
channel.start_publisher()
|
||||
|
||||
source = FakeQuoteSource()
|
||||
manager = QuoteSubscriptionManager(source, on_push_publisher=channel.publish)
|
||||
manager.subscribe("clientA", "sub1", ["SH"])
|
||||
handle = next(iter(source.subscriptions))
|
||||
source.fire(handle, {"000001.SZ": {"lastPrice": 10.5}})
|
||||
|
||||
self.assertIn("bigqmt:quote_push:acct:SH", redis_client.messages)
|
||||
channel.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,190 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.quote_push_channel import (
|
||||
_HAS_MSGPACK,
|
||||
RedisQuotePushChannel,
|
||||
ZmqQuotePushChannel,
|
||||
decode_push_payload,
|
||||
encode_push_payload,
|
||||
)
|
||||
|
||||
|
||||
def _msgpack_packb(payload):
|
||||
import msgpack
|
||||
|
||||
return msgpack.packb(payload, use_bin_type=True)
|
||||
|
||||
|
||||
class FakePubSub:
|
||||
def __init__(self, redis_client):
|
||||
self._redis = redis_client
|
||||
self._channels = []
|
||||
self._closed = False
|
||||
|
||||
def subscribe(self, *channels):
|
||||
self._channels.extend(channels)
|
||||
|
||||
def get_message(self, timeout=0.1):
|
||||
# Pull one queued message for a subscribed channel, if any.
|
||||
for _ in range(50):
|
||||
for channel in self._channels:
|
||||
queue = self._redis.messages.setdefault(channel, [])
|
||||
if queue:
|
||||
return {"type": "message", "channel": channel, "data": queue.pop(0)}
|
||||
time.sleep(0.01)
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
self._closed = True
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
def __init__(self):
|
||||
self.messages = {}
|
||||
|
||||
def publish(self, channel, value):
|
||||
self.messages.setdefault(channel, []).append(value)
|
||||
return 1
|
||||
|
||||
def pubsub(self, ignore_subscribe_messages=True):
|
||||
return FakePubSub(self)
|
||||
|
||||
|
||||
class PayloadCodecTest(unittest.TestCase):
|
||||
def test_roundtrip(self):
|
||||
payload = {"combo_key": "SH,SZ", "data": {"000001.SZ": {"lastPrice": 10.5}}, "ts": 1.5}
|
||||
blob = encode_push_payload(payload)
|
||||
self.assertEqual(decode_push_payload(blob), payload)
|
||||
|
||||
def test_binary_blob(self):
|
||||
# Wire encoding must be bytes (msgpack or utf-8 json), not str.
|
||||
blob = encode_push_payload({"a": 1})
|
||||
self.assertIsInstance(blob, (bytes, bytearray))
|
||||
|
||||
def test_decode_json_wire_with_msgpack_available(self):
|
||||
# 服务端未装 msgpack 时用 json 兜底编码;若客户端装了 msgpack,
|
||||
# msgpack.unpackb 会把 json 文本首字节 '{'(0x7B) 当整数解析并抛
|
||||
# ExtraData("unpack(b) received extra data")——真实联调中触发。
|
||||
# decode 必须识别并回退到 json。
|
||||
payload = {"combo_key": "000001.SZ", "data": {"000001.SZ": {"lastPrice": 11.19}}}
|
||||
wire = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
|
||||
self.assertIsInstance(wire, bytes)
|
||||
self.assertEqual(decode_push_payload(wire), payload)
|
||||
|
||||
def test_decode_msgpack_wire_without_msgpack_available(self):
|
||||
# 反向:服务端装了 msgpack、客户端未装(仅 json 可用)。msgpack 字节流
|
||||
# 通常不是合法 utf-8,json 解码会失败——此时应显式尝试 msgpack 解码,
|
||||
# 而不是吞掉异常返回 None。
|
||||
payload = {"combo_key": "SH,SZ", "data": {"600000.SH": {"lastPrice": 9.9}}}
|
||||
if not _HAS_MSGPACK:
|
||||
self.skipTest("msgpack not installed on this client")
|
||||
wire = _msgpack_packb(payload)
|
||||
self.assertEqual(decode_push_payload(wire), payload)
|
||||
|
||||
|
||||
class ZmqPushChannelTest(unittest.TestCase):
|
||||
def test_pub_sub_roundtrip_and_topic_filter(self):
|
||||
zmq = __import__("zmq")
|
||||
ctx = zmq.Context.instance()
|
||||
pub_addr = "inproc://quote-push-test-%d" % id(self)
|
||||
|
||||
server = ZmqQuotePushChannel(bind_address=pub_addr, context=ctx)
|
||||
server.start_publisher()
|
||||
|
||||
received = []
|
||||
done = threading.Event()
|
||||
|
||||
client = ZmqQuotePushChannel(connect_address=pub_addr, context=ctx)
|
||||
|
||||
def on_msg(topic, data):
|
||||
received.append((topic, data))
|
||||
done.set()
|
||||
|
||||
client.start_subscriber(["SH,SZ"], on_msg)
|
||||
try:
|
||||
# Give the SUB a moment to connect + apply the subscription filter.
|
||||
time.sleep(0.2)
|
||||
server.publish("SH,SZ", {"000001.SZ": {"lastPrice": 10.5}})
|
||||
server.publish("SH", {"600000.SH": {"lastPrice": 9.9}}) # filtered out
|
||||
self.assertTrue(done.wait(2.0), "subscriber did not receive the SH,SZ push")
|
||||
finally:
|
||||
client.stop()
|
||||
server.stop()
|
||||
|
||||
self.assertEqual(len(received), 1)
|
||||
topic, data = received[0]
|
||||
self.assertEqual(topic, "SH,SZ")
|
||||
self.assertEqual(data["000001.SZ"]["lastPrice"], 10.5)
|
||||
|
||||
def test_stop_from_foreign_thread_while_sub_active_does_not_crash(self):
|
||||
"""Regression: stop() used to close the SUB socket from the calling
|
||||
thread while the sub thread was still polling it -> Windows ZMQ
|
||||
signaler abort -> QMT process crash ("auto-exit"). The sub thread must
|
||||
close its own socket; repeated stop/start (topic changes) must be safe."""
|
||||
zmq = __import__("zmq")
|
||||
ctx = zmq.Context.instance()
|
||||
pub_addr = "inproc://quote-push-stop-%d" % id(self)
|
||||
|
||||
server = ZmqQuotePushChannel(bind_address=pub_addr, context=ctx)
|
||||
server.start_publisher()
|
||||
client = ZmqQuotePushChannel(connect_address=pub_addr, context=ctx)
|
||||
try:
|
||||
for i in range(5):
|
||||
# Simulate _sync_subscriber_locked topic changes: start a
|
||||
# subscriber, immediately stop it from this (foreign) thread.
|
||||
client.start_subscriber(["T%d" % i], lambda t, d: None)
|
||||
time.sleep(0.05)
|
||||
client.stop() # must not raise / abort
|
||||
finally:
|
||||
client.stop()
|
||||
server.stop()
|
||||
# Reaching here without an abort/crash is the assertion.
|
||||
self.assertTrue(True)
|
||||
|
||||
|
||||
class RedisPushChannelTest(unittest.TestCase):
|
||||
def test_pub_sub_roundtrip(self):
|
||||
redis_client = FakeRedis()
|
||||
server = RedisQuotePushChannel(redis_client, account_id="acct")
|
||||
server.start_publisher()
|
||||
|
||||
received = []
|
||||
done = threading.Event()
|
||||
client = RedisQuotePushChannel(redis_client, account_id="acct")
|
||||
|
||||
def on_msg(topic, data):
|
||||
received.append((topic, data))
|
||||
done.set()
|
||||
|
||||
client.start_subscriber(["SH,SZ"], on_msg)
|
||||
try:
|
||||
time.sleep(0.1)
|
||||
server.publish("SH,SZ", {"000001.SZ": {"lastPrice": 10.5}})
|
||||
self.assertTrue(done.wait(2.0), "redis subscriber did not receive the push")
|
||||
finally:
|
||||
client.stop()
|
||||
server.stop()
|
||||
|
||||
self.assertEqual(received[0][0], "SH,SZ")
|
||||
self.assertEqual(received[0][1]["000001.SZ"]["lastPrice"], 10.5)
|
||||
|
||||
def test_channel_name_scoped_by_account(self):
|
||||
redis_client = FakeRedis()
|
||||
server = RedisQuotePushChannel(redis_client, account_id="acct")
|
||||
server.start_publisher()
|
||||
server.publish("SH", {"x": 1})
|
||||
server.stop()
|
||||
self.assertIn("bigqmt:quote_push:acct:SH", redis_client.messages)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.quote_subscription_manager import (
|
||||
ContextInfoQuoteSource,
|
||||
QuoteSourceAdapter,
|
||||
QuoteSubscriptionManager,
|
||||
combo_key,
|
||||
)
|
||||
|
||||
|
||||
class FakeContextInfo:
|
||||
"""Mirrors big-QMT ContextInfo subscribe_whole_quote/unsubscribe_quote."""
|
||||
|
||||
def __init__(self, fail=False):
|
||||
self.calls = []
|
||||
self.fail = fail
|
||||
self._next = 0
|
||||
|
||||
def subscribe_whole_quote(self, code_list, callback=None):
|
||||
self.calls.append(("subscribe", list(code_list)))
|
||||
if self.fail:
|
||||
return -1
|
||||
self._next += 1
|
||||
return self._next
|
||||
|
||||
def unsubscribe_quote(self, sub_id):
|
||||
self.calls.append(("unsubscribe", sub_id))
|
||||
return 0
|
||||
|
||||
|
||||
class ContextInfoQuoteSourceTest(unittest.TestCase):
|
||||
def test_subscribe_returns_handle_and_forwards_callback(self):
|
||||
context = FakeContextInfo()
|
||||
source = ContextInfoQuoteSource(context)
|
||||
received = []
|
||||
handle = source.subscribe(["SH", "SZ"], received.append)
|
||||
self.assertEqual(handle, 1)
|
||||
self.assertEqual(context.calls, [("subscribe", ["SH", "SZ"])])
|
||||
|
||||
def test_subscribe_failure_raises(self):
|
||||
context = FakeContextInfo(fail=True)
|
||||
source = ContextInfoQuoteSource(context)
|
||||
with self.assertRaises(RuntimeError):
|
||||
source.subscribe(["SH"], lambda d: None)
|
||||
|
||||
def test_unsubscribe_forwards_sub_id(self):
|
||||
context = FakeContextInfo()
|
||||
source = ContextInfoQuoteSource(context)
|
||||
handle = source.subscribe(["SH"], lambda d: None)
|
||||
source.unsubscribe(handle)
|
||||
self.assertEqual(context.calls[-1], ("unsubscribe", handle))
|
||||
|
||||
|
||||
|
||||
class FakeQuoteSource(QuoteSourceAdapter):
|
||||
"""Records subscribe/unsubscribe calls against a fake big-QMT quote source."""
|
||||
|
||||
def __init__(self):
|
||||
self.subscriptions = {}
|
||||
self.unsubscribed = []
|
||||
self._next_handle = 0
|
||||
|
||||
def subscribe(self, codes, on_push):
|
||||
self._next_handle += 1
|
||||
handle = self._next_handle
|
||||
self.subscriptions[handle] = {"codes": list(codes), "on_push": on_push}
|
||||
return handle
|
||||
|
||||
def unsubscribe(self, handle):
|
||||
self.unsubscribed.append(handle)
|
||||
self.subscriptions.pop(handle, None)
|
||||
|
||||
|
||||
class ComboKeyTest(unittest.TestCase):
|
||||
def test_order_independent(self):
|
||||
self.assertEqual(combo_key(["SH", "SZ"]), combo_key(["SZ", "SH"]))
|
||||
|
||||
def test_case_and_whitespace_normalized(self):
|
||||
self.assertEqual(combo_key([" sh ", "sz"]), combo_key(["SH", "SZ"]))
|
||||
|
||||
def test_duplicates_collapse(self):
|
||||
self.assertEqual(combo_key(["SH", "SH", "SZ"]), combo_key(["SH", "SZ"]))
|
||||
|
||||
def test_symbol_list(self):
|
||||
self.assertEqual(
|
||||
combo_key(["600000.SH", "000001.SZ"]),
|
||||
combo_key(["000001.SZ", "600000.SH"]),
|
||||
)
|
||||
|
||||
def test_empty_entries_dropped(self):
|
||||
self.assertEqual(combo_key(["SH", "", None, " "]), "SH")
|
||||
|
||||
def test_empty_list(self):
|
||||
self.assertEqual(combo_key([]), "")
|
||||
|
||||
|
||||
class QuoteSubscriptionManagerTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.source = FakeQuoteSource()
|
||||
self.manager = QuoteSubscriptionManager(
|
||||
self.source,
|
||||
heartbeat_timeout_seconds=30.0,
|
||||
)
|
||||
|
||||
# -- first client creates the big-QMT subscription -----------------------
|
||||
def test_first_subscribe_creates_qmt_subscription(self):
|
||||
result = self.manager.subscribe("clientA", "sub1", ["SH", "SZ"])
|
||||
self.assertEqual(len(self.source.subscriptions), 1)
|
||||
handle = next(iter(self.source.subscriptions))
|
||||
self.assertEqual(self.source.subscriptions[handle]["codes"], ["SH", "SZ"])
|
||||
self.assertEqual(result["combo_key"], "SH,SZ")
|
||||
self.assertIn("topic", result)
|
||||
|
||||
def test_same_combo_different_order_shares_subscription(self):
|
||||
self.manager.subscribe("clientA", "sub1", ["SH", "SZ"])
|
||||
self.manager.subscribe("clientB", "sub2", ["sz", "sh"])
|
||||
# Same normalized combo -> only one big-QMT subscription.
|
||||
self.assertEqual(len(self.source.subscriptions), 1)
|
||||
|
||||
def test_different_combos_create_separate_subscriptions(self):
|
||||
self.manager.subscribe("clientA", "sub1", ["SH"])
|
||||
self.manager.subscribe("clientB", "sub2", ["SZ"])
|
||||
self.assertEqual(len(self.source.subscriptions), 2)
|
||||
|
||||
def test_idempotent_replay_same_client_and_sub(self):
|
||||
# Replayed subscribe (recovery) must not create a second big-QMT subscription.
|
||||
self.manager.subscribe("clientA", "sub1", ["SH", "SZ"])
|
||||
self.manager.subscribe("clientA", "sub1", ["SH", "SZ"])
|
||||
self.assertEqual(len(self.source.subscriptions), 1)
|
||||
|
||||
def test_subscribe_response_carries_push_endpoint_when_configured(self):
|
||||
manager = QuoteSubscriptionManager(
|
||||
self.source, push_endpoint="tcp://127.0.0.1:15561"
|
||||
)
|
||||
result = manager.subscribe("clientA", "sub1", ["SH"])
|
||||
self.assertEqual(result["push_endpoint"], "tcp://127.0.0.1:15561")
|
||||
|
||||
def test_subscribe_response_push_endpoint_defaults_empty(self):
|
||||
result = self.manager.subscribe("clientA", "sub1", ["SH"])
|
||||
self.assertEqual(result["push_endpoint"], "")
|
||||
|
||||
# -- reference counting / unsubscribe ------------------------------------
|
||||
def test_unsubscribe_one_of_two_keeps_qmt_subscription(self):
|
||||
self.manager.subscribe("clientA", "sub1", ["SH", "SZ"])
|
||||
self.manager.subscribe("clientB", "sub2", ["SH", "SZ"])
|
||||
self.manager.unsubscribe("clientA", "sub1")
|
||||
self.assertEqual(len(self.source.subscriptions), 1)
|
||||
self.assertEqual(self.source.unsubscribed, [])
|
||||
|
||||
def test_unsubscribe_one_sub_of_same_client_keeps_combo_alive(self):
|
||||
"""同一 client 两个 sub_id 订阅同一组合,退订一个后另一个仍在:
|
||||
服务端引用按 (client_id, sub_id) 粒度,不按 client 粒度。"""
|
||||
self.manager.subscribe("clientA", "sub1", ["SH", "SZ"])
|
||||
self.manager.subscribe("clientA", "sub2", ["SH", "SZ"])
|
||||
self.manager.unsubscribe("clientA", "sub1")
|
||||
self.assertEqual(len(self.source.subscriptions), 1, "组合不应被拆掉")
|
||||
self.assertEqual(self.source.unsubscribed, [], "不应退订大 QMT 订阅")
|
||||
# 另一个 sub 还在:keepalive 应仍有效(不产生新订阅/退订)
|
||||
self.manager.keepalive("clientA", "sub2")
|
||||
self.assertEqual(len(self.source.subscriptions), 1)
|
||||
# 最后一个 sub 退订 -> 组合拆掉
|
||||
self.manager.unsubscribe("clientA", "sub2")
|
||||
self.assertEqual(len(self.source.subscriptions), 0)
|
||||
self.assertEqual(len(self.source.unsubscribed), 1)
|
||||
|
||||
def test_unsubscribe_last_client_unsubscribes_qmt(self):
|
||||
self.manager.subscribe("clientA", "sub1", ["SH", "SZ"])
|
||||
self.manager.subscribe("clientB", "sub2", ["SH", "SZ"])
|
||||
self.manager.unsubscribe("clientA", "sub1")
|
||||
self.manager.unsubscribe("clientB", "sub2")
|
||||
self.assertEqual(len(self.source.subscriptions), 0)
|
||||
self.assertEqual(len(self.source.unsubscribed), 1)
|
||||
|
||||
def test_unsubscribe_unknown_sub_is_noop(self):
|
||||
self.manager.subscribe("clientA", "sub1", ["SH"])
|
||||
# Should not raise, should not affect the live subscription.
|
||||
self.manager.unsubscribe("clientA", "no-such-sub")
|
||||
self.assertEqual(len(self.source.subscriptions), 1)
|
||||
|
||||
# -- keepalive / reaper ----------------------------------------------------
|
||||
def test_keepalive_refreshes_last_seen(self):
|
||||
self.manager.subscribe("clientA", "sub1", ["SH"])
|
||||
self.manager.keepalive("clientA", "sub1")
|
||||
# Still alive -> reaping at a fresh timestamp removes nothing.
|
||||
self.assertEqual(self.manager.reap_expired(now=self.manager._now()), 0)
|
||||
self.assertEqual(len(self.source.subscriptions), 1)
|
||||
|
||||
def test_reap_removes_timed_out_client_and_unsubscribes(self):
|
||||
self.manager.subscribe("clientA", "sub1", ["SH"])
|
||||
now = self.manager._now()
|
||||
# Advance past the 30s timeout with no keepalive.
|
||||
self.assertEqual(self.manager.reap_expired(now=now + 31.0), 1)
|
||||
self.assertEqual(len(self.source.subscriptions), 0)
|
||||
|
||||
def test_reap_keeps_combo_alive_while_one_client_alive(self):
|
||||
clock = [1000.0]
|
||||
manager = QuoteSubscriptionManager(
|
||||
self.source, heartbeat_timeout_seconds=30.0, time_func=lambda: clock[0]
|
||||
)
|
||||
manager.subscribe("clientA", "sub1", ["SH"])
|
||||
manager.subscribe("clientB", "sub2", ["SH"])
|
||||
# clientB keepalives at +31 (fresh); clientA has been silent since subscribe.
|
||||
clock[0] = 1031.0
|
||||
manager.keepalive("clientB", "sub2")
|
||||
removed = manager.reap_expired()
|
||||
# clientA reaped, but combo still has clientB -> big-QMT subscription stays.
|
||||
self.assertEqual(removed, 1)
|
||||
self.assertEqual(len(self.source.subscriptions), 1)
|
||||
|
||||
def test_keepalive_unknown_sub_is_noop(self):
|
||||
# Unknown sub should not raise.
|
||||
self.manager.keepalive("clientA", "ghost")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.quote_push_channel import (
|
||||
RedisQuotePushChannel,
|
||||
ZmqQuotePushChannel,
|
||||
)
|
||||
from bigqmt_signal_trader.quote_subscription_manager import (
|
||||
QuoteSubscriptionManager,
|
||||
build_quote_subscription_service,
|
||||
)
|
||||
|
||||
|
||||
class FakeContextInfo:
|
||||
def __init__(self):
|
||||
self._next = 0
|
||||
|
||||
def subscribe_whole_quote(self, code_list, callback=None):
|
||||
self._next += 1
|
||||
return self._next
|
||||
|
||||
def unsubscribe_quote(self, sub_id):
|
||||
return 0
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
def publish(self, channel, value):
|
||||
return 1
|
||||
|
||||
def pubsub(self, ignore_subscribe_messages=True):
|
||||
raise AssertionError("not used here")
|
||||
|
||||
|
||||
class BuildQuoteSubscriptionServiceTest(unittest.TestCase):
|
||||
def test_disabled_returns_none(self):
|
||||
result = build_quote_subscription_service(
|
||||
FakeContextInfo(), transport_name="redis", account_id="acct",
|
||||
redis_client=FakeRedis(), enabled=False,
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_redis_transport_builds_redis_channel(self):
|
||||
service = build_quote_subscription_service(
|
||||
FakeContextInfo(), transport_name="redis", account_id="acct",
|
||||
redis_client=FakeRedis(), enabled=True,
|
||||
)
|
||||
manager, channel = service
|
||||
self.assertIsInstance(manager, QuoteSubscriptionManager)
|
||||
self.assertIsInstance(channel, RedisQuotePushChannel)
|
||||
|
||||
def test_zmq_transport_builds_zmq_channel_with_bind_address(self):
|
||||
service = build_quote_subscription_service(
|
||||
FakeContextInfo(), transport_name="zmq", account_id="acct",
|
||||
zmq_bind_address="tcp://127.0.0.1:15561", enabled=True,
|
||||
)
|
||||
manager, channel = service
|
||||
self.assertIsInstance(channel, ZmqQuotePushChannel)
|
||||
self.assertEqual(channel.bind_address, "tcp://127.0.0.1:15561")
|
||||
|
||||
def test_manager_on_push_publisher_is_channel_publish(self):
|
||||
service = build_quote_subscription_service(
|
||||
FakeContextInfo(), transport_name="redis", account_id="acct",
|
||||
redis_client=FakeRedis(), enabled=True,
|
||||
)
|
||||
manager, channel = service
|
||||
# The assembled manager must publish through the assembled channel.
|
||||
self.assertEqual(manager._on_push_publisher, channel.publish)
|
||||
|
||||
def test_heartbeat_timeout_configurable(self):
|
||||
service = build_quote_subscription_service(
|
||||
FakeContextInfo(), transport_name="redis", account_id="acct",
|
||||
redis_client=FakeRedis(), enabled=True, heartbeat_timeout_seconds=30.0,
|
||||
)
|
||||
manager, _channel = service
|
||||
self.assertEqual(manager._heartbeat_timeout, 30.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,179 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.adapter_factory import build_app
|
||||
from bigqmt_signal_trader.adapters.position_sync_redis import RedisPositionSyncSink
|
||||
from bigqmt_signal_trader.adapters.signal_redis import RedisStreamSignalSource, push_trade_signal
|
||||
from bigqmt_signal_trader.adapters.state_redis import RedisStateStore
|
||||
from bigqmt_signal_trader.models import AccountSnapshot, AssetSnapshot, PositionSnapshot
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
def __init__(self):
|
||||
self.streams = {}
|
||||
self.groups = set()
|
||||
self.acked = []
|
||||
self.kv = {}
|
||||
self.hashes = {}
|
||||
self.expired = []
|
||||
self.next_id = 1
|
||||
|
||||
def xgroup_create(self, stream_key, group_name, id="0-0", mkstream=True):
|
||||
key = (stream_key, group_name)
|
||||
if key in self.groups:
|
||||
raise Exception("BUSYGROUP Consumer Group name already exists")
|
||||
self.groups.add(key)
|
||||
self.streams.setdefault(stream_key, [])
|
||||
return True
|
||||
|
||||
def xreadgroup(self, groupname, consumername, streams, count=None, block=None):
|
||||
result = []
|
||||
for stream_key in streams:
|
||||
entries = self.streams.get(stream_key, [])[: count or None]
|
||||
if entries:
|
||||
result.append((stream_key, entries))
|
||||
return result
|
||||
|
||||
def xack(self, stream_key, group_name, stream_id):
|
||||
self.acked.append((stream_key, group_name, stream_id))
|
||||
return 1
|
||||
|
||||
def xadd(self, stream_key, fields, maxlen=None, approximate=False):
|
||||
stream_id = "%d-0" % self.next_id
|
||||
self.next_id += 1
|
||||
self.streams.setdefault(stream_key, []).append((stream_id, fields))
|
||||
return stream_id
|
||||
|
||||
def set(self, key, value, nx=False, ex=None):
|
||||
if nx and key in self.kv:
|
||||
return False
|
||||
self.kv[key] = value
|
||||
return True
|
||||
|
||||
def hset(self, key, mapping):
|
||||
self.hashes.setdefault(key, {}).update(mapping)
|
||||
return len(mapping)
|
||||
|
||||
def expire(self, key, seconds):
|
||||
self.expired.append((key, seconds))
|
||||
return True
|
||||
|
||||
def setex(self, key, seconds, value):
|
||||
self.kv[key] = value
|
||||
self.expired.append((key, seconds))
|
||||
return True
|
||||
|
||||
|
||||
def _payload(**kwargs):
|
||||
payload = {
|
||||
"signal_id": "sig-redis-001",
|
||||
"account_id": "acct",
|
||||
"action": "BUY",
|
||||
"stock_code": "600000",
|
||||
"amount": 300,
|
||||
"created_at": "2026-07-01 09:31:00",
|
||||
"expire_at": "2026-07-01 09:36:00",
|
||||
"schema_version": 1,
|
||||
"force": "false",
|
||||
}
|
||||
payload.update(kwargs)
|
||||
return payload
|
||||
|
||||
|
||||
class RedisAdaptersTest(unittest.TestCase):
|
||||
def test_stream_source_reads_json_payload_and_acks(self):
|
||||
r = FakeRedis()
|
||||
stream_key = "bigqmt:signals:acct"
|
||||
r.xadd(stream_key, {"payload": json.dumps(_payload())})
|
||||
source = RedisStreamSignalSource(r, consumer_name="c1")
|
||||
|
||||
signals = source.fetch("acct", 10)
|
||||
source.ack(signals[0])
|
||||
|
||||
self.assertEqual(signals[0].signal_id, "sig-redis-001")
|
||||
self.assertFalse(signals[0].force)
|
||||
self.assertEqual(r.acked, [(stream_key, "bigqmt-signal-trader", "1-0")])
|
||||
|
||||
def test_stream_source_reads_flat_payload_with_bytes_keys(self):
|
||||
r = FakeRedis()
|
||||
fields = {key.encode("utf-8"): str(value).encode("utf-8") for key, value in _payload().items()}
|
||||
r.streams["bigqmt:signals:acct"] = [("9-0", fields)]
|
||||
source = RedisStreamSignalSource(r)
|
||||
|
||||
signals = source.fetch("acct", 10)
|
||||
|
||||
self.assertEqual(signals[0].stock_code, "600000")
|
||||
self.assertEqual(signals[0].amount, 300)
|
||||
|
||||
def test_state_store_claim_is_idempotent_and_writes_status(self):
|
||||
r = FakeRedis()
|
||||
source = RedisStreamSignalSource(r)
|
||||
r.xadd("bigqmt:signals:acct", {"payload": json.dumps(_payload())})
|
||||
signal = source.fetch("acct", 1)[0]
|
||||
store = RedisStateStore(r, account_id="acct")
|
||||
|
||||
self.assertTrue(store.claim(signal, "consumer-a"))
|
||||
self.assertFalse(store.claim(signal, "consumer-b"))
|
||||
store.mark_finished(signal.signal_id, "SKIPPED", "test")
|
||||
|
||||
status_key = "bigqmt:signal_status:acct:sig-redis-001"
|
||||
self.assertEqual(r.hashes[status_key]["status"], "SKIPPED")
|
||||
self.assertEqual(r.hashes[status_key]["message"], "test")
|
||||
|
||||
def test_position_sync_sink_writes_snapshot_and_event(self):
|
||||
r = FakeRedis()
|
||||
sink = RedisPositionSyncSink(r)
|
||||
snapshot = AccountSnapshot(
|
||||
account_id="acct",
|
||||
asset=AssetSnapshot(account_id="acct", cash=100.0, total_asset=1000.0),
|
||||
positions={
|
||||
"600000.SH": PositionSnapshot(
|
||||
stock_code="600000.SH",
|
||||
volume=100,
|
||||
available=100,
|
||||
cost=10.0,
|
||||
stock_name="PF Bank",
|
||||
)
|
||||
},
|
||||
reason="test",
|
||||
updated_at=__import__("datetime").datetime(2026, 7, 1, 9, 31),
|
||||
)
|
||||
|
||||
sink.publish(snapshot)
|
||||
|
||||
self.assertIn("bigqmt:positions:acct", r.kv)
|
||||
self.assertIn("bigqmt:position_events:acct", r.streams)
|
||||
|
||||
def test_push_trade_signal_uses_account_stream(self):
|
||||
r = FakeRedis()
|
||||
|
||||
stream_id = push_trade_signal(r, _payload())
|
||||
|
||||
self.assertEqual(stream_id, "1-0")
|
||||
self.assertIn("bigqmt:signals:acct", r.streams)
|
||||
|
||||
def test_factory_wires_redis_adapters_without_real_redis(self):
|
||||
r = FakeRedis()
|
||||
app = build_app(
|
||||
config={
|
||||
"account_id": "acct",
|
||||
"signal_source_type": "redis",
|
||||
"state_store_type": "redis",
|
||||
"position_sync_type": "redis",
|
||||
"redis_client": r,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertIsInstance(app.signal_source, RedisStreamSignalSource)
|
||||
self.assertIsInstance(app.state_store, RedisStateStore)
|
||||
self.assertIsInstance(app.position_sync_sink, RedisPositionSyncSink)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
||||
import datetime
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
import bigqmt_signal_trader_strategy as strategy_module
|
||||
|
||||
|
||||
class FakeApp:
|
||||
def __init__(self):
|
||||
self.inited = 0
|
||||
self.ticks = []
|
||||
self.orders = []
|
||||
self.trades = []
|
||||
self.sync_reasons = []
|
||||
|
||||
def on_init(self, runtime):
|
||||
self.inited += 1
|
||||
|
||||
def tick(self, now=None):
|
||||
self.ticks.append(now)
|
||||
|
||||
def on_order_event(self, event):
|
||||
self.orders.append(event)
|
||||
|
||||
def on_trade_event(self, event):
|
||||
self.trades.append(event)
|
||||
|
||||
def sync_positions(self, reason):
|
||||
self.sync_reasons.append(reason)
|
||||
|
||||
|
||||
class FakeContext:
|
||||
def __init__(self):
|
||||
self.accounts = []
|
||||
|
||||
def set_account(self, account_id):
|
||||
self.accounts.append(account_id)
|
||||
|
||||
|
||||
class FakeHistoryContext(FakeContext):
|
||||
def is_last_bar(self):
|
||||
return False
|
||||
|
||||
|
||||
class FakeRpcService:
|
||||
def __init__(self):
|
||||
self.drained = []
|
||||
|
||||
def drain_pending(self, max_items=20):
|
||||
self.drained.append(max_items)
|
||||
return 0
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
|
||||
class BigQmtStrategyRunnerTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.app = FakeApp()
|
||||
strategy_module.reset_app()
|
||||
strategy_module.set_app_factory(lambda context: self.app)
|
||||
|
||||
def tearDown(self):
|
||||
strategy_module.reset_app()
|
||||
strategy_module.set_app_factory(None)
|
||||
strategy_module.set_account_id("")
|
||||
|
||||
def test_init_builds_app_and_calls_on_init(self):
|
||||
strategy_module.init(FakeContext())
|
||||
|
||||
self.assertEqual(self.app.inited, 1)
|
||||
|
||||
def test_init_sets_bigqmt_account_when_configured(self):
|
||||
context = FakeContext()
|
||||
strategy_module.set_account_id("test-account")
|
||||
|
||||
strategy_module.init(context)
|
||||
|
||||
self.assertEqual(context.accounts, ["test-account"])
|
||||
|
||||
def test_init_detects_bigqmt_account_from_runtime_global(self):
|
||||
context = FakeContext()
|
||||
strategy_module.account = "runtime-account"
|
||||
try:
|
||||
strategy_module.init(context)
|
||||
finally:
|
||||
delattr(strategy_module, "account")
|
||||
|
||||
self.assertEqual(context.accounts, ["runtime-account"])
|
||||
|
||||
def test_adjust_forwards_to_app_tick(self):
|
||||
strategy_module.init(FakeContext())
|
||||
strategy_module.adjust(FakeContext())
|
||||
|
||||
self.assertEqual(len(self.app.ticks), 1)
|
||||
self.assertIsInstance(self.app.ticks[0], datetime.datetime)
|
||||
|
||||
def test_handlebar_forwards_to_app_tick(self):
|
||||
strategy_module.init(FakeContext())
|
||||
strategy_module.handlebar(FakeContext())
|
||||
|
||||
self.assertEqual(len(self.app.ticks), 1)
|
||||
self.assertIsInstance(self.app.ticks[0], datetime.datetime)
|
||||
|
||||
def test_adjust_skips_history_bars_when_bigqmt_exposes_is_last_bar(self):
|
||||
strategy_module.init(FakeContext())
|
||||
|
||||
strategy_module.adjust(FakeHistoryContext())
|
||||
|
||||
self.assertEqual(self.app.ticks, [])
|
||||
|
||||
def test_adjust_drains_rpc_even_when_not_last_bar(self):
|
||||
rpc_service = FakeRpcService()
|
||||
strategy_module._rpc_service = rpc_service
|
||||
|
||||
strategy_module.adjust(FakeHistoryContext())
|
||||
|
||||
self.assertEqual(rpc_service.drained, [20])
|
||||
self.assertEqual(self.app.ticks, [])
|
||||
|
||||
def test_zmq_rpc_build_does_not_create_redis_clients(self):
|
||||
config = {
|
||||
"account_id": "acct",
|
||||
"enable_rpc": True,
|
||||
"rpc": {
|
||||
"enabled": True,
|
||||
"account_id": "acct",
|
||||
"transport": "zmq",
|
||||
"zmq": {"connect_address": "tcp://127.0.0.1:20146"},
|
||||
"background_threads": True,
|
||||
},
|
||||
"qmt_api": {},
|
||||
}
|
||||
app = SimpleNamespace(order_gateway=None, position_sync_sink=None)
|
||||
|
||||
with mock.patch(
|
||||
"bigqmt_signal_trader.adapters.redis_common.build_redis_client",
|
||||
side_effect=AssertionError("ZMQ mode must not build Redis clients"),
|
||||
):
|
||||
service = strategy_module._build_rpc_service(FakeContext(), app, config)
|
||||
|
||||
self.assertIsNone(service.listen_redis)
|
||||
self.assertIsNone(service.redis)
|
||||
self.assertEqual(service._transport.name, "zmq")
|
||||
|
||||
def test_bigqmt_named_callbacks_forward_to_app(self):
|
||||
strategy_module.init(FakeContext())
|
||||
order = object()
|
||||
trade = object()
|
||||
|
||||
strategy_module.order_callback(FakeContext(), order)
|
||||
strategy_module.deal_callback(FakeContext(), trade)
|
||||
|
||||
self.assertEqual(self.app.orders, [order])
|
||||
self.assertEqual(self.app.trades, [trade])
|
||||
|
||||
def test_only_bigqmt_named_callbacks_are_exposed(self):
|
||||
self.assertFalse(hasattr(strategy_module, "on_order"))
|
||||
self.assertFalse(hasattr(strategy_module, "on_trade"))
|
||||
self.assertTrue(hasattr(strategy_module, "order_callback"))
|
||||
self.assertTrue(hasattr(strategy_module, "deal_callback"))
|
||||
|
||||
def test_manual_sync_forwards_to_app(self):
|
||||
strategy_module.init(FakeContext())
|
||||
|
||||
strategy_module.sync_positions(FakeContext())
|
||||
|
||||
self.assertEqual(self.app.sync_reasons, ["manual"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,42 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader_strategy import _is_redis_transport, _resolve_background_threads
|
||||
|
||||
|
||||
class BackgroundThreadResolutionTest(unittest.TestCase):
|
||||
def test_redis_transport_keeps_configured_value(self):
|
||||
# Redis (default) honors the configured flag — adjust-drain path when off.
|
||||
self.assertFalse(_resolve_background_threads("redis", False))
|
||||
self.assertTrue(_resolve_background_threads("redis", True))
|
||||
self.assertFalse(_resolve_background_threads("", False))
|
||||
self.assertFalse(_resolve_background_threads("default", False))
|
||||
self.assertFalse(_resolve_background_threads(None, False))
|
||||
|
||||
def test_zmq_transport_forces_background_threads_on(self):
|
||||
# ZMQ must run its background router thread regardless of config —
|
||||
# without it, requests are never received (start_receiving(False)
|
||||
# only binds the socket, never polls it).
|
||||
self.assertTrue(_resolve_background_threads("zmq", False))
|
||||
self.assertTrue(_resolve_background_threads("ZMQ", False))
|
||||
self.assertTrue(_resolve_background_threads("zmq", True))
|
||||
|
||||
def test_other_non_redis_transports_force_background_threads_on(self):
|
||||
for name in ("mysql", "shm"):
|
||||
self.assertTrue(_resolve_background_threads(name, False), name)
|
||||
self.assertTrue(_resolve_background_threads(name, True), name)
|
||||
|
||||
def test_is_redis_transport(self):
|
||||
for name in ("redis", "", "default", None, "REDIS", "Default"):
|
||||
self.assertTrue(_is_redis_transport(name), name)
|
||||
for name in ("zmq", "mysql", "shm", "ZMQ"):
|
||||
self.assertFalse(_is_redis_transport(name), name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,352 @@
|
||||
# coding: utf-8
|
||||
"""Tests for the pluggable transport layer.
|
||||
|
||||
Covers:
|
||||
* ``FakeTransport`` round-trip (validates the abstract contract).
|
||||
* ZMQ transport round-trip on tcp loopback (the main low-latency path).
|
||||
* MySQL transport round-trip against an in-memory sqlite3 DB (driver-agnostic).
|
||||
* Factory dispatch: name → transport class, unknown-name rejection, shm raising.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.transports import ( # noqa: E402
|
||||
RpcTransport,
|
||||
TransportError,
|
||||
TransportTimeout,
|
||||
build_transport,
|
||||
)
|
||||
from bigqmt_signal_trader.transports.base import RpcTransport as Base # noqa: E402
|
||||
|
||||
|
||||
class FakeTransport(RpcTransport):
|
||||
"""In-process transport: a thread-safe queue pair. Validates the contract."""
|
||||
|
||||
name = "fake"
|
||||
|
||||
def __init__(self, account_id="", print_prefix="[fake]"):
|
||||
super(FakeTransport, self).__init__(account_id=account_id, print_prefix=print_prefix)
|
||||
self._req_q = [] # inbound requests waiting for the server
|
||||
self._resp_q = {} # request_id -> response
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def send_request(self, request, timeout_seconds):
|
||||
rid = request["request_id"]
|
||||
# Hand the request to the server (call on_request), then collect reply.
|
||||
with self._lock:
|
||||
self._req_q.append(request)
|
||||
# Server processing: the registered callback handles it immediately.
|
||||
callback = self._on_request
|
||||
if callback is None:
|
||||
raise TransportError("no server registered")
|
||||
response = callback(request) or {}
|
||||
with self._lock:
|
||||
self._resp_q[rid] = response
|
||||
return response
|
||||
|
||||
def send_response(self, request, response):
|
||||
rid = response.get("request_id") or request.get("request_id")
|
||||
with self._lock:
|
||||
self._resp_q[rid] = response
|
||||
|
||||
def start_receiving(self, on_request, background_threads=True):
|
||||
super(FakeTransport, self).start_receiving(on_request)
|
||||
|
||||
|
||||
def _build_request(method="ping", params=None, account_id="acct"):
|
||||
import uuid
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"request_id": uuid.uuid4().hex,
|
||||
"account_id": account_id,
|
||||
"method": method,
|
||||
"params": params or {},
|
||||
"reply_channel": "",
|
||||
"reply_list": "",
|
||||
"reply_key": "",
|
||||
"ttl_seconds": 5,
|
||||
}
|
||||
|
||||
|
||||
class FakeTransportTest(unittest.TestCase):
|
||||
def test_round_trip(self):
|
||||
server = FakeTransport(account_id="acct")
|
||||
server.start_receiving(lambda req: {"ok": True, "request_id": req["request_id"], "data": {"echo": req["params"]}})
|
||||
client = FakeTransport(account_id="acct")
|
||||
client._on_request = server._on_request # share the handler
|
||||
req = _build_request(params={"x": 1})
|
||||
resp = client.send_request(req, 2.0)
|
||||
self.assertTrue(resp["ok"])
|
||||
self.assertEqual(resp["request_id"], req["request_id"])
|
||||
self.assertEqual(resp["data"], {"echo": {"x": 1}})
|
||||
|
||||
def test_deliver_auto_sends_response(self):
|
||||
server = FakeTransport(account_id="acct")
|
||||
sent = []
|
||||
server.send_response = lambda req, resp: sent.append(resp) # capture
|
||||
server.start_receiving(lambda req: {"ok": True, "request_id": req["request_id"]})
|
||||
server.deliver(_build_request())
|
||||
self.assertEqual(len(sent), 1)
|
||||
self.assertTrue(sent[0]["ok"])
|
||||
|
||||
def test_deliver_turns_handler_exception_into_error_envelope(self):
|
||||
server = FakeTransport(account_id="acct")
|
||||
sent = []
|
||||
server.send_response = lambda req, resp: sent.append(resp)
|
||||
def boom(req):
|
||||
raise ValueError("handler exploded")
|
||||
server.start_receiving(boom)
|
||||
server.deliver(_build_request())
|
||||
self.assertEqual(len(sent), 1)
|
||||
self.assertFalse(sent[0]["ok"])
|
||||
self.assertIn("handler exploded", sent[0]["error"])
|
||||
|
||||
|
||||
class FactoryTest(unittest.TestCase):
|
||||
def test_unknown_transport_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
build_transport("nonsense", {}, account_id="x")
|
||||
|
||||
def test_shm_raises_on_use(self):
|
||||
shm = build_transport("shm", {}, account_id="x")
|
||||
with self.assertRaises(TransportError):
|
||||
shm.send_request(_build_request(), 1.0)
|
||||
|
||||
def test_redis_factory_with_injected_clients(self):
|
||||
sentinel_listen = object()
|
||||
sentinel_resp = object()
|
||||
t = build_transport(
|
||||
"redis",
|
||||
{"redis_client": sentinel_listen, "response_redis_client": sentinel_resp},
|
||||
account_id="acct",
|
||||
)
|
||||
self.assertIs(t.listen_redis, sentinel_listen)
|
||||
self.assertIs(t.redis, sentinel_resp)
|
||||
self.assertEqual(t.account_id, "acct")
|
||||
|
||||
|
||||
class ZmqTransportTest(unittest.TestCase):
|
||||
"""Round-trip over tcp loopback. Skipped if pyzmq isn't installed."""
|
||||
|
||||
def setUp(self):
|
||||
try:
|
||||
import zmq # noqa: F401
|
||||
except ImportError:
|
||||
self.skipTest("pyzmq not installed")
|
||||
# Find a free port to avoid collisions between test runs.
|
||||
import socket
|
||||
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
self.port = s.getsockname()[1]
|
||||
s.close()
|
||||
self.address = "tcp://127.0.0.1:%d" % self.port
|
||||
|
||||
def test_round_trip(self):
|
||||
from bigqmt_signal_trader.transports.zmq_transport import ZmqTransport
|
||||
|
||||
server = ZmqTransport(
|
||||
bind_address=self.address, account_id="acct", recv_timeout_seconds=0.3
|
||||
)
|
||||
|
||||
def on_req(req):
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"request_id": req["request_id"],
|
||||
"account_id": "acct",
|
||||
"method": req["method"],
|
||||
"ok": True,
|
||||
"data": {"pong": True},
|
||||
"error": "",
|
||||
"handled_at": "now",
|
||||
}
|
||||
|
||||
server.start_receiving(on_req, background_threads=True)
|
||||
try:
|
||||
time.sleep(0.4)
|
||||
client = ZmqTransport(connect_address=self.address, account_id="acct")
|
||||
time.sleep(0.3)
|
||||
req = _build_request()
|
||||
resp = client.send_request(req, timeout_seconds=3.0)
|
||||
self.assertTrue(resp["ok"])
|
||||
self.assertEqual(resp["request_id"], req["request_id"])
|
||||
self.assertTrue(resp["data"]["pong"])
|
||||
client.stop()
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
def test_main_thread_drain_round_trip(self):
|
||||
from bigqmt_signal_trader.transports.zmq_transport import ZmqTransport
|
||||
|
||||
server = ZmqTransport(
|
||||
bind_address=self.address, account_id="acct", recv_timeout_seconds=0.01
|
||||
)
|
||||
server.start_receiving(
|
||||
lambda request: {
|
||||
"request_id": request["request_id"],
|
||||
"account_id": "acct",
|
||||
"method": request["method"],
|
||||
"ok": True,
|
||||
"data": {"main_thread": True},
|
||||
"error": "",
|
||||
},
|
||||
background_threads=False,
|
||||
)
|
||||
client = ZmqTransport(connect_address=self.address, account_id="acct")
|
||||
result = {}
|
||||
|
||||
def call_client():
|
||||
result["response"] = client.send_request(_build_request(), timeout_seconds=2.0)
|
||||
|
||||
try:
|
||||
thread = threading.Thread(target=call_client)
|
||||
thread.start()
|
||||
deadline = time.time() + 1.0
|
||||
while thread.is_alive() and time.time() < deadline:
|
||||
server.drain_request_queue(max_items=10)
|
||||
time.sleep(0.01)
|
||||
thread.join(1.0)
|
||||
self.assertFalse(thread.is_alive())
|
||||
self.assertEqual(result["response"]["data"], {"main_thread": True})
|
||||
finally:
|
||||
client.stop()
|
||||
server.stop()
|
||||
|
||||
def test_duplicate_server_address_fails_without_port_fallback(self):
|
||||
from bigqmt_signal_trader.transports.zmq_transport import ZmqTransport
|
||||
|
||||
first = ZmqTransport(bind_address=self.address, account_id="acct")
|
||||
duplicate = ZmqTransport(
|
||||
bind_address=self.address, account_id="acct", port_scan_range=50
|
||||
)
|
||||
first.start_receiving(lambda _request: {}, background_threads=False)
|
||||
try:
|
||||
with self.assertRaisesRegex(TransportError, "ZMQ_BIND_CONFLICT"):
|
||||
duplicate.start_receiving(
|
||||
lambda _request: {}, background_threads=False
|
||||
)
|
||||
self.assertIsNone(duplicate._actual_bind_address)
|
||||
finally:
|
||||
duplicate.stop()
|
||||
first.stop()
|
||||
|
||||
def test_deferred_response_returns_through_router_thread(self):
|
||||
from bigqmt_signal_trader.transports.zmq_transport import ZmqTransport
|
||||
|
||||
pending = []
|
||||
server = ZmqTransport(
|
||||
bind_address=self.address, account_id="acct", recv_timeout_seconds=0.05
|
||||
)
|
||||
server.start_receiving(lambda request: pending.append(request), background_threads=True)
|
||||
client = ZmqTransport(connect_address=self.address, account_id="acct")
|
||||
result = {}
|
||||
|
||||
def call_client():
|
||||
result["response"] = client.send_request(_build_request(), timeout_seconds=2.0)
|
||||
|
||||
try:
|
||||
thread = threading.Thread(target=call_client)
|
||||
thread.start()
|
||||
deadline = time.time() + 1.0
|
||||
while not pending and time.time() < deadline:
|
||||
time.sleep(0.01)
|
||||
self.assertTrue(pending)
|
||||
request = pending[0]
|
||||
server.send_response(
|
||||
request,
|
||||
{
|
||||
"request_id": request["request_id"],
|
||||
"ok": True,
|
||||
"data": {"deferred": True},
|
||||
"error": "",
|
||||
},
|
||||
)
|
||||
thread.join(2.0)
|
||||
self.assertFalse(thread.is_alive())
|
||||
self.assertEqual(result["response"]["data"], {"deferred": True})
|
||||
finally:
|
||||
client.stop()
|
||||
server.stop()
|
||||
|
||||
def test_timeout_raises(self):
|
||||
from bigqmt_signal_trader.transports.zmq_transport import ZmqTransport
|
||||
|
||||
# Client connects to a port with no server → times out.
|
||||
client = ZmqTransport(connect_address=self.address, account_id="acct")
|
||||
time.sleep(0.2)
|
||||
with self.assertRaises(TransportTimeout):
|
||||
client.send_request(_build_request(), timeout_seconds=0.5)
|
||||
client.stop()
|
||||
|
||||
|
||||
class MysqlTransportTest(unittest.TestCase):
|
||||
"""Round-trip over sqlite3 (the transport is driver-agnostic)."""
|
||||
|
||||
def setUp(self):
|
||||
try:
|
||||
import sqlite3 # noqa: F401
|
||||
except ImportError:
|
||||
self.skipTest("sqlite3 not available")
|
||||
self.db_path = os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "_test_rpc_%s.sqlite" % os.getpid()
|
||||
)
|
||||
if os.path.exists(self.db_path):
|
||||
os.unlink(self.db_path)
|
||||
|
||||
def tearDown(self):
|
||||
if os.path.exists(self.db_path):
|
||||
try:
|
||||
os.unlink(self.db_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def test_round_trip(self):
|
||||
from bigqmt_signal_trader.transports.mysql_transport import MysqlTransport
|
||||
|
||||
cfg = {
|
||||
"driver": "sqlite3",
|
||||
"connect_kwargs": {"database": self.db_path, "check_same_thread": False},
|
||||
"account_id": "acct",
|
||||
"poll_interval_seconds": 0.01,
|
||||
# sqlite connections are thread-bound; keep them single-threaded in
|
||||
# the pool. Real MySQL doesn't need this.
|
||||
"pool_config": {"mincached": 1, "maxcached": 2, "maxshared": 0, "maxconnections": 2},
|
||||
}
|
||||
server = MysqlTransport.from_config(cfg, account_id="acct")
|
||||
server._ensure_schema()
|
||||
|
||||
def on_req(req):
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"request_id": req["request_id"],
|
||||
"account_id": "acct",
|
||||
"method": req["method"],
|
||||
"ok": True,
|
||||
"data": {"pong": True},
|
||||
"error": "",
|
||||
"handled_at": "now",
|
||||
}
|
||||
|
||||
server.start_receiving(on_req, background_threads=True)
|
||||
try:
|
||||
client = MysqlTransport.from_config(cfg, account_id="acct")
|
||||
req = _build_request()
|
||||
resp = client.send_request(req, timeout_seconds=3.0)
|
||||
self.assertTrue(resp["ok"])
|
||||
self.assertEqual(resp["request_id"], req["request_id"])
|
||||
self.assertTrue(resp["data"]["pong"])
|
||||
client.stop()
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,284 @@
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.whole_quote_session import WholeQuoteClientSession
|
||||
|
||||
|
||||
class FakeRpc:
|
||||
"""Records control RPCs and returns canned subscribe responses."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def __call__(self, method, params):
|
||||
with self._lock:
|
||||
self.calls.append((method, dict(params)))
|
||||
if method == "subscribe_whole_quote":
|
||||
codes = sorted(str(c).upper() for c in params.get("codes") or [])
|
||||
return {"combo_key": ",".join(codes), "topic": ",".join(codes)}
|
||||
return {}
|
||||
|
||||
def methods(self):
|
||||
with self._lock:
|
||||
return [m for m, _ in self.calls]
|
||||
|
||||
|
||||
class FakeRpcWithRestart(FakeRpc):
|
||||
"""Simulates a server restart: keepalive fails for a window of calls,
|
||||
then the server is back (subscribe succeeds again)."""
|
||||
|
||||
def __init__(self, fail_start=0, fail_count=3):
|
||||
super().__init__()
|
||||
self.fail_start = fail_start
|
||||
self.fail_count = fail_count
|
||||
|
||||
def __call__(self, method, params):
|
||||
with self._lock:
|
||||
self.calls.append((method, dict(params)))
|
||||
if method == "quote_keepalive":
|
||||
n = sum(1 for m, _ in self.calls if m == "quote_keepalive") - 1 # 本次
|
||||
if method == "quote_keepalive" and self.fail_start <= n < self.fail_start + self.fail_count:
|
||||
raise RuntimeError("server restarting")
|
||||
if method == "subscribe_whole_quote":
|
||||
codes = sorted(str(c).upper() for c in params.get("codes") or [])
|
||||
return {"combo_key": ",".join(codes), "topic": ",".join(codes)}
|
||||
return {}
|
||||
|
||||
|
||||
class FakePushChannel:
|
||||
"""Client-side push channel stand-in: lets tests inject server pushes."""
|
||||
|
||||
def __init__(self):
|
||||
self.subscriptions = [] # list of (topics_tuple, on_msg)
|
||||
self.started = False
|
||||
self.stopped = False
|
||||
self._on_msg = None
|
||||
|
||||
def start_subscriber(self, topics, on_msg):
|
||||
self.started = True
|
||||
self._on_msg = on_msg
|
||||
self.subscriptions.append(tuple(topics))
|
||||
|
||||
def inject(self, topic, data):
|
||||
if self._on_msg is not None:
|
||||
self._on_msg(topic, data)
|
||||
|
||||
def stop(self):
|
||||
self.stopped = True
|
||||
|
||||
|
||||
class FakePushChannelWithTopics(FakePushChannel):
|
||||
"""Like FakePushChannel but tracks the currently subscribed topic set, so
|
||||
the session can diff and reuse an existing subscriber."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.active_topics = frozenset()
|
||||
|
||||
def start_subscriber(self, topics, on_msg):
|
||||
super().start_subscriber(topics, on_msg)
|
||||
self.active_topics = frozenset(topics)
|
||||
|
||||
def stop(self):
|
||||
super().stop()
|
||||
self.active_topics = frozenset()
|
||||
|
||||
|
||||
class WholeQuoteSessionTest(unittest.TestCase):
|
||||
def _session(self, **kwargs):
|
||||
rpc = FakeRpc()
|
||||
channel = FakePushChannel()
|
||||
session = WholeQuoteClientSession(
|
||||
rpc_call=rpc,
|
||||
push_channel=channel,
|
||||
client_id="client-test",
|
||||
heartbeat_interval_seconds=kwargs.pop("heartbeat_interval_seconds", 0.05),
|
||||
**kwargs,
|
||||
)
|
||||
return session, rpc, channel
|
||||
|
||||
def test_subscribe_sends_rpc_and_returns_sub_id(self):
|
||||
session, rpc, _channel = self._session()
|
||||
sub_id = session.subscribe_whole_quote(["SH", "SZ"], callback=lambda d: None)
|
||||
self.assertIsNotNone(sub_id)
|
||||
self.assertIn("subscribe_whole_quote", rpc.methods())
|
||||
|
||||
def test_subscribe_starts_push_channel_with_topic(self):
|
||||
session, _rpc, channel = self._session()
|
||||
session.subscribe_whole_quote(["SH", "SZ"], callback=lambda d: None)
|
||||
self.assertTrue(channel.started)
|
||||
self.assertIn(("SH,SZ",), channel.subscriptions)
|
||||
|
||||
def test_incoming_push_invokes_callback(self):
|
||||
session, _rpc, channel = self._session()
|
||||
received = []
|
||||
session.subscribe_whole_quote(["SH"], callback=received.append)
|
||||
channel.inject("SH", {"000001.SZ": {"lastPrice": 10.5}})
|
||||
self.assertEqual(received, [{"000001.SZ": {"lastPrice": 10.5}}])
|
||||
|
||||
def test_two_subscriptions_same_combo_share_one_push(self):
|
||||
session, _rpc, channel = self._session()
|
||||
got_a, got_b = [], []
|
||||
session.subscribe_whole_quote(["SH", "SZ"], callback=got_a.append)
|
||||
session.subscribe_whole_quote(["sz", "sh"], callback=got_b.append)
|
||||
channel.inject("SH,SZ", {"000001.SZ": {"lastPrice": 1.0}})
|
||||
self.assertEqual(len(got_a), 1)
|
||||
self.assertEqual(len(got_b), 1)
|
||||
|
||||
def test_unsubscribe_stops_callback_and_sends_rpc(self):
|
||||
session, rpc, channel = self._session()
|
||||
received = []
|
||||
sub_id = session.subscribe_whole_quote(["SH"], callback=received.append)
|
||||
session.unsubscribe_quote(sub_id)
|
||||
self.assertIn("unsubscribe_whole_quote", rpc.methods())
|
||||
channel.inject("SH", {"x": 1})
|
||||
self.assertEqual(received, [])
|
||||
|
||||
def test_keepalive_sent_for_active_subscriptions(self):
|
||||
session, rpc, _channel = self._session(heartbeat_interval_seconds=0.05)
|
||||
session.subscribe_whole_quote(["SH"], callback=lambda d: None)
|
||||
session.start()
|
||||
try:
|
||||
deadline = time.time() + 1.5
|
||||
while time.time() < deadline and rpc.methods().count("quote_keepalive") < 2:
|
||||
time.sleep(0.02)
|
||||
finally:
|
||||
session.stop()
|
||||
self.assertGreaterEqual(rpc.methods().count("quote_keepalive"), 2)
|
||||
|
||||
def test_keepalive_stops_after_unsubscribe(self):
|
||||
session, rpc, _channel = self._session(heartbeat_interval_seconds=0.05)
|
||||
sub_id = session.subscribe_whole_quote(["SH"], callback=lambda d: None)
|
||||
session.start()
|
||||
time.sleep(0.15)
|
||||
session.unsubscribe_quote(sub_id)
|
||||
count_at_unsub = rpc.methods().count("quote_keepalive")
|
||||
time.sleep(0.2)
|
||||
session.stop()
|
||||
self.assertEqual(rpc.methods().count("quote_keepalive"), count_at_unsub)
|
||||
|
||||
def test_replay_resubscribes_all_active(self):
|
||||
session, rpc, _channel = self._session()
|
||||
session.subscribe_whole_quote(["SH"], callback=lambda d: None)
|
||||
session.subscribe_whole_quote(["SZ"], callback=lambda d: None)
|
||||
subscribes_before = rpc.methods().count("subscribe_whole_quote")
|
||||
session.replay_subscriptions()
|
||||
self.assertEqual(rpc.methods().count("subscribe_whole_quote"), subscribes_before + 2)
|
||||
|
||||
def test_client_id_used_in_rpc(self):
|
||||
session, rpc, _channel = self._session()
|
||||
session.subscribe_whole_quote(["SH"], callback=lambda d: None)
|
||||
sub_params = [p for m, p in rpc.calls if m == "subscribe_whole_quote"][0]
|
||||
self.assertEqual(sub_params["client_id"], "client-test")
|
||||
|
||||
def test_auto_replay_after_server_restart(self):
|
||||
"""服务端重启后, 心跳线程应检测到 keepalive 失败并在服务端恢复后
|
||||
自动重放订阅(否则推送永久中断)。"""
|
||||
rpc = FakeRpcWithRestart(fail_start=1, fail_count=3) # 第1-3次keepalive失败
|
||||
channel = FakePushChannelWithTopics()
|
||||
session = WholeQuoteClientSession(
|
||||
rpc_call=rpc, push_channel=channel, client_id="client-test",
|
||||
heartbeat_interval_seconds=0.05,
|
||||
)
|
||||
session.subscribe_whole_quote(["SH"], callback=lambda d: None)
|
||||
session.start()
|
||||
try:
|
||||
deadline = time.time() + 2.0
|
||||
# 等待: keepalive 失败触发重放, 重放后又有新的 keepalive
|
||||
while time.time() < deadline:
|
||||
if rpc.methods().count("subscribe_whole_quote") >= 2 and \
|
||||
rpc.methods().count("quote_keepalive") >= 5:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
subs = rpc.methods().count("subscribe_whole_quote")
|
||||
kps = rpc.methods().count("quote_keepalive")
|
||||
print("auto-replay: subscribe=%d keepalive=%d" % (subs, kps))
|
||||
self.assertGreaterEqual(subs, 2, "服务端恢复后应重放订阅")
|
||||
self.assertGreaterEqual(kps, 5)
|
||||
finally:
|
||||
session.stop()
|
||||
|
||||
def test_no_replay_when_server_healthy(self):
|
||||
"""服务端健康时不应反复重放订阅(只保留初始 subscribe)。"""
|
||||
session, rpc, _channel = self._session(heartbeat_interval_seconds=0.05)
|
||||
session.subscribe_whole_quote(["SH"], callback=lambda d: None)
|
||||
session.start()
|
||||
try:
|
||||
time.sleep(0.4)
|
||||
self.assertEqual(rpc.methods().count("subscribe_whole_quote"), 1)
|
||||
finally:
|
||||
session.stop()
|
||||
|
||||
def test_replay_when_push_silent_after_restart(self):
|
||||
"""服务端重启后 keepalive 可能不失败(redis 队列兜住),但推送会静默。
|
||||
客户端应在推送静默超过阈值后自动重放订阅。"""
|
||||
rpc = FakeRpc() # keepalive 从不失败
|
||||
channel = FakePushChannelWithTopics()
|
||||
session = WholeQuoteClientSession(
|
||||
rpc_call=rpc, push_channel=channel, client_id="client-test",
|
||||
heartbeat_interval_seconds=0.05,
|
||||
push_silence_replay_heartbeats=2, # 2 个心跳周期无推送即重放
|
||||
)
|
||||
session.subscribe_whole_quote(["SH"], callback=lambda d: None)
|
||||
session.start()
|
||||
try:
|
||||
deadline = time.time() + 1.5
|
||||
while time.time() < deadline:
|
||||
if rpc.methods().count("subscribe_whole_quote") >= 2:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
self.assertGreaterEqual(
|
||||
rpc.methods().count("subscribe_whole_quote"), 2,
|
||||
"推送静默超阈值应自动重放订阅",
|
||||
)
|
||||
finally:
|
||||
session.stop()
|
||||
|
||||
def test_subscriber_reused_when_topic_set_unchanged(self):
|
||||
"""订阅/退订不应为同一 topic 集合反复重建订阅线程(线程泄漏)。"""
|
||||
rpc = FakeRpc()
|
||||
channel = FakePushChannelWithTopics()
|
||||
session = WholeQuoteClientSession(
|
||||
rpc_call=rpc, push_channel=channel, client_id="client-test",
|
||||
heartbeat_interval_seconds=0.05,
|
||||
)
|
||||
sub1 = session.subscribe_whole_quote(["SH"], callback=lambda d: None)
|
||||
sub2 = session.subscribe_whole_quote(["SH"], callback=lambda d: None)
|
||||
# 两次同 topic 订阅:共享订阅线程,只 start 一次
|
||||
self.assertEqual(len(channel.subscriptions), 1)
|
||||
# 退订一个 sub_id:topic 集合不变,不应重启线程
|
||||
session.unsubscribe_quote(sub1)
|
||||
self.assertEqual(len(channel.subscriptions), 1)
|
||||
self.assertFalse(channel.stopped)
|
||||
# 退订最后一个 sub_id:topic 集合变空,应停掉线程
|
||||
session.unsubscribe_quote(sub2)
|
||||
self.assertTrue(channel.stopped)
|
||||
self.assertEqual(len(channel.subscriptions), 1)
|
||||
|
||||
def test_subscriber_restarts_when_topic_set_changes(self):
|
||||
"""topic 集合变化时重建订阅线程,但旧线程先 stop。"""
|
||||
rpc = FakeRpc()
|
||||
channel = FakePushChannelWithTopics()
|
||||
session = WholeQuoteClientSession(
|
||||
rpc_call=rpc, push_channel=channel, client_id="client-test",
|
||||
heartbeat_interval_seconds=0.05,
|
||||
)
|
||||
session.subscribe_whole_quote(["SH"], callback=lambda d: None)
|
||||
session.subscribe_whole_quote(["SZ"], callback=lambda d: None)
|
||||
# topic 集合从 {SH} 变成 {SH,SZ} -> 重建(但旧 stop)
|
||||
self.assertEqual(len(channel.subscriptions), 2)
|
||||
self.assertTrue(channel.stopped)
|
||||
# 新线程订阅 {SH,SZ}
|
||||
self.assertEqual(channel.active_topics, frozenset(["SH", "SZ"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,203 @@
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.quote_subscription_manager import (
|
||||
QuoteSourceAdapter,
|
||||
QuoteSubscriptionManager,
|
||||
)
|
||||
from bigqmt_signal_trader.redis_rpc import BigQmtRpcHandlers
|
||||
from bigqmt_signal_trader.whole_quote_session import WholeQuoteClientSession
|
||||
|
||||
|
||||
class FakeQuoteSource(QuoteSourceAdapter):
|
||||
def __init__(self):
|
||||
self.subscriptions = {}
|
||||
self.unsubscribed = []
|
||||
self._next = 0
|
||||
|
||||
def subscribe(self, codes, on_push):
|
||||
self._next += 1
|
||||
handle = self._next
|
||||
self.subscriptions[handle] = {"codes": list(codes), "on_push": on_push}
|
||||
return handle
|
||||
|
||||
def unsubscribe(self, handle):
|
||||
self.unsubscribed.append(handle)
|
||||
self.subscriptions.pop(handle, None)
|
||||
|
||||
def fire(self, data_by_combo_codes):
|
||||
# Fire every live subscription's on_push with the given data.
|
||||
for handle, sub in list(self.subscriptions.items()):
|
||||
sub["on_push"](data_by_combo_codes)
|
||||
|
||||
|
||||
class FakeMarketData:
|
||||
def get_ticks(self, codes):
|
||||
return {}
|
||||
|
||||
|
||||
class FakePositionProvider:
|
||||
def get_positions(self, account_id):
|
||||
return {}
|
||||
|
||||
def get_asset(self, account_id):
|
||||
return None
|
||||
|
||||
|
||||
class InProcPushBus:
|
||||
"""Stands in for the QuotePushChannel: server publishes, every client channel
|
||||
subscribed to the topic receives (topic, data)."""
|
||||
|
||||
def __init__(self):
|
||||
self._subscribers = [] # list of _BusSubscriber
|
||||
|
||||
def register(self, subscriber):
|
||||
self._subscribers.append(subscriber)
|
||||
|
||||
def publish(self, topic, data):
|
||||
for sub in list(self._subscribers):
|
||||
sub._deliver(topic, data)
|
||||
|
||||
|
||||
class ClientPushChannel:
|
||||
"""Client-side push channel wired to the bus. Mirrors QuotePushChannel's
|
||||
subscriber surface used by WholeQuoteClientSession."""
|
||||
|
||||
def __init__(self, bus):
|
||||
self.bus = bus
|
||||
self.topics = []
|
||||
self._on_msg = None
|
||||
bus.register(self)
|
||||
|
||||
def start_subscriber(self, topics, on_msg):
|
||||
self.topics = list(topics)
|
||||
self._on_msg = on_msg
|
||||
|
||||
def _deliver(self, topic, data):
|
||||
if self._on_msg is not None and topic in self.topics:
|
||||
self._on_msg(topic, data)
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
|
||||
class WholeQuoteE2ETest(unittest.TestCase):
|
||||
def _server(self, heartbeat_timeout=30.0, clock=None):
|
||||
source = FakeQuoteSource()
|
||||
bus = InProcPushBus()
|
||||
manager = QuoteSubscriptionManager(
|
||||
source,
|
||||
heartbeat_timeout_seconds=heartbeat_timeout,
|
||||
time_func=clock,
|
||||
on_push_publisher=bus.publish,
|
||||
)
|
||||
handlers = BigQmtRpcHandlers(
|
||||
account_id="acct",
|
||||
market_data=FakeMarketData(),
|
||||
position_provider=FakePositionProvider(),
|
||||
quote_subscription_manager=manager,
|
||||
)
|
||||
return source, bus, manager, handlers
|
||||
|
||||
def _client(self, client_id, bus, handlers):
|
||||
def rpc_call(method, params):
|
||||
return handlers.handle(method, params)
|
||||
|
||||
return WholeQuoteClientSession(
|
||||
rpc_call=rpc_call,
|
||||
push_channel=ClientPushChannel(bus),
|
||||
client_id=client_id,
|
||||
heartbeat_interval_seconds=0.05,
|
||||
)
|
||||
|
||||
def test_two_clients_share_one_qmt_subscription_and_both_receive(self):
|
||||
source, bus, manager, handlers = self._server()
|
||||
client_a = self._client("clientA", bus, handlers)
|
||||
client_b = self._client("clientB", bus, handlers)
|
||||
got_a, got_b = [], []
|
||||
client_a.subscribe_whole_quote(["SH", "SZ"], callback=got_a.append)
|
||||
client_b.subscribe_whole_quote(["sz", "sh"], callback=got_b.append)
|
||||
|
||||
# One shared big-QMT subscription for the same normalized combo.
|
||||
self.assertEqual(len(source.subscriptions), 1)
|
||||
source.fire({"000001.SZ": {"lastPrice": 10.5}})
|
||||
self.assertEqual(len(got_a), 1)
|
||||
self.assertEqual(len(got_b), 1)
|
||||
|
||||
def test_one_unsubscribe_keeps_other_receiving(self):
|
||||
source, bus, manager, handlers = self._server()
|
||||
client_a = self._client("clientA", bus, handlers)
|
||||
client_b = self._client("clientB", bus, handlers)
|
||||
got_a, got_b = [], []
|
||||
sub_a = client_a.subscribe_whole_quote(["SH"], callback=got_a.append)
|
||||
client_b.subscribe_whole_quote(["SH"], callback=got_b.append)
|
||||
|
||||
client_a.unsubscribe_quote(sub_a)
|
||||
self.assertEqual(len(source.subscriptions), 1) # still alive for clientB
|
||||
source.fire({"x": 1})
|
||||
self.assertEqual(got_a, [])
|
||||
self.assertEqual(len(got_b), 1)
|
||||
|
||||
def test_all_unsubscribe_tears_down_qmt_subscription(self):
|
||||
source, bus, manager, handlers = self._server()
|
||||
client_a = self._client("clientA", bus, handlers)
|
||||
client_b = self._client("clientB", bus, handlers)
|
||||
sub_a = client_a.subscribe_whole_quote(["SH"], callback=lambda d: None)
|
||||
sub_b = client_b.subscribe_whole_quote(["SH"], callback=lambda d: None)
|
||||
client_a.unsubscribe_quote(sub_a)
|
||||
client_b.unsubscribe_quote(sub_b)
|
||||
self.assertEqual(len(source.subscriptions), 0)
|
||||
self.assertEqual(len(source.unsubscribed), 1)
|
||||
|
||||
def test_server_restart_client_replay_restores_push(self):
|
||||
source, bus, manager, handlers = self._server()
|
||||
client = self._client("clientA", bus, handlers)
|
||||
received = []
|
||||
client.subscribe_whole_quote(["SH"], callback=received.append)
|
||||
self.assertEqual(len(source.subscriptions), 1)
|
||||
|
||||
# Server "restarts": a brand-new manager/source/handlers on the same bus.
|
||||
source2 = FakeQuoteSource()
|
||||
manager2 = QuoteSubscriptionManager(source2, on_push_publisher=bus.publish)
|
||||
handlers2 = BigQmtRpcHandlers(
|
||||
account_id="acct",
|
||||
market_data=FakeMarketData(),
|
||||
position_provider=FakePositionProvider(),
|
||||
quote_subscription_manager=manager2,
|
||||
)
|
||||
self.assertEqual(len(source2.subscriptions), 0)
|
||||
|
||||
# Client detects the restart and replays its subscriptions.
|
||||
def rpc_call2(method, params):
|
||||
return handlers2.handle(method, params)
|
||||
|
||||
client._rpc = rpc_call2
|
||||
client.replay_subscriptions()
|
||||
|
||||
self.assertEqual(len(source2.subscriptions), 1)
|
||||
source2.fire({"000001.SZ": {"lastPrice": 11.0}})
|
||||
self.assertEqual(len(received), 1)
|
||||
self.assertEqual(received[0]["000001.SZ"]["lastPrice"], 11.0)
|
||||
|
||||
def test_silent_client_reaped_and_qmt_subscription_torn_down(self):
|
||||
clock = [1000.0]
|
||||
source, bus, manager, handlers = self._server(clock=lambda: clock[0])
|
||||
client = self._client("clientA", bus, handlers)
|
||||
client.subscribe_whole_quote(["SH"], callback=lambda d: None)
|
||||
self.assertEqual(len(source.subscriptions), 1)
|
||||
|
||||
# Client goes silent (no keepalive); clock advances past the timeout.
|
||||
clock[0] += 31.0
|
||||
manager.reap_expired()
|
||||
self.assertEqual(len(source.subscriptions), 0)
|
||||
self.assertEqual(len(source.unsubscribed), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.quote_subscription_manager import (
|
||||
QuoteSourceAdapter,
|
||||
QuoteSubscriptionManager,
|
||||
)
|
||||
from bigqmt_signal_trader.redis_rpc import BigQmtRpcHandlers
|
||||
|
||||
|
||||
class FakeQuoteSource(QuoteSourceAdapter):
|
||||
def __init__(self):
|
||||
self.subscriptions = {}
|
||||
self.unsubscribed = []
|
||||
self._next_handle = 0
|
||||
|
||||
def subscribe(self, codes, on_push):
|
||||
self._next_handle += 1
|
||||
handle = self._next_handle
|
||||
self.subscriptions[handle] = {"codes": list(codes), "on_push": on_push}
|
||||
return handle
|
||||
|
||||
def unsubscribe(self, handle):
|
||||
self.unsubscribed.append(handle)
|
||||
self.subscriptions.pop(handle, None)
|
||||
|
||||
|
||||
class FakeMarketData:
|
||||
def get_ticks(self, codes):
|
||||
return {}
|
||||
|
||||
|
||||
class FakePositionProvider:
|
||||
def get_positions(self, account_id):
|
||||
return {}
|
||||
|
||||
def get_asset(self, account_id):
|
||||
return None
|
||||
|
||||
|
||||
def _handlers(with_manager=True):
|
||||
source = FakeQuoteSource()
|
||||
manager = QuoteSubscriptionManager(source) if with_manager else None
|
||||
handlers = BigQmtRpcHandlers(
|
||||
account_id="acct",
|
||||
market_data=FakeMarketData(),
|
||||
position_provider=FakePositionProvider(),
|
||||
quote_subscription_manager=manager,
|
||||
)
|
||||
return handlers, source
|
||||
|
||||
|
||||
class WholeQuoteRpcHandlersTest(unittest.TestCase):
|
||||
def test_subscribe_whole_quote_allowed_and_creates_subscription(self):
|
||||
handlers, source = _handlers()
|
||||
result = handlers.handle(
|
||||
"subscribe_whole_quote",
|
||||
{"client_id": "c1", "sub_id": "s1", "codes": ["SH", "SZ"]},
|
||||
)
|
||||
self.assertEqual(len(source.subscriptions), 1)
|
||||
self.assertEqual(result["combo_key"], "SH,SZ")
|
||||
self.assertIn("topic", result)
|
||||
|
||||
def test_subscribe_whole_quote_idempotent_replay(self):
|
||||
handlers, source = _handlers()
|
||||
params = {"client_id": "c1", "sub_id": "s1", "codes": ["SH", "SZ"]}
|
||||
handlers.handle("subscribe_whole_quote", params)
|
||||
handlers.handle("subscribe_whole_quote", params) # replay after recovery
|
||||
self.assertEqual(len(source.subscriptions), 1)
|
||||
|
||||
def test_subscribe_whole_quote_requires_client_and_codes(self):
|
||||
handlers, _source = _handlers()
|
||||
with self.assertRaises(ValueError):
|
||||
handlers.handle("subscribe_whole_quote", {"sub_id": "s1", "codes": ["SH"]})
|
||||
with self.assertRaises(ValueError):
|
||||
handlers.handle("subscribe_whole_quote", {"client_id": "c1", "sub_id": "s1", "codes": []})
|
||||
|
||||
def test_unsubscribe_whole_quote_tears_down_last_client(self):
|
||||
handlers, source = _handlers()
|
||||
handlers.handle("subscribe_whole_quote", {"client_id": "c1", "sub_id": "s1", "codes": ["SH"]})
|
||||
handlers.handle("unsubscribe_whole_quote", {"client_id": "c1", "sub_id": "s1"})
|
||||
self.assertEqual(len(source.subscriptions), 0)
|
||||
self.assertEqual(len(source.unsubscribed), 1)
|
||||
|
||||
def test_quote_keepalive_ok(self):
|
||||
handlers, _source = _handlers()
|
||||
handlers.handle("subscribe_whole_quote", {"client_id": "c1", "sub_id": "s1", "codes": ["SH"]})
|
||||
result = handlers.handle("quote_keepalive", {"client_id": "c1", "sub_id": "s1"})
|
||||
self.assertEqual(result, {})
|
||||
|
||||
def test_quote_methods_rejected_without_manager(self):
|
||||
handlers, _source = _handlers(with_manager=False)
|
||||
with self.assertRaises(RuntimeError):
|
||||
handlers.handle("subscribe_whole_quote", {"client_id": "c1", "sub_id": "s1", "codes": ["SH"]})
|
||||
with self.assertRaises(RuntimeError):
|
||||
handlers.handle("quote_keepalive", {"client_id": "c1", "sub_id": "s1"})
|
||||
|
||||
def test_quote_methods_in_default_allowed_set(self):
|
||||
# The three methods must be reachable through the default whitelist (no
|
||||
# explicit allowed_methods passed), same as every other read method.
|
||||
handlers = BigQmtRpcHandlers(
|
||||
account_id="acct",
|
||||
market_data=FakeMarketData(),
|
||||
position_provider=FakePositionProvider(),
|
||||
quote_subscription_manager=QuoteSubscriptionManager(FakeQuoteSource()),
|
||||
)
|
||||
for method in ("subscribe_whole_quote", "unsubscribe_whole_quote", "quote_keepalive"):
|
||||
self.assertIn(method, handlers.allowed_methods)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,101 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
import bigqmt_signal_trader.xtquant_compat as compat
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""Captures BigQmtXtData -> WholeQuoteClientSession delegation."""
|
||||
|
||||
def __init__(self):
|
||||
self.subscribed = []
|
||||
self.unsubscribed = []
|
||||
self.started = False
|
||||
self._next = 0
|
||||
self._active = set()
|
||||
|
||||
def subscribe_whole_quote(self, code_list, callback=None):
|
||||
self._next += 1
|
||||
self._active.add(self._next)
|
||||
self.subscribed.append((list(code_list), callback))
|
||||
return self._next
|
||||
|
||||
def unsubscribe_quote(self, sub_id):
|
||||
self.unsubscribed.append(sub_id)
|
||||
self._active.discard(sub_id)
|
||||
return 0
|
||||
|
||||
def has_subscription(self, sub_id):
|
||||
return sub_id in self._active
|
||||
|
||||
def start(self):
|
||||
self.started = True
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.account_id = "acct"
|
||||
self.local_cache_config = {}
|
||||
self.full_tick_cache_config = {}
|
||||
self.transport_name = "redis"
|
||||
self.calls = []
|
||||
|
||||
def call(self, method, params=None, **kwargs):
|
||||
self.calls.append((method, params))
|
||||
if method == "get_full_tick":
|
||||
return {c: {"lastPrice": 1.0} for c in (params or {}).get("codes") or []}
|
||||
return {}
|
||||
|
||||
def _redis(self):
|
||||
raise AssertionError("redis not expected in this test")
|
||||
|
||||
|
||||
class XtDataWholeQuoteDelegationTest(unittest.TestCase):
|
||||
def _xtdata(self, session):
|
||||
client = FakeClient()
|
||||
data = compat.BigQmtXtData(client)
|
||||
data._quote_session_factory = lambda: session
|
||||
return data, client
|
||||
|
||||
def test_subscribe_delegates_to_session_and_primes_full_tick(self):
|
||||
session = FakeSession()
|
||||
data, _client = self._xtdata(session)
|
||||
received = []
|
||||
sub_id = data.subscribe_whole_quote(["000001.SZ"], callback=received.append)
|
||||
# Delegated to the session.
|
||||
self.assertEqual(session.subscribed[0][0], ["000001.SZ"])
|
||||
self.assertEqual(sub_id, 1)
|
||||
# Primed via get_full_tick so the callback fires once with the snapshot.
|
||||
self.assertEqual(received, [{"000001.SZ": {"lastPrice": 1.0}}])
|
||||
|
||||
def test_subscribe_starts_session_once(self):
|
||||
session = FakeSession()
|
||||
data, _client = self._xtdata(session)
|
||||
data.subscribe_whole_quote(["SH"], callback=lambda d: None)
|
||||
self.assertTrue(session.started)
|
||||
|
||||
def test_unsubscribe_delegates_to_session(self):
|
||||
session = FakeSession()
|
||||
data, _client = self._xtdata(session)
|
||||
sub_id = data.subscribe_whole_quote(["SH"], callback=lambda d: None)
|
||||
data.unsubscribe_quote(sub_id)
|
||||
self.assertEqual(session.unsubscribed, [sub_id])
|
||||
|
||||
def test_session_reused_across_subscriptions(self):
|
||||
session = FakeSession()
|
||||
data, _client = self._xtdata(session)
|
||||
data.subscribe_whole_quote(["SH"], callback=lambda d: None)
|
||||
data.subscribe_whole_quote(["SZ"], callback=lambda d: None)
|
||||
self.assertEqual(len(session.subscribed), 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,648 @@
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from bigqmt_signal_trader.xtquant_compat import (
|
||||
BigQmtRpcClient,
|
||||
FIX_PRICE,
|
||||
MARKET_PEER_PRICE_FIRST,
|
||||
SH_MARKET,
|
||||
STOCK_BUY,
|
||||
STOCK_SELL,
|
||||
SZ_MARKET,
|
||||
BigQmtXtData,
|
||||
BigQmtXtTrader,
|
||||
StockAccount,
|
||||
configure,
|
||||
load_client_config,
|
||||
xt_trader,
|
||||
)
|
||||
from bigqmt_signal_trader.full_tick_cache import full_tick_demand_key, full_tick_request_id, write_full_tick_cache
|
||||
|
||||
|
||||
class FakeRpcClient:
|
||||
def __init__(self):
|
||||
self.account_id = "acct"
|
||||
self.calls = []
|
||||
self.redis = FakeRedisEvents()
|
||||
self.full_tick_cache_config = {
|
||||
"enabled": True,
|
||||
"demand_ttl_seconds": 10,
|
||||
"cache_ttl_seconds": 10,
|
||||
"wait_seconds": 0.1,
|
||||
"poll_interval_seconds": 0.01,
|
||||
}
|
||||
|
||||
def _redis(self):
|
||||
return self.redis
|
||||
|
||||
def call(self, method, params=None, account_id=None, timeout_seconds=None):
|
||||
self.calls.append((method, params or {}, account_id, timeout_seconds))
|
||||
if method == "query_stock_asset":
|
||||
return {"account_id": "acct", "cash": 100.5, "total_asset": 1000.5}
|
||||
if method == "query_stock_positions":
|
||||
return {
|
||||
"600000.SH": {
|
||||
"stock_code": "600000.SH",
|
||||
"volume": 1000,
|
||||
"available": 800,
|
||||
"cost": 10.2,
|
||||
"price": 10.8,
|
||||
"market_value": 10800.0,
|
||||
"frozen_volume": 200,
|
||||
"on_road_volume": 5,
|
||||
"yesterday_volume": 900,
|
||||
"direction": 48,
|
||||
"stock_name": "PF Bank",
|
||||
}
|
||||
}
|
||||
if method == "query_stock_position":
|
||||
return {
|
||||
"stock_code": "600000.SH",
|
||||
"volume": 1000,
|
||||
"available": 800,
|
||||
"cost": 10.2,
|
||||
}
|
||||
if method == "query_stock_orders":
|
||||
return [
|
||||
{
|
||||
"order_sys_id": "sys-1",
|
||||
"user_order_id": "remark-1",
|
||||
"stock_code": "600000.SH",
|
||||
"action": "SELL",
|
||||
"volume": 300,
|
||||
"traded_volume": 100,
|
||||
"status": "50",
|
||||
"price": 10.1,
|
||||
}
|
||||
]
|
||||
if method == "query_stock_trades":
|
||||
return [
|
||||
{
|
||||
"trade_id": "trade-1",
|
||||
"order_sys_id": "sys-1",
|
||||
"user_order_id": "remark-1",
|
||||
"stock_code": "600000.SH",
|
||||
"action": "BUY",
|
||||
"volume": 100,
|
||||
"price": 10.0,
|
||||
}
|
||||
]
|
||||
if method == "query_execution_snapshot":
|
||||
return {
|
||||
"account_id": "acct",
|
||||
"server_time": "2026-07-15 10:00:00",
|
||||
"orders": [
|
||||
{
|
||||
"order_sys_id": "sys-1", "user_order_id": "remark-1",
|
||||
"stock_code": "600000.SH", "action": "SELL", "volume": 300,
|
||||
"traded_volume": 100, "status": "50", "price": 10.1,
|
||||
}
|
||||
],
|
||||
"trades": [
|
||||
{
|
||||
"trade_id": "trade-1", "order_sys_id": "sys-1",
|
||||
"user_order_id": "remark-1", "stock_code": "600000.SH",
|
||||
"action": "BUY", "volume": 100, "price": 10.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
if method == "order_stock":
|
||||
return {"status": "SUBMITTED", "user_order_id": "bq:1", "order_sys_id": "sys-2"}
|
||||
if method == "order_stock_batch":
|
||||
return [{"success": True, "accepted": True, "user_order_id": "batch-tag"}]
|
||||
if method == "cancel_order_stock_sysid":
|
||||
return {"success": True}
|
||||
if method == "get_full_tick":
|
||||
codes = params.get("codes") or []
|
||||
if codes == ["SH", "SZ"]:
|
||||
return {
|
||||
"000001.SH": {"lastPrice": 3000},
|
||||
"000001.SZ": {"lastPrice": 10},
|
||||
"600000.SH": {"lastPrice": 10},
|
||||
"510300.SH": {"lastPrice": 4},
|
||||
"300001.SZ": {"lastPrice": 20},
|
||||
"113001.SH": {"lastPrice": 100},
|
||||
}
|
||||
return {codes[0]: {"lastPrice": 10, "bidPrice": [9.9], "askPrice": [10.1]}}
|
||||
if method == "get_instrument_detail":
|
||||
return {"InstrumentStatus": 0, "code": params.get("code")}
|
||||
if method == "get_market_data_ex":
|
||||
if params.get("stock_list") == ["159518.SZ"]:
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
return {
|
||||
"159518.SZ": pd.DataFrame(
|
||||
{
|
||||
"stime": ["20250813 10:27:00", "20250813 10:28:00"],
|
||||
"time": [None, None],
|
||||
"open": [0.872, 0.873],
|
||||
"high": [0.873, 0.873],
|
||||
"low": [0.872, 0.872],
|
||||
"close": [0.872, 0.872],
|
||||
"volume": [2791.0, 1659.0],
|
||||
}
|
||||
)
|
||||
}
|
||||
except Exception:
|
||||
return {"159518.SZ": []}
|
||||
return {"600000.SH": {"close": [10.0]}}
|
||||
if method == "ping":
|
||||
return {"pong": True}
|
||||
raise AssertionError("unexpected method: %s" % method)
|
||||
|
||||
def publish_event(self, event_type, payload, stream_template="bigqmt:quote_events:{account_id}"):
|
||||
return self.redis.publish_event(event_type, payload)
|
||||
|
||||
def save_quote_subscription(self, seq, payload, active=True):
|
||||
if active:
|
||||
self.redis.hset("bigqmt:quote_subscriptions:%s" % self.account_id, str(seq), payload)
|
||||
else:
|
||||
self.redis.hdel("bigqmt:quote_subscriptions:%s" % self.account_id, str(seq))
|
||||
|
||||
|
||||
class FakeRedisEvents:
|
||||
def __init__(self):
|
||||
self.kv = {}
|
||||
self.hashes = {}
|
||||
self.deleted = []
|
||||
self.events = []
|
||||
self.expired = []
|
||||
|
||||
def hset(self, key, field, value):
|
||||
self.hashes.setdefault(key, {})[field] = value
|
||||
return 1
|
||||
|
||||
def hdel(self, key, field):
|
||||
self.deleted.append((key, field))
|
||||
self.hashes.setdefault(key, {}).pop(field, None)
|
||||
return 1
|
||||
|
||||
def hgetall(self, key):
|
||||
return self.hashes.get(key, {})
|
||||
|
||||
def expire(self, key, seconds):
|
||||
self.expired.append((key, seconds))
|
||||
return True
|
||||
|
||||
def setex(self, key, seconds, value):
|
||||
self.kv[key] = value
|
||||
self.expired.append((key, seconds))
|
||||
return True
|
||||
|
||||
def publish_event(self, event_type, payload):
|
||||
self.events.append((event_type, payload))
|
||||
return {"event_type": event_type, "payload": payload}
|
||||
|
||||
def get(self, key):
|
||||
if key in self.kv:
|
||||
return self.kv[key]
|
||||
value = self.hashes.get(key)
|
||||
if value is None:
|
||||
return None
|
||||
import json
|
||||
|
||||
return json.dumps(value).encode("utf-8")
|
||||
|
||||
|
||||
class XtquantCompatTest(unittest.TestCase):
|
||||
def _with_fake_config(self, module_name="test_bigqmt_client_cfg"):
|
||||
module = types.ModuleType(module_name)
|
||||
module.BIGQMT_ACCOUNT_ID = "cfg-account"
|
||||
module.BIGQMT_RPC_TIMEOUT_SECONDS = 9
|
||||
module.BIGQMT_REDIS_CONFIG = {
|
||||
"host": "cfg-host",
|
||||
"port": 6380,
|
||||
"db": 6,
|
||||
"username": "cfg-user",
|
||||
"password": "cfg-pass",
|
||||
}
|
||||
old_env = os.environ.get("BIGQMT_CLIENT_CONFIG_MODULE")
|
||||
os.environ["BIGQMT_CLIENT_CONFIG_MODULE"] = module_name
|
||||
sys.modules[module_name] = module
|
||||
return module_name, old_env
|
||||
|
||||
def _cleanup_fake_config(self, module_name, old_env):
|
||||
sys.modules.pop(module_name, None)
|
||||
if old_env is None:
|
||||
os.environ.pop("BIGQMT_CLIENT_CONFIG_MODULE", None)
|
||||
else:
|
||||
os.environ["BIGQMT_CLIENT_CONFIG_MODULE"] = old_env
|
||||
|
||||
def _trader(self):
|
||||
trader = BigQmtXtTrader(account_id="acct")
|
||||
trader.client = FakeRpcClient()
|
||||
return trader
|
||||
|
||||
def _xtdata(self):
|
||||
return BigQmtXtData(FakeRpcClient())
|
||||
|
||||
def test_trader_read_methods_return_miniqmt_style_objects(self):
|
||||
trader = self._trader()
|
||||
acc = StockAccount("acct")
|
||||
|
||||
asset = trader.query_stock_asset(acc)
|
||||
positions = trader.query_stock_positions(acc)
|
||||
single = trader.query_stock_position(acc, "600000")
|
||||
|
||||
self.assertEqual(asset.cash, 100.5)
|
||||
self.assertEqual(asset.market_value, 900.0)
|
||||
self.assertEqual(positions[0].stock_code, "600000.SH")
|
||||
self.assertEqual(positions[0].can_use_volume, 800)
|
||||
self.assertEqual(positions[0].avg_price, 10.2)
|
||||
self.assertEqual(positions[0].price, 10.8)
|
||||
self.assertEqual(positions[0].market_value, 10800.0)
|
||||
self.assertEqual(positions[0].frozen_volume, 200)
|
||||
self.assertEqual(positions[0].on_road_volume, 5)
|
||||
self.assertEqual(positions[0].yesterday_volume, 900)
|
||||
self.assertEqual(positions[0].direction, 48)
|
||||
self.assertEqual(single.stock_code, "600000.SH")
|
||||
|
||||
def test_orders_trades_order_and_cancel_are_miniqmt_shaped(self):
|
||||
trader = self._trader()
|
||||
acc = StockAccount("acct")
|
||||
|
||||
orders = trader.query_stock_orders(acc, cancelable_only=False)
|
||||
trades = trader.query_stock_trades(acc)
|
||||
order_id = trader.order_stock(
|
||||
acc,
|
||||
"600000.SH",
|
||||
STOCK_BUY,
|
||||
100,
|
||||
MARKET_PEER_PRICE_FIRST,
|
||||
0,
|
||||
"strategy",
|
||||
"remark",
|
||||
)
|
||||
cancelled = trader.cancel_order_stock_sysid(acc, SH_MARKET, "sys-2")
|
||||
|
||||
self.assertEqual(orders[0].order_type, STOCK_SELL)
|
||||
self.assertEqual(orders[0].order_status, 50)
|
||||
self.assertEqual(orders[0].order_volume, 300)
|
||||
self.assertEqual(trades[0].order_type, STOCK_BUY)
|
||||
self.assertEqual(trades[0].traded_price, 10.0)
|
||||
self.assertEqual(trades[0].order_remark, "remark-1")
|
||||
self.assertEqual(order_id, "sys-2")
|
||||
self.assertTrue(cancelled)
|
||||
self.assertEqual(trader.client.calls[-2][1]["price_type"], MARKET_PEER_PRICE_FIRST)
|
||||
# strategy_name 默认 ""(返回全部委托),与服务端一致(strategy_name 陷阱)。
|
||||
self.assertEqual(trader.client.calls[-4][1]["strategy_name"], "")
|
||||
|
||||
def test_execution_snapshot_maps_orders_and_trades_with_one_rpc(self):
|
||||
trader = self._trader()
|
||||
acc = StockAccount("acct")
|
||||
|
||||
snapshot = trader.query_execution_snapshot(
|
||||
acc, order_strategy_name="icestone_grid_600276", trade_strategy_name=""
|
||||
)
|
||||
|
||||
self.assertEqual(snapshot["orders"][0].order_sysid, "sys-1")
|
||||
self.assertEqual(snapshot["trades"][0].trade_id, "trade-1")
|
||||
self.assertEqual(snapshot["server_time"], "2026-07-15 10:00:00")
|
||||
self.assertEqual(trader.client.calls[-1][0], "query_execution_snapshot")
|
||||
self.assertEqual(trader.client.calls[-1][1]["trade_strategy_name"], "")
|
||||
|
||||
def test_order_stock_never_returns_user_tag_as_real_order_id(self):
|
||||
trader = self._trader()
|
||||
acc = StockAccount("acct")
|
||||
trader.client.call = lambda *_args, **_kwargs: {
|
||||
"status": "SUBMITTED", "user_order_id": "bq:request-only",
|
||||
"order_sys_id": None,
|
||||
}
|
||||
|
||||
order_id = trader.order_stock(
|
||||
acc, "600000.SH", STOCK_BUY, 100, FIX_PRICE, 10.0,
|
||||
"strategy", "remark",
|
||||
)
|
||||
result = trader.order_stock_result(
|
||||
acc, "600000.SH", STOCK_BUY, 100, FIX_PRICE, 10.0,
|
||||
"strategy", "remark",
|
||||
)
|
||||
|
||||
self.assertEqual(order_id, -1)
|
||||
self.assertEqual(result["user_order_id"], "bq:request-only")
|
||||
self.assertIsNone(result["order_sys_id"])
|
||||
|
||||
def test_order_stock_batch_forwards_batch_identity(self):
|
||||
trader = self._trader()
|
||||
acc = StockAccount("acct")
|
||||
|
||||
result = trader.order_stock_batch(
|
||||
acc,
|
||||
[{"stock_code": "600000.SH", "order_type": STOCK_BUY,
|
||||
"order_volume": 100, "price": 10.0,
|
||||
"order_remark": "batch-tag"}],
|
||||
batch_id="BATCH-IDENTITY-1",
|
||||
)
|
||||
|
||||
method, params, account_id, _timeout = trader.client.calls[-1]
|
||||
self.assertEqual(method, "order_stock_batch")
|
||||
self.assertEqual(account_id, "acct")
|
||||
self.assertEqual(params["batch_id"], "BATCH-IDENTITY-1")
|
||||
self.assertEqual(params["orders"][0]["order_remark"], "batch-tag")
|
||||
self.assertTrue(result[0]["accepted"])
|
||||
|
||||
def test_xtdata_read_methods_and_sector_filter(self):
|
||||
xtdata = self._xtdata()
|
||||
write_full_tick_cache(
|
||||
xtdata.client.redis,
|
||||
xtdata.client.account_id,
|
||||
["600000.SH"],
|
||||
{"600000.SH": {"lastPrice": 10, "bidPrice": [9.9], "askPrice": [10.1]}},
|
||||
)
|
||||
write_full_tick_cache(
|
||||
xtdata.client.redis,
|
||||
xtdata.client.account_id,
|
||||
["SH", "SZ"],
|
||||
{
|
||||
"000001.SH": {"lastPrice": 3000},
|
||||
"000001.SZ": {"lastPrice": 10},
|
||||
"600000.SH": {"lastPrice": 10},
|
||||
"510300.SH": {"lastPrice": 4},
|
||||
"300001.SZ": {"lastPrice": 20},
|
||||
"113001.SH": {"lastPrice": 100},
|
||||
},
|
||||
)
|
||||
|
||||
ticks = xtdata.get_full_tick(["600000.SH"])
|
||||
detail = xtdata.get_instrument_detail("600000.SH")
|
||||
sector_codes = xtdata.get_stock_list_in_sector("沪深A股")
|
||||
market_data = xtdata.get_market_data_ex(["close"], ["600000.SH"], count=1)
|
||||
|
||||
self.assertEqual(ticks["600000.SH"]["bidPrice"], [9.9])
|
||||
self.assertEqual(detail["InstrumentStatus"], 0)
|
||||
self.assertEqual(sector_codes, ["000001.SZ", "300001.SZ", "600000.SH"])
|
||||
self.assertEqual(market_data["600000.SH"]["close"], [10.0])
|
||||
|
||||
def test_market_data_ex_normalizes_bigqmt_stime_to_miniqmt_shape(self):
|
||||
try:
|
||||
import pandas # noqa: F401
|
||||
except Exception:
|
||||
self.skipTest("pandas not installed")
|
||||
|
||||
xtdata = self._xtdata()
|
||||
|
||||
data = xtdata.get_market_data_ex(
|
||||
["time", "open", "high", "low", "close", "volume"],
|
||||
["159518.SZ"],
|
||||
period="1m",
|
||||
start_time="20250601000000",
|
||||
end_time="",
|
||||
count=-1,
|
||||
)
|
||||
df = data["159518.SZ"]
|
||||
|
||||
self.assertEqual(list(df.index), ["20250813102700", "20250813102800"])
|
||||
self.assertEqual(
|
||||
list(df.columns),
|
||||
["time", "open", "high", "low", "close", "volume"],
|
||||
)
|
||||
self.assertEqual(int(df.iloc[0]["time"]), 1755052020000)
|
||||
self.assertNotIn("stime", df.columns)
|
||||
|
||||
def test_xtdata_full_tick_reads_redis_cache_and_renews_demand(self):
|
||||
xtdata = self._xtdata()
|
||||
write_full_tick_cache(
|
||||
xtdata.client.redis,
|
||||
xtdata.client.account_id,
|
||||
["SZ", "SH"],
|
||||
{"600000.SH": {"lastPrice": 10, "bidPrice": [9.9], "askPrice": [10.1]}},
|
||||
)
|
||||
|
||||
ticks = xtdata.get_full_tick(["SH", "SZ"])
|
||||
|
||||
self.assertIn("600000.SH", ticks)
|
||||
self.assertFalse([call for call in xtdata.client.calls if call[0] == "get_full_tick"])
|
||||
demand_key = full_tick_demand_key(xtdata.client.account_id)
|
||||
self.assertIn(full_tick_request_id(["SH", "SZ"]), xtdata.client.redis.hashes[demand_key])
|
||||
|
||||
def test_xtdata_full_tick_symbol_miss_falls_back_to_rpc(self):
|
||||
xtdata = self._xtdata()
|
||||
xtdata.client.full_tick_cache_config["wait_seconds"] = 0
|
||||
|
||||
ticks = xtdata.get_full_tick(["600000.SH"])
|
||||
|
||||
# A cold cache miss on a symbol list now falls back to a live RPC instead
|
||||
# of a hard wait_seconds stall, so the first call returns in ~ms.
|
||||
self.assertEqual(ticks["600000.SH"]["bidPrice"], [9.9])
|
||||
self.assertEqual([call[0] for call in xtdata.client.calls if call[0] == "get_full_tick"], ["get_full_tick"])
|
||||
demand_key = full_tick_demand_key(xtdata.client.account_id)
|
||||
self.assertIn(full_tick_request_id(["600000.SH"]), xtdata.client.redis.hashes[demand_key])
|
||||
|
||||
def test_xtdata_full_market_tick_miss_raises_without_rpc(self):
|
||||
xtdata = self._xtdata()
|
||||
xtdata.client.full_tick_cache_config["wait_seconds"] = 0
|
||||
|
||||
# Whole-market snapshots must stay on the demand cache; a miss must never
|
||||
# live-pull ~50k rows over RPC.
|
||||
with self.assertRaises(TimeoutError):
|
||||
xtdata.get_full_tick(["SH", "SZ"])
|
||||
|
||||
self.assertFalse([call for call in xtdata.client.calls if call[0] == "get_full_tick"])
|
||||
demand_key = full_tick_demand_key(xtdata.client.account_id)
|
||||
self.assertIn(full_tick_request_id(["SH", "SZ"]), xtdata.client.redis.hashes[demand_key])
|
||||
|
||||
def test_xtdata_full_market_tick_can_fall_back_to_rpc_when_cache_disabled(self):
|
||||
xtdata = self._xtdata()
|
||||
xtdata.client.full_tick_cache_config["enabled"] = False
|
||||
|
||||
xtdata.get_full_tick(["SH", "SZ"])
|
||||
|
||||
self.assertEqual(xtdata.client.calls[-1][0], "get_full_tick")
|
||||
self.assertEqual(xtdata.client.calls[-1][3], 30)
|
||||
|
||||
def test_quote_subscribe_and_unsubscribe_write_redis_events(self):
|
||||
xtdata = self._xtdata()
|
||||
|
||||
seq = xtdata.subscribe_quote("600000.SH", period="tick")
|
||||
result = xtdata.unsubscribe_quote(seq)
|
||||
|
||||
key = "bigqmt:quote_subscriptions:acct"
|
||||
self.assertEqual(result, 0)
|
||||
self.assertNotIn(str(seq), xtdata.client.redis.hashes.get(key, {}))
|
||||
self.assertIn((key, str(seq)), xtdata.client.redis.deleted)
|
||||
self.assertEqual(xtdata.client.redis.events[0][0], "subscribe_quote")
|
||||
self.assertEqual(xtdata.client.redis.events[1][0], "unsubscribe_quote")
|
||||
|
||||
def test_optional_xtquant_shim_imports_constants_and_classes(self):
|
||||
from xtquant import xtconstant
|
||||
from xtquant.xttrader import XtQuantTrader
|
||||
from xtquant.xttype import StockAccount as ShimStockAccount
|
||||
|
||||
self.assertEqual(xtconstant.STOCK_BUY, STOCK_BUY)
|
||||
self.assertEqual(xtconstant.FIX_PRICE, FIX_PRICE)
|
||||
self.assertEqual(xtconstant.SZ_MARKET, SZ_MARKET)
|
||||
self.assertIs(XtQuantTrader, BigQmtXtTrader)
|
||||
self.assertEqual(ShimStockAccount("acct").account_id, "acct")
|
||||
|
||||
def test_configure_updates_imported_xt_trader_object_in_place(self):
|
||||
original = xt_trader
|
||||
configure(account_id="acct-new", redis_client=FakeRpcClient())
|
||||
|
||||
self.assertIs(xt_trader, original)
|
||||
self.assertEqual(xt_trader.client.account_id, "acct-new")
|
||||
|
||||
def test_client_reads_account_and_redis_from_private_config(self):
|
||||
module_name, old_env = self._with_fake_config()
|
||||
try:
|
||||
config = load_client_config()
|
||||
client = BigQmtRpcClient()
|
||||
finally:
|
||||
self._cleanup_fake_config(module_name, old_env)
|
||||
|
||||
self.assertEqual(config["account_id"], "cfg-account")
|
||||
self.assertEqual(client.account_id, "cfg-account")
|
||||
self.assertEqual(client.redis_config["host"], "cfg-host")
|
||||
self.assertEqual(client.redis_config["port"], 6380)
|
||||
self.assertEqual(client.redis_config["db"], 6)
|
||||
self.assertEqual(client.redis_config["username"], "cfg-user")
|
||||
self.assertEqual(client.redis_config["password"], "cfg-pass")
|
||||
self.assertEqual(client.timeout_seconds, 9)
|
||||
|
||||
def test_explicit_client_params_override_private_config(self):
|
||||
module_name, old_env = self._with_fake_config()
|
||||
try:
|
||||
client = BigQmtRpcClient(
|
||||
account_id="explicit-account",
|
||||
redis_config={"host": "explicit-host", "password": ""},
|
||||
timeout_seconds=3,
|
||||
)
|
||||
finally:
|
||||
self._cleanup_fake_config(module_name, old_env)
|
||||
|
||||
self.assertEqual(client.account_id, "explicit-account")
|
||||
self.assertEqual(client.redis_config["host"], "explicit-host")
|
||||
self.assertEqual(client.redis_config["port"], 6380)
|
||||
self.assertEqual(client.redis_config["password"], "")
|
||||
self.assertEqual(client.timeout_seconds, 3)
|
||||
|
||||
def test_trader_falls_back_to_cached_positions_when_rpc_fails(self):
|
||||
class FailingRpcClient(FakeRpcClient):
|
||||
def call(self, method, params=None, account_id=None, timeout_seconds=None):
|
||||
if method in ("query_stock_asset", "query_stock_positions", "query_stock_position"):
|
||||
raise RuntimeError("rpc down")
|
||||
return super().call(method, params, account_id, timeout_seconds)
|
||||
|
||||
client = FailingRpcClient()
|
||||
client.redis.hashes["bigqmt:positions:acct"] = {
|
||||
"account_id": "acct",
|
||||
"asset": {"cash": 123.0, "total_asset": 456.0},
|
||||
"positions": {
|
||||
"600000.SH": {
|
||||
"stock_code": "600000.SH",
|
||||
"volume": 100,
|
||||
"available": 80,
|
||||
"cost": 10.5,
|
||||
"stock_name": "cached",
|
||||
}
|
||||
},
|
||||
}
|
||||
trader = BigQmtXtTrader(account_id="acct")
|
||||
trader.client = client
|
||||
acc = StockAccount("acct")
|
||||
|
||||
asset = trader.query_stock_asset(acc)
|
||||
positions = trader.query_stock_positions(acc)
|
||||
single = trader.query_stock_position(acc, "600000")
|
||||
|
||||
self.assertEqual(asset.cash, 123.0)
|
||||
self.assertEqual(asset.total_asset, 456.0)
|
||||
self.assertEqual(positions[0].stock_code, "600000.SH")
|
||||
self.assertEqual(positions[0].can_use_volume, 80)
|
||||
self.assertEqual(single.stock_name, "cached")
|
||||
|
||||
def test_zmq_query_failure_never_falls_back_to_redis_cache(self):
|
||||
class FailingZmqClient(FakeRpcClient):
|
||||
transport_name = "zmq"
|
||||
|
||||
def call(self, method, params=None, account_id=None, timeout_seconds=None):
|
||||
raise RuntimeError("zmq timeout")
|
||||
|
||||
def _redis(self):
|
||||
raise AssertionError("ZMQ query must not access Redis")
|
||||
|
||||
trader = BigQmtXtTrader(account_id="acct")
|
||||
trader.client = FailingZmqClient()
|
||||
acc = StockAccount("acct")
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "zmq timeout"):
|
||||
trader.query_stock_asset(acc)
|
||||
with self.assertRaisesRegex(RuntimeError, "zmq timeout"):
|
||||
trader.query_stock_positions(acc)
|
||||
with self.assertRaisesRegex(RuntimeError, "zmq timeout"):
|
||||
trader.query_stock_position(acc, "600000.SH")
|
||||
|
||||
def test_client_call_via_transport_builds_valid_request(self):
|
||||
# Regression: the swappable-transport path in BigQmtRpcClient.call() built
|
||||
# request_id with __import__("uuid").uuid.uuid4() (AttributeError), crashing
|
||||
# every non-redis transport on first call. This path had no coverage.
|
||||
captured = {}
|
||||
|
||||
class _FakeTransport:
|
||||
def send_request(self, request, timeout_seconds):
|
||||
captured["request"] = request
|
||||
captured["timeout"] = timeout_seconds
|
||||
return {"ok": True, "data": {"pong": True}}
|
||||
|
||||
client = BigQmtRpcClient(account_id="acct", redis_config={"host": "127.0.0.1"})
|
||||
client.transport_name = "zmq"
|
||||
client._transport_instance = _FakeTransport()
|
||||
|
||||
result = client.call("ping", {"x": 1})
|
||||
|
||||
self.assertEqual(result, {"pong": True})
|
||||
request = captured["request"]
|
||||
self.assertEqual(request["method"], "ping")
|
||||
self.assertEqual(request["account_id"], "acct")
|
||||
self.assertEqual(request["params"], {"x": 1})
|
||||
# request_id must be a real 32-char uuid hex, not a crash.
|
||||
self.assertEqual(len(request["request_id"]), 32)
|
||||
int(request["request_id"], 16)
|
||||
|
||||
def test_client_call_raises_on_server_error(self):
|
||||
# issue #38: server_error(QMT 端诊断,如 passorder 提交但委托没进系统)
|
||||
# 之前被 call() 静默丢弃,导致下单流程看不到真实原因。现在必须转成异常。
|
||||
class _FakeTransport:
|
||||
def send_request(self, request, timeout_seconds):
|
||||
return {
|
||||
"ok": True,
|
||||
"data": {"status": "SUBMITTED", "order_sys_id": ""},
|
||||
"server_error": "passorder submitted but order not found in system",
|
||||
}
|
||||
|
||||
client = BigQmtRpcClient(account_id="acct", redis_config={"host": "127.0.0.1"})
|
||||
client.transport_name = "zmq"
|
||||
client._transport_instance = _FakeTransport()
|
||||
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
client.call("order_stock", {"stock_code": "600000.SH"})
|
||||
self.assertIn("not found in system", str(ctx.exception))
|
||||
|
||||
def test_zmq_with_explicit_address_never_builds_redis_discovery(self):
|
||||
client = BigQmtRpcClient(
|
||||
account_id="acct",
|
||||
redis_config={
|
||||
"transport": "zmq",
|
||||
"zmq": {"connect_address": "tcp://127.0.0.1:20146"},
|
||||
},
|
||||
)
|
||||
|
||||
def forbidden_redis():
|
||||
raise AssertionError("ZMQ explicit-address mode must not touch Redis")
|
||||
|
||||
client._redis = forbidden_redis
|
||||
transport = client._transport()
|
||||
|
||||
self.assertEqual(transport.name, "zmq")
|
||||
self.assertEqual(transport.connect_address, "tcp://127.0.0.1:20146")
|
||||
self.assertIsNone(transport.discovery_redis_client)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user