三源合议工作包三(二):策略层两道技术面离场(part 3+4)

part3 跟踪止盈盘中 SAR 止损线:09:45 后现价跌破昨日 SAR×(1-0.003) 即全清、当日一次;
每天 09:40 把技术面映射的 SAR 值刷进每条自动跟踪止盈,无读数撤旧线。
part4 弱基本面试探仓紧止盈自动挂载:基本面看空+技术面看多的试探仓次日 09:40 自动挂
回撤 3%/硬目标 8%/带 SAR 线的跟踪止盈,不占每日名额;认它靠入场账本 advice.tight_trail。

至此工作包三四部分全做完。台账 011;第三十批扩到 33 例;开发机全量 ALL SUITES PASS。
只对自动跟踪止盈与弱基本面试探仓生效,其余策略票行为不变(batch17/19 回归绿)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-11 17:13:51 +08:00
parent 85f4330c59
commit 2d019a1b17
7 changed files with 413 additions and 5 deletions

View File

@ -1433,6 +1433,34 @@ app/core/action_engine.pyeval_tech_exit、SRC_TECH_EXIT、EVALUATORS 加一
工作包三剩两部分:跟踪止盈策略加盘中 SAR 止损线09:45 后现价低于昨日 SAR 千分之三即卖、当日一次,每天 09:40 把映射里的 SAR 刷进自动挂载的跟踪止盈);弱基本面加技术面看多的试探仓次日 09:40 自动挂回撤 3%、硬目标 8%、带 SAR 线的紧止盈(台账 011。工作包四选股打分另一仓库单独审批。工作包五文档。
---
## 2026-09-11 · 工作包三离场纪律part 3+4策略层的两道技术面离场
**做了什么**
补齐工作包三的后两部分,都在策略层。至此工作包三四部分全做完。
一,跟踪止盈加盘中 SAR 止损线。跟踪止盈评估器加一道 SAR 止损09:45 后现价跌破昨日 SAR 值的千分之三缓冲即全清当日只触发一次。SAR 值由 09:40 的策略自动挂载一跳刷进每条自动挂载的跟踪止盈的参数。取不到读数就撤掉旧的 SAR 线,不拿旧读数当今天。没刷进 SAR 线时这道线不判,加不改。
二,弱基本面试探仓的紧止盈自动挂载。基本面看空加技术面看多的试探仓,次日 09:40 自动挂一条回撤百分之三、硬目标百分之八、带 SAR 线的跟踪止盈,不占每日新挂名额。认它靠入场账本里 advice.tight_trail 这个标记(仓位矩阵 advise_v2 给这类试探仓打的),顺着首批未平批次到指令到账本放行记录找。冷却与在途照常让路。
**动了哪些文件**
app/services/strategy_runner.py跟踪止盈评估器 _eval_trail 加盘中 SAR 止损线,防了单测传 now=None 的空指针app/services/strategy_advisor.py09:40 一跳加 _refresh_sar_lines 刷 SAR 线_entry_tight_trail 认入场标记、_attach_tight_trail 挂紧止盈接进主循环无策略票走常规边之前app/services/param_store.py登记 PMS_TECH_SAR_STOP_ON_TRAIL、PMS_TECH_SAR_STOP_BUFFER、PMS_TECH_TIGHT_TRAIL_GIVEBACK、PMS_TECH_TIGHT_TRAIL_TARGET三个加校验范围scripts/test_batch30_units.pypart3 九例、part4 六例,共扩到 33 例scripts/run_tests.py例数与总数 820docs/复盘决定台账.md台账 011
**部署方式**
改了 Pythonmake deploy 重建镜像加 force-recreate。收盘后 155已授权直接部署。
**真机判收**
待部署后补。开发机全量 ALL SUITES PASS第三十批 33 例含每组开关关掉与没读数时不生效的守。这两部分只对自动挂载的跟踪止盈与弱基本面试探仓生效其余策略票行为不变batch17/19 回归绿)。
**还欠着什么**
工作包三四部分全做完。剩:工作包四选股打分在 akg-factor-bridge 另一仓库,单独审批后再动;页面管理视图持仓总览「证据列印两遍」的显示错误与研究面列本轮未碰。真机判收要看真有弱基本面试探仓入场次日的紧止盈自动挂载、以及策略票 SAR 翻空当天的止损。
---
<!--
下一条节点从这里往下写,格式照抄上面:

