tradingSystem/scripts/test_core_units.py

191 lines
7.3 KiB
Python
Raw Normal View History

2026-07-27 15:50:57 +08:00
# -*- coding: utf-8 -*-
"""
核心纯逻辑模块单测 (实机运行, 零外部依赖)
==========================================
运行: tradingSystem 仓库根目录执行 python scripts/test_core_units.py
约定: 全部断言通过输出 "ALL PASS (n cases)" 并退出码 0; 任一失败输出明细并退出码 1
覆盖: sizer 批次拆分/一手合并/组合约束/风险披露; cushion 摊薄成本/垫子状态/保垫触发/核销次序/补仓档
"""
import os
import sys
import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core.sizer import lot_qty, split_batches, check_caps, risk_exposure, risk_warnings # noqa: E402
from app.core.cushion import (PositionCost, cushion_state, trim_trigger, # noqa: E402
sell_allocation, dca_stage)
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
# ---------------------------------------------------------------- sizer
@case("批次拆分·常规 (12万目标, 10元股 → 6000/3000/3000 股)")
def _():
r = split_batches(120_000, 10.0)
assert r["ok"] and r["scheme"] == (0.5, 0.25, 0.25), r
assert [b["qty"] for b in r["batches"]] == [6000, 3000, 3000], r
@case("批次拆分·高价股自动合并 (12万目标, 700元股 → 合并为单批 100 股)")
def _():
# 50%=6万/700=85股 不足一手 → 60/40: 40%=4.8万=68股 仍不足 → 100%: 171→100股
r = split_batches(120_000, 700.0)
assert r["ok"] and r["scheme"] == (1.0,), r
assert len(r["batches"]) == 1 and r["batches"][0]["qty"] == 100, r
@case("批次拆分·极端高价买不起 (12万目标, 2000元股 → 放弃并给原因)")
def _():
r = split_batches(120_000, 2000.0)
assert not r["ok"] and "买不足一手" in r["reason"], r
@case("批次拆分·非法输入")
def _():
assert not split_batches(120_000, 0)["ok"]
assert not split_batches(0, 10.0)["ok"]
assert lot_qty(9_999, 100.0) == 0 and lot_qty(10_000, 100.0) == 100
@case("组合约束·单股上限拦截 (200万规模, 8%上限, 已持14万再买4万)")
def _():
ctx = dict(scale=2_000_000, portfolio_cap=0.60, stock_cap=0.08, max_names=15,
portfolio_mv=800_000, names_count=8, stock_mv=140_000, is_new_name=False,
sector=None, sector_max_names=4, sector_max_ratio=0.40)
v = check_caps(ts_code="600000.SH", add_amount=40_000, ctx=ctx)
assert len(v) == 1 and v[0].startswith("STOCK_CAP"), v
v2 = check_caps(ts_code="600000.SH", add_amount=10_000, ctx=ctx) # 14+1=15万 < 16万上限
assert v2 == [], v2
@case("组合约束·总仓+持仓数+行业同时拦截")
def _():
ctx = dict(scale=2_000_000, portfolio_cap=0.60, stock_cap=0.08, max_names=10,
portfolio_mv=1_180_000, names_count=10, stock_mv=0, is_new_name=True,
sector="半导体", sector_names=4, sector_mv=470_000,
sector_max_names=4, sector_max_ratio=0.40)
v = check_caps(ts_code="688000.SH", add_amount=60_000, ctx=ctx)
kinds = {x.split(":")[0] for x in v}
assert kinds == {"PORTFOLIO_CAP", "MAX_NAMES", "SECTOR_NAMES", "SECTOR_RATIO"}, v
@case("组合约束·行业数据源未配置时行业约束跳过")
def _():
ctx = dict(scale=2_000_000, portfolio_cap=0.60, stock_cap=0.08, max_names=15,
portfolio_mv=100_000, names_count=2, stock_mv=0, is_new_name=True,
sector=None, sector_names=99, sector_mv=9_999_999,
sector_max_names=4, sector_max_ratio=0.40)
assert check_caps(ts_code="000001.SZ", add_amount=60_000, ctx=ctx) == []
@case("风险披露·敞口计算与告警")
def _():
e = risk_exposure(6000, 10.0, 9.5) # 6000×0.5 = 3000 元
assert abs(e - 3000) < 1e-6, e
w = risk_warnings(entry_exposure=25_000, portfolio_exposure=0, scale=2_000_000)
assert len(w) == 1 and w[0].startswith("RISK_ENTRY"), w # 1.25% > 1%
w2 = risk_warnings(entry_exposure=-1, portfolio_exposure=0, scale=2_000_000)
assert w2 and w2[0].startswith("RISK_UNKNOWN"), w2
# ---------------------------------------------------------------- cushion
@case("摊薄成本·买入/卖出/T利润全链 (卖高于成本与做T都摊低成本)")
def _():
pc = PositionCost()
pc.buy(6000, 10.0) # 均价 10
assert abs(pc.avg_cost - 10.0) < 1e-9
pc.buy(3000, 11.0) # (60000+33000)/9000 = 10.333
assert abs(pc.avg_cost - 93000 / 9000) < 1e-9
pc.sell(3000, 12.0) # 净成本 93000-36000=57000, 6000股 → 9.5
assert abs(pc.avg_cost - 9.5) < 1e-9
pc.add_t_profit(1200) # (57000-1200)/6000 = 9.3
assert abs(pc.avg_cost - 9.3) < 1e-9
assert abs(pc.cushion(10.23) - 0.1) < 1e-3
pc.sell(6000, 10.0) # 清仓即结账
assert pc.avg_cost is None and pc.qty == 0
@case("摊薄成本·本金全部收回后成本归零 (垫子视为极厚)")
def _():
pc = PositionCost()
pc.buy(1000, 10.0)
pc.buy(1000, 10.0)
pc.sell(1000, 21.0) # 收回 21000 > 总投入 20000, 剩 1000 股净成本为负 → 0
assert pc.avg_cost == 0.0 and pc.cushion(10.0) == 9.99
@case("垫子状态机·边界 (solid=3%)")
def _():
assert cushion_state(None) == "NONE"
assert cushion_state(-0.001) == "NONE"
assert cushion_state(0.0) == "THIN"
assert cushion_state(0.0299) == "THIN"
assert cushion_state(0.03) == "SOLID"
@case("保垫减仓·触发边界 (峰值6%回吐一半)")
def _():
assert trim_trigger(0.08, 0.04) is True # 回吐 0.04 = 峰值一半
assert trim_trigger(0.08, 0.0401) is False
assert trim_trigger(0.05, -0.01) is False # 峰值不足 6% 永不触发
assert trim_trigger(0.06, 0.03) is True
@case("卖出核销次序·T0 → ADD(新→旧) → DCA → FILL → BASE")
def _():
lots = [
{"lot_id": "L_base", "lot_type": "BASE", "qty": 6000, "open_date": 20260701},
{"lot_id": "L_fill", "lot_type": "FILL", "qty": 3000, "open_date": 20260703},
{"lot_id": "L_add1", "lot_type": "ADD", "qty": 2000, "open_date": 20260710},
{"lot_id": "L_add2", "lot_type": "ADD", "qty": 1000, "open_date": 20260720},
{"lot_id": "L_t0", "lot_type": "T0", "qty": 1000, "open_date": 20260727},
]
alloc = sell_allocation(lots, 4500)
assert [a["lot_id"] for a in alloc] == ["L_t0", "L_add2", "L_add1", "L_fill"], alloc
assert [a["qty"] for a in alloc] == [1000, 1000, 2000, 500], alloc
try:
sell_allocation(lots, 99_999)
assert False, "超量卖出未拦截"
except ValueError:
pass
@case("补仓评估档·-8%/-15% 两档")
def _():
assert dca_stage(-0.05) == 0
assert dca_stage(-0.08) == 1
assert dca_stage(-0.12) == 1
assert dca_stage(-0.15) == 2
assert dca_stage(-0.30) == 2
# ---------------------------------------------------------------- runner
def main():
passed, failed = 0, 0
for name, fn in RESULTS:
try:
fn()
print(f" PASS {name}")
passed += 1
except Exception:
print(f" FAIL {name}")
traceback.print_exc()
failed += 1
print("-" * 60)
if failed:
print(f"FAILED: {failed} / {passed + failed}")
sys.exit(1)
print(f"ALL PASS ({passed} cases)")
if __name__ == "__main__":
main()