tradingSystem/app/core/sizer.py

178 lines
9.7 KiB
Python
Raw Normal View History

2026-07-27 15:50:57 +08:00
# -*- coding: utf-8 -*-
"""
仓位规划器 (纯函数, 无外部依赖, 可单测)
========================================
职责 (POSITION_MGMT_DESIGN.md §5):
1. 百分比制批次拆分: 单股目标仓位按 50/25/25 分批, 含一手可行性检查与自动合并
(50/25/25 60/40 100, 仍买不足一手 放弃并给出原因)
2. 组合约束校验: 总仓上限 / 单股上限 / 最大持仓数 / 行业集中度 (硬拦截)
3. 风险敞口披露: 不定量只计算与告警
约定: 金额单位元, 价格单位元, 数量单位股 (A股一手=100)
"""
from __future__ import annotations
LOT = 100 # A股一手
# 批次合并阶梯: 一手检查不过时逐级降档
MERGE_LADDER = [
("BASE/FILL/ADD", (0.5, 0.25, 0.25)),
("BASE/FILL", (0.6, 0.4)),
("BASE", (1.0,)),
]
BATCH_NAMES = ["BASE", "FILL", "ADD"]
2026-08-28 13:07:27 +08:00
def lot_of(ts_code) -> int:
"""最小申报数量: 科创板 (688/689 开头) 买入 200 股起, 其余 100 股。
2026-08-28 全库统一到这里 (原先只有 strategy_advisor / strategy_runner 各自兜了一份):
planner 的批次拆分rule_gate 的一手检查executor 的当日配额action_engine
四类动作与新建仓, 全部改为按代码取最小申报数量规则出处: 科创板限价申报单笔
不小于 200 ; 卖出余额不足 200 股时应当一次性申报卖出 (各清仓路径单独处理)
"""
return 200 if str(ts_code or "").strip().upper().startswith(("688", "689")) else 100
def lot_qty(amount: float, price: float, lot: int = LOT) -> int:
"""金额换算成整手股数 (向下取整到一手)。价格非法返回 0。
加了 1e-9 的浮点容差: 407 元买 4.07 元的票, 407/4.07 在浮点里是 99.9999,
不加容差会把"恰好买得起一手"算成零手 (2026-08-28 审查发现的边界)
"""
2026-07-27 15:50:57 +08:00
if price is None or price <= 0 or amount is None or amount <= 0:
return 0
2026-08-28 13:07:27 +08:00
return int(amount / price / lot + 1e-9) * lot
2026-07-27 15:50:57 +08:00
2026-08-28 13:07:27 +08:00
def split_batches(target_amount: float, price: float, splits=None, merge: bool = True,
min_lot: int = None) -> dict:
2026-07-27 15:50:57 +08:00
"""把单股目标金额拆成分批投放计划, 含一手检查与自动合并。
返回 {"ok": bool, "scheme": tuple, "batches": [{"name","amount","qty"}...], "reason": str}
- ok=False batches 为空, reason 说明原因 ("目标金额买不足一手")
- 自动合并: 首选方案任一批次不足一手 逐级降档 (60/40 100)
merge=False 时不合并, 直接返回失败明细 (供页面提示)
2026-08-28 13:07:27 +08:00
- min_lot: 每批的最小申报数量 (科创板 200; 调用方按代码传 lot_of(code))
数量仍按整百取整 (200 以上按 100 递增合法), 只是可行性线抬到 min_lot
2026-07-27 15:50:57 +08:00
"""
2026-08-28 13:07:27 +08:00
floor = int(min_lot or LOT)
2026-07-27 15:50:57 +08:00
if price is None or price <= 0:
return {"ok": False, "scheme": (), "batches": [], "reason": "价格非法"}
if target_amount is None or target_amount <= 0:
return {"ok": False, "scheme": (), "batches": [], "reason": "目标金额非法"}
ladders = MERGE_LADDER if splits is None else [("CUSTOM", tuple(splits))] + (MERGE_LADDER[1:] if merge else [])
tried = []
for label, scheme in (ladders if merge else ladders[:1]):
batches = []
feasible = True
for i, ratio in enumerate(scheme):
amt = target_amount * ratio
q = lot_qty(amt, price)
2026-08-28 13:07:27 +08:00
if q < floor:
2026-07-27 15:50:57 +08:00
feasible = False
break
name = BATCH_NAMES[i] if i < len(BATCH_NAMES) else f"B{i+1}"
batches.append({"name": name, "amount": round(amt, 2), "qty": q})
tried.append(label)
if feasible:
return {"ok": True, "scheme": scheme, "batches": batches, "reason": ""}
return {
"ok": False, "scheme": (), "batches": [],
2026-08-28 13:07:27 +08:00
"reason": (f"目标金额 {target_amount:.0f} 元按现价 {price:.2f} 买不足一手"
+ (f" (科创板最少 {floor} 股)" if floor > LOT else "")
+ f" (已尝试: {''.join(tried)})"),
2026-07-27 15:50:57 +08:00
}
def check_caps(*, ts_code: str, add_amount: float, ctx: dict) -> list:
"""组合约束硬校验。返回未通过项列表 (空列表 = 全过)。
ctx (由调用方备齐, 全部为加仓前快照):
scale 总规模 ()
portfolio_cap 总仓上限 (比例)
stock_cap 单股上限 (比例)
max_names 最大持仓数
portfolio_mv 当前组合市值 ()
names_count 当前持仓数
stock_mv 该股当前市值 (, 无仓=0)
is_new_name 本次是否新开仓
sector 该股行业名 (None=行业数据源未配置 行业约束跳过)
sector_names 同行业当前持仓数
sector_mv 同行业当前市值 ()
sector_max_names / sector_max_ratio 行业约束参数
"""
v = []
scale = float(ctx["scale"])
if scale <= 0:
PMS 文案第二批:规则闸、仓位约束、资金、计划取数 抽了一个公共的显示层函数 app/core/copy.py,把句首那个大写判据码剥掉再给人看。 这一步是有讲究的。那些判据码是承重的:账本靠它取去重键(同一条被拒指令当日只记一条), 命令进度靠它做原因聚类。2026-07-29 账本被同一条指令按分钟灌满,病根就是去重键失效。 所以码必须留在句首,只能在显示层剥。此前后端两处各写了一份剥前缀的逻辑,现在共用一份。 改写的文案: 规则闸六句。冻结原因原先写「该股处于 COMMAND_HALT, 禁止增持」,既印了枚举值,也没分开 三种来路——你下的命令、你手设的、系统刹车期间,对交易员是三件不同的事。昨夜信号原先写 「决策系统昨夜定性 SELL (形态恶化)」,SELL 是枚举值,而「形态恶化」是给三种信号硬安的 定性,回避未必是形态问题。 仓位约束六句,原先全是「加后总仓 82.3% > 上限 80%」这种电报体。大于号要人翻译,而且 没说清百分比是占什么。行业占比那条还漏了设计要点——两条都超才拦,不写会被当成误拦 来投诉。 资金四句改在源头。这个字段有三个下游都直接显示给人看,改一处三处一起干净。异常原文 只进日志。 计划取数两句会渲染成页面顶部整条红色横幅。原先网络抖一下就顶一条异常原文给基金经理 看。改成说清三件事:现在拿不到什么、多久之后重试有意义、还不行找谁。其中「过 1 分钟 再试」不能省——失败结果会被缓存一分钟,早于这个点重读拿到的还是同一条失败。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:26:57 +08:00
return ["SCALE_INVALID: 系统里还没填总规模,仓位上限算不出来,先去参数设置里填总规模"]
2026-07-27 15:50:57 +08:00
if (ctx["portfolio_mv"] + add_amount) / scale > ctx["portfolio_cap"] + 1e-9:
PMS 文案第二批:规则闸、仓位约束、资金、计划取数 抽了一个公共的显示层函数 app/core/copy.py,把句首那个大写判据码剥掉再给人看。 这一步是有讲究的。那些判据码是承重的:账本靠它取去重键(同一条被拒指令当日只记一条), 命令进度靠它做原因聚类。2026-07-29 账本被同一条指令按分钟灌满,病根就是去重键失效。 所以码必须留在句首,只能在显示层剥。此前后端两处各写了一份剥前缀的逻辑,现在共用一份。 改写的文案: 规则闸六句。冻结原因原先写「该股处于 COMMAND_HALT, 禁止增持」,既印了枚举值,也没分开 三种来路——你下的命令、你手设的、系统刹车期间,对交易员是三件不同的事。昨夜信号原先写 「决策系统昨夜定性 SELL (形态恶化)」,SELL 是枚举值,而「形态恶化」是给三种信号硬安的 定性,回避未必是形态问题。 仓位约束六句,原先全是「加后总仓 82.3% > 上限 80%」这种电报体。大于号要人翻译,而且 没说清百分比是占什么。行业占比那条还漏了设计要点——两条都超才拦,不写会被当成误拦 来投诉。 资金四句改在源头。这个字段有三个下游都直接显示给人看,改一处三处一起干净。异常原文 只进日志。 计划取数两句会渲染成页面顶部整条红色横幅。原先网络抖一下就顶一条异常原文给基金经理 看。改成说清三件事:现在拿不到什么、多久之后重试有意义、还不行找谁。其中「过 1 分钟 再试」不能省——失败结果会被缓存一分钟,早于这个点重读拿到的还是同一条失败。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:26:57 +08:00
v.append(f"PORTFOLIO_CAP: 买了这一笔,总持仓会占到总规模的 "
f"{(ctx['portfolio_mv'] + add_amount) / scale:.1%}"
f"超过 {ctx['portfolio_cap']:.0%} 的总仓上限")
2026-07-27 15:50:57 +08:00
if (ctx["stock_mv"] + add_amount) / scale > ctx["stock_cap"] + 1e-9:
PMS 文案第二批:规则闸、仓位约束、资金、计划取数 抽了一个公共的显示层函数 app/core/copy.py,把句首那个大写判据码剥掉再给人看。 这一步是有讲究的。那些判据码是承重的:账本靠它取去重键(同一条被拒指令当日只记一条), 命令进度靠它做原因聚类。2026-07-29 账本被同一条指令按分钟灌满,病根就是去重键失效。 所以码必须留在句首,只能在显示层剥。此前后端两处各写了一份剥前缀的逻辑,现在共用一份。 改写的文案: 规则闸六句。冻结原因原先写「该股处于 COMMAND_HALT, 禁止增持」,既印了枚举值,也没分开 三种来路——你下的命令、你手设的、系统刹车期间,对交易员是三件不同的事。昨夜信号原先写 「决策系统昨夜定性 SELL (形态恶化)」,SELL 是枚举值,而「形态恶化」是给三种信号硬安的 定性,回避未必是形态问题。 仓位约束六句,原先全是「加后总仓 82.3% > 上限 80%」这种电报体。大于号要人翻译,而且 没说清百分比是占什么。行业占比那条还漏了设计要点——两条都超才拦,不写会被当成误拦 来投诉。 资金四句改在源头。这个字段有三个下游都直接显示给人看,改一处三处一起干净。异常原文 只进日志。 计划取数两句会渲染成页面顶部整条红色横幅。原先网络抖一下就顶一条异常原文给基金经理 看。改成说清三件事:现在拿不到什么、多久之后重试有意义、还不行找谁。其中「过 1 分钟 再试」不能省——失败结果会被缓存一分钟,早于这个点重读拿到的还是同一条失败。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:26:57 +08:00
# 句首不写股票代码:页面左边一列已经是股票名和代码,重复只是噪音。
v.append(f"STOCK_CAP: 买了这一笔,这只票会占到总规模的 "
f"{(ctx['stock_mv'] + add_amount) / scale:.1%}"
f"超过单只 {ctx['stock_cap']:.0%} 的上限")
2026-07-27 15:50:57 +08:00
if ctx.get("is_new_name") and ctx["names_count"] + 1 > ctx["max_names"]:
PMS 文案第二批:规则闸、仓位约束、资金、计划取数 抽了一个公共的显示层函数 app/core/copy.py,把句首那个大写判据码剥掉再给人看。 这一步是有讲究的。那些判据码是承重的:账本靠它取去重键(同一条被拒指令当日只记一条), 命令进度靠它做原因聚类。2026-07-29 账本被同一条指令按分钟灌满,病根就是去重键失效。 所以码必须留在句首,只能在显示层剥。此前后端两处各写了一份剥前缀的逻辑,现在共用一份。 改写的文案: 规则闸六句。冻结原因原先写「该股处于 COMMAND_HALT, 禁止增持」,既印了枚举值,也没分开 三种来路——你下的命令、你手设的、系统刹车期间,对交易员是三件不同的事。昨夜信号原先写 「决策系统昨夜定性 SELL (形态恶化)」,SELL 是枚举值,而「形态恶化」是给三种信号硬安的 定性,回避未必是形态问题。 仓位约束六句,原先全是「加后总仓 82.3% > 上限 80%」这种电报体。大于号要人翻译,而且 没说清百分比是占什么。行业占比那条还漏了设计要点——两条都超才拦,不写会被当成误拦 来投诉。 资金四句改在源头。这个字段有三个下游都直接显示给人看,改一处三处一起干净。异常原文 只进日志。 计划取数两句会渲染成页面顶部整条红色横幅。原先网络抖一下就顶一条异常原文给基金经理 看。改成说清三件事:现在拿不到什么、多久之后重试有意义、还不行找谁。其中「过 1 分钟 再试」不能省——失败结果会被缓存一分钟,早于这个点重读拿到的还是同一条失败。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:26:57 +08:00
v.append(f"MAX_NAMES: 再开一只就是 {ctx['names_count'] + 1} 只持仓,"
f"超过最多 {ctx['max_names']} 只的上限")
2026-07-27 15:50:57 +08:00
sector = ctx.get("sector")
if sector: # None/"" = 行业数据源未配置, 约束停用 (调用方负责页面提示)
if ctx.get("is_new_name") and ctx.get("sector_names", 0) + 1 > ctx["sector_max_names"]:
PMS 文案第二批:规则闸、仓位约束、资金、计划取数 抽了一个公共的显示层函数 app/core/copy.py,把句首那个大写判据码剥掉再给人看。 这一步是有讲究的。那些判据码是承重的:账本靠它取去重键(同一条被拒指令当日只记一条), 命令进度靠它做原因聚类。2026-07-29 账本被同一条指令按分钟灌满,病根就是去重键失效。 所以码必须留在句首,只能在显示层剥。此前后端两处各写了一份剥前缀的逻辑,现在共用一份。 改写的文案: 规则闸六句。冻结原因原先写「该股处于 COMMAND_HALT, 禁止增持」,既印了枚举值,也没分开 三种来路——你下的命令、你手设的、系统刹车期间,对交易员是三件不同的事。昨夜信号原先写 「决策系统昨夜定性 SELL (形态恶化)」,SELL 是枚举值,而「形态恶化」是给三种信号硬安的 定性,回避未必是形态问题。 仓位约束六句,原先全是「加后总仓 82.3% > 上限 80%」这种电报体。大于号要人翻译,而且 没说清百分比是占什么。行业占比那条还漏了设计要点——两条都超才拦,不写会被当成误拦 来投诉。 资金四句改在源头。这个字段有三个下游都直接显示给人看,改一处三处一起干净。异常原文 只进日志。 计划取数两句会渲染成页面顶部整条红色横幅。原先网络抖一下就顶一条异常原文给基金经理 看。改成说清三件事:现在拿不到什么、多久之后重试有意义、还不行找谁。其中「过 1 分钟 再试」不能省——失败结果会被缓存一分钟,早于这个点重读拿到的还是同一条失败。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:26:57 +08:00
v.append(f"SECTOR_NAMES: {sector}已经持有 {ctx.get('sector_names', 0)} 只,"
f"再开一只就是 {ctx['sector_names'] + 1} 只,"
f"超过同一行业最多 {ctx['sector_max_names']} 只的上限")
2026-07-31 14:36:50 +08:00
# 行业占比: **占组合超上限 且 绝对敞口够大**, 两条同时成立才拦 (2026-07-31 定)
# ---------------------------------------------------------------------
# 只看"占组合"的话, 这道闸在建仓期是**结构性不可满足**的: 空账本买第一只票,
# 它按定义就是组合的 100%, 必然 > 任何小于 100% 的上限; 而第一只被拒之后组合市值
# 不推进, 第二只第三只面对的还是 100% —— 哪怕候选分属十个不同行业也全军覆没。
# 实测 2026-07-31: 空账本 + 10 只强传导候选 + 一条 60% 升仓命令 → 一条方案都出不来,
# 报出来却是"候选与补仓空间不足"。行业源是当天才通的 (此前 sector 恒为 None、整段
# 跳过), 所以这个洞一直藏着。数学上上限 40% 至少要 3 只不同行业的票同时在组合里才
# 可能满足, 调松阈值解决不了。
#
# 加"绝对敞口"这条判据的道理: 集中度是风险的**放大器**, 不是风险本身 —— 敞口只有
# 规模 6% 的时候, 它 100% 集中在一个行业也谈不上风险。绝对线取
# `sector_max_ratio × portfolio_cap`, 于是组合建满到总仓上限时两条判据自然趋同
# (40% × 60% = 24% of scale ⟺ 40% of portfolio), 中间是连续过渡, 没有"第几只突然
# 开始生效"的台阶。
2026-07-27 15:50:57 +08:00
port_after = ctx["portfolio_mv"] + add_amount
2026-07-31 14:36:50 +08:00
sector_after = ctx.get("sector_mv", 0) + add_amount
ratio_max = ctx["sector_max_ratio"]
of_port = (sector_after / port_after) if port_after > 0 else 0.0
of_scale = sector_after / scale
floor = ratio_max * float(ctx.get("portfolio_cap") or 1.0)
if of_port > ratio_max + 1e-9 and of_scale > floor + 1e-9:
PMS 文案第二批:规则闸、仓位约束、资金、计划取数 抽了一个公共的显示层函数 app/core/copy.py,把句首那个大写判据码剥掉再给人看。 这一步是有讲究的。那些判据码是承重的:账本靠它取去重键(同一条被拒指令当日只记一条), 命令进度靠它做原因聚类。2026-07-29 账本被同一条指令按分钟灌满,病根就是去重键失效。 所以码必须留在句首,只能在显示层剥。此前后端两处各写了一份剥前缀的逻辑,现在共用一份。 改写的文案: 规则闸六句。冻结原因原先写「该股处于 COMMAND_HALT, 禁止增持」,既印了枚举值,也没分开 三种来路——你下的命令、你手设的、系统刹车期间,对交易员是三件不同的事。昨夜信号原先写 「决策系统昨夜定性 SELL (形态恶化)」,SELL 是枚举值,而「形态恶化」是给三种信号硬安的 定性,回避未必是形态问题。 仓位约束六句,原先全是「加后总仓 82.3% > 上限 80%」这种电报体。大于号要人翻译,而且 没说清百分比是占什么。行业占比那条还漏了设计要点——两条都超才拦,不写会被当成误拦 来投诉。 资金四句改在源头。这个字段有三个下游都直接显示给人看,改一处三处一起干净。异常原文 只进日志。 计划取数两句会渲染成页面顶部整条红色横幅。原先网络抖一下就顶一条异常原文给基金经理 看。改成说清三件事:现在拿不到什么、多久之后重试有意义、还不行找谁。其中「过 1 分钟 再试」不能省——失败结果会被缓存一分钟,早于这个点重读拿到的还是同一条失败。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:26:57 +08:00
# 末尾那句"两条都超才拦"必须写出来:这是这道闸的设计要点,不说的话
# 会被当成误拦来投诉。
v.append(f"SECTOR_RATIO: 买了这一笔,{sector}会占到持仓市值的 {of_port:.1%}"
f"超过 {ratio_max:.0%} 的上限;同时占到总规模的 {of_scale:.1%}"
f"超过 {floor:.1%}。这两条都超了才拦")
2026-07-27 15:50:57 +08:00
return v
def risk_exposure(qty: int, price: float, stop_ref: float) -> float:
"""单笔风险敞口 (元) = 数量 × max(0, 买价 止损参考)。stop_ref 缺失返回 -1 表示无法计算。"""
if stop_ref is None or stop_ref <= 0:
return -1.0
return qty * max(0.0, price - stop_ref)
def risk_warnings(*, entry_exposure: float, portfolio_exposure: float, scale: float,
warn_entry: float = 0.01, warn_portfolio: float = 0.06) -> list:
"""风险披露告警 (不拦截, 只提示)。entry_exposure=-1 时提示无法计算。"""
w = []
if entry_exposure < 0:
w.append("RISK_UNKNOWN: 无止损参考位, 敞口无法计算")
elif scale > 0 and entry_exposure / scale > warn_entry:
w.append(f"RISK_ENTRY: 单笔敞口 {entry_exposure / scale:.2%} > 披露线 {warn_entry:.0%}")
if scale > 0 and portfolio_exposure > 0 and portfolio_exposure / scale > warn_portfolio:
w.append(f"RISK_PORTFOLIO: 组合敞口 {portfolio_exposure / scale:.2%} > 披露线 {warn_portfolio:.0%}")
return w