View File

@ -120,6 +120,12 @@ RUNTIME_EXTRA = {
"PMS_TECH_EXIT_AUTONOMY": ("full", str, "技术面转空离场档位: off 不评 / propose_only 交人确认 / full 自动执行 (与保垫减仓同档, 按拍板默认)"),
"PMS_TECH_EXIT_TRIM_RATIO": (0.3333, float, "转空未确认时的减持比例 (占总持仓; 确认转空则清仓全部可卖量; 默认三分之一)"),
"PMS_TECH_EXIT_DONE": ("", str, "本次翻空已处理过的代码集 (scan_and_route 写入的 JSON: codes/at; 跨轮去重, 页面只读, 勿手改)"),
# 跟踪止盈的盘中 SAR 止损线 (2026-09-11 工作包三 part 3): 每天 09:40 把技术面 SAR 值刷进自动跟踪止盈, 盘中跌破即卖。
"PMS_TECH_SAR_STOP_ON_TRAIL": (True, bool, "自动跟踪止盈加盘中 SAR 止损线: 09:45 后现价跌破昨日 SAR 值的缓冲即全清, 当日一次 (关掉即不刷不判)"),
"PMS_TECH_SAR_STOP_BUFFER": (0.003, float, "SAR 止损线的跌破缓冲 (现价低于 SAR×(1-缓冲) 才触发; 默认千分之三)"),
# 弱基本面试探仓的紧止盈自动挂载 (2026-09-11 工作包三 part 4, 台账 011): 基本面看空加技术面看多的试探仓次日自动挂。
"PMS_TECH_TIGHT_TRAIL_GIVEBACK": (0.03, float, "弱基本面试探仓紧止盈的回撤比例 (从高点回落这么多即全清; 默认百分之三)"),
"PMS_TECH_TIGHT_TRAIL_TARGET": (0.08, float, "弱基本面试探仓紧止盈的硬目标 (浮盈到这么多直接全清; 默认百分之八)"),
}
# **读不到时必须按"已暂停"处理的键 (fail-closed)。**
@ -498,7 +504,8 @@ _RANGES = {
"PMS_TECH_FLIP_FRESH_DAYS": (1, 10),
# 三源合议 (2026-09-11)
"PMS_FUND_STALE_DAYS": (0, 3650), "PMS_FUND_CONSENSUS_GOOD_MIN": (0, 10),
"PMS_TECH_EXIT_TRIM_RATIO": (0, 1),
"PMS_TECH_EXIT_TRIM_RATIO": (0, 1), "PMS_TECH_SAR_STOP_BUFFER": (0, 0.1),
"PMS_TECH_TIGHT_TRAIL_GIVEBACK": (0.005, 0.5), "PMS_TECH_TIGHT_TRAIL_TARGET": (0.01, 1),
# 宏观择时层
"PMS_MACRO_HOT_TH": (0, 100), "PMS_MACRO_COLD_TH": (-100, 0),
"PMS_MACRO_EXIT_BAND": (0, 100), "PMS_MACRO_CONFIRM_DAYS": (1, 10),

View File

@ -480,6 +480,15 @@ def scan(*, dry_run: bool = False, now=None) -> dict:
except Exception: # noqa: BLE001
pass
# 盘中 SAR 止损线的每日刷新 (2026-09-11 工作包三 part 3): 把技术面映射里的 SAR 值刷进每条
# 自动挂载的跟踪止盈的 params.sar_line, 供 strategy_runner._eval_trail 的盘中 SAR 止损用。
# 开关 PMS_TECH_SAR_STOP_ON_TRAIL 关掉不刷; 试算不写。取不到读数就撤掉旧 SAR 线 (不拿旧读数当今天)。
if param_store.get_bool("PMS_TECH_SAR_STOP_ON_TRAIL", True) and not dry_run:
try:
_refresh_sar_lines(strat_all, out)
except Exception as e: # noqa: BLE001 —— 刷 SAR 线失败不拖垮整轮挂载
out["errors"].append(f"SAR 线刷新失败: {type(e).__name__}: {e}")
# ---- 逐票走状态机 ----
for p in held:
out["checked"] += 1
@ -504,6 +513,10 @@ def scan(*, dry_run: bool = False, now=None) -> dict:
_tend_existing(st, p, a, fresh, hv, prm, buypause, handoff_cool,
today, dry_run, out, strategy_service, accum_ok=accum_ok)
continue
# 弱基本面试探仓的紧止盈自动挂载 (2026-09-11 工作包三 part 4): 无策略票里, 基本面看空加
# 技术面看多的试探仓次日自动挂紧止盈, 不占每日名额。挂了 (或占位让路) 就不再走常规边。
if _attach_tight_trail(p, prm, live_codes, optout, dry_run, out, strategy_service):
continue
edge, why = plan_edge(cls=a.get("cls"), fresh=fresh, heat=hv,
cushion=p.get("cushion_pct"), prm=prm)
if not edge:
@ -521,6 +534,122 @@ def scan(*, dry_run: bool = False, now=None) -> dict:
return _record_scan(out, dry_run, now)
def _refresh_sar_lines(strat_all, out) -> None:
"""把技术面映射里的 SAR 值刷进每条自动挂载的跟踪止盈的 params.sar_line (2026-09-11 工作包三 part 3)。
只动自动挂载 (note 自动挂载开头) ACTIVE 的跟踪止盈; 人工策略与网格不碰
取不到某票的 SAR (无读数映射停更) 就撤掉它旧的 sar_line 不拿旧读数当今天的止损线
值没变就不写库写失败只记 errors, 不拦整轮
"""
from app.services import tech_service
tmap = tech_service.state_map() # 取不到抛给调用方的 try 兜住
n = 0
for s in strat_all:
if str(s.get("type") or "").upper() != "TRAIL":
continue
if not str(s.get("note") or "").startswith("自动挂载"):
continue
if str(s.get("status") or "ACTIVE").upper() != "ACTIVE":
continue
params = dict(s.get("params") or {})
old = params.get("sar_line")
sar = _f((tmap.get(s.get("ts_code")) or {}).get("sar_value"))
if sar and sar > 0:
params["sar_line"] = round(sar, 3)
else:
params.pop("sar_line", None) # 无读数: 撤掉旧线, 这道 SAR 止损本日不生效
if params.get("sar_line") != old:
if pms_repo.update_strategy(s["strategy_id"], params=params):
n += 1
else:
out["errors"].append(f"{s.get('ts_code')} SAR 线刷新影响 0 行")
if n:
out["sar_refreshed"] = n
def _entry_tight_trail(code) -> bool:
"""这只持仓是不是「基本面看空加技术面看多的试探仓」(2026-09-11 工作包三 part 4)。
判据是入场那条账本记录里 advice.tight_trail 为真 那是 advise_v2 给这类试探仓打的标记
(方案附录乙: 只给试探仓一批交人配紧止盈)顺着首批未平批次 指令 账本放行记录找
读不到认不出一律按否 认不出就不自动挂紧止盈, 交给常规边或人工 (加不改)
"""
try:
lots = pms_repo.list_lots(code, status="OPEN", limit=50)
if not lots:
return False
iid = lots[0].get("instruction_id")
if not iid:
return False
refs = [str(iid)]
ins = pms_repo.get_instruction(str(iid)) or {}
for k in (ins.get("origin_id"), (ins.get("progress") or {}).get("from_proposal")):
if k and str(k) not in refs:
refs.append(str(k))
for r in pms_repo.ledger_by_ref(refs):
if r.get("verdict") != "PASS":
continue
adv = (r.get("hard_numbers") or {}).get("advice")
if isinstance(adv, dict) and adv.get("tight_trail"):
return True
except Exception as e: # noqa: BLE001
logger.warning("[自动挂载] 查入场紧止盈标记失败 %s (按否): %s", code, e)
return False
def _attach_tight_trail(p, prm, live_codes, optout, dry_run, out, strategy_service) -> bool:
"""弱基本面试探仓的紧止盈自动挂载 (2026-09-11 工作包三 part 4, 台账 011)。
基本面看空加技术面看多的试探仓, 次日 09:40 自动挂一条回撤 3%硬目标 8% SAR 线的跟踪止盈,
**不占每日新挂名额** (它是给弱基本面试探仓配的保护, 不是常规吸筹/高热挂载)挂上后这只票就有
策略了, 下一跳 st 不为空不再重挂返回 True 表示这只票已按紧止盈处理(占位, 不再走常规边)
冷却与在途照常让路: 人工撤下过就等冷却, 有在途就缓一天挂载参数与 SAR 线由本函数生成,
SAR 值取当轮技术面映射; 取不到就不带 SAR 线 (回撤与硬目标照常, 加不改)
"""
code = p["ts_code"]
if not _entry_tight_trail(code):
return False
if (code, R_TRAIL) in optout:
out["skipped"].append({"ts_code": code, "why":
f"弱基本面试探仓想挂紧止盈, 但人工撤下同类冷却期内 "
f"({prm['optout_cooldown_tdays']} 个交易日)"})
return True
if code in live_codes:
out["skipped"].append({"ts_code": code, "why": "弱基本面试探仓想挂紧止盈, 但有在途, 缓到下一个扫描日"})
return True
gb = param_store.get_float("PMS_TECH_TIGHT_TRAIL_GIVEBACK", 0.03)
ht = param_store.get_float("PMS_TECH_TIGHT_TRAIL_TARGET", 0.08)
params = {"giveback": gb, "sell_ratio": 1.0, "hard_target": ht}
try:
from app.services import tech_service
sar = _f((tech_service.state_map().get(code) or {}).get("sar_value"))
if sar and sar > 0:
params["sar_line"] = round(sar, 3)
except Exception: # noqa: BLE001 —— 取不到 SAR 不拦, 回撤与硬目标照常
pass
note = (f"{NOTE_AUTO}基本面看空加技术面看多的试探仓 → 紧止盈 "
f"回撤 {gb:.0%} 硬目标 {ht:.0%}" + ("、带 SAR 线" if params.get("sar_line") else ""))
if dry_run:
out["attached"].append({"ts_code": code, "type": "TRAIL", "dry_run": True,
"why": "弱基本面试探仓紧止盈 (不占每日名额)", "params": params})
return True
r = strategy_service.attach({"ts_code": code, "type": "TRAIL", "autonomy": "auto",
"params": params, "note": note[:280]}, by="auto") or {}
if not r.get("ok"):
errs = "; ".join(str(x) for x in (r.get("errors") or ["挂载校验未过"]))
out["blocked"].append({"ts_code": code, "edge": R_TRAIL, "why": errs})
_ledger(code, "NOTE", f"想挂弱基本面紧止盈被挂载校验挡下: {errs}",
{"price": p.get("price")}, None, out)
return True
out["attached"].append({"ts_code": code, "type": "TRAIL", "strategy_id": r.get("strategy_id"),
"why": "弱基本面试探仓紧止盈 (不占每日名额)"})
_ledger(code, "PASS", note[:200], {"price": p.get("price"), "sar_line": params.get("sar_line")},
r.get("strategy_id"), out, action="ATTACH")
logger.warning("[自动挂载] %s 挂弱基本面紧止盈: 回撤 %.0f%% 硬目标 %.0f%%", code, gb * 100, ht * 100)
return True
def _tend_existing(st, p, a, fresh, hv, prm, buypause, handoff_cool, today,
dry_run, out, strategy_service, accum_ok=True):
"""已挂策略的票: 边三 (派发停买/回明确恢复) 与 边四 (接力切换)。"""

View File

@ -526,6 +526,21 @@ def _eval_trail(st, pos, day, now, ctx):
# 全清数量: 科创板持仓不足 200 股时按交易所例外一次性全卖, 否则整百
return avail if avail < mlot else (_round_lot(avail) or avail)
# 盘中 SAR 止损线 (2026-09-11 工作包三 part 3): 09:45 后现价跌破昨日 SAR 值的缓冲即全清, 当日只触发一次。
# SAR 值由 09:40 的 strategy_advisor.scan 刷进 params["sar_line"] (工作包三 part 3 的刷新腿);
# 没刷进 (技术面无读数、首日、映射停更) 就跳过这道线 —— 加不改, 拿不到 SAR 绝不当"跌破"。
# 与 giveback/hard_target 并列的一道硬止损, 不看 armed (SAR 翻空是趋势破位, 不必先武装)。
sar_line = _f(params.get("sar_line"))
if sar_line > 0 and now is not None and param_store.get_bool("PMS_TECH_SAR_STOP_ON_TRAIL", True):
hm = now.hour * 100 + now.minute
if hm >= 945 and int(state.get("sar_stop_day") or 0) != td.ymd(now):
buf = param_store.get_float("PMS_TECH_SAR_STOP_BUFFER", 0.003)
if price <= sar_line * (1 - buf):
state["sar_stop_day"] = td.ymd(now) # 当日只触发一次
return {"side": "sell", "action": A_EXIT, "qty": _all_out(), "leg": "trail_sar",
"reason": f"跟踪止盈-SAR 止损: 现价 {price} 跌破昨日 SAR {sar_line}"
f"×(1-{buf:.1%}), 全清 {_all_out()}"}
# 硬止盈目标: 直接全清
if hard_target > 0 and profit >= hard_target:
return {"side": "sell", "action": A_EXIT, "qty": _all_out(), "leg": "trail_hard",

View File

@ -174,4 +174,16 @@
**复核日期。** 上线后每周复核一次退出条数与误伤。
**尚未完成的两部分(属工作包三,随后补台账)。** 一是跟踪止盈策略加盘中 SAR 止损线,二是弱基本面加技术面看多的试探仓次日自动挂紧止盈(记台账 011
## 011 · 2026-09-11 · 策略层的两道技术面离场:跟踪止盈盘中 SAR 止损线与弱基本面试探仓紧止盈
**改动一,跟踪止盈加盘中 SAR 止损线。** 自动挂载的跟踪止盈策略,每天早上把技术面映射里的 SAR 值刷进它的参数。盘中九点四十五之后,现价跌破昨日 SAR 值的千分之三缓冲就全清,当日只触发一次。这是给策略票配的趋势止损。动作引擎那条转空离场只管常规持仓,策略票的 SAR 由这道线管,两者分工不重叠。取不到 SAR 读数就撤掉旧的止损线,绝不拿旧读数当今天。
**改动二,弱基本面试探仓的紧止盈自动挂载。** 基本面看空加技术面看多的票,只给试探仓一批,交人拍板后入场。这类票次日早上九点四十自动挂一条跟踪止盈,回撤百分之三就全清、硬目标百分之八直接全清、带 SAR 止损线。它不占每日新挂名额,因为这是给弱基本面试探仓配的保护,不是常规的吸筹或高热挂载。认它靠入场账本里的一个标记,那是仓位矩阵给这类试探仓打的。
**依据。** 弱基本面的票本就是试探,一旦技术面也走坏就该快离场,所以配紧止盈加 SAR 线。SAR 是跟踪止损,翻空即趋势破位。
**参数。** SAR 止损线开关 PMS_TECH_SAR_STOP_ON_TRAIL 默认开,缓冲 PMS_TECH_SAR_STOP_BUFFER 默认千分之三。紧止盈回撤 PMS_TECH_TIGHT_TRAIL_GIVEBACK 默认百分之三,硬目标 PMS_TECH_TIGHT_TRAIL_TARGET 默认百分之八。
**预期。** 策略票在 SAR 翻空的当天被止损线保护。弱基本面试探仓入场次日就有紧止盈兜底。
**复核日期。** 上线后每周复核一次看紧止盈有没有过早止损、SAR 线有没有误杀。

View File

@ -83,16 +83,18 @@
test_batch30_units.py 三源合议·工作包三离场纪律 (2026-09-11): eval_tech_exit 转空自动离场(确认清仓/
未确认减三分之一/翻向超期不评/同一翻空一次/无读数弃权/propose_only 交人/可卖量
夹紧/开关关掉逐字回旧)/卖出优先级目标价<研究走弱<技术面转空<保垫减仓/scan 端到端/
策略票只看目标价按函数判(技术面转空对策略票不评)/参数登记 (18 )
策略票只看目标价按函数判(技术面转空对策略票不评)/跟踪止盈盘中 SAR 止损线
(09:45 后跌破缓冲全清当日一次没刷进不判开关关掉不触发)/SAR 线刷新腿只动
自动跟踪止盈且无读数撤旧线/弱基本面试探仓紧止盈自动挂载(入场标记识别回撤3%硬目标8%带SAR线不占名额冷却让路)/参数登记 (33 )
test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许
直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象
打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) +
目标价到价必定入队 (档位 full 也不自动卖) +
用户设的止损价与目标价单独成列显示 (70 )
805
820
(总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条
减持与研究理由两键各一例, 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 654; 又加了页面文案守卫一例, 655; 09-07 审查修复加了跨轮减持等五例, 660; 第二件低把握驳回交人一例, 661; 第三件逻辑状态接入第二十二批十五例, 676; 第四件安全边际整句透传一例, 677; 参考目标价一例, 678; 催化事件与定价状态透传一例, 679; 两个期限的头一例, 现为 680; 09-10 建议档位对齐第二十六批十七例到 697; 09-11 技术面接入工作包一第二十七批二十七例到 724; 三源合议工作包二纯逻辑第二十八批三十七例到 763; 接入下单链路第二十九批二十四例到 787; 工作包三离场纪律第三十批十八例, 现为 805)
减持与研究理由两键各一例, 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 654; 又加了页面文案守卫一例, 655; 09-07 审查修复加了跨轮减持等五例, 660; 第二件低把握驳回交人一例, 661; 第三件逻辑状态接入第二十二批十五例, 676; 第四件安全边际整句透传一例, 677; 参考目标价一例, 678; 催化事件与定价状态透传一例, 679; 两个期限的头一例, 现为 680; 09-10 建议档位对齐第二十六批十七例到 697; 09-11 技术面接入工作包一第二十七批二十七例到 724; 三源合议工作包二纯逻辑第二十八批三十七例到 763; 接入下单链路第二十九批二十四例到 787; 工作包三离场纪律第三十批十八例805; part3 盘中 SAR 止损线补九例到 814; part4 弱基本面紧止盈补六例, 现为 820)
任一子集失败即整体失败 (退出码 1)
哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了):

