tradingSystem/app/services/portfolio.py

237 lines
12 KiB
Python
Raw 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 -*-
"""
组合快照 (账本 + 行情 + 行业 → 方案生成器/规则闸/页面的统一输入)
================================================================
一处组装, 三处复用: 命令方案生成、组合约束校验、管理页面「持仓与账本」。
安全垫连续为负天数 (清弱票判定所需) 的存放:
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,
}