tradingSystem/app/core/planner.py

553 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
方案生成器 (纯逻辑, 无外部依赖, 可单测)
========================================
把一条任务命令展开成分股行动清单 (设计 POSITION_MGMT_DESIGN.md §3.2 / §5 / §6)。
只算数, 不落库、不下发 —— 调用方 (services/command_service.py) 负责写 pms_plan。
核心是「降仓凑额」四档优先级 (设计 §3.2):
① 撤销在途买入类指令 (先停止继续投入, 不计入释放额)
② 清弱票 —— 浮亏且安全垫为负、持续 ≥N 日 → 整票清仓
③ 收利润 —— 浮盈票卖出加仓批/补足批 (保留底仓)
④ 等比微减 —— 仍不足则各票按市值等比例微减 (此时才动底仓)
约定与口径:
* 金额单位元, 数量单位股; A股一手 = 100 股。
* 部分减持数量一律向下取整到一手; **整票清仓允许卖零股** (A股零股可一次性卖出),
所以 EXIT 用 total_qty 原值, 不做取整。
* 冻结 (frozen_reason != NONE) 只禁增持不禁减持 —— 降仓类方案照常纳入该票。
* 同一票在同一份方案中只出一条减持动作 (跨档累计用 _sold 台账防超卖)。
* 卖出数量受 T+1 可卖量限制的部分不在此处扣减: 方案给的是**目标数量**,
分日出手由择时执行器按 avail_qty 处理 (设计 §8)。方案层只保证不超过 total_qty。
"""
from __future__ import annotations
from app.core.sizer import LOT, lot_qty, split_batches, check_caps
from app.core.cushion import sell_allocation # noqa: F401 (供调用方做核销预览)
# 方案动作词表 (与 ddl_pms_v1.sql pms_plan.action 注释一致)
A_OPEN, A_FILL, A_ADD, A_DCA = "OPEN", "FILL", "ADD", "DCA"
A_TRIM, A_EXIT, A_HALT, A_T0 = "TRIM", "EXIT", "HALT", "T0_ROUND"
SIDE_BUY, SIDE_SELL, SIDE_NONE = "buy", "sell", "none"
# 优先级 (越小越先执行; 设计 §3.2 的四档次序)
P_HALT, P_WEAK, P_HARVEST, P_PRORATA = 10, 20, 30, 40
P_OPEN_BASE, P_OPEN_FILL, P_OPEN_ADD = 10, 20, 30
# ---------------------------------------------------------------- 小工具
def floor_lot(qty) -> int:
return int(max(0, int(qty)) // LOT) * LOT
def ceil_lot(qty) -> int:
q = max(0, int(qty))
return ((q + LOT - 1) // LOT) * LOT
def mv_of(p: dict) -> float:
"""持仓市值 = 数量 × 现价 (缺价按 0, 由调用方剔除或告警)。"""
return float(p.get("total_qty") or 0) * float(p.get("price") or 0)
def _item(ts_code, action, side, qty, amount, priority, reason, tier="", **kw):
d = {"ts_code": ts_code, "action": action, "side": side, "qty": int(qty),
"amount": round(float(amount), 2), "priority": priority, "reason": reason,
"tier": tier}
d.update(kw)
return d
def _sortable(p, key, reverse=False):
"""稳定排序键: 主键 + ts_code (保证同值时结果确定, 便于单测与复现)。"""
v = p.get(key)
v = 0.0 if v is None else float(v)
return (-v if reverse else v, p.get("ts_code") or "")
def _usable(positions, exclude_codes):
"""可纳入减持方案的持仓: 有数量、有现价、不在排除名单 (已有在途方案的票)。"""
ex = set(exclude_codes or ())
out = []
for p in positions or []:
if p.get("ts_code") in ex:
continue
if int(p.get("total_qty") or 0) <= 0 or float(p.get("price") or 0) <= 0:
continue
out.append(p)
return out
# ================================================================
# 一、降仓凑额 (设计 §3.2 全流程示例的第 1 步)
# ================================================================
def plan_reduce_exposure(*, release_amount: float, positions: list, pending_buys=None,
params=None, exclude_codes=()) -> dict:
"""降仓 X% 的方案生成。
release_amount 需释放金额 (元) = 规模 × pct
positions 持仓快照列表, 每项至少含:
ts_code / price / total_qty / base_qty / cushion_pct /
neg_cushion_days (安全垫连续为负天数)
pending_buys 在途买入指令 [{instruction_id, ts_code, qty, amount}], 全部撤销
params {"weak_neg_days":5}
返回 {"ok","target_amount","planned_amount","gap","items","notes"}
"""
params = params or {}
weak_days = int(params.get("weak_neg_days", 5))
target = float(release_amount or 0)
items, notes = [], []
# ---- ① 撤销在途买入 (不计入释放额, 但必须先做: 停止继续投入) ----
for b in (pending_buys or []):
items.append(_item(b.get("ts_code"), A_HALT, SIDE_NONE, b.get("qty") or 0,
b.get("amount") or 0, P_HALT,
"降仓命令: 撤销在途买入指令", tier="1_停新买",
cancel_instruction_id=b.get("instruction_id")))
if pending_buys:
notes.append(f"撤销在途买入指令 {len(pending_buys)} 条 (不计入释放额)")
if target <= 0:
return {"ok": False, "target_amount": 0.0, "planned_amount": 0.0, "gap": 0.0,
"items": items, "notes": notes + ["释放金额为 0, 无减持动作"]}
pool = _usable(positions, exclude_codes)
if exclude_codes:
notes.append(f"跳过已有在途方案的票: {sorted(set(exclude_codes))}")
sold = {} # ts_code -> 已计划卖出股数 (跨档防超卖)
acc = 0.0
def remain_qty(p):
return int(p.get("total_qty") or 0) - sold.get(p["ts_code"], 0)
# ---- ② 清弱票: 浮亏且安全垫为负、持续 ≥N 日 → 整票清仓 (最差的先清) ----
weak = [p for p in pool
if p.get("cushion_pct") is not None and float(p["cushion_pct"]) < 0
and int(p.get("neg_cushion_days") or 0) >= weak_days]
for p in sorted(weak, key=lambda x: _sortable(x, "cushion_pct")):
if acc >= target:
break
qty = remain_qty(p) # 清仓卖全量, 零股一并卖出 (不取整)
if qty <= 0:
continue
amt = qty * float(p["price"])
sold[p["ts_code"]] = sold.get(p["ts_code"], 0) + qty
acc += amt
items.append(_item(p["ts_code"], A_EXIT, SIDE_SELL, qty, amt, P_WEAK,
f"清弱票: 安全垫 {float(p['cushion_pct']):.1%} 连续为负 "
f"{int(p.get('neg_cushion_days') or 0)}", tier="2_清弱票"))
if acc > target and weak:
notes.append(f"清弱票为整票清仓, 释放额 {acc:,.0f} 元已超目标 {target:,.0f}"
f"(超出 {acc - target:,.0f} 元, 弱票不做拆卖)")
# ---- ③ 收利润: 浮盈票卖加仓批+补足批, 保留底仓 (垫子厚的先收) ----
if acc < target:
profit = [p for p in pool
if p.get("cushion_pct") is not None and float(p["cushion_pct"]) > 0
and remain_qty(p) > 0]
for p in sorted(profit, key=lambda x: _sortable(x, "cushion_pct", reverse=True)):
if acc >= target:
break
keep_base = int(p.get("base_qty") or 0)
sellable = floor_lot(min(remain_qty(p), max(0, int(p.get("total_qty") or 0) - keep_base)))
if sellable <= 0:
continue
need = target - acc
want = min(sellable, ceil_lot(need / float(p["price"])))
if want <= 0:
continue
amt = want * float(p["price"])
sold[p["ts_code"]] = sold.get(p["ts_code"], 0) + want
acc += amt
items.append(_item(p["ts_code"], A_TRIM, SIDE_SELL, want, amt, P_HARVEST,
f"收利润: 安全垫 {float(p['cushion_pct']):.1%}, "
f"卖出加仓/补足批 {want} 股, 保留底仓 {keep_base}",
tier="3_收利润"))
# ---- ④ 等比微减: 仍不足 → 各票按市值等比例微减 (此时可动底仓) ----
if acc < target:
gap = target - acc
cand = [p for p in pool if remain_qty(p) >= LOT]
total_mv = sum(remain_qty(p) * float(p["price"]) for p in cand)
if total_mv <= 0:
notes.append("等比微减: 无可减持仓")
else:
plan_qty = {}
for p in cand:
w = remain_qty(p) * float(p["price"]) / total_mv
q = floor_lot(min(remain_qty(p), lot_qty(gap * w, float(p["price"]))))
if q > 0:
plan_qty[p["ts_code"]] = q
# 取整造成的缺口: 按剩余市值从大到小逐手补齐
planned = sum(q * float(next(x for x in cand if x["ts_code"] == c)["price"])
for c, q in plan_qty.items())
order = sorted(cand, key=lambda x: (-(remain_qty(x) * float(x["price"])),
x["ts_code"]))
guard = 0
while planned < gap - 1e-6 and guard < 10000:
guard += 1
progressed = False
for p in order:
cur = plan_qty.get(p["ts_code"], 0)
if cur + LOT <= remain_qty(p):
plan_qty[p["ts_code"]] = cur + LOT
planned += LOT * float(p["price"])
progressed = True
if planned >= gap - 1e-6:
break
if not progressed:
break
for p in order:
q = plan_qty.get(p["ts_code"], 0)
if q <= 0:
continue
amt = q * float(p["price"])
sold[p["ts_code"]] = sold.get(p["ts_code"], 0) + q
acc += amt
items.append(_item(p["ts_code"], A_TRIM, SIDE_SELL, q, amt, P_PRORATA,
f"等比微减: 按市值权重摊派 {q}", tier="4_等比微减"))
gap = max(0.0, target - acc)
if gap > 0:
notes.append(f"可减持仓不足, 缺口 {gap:,.0f} 元 —— 命令将置部分完成并告警")
return {"ok": gap <= 0, "target_amount": round(target, 2), "planned_amount": round(acc, 2),
"gap": round(gap, 2), "items": items, "notes": notes}
# ================================================================
# 二、升仓 (设计 §3.1 B: 既有持仓补到目标 + 候选池新票建仓)
# ================================================================
def plan_increase_exposure(*, add_amount: float, positions: list, candidates=None,
ctx: dict = None, params=None) -> dict:
"""升仓 X%。既有持仓中「垫厚(SOLID)且未达目标」的票先补足, 再从候选池建新仓。
candidates: [{ts_code, price, score, sector}] —— 上游计划池 白名单, 由调用方备好并
已剔除黑名单/已持有/冻结票。每只都要过组合约束 (累计口径)。
ctx: check_caps 所需上下文 (见 sizer.check_caps), 规划过程中滚动更新。
"""
params = params or {}
ctx = dict(ctx or {})
target = float(add_amount or 0)
items, notes, rejects = [], [], []
acc = 0.0
scale = float(ctx.get("scale") or 0)
default_target = float(params.get("stock_target_default", 0.06))
batch_split = params.get("batch_split") or (0.5, 0.25, 0.25)
merge = bool(params.get("min_lot_merge", True))
if target <= 0 or scale <= 0:
return {"ok": False, "target_amount": target, "planned_amount": 0.0,
"gap": target, "items": [], "notes": ["升仓金额或总规模为 0"], "rejects": []}
# ---- ① 既有持仓补到目标 (只补垫厚票, 呼应设计"垫厚且决策系统看多") ----
for p in sorted(positions or [], key=lambda x: _sortable(x, "cushion_pct", reverse=True)):
if acc >= target:
break
if float(p.get("price") or 0) <= 0:
continue
if (p.get("cushion_state") or "") != "SOLID":
continue
if (p.get("frozen_reason") or "NONE") != "NONE":
continue
tgt_pct = float(p.get("target_pct") or default_target)
room = tgt_pct * scale - mv_of(p)
if room <= 0:
continue
want_amt = min(room, target - acc)
q = lot_qty(want_amt, float(p["price"]))
if q < LOT:
continue
amt = q * float(p["price"])
bad = check_all_caps(ts_code=p["ts_code"], add_amount=amt, ctx=_stock_ctx(ctx, p, False))
if bad:
rejects.append({"ts_code": p["ts_code"], "reasons": bad})
continue
items.append(_item(p["ts_code"], A_ADD, SIDE_BUY, q, amt, P_OPEN_BASE,
f"升仓: 垫厚({float(p.get('cushion_pct') or 0):.1%})补至目标 "
f"{tgt_pct:.1%}", tier="1_补既有"))
acc += amt
ctx = _ctx_after(ctx, amt, is_new_name=False, sector=p.get("sector"))
# ---- ② 候选池新票建仓 ----
for c in sorted(candidates or [], key=lambda x: _sortable(x, "score", reverse=True)):
if acc >= target:
break
price = float(c.get("price") or 0)
if price <= 0:
continue
want_amt = min(default_target * scale, target - acc)
sp = split_batches(want_amt, price, splits=batch_split, merge=merge)
if not sp["ok"]:
rejects.append({"ts_code": c.get("ts_code"), "reasons": [sp["reason"]]})
continue
base = sp["batches"][0]
bad = check_all_caps(ts_code=c.get("ts_code"), add_amount=want_amt,
ctx=_new_name_ctx(ctx, c))
if bad:
rejects.append({"ts_code": c.get("ts_code"), "reasons": bad})
continue
items.extend(_open_batch_items(c.get("ts_code"), sp, price,
reason_prefix="升仓建新仓"))
acc += want_amt
ctx = _ctx_after(ctx, want_amt, is_new_name=True, sector=c.get("sector"))
notes.append(f"{c.get('ts_code')}: 目标 {want_amt:,.0f} 元, 首批 {base['qty']}")
gap = max(0.0, target - acc)
if gap > 0:
notes.append(f"候选与补仓空间不足, 缺口 {gap:,.0f}")
return {"ok": gap <= 0, "target_amount": round(target, 2), "planned_amount": round(acc, 2),
"gap": round(gap, 2), "items": items, "notes": notes, "rejects": rejects}
# ================================================================
# 三、个股级方案
# ================================================================
def plan_open_target(*, ts_code: str, target_pct: float, price: float, ctx: dict,
params=None) -> dict:
"""建仓某股至目标% —— 50/25/25 分批 (含一手合并), 组合约束硬校验。
BASE 批立即可执行; FILL/ADD 批以 gated=True 落方案, 由动作引擎按条件解锁 (设计 §6)。
"""
params = params or {}
batch_split = params.get("batch_split") or (0.5, 0.25, 0.25)
merge = bool(params.get("min_lot_merge", True))
scale = float(ctx.get("scale") or 0)
stock_mv = float(ctx.get("stock_mv") or 0)
add_amount = max(0.0, float(target_pct) * scale - stock_mv)
if scale <= 0:
return {"ok": False, "items": [], "notes": [], "rejects": [
{"ts_code": ts_code, "reasons": ["SCALE_INVALID: 总规模未设置"]}]}
if add_amount <= 0:
return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "notes": [f"{ts_code} 现有仓位已达/超过目标 {target_pct:.1%}, 无需建仓"],
"rejects": []}
bad = check_all_caps(ts_code=ts_code, add_amount=add_amount, ctx=ctx)
if bad:
return {"ok": False, "items": [], "target_amount": round(add_amount, 2),
"planned_amount": 0.0, "gap": round(add_amount, 2), "notes": [],
"rejects": [{"ts_code": ts_code, "reasons": bad}]}
sp = split_batches(add_amount, float(price), splits=batch_split, merge=merge)
if not sp["ok"]:
return {"ok": False, "items": [], "target_amount": round(add_amount, 2),
"planned_amount": 0.0, "gap": round(add_amount, 2), "notes": [],
"rejects": [{"ts_code": ts_code, "reasons": [sp["reason"]]}]}
items = _open_batch_items(ts_code, sp, float(price), reason_prefix="建仓命令")
planned = sum(i["amount"] for i in items)
notes = []
if sp["scheme"] != tuple(batch_split):
notes.append(f"一手检查: 批次自动合并为 {sp['scheme']} (原 {tuple(batch_split)})")
return {"ok": True, "items": items, "target_amount": round(add_amount, 2),
"planned_amount": round(planned, 2), "gap": 0.0, "notes": notes, "rejects": []}
def plan_exit_stock(*, ts_code: str, position: dict, reason: str = "清仓命令") -> dict:
"""清仓某股 (整票, 零股一并卖出)。"""
qty = int((position or {}).get("total_qty") or 0)
price = float((position or {}).get("price") or 0)
if qty <= 0:
return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "notes": [f"{ts_code} 无持仓, 命令直接完成"], "rejects": []}
amt = qty * price
return {"ok": True, "target_amount": round(amt, 2), "planned_amount": round(amt, 2),
"gap": 0.0, "notes": [], "rejects": [],
"items": [_item(ts_code, A_EXIT, SIDE_SELL, qty, amt, P_WEAK, reason,
tier="个股清仓")]}
def plan_reduce_stock(*, ts_code: str, target_pct: float, position: dict, scale: float) -> dict:
"""减至 X%: 卖出超出目标仓位的部分 (向下取整到一手; 目标 0 等价清仓)。"""
qty_hold = int((position or {}).get("total_qty") or 0)
price = float((position or {}).get("price") or 0)
if qty_hold <= 0 or price <= 0:
return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "notes": [f"{ts_code} 无持仓或无现价"], "rejects": []}
if float(target_pct) <= 0:
return plan_exit_stock(ts_code=ts_code, position=position, reason="减至 0% (等价清仓)")
over = qty_hold * price - float(target_pct) * float(scale)
if over <= 0:
return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "notes": [f"{ts_code} 当前仓位未超过目标 {target_pct:.1%}"],
"rejects": []}
qty = min(qty_hold, floor_lot(lot_qty(over, price)))
if qty <= 0:
return {"ok": False, "items": [], "target_amount": round(over, 2), "planned_amount": 0.0,
"gap": round(over, 2), "rejects": [],
"notes": [f"{ts_code} 超出额 {over:,.0f} 元不足一手, 不减持"]}
amt = qty * price
return {"ok": True, "target_amount": round(over, 2), "planned_amount": round(amt, 2),
"gap": round(max(0.0, over - amt), 2), "notes": [], "rejects": [],
"items": [_item(ts_code, A_TRIM, SIDE_SELL, qty, amt, P_HARVEST,
f"减至 {target_pct:.1%}: 卖出超出部分 {qty}", tier="个股减仓")]}
def plan_liquidate_all(*, positions: list, pending_buys=None) -> dict:
"""一键清仓 (紧急): 撤在途买入 + 全部持仓清仓, 市值大的先卖。"""
items = []
for b in (pending_buys or []):
items.append(_item(b.get("ts_code"), A_HALT, SIDE_NONE, b.get("qty") or 0,
b.get("amount") or 0, P_HALT, "一键清仓: 撤销在途买入",
tier="1_停新买", cancel_instruction_id=b.get("instruction_id")))
acc = 0.0
for p in sorted(_usable(positions, ()), key=lambda x: (-mv_of(x), x["ts_code"])):
qty = int(p.get("total_qty") or 0)
amt = qty * float(p["price"])
acc += amt
items.append(_item(p["ts_code"], A_EXIT, SIDE_SELL, qty, amt, P_WEAK,
"一键清仓 (紧急, 不做择时优化)", tier="全部清仓"))
return {"ok": True, "target_amount": round(acc, 2), "planned_amount": round(acc, 2),
"gap": 0.0, "items": items, "rejects": [],
"notes": [f"全部 {len([i for i in items if i['action'] == A_EXIT])} 只持仓清仓"]}
def plan_sector_exit(*, sector: str, positions: list) -> dict:
"""清仓某行业 (行业名由 IndustryClassifier 提供, positions 需带 sector 字段)。"""
hit = [p for p in _usable(positions, ()) if (p.get("sector") or "") == sector]
if not hit:
return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "notes": [f"行业[{sector}]当前无持仓"], "rejects": []}
items, acc = [], 0.0
for p in sorted(hit, key=lambda x: (-mv_of(x), x["ts_code"])):
qty = int(p["total_qty"])
amt = qty * float(p["price"])
acc += amt
items.append(_item(p["ts_code"], A_EXIT, SIDE_SELL, qty, amt, P_WEAK,
f"清仓行业[{sector}]", tier="行业清仓"))
return {"ok": True, "target_amount": round(acc, 2), "planned_amount": round(acc, 2),
"gap": 0.0, "items": items, "notes": [], "rejects": []}
def plan_sector_cap(*, sector: str, cap: float, positions: list) -> dict:
"""限制某行业上限: 超出部分在该行业内按市值等比例减持 (不足一手的票跳过)。"""
pool = _usable(positions, ())
port_mv = sum(mv_of(p) for p in pool)
hit = [p for p in pool if (p.get("sector") or "") == sector]
sec_mv = sum(mv_of(p) for p in hit)
if port_mv <= 0 or sec_mv <= 0:
return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "notes": [f"行业[{sector}]无持仓, 仅写入上限参数"], "rejects": []}
over = sec_mv - float(cap) * port_mv
if over <= 0:
return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "notes": [f"行业[{sector}]占比 {sec_mv / port_mv:.1%} 未超上限 "
f"{float(cap):.0%}, 仅写入参数"], "rejects": []}
items, acc = [], 0.0
for p in sorted(hit, key=lambda x: (-mv_of(x), x["ts_code"])):
w = mv_of(p) / sec_mv
q = min(int(p["total_qty"]), floor_lot(lot_qty(over * w, float(p["price"]))))
if q <= 0:
continue
amt = q * float(p["price"])
acc += amt
items.append(_item(p["ts_code"], A_TRIM, SIDE_SELL, q, amt, P_PRORATA,
f"行业[{sector}]超限, 等比减持 {q}", tier="行业限额"))
return {"ok": acc > 0, "target_amount": round(over, 2), "planned_amount": round(acc, 2),
"gap": round(max(0.0, over - acc), 2), "items": items, "rejects": [],
"notes": [f"行业[{sector}]占比 {sec_mv / port_mv:.1%} → 需减 {over:,.0f}"]}
def plan_halt_buy(*, pending_buys: list) -> dict:
"""全局暂停买入: 撤销全部在途买入指令 (卖出与止损不受影响)。"""
items = [_item(b.get("ts_code"), A_HALT, SIDE_NONE, b.get("qty") or 0,
b.get("amount") or 0, P_HALT, "全局暂停买入: 撤销在途买入指令",
tier="停新买", cancel_instruction_id=b.get("instruction_id"))
for b in (pending_buys or [])]
return {"ok": True, "items": items, "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "rejects": [],
"notes": [f"撤销在途买入指令 {len(items)}"] if items else ["无在途买入指令"]}
def plan_halt_all(*, pending_instructions: list) -> dict:
"""全局暂停执行 (休假模式): 撤销全部在途指令, 仅保留对账与日报。"""
items = [_item(b.get("ts_code"), A_HALT, SIDE_NONE, b.get("qty") or 0,
b.get("amount") or 0, P_HALT,
f"休假模式: 撤销在途{'买入' if b.get('side') == SIDE_BUY else '卖出'}指令",
tier="停执行", cancel_instruction_id=b.get("instruction_id"))
for b in (pending_instructions or [])]
return {"ok": True, "items": items, "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "rejects": [],
"notes": [f"撤销在途指令 {len(items)}"] if items else ["无在途指令"]}
# ================================================================
# 四、约束校验 (规则闸与方案生成器共用同一份口径)
# ================================================================
def check_all_caps(*, ts_code: str, add_amount: float, ctx: dict) -> list:
"""组合约束 (sizer.check_caps) + 预留现金约束。返回未通过项列表, 空 = 全过。
预留现金 (设计 §3.1 A): 总规模中永不动用的部分, 与总仓上限双重约束 ——
加仓后组合市值不得超过 规模 × (1 预留比例)。
"""
v = list(check_caps(ts_code=ts_code, add_amount=add_amount, ctx=ctx))
reserve = float(ctx.get("cash_reserve") or 0)
scale = float(ctx.get("scale") or 0)
if reserve > 0 and scale > 0:
usable = scale * (1 - reserve)
after = float(ctx.get("portfolio_mv") or 0) + float(add_amount)
if after > usable + 1e-9:
v.append(f"CASH_RESERVE: 加后市值 {after:,.0f} > 可用额度 {usable:,.0f} "
f"(预留现金 {reserve:.0%})")
return v
def _stock_ctx(ctx: dict, p: dict, is_new: bool) -> dict:
d = dict(ctx)
sector = p.get("sector") if ctx.get("sector_source_ready", True) else None
d["stock_mv"] = mv_of(p)
d["is_new_name"] = is_new
d["sector"] = sector
d["sector_names"] = int((ctx.get("sector_names_map") or {}).get(sector, 0))
d["sector_mv"] = float((ctx.get("sector_mv_map") or {}).get(sector, 0.0))
return d
def _new_name_ctx(ctx: dict, c: dict) -> dict:
d = dict(ctx)
d["stock_mv"] = 0.0
d["is_new_name"] = True
d["sector"] = c.get("sector") if ctx.get("sector_source_ready", True) else None
d["sector_names"] = int((ctx.get("sector_names_map") or {}).get(c.get("sector"), 0))
d["sector_mv"] = float((ctx.get("sector_mv_map") or {}).get(c.get("sector"), 0.0))
return d
def _ctx_after(ctx: dict, add_amount: float, is_new_name: bool, sector=None) -> dict:
"""规划过程中滚动更新组合快照, 保证多笔累计口径下的上限校验正确。"""
d = dict(ctx)
d["portfolio_mv"] = float(ctx.get("portfolio_mv") or 0) + float(add_amount)
if is_new_name:
d["names_count"] = int(ctx.get("names_count") or 0) + 1
if sector:
nm = dict(ctx.get("sector_names_map") or {})
mm = dict(ctx.get("sector_mv_map") or {})
if is_new_name:
nm[sector] = int(nm.get(sector, 0)) + 1
mm[sector] = float(mm.get(sector, 0.0)) + float(add_amount)
d["sector_names_map"], d["sector_mv_map"] = nm, mm
return d
def _open_batch_items(ts_code, sp, price, reason_prefix="建仓"):
"""把 split_batches 的结果转成方案条目: BASE 立即执行, FILL/ADD 挂 gated 待引擎解锁。"""
names = [b["name"] for b in sp["batches"]]
prio = {"BASE": P_OPEN_BASE, "FILL": P_OPEN_FILL, "ADD": P_OPEN_ADD}
act = {"BASE": A_OPEN, "FILL": A_FILL, "ADD": A_ADD}
gate_reason = {
"BASE": "首批底仓, 规则闸通过即入择时队列",
"FILL": "回踩补足批: 建仓期内回踩支撑不破且浮亏 <3% 时解锁",
"ADD": "盈利加仓批: 安全垫 ≥3% 且创 5 日新高/站上压力位时解锁",
}
out = []
for i, b in enumerate(sp["batches"]):
nm = b["name"] if b["name"] in act else names[i]
out.append(_item(ts_code, act.get(nm, A_OPEN), SIDE_BUY, b["qty"], b["qty"] * price,
prio.get(nm, P_OPEN_BASE),
f"{reason_prefix}: {nm}{b['qty']} 股 —— {gate_reason.get(nm, '')}",
tier=f"批次_{nm}", gated=(nm != "BASE")))
return out