View File

@ -196,6 +196,221 @@ def _():
assert abs(ps.RUNTIME_EXTRA["PMS_TECH_EXIT_TRIM_RATIO"][0] - 1.0 / 3) < 1e-3
# ================================================================ F 跟踪止盈盘中 SAR 止损线 (part 3)
def _trail_sar(price, sar_line, now, avail=6000, avg=10.0, sold_day=0):
from app.services import strategy_runner as srun
st = {"ts_code": "600000.SH",
"params": {"giveback": 0.05, "sell_ratio": 1.0, "sar_line": sar_line}}
pos = {"avg_cost": avg, "avail_qty": avail, "total_qty": avail, "cushion_pct": price / avg - 1}
ctx = {"state": {"armed": False, "high_water": price, "sar_stop_day": sold_day}, "notes": []}
d = srun._eval_trail(st, pos, {"price": price}, now, ctx)
return d, ctx
@case("F SAR 线·09:45 后跌破 SAR 缓冲即全清 (leg=trail_sar, 当日记一次)")
def _():
from datetime import datetime
from app.services import strategy_runner as srun
d, ctx = _trail_sar(9.9, 10.0, datetime(2026, 9, 11, 10, 0)) # 9.9 < 10×0.997=9.97
assert d and d["action"] == srun.A_EXIT and d["leg"] == "trail_sar" and d["qty"] == 6000
from app.core import tradedays as td
assert ctx["state"]["sar_stop_day"] == td.ymd(datetime(2026, 9, 11, 10, 0))
@case("F SAR 线·09:45 前不触发")
def _():
from datetime import datetime
d, _ = _trail_sar(9.5, 10.0, datetime(2026, 9, 11, 9, 40))
assert d is None
@case("F SAR 线·现价在缓冲之上不触发")
def _():
from datetime import datetime
d, _ = _trail_sar(9.98, 10.0, datetime(2026, 9, 11, 10, 0)) # 9.98 > 9.97
assert d is None
@case("F SAR 线·当日只触发一次 (已记 sar_stop_day)")
def _():
from datetime import datetime
from app.core import tradedays as td
now = datetime(2026, 9, 11, 10, 0)
d, _ = _trail_sar(9.5, 10.0, now, sold_day=td.ymd(now))
assert d is None
@case("F SAR 线·没刷进 sar_line 时不判 (加不改, 拿不到 SAR 绝不当跌破)")
def _():
from datetime import datetime
from app.services import strategy_runner as srun
st = {"ts_code": "600000.SH", "params": {"giveback": 0.05, "sell_ratio": 1.0}} # 无 sar_line
pos = {"avg_cost": 10.0, "avail_qty": 6000, "total_qty": 6000, "cushion_pct": -0.05}
ctx = {"state": {"armed": False, "high_water": 9.5}, "notes": []}
assert srun._eval_trail(st, pos, {"price": 9.5}, datetime(2026, 9, 11, 10, 0), ctx) is None
@case("F SAR 线·开关关掉不触发")
def _():
from datetime import datetime
saved = ps.get_bool
ps.get_bool = lambda k, d=False: (False if k == "PMS_TECH_SAR_STOP_ON_TRAIL" else saved(k, d))
try:
d, _ = _trail_sar(9.5, 10.0, datetime(2026, 9, 11, 10, 0))
assert d is None
finally:
ps.get_bool = saved
# ================================================================ G SAR 线刷新腿 (part 3)
@case("G 刷新腿·只动自动挂载的 ACTIVE 跟踪止盈, 网格与人工不碰")
def _():
from app.services import strategy_advisor as adv, tech_service
from app.repo import pms_repo
calls = []
s_sm, s_up = tech_service.state_map, pms_repo.update_strategy
tech_service.state_map = lambda: {"600000.SH": {"sar_value": 9.55}}
pms_repo.update_strategy = lambda sid, **kw: (calls.append((sid, kw)) or 1)
try:
strat_all = [
{"strategy_id": "A", "ts_code": "600000.SH", "type": "TRAIL", "status": "ACTIVE",
"note": "自动挂载: x", "params": {"giveback": 0.05}},
{"strategy_id": "B", "ts_code": "600000.SH", "type": "GRID", "status": "ACTIVE",
"note": "自动挂载: x", "params": {}},
{"strategy_id": "C", "ts_code": "600000.SH", "type": "TRAIL", "status": "ACTIVE",
"note": "手工挂的", "params": {}},
]
out = {"errors": []}
adv._refresh_sar_lines(strat_all, out)
assert len(calls) == 1 and calls[0][0] == "A"
assert abs(calls[0][1]["params"]["sar_line"] - 9.55) < 1e-6
finally:
tech_service.state_map, pms_repo.update_strategy = s_sm, s_up
@case("G 刷新腿·无读数撤掉旧 SAR 线 (不拿旧读数当今天)")
def _():
from app.services import strategy_advisor as adv, tech_service
from app.repo import pms_repo
calls = []
s_sm, s_up = tech_service.state_map, pms_repo.update_strategy
tech_service.state_map = lambda: {} # 全无读数
pms_repo.update_strategy = lambda sid, **kw: (calls.append((sid, kw)) or 1)
try:
strat_all = [{"strategy_id": "A", "ts_code": "600000.SH", "type": "TRAIL",
"status": "ACTIVE", "note": "自动挂载: x",
"params": {"giveback": 0.05, "sar_line": 9.9}}]
out = {"errors": []}
adv._refresh_sar_lines(strat_all, out)
assert len(calls) == 1 and "sar_line" not in calls[0][1]["params"]
finally:
tech_service.state_map, pms_repo.update_strategy = s_sm, s_up
@case("G 参数·SAR 止损线两键登记")
def _():
assert ps.RUNTIME_EXTRA["PMS_TECH_SAR_STOP_ON_TRAIL"][0] is True
assert abs(ps.RUNTIME_EXTRA["PMS_TECH_SAR_STOP_BUFFER"][0] - 0.003) < 1e-9
# ================================================================ H 弱基本面试探仓紧止盈自动挂载 (part 4)
@case("H 紧止盈·入场账本 advice.tight_trail 为真才认")
def _():
from app.services import strategy_advisor as adv
from app.repo import pms_repo
s_lots, s_ins, s_led = pms_repo.list_lots, pms_repo.get_instruction, pms_repo.ledger_by_ref
pms_repo.list_lots = lambda code, **kw: [{"instruction_id": "I1"}]
pms_repo.get_instruction = lambda iid: {"origin_id": "P1"}
try:
pms_repo.ledger_by_ref = lambda refs: [{"verdict": "PASS",
"hard_numbers": {"advice": {"tight_trail": True}}}]
assert adv._entry_tight_trail("600000.SH") is True
pms_repo.ledger_by_ref = lambda refs: [{"verdict": "PASS",
"hard_numbers": {"advice": {"tight_trail": False}}}]
assert adv._entry_tight_trail("600000.SH") is False
pms_repo.ledger_by_ref = lambda refs: [{"verdict": "PASS", "hard_numbers": {}}]
assert adv._entry_tight_trail("600000.SH") is False
finally:
pms_repo.list_lots, pms_repo.get_instruction, pms_repo.ledger_by_ref = s_lots, s_ins, s_led
@case("H 紧止盈·非试探仓不挂 (返回 False, 走常规边)")
def _():
from app.services import strategy_advisor as adv
s = adv._entry_tight_trail
adv._entry_tight_trail = lambda code: False
try:
out = {"attached": [], "skipped": [], "blocked": []}
r = adv._attach_tight_trail({"ts_code": "600000.SH"}, {"optout_cooldown_tdays": 10},
set(), set(), True, out, None)
assert r is False and not out["attached"]
finally:
adv._entry_tight_trail = s
@case("H 紧止盈·试探仓 dry_run 挂 回撤3%/硬目标8%/带SAR线")
def _():
from app.services import strategy_advisor as adv, tech_service
s_e, s_sm = adv._entry_tight_trail, tech_service.state_map
adv._entry_tight_trail = lambda code: True
tech_service.state_map = lambda: {"600000.SH": {"sar_value": 9.6}}
try:
out = {"attached": [], "skipped": [], "blocked": []}
r = adv._attach_tight_trail({"ts_code": "600000.SH", "price": 10.0},
{"optout_cooldown_tdays": 10}, set(), set(), True, out, None)
assert r is True and len(out["attached"]) == 1
pr = out["attached"][0]["params"]
assert abs(pr["giveback"] - 0.03) < 1e-9 and abs(pr["hard_target"] - 0.08) < 1e-9
assert abs(pr["sar_line"] - 9.6) < 1e-6
finally:
adv._entry_tight_trail, tech_service.state_map = s_e, s_sm
@case("H 紧止盈·真挂调 strategy_service.attach 且 note 标弱基本面")
def _():
from app.services import strategy_advisor as adv, tech_service
from app.repo import pms_repo
s_e, s_sm, s_il = adv._entry_tight_trail, tech_service.state_map, pms_repo.insert_ledger
adv._entry_tight_trail = lambda code: True
tech_service.state_map = lambda: {}
pms_repo.insert_ledger = lambda **kw: "L"
calls = []
class SS:
def attach(self, spec, by=None):
calls.append(spec)
return {"ok": True, "strategy_id": "T9"}
try:
out = {"attached": [], "skipped": [], "blocked": [], "errors": []}
r = adv._attach_tight_trail({"ts_code": "600000.SH", "price": 10.0},
{"optout_cooldown_tdays": 10}, set(), set(), False, out, SS())
assert r is True and calls and calls[0]["type"] == "TRAIL"
assert "试探仓" in calls[0]["note"] and "紧止盈" in calls[0]["note"]
assert out["attached"][0]["strategy_id"] == "T9"
finally:
adv._entry_tight_trail, tech_service.state_map, pms_repo.insert_ledger = s_e, s_sm, s_il
@case("H 紧止盈·人工撤下冷却期内占位让路 (返回 True 不走常规边, 不挂)")
def _():
from app.services import strategy_advisor as adv
s_e = adv._entry_tight_trail
adv._entry_tight_trail = lambda code: True
try:
out = {"attached": [], "skipped": [], "blocked": []}
r = adv._attach_tight_trail({"ts_code": "600000.SH"}, {"optout_cooldown_tdays": 10},
set(), {("600000.SH", adv.R_TRAIL)}, True, out, None)
assert r is True and len(out["skipped"]) == 1 and not out["attached"]
finally:
adv._entry_tight_trail = s_e
@case("H 参数·紧止盈两键登记")
def _():
assert abs(ps.RUNTIME_EXTRA["PMS_TECH_TIGHT_TRAIL_GIVEBACK"][0] - 0.03) < 1e-9
assert abs(ps.RUNTIME_EXTRA["PMS_TECH_TIGHT_TRAIL_TARGET"][0] - 0.08) < 1e-9
def main():
ok = 0
for name, fn in RESULTS: