237 lines
12 KiB
Python
237 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
组合快照 (账本 + 行情 + 行业 → 方案生成器/规则闸/页面的统一输入)
|
||
================================================================
|
||
一处组装, 三处复用: 命令方案生成、组合约束校验、管理页面「持仓与账本」。
|
||
|
||
安全垫连续为负天数 (清弱票判定所需) 的存放:
|
||
DDL 未设该列, 故以单行运行参数 PMS_CUSHION_NEG_STREAK (JSON 映射) 承载,
|
||
由日终结算 (ledger_service.daily_settle) 维护 —— 不改表结构, 数据可查可重算。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
|
||
from app.core import cushion as cu
|
||
from app.core import ws_codec as wsc
|
||
from app.repo import pms_repo, qmt_repo
|
||
from app.services import industry, market, param_store
|
||
|
||
logger = logging.getLogger("pms.portfolio")
|
||
|
||
NEG_STREAK_KEY = "PMS_CUSHION_NEG_STREAK"
|
||
CASH_WS, CASH_EST = "ws", "estimate"
|
||
|
||
|
||
def cash_view() -> dict:
|
||
"""真实可用资金 (ws 资金快照 §5.7 kind=funds)。取不到则回落估算并**标明是估的**。
|
||
|
||
为什么必须有这一层
|
||
------------------
|
||
原来全系统只有一个 `cash_est = PMS_TOTAL_SCALE − 持仓市值` —— 那是**从参数算出来的虚数**,
|
||
与账户里真有多少钱毫无关系。`PMS_TOTAL_SCALE` 是用户命令参数 (「我打算投多少」),
|
||
`available_cash` 是账户事实 (「现在有多少钱」), 两者是不同的约束, 不该混:
|
||
* scale 管**仓位纪律** —— 单股不超几成、总仓不超几成、最多几只
|
||
* 可用资金管**买不买得起** —— 这一单的钱够不够
|
||
07-30 实测: scale=200 万, 而模拟账户 total_asset 只有 98.1 万、available_cash 97.1 万。
|
||
少了第二个约束, 规划器会按 200 万排出账户根本执行不了的方案, 规则闸一路放行, 直到 QMT
|
||
回 `INSUFFICIENT_CASH` 才被拒 —— 而 PMS 收到 reject 不自动重发, 择时下一跳又算又发又拒,
|
||
页面看着一切正常, 实际一单也下不去。
|
||
|
||
**`sell_return_today` 必须加进来**: 当日卖出回笼资金 T+0 可用 (协议 §5.7, A3 点名要的
|
||
字段)。不加会把「刚卖掉一只、拿回笼的钱买另一只」这条最常见的换仓路径判成资金不足。
|
||
"""
|
||
max_age = param_store.get_int("PMS_RECON_WS_SNAPSHOT_MAX_AGE_SEC", 900)
|
||
out = {"source": CASH_EST, "cash_avail": None, "total_asset": None,
|
||
"age_sec": None, "why": ""}
|
||
try:
|
||
snap = qmt_repo.latest_snapshot("funds")
|
||
except Exception as e:
|
||
out["why"] = f"读 ws 资金快照失败: {type(e).__name__}: {e}"
|
||
return out
|
||
if not snap:
|
||
out["why"] = "ws 从未回过 funds 快照 (对端未实现 query_funds, 或 pms-ws 没在跑)"
|
||
return out
|
||
age = snap.get("age_sec")
|
||
if age is not None and age > max_age:
|
||
out.update({"why": f"ws 资金快照已过期 ({age:.0f}s > {max_age}s)", "age_sec": age})
|
||
return out
|
||
f = wsc.parse_funds_snapshot(snap["payload"])
|
||
avail = f.get("available_cash")
|
||
if avail is None:
|
||
out["why"] = "ws 资金快照里认不出可用资金字段 (补进 ws_codec.parse_funds_snapshot)"
|
||
return out
|
||
out.update({"source": CASH_WS, "age_sec": age, "total_asset": f.get("total_asset"),
|
||
"cash_avail": round(float(avail) + float(f.get("sell_return_today") or 0), 2)})
|
||
return out
|
||
|
||
|
||
def neg_streak_map() -> dict:
|
||
try:
|
||
raw = pms_repo.get_param(NEG_STREAK_KEY)
|
||
return json.loads(raw) if raw else {}
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
def save_neg_streak(m: dict) -> dict:
|
||
"""写「安全垫连续为负天数」。**必须返回成败, 调用方必须接。**
|
||
|
||
这个计数是 `plan_reduce_exposure` 一档「清弱票」的唯一判据 (连负 N 日 → 优先清)。
|
||
原来这里把异常吞成 warning 就返回 None, 上层 `_settle_cushion` 照样报
|
||
{"updated": n, ...}、`daily_settle` 照样 ok=True —— 计数永远停在旧值, 降仓命令的
|
||
第一档静默失灵, 改成卖别的票, 而没有任何一处报错。(2026-07-31 静默失败专项)
|
||
"""
|
||
try:
|
||
pms_repo.set_param(NEG_STREAK_KEY, json.dumps(m, ensure_ascii=False), "system")
|
||
return {"ok": True}
|
||
except Exception as e:
|
||
logger.error("安全垫连负天数写入失败: %s —— 「清弱票」判据将停在旧值", e)
|
||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||
|
||
|
||
def positions_view(*, with_price: bool = True) -> dict:
|
||
"""账本持仓 + 现价/安全垫/行业/占比。取不到现价的票用摊薄成本兜底并列入 price_missing。"""
|
||
p = param_store.sizing_params()
|
||
rows = pms_repo.list_positions()
|
||
codes = [r["ts_code"] for r in rows]
|
||
prices = market.get_prices(codes) if (with_price and codes) else {}
|
||
sectors = industry.get_many(codes) if codes else {}
|
||
streak = neg_streak_map()
|
||
scale = float(p["scale"] or 0)
|
||
|
||
out, missing = [], []
|
||
for r in rows:
|
||
code = r["ts_code"]
|
||
avg_cost = float(r.get("avg_cost") or 0)
|
||
px = prices.get(code)
|
||
# 取不到现价时拿摊薄成本顶上, 是为了市值/占比还能算 (否则整张页面塌掉)。
|
||
# **但安全垫绝不能跟着算出来**: px := avg_cost 会让 cushion 恰好等于 0.0, 而 0.0
|
||
# 与"真的不赚不亏"完全同形。后果不是显示难看, 是**凭空产生卖单** —— 一只安全垫
|
||
# 峰值 8% 的停牌票会被判成"回吐至 0%(过半)"而触发 TRIM 保垫减仓, 而保垫减仓
|
||
# **不设确认门槛、直接落指令**; 反过来浮亏 −50% 的票补仓评估档一次都不会触发。
|
||
# 停牌票在行情库里永远没有当日分钟线, 所以这是常规路径不是罕见路径。
|
||
# 价格可以估, 安全垫必须留 None ——「拿不到 ≠ 是 0」。
|
||
price_ok = bool(px and px > 0)
|
||
if not price_ok:
|
||
px = avg_cost or 0.0
|
||
if int(r.get("total_qty") or 0) > 0:
|
||
missing.append(code)
|
||
qty = int(r.get("total_qty") or 0)
|
||
mv = qty * px
|
||
cp = (px / avg_cost - 1.0) if (price_ok and avg_cost > 0) else None
|
||
out.append({
|
||
"ts_code": code, "status": r.get("status"), "frozen_reason": r.get("frozen_reason"),
|
||
# price_ok=False → 这一行的 price 是拿摊薄成本顶的, 不是行情。动作引擎据此整只
|
||
# 跳过并留痕 (见 action_engine.scan) —— 不是靠 cushion_pct 恰好为 None 兜住。
|
||
"price": round(px, 3), "price_ok": price_ok,
|
||
"total_qty": qty, "avail_qty": int(r.get("avail_qty") or 0),
|
||
"base_qty": int(r.get("base_qty") or 0), "fill_qty": int(r.get("fill_qty") or 0),
|
||
"add_qty": int(r.get("add_qty") or 0), "dca_qty": int(r.get("dca_qty") or 0),
|
||
"t0_qty": int(r.get("t0_qty") or 0), "avg_cost": round(avg_cost, 3) or None,
|
||
"market_value": round(mv, 2),
|
||
"cushion_pct": round(cp, 4) if cp is not None else None,
|
||
"cushion_state": cu.cushion_state(cp, p["cushion_solid"]),
|
||
"cushion_peak": float(r.get("cushion_peak") or 0),
|
||
"neg_cushion_days": int(streak.get(code, 0)),
|
||
"pct_of_scale": round(mv / scale, 4) if scale > 0 else None,
|
||
"target_pct": float(r.get("target_pct") or 0) or None,
|
||
"stop_ref": float(r.get("stop_ref") or 0) or None,
|
||
"support_ref": float(r.get("support_ref") or 0) or None,
|
||
"pressure_ref": float(r.get("pressure_ref") or 0) or None,
|
||
"ref_source": r.get("ref_source"),
|
||
"t0_enabled": int(r.get("t0_enabled") or 0), "t0_ratio": r.get("t0_ratio"),
|
||
"realized_t_profit": float(r.get("realized_t_profit") or 0),
|
||
"sector": sectors.get(code),
|
||
})
|
||
|
||
held = [x for x in out if x["total_qty"] > 0]
|
||
port_mv = sum(x["market_value"] for x in held)
|
||
sector_names, sector_mv = {}, {}
|
||
for x in held:
|
||
s = x.get("sector")
|
||
if not s:
|
||
continue
|
||
sector_names[s] = sector_names.get(s, 0) + 1
|
||
sector_mv[s] = sector_mv.get(s, 0.0) + x["market_value"]
|
||
cost_sum = sum((x["avg_cost"] or 0) * x["total_qty"] for x in held)
|
||
cash = cash_view()
|
||
totals = {
|
||
"scale": scale, "portfolio_mv": round(port_mv, 2),
|
||
"portfolio_pct": round(port_mv / scale, 4) if scale > 0 else None,
|
||
# cash_est 是 scale−市值 的**估算**, 与账户里有多少钱无关;
|
||
# cash_avail 才是账户事实 (ws 资金快照)。两个都留着并标明来源, 免得后来者
|
||
# 又把估算值当真钱用 —— 见 cash_view 的说明。
|
||
"names_count": len(held), "cash_est": round(scale - port_mv, 2) if scale > 0 else None,
|
||
"cash_avail": cash["cash_avail"], "cash_source": cash["source"],
|
||
"cash_age_sec": cash["age_sec"], "cash_why": cash["why"],
|
||
"total_asset": cash["total_asset"],
|
||
"float_pnl": round(port_mv - cost_sum, 2) if cost_sum else 0.0,
|
||
"float_pnl_pct": round(port_mv / cost_sum - 1, 4) if cost_sum > 0 else None,
|
||
"sector_names": sector_names, "sector_mv": sector_mv,
|
||
"solid_names": len([x for x in held if x["cushion_state"] == "SOLID"]),
|
||
"neg_names": len([x for x in held if (x["cushion_pct"] or 0) < 0]),
|
||
}
|
||
return {"positions": out, "held": held, "totals": totals, "params": p,
|
||
"price_missing": missing, "sector_ready": industry.ready()}
|
||
|
||
|
||
def caps_ctx(view: dict, *, ts_code=None, is_new_name=False, sector=None) -> dict:
|
||
"""组装 check_all_caps / planner 所需的组合上下文 (加仓前快照)。"""
|
||
p, t = view["params"], view["totals"]
|
||
stock_mv = 0.0
|
||
if ts_code:
|
||
for x in view["positions"]:
|
||
if x["ts_code"] == ts_code:
|
||
stock_mv = x["market_value"]
|
||
sector = sector or x.get("sector")
|
||
is_new_name = x["total_qty"] <= 0
|
||
break
|
||
else:
|
||
is_new_name = True
|
||
ready = view.get("sector_ready", False)
|
||
return {
|
||
"scale": p["scale"], "portfolio_cap": p["portfolio_cap"], "stock_cap": p["stock_cap"],
|
||
"max_names": p["max_names"], "portfolio_mv": t["portfolio_mv"],
|
||
"names_count": t["names_count"], "stock_mv": stock_mv, "is_new_name": is_new_name,
|
||
"sector": sector if ready else None,
|
||
"sector_names": int(t["sector_names"].get(sector, 0)) if ready and sector else 0,
|
||
"sector_mv": float(t["sector_mv"].get(sector, 0.0)) if ready and sector else 0.0,
|
||
"sector_names_map": t["sector_names"] if ready else {},
|
||
"sector_mv_map": t["sector_mv"] if ready else {},
|
||
"sector_max_names": p["sector_max_names"], "sector_max_ratio": p["sector_max_ratio"],
|
||
"cash_reserve": p["cash_reserve"], "sector_source_ready": ready,
|
||
# 真实可用资金随 caps 一起进规则闸 (executor 已经在传 caps, 不必再改它的取数)。
|
||
# cash_source 一定要跟着传: 规则闸靠它区分「校验过了」和「拿不到数据只能降级」。
|
||
"cash_avail": t.get("cash_avail"), "cash_source": t.get("cash_source", "estimate"),
|
||
}
|
||
|
||
|
||
def overview() -> dict:
|
||
"""页面顶部仪表 (总览)。任何一环取不到都不该让页面开不了 —— 逐项 try。"""
|
||
try:
|
||
v = positions_view()
|
||
except Exception as e:
|
||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||
t, p = v["totals"], v["params"]
|
||
brake_until = param_store.get_int("PMS_BRAKE_UNTIL", 0)
|
||
return {
|
||
"ok": True,
|
||
"scale": t["scale"], "portfolio_mv": t["portfolio_mv"], "portfolio_pct": t["portfolio_pct"],
|
||
"portfolio_cap": p["portfolio_cap"], "names_count": t["names_count"],
|
||
"max_names": p["max_names"], "cash_est": t["cash_est"],
|
||
# 真钱与来源一起给页面 —— 只显示 cash_est 的话, 那个数在 scale 与账户不一致时
|
||
# 会让人以为钱多得是 (07-30: cash_est 199 万 vs 真实可用 97 万)
|
||
"cash_avail": t["cash_avail"], "cash_source": t["cash_source"],
|
||
"total_asset": t["total_asset"], "cash_why": t["cash_why"],
|
||
"float_pnl": t["float_pnl"],
|
||
"float_pnl_pct": t["float_pnl_pct"], "solid_names": t["solid_names"],
|
||
"neg_names": t["neg_names"], "autonomy": p["autonomy"],
|
||
"buy_halt": p["buy_halt"], "exec_halt": p["exec_halt"],
|
||
"brake_until": brake_until, "sector_ready": v["sector_ready"],
|
||
"price_missing": v["price_missing"],
|
||
"cap_room": round(p["portfolio_cap"] * t["scale"] - t["portfolio_mv"], 2)
|
||
if t["scale"] else None,
|
||
}
|