# -*- 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, }