@@ -416,6 +419,11 @@
出手一跳
窗口收口
+
+
+ 提议扫描试算 (不落表)
+ 提议扫描
+
成交回放
账本对账
diff --git a/config/settings.py b/config/settings.py
index ecaee2f..7e773c7 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -99,6 +99,8 @@ class Settings(BaseSettings):
PMS_JUDGE_ENABLED: bool = True
PMS_JUDGE_ACTIONS: str = "FILL,ADD,DCA,SWITCH"
PMS_JUDGE_TIMEOUT: int = 90 # 超时 → 降级 propose_only
+ PMS_JUDGE_API_BASE: str = "" # 决策系统 PMS 研判接口根地址; 空=未接通(自动降级人工确认)
+ PMS_JUDGE_PATH: str = "/api/intraday/pms_judge" # 研判接口路径 (bionic 侧配套改造后确定)
# --- T0 做T (命令授权制) ---
PMS_T0_RATIO_MAX: float = 0.333 # T仓硬上限 (占持仓)
diff --git a/scripts/run_tests.py b/scripts/run_tests.py
index 700c1e2..d56c911 100644
--- a/scripts/run_tests.py
+++ b/scripts/run_tests.py
@@ -8,7 +8,8 @@
test_core_units.py 仓位规划器 / 安全垫与成本账 (14 例)
test_batch2_units.py 命令状态机 / 方案生成器 / 回放对账纯逻辑 (35 例)
test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 (18 例)
- test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (25 例)
+ test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 (11 例)
+ test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (30 例)
任一子集失败即整体失败 (退出码 1)。
"""
import os
@@ -18,7 +19,7 @@ import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
- "test_wiring.py"]
+ "test_batch4_units.py", "test_wiring.py"]
def main():
diff --git a/scripts/test_batch4_units.py b/scripts/test_batch4_units.py
new file mode 100644
index 0000000..a92a775
--- /dev/null
+++ b/scripts/test_batch4_units.py
@@ -0,0 +1,201 @@
+# -*- coding: utf-8 -*-
+"""
+第四批模块单测 (实机运行, 零外部依赖)
+======================================
+运行: 在 tradingSystem 仓库根目录执行 python scripts/test_batch4_units.py
+覆盖: action_engine 四类自主动作的触发边界与数量口径 (FILL/ADD/DCA/TRIM) 及扫描剪枝。
+"""
+import os
+import sys
+import traceback
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from app.core import action_engine as ae # noqa: E402
+
+RESULTS = []
+
+
+def case(name):
+ def deco(fn):
+ RESULTS.append((name, fn))
+ return fn
+ return deco
+
+
+PARAMS = {"scale": 2_000_000, "stock_target_default": 0.06,
+ "batch_split": (0.5, 0.25, 0.25), "cushion_solid": 0.03,
+ "trim_peak": 0.06, "trim_giveback": 0.5,
+ "dca_triggers": (-0.08, -0.15), "dca_deep_confirm": -0.15, "dca_max_ratio": 0.5,
+ "no_chase_ma5": 0.06, "build_window_tdays": 10, "fill_max_loss": -0.03}
+
+
+def pos(**kw):
+ p = {"ts_code": "600000.SH", "price": 10.0, "avg_cost": 10.0, "total_qty": 6000,
+ "base_qty": 6000, "add_qty": 0, "dca_qty": 0, "market_value": 60_000,
+ "cushion_pct": 0.0, "cushion_peak": 0.0, "target_pct": 0.06,
+ "support_ref": None, "pressure_ref": None, "stop_ref": None,
+ "fill_count": 0, "dca_count": 0, "frozen_reason": "NONE"}
+ p.update(kw)
+ return p
+
+
+def mkt(**kw):
+ m = {"ma5": 10.0, "high5": 10.0, "tdays_since_open": 3, "tdays_since_last_add": 5}
+ m.update(kw)
+ return m
+
+
+# ================================================================ FILL
+@case("回踩补足·建仓期内浅亏未破支撑 → 按补足批额度补到目标")
+def _():
+ c = ae.eval_fill(pos(price=9.8, cushion_pct=-0.02, support_ref=9.7, market_value=58_800),
+ PARAMS, mkt())
+ assert c and c["action"] == "FILL" and c["side"] == "buy", c
+ assert c["qty"] == 3000, c # 补足批 3 万 ÷ 9.8 → 3000 股
+ assert c["judge_required"] and not c["needs_user_confirm"]
+ assert "未破支撑" in c["reason"]
+
+
+@case("回踩补足·每票一次 / 出建仓期 / 亏太深 / 破支撑 / 无支撑 一律不提")
+def _():
+ base = dict(price=9.8, cushion_pct=-0.02, support_ref=9.7, market_value=58_800)
+ assert ae.eval_fill(pos(**base, fill_count=1), PARAMS, mkt()) is None
+ assert ae.eval_fill(pos(**base), PARAMS, mkt(tdays_since_open=11)) is None
+ assert ae.eval_fill(pos(price=9.5, cushion_pct=-0.05, support_ref=9.0,
+ market_value=57_000), PARAMS, mkt()) is None
+ assert ae.eval_fill(pos(price=9.5, cushion_pct=-0.02, support_ref=9.7,
+ market_value=57_000), PARAMS, mkt()) is None # 破支撑
+ assert ae.eval_fill(pos(**{**base, "support_ref": None}), PARAMS, mkt()) is None
+ # 已转盈就不是「回踩补足」的场景了
+ assert ae.eval_fill(pos(price=10.5, cushion_pct=0.05, support_ref=9.7,
+ market_value=63_000), PARAMS, mkt()) is None
+
+
+# ================================================================ ADD
+@case("盈利加仓·厚垫 + 创5日新高 → 按加仓批额度加")
+def _():
+ c = ae.eval_add(pos(price=11.0, cushion_pct=0.10, market_value=66_000),
+ PARAMS, mkt(ma5=10.6, high5=11.0))
+ assert c and c["action"] == "ADD" and c["qty"] == 2700, c # 3万 ÷ 11 → 2700 股
+ assert "创 5 日新高" in c["reason"], c
+ # 站上压力位也算
+ c2 = ae.eval_add(pos(price=11.0, cushion_pct=0.10, pressure_ref=10.9,
+ market_value=66_000), PARAMS, mkt(ma5=10.6, high5=12.0))
+ assert c2 and "压力位" in c2["reason"], c2
+
+
+@case("盈利加仓·薄垫/未突破/距上次<2日/追高 一律不提")
+def _():
+ assert ae.eval_add(pos(price=11.0, cushion_pct=0.02, market_value=66_000),
+ PARAMS, mkt(high5=11.0)) is None # 垫子不厚
+ assert ae.eval_add(pos(price=10.5, cushion_pct=0.10, market_value=63_000),
+ PARAMS, mkt(high5=12.0)) is None # 没新高没压力位
+ assert ae.eval_add(pos(price=11.0, cushion_pct=0.10, market_value=66_000),
+ PARAMS, mkt(high5=11.0, tdays_since_last_add=1)) is None
+ assert ae.eval_add(pos(price=11.0, cushion_pct=0.10, market_value=66_000),
+ PARAMS, mkt(ma5=10.0, high5=11.0)) is None # 距 MA5 10% > 6%
+
+
+# ================================================================ DCA
+@case("补仓·触及 -8% 首档 → 按底仓一半提, 不强制确认")
+def _():
+ c = ae.eval_dca(pos(price=9.1, cushion_pct=-0.09, market_value=54_600), PARAMS, mkt())
+ assert c and c["action"] == "DCA" and c["qty"] == 3000, c # 底仓 6000 × 50%
+ assert c["needs_user_confirm"] is False and c["judge_required"], c
+ assert c["hard_numbers"]["stage"] == 1
+
+
+@case("补仓·-15% 及更深 永远需用户确认 + 研判须答杀逻辑还是杀情绪")
+def _():
+ c = ae.eval_dca(pos(price=8.4, cushion_pct=-0.16, market_value=50_400), PARAMS, mkt())
+ assert c and c["needs_user_confirm"] is True, c
+ assert c["hard_numbers"]["stage"] == 2 and "必须用户确认" in c["reason"], c
+
+
+@case("补仓·各档只评估一次 / 终身只执行一次 / 浮盈不评估")
+def _():
+ assert ae.eval_dca(pos(cushion_pct=-0.09, dca_count=1), PARAMS, mkt()) is None
+ assert ae.eval_dca(pos(cushion_pct=-0.16, dca_count=1), PARAMS, mkt()) is not None
+ assert ae.eval_dca(pos(cushion_pct=-0.16, dca_count=2), PARAMS, mkt()) is None
+ assert ae.eval_dca(pos(cushion_pct=-0.20, dca_qty=1000), PARAMS, mkt()) is None
+ assert ae.eval_dca(pos(cushion_pct=-0.05), PARAMS, mkt()) is None
+ assert ae.eval_dca(pos(cushion_pct=0.05), PARAMS, mkt()) is None
+
+
+# ================================================================ TRIM
+@case("保垫减仓·峰值≥6%且回吐过半 → 减 1/3, 不需研判不需确认")
+def _():
+ c = ae.eval_trim(pos(cushion_peak=0.08, cushion_pct=0.04), PARAMS)
+ assert c and c["action"] == "TRIM" and c["side"] == "sell", c
+ assert c["qty"] == 2000, c # 6000 的 1/3
+ assert not c["judge_required"] and not c["needs_user_confirm"], c
+
+
+@case("保垫减仓·峰值不足或回吐不到一半 不提")
+def _():
+ assert ae.eval_trim(pos(cushion_peak=0.05, cushion_pct=-0.01), PARAMS) is None
+ assert ae.eval_trim(pos(cushion_peak=0.08, cushion_pct=0.0401), PARAMS) is None
+ assert ae.eval_trim(pos(cushion_peak=0.08, cushion_pct=None), PARAMS) is None
+ assert ae.eval_trim(pos(cushion_peak=0.08, cushion_pct=0.04, total_qty=200),
+ PARAMS) is None # 1/3 不足一手
+
+
+# ================================================================ 扫描
+@case("扫描·冻结票只评减仓 / 已在途不重复提 / 单票异常不拖垮整轮")
+def _():
+ ps = [pos(ts_code="600000.SH", cushion_peak=0.08, cushion_pct=0.04,
+ frozen_reason="COMMAND_HALT"),
+ pos(ts_code="000001.SZ", price=11.0, cushion_pct=0.10, market_value=66_000),
+ pos(ts_code="600519.SH", price=None, cushion_pct=0.10, market_value=66_000)]
+ r = ae.scan(positions=ps, params=PARAMS, market={
+ "600000.SH": mkt(), "000001.SZ": mkt(ma5=10.6, high5=11.0), "600519.SH": mkt()})
+ acts = {(c["ts_code"], c["action"]) for c in r["candidates"]}
+ assert ("600000.SH", "TRIM") in acts, acts # 冻结不挡减仓
+ assert not any(c[0] == "600000.SH" and c[1] != "TRIM" for c in acts), acts
+ assert ("000001.SZ", "ADD") in acts, acts
+ assert any(s["why"].endswith("禁增持") for s in r["skipped"]), r["skipped"]
+
+ r2 = ae.scan(positions=ps, params=PARAMS,
+ market={"600000.SH": mkt(), "000001.SZ": mkt(ma5=10.6, high5=11.0),
+ "600519.SH": mkt()},
+ skip={("000001.SZ", "ADD")})
+ assert ("000001.SZ", "ADD") not in {(c["ts_code"], c["action"]) for c in r2["candidates"]}
+ assert any(s.get("why") == "已有在途提议/指令" for s in r2["skipped"]), r2["skipped"]
+
+ # 空仓票直接跳过, 不进候选
+ r3 = ae.scan(positions=[pos(ts_code="300750.SZ", total_qty=0)], params=PARAMS, market={})
+ assert r3["candidates"] == []
+
+
+@case("扫描·批次额度与距目标空间口径")
+def _():
+ p = pos(market_value=100_000)
+ assert ae.batch_amount(p, PARAMS, 0) == 60_000.0 # 底仓 50%
+ assert ae.batch_amount(p, PARAMS, 1) == 30_000.0 # 补足 25%
+ assert ae.batch_amount(p, PARAMS, 2) == 30_000.0 # 加仓 25%
+ assert ae.room_to_target(p, PARAMS) == 20_000.0
+ assert ae.room_to_target(pos(market_value=130_000), PARAMS) == 0.0
+
+
+# ---------------------------------------------------------------- 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()
diff --git a/scripts/test_wiring.py b/scripts/test_wiring.py
index 8322f0a..d2b516a 100644
--- a/scripts/test_wiring.py
+++ b/scripts/test_wiring.py
@@ -298,7 +298,7 @@ class FakeRepo:
return len(rows)
-def install_fakes(prices=None, positions=None, params=None):
+def install_fakes(prices=None, positions=None, params=None, high5=None):
"""把内存桩装到各模块上, 返回 FakeRepo 实例。"""
from app.repo import downstream_repo, pms_repo
from app.services import industry, market, param_store, portfolio
@@ -323,6 +323,7 @@ def install_fakes(prices=None, positions=None, params=None):
market.get_refs = lambda c, **kw: {"support": None, "pressure": None, "stop": None,
"source": "none"}
market.get_ma5 = lambda c: (prices or {}).get(c)
+ market.get_high5 = lambda c: (high5 or prices or {}).get(c)
market.day_snapshot = lambda c: ({} if not (prices or {}).get(c) else {
"price": prices[c], "vwap": prices[c], "open": prices[c], "high": prices[c] * 1.02,
"low": prices[c] * 0.98, "day_chg_from_open": 0.0, "bars": 60})
@@ -871,21 +872,160 @@ def _():
assert executor.cancel_instruction("INS_C")["ok"] is False
+def _prop_fakes(**kw):
+ """自主提议用的组合: A 票厚垫创新高(可加仓), B 票垫子回吐过半(可保垫减仓)。"""
+ return install_fakes(
+ prices={"600000.SH": 11.0, "000001.SZ": 10.4},
+ high5={"600000.SH": 11.0, "000001.SZ": 12.0}, # B 没创新高, 只该出 TRIM
+ params={"PMS_TOTAL_SCALE": "2000000", **(kw.get("params") or {})},
+ positions=[{"ts_code": "600000.SH", "total_qty": 6000, "avail_qty": 6000,
+ "base_qty": 6000, "avg_cost": 10.0, "cushion_peak": 0.0,
+ "target_pct": 0.06},
+ {"ts_code": "000001.SZ", "total_qty": 6000, "avail_qty": 6000,
+ "base_qty": 6000, "avg_cost": 10.0, "cushion_peak": 0.08,
+ "target_pct": 0.06}])
+
+
+@case("自主提议·propose_only: 减持自动执行 / 增持入队 / 研判未接通即降级留痕")
+def _():
+ from app.services import proposal_service as ps
+ fake = _prop_fakes(params={"PMS_AUTONOMY": "propose_only"})
+ r = ps.scan_and_route()
+ assert r["ok"], r
+ ex = {(x["ts_code"], x["action"]) for x in r["executed"]}
+ qd = {(x["ts_code"], x["action"]) for x in r["queued"]}
+ assert ("000001.SZ", "TRIM") in ex, r # 减持方向不设确认门槛
+ assert ("600000.SH", "ADD") in qd, r # 增持入队
+ assert r["degraded"] is True, r # 研判未接通 → 降级
+ assert any("研判不可用" in x["why"] for x in r["queued"]), r["queued"]
+ # 减持落了指令, 增持落了提议
+ trim_ins = [i for i in fake.instructions.values() if i["action"] == "TRIM"]
+ assert trim_ins and trim_ins[0]["side"] == "sell" and trim_ins[0]["qty"] == 2000, trim_ins
+ add_prop = [p for p in fake.proposals.values() if p["action"] == "ADD"]
+ assert add_prop and add_prop[0]["qty"] == 2700, add_prop
+ assert add_prop[0]["hard_numbers"]["price"] == 11.0
+ # 再扫一轮不重复提 (在途去重)
+ r2 = ps.scan_and_route()
+ assert not r2["executed"] and not r2["queued"], r2
+ assert any(s.get("why") == "已有在途提议/指令" for s in r2["skipped"]), r2["skipped"]
+
+
+@case("自主提议·full + 研判通过: 增持直接落指令; 深档补仓仍强制确认")
+def _():
+ from app.services import judge, proposal_service as ps
+ orig = judge.request
+ try:
+ judge.request = lambda c, context=None, **kw: {"verdict": judge.PASS,
+ "reason": "研判通过(桩)",
+ "degraded": False, "raw": None}
+ fake = _prop_fakes(params={"PMS_AUTONOMY": "full"})
+ r = ps.scan_and_route()
+ assert r["degraded"] is False, r
+ ex = {(x["ts_code"], x["action"]) for x in r["executed"]}
+ assert ("600000.SH", "ADD") in ex and ("000001.SZ", "TRIM") in ex, r
+ assert not r["queued"], r
+ add_ins = [i for i in fake.instructions.values() if i["action"] == "ADD"]
+ assert add_ins and add_ins[0]["side"] == "buy" and add_ins[0]["qty"] == 2700, add_ins
+ assert add_ins[0]["progress"]["is_command"] is False
+
+ # 深档补仓: 即使档位 full、研判通过, 也必须入队等用户点头
+ fake2 = install_fakes(prices={"600519.SH": 8.4}, high5={"600519.SH": 9.9},
+ params={"PMS_TOTAL_SCALE": "2000000", "PMS_AUTONOMY": "full"},
+ positions=[{"ts_code": "600519.SH", "total_qty": 6000,
+ "avail_qty": 6000, "base_qty": 6000,
+ "avg_cost": 10.0, "cushion_peak": 0.0,
+ "target_pct": 0.06}])
+ r2 = ps.scan_and_route()
+ qd = {(x["ts_code"], x["action"]) for x in r2["queued"]}
+ assert ("600519.SH", "DCA") in qd, r2
+ assert any("深档" in x["why"] for x in r2["queued"]), r2["queued"]
+ prop = [p for p in fake2.proposals.values() if p["action"] == "DCA"][0]
+ assert prop["qty"] == 3000 and prop["hard_numbers"]["stage"] == 2, prop
+ finally:
+ judge.request = orig
+
+
+@case("自主提议·研判驳回与规则闸拦截各自留痕")
+def _():
+ from app.services import judge, proposal_service as ps
+ orig = judge.request
+ try:
+ judge.request = lambda c, context=None, **kw: {"verdict": judge.REJECT,
+ "reason": "形态走坏, 不宜加仓",
+ "degraded": False, "raw": None}
+ fake = _prop_fakes(params={"PMS_AUTONOMY": "full"})
+ r = ps.scan_and_route()
+ rej = {(x["ts_code"], x["action"], x["by"]) for x in r["rejected"]}
+ assert ("600000.SH", "ADD", "judge") in rej, r
+ assert any(x["arbiter"] == "judge" and x["verdict"] == "REJECT" for x in fake.ledger)
+ assert not any(i["action"] == "ADD" for i in fake.instructions.values())
+ finally:
+ judge.request = orig
+
+ # 规则闸拦截: 全局暂停买入
+ fake2 = _prop_fakes(params={"PMS_AUTONOMY": "full", "PMS_GLOBAL_BUY_HALT": "true"})
+ r2 = ps.scan_and_route()
+ rej2 = {(x["ts_code"], x["action"], x["by"]) for x in r2["rejected"]}
+ assert ("600000.SH", "ADD", "rule") in rej2, r2
+ assert any("BUY_HALT" in f for x in r2["rejected"] for f in x["failed"]), r2
+ assert any(x["arbiter"] == "rule" and x["verdict"] == "REJECT" for x in fake2.ledger)
+ # 减持不受暂停买入影响, 照样执行
+ assert ("000001.SZ", "TRIM") in {(x["ts_code"], x["action"]) for x in r2["executed"]}, r2
+
+
+@case("自主提议·档位 off 与休假模式不扫描; 试算不落表")
+def _():
+ from app.services import proposal_service as ps
+ _prop_fakes(params={"PMS_AUTONOMY": "off"})
+ r = ps.scan_and_route()
+ assert r["candidates"] == 0 and any("off" in s["why"] for s in r["skipped"]), r
+
+ _prop_fakes(params={"PMS_AUTONOMY": "full", "PMS_GLOBAL_EXEC_HALT": "true"})
+ r2 = ps.scan_and_route()
+ assert any("休假" in s["why"] for s in r2["skipped"]), r2
+
+ fake = _prop_fakes(params={"PMS_AUTONOMY": "propose_only"})
+ r3 = ps.scan_and_route(dry_run=True)
+ assert (r3["executed"] or r3["queued"]) and all(
+ x.get("dry_run") for x in r3["executed"] + r3["queued"]), r3
+ assert not fake.instructions and not fake.proposals and not fake.ledger
+
+
+@case("研判闸·未配置即不可用, 动作不在研判范围则直接放行")
+def _():
+ from app.services import judge
+ install_fakes()
+ assert judge.available() is False
+ st = judge.status()
+ assert st["available"] is False and "未配置" in st["reason"], st
+ r = judge.request({"action": "ADD", "ts_code": "600000.SH", "qty": 100})
+ assert r["verdict"] == judge.UNAVAILABLE and r["degraded"] is True, r
+ r2 = judge.request({"action": "TRIM", "ts_code": "600000.SH", "qty": 100})
+ assert r2["verdict"] == judge.PASS and r2["degraded"] is False, r2 # TRIM 不在研判范围
+
+
@case("装配·执行相关路由与调度接线到位")
def _():
from app.web.main import app
from app import scheduler as sch
paths = {r.path for r in app.routes}
for p in ("/api/ops/materialize", "/api/ops/exec-tick", "/api/ops/sweep-windows",
- "/api/instructions/{instruction_id}/cancel", "/api/dispatch-mode"):
+ "/api/instructions/{instruction_id}/cancel", "/api/dispatch-mode",
+ "/api/ops/scan-proposals"):
assert p in paths, p
import inspect
src = inspect.getsource(sch.intraday_exec)
assert "executor" in src and "run_tick" in src, "调度器未接执行器"
+ assert "proposal_service" in src, "调度器未接自主提议扫描"
# ---------------------------------------------------------------- runner
def main():
+ # 静音日志: 本套里有好几条用例**故意**触发异常与告警来验证「守成」行为
+ # (调度守卫吞异常、外部成交告警、连续对账升级 ERROR、窗口耗尽告警),
+ # 这些 ERROR 栈打在测试输出里会被误读成失败。判定标准只看断言, 不看日志。
+ import logging
+ logging.disable(logging.CRITICAL)
passed, failed = 0, 0
for name, fn in RESULTS:
try: