159 lines
6.4 KiB
Python
159 lines
6.4 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""入池/留池/出池纯逻辑的单测(不连任何库)。
|
|
|
|
运行: docker compose exec -T akg-factor-bridge python test_pool_logic.py
|
|
全过输出 "ALL PASS (n cases)",任一失败退出码 1。
|
|
被测函数: pool.decide / pool.build_remark / pool.build_recycle_docs。
|
|
"""
|
|
import datetime as dt
|
|
import sys
|
|
import traceback
|
|
|
|
import pool
|
|
|
|
RESULTS = []
|
|
|
|
|
|
def case(name):
|
|
def deco(fn):
|
|
RESULTS.append((name, fn))
|
|
return fn
|
|
return deco
|
|
|
|
|
|
TODAY = "2026-08-04"
|
|
|
|
|
|
@case("首次建池: 空池 + 计划20只 + 持仓3只(1只重叠) → 池=计划∪持仓, 无出池")
|
|
def _():
|
|
plan = [f"SH60{i:04d}" for i in range(20)]
|
|
hold = {"SH600000", "SZ000001", "SZ000002"} # SH600000 与计划重叠
|
|
d = pool.decide(set(), {}, plan, hold, set(), 60, TODAY)
|
|
assert len(d["pool"]) == 22, d["pool"]
|
|
assert set(plan) <= set(d["pool"]) and hold <= set(d["pool"])
|
|
assert d["retained_holdings"] == ["SZ000001", "SZ000002"]
|
|
assert d["recycled"] == [] and d["cap_evicted"] == [] and d["observers"] == []
|
|
assert d["new_entrants"] == sorted(plan)
|
|
# 成员记录: 计划票有 last_plan, 纯持仓票只有 added
|
|
assert d["meta"]["SH600000"]["last_plan"] == TODAY
|
|
assert "last_plan" not in d["meta"]["SZ000001"]
|
|
|
|
|
|
@case("掉榜不等于变坏: 掉出计划且无持仓、形态未恶化 → 留池观察")
|
|
def _():
|
|
old = {"SH600001", "SH600002", "SH600003"}
|
|
meta = {c: {"added": "2026-08-01", "last_plan": "2026-08-01"} for c in old}
|
|
d = pool.decide(old, meta, ["SH600001"], set(), set(), 60, TODAY)
|
|
assert d["observers"] == ["SH600002", "SH600003"], d
|
|
assert set(d["pool"]) == old # 都还在池里
|
|
assert d["recycled"] == []
|
|
|
|
|
|
@case("出池三条件缺一不可: 无持仓 + 不在计划 + 形态恶化 → 回收站")
|
|
def _():
|
|
old = {"SH600001", "SH600002", "SH600003", "SH600004"}
|
|
meta = {c: {"added": "2026-08-01"} for c in old}
|
|
bad = {"SH600002", "SH600003", "SH600004"}
|
|
# 600002 恶化但仍在计划 → 留; 600003 恶化但有持仓 → 留(警示); 600004 三条全中 → 回收
|
|
d = pool.decide(old, meta, ["SH600001", "SH600002"], {"SH600003"}, bad, 60, TODAY)
|
|
assert d["recycled"] == ["SH600004"], d
|
|
assert "SH600002" in d["pool"] and "SH600003" in d["pool"]
|
|
assert d["held_bad"] == ["SH600003"]
|
|
|
|
|
|
@case("池满出清: 只清留池观察、按最久没上榜的先清, 计划与持仓永不清")
|
|
def _():
|
|
plan = [f"SH61{i:04d}" for i in range(5)]
|
|
hold = {"SZ000001"}
|
|
old = set(plan) | hold | {"SH620001", "SH620002", "SH620003"}
|
|
meta = {"SH620001": {"added": "2026-07-01", "last_plan": "2026-07-10"},
|
|
"SH620002": {"added": "2026-07-01", "last_plan": "2026-07-20"},
|
|
"SH620003": {"added": "2026-07-01", "last_plan": "2026-07-30"}}
|
|
d = pool.decide(old, meta, plan, hold, set(), 8, TODAY) # 5+1+3=9 > 8, 清 1 只
|
|
assert d["cap_evicted"] == ["SH620001"], d # 最久没上榜的
|
|
assert len(d["pool"]) == 8
|
|
assert d["recycled"] == [] # 池满出清不进回收站
|
|
assert "SH620001" not in d["meta"] # 出池即清成员记录
|
|
|
|
|
|
@case("计划+持仓本身超上限时不硬砍 (允许超, 只把观察清空)")
|
|
def _():
|
|
plan = [f"SH63{i:04d}" for i in range(10)]
|
|
hold = {f"SZ00{i:04d}" for i in range(5)}
|
|
old = set(plan) | hold | {"SH640001"}
|
|
d = pool.decide(old, {"SH640001": {"added": "2026-07-01"}}, plan, hold, set(), 12, TODAY)
|
|
assert d["cap_evicted"] == ["SH640001"]
|
|
assert len(d["pool"]) == 15 # 10+5, 超 12 但不砍计划/持仓
|
|
|
|
|
|
@case("恶化名单为空 (结论读不到的降级) → 一只都不回收")
|
|
def _():
|
|
old = {"SH600001", "SH600002"}
|
|
d = pool.decide(old, {}, [], set(), set(), 60, TODAY)
|
|
assert d["recycled"] == [] and set(d["observers"]) == old
|
|
|
|
|
|
@case("幂等: 同一天跑两遍, 第二遍无新进无回收, 池子不变")
|
|
def _():
|
|
plan = ["SH600001", "SH600002"]
|
|
d1 = pool.decide(set(), {}, plan, {"SZ000001"}, set(), 60, TODAY)
|
|
d2 = pool.decide(set(d1["pool"]), d1["meta"], plan, {"SZ000001"}, set(), 60, TODAY)
|
|
assert d2["pool"] == d1["pool"]
|
|
assert d2["new_entrants"] == [] and d2["recycled"] == []
|
|
|
|
|
|
@case("remark 汇总句: 数字对得上, 持仓恶化有警示, 降级有说明")
|
|
def _():
|
|
d = pool.decide({"SH600009"}, {"SH600009": {"added": "2026-08-01"}},
|
|
["SH600001"], {"SH600003"}, {"SH600003"}, 60, TODAY)
|
|
r = pool.build_remark(d, "2026-08-04", "2026-08-04 07:20:00", degraded="测试降级说明")
|
|
# 池 = SH600001(计划) + SH600003(持仓, 恶化但保留) + SH600009(未恶化留池) = 3 只
|
|
assert "共入池 3 只" in r["summary"], r["summary"]
|
|
assert "当日计划 1 只" in r["summary"] and "持仓保留 1 只" in r["summary"]
|
|
assert "留池观察 1 只" in r["summary"], r["summary"]
|
|
assert "警示" in r["summary"] and "SH600003" in r["summary"]
|
|
assert "测试降级说明" in r["summary"]
|
|
assert r["retained_positions"] == ["SH600003"]
|
|
assert r["factor_details"][0]["selected_codes"] == ["SH600001"]
|
|
assert r["factor_details"][0]["trade_date"] == "2026-08-04"
|
|
|
|
|
|
@case("回收站文档: 字段与现有格式一致 (group_id/removal_batch/removed_at/stock_code)")
|
|
def _():
|
|
d = pool.decide({"SH600004"}, {}, [], set(), {"SH600004"}, 60, TODAY)
|
|
now = dt.datetime(2026, 8, 4, 7, 20, 0)
|
|
docs = pool.build_recycle_docs(d, "6865e8eb97623ef14325c8d3", "AKG每日选股计划池",
|
|
"489281497140", now)
|
|
assert len(docs) == 1
|
|
doc = docs[0]
|
|
for k in ("group_id", "group_name", "org_id", "removal_batch", "removed_at",
|
|
"stock_code", "reason"):
|
|
assert k in doc, k
|
|
assert doc["stock_code"] == "SH600004"
|
|
assert doc["removal_batch"] == now.isoformat()
|
|
assert doc["removed_at"] == now
|
|
assert "形态恶化" in doc["reason"]
|
|
|
|
|
|
# ---------------------------------------------------------------- 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()
|