2026-08-27 13:26:24 +08:00
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
"""
|
|
|
|
|
|
入场体检: 决策系统的买入, 有多少是"买完就被风控清掉"的坏入场, 该不该在入场端加一道过滤 (只读)
|
|
|
|
|
|
==============================================================================
|
|
|
|
|
|
承接换手体检 backtest_churn.py 的结论: 决策系统的快速卖出这一批全是风控止损, 而且基本都砍对了
|
|
|
|
|
|
(卖完股价还在跌), 加"卖出端最小持有期护栏"只会更差。于是问题被顶到了另一头 —— 不是卖得太快,
|
|
|
|
|
|
是买得不对: 系统反复买那种一两天内就触发风控、只能亏着清掉的票。这个脚本把这批"坏入场"拎出来,
|
|
|
|
|
|
量三件事:
|
|
|
|
|
|
一, 它们买入时的价格情形有没有共性 (是不是买在冲高、买在已经下跌的票上);
|
|
|
|
|
|
二, 买入之后股价怎么走 (入场时点本身好不好, 跟全体入场比);
|
|
|
|
|
|
三, 若在入场端加一道过滤, 是净赚 (挡掉的多是亏的), 还是误伤好票 (挡掉的里不少是赚的)。
|
|
|
|
|
|
|
|
|
|
|
|
一句话方法: 同样只读评审账本 pms_action_ledger, 把"一次买紧跟一次卖"配成来回。来回里"卖出是
|
|
|
|
|
|
决策系统驱动、且持有不超过 N 个交易日"的那批, 就是坏入场。买入时点的价格情形用 gp_day_data 的
|
|
|
|
|
|
当日 OHLC 重建 (它有 open/high/low/close/volume, 见 DATA_MODEL §1.1)。
|
|
|
|
|
|
|
|
|
|
|
|
五段:
|
|
|
|
|
|
① 样本盘点 全部买入多少、配成来回多少、其中"买完就被风控快速清掉"的坏入场多少。
|
|
|
|
|
|
② 坏入场画像 坏入场 vs 全体入场, 比买入日的三个价格特征: 追高度、当日涨跌、前5日动量。
|
|
|
|
|
|
③ 入场后前向 每笔买入之后 T+1/5/20 相对买价的涨跌, 坏入场 vs 全体, 看入场时点本身好不好。
|
|
|
|
|
|
④ 成本账 这批坏入场的来回一共交了多少纯摩擦。
|
|
|
|
|
|
⑤ 反事实过滤 扫几条入场过滤规则(追高/逆势/放量阴线): 各挡掉多少来回、其中坏入场多少(召回)、
|
|
|
|
|
|
挡掉的平均实现净收益、误伤率、以及组合净效果。挡掉的多是亏的且净效果为正=该过滤
|
|
|
|
|
|
有用; 误伤率高(挡掉的里不少是赚的)=别上。
|
|
|
|
|
|
|
|
|
|
|
|
成本口径与前向价源同 backtest_churn.py (往返≈0.262%; gp_day_data @ 18.199 走 app 的 index 源)。
|
|
|
|
|
|
读数纪律: 样本少于 MIN_SAMPLE 的段落只列数、不下结论 —— 不等数据的判分是编故事。
|
|
|
|
|
|
|
|
|
|
|
|
一处口径说明: 判"坏入场"用的是卖出的 dominant_signal 与来源, 与换手体检同口径; 只认"决策系统
|
|
|
|
|
|
驱动的卖出"配成的来回。到价止盈那类计划位平仓不在决策系统卖出信号里(它走执行层的目标价),
|
|
|
|
|
|
所以不进这里的坏入场, 也不该进 —— 这段量的就是"被风控快速清掉"的那类入场。
|
|
|
|
|
|
|
|
|
|
|
|
运行(桥机 factorevaluation, 项目根目录):
|
|
|
|
|
|
docker compose run --rm pms-web python scripts/backtest_entry.py --days 120
|
|
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
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, DBUnavailable # noqa: E402 只读单表, 过单表守卫
|
2026-08-28 13:07:27 +08:00
|
|
|
|
from app.core import tradedays as _td # noqa: E402 真交易日计数
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def tdays_between(d0, d1) -> int:
|
|
|
|
|
|
"""(d0, d1] 内的交易日数 (同日买卖 = 0) —— 与 backtest_churn 同口径 (2026-08-28)。"""
|
|
|
|
|
|
n, cur, guard = 0, d0, 0
|
|
|
|
|
|
while cur < d1 and guard < 400:
|
|
|
|
|
|
cur += timedelta(days=1)
|
|
|
|
|
|
guard += 1
|
|
|
|
|
|
if _td.is_trade_day(cur):
|
|
|
|
|
|
n += 1
|
|
|
|
|
|
return n
|
2026-08-27 13:26:24 +08:00
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ 可调常量
|
|
|
|
|
|
MIN_SAMPLE = 5 # 少于这个数只报数不下结论
|
|
|
|
|
|
|
|
|
|
|
|
COMMISSION_RATE = 0.00025 # 佣金费率(每边); 万2.5
|
|
|
|
|
|
COMMISSION_MIN = 5.0 # 佣金每笔最低(元)
|
|
|
|
|
|
STAMP_RATE = 0.0005 # 印花税(仅卖出); 千0.5
|
|
|
|
|
|
TRANSFER_RATE = 0.00001 # 过户费(双边); 十万1
|
|
|
|
|
|
SLIPPAGE_BPS = 8.0 # 滑点(每边, 基点); 8bp = 0.08%
|
|
|
|
|
|
|
|
|
|
|
|
# 账本 action 归类 (与 backtest_churn.py 同口径)
|
|
|
|
|
|
BUY_ACTIONS = {"OPEN", "FILL", "ADD", "DCA"}
|
|
|
|
|
|
SELL_ACTIONS = {"EXIT", "TRIM"}
|
|
|
|
|
|
SIGNAL_SOURCES = {"intraday", "risk_sell"}
|
|
|
|
|
|
SIGNAL_WORDS = ("决策系统", "风控", "SELL", "转弱", "派发")
|
|
|
|
|
|
TAKE_PROFIT_DOM = "take_profit" # 卖出 dominant_signal 是这个 = 止盈离场, 其余 = 风控止损
|
|
|
|
|
|
|
|
|
|
|
|
# "坏入场"判据: 买入配成的来回, 卖出是决策系统驱动、且持有不超过这么多交易日(自然日近似)
|
|
|
|
|
|
QUICK_DAYS = 10
|
|
|
|
|
|
FWD_HORIZONS = (1, 5, 20) # 入场后前向看这几个交易日
|
|
|
|
|
|
|
2026-08-28 13:07:27 +08:00
|
|
|
|
# 入场过滤规则的阈值 (⑤ 反事实扫的候选闸门)。口径 (2026-08-28 理清):
|
|
|
|
|
|
# 逆势买 / 放量阴线 只用买入**前一交易日**及更早的数据 —— 实盘上午就拿得到, 无前视;
|
|
|
|
|
|
# 追高 用买入日全天高低 —— 收盘才知道, 属事后画像口径, 它的反事实读数是乐观上限。
|
2026-08-27 13:26:24 +08:00
|
|
|
|
CHASE_TOP_FRAC = 0.70 # 追高: 买价落在当日振幅的顶部这个比例以上 (1.0=买在最高)
|
|
|
|
|
|
DOWNTREND_5D = -0.03 # 逆势: 买入日相对前5个交易日收盘已跌超这个幅度
|
|
|
|
|
|
REDVOL_VOL_MULT = 1.5 # 放量阴线: 买入日是阴线且量能≥前5日均量的这个倍数
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ 小工具
|
|
|
|
|
|
def _f(v, d=0.0):
|
|
|
|
|
|
try:
|
|
|
|
|
|
return float(v)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return d
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_json(s):
|
|
|
|
|
|
if not s:
|
|
|
|
|
|
return {}
|
|
|
|
|
|
try:
|
|
|
|
|
|
return json.loads(s) if isinstance(s, str) else dict(s)
|
|
|
|
|
|
except (json.JSONDecodeError, TypeError, ValueError):
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _pct(entry, exit_):
|
|
|
|
|
|
e, x = _f(entry), _f(exit_)
|
|
|
|
|
|
return (x / e - 1.0) if e > 0 and x > 0 else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fmt_pct(x):
|
|
|
|
|
|
return f"{x:+.2%}" if x is not None else "—"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _avg(xs):
|
|
|
|
|
|
xs = [x for x in xs if x is not None]
|
|
|
|
|
|
return (sum(xs) / len(xs)) if xs else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _rt_cost_rate():
|
|
|
|
|
|
return COMMISSION_RATE * 2 + STAMP_RATE + TRANSFER_RATE * 2 + SLIPPAGE_BPS / 10000.0 * 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ 读账本 (单表, 过守卫)
|
|
|
|
|
|
def read_ledger(since_dt: datetime) -> list:
|
|
|
|
|
|
"""按时间读评审账本。只读一张表, 满足 153 代理的严格单表访问。"""
|
|
|
|
|
|
rows = fetch_all(
|
|
|
|
|
|
"SELECT id, ts_code, decided_at, action, arbiter, verdict, price_at, "
|
|
|
|
|
|
"hard_numbers_json, reason, ref_id "
|
|
|
|
|
|
"FROM pms_action_ledger WHERE decided_at >= :since "
|
|
|
|
|
|
"ORDER BY ts_code, decided_at, id",
|
|
|
|
|
|
{"since": since_dt.strftime("%Y-%m-%d %H:%M:%S")}, source="proxy")
|
|
|
|
|
|
out = []
|
|
|
|
|
|
for r in rows:
|
|
|
|
|
|
r = dict(r)
|
|
|
|
|
|
r["hn"] = _load_json(r.get("hard_numbers_json"))
|
|
|
|
|
|
out.append(r)
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def classify(row: dict) -> dict:
|
|
|
|
|
|
"""给账本一行贴标签: side(buy/sell/other)、signal_driven(是否决策系统驱动的卖出)、
|
|
|
|
|
|
以及 cohort(止盈动量 / 风控止损, 按 hard_numbers.dominant_signal 分, 只对决策系统卖出有值)。"""
|
|
|
|
|
|
act = str(row.get("action") or "").upper()
|
|
|
|
|
|
verdict = str(row.get("verdict") or "").upper()
|
|
|
|
|
|
acted = verdict == "PASS" # 只认真正放行落地的决策
|
|
|
|
|
|
if act in BUY_ACTIONS and acted:
|
|
|
|
|
|
side = "buy"
|
|
|
|
|
|
elif act in SELL_ACTIONS and acted:
|
|
|
|
|
|
side = "sell"
|
|
|
|
|
|
else:
|
|
|
|
|
|
side = "other"
|
|
|
|
|
|
hn = row.get("hn") or {}
|
|
|
|
|
|
src = str(hn.get("source") or "").lower()
|
|
|
|
|
|
reason = str(row.get("reason") or "")
|
|
|
|
|
|
signal_driven = (side == "sell") and (
|
|
|
|
|
|
src in SIGNAL_SOURCES or any(w in reason for w in SIGNAL_WORDS))
|
|
|
|
|
|
dom = str(hn.get("dominant_signal") or "").strip().lower()
|
|
|
|
|
|
cohort = None
|
|
|
|
|
|
if signal_driven:
|
|
|
|
|
|
cohort = "止盈动量" if dom == TAKE_PROFIT_DOM else "风控止损"
|
|
|
|
|
|
return {"side": side, "signal_driven": signal_driven,
|
|
|
|
|
|
"dominant_signal": dom, "cohort": cohort}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ 历史日线源 (gp_day_data @ 18.199)
|
|
|
|
|
|
_BAR_CACHE: dict = {}
|
|
|
|
|
|
_ANALYSIS_START = None # main 里设; 让按整个分析区间取足够宽的每股窗口
|
|
|
|
|
|
_ANALYSIS_END = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _to_prefix(ts_code: str) -> str:
|
|
|
|
|
|
"""点式 600000.SH → 前缀式 SH600000 (gp_day_data.symbol 用前缀式, 见 DATA_MODEL 约定)。"""
|
|
|
|
|
|
s = str(ts_code or "").strip().upper()
|
|
|
|
|
|
if "." in s:
|
|
|
|
|
|
num, ex = s.split(".", 1)
|
|
|
|
|
|
return ex + num
|
|
|
|
|
|
return s
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fetch_daily_bars(ts_code: str, start_dt: datetime, end_dt: datetime) -> dict:
|
|
|
|
|
|
"""返回 {date: {open,high,low,close,vol,pre_close,pct}}, 只含有行情的交易日。
|
|
|
|
|
|
|
|
|
|
|
|
源: gp_day_data (db_gp_cj @ 192.168.18.199, 走 app 的 index 数据源)。按 DATA_MODEL §1.1:
|
|
|
|
|
|
symbol 是**前缀式**(这里转一下); open/high/low/close 是 **VARCHAR**(读出转 float);
|
|
|
|
|
|
percent、pre_close 是 DECIMAL。用原始价(非前复权), 与账本 price_at 同口径。
|
|
|
|
|
|
库不可达或该股无行情返回 {}, ②③⑤ 相关段落自动跳过、不报错。
|
|
|
|
|
|
"""
|
|
|
|
|
|
sym = _to_prefix(ts_code)
|
|
|
|
|
|
try:
|
|
|
|
|
|
rows = fetch_all(
|
|
|
|
|
|
"SELECT `timestamp` AS d, `open` AS o, `high` AS h, `low` AS l, `close` AS c, "
|
|
|
|
|
|
"`volume` AS v, `pre_close` AS pc, `percent` AS pct FROM gp_day_data "
|
|
|
|
|
|
"WHERE symbol = :s AND `timestamp` >= :a AND `timestamp` <= :b "
|
|
|
|
|
|
"ORDER BY `timestamp`",
|
|
|
|
|
|
{"s": sym, "a": start_dt.strftime("%Y-%m-%d 00:00:00"),
|
|
|
|
|
|
"b": end_dt.strftime("%Y-%m-%d 23:59:59")}, source="index")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return {}
|
|
|
|
|
|
out = {}
|
|
|
|
|
|
for r in rows:
|
|
|
|
|
|
d = r.get("d")
|
|
|
|
|
|
if d is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
dd = d.date() if isinstance(d, datetime) else datetime.fromisoformat(str(d)).date()
|
|
|
|
|
|
close = _f(r.get("c"))
|
|
|
|
|
|
if close <= 0:
|
|
|
|
|
|
continue
|
|
|
|
|
|
out[dd] = {"open": _f(r.get("o")), "high": _f(r.get("h")), "low": _f(r.get("l")),
|
|
|
|
|
|
"close": close, "vol": _f(r.get("v")),
|
|
|
|
|
|
"pre_close": _f(r.get("pc")), "pct": _f(r.get("pct"))}
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _bars(ts_code: str, anchor_dt: datetime) -> dict:
|
|
|
|
|
|
"""按股缓存**整个分析区间**的日线(带前后余量), 覆盖该股所有买卖点。"""
|
|
|
|
|
|
if ts_code not in _BAR_CACHE:
|
|
|
|
|
|
lo = (_ANALYSIS_START or anchor_dt) - timedelta(days=30) # 前多留些, 好算前5日动量
|
|
|
|
|
|
hi = (_ANALYSIS_END or anchor_dt) + timedelta(days=60)
|
|
|
|
|
|
_BAR_CACHE[ts_code] = fetch_daily_bars(ts_code, lo, hi)
|
|
|
|
|
|
return _BAR_CACHE[ts_code]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _closes_after(ts_code: str, dt: datetime) -> list:
|
|
|
|
|
|
"""某日之后的交易日收盘序列(升序), 供取前向 T+1/T+5/T+20。"""
|
|
|
|
|
|
bars = _bars(ts_code, dt)
|
|
|
|
|
|
if not bars:
|
|
|
|
|
|
return []
|
|
|
|
|
|
d0 = dt.date()
|
|
|
|
|
|
after = sorted((d, b["close"]) for d, b in bars.items() if d > d0)
|
|
|
|
|
|
return [c for _, c in after]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def forward_returns(ts_code: str, dt: datetime, base_price: float) -> dict:
|
|
|
|
|
|
"""dt 之后 T+h 相对 base_price 的涨跌。"""
|
|
|
|
|
|
seq = _closes_after(ts_code, dt)
|
|
|
|
|
|
return {h: (_pct(base_price, seq[h - 1]) if len(seq) >= h else None) for h in FWD_HORIZONS}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def entry_context(ts_code: str, buy_dt: datetime, buy_price: float) -> dict:
|
|
|
|
|
|
"""重建买入时点的价格情形。返回 {chase, day_chg, mom5, red_vol}, 缺数据的项为 None。
|
2026-08-28 13:07:27 +08:00
|
|
|
|
chase 追高度 = (买价-当日最低)/(当日最高-当日最低), 1.0=买在最高。**事后口径**
|
|
|
|
|
|
(全天高低要收盘才定), 只作画像与乐观上限, 不是实盘可复刻的闸门。
|
|
|
|
|
|
day_chg 买入日全天涨跌 —— 同为事后口径。
|
|
|
|
|
|
mom5 前一交易日收盘 / 再前5个交易日收盘 - 1 —— **盘中可得** (2026-08-28 改),
|
|
|
|
|
|
负=买在已经下跌的票上(逆势)。
|
|
|
|
|
|
red_vol 前一交易日是阴线且量能≥更早5日均量×倍数 —— **盘中可得** (2026-08-28 改)。
|
2026-08-27 13:26:24 +08:00
|
|
|
|
"""
|
|
|
|
|
|
bars = _bars(ts_code, buy_dt)
|
|
|
|
|
|
ctx = {"chase": None, "day_chg": None, "mom5": None, "red_vol": None}
|
|
|
|
|
|
if not bars:
|
|
|
|
|
|
return ctx
|
|
|
|
|
|
bd = buy_dt.date()
|
|
|
|
|
|
bar = bars.get(bd)
|
|
|
|
|
|
if bar is None:
|
2026-08-28 13:07:27 +08:00
|
|
|
|
# 买入日没有行情 (极少见): **直接跳过该样本** —— 原来用"其后第一根"近似,
|
|
|
|
|
|
# 那是买入之后那天的数据, 与"无未来函数"的承诺相悖 (2026-08-28 修)
|
|
|
|
|
|
return ctx
|
2026-08-27 13:26:24 +08:00
|
|
|
|
hi, lo = bar["high"], bar["low"]
|
|
|
|
|
|
if buy_price > 0 and hi > lo:
|
2026-08-28 13:07:27 +08:00
|
|
|
|
# 追高度用当日全天高低点: 这是**事后口径** (买入时刻只知道到那一刻的高低),
|
|
|
|
|
|
# 结果是过滤效果的乐观上限, 读数时要打这个折 (2026-08-28 说明)
|
2026-08-27 13:26:24 +08:00
|
|
|
|
ctx["chase"] = max(0.0, min(1.0, (buy_price - lo) / (hi - lo)))
|
|
|
|
|
|
if bar["pct"]:
|
2026-08-28 13:07:27 +08:00
|
|
|
|
ctx["day_chg"] = bar["pct"] / 100.0 # 同为事后口径 (全天涨跌)
|
2026-08-27 13:26:24 +08:00
|
|
|
|
elif bar["pre_close"] > 0:
|
|
|
|
|
|
ctx["day_chg"] = bar["close"] / bar["pre_close"] - 1.0
|
|
|
|
|
|
prior = sorted((d, b) for d, b in bars.items() if d < bd)
|
2026-08-28 13:07:27 +08:00
|
|
|
|
# mom5 与 red_vol 改用**买入前一交易日及更早**的数据 (2026-08-28 修): 原来用买入日
|
|
|
|
|
|
# 收盘和全天量 —— 那要收盘才知道, 实盘过滤器在上午拿不到。改后这两条规则是真正
|
|
|
|
|
|
# 可实现的 (前一日收盘 / 前一日阴线放量), 反事实结果不再靠日内前视撑着。
|
|
|
|
|
|
if len(prior) >= 6:
|
|
|
|
|
|
y = prior[-1][1] # 买入前一交易日
|
|
|
|
|
|
c5 = prior[-6][1]["close"] # 前一日再往前 5 个交易日
|
|
|
|
|
|
if c5 > 0 and y["close"] > 0:
|
|
|
|
|
|
ctx["mom5"] = y["close"] / c5 - 1.0
|
|
|
|
|
|
vols = [b["vol"] for _, b in prior[-6:-1] if b["vol"] > 0]
|
|
|
|
|
|
if vols and y["vol"] > 0:
|
2026-08-27 13:26:24 +08:00
|
|
|
|
avgv = sum(vols) / len(vols)
|
2026-08-28 13:07:27 +08:00
|
|
|
|
ctx["red_vol"] = (y["close"] < y["open"]) and (y["vol"] >= REDVOL_VOL_MULT * avgv)
|
2026-08-27 13:26:24 +08:00
|
|
|
|
return ctx
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ 成本
|
|
|
|
|
|
def trade_cost(notional: float, is_sell: bool) -> float:
|
|
|
|
|
|
n = abs(_f(notional))
|
|
|
|
|
|
if n <= 0:
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
commission = max(n * COMMISSION_RATE, COMMISSION_MIN)
|
|
|
|
|
|
stamp = n * STAMP_RATE if is_sell else 0.0
|
|
|
|
|
|
transfer = n * TRANSFER_RATE
|
|
|
|
|
|
slip = n * SLIPPAGE_BPS / 10000.0
|
|
|
|
|
|
return commission + stamp + transfer + slip
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ 来回配对 (买→其后第一次卖)
|
|
|
|
|
|
def pair_round_trips(rows_by_code: dict) -> list:
|
|
|
|
|
|
"""同一只票: 把"一次买"和其后"第一次卖"配成一个来回(粗配, 不做逐笔 FIFO)。
|
|
|
|
|
|
说明: 多次买(加仓)只认第一笔为入场; 入场时点体检看的是首次进场那一下。"""
|
|
|
|
|
|
trips = []
|
|
|
|
|
|
for code, rows in rows_by_code.items():
|
|
|
|
|
|
pending_buy = None
|
|
|
|
|
|
for r in rows:
|
|
|
|
|
|
tag = classify(r)
|
|
|
|
|
|
if tag["side"] == "buy":
|
|
|
|
|
|
if pending_buy is None:
|
|
|
|
|
|
pending_buy = r
|
|
|
|
|
|
elif tag["side"] == "sell" and pending_buy is not None:
|
|
|
|
|
|
b, s = pending_buy, r
|
|
|
|
|
|
bt, st = b["decided_at"], s["decided_at"]
|
|
|
|
|
|
bt = bt if isinstance(bt, datetime) else datetime.fromisoformat(str(bt))
|
|
|
|
|
|
st = st if isinstance(st, datetime) else datetime.fromisoformat(str(st))
|
2026-08-28 13:07:27 +08:00
|
|
|
|
hold_days = tdays_between(bt.date(), st.date()) # 真交易日 (2026-08-28)
|
2026-08-27 13:26:24 +08:00
|
|
|
|
gross = _pct(b["price_at"], s["price_at"])
|
|
|
|
|
|
trips.append({
|
|
|
|
|
|
"ts_code": code, "buy_at": bt, "sell_at": st,
|
|
|
|
|
|
"buy_price": _f(b["price_at"]), "sell_price": _f(s["price_at"]),
|
|
|
|
|
|
"hold_days": hold_days, "gross_ret": gross,
|
|
|
|
|
|
"signal_driven": tag["signal_driven"], "cohort": tag.get("cohort"),
|
|
|
|
|
|
"sell_reason": s.get("reason"),
|
|
|
|
|
|
})
|
|
|
|
|
|
pending_buy = None
|
|
|
|
|
|
return trips
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_bad_entry(t: dict) -> bool:
|
|
|
|
|
|
"""坏入场: 决策系统驱动的卖出、且持有不超过 QUICK_DAYS —— 买完就被风控快速清掉的那批。"""
|
|
|
|
|
|
return bool(t["signal_driven"]) and t["hold_days"] is not None and t["hold_days"] <= QUICK_DAYS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ 各段输出
|
|
|
|
|
|
def section_inventory(rows, trips):
|
|
|
|
|
|
tags = [classify(r) for r in rows]
|
|
|
|
|
|
buys = sum(1 for t in tags if t["side"] == "buy")
|
|
|
|
|
|
sells = sum(1 for t in tags if t["side"] == "sell")
|
|
|
|
|
|
bad = [t for t in trips if is_bad_entry(t)]
|
|
|
|
|
|
print("\n① 样本盘点")
|
|
|
|
|
|
print(f" 账本落地买入 {buys} 笔 · 落地卖出 {sells} 笔 · 配成来回 {len(trips)} 组")
|
|
|
|
|
|
print(f" 其中「买完就被风控快速清掉」的坏入场(卖出为决策系统驱动、持有≤{QUICK_DAYS}交易日) "
|
|
|
|
|
|
f"{len(bad)} 组")
|
|
|
|
|
|
return bad
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _profile(trips):
|
|
|
|
|
|
"""算一批来回的入场画像三特征均值。返回 (chase, day_chg, mom5, n_ctx)。"""
|
|
|
|
|
|
ch, dc, mo = [], [], []
|
|
|
|
|
|
for t in trips:
|
|
|
|
|
|
c = entry_context(t["ts_code"], t["buy_at"], t["buy_price"])
|
|
|
|
|
|
ch.append(c["chase"]); dc.append(c["day_chg"]); mo.append(c["mom5"])
|
|
|
|
|
|
n_ctx = sum(1 for x in ch if x is not None)
|
|
|
|
|
|
return _avg(ch), _avg(dc), _avg(mo), n_ctx
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def section_profile(trips):
|
|
|
|
|
|
"""② 坏入场画像 vs 全体入场: 三个买入日价格特征。"""
|
|
|
|
|
|
print("\n② 坏入场画像 vs 全体入场 (买入日的价格情形)")
|
|
|
|
|
|
all_paired = [t for t in trips if t["buy_price"] > 0]
|
|
|
|
|
|
bad = [t for t in all_paired if is_bad_entry(t)]
|
|
|
|
|
|
ac, ad, am, an = _profile(all_paired)
|
|
|
|
|
|
bc, bd, bm, bn = _profile(bad)
|
|
|
|
|
|
if an == 0:
|
|
|
|
|
|
print(" (前向/日线价源未接通 fetch_daily_bars, 本段跳过 —— 接上后重跑即出)")
|
|
|
|
|
|
return
|
|
|
|
|
|
print(f" 全体入场 {len(all_paired)} 组(有行情 {an} 组): "
|
|
|
|
|
|
f"追高度均值 {ac:.2f} · 买入日涨跌 {_fmt_pct(ad)} · 前5日动量 {_fmt_pct(am)}")
|
|
|
|
|
|
if bn < MIN_SAMPLE:
|
|
|
|
|
|
print(f" 坏入场 {len(bad)} 组(有行情 {bn} 组, <{MIN_SAMPLE}): 只列数不下结论。")
|
|
|
|
|
|
print(f" 坏入场 {len(bad)} 组(有行情 {bn} 组): "
|
|
|
|
|
|
f"追高度均值 {bc if bc is None else round(bc,2)} · 买入日涨跌 {_fmt_pct(bd)} · "
|
|
|
|
|
|
f"前5日动量 {_fmt_pct(bm)}")
|
|
|
|
|
|
print(" 读法: 追高度=买价在当日最高最低之间的位置(1=买在最高)。坏入场若追高度明显更高、"
|
|
|
|
|
|
"或前5日动量明显更负, 说明它们多买在冲高或买在已下跌的票上 —— 那正是可在入场端拦的把手。")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fwd_profile(trips):
|
|
|
|
|
|
got = {h: [] for h in FWD_HORIZONS}
|
|
|
|
|
|
for t in trips:
|
|
|
|
|
|
fr = forward_returns(t["ts_code"], t["buy_at"], t["buy_price"])
|
|
|
|
|
|
for h in FWD_HORIZONS:
|
|
|
|
|
|
if fr[h] is not None:
|
|
|
|
|
|
got[h].append(fr[h])
|
|
|
|
|
|
return got
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _print_fwd(label, got):
|
|
|
|
|
|
if not any(got.values()):
|
|
|
|
|
|
return False
|
|
|
|
|
|
print(f" 【{label}】")
|
|
|
|
|
|
for h in FWD_HORIZONS:
|
|
|
|
|
|
v = got[h]
|
|
|
|
|
|
if len(v) < MIN_SAMPLE:
|
|
|
|
|
|
print(f" T+{h}: 样本 {len(v)} (<{MIN_SAMPLE}), 只报数")
|
|
|
|
|
|
continue
|
|
|
|
|
|
avg = sum(v) / len(v)
|
|
|
|
|
|
down = sum(1 for x in v if x < 0) / len(v)
|
|
|
|
|
|
print(f" T+{h}: 样本 {len(v)} · 买后平均 {_fmt_pct(avg)} · 买后就跌比例 {down:.0%}")
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def section_forward(trips):
|
|
|
|
|
|
"""③ 入场后前向收益: 买入时点本身好不好 (坏入场 vs 全体)。"""
|
|
|
|
|
|
print("\n③ 买入之后的前向收益 (相对买价; 负=买在了下跌前, 入场时点差)")
|
|
|
|
|
|
all_paired = [t for t in trips if t["buy_price"] > 0]
|
|
|
|
|
|
bad = [t for t in all_paired if is_bad_entry(t)]
|
|
|
|
|
|
if not _print_fwd("全体入场", _fwd_profile(all_paired)):
|
|
|
|
|
|
print(" (前向价源未接通 fetch_daily_bars, 本段跳过 —— 接上后重跑即出)")
|
|
|
|
|
|
return
|
|
|
|
|
|
_print_fwd("坏入场 (买完就被风控清掉这批)", _fwd_profile(bad))
|
|
|
|
|
|
print(" 读法: 坏入场买后前向明显比全体更负=这批入场时点确实差(不是卖错是买错); "
|
|
|
|
|
|
"若全体入场买后也普遍为负, 说明入场时点是系统性问题, 不止这十几笔。")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def section_cost(trips):
|
|
|
|
|
|
print("\n④ 坏入场的来回, 纯摩擦成本")
|
|
|
|
|
|
bad = [t for t in trips if is_bad_entry(t) and t["gross_ret"] is not None]
|
|
|
|
|
|
if not bad:
|
|
|
|
|
|
print(" 无样本。")
|
|
|
|
|
|
return
|
|
|
|
|
|
total_rate = len(bad) * _rt_cost_rate()
|
|
|
|
|
|
print(f" 每个来回往返摩擦约 {_rt_cost_rate():.3%} · {len(bad)} 组坏入场累计摩擦 ≈ "
|
|
|
|
|
|
f"名义规模的 {total_rate:.2%}")
|
|
|
|
|
|
print(" 读法: 这是「买错就得原路平掉」白交的过路费, 少买一笔坏入场就省一份。")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- ⑤ 反事实入场过滤 ----
|
|
|
|
|
|
def _filters():
|
|
|
|
|
|
"""候选入场闸门: 每条是 (名称, 说明, 判定函数(trip,ctx)->bool 命中即"该拦")。
|
|
|
|
|
|
只用买入日及之前的信息, 无未来函数。"""
|
|
|
|
|
|
return [
|
2026-08-28 13:07:27 +08:00
|
|
|
|
("追高[事后口径]", f"买价落在当日振幅顶部{(1-CHASE_TOP_FRAC):.0%}以内(追高度≥{CHASE_TOP_FRAC}; "
|
|
|
|
|
|
f"全天高低收盘才定, 此条是乐观上限)",
|
2026-08-27 13:26:24 +08:00
|
|
|
|
lambda t, c: c["chase"] is not None and c["chase"] >= CHASE_TOP_FRAC),
|
2026-08-28 13:07:27 +08:00
|
|
|
|
("逆势买", f"前一交易日已较其前5日跌超{abs(DOWNTREND_5D):.0%}(盘中可得)",
|
2026-08-27 13:26:24 +08:00
|
|
|
|
lambda t, c: c["mom5"] is not None and c["mom5"] <= DOWNTREND_5D),
|
2026-08-28 13:07:27 +08:00
|
|
|
|
("放量阴线", f"前一交易日是阴线且量能≥更早5日均量×{REDVOL_VOL_MULT}(盘中可得)",
|
2026-08-27 13:26:24 +08:00
|
|
|
|
lambda t, c: c["red_vol"] is True),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def section_counterfactual(trips):
|
|
|
|
|
|
"""⑤ 反事实: 在入场端加一道过滤, 净赚还是误伤好票。"""
|
|
|
|
|
|
print("\n⑤ 反事实: 入场端加一道过滤, 净赚还是误伤好票")
|
|
|
|
|
|
uni = [t for t in trips if t["buy_price"] > 0 and t["gross_ret"] is not None]
|
|
|
|
|
|
# 预取每笔的入场情形, 顺带探价源
|
|
|
|
|
|
ctxs = {id(t): entry_context(t["ts_code"], t["buy_at"], t["buy_price"]) for t in uni}
|
|
|
|
|
|
if not any(c["chase"] is not None or c["mom5"] is not None for c in ctxs.values()):
|
|
|
|
|
|
print(" (前向/日线价源未接通, 本段跳过 —— 接上 fetch_daily_bars 后重跑即出)")
|
|
|
|
|
|
return
|
|
|
|
|
|
n_bad = sum(1 for t in uni if is_bad_entry(t))
|
|
|
|
|
|
print(f" 口径: 全体可判来回 {len(uni)} 组(其中坏入场 {n_bad} 组)。"
|
|
|
|
|
|
f"每条过滤=不买命中的那些票, 省掉其整个来回的实现净收益(毛收益减往返摩擦{_rt_cost_rate():.2%})。")
|
|
|
|
|
|
for name, desc, fn in _filters():
|
|
|
|
|
|
blocked = [t for t in uni if fn(t, ctxs[id(t)])]
|
|
|
|
|
|
if not blocked:
|
|
|
|
|
|
print(f" · {name}({desc}): 一笔没命中, 跳过。")
|
|
|
|
|
|
continue
|
|
|
|
|
|
nets = [t["gross_ret"] - _rt_cost_rate() for t in blocked]
|
|
|
|
|
|
caught_bad = sum(1 for t in blocked if is_bad_entry(t))
|
|
|
|
|
|
winners = sum(1 for x in nets if x > 0)
|
|
|
|
|
|
avg_net = sum(nets) / len(nets)
|
|
|
|
|
|
portfolio = -sum(nets) # 不买这些 → 组合少了它们的净收益; 正=少亏(有用)
|
|
|
|
|
|
recall = (caught_bad / n_bad) if n_bad else None
|
|
|
|
|
|
if len(blocked) < MIN_SAMPLE:
|
|
|
|
|
|
print(f" · {name}({desc}): 挡掉 {len(blocked)} 组(<{MIN_SAMPLE}, 只报数) · "
|
|
|
|
|
|
f"含坏入场 {caught_bad} 组 · 挡掉里赚钱 {winners} 组")
|
|
|
|
|
|
continue
|
|
|
|
|
|
print(f" · {name}: 挡掉 {len(blocked)} 组 · 含坏入场 {caught_bad} 组"
|
|
|
|
|
|
f"{'' if recall is None else f'(召回坏入场 {recall:.0%})'} · "
|
|
|
|
|
|
f"挡掉平均实现净收益 {_fmt_pct(avg_net)} · 误伤率 {winners/len(blocked):.0%} · "
|
|
|
|
|
|
f"组合净效果 {_fmt_pct(portfolio)}")
|
|
|
|
|
|
print(f" ({desc})")
|
|
|
|
|
|
print(" 读法: 某条过滤「挡掉平均实现净收益」为负(挡掉的多是亏钱来回)、误伤率低、组合净效果为正,"
|
|
|
|
|
|
" 才值得上; 若误伤率高(挡掉里不少是赚的)或净效果为负, 说明它连好票一起误杀, 别上。"
|
|
|
|
|
|
" 召回=这条能拦住多少比例的坏入场。")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ main
|
|
|
|
|
|
def main():
|
|
|
|
|
|
ap = argparse.ArgumentParser(description="入场体检: 坏入场画像与入场过滤反事实 (只读)")
|
|
|
|
|
|
ap.add_argument("--days", type=int, default=120, help="回看多少自然日 (默认120)")
|
|
|
|
|
|
ap.add_argument("--since", type=str, default=None, help="或指定起始日 YYYY-MM-DD")
|
|
|
|
|
|
args = ap.parse_args()
|
|
|
|
|
|
|
|
|
|
|
|
since = (datetime.strptime(args.since, "%Y-%m-%d") if args.since
|
|
|
|
|
|
else datetime.now() - timedelta(days=args.days))
|
|
|
|
|
|
global _ANALYSIS_START, _ANALYSIS_END
|
|
|
|
|
|
_ANALYSIS_START, _ANALYSIS_END = since, datetime.now()
|
|
|
|
|
|
print(f"入场体检 · 账本自 {since:%Y-%m-%d} 起 · 成本口径 往返≈{_rt_cost_rate():.3%} · "
|
|
|
|
|
|
f"坏入场=决策系统卖出且持有≤{QUICK_DAYS}交易日")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
rows = read_ledger(since)
|
|
|
|
|
|
except DBUnavailable as e:
|
|
|
|
|
|
print(f"[FAIL] 读账本失败(库不可达): {e}"); sys.exit(1)
|
|
|
|
|
|
if not rows:
|
|
|
|
|
|
print("账本在该窗口内为空 —— 换个更长的 --days 再看。"); return
|
|
|
|
|
|
|
|
|
|
|
|
by_code = {}
|
|
|
|
|
|
for r in rows:
|
|
|
|
|
|
by_code.setdefault(r["ts_code"], []).append(r)
|
|
|
|
|
|
trips = pair_round_trips(by_code)
|
|
|
|
|
|
|
|
|
|
|
|
section_inventory(rows, trips)
|
|
|
|
|
|
section_profile(trips)
|
|
|
|
|
|
section_forward(trips)
|
|
|
|
|
|
section_cost(trips)
|
|
|
|
|
|
section_counterfactual(trips)
|
|
|
|
|
|
print("\n完成。②③⑤ 若显示「未接通」, 是历史日线源(fetch_daily_bars)还没接 —— "
|
|
|
|
|
|
"确认接哪张表后重跑即全。")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|