"""sanguo_live 单元测试。 分两层: (1) 纯 Python 逻辑层 —— Mac dev 机也跑(config 解析、注册表、默认参数); (2) 依赖 vnpy_ctastrategy 层 —— Mac 未装时单测级 skip,VPS 装齐则跑通。 Mac 跑:``pytest tests/test_live_engine.py -v``(层 1 全 pass + 层 2 skipped,exit 0)。 VPS 跑:全部 pass(含定寸/禁做空/引擎装配)。 """ from __future__ import annotations import importlib import pytest def _has_vnpy_cta() -> bool: """Mac dev 机没装 vnpy_ctastrategy(只装在 VPS)。""" try: importlib.import_module("vnpy_ctastrategy") return True except ImportError: return False # 单测级 skip marker(模块级 importorskip 会跳过整个文件,误伤层 1) needs_vnpy_cta = pytest.mark.skipif( not _has_vnpy_cta(), reason="本机未装 vnpy_ctastrategy(仅 VPS 有)— 跳过依赖它的单测", ) # ============================================================================= # 层 1:纯 Python 逻辑(Mac dev 机也跑) # ============================================================================= def test_module_import_tolerant(): """``sanguo_live`` 包 import 不应崩(即便本机没 vnpy_ctastrategy)。""" importlib.import_module("sanguo_live") importlib.import_module("sanguo_live.base_template") importlib.import_module("sanguo_live.runner") # engine / strategies import 了 vnpy_qmt/vnpy_ctastrategy 的类绑定, # 但都用 try/except 容错,模块本身能 import。 importlib.import_module("sanguo_live.engine") importlib.import_module("sanguo_live.strategies") def test_default_config_fields(): from sanguo_live.runner import DEFAULT_CONFIG assert DEFAULT_CONFIG["strategy_class"] == "AShareDoubleMaStrategy" assert DEFAULT_CONFIG["vt_symbol"] == "600000.SSE" s = DEFAULT_CONFIG["setting"] assert s["window"] == 15 assert s["size"] == 100 assert s["forbid_short"] is True assert s["fast_window"] == 10 assert s["slow_window"] == 20 def test_load_config_env_override(monkeypatch): """env SANGUO_QMT_ACCOUNT / SANGUO_QMT_PATH 优先于 yaml / 默认。""" monkeypatch.setenv("SANGUO_QMT_ACCOUNT", "12345678") monkeypatch.setenv("SANGUO_QMT_PATH", "/tmp/fake_mini") from sanguo_live.runner import load_config cfg = load_config("/nonexistent/path.yaml") # 文件不存在 → 走默认 assert cfg["account"] == "12345678" assert cfg["mini_path"] == "/tmp/fake_mini" def test_load_config_yaml_merge(tmp_path): """yaml 能覆盖默认 fast_window 等。""" yaml_file = tmp_path / "live.yaml" yaml_file.write_text( "account: '99999999'\n" "vt_symbol: '000001.SZSE'\n" "setting:\n" " fast_window: 5\n" " slow_window: 30\n", encoding="utf-8", ) from sanguo_live.runner import load_config cfg = load_config(str(yaml_file)) assert cfg["account"] == "99999999" assert cfg["vt_symbol"] == "000001.SZSE" assert cfg["setting"]["fast_window"] == 5 assert cfg["setting"]["slow_window"] == 30 # 未覆盖的字段保留默认 assert cfg["setting"]["window"] == 15 assert cfg["strategy_class"] == "AShareDoubleMaStrategy" def test_build_strategy_class_known(): from sanguo_live.runner import build_strategy_class cls = build_strategy_class("AShareDoubleMaStrategy") assert cls.__name__ == "AShareDoubleMaStrategy" def test_build_strategy_class_unknown_raises(): from sanguo_live.runner import build_strategy_class with pytest.raises(ValueError, match="未知策略类"): build_strategy_class("NoSuchStrategy_xyz") def test_strategy_class_has_parameters(): """AShareDoubleMaStrategy.parameters 必须暴露 size/forbid_short/window + fast/slow_window(缺一个都会让 update_setting 漏字段)。""" from sanguo_live.strategies import AShareDoubleMaStrategy params = AShareDoubleMaStrategy.parameters for required in ("fast_window", "slow_window", "window", "size", "forbid_short"): assert required in params, f"缺少 parameter: {required}" # ============================================================================= # 层 2:依赖 vnpy_ctastrategy(Mac skip,VPS 跑) # ============================================================================= class _FakeCtaEngine: """记录 send_order 调用,模拟 CtaTemplate 依赖的 cta_engine。""" def __init__(self) -> None: self.calls: list[tuple] = [] def send_order(self, strategy, direction, offset, price, volume, stop=False, lock=False, net=False): self.calls.append((direction, offset, price, volume, stop, lock, net)) return [] def cancel_all(self, strategy): return None def _make_strategy(cls, setting=None): """构造一个策略实例(trading=True,可发单)。 ``cls`` 必须是具体类(CtaTemplate 是 ABC,带抽象 on_init,不能直接实例化)。 用 ``_ConcreteAShare`` 包装 AShareCtaTemplate 来测基类定寸/禁做空逻辑。 """ strat = cls(_FakeCtaEngine(), "test_strat", "600000.SSE", setting or {}) strat.trading = True return strat def _concrete_asare(): """返回 AShareCtaTemplate 的一个具体子类(stub on_init/on_tick/on_bar)。""" from sanguo_live.base_template import AShareCtaTemplate class _Concrete(AShareCtaTemplate): author = "test" def on_init(self) -> None: # type: ignore[override] return def on_tick(self, tick) -> None: # type: ignore[override] return def on_bar(self, bar) -> None: # type: ignore[override] return return _Concrete @needs_vnpy_cta def test_buy_volume_multiplied_by_size(): """buy(1) 实际下单 volume=100(1 手 × size)。""" strat = _make_strategy(_concrete_asare(), {"size": 100}) strat.buy(10.0, 1) assert len(strat.cta_engine.calls) == 1 _, _, price, volume, *_ = strat.cta_engine.calls[0] assert price == 10.0 assert volume == 100 @needs_vnpy_cta def test_buy_custom_size_multiplier(): """size=200 → buy(2) 下 400。""" strat = _make_strategy(_concrete_asare(), {"size": 200}) strat.buy(8.8, 2) assert strat.cta_engine.calls[0][3] == 400 @needs_vnpy_cta def test_sell_volume_multiplied_by_size(): """sell(平多)同样定寸。""" strat = _make_strategy(_concrete_asare(), {"size": 100}) strat.sell(11.0, 1) assert strat.cta_engine.calls[0][3] == 100 @needs_vnpy_cta def test_cover_volume_multiplied_by_size(): """cover 也定寸(策略逻辑误调时不至于下零股)。""" strat = _make_strategy(_concrete_asare(), {"size": 100}) strat.cover(11.0, 1) assert strat.cta_engine.calls[0][3] == 100 @needs_vnpy_cta def test_short_blocked_by_default(): """forbid_short=True(默认) → short 返回 [],不触达 send_order。""" class _ExplodingEngine: def send_order(self, *a, **kw): raise AssertionError("short 不应到达 send_order") def write_log(self, msg, strategy=None): return strat = _concrete_asare()(_ExplodingEngine(), "t", "600000.SSE", {}) strat.trading = True result = strat.short(10.0, 1) assert result == [] @needs_vnpy_cta def test_short_passes_when_forbid_disabled(): """forbid_short=False → short 定寸后透传到基类(A 股不推荐,仅测试逻辑)。""" strat = _make_strategy(_concrete_asare(), {"size": 100, "forbid_short": False}) strat.short(10.0, 1) assert len(strat.cta_engine.calls) == 1 assert strat.cta_engine.calls[0][3] == 100 # 1 手 × 100 @needs_vnpy_cta def test_double_ma_strategy_uses_15min_window(): """AShareDoubleMaStrategy 默认 window=15(不是 1min)。""" from sanguo_live.strategies import AShareDoubleMaStrategy strat = _make_strategy(AShareDoubleMaStrategy, {}) assert strat.window == 15 assert strat.size == 100 assert strat.forbid_short is True @needs_vnpy_cta def test_engine_assembly_and_close(): """LiveTradingEngine 初始化 → MainEngine 装入 QMT gateway + CTA app,close 干净退出。 会真启动 EventEngine 线程,测试结束必须 close。 """ from sanguo_live.engine import LiveTradingEngine eng = LiveTradingEngine() try: assert eng.cta_engine is not None assert "QMT" in eng.main_engine.gateways # 查询方法不抛(连接前可能返回空) assert isinstance(eng.get_all_accounts(), list) assert isinstance(eng.get_positions(), list) assert isinstance(eng.get_orders(), list) finally: eng.close()