157 lines
7.0 KiB
Python
157 lines
7.0 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.repo import pms_repo
|
||
|
|
from app.services import industry, market, param_store
|
||
|
|
|
||
|
|
logger = logging.getLogger("pms.portfolio")
|
||
|
|
|
||
|
|
NEG_STREAK_KEY = "PMS_CUSHION_NEG_STREAK"
|
||
|
|
|
||
|
|
|
||
|
|
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):
|
||
|
|
try:
|
||
|
|
pms_repo.set_param(NEG_STREAK_KEY, json.dumps(m, ensure_ascii=False), "system")
|
||
|
|
except Exception as e:
|
||
|
|
logger.warning("安全垫连负天数写入失败: %s", 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)
|
||
|
|
if not px or px <= 0:
|
||
|
|
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 avg_cost > 0 else None
|
||
|
|
out.append({
|
||
|
|
"ts_code": code, "status": r.get("status"), "frozen_reason": r.get("frozen_reason"),
|
||
|
|
"price": round(px, 3), "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)
|
||
|
|
totals = {
|
||
|
|
"scale": scale, "portfolio_mv": round(port_mv, 2),
|
||
|
|
"portfolio_pct": round(port_mv / scale, 4) if scale > 0 else None,
|
||
|
|
"names_count": len(held), "cash_est": round(scale - port_mv, 2) if scale > 0 else None,
|
||
|
|
"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,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
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"], "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,
|
||
|
|
}
|