tradingSystem/scripts/report_strategy_score.py

259 lines
12 KiB
Python
Raw Normal View History

2026-08-25 13:57:16 +08:00
# -*- coding: utf-8 -*-
"""
策略判分读数 · 只读统计 (STRATEGY_AUTO_ATTACH_PLAN.md 判分闭环, 拍板⑤)
======================================================================
只读, 不写任何表, 随时可跑回答一个问题: **自动挂载的策略到底有没有挣到钱**
三张对照 + 一张盘点:
. 样本盘点 自动策略共几条 (在跑/撤下/接力), 台账留痕几条 先看样本够不够
. 网格差价 每条自动网格: 买了多少/卖了多少/已实现差价/在手浮动
. 止盈判分 每条自动止盈: 触发过没有; 触发的 = 卖点之后又跌了多少 (保住的钱,
负数=卖飞); 没触发的 = 高水位与武装状态
. /未挂对照 窗口内 ATTACH 成功的票 vs 想挂但被**每日名额**挡下的票,
各自从决定日到现在的涨跌 名额挡下的是天然对照组
(资格全同, 只差没轮上); 排除项挡下的 (冻结/买不起/黑名单)
结构上就不同, 不进对照
运行 (桥机 factorevaluation):
docker compose run --rm pms-web python scripts/report_strategy_score.py [--days 30]
读数纪律:
- 差价用**策略内均价配对** (卖出收益 = 卖出额 卖出股数×买入均价), 不做逐笔 FIFO
月度读数要的是方向和量级, 不是会计账; 逐笔账在 pms_lot , 要精算去查它
- 现价来自分钟线缓存 (market.get_prices), 收盘后或缓存过期时取不到 相应栏目
明说无现价, 绝不拿旧价冒充
- 样本少于三条的段落只列数不下结论 判分要等数据, 不等数据的判分是编故事
"""
import argparse
import json
import os
import sys
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.db.session import fetch_all # noqa: E402
from app.repo import pms_repo # noqa: E402
from app.services.strategy_advisor import ( # noqa: E402
NOTE_AUTO, NOTE_HANDOFF, MARK_HANDOFF_OUT)
MIN_SAMPLE = 3 # 少于这个数只报数不下结论
def _f(v, d=0.0):
try:
return float(v)
except (TypeError, ValueError):
return d
def _pct(a, b):
"""b 相对 a 的涨跌; 任一边取不到返回 None。"""
a, b = _f(a), _f(b)
return (b / a - 1.0) if a > 0 and b > 0 else None
def _fmt_pct(x):
return f"{x:+.2%}" if x is not None else ""
def auto_strategies():
"""全部自动策略 (含已归档已撤销), 按 note 前缀识别。"""
rows = pms_repo.list_strategies(limit=1000, include_archived=True)
return [r for r in rows if str(r.get("note") or "").startswith("自动挂载")]
def fills_of(strategy_id):
"""该策略名下有成交的指令 (exec_qty>0, 不论终态) → 买卖两侧合计。单表。"""
rows = fetch_all(
"SELECT side, exec_qty, exec_avg_price FROM pms_instruction "
"WHERE origin_type = 'strategy' AND origin_id = :sid AND exec_qty > 0",
{"sid": strategy_id})
agg = {"buy": {"qty": 0, "amt": 0.0, "n": 0}, "sell": {"qty": 0, "amt": 0.0, "n": 0}}
for r in rows:
side = str(r.get("side") or "").lower()
if side not in agg:
continue
q, px = int(r.get("exec_qty") or 0), _f(r.get("exec_avg_price"))
agg[side]["qty"] += q
agg[side]["amt"] += q * px
agg[side]["n"] += 1
return agg
def ledger_window(days):
"""窗口内 advisor 留的 ATTACH 痕 (PASS=挂了 / NOTE+名额已满=对照)。单表。"""
since = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
rows = fetch_all(
"SELECT ts_code, decided_at, verdict, price_at, reason, ref_id "
"FROM pms_action_ledger WHERE action = 'ATTACH' AND decided_at >= :since",
{"since": since})
attached, control = [], []
for r in rows:
if str(r.get("verdict")) == "PASS":
attached.append(r)
elif "名额已满" in str(r.get("reason") or ""):
control.append(r)
return attached, control
def current_prices(codes):
try:
from app.services import market
return market.get_prices(sorted(set(c for c in codes if c)))
except Exception as e: # noqa: BLE001 —— redis 不在 (本地跑) 也要能出报告
print(f" (现价不可用: {type(e).__name__}: {e} —— 浮动与保住栏按无现价处理)")
return {}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--days", type=int, default=30, help="对照窗口 (自然日, 默认 30)")
args = ap.parse_args()
print("=" * 66)
print("策略判分读数 %s (只读; 窗口 %d 天)"
% (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), args.days))
print("=" * 66)
try:
strats = auto_strategies()
except Exception as e: # noqa: BLE001 —— 连不上库也要说人话, 不甩栈
print(f"\n✗ 策略表读取失败: {type(e).__name__}: {e}\n"
f" (本脚本要在桥机容器里跑: docker compose run --rm pms-web "
f"python scripts/report_strategy_score.py)")
sys.exit(1)
grids = [s for s in strats if str(s.get("type")).upper() == "GRID"]
trails = [s for s in strats if str(s.get("type")).upper() == "TRAIL"]
handoff_in = [s for s in strats if str(s.get("note") or "").startswith(NOTE_HANDOFF)]
handoff_out = [s for s in strats if MARK_HANDOFF_OUT in str(s.get("note") or "")]
codes = {s.get("ts_code") for s in strats}
# ---- 零、样本盘点 ----
print("\n【零】样本盘点")
if not strats:
print(" 还没有任何自动挂载的策略 —— 判分从第一条挂出才开始, 本报告先当空跑冒烟。")
else:
by_status = {}
for s in strats:
by_status[s.get("status")] = by_status.get(s.get("status"), 0) + 1
print(f" 自动策略共 {len(strats)} 条 (网格 {len(grids)} / 止盈 {len(trails)}), "
f"状态: " + ", ".join(f"{k} {v}" for k, v in sorted(by_status.items())))
print(f" 接力: 换挂出的止盈 {len(handoff_in)} 条, 被接力撤下的网格 {len(handoff_out)}")
px = current_prices(codes) if strats else {}
# ---- 一、网格差价 ----
print("\n【一】网格差价 (含已撤下的; 差价=卖出额−卖出股数×买入均价, 均价配对口径)")
if not grids:
print(" 无样本。")
tot_real, tot_float, n_traded = 0.0, 0.0, 0
for s in grids:
agg = fills_of(s["strategy_id"])
b, sl = agg["buy"], agg["sell"]
code, sid = s.get("ts_code"), s["strategy_id"]
if b["qty"] == 0 and sl["qty"] == 0:
print(f" {code} {sid} [{s.get('status')}]: 还没成交过一笔")
continue
n_traded += 1
buy_avg = b["amt"] / b["qty"] if b["qty"] else 0.0
2026-08-28 13:07:27 +08:00
if sl["qty"] and not b["qty"]:
# 只有卖出没有买入 (买入腿痕迹缺失/被对账冲销): 按零成本算会把整笔卖出额
# 报成利润, 判分虚高误导阈值决策 (2026-08-28 修) —— 这条不算差价, 单独点名。
print(f" {code} {sid} [{s.get('status')}]: 卖 {sl['qty']} 股/{sl['amt']:,.0f} 元, "
f"但**查无买入腿成交** —— 差价无法计算, 不计入合计, 请核对该策略的成交归属")
continue
sell_q = min(sl["qty"], b["qty"])
if sl["qty"] > b["qty"]:
print(f" {code} {sid}: 卖出 {sl['qty']} 股 > 买入 {b['qty']} 股, "
f"超出部分 {sl['qty'] - b['qty']} 股不属本策略买入, 差价只按配对部分算")
realized = (sl["amt"] * (sell_q / sl["qty"]) - sell_q * buy_avg) if sl["qty"] else 0.0
2026-08-25 13:57:16 +08:00
net_qty = b["qty"] - sl["qty"]
cur = _f(px.get(code))
floating = net_qty * (cur - buy_avg) if (net_qty > 0 and cur > 0 and buy_avg > 0) else None
tot_real += realized
if floating is not None:
tot_float += floating
print(f" {code} {sid} [{s.get('status')}]: "
f"{b['qty']} 股/{b['amt']:,.0f} 元 (均 {buy_avg:.3f}), "
f"{sl['qty']} 股/{sl['amt']:,.0f} 元, 已实现 {realized:+,.0f} 元, "
+ (f"在手 {net_qty} 股浮动 {floating:+,.0f}" if floating is not None
else f"在手 {net_qty} 股 (无现价, 浮动不算)"))
if grids:
print(f" 合计: 有成交的 {n_traded}/{len(grids)} 条, "
f"已实现 {tot_real:+,.0f} 元, 可算浮动 {tot_float:+,.0f}"
+ ("" if n_traded >= MIN_SAMPLE else f" (样本不足 {MIN_SAMPLE} 条, 只报数不下结论)"))
# ---- 二、止盈判分 ----
print("\n【二】止盈判分 (触发的看「卖点之后又跌了多少」= 保住的钱, 负数=卖飞)")
if not trails:
print(" 无样本。")
n_fired, tot_saved = 0, 0.0
for s in trails:
agg = fills_of(s["strategy_id"])
sl = agg["sell"]
code, sid = s.get("ts_code"), s["strategy_id"]
st = s.get("state") or {}
if sl["qty"] > 0:
n_fired += 1
sell_avg = sl["amt"] / sl["qty"]
cur = _f(px.get(code))
saved = (sell_avg - cur) * sl["qty"] if cur > 0 else None
if saved is not None:
tot_saved += saved
print(f" {code} {sid} [{s.get('status')}]: 触发过, "
f"{sl['qty']} 股 (均 {sell_avg:.3f}), "
+ (f"现价 {cur:.3f} → 保住 {saved:+,.0f}" if saved is not None
else "无现价, 保住的钱先不算"))
else:
hw, armed = _f(st.get("high_water")), bool(st.get("armed"))
print(f" {code} {sid} [{s.get('status')}]: 未触发, "
f"高水位 {hw or ''}, {'已武装' if armed else '未武装 (垫子还没到武装线)'}")
if trails and n_fired:
print(f" 合计: 触发 {n_fired}/{len(trails)} 条, 可算的保住 {tot_saved:+,.0f}"
+ ("" if n_fired >= MIN_SAMPLE else f" (样本不足 {MIN_SAMPLE} 条, 只报数不下结论)"))
# ---- 三、挂/未挂对照 ----
print(f"\n【三】挂/未挂对照 (近 {args.days} 天; 对照组=资格全同只是没轮上名额的票)")
try:
attached, control = ledger_window(args.days)
except Exception as e: # noqa: BLE001
print(f" ✗ 台账读取失败: {type(e).__name__}: {e}")
attached, control = [], []
px3 = current_prices([r.get("ts_code") for r in attached + control])
def _group(rows, label):
moves = []
for r in rows:
chg = _pct(r.get("price_at"), px3.get(r.get("ts_code")))
d = str(r.get("decided_at"))[:10]
print(f" {r.get('ts_code')} {d} 决定价 {_f(r.get('price_at')):.3f}"
f"{_fmt_pct(chg)}")
if chg is not None:
moves.append(chg)
if moves:
avg = sum(moves) / len(moves)
print(f" {label}: {len(rows)} 次决定, 可算 {len(moves)} 次, 平均 {_fmt_pct(avg)}")
else:
print(f" {label}: {len(rows)} 次决定, 无一次可算 (决定价或现价缺失)")
return moves
if not attached and not control:
print(" 窗口内没有 ATTACH 留痕 —— 系统还没挂过, 或窗口太短。")
else:
print(" 挂上的:")
m1 = _group(attached, "挂上的")
print(" 名额挡下的 (对照):")
m2 = _group(control, "对照组")
if len(m1) >= MIN_SAMPLE and len(m2) >= MIN_SAMPLE:
print(f" 对照差: 挂上的平均 {_fmt_pct(sum(m1) / len(m1))} vs "
f"没轮上的 {_fmt_pct(sum(m2) / len(m2))} —— 差值为正说明排序在挑对票")
else:
print(f" 两组样本 ({len(m1)}/{len(m2)}) 不足 {MIN_SAMPLE}, 本期不下结论。")
print("\n判分口径提醒: 本报告只陈述事实读数, 阈值调整 (热度 0.80 / 日上限 2) "
"等两周以上样本再议; 精算逐笔账查 pms_lot 与台账。")
if __name__ == "__main__":
main()