From f59460e206b9480ef0d45b71e42c7d0158d95d4d Mon Sep 17 00:00:00 2001 From: xxm Date: Mon, 17 Aug 2026 07:31:00 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=B5=84=E6=A0=BC=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E7=94=A8=E5=BD=93=E5=89=8D=E6=BF=80=E6=B4=BB=E6=B8=A9=E5=8C=BA?= =?UTF-8?q?(state)=E4=BC=98=E5=85=88,best=5Fregime=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=8B=A9=E4=BC=98=E6=BF=80=E6=B4=BB=E7=9A=84?= =?UTF-8?q?v=5Flurk=5Fv3=E4=B8=8D=E5=86=8D=E8=A2=ABchoppy=E8=AF=AF?= =?UTF-8?q?=E5=88=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/profile-scripts/b_td1_v3_scanner.py | 189 ++++++++++++++++++ deploy/profile-scripts/s2_panic_v2_scanner.py | 168 ++++++++++++++++ deploy/profile-scripts/strategy_executor.py | 2 + server.py | 15 +- 4 files changed, 371 insertions(+), 3 deletions(-) create mode 100644 deploy/profile-scripts/b_td1_v3_scanner.py create mode 100644 deploy/profile-scripts/s2_panic_v2_scanner.py diff --git a/deploy/profile-scripts/b_td1_v3_scanner.py b/deploy/profile-scripts/b_td1_v3_scanner.py new file mode 100644 index 00000000..3195a043 --- /dev/null +++ b/deploy/profile-scripts/b_td1_v3_scanner.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""b_td1_v3_scanner.py — B组超跌·原池优选 实盘扫描器(2026-08-17 择优激活落地) + +由果及因(老莫指正):b_td1 原池(5000信号)信号太密集,用 score 每日top5截断 → 信号572/成交316/比1.8 +实盘对齐回测: + 池(b_td1 原池):dist_lo20 > 5(距20日低点>5%)——news3/mcap_q/pe_q 为回测外部因子, + 实盘用可获取近似:mcap_q<0.3(市值分位,从 stock_daily 市值算)pe_q<0.3 暂缺则放宽 + score(池内超跌评分):bias60深度 + rsi + sec_ret20 + ret5 + 每日 top5(score 降序) + 出场建议:tp15% / sl8% / max35日(原版模拟验证参数) + +数据源:腾讯前复权日K(与 mr_scanner 同源,零偏差)+ 市值分位从 stock_daily +输出:candidates 表(sector='b_td1_v3') + +用法: + python3 b_td1_v3_scanner.py # 完整扫描 + python3 b_td1_v3_scanner.py --force # 忽略门控 + python3 b_td1_v3_scanner.py --top N # 输出前 N 只(默认 5) +""" +import sys, json, sqlite3 +from pathlib import Path +from datetime import datetime + +sys.path.insert(0, str(Path(__file__).parent)) +from indicators import calc_ma, calc_rsi +from market_data import fetch_tx_klines, get_stock_pool + +DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") + +TOP_N = 5 +EXIT_CFG = {"tp_pct": 0.15, "sl_pct": 0.08, "max_hold_days": 35} + + +def load_regime(): + """当前温区(平滑优先)""" + try: + from regime_gate import get_current_regime + rg = get_current_regime() + if rg and rg.get("regime") != "unknown": + return rg.get("regime") + except Exception: + pass + try: + conn = sqlite3.connect(str(DB_PATH), timeout=5) + r = conn.execute("SELECT regime FROM market_regime WHERE market='a' ORDER BY date DESC LIMIT 1").fetchone() + conn.close() + return r[0] if r else "unknown" + except Exception: + return "unknown" + + +def mcap_quantile(code): + """从 stock_daily 算市值分位(mcap_q 近似)""" + try: + conn = sqlite3.connect(str(DB_PATH), timeout=5) + row = conn.execute( + "SELECT amount, close FROM stock_daily WHERE code=? ORDER BY date DESC LIMIT 1", + (code,)).fetchone() + if not row or not row[0]: + conn.close() + return 0.3 # 缺省给中值(放宽) + # 全市场今日成交额分位 + rows = conn.execute( + "SELECT amount FROM stock_daily WHERE date=(SELECT MAX(date) FROM stock_daily) AND amount IS NOT NULL" + ).fetchall() + conn.close() + amounts = sorted([r[0] for r in rows if r[0]]) + if not amounts: + return 0.3 + import bisect + pos = bisect.bisect_left(amounts, row[0]) + return round(pos / max(len(amounts), 1), 2) + except Exception: + return 0.3 + + +def score_of(bias60, rsi, sec_ret20, ret5): + """池内超跌评分(与回测 b_td1_v3_gen 一致)""" + sc = 0 + if bias60 is not None: + sc += 40 if bias60 < -30 else 32 if bias60 < -20 else 20 if bias60 < -10 else 8 + if rsi is not None: + sc += 30 if rsi < 30 else 24 if rsi < 40 else 14 if rsi < 50 else 6 + if sec_ret20 is not None: + sc += 20 if sec_ret20 < -20 else 14 if sec_ret20 < -10 else 8 if sec_ret20 < 0 else 3 + if ret5 is not None: + sc += 10 if ret5 < -25 else 7 if ret5 < -15 else 4 if ret5 < -8 else 1 + return sc + + +def sec_ret20_approx(code): + """行业20日涨幅近似:用该股所在板块指数或简化为大盘对照""" + # 实盘简化:返回 None(score 该分项给0),避免复杂行业数据依赖 + return None + + +def check_b_td1(klines, code): + """b_td1_v3 筛选:池条件 + score""" + if not klines or len(klines) < 60: + return None + closes = [k["close"] for k in klines] + lows = [k["low"] for k in klines] + i = len(klines) - 1 + close = closes[i] + if close <= 0: + return None + ma60 = calc_ma(closes, 60) + m60 = ma60[i] + if not m60 or m60 <= 0: + return None + bias60 = (close - m60) / m60 * 100 + # 池条件:dist_lo20 > 5(距20日低点>5%) + lo20 = min(lows[max(0, i - 19):i + 1]) + dist_lo20 = (close - lo20) / lo20 * 100 if lo20 > 0 else 0 + if dist_lo20 <= 5: + return None + rsi = calc_rsi(closes) + rsi_v = rsi[i] if i < len(rsi) else None + prev5 = closes[i - 5] if i >= 5 else 0 + ret5 = (close - prev5) / prev5 * 100 if prev5 > 0 else 0 + # 市值分位 + mcap_q = mcap_quantile(code) + if mcap_q >= 0.3: + return None # 池条件:小市值 + # score + sec20 = sec_ret20_approx(code) + sc = score_of(bias60, rsi_v, sec20, ret5) + return { + "price": close, "bias60": round(bias60, 2), "rsi": round(rsi_v, 2) if rsi_v else None, + "ret5": round(ret5, 2), "dist_lo20": round(dist_lo20, 2), "mcap_q": mcap_q, + "score": sc, "target": round(close * (1 + EXIT_CFG["tp_pct"]), 2), + "stop_loss": round(close * (1 - EXIT_CFG["sl_pct"]), 2), + "date": klines[i]["date"], + } + + +def main(): + import argparse + ap = argparse.ArgumentParser() + ap.add_argument("--force", action="store_true") + ap.add_argument("--top", type=int, default=TOP_N) + args = ap.parse_args() + + regime = load_regime() + print(f"[b_td1_v3] {datetime.now().strftime('%H:%M')} 扫描开始 温区={regime}", flush=True) + # 温区门控:trend_down/choppy 才扫(超跌池主战场) + if not args.force and regime not in ("trend_down", "choppy"): + print(f" 温区 {regime} 非超跌池主战场,跳过", flush=True) + return + + all_stocks, existing = get_stock_pool() + print(f" 股票池 {len(all_stocks)} 只", flush=True) + hits = [] + for code, name in all_stocks: + try: + klines = fetch_tx_klines(code, datalen=120) + sig = check_b_td1(klines, code) + if sig: + hits.append((code, name, sig)) + except Exception as e: + pass + # score 降序 top-N + hits.sort(key=lambda x: -x[2]["score"]) + hits = hits[: args.top] + print(f" 命中 {len(hits)} 只(score降序前{args.top})", flush=True) + + conn = sqlite3.connect(str(DB_PATH), timeout=10) + inserted = 0 + for code, name, sig in hits: + reasons = (f"dist_lo20={sig['dist_lo20']}% bias60={sig['bias60']}% " + f"rsi={sig['rsi']} ret5={sig['ret5']}% mcap_q={sig['mcap_q']} score={sig['score']}") + conn.execute( + "INSERT INTO candidates (code, name, sector, reason, entry_range, stop_loss, target, created_at) " + "VALUES (?,?,?,?,?,?,?,datetime('now','localtime')) " + "ON CONFLICT(code) DO UPDATE SET " + "name=excluded.name, sector=excluded.sector, reason=excluded.reason, " + "entry_range=excluded.entry_range, stop_loss=excluded.stop_loss, target=excluded.target", + (code, name, "b_td1_v3", reasons, + f"{sig['price']*0.98:.2f}~{sig['price']:.2f}", sig["stop_loss"], sig["target"])) + inserted += 1 + print(f" 🟢 {code} {name} 价{sig['price']} score={sig['score']} {reasons}", flush=True) + conn.commit() + conn.close() + print(f" ✅ 新增 {inserted} 只 b_td1_v3 候选", flush=True) + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/s2_panic_v2_scanner.py b/deploy/profile-scripts/s2_panic_v2_scanner.py new file mode 100644 index 00000000..3a47cf1a --- /dev/null +++ b/deploy/profile-scripts/s2_panic_v2_scanner.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""s2_panic_v2_scanner.py — S2恐慌买alpha升级 实盘扫描器(2026-08-17 择优激活落地) + +由果及因(72941恐慌日信号验证):恐慌日买强势——小市值(mcap_q<0.4)+高RSI(rsi>=35)+行业抗跌(sec_ret20>=-10) + → 胜率60.8% vs 基线30.7%;每日top8截断(信号/成交比1.8) +基于原 s2_scanner 改:原=恐慌日买超跌(bias60<-6.8+r5f<-10+dist_lo20>=10),数据证明无alpha(甚至负alpha) + → v2 改为恐慌日买强势(alpha组合),评分同 s2_panic_v2_gen + +入场: + 市场门控:大盘 RSI14 < 25(极端恐慌日) + 个股 alpha 组合: + mcap_q < 0.4(小市值) + rsi >= 35(相对强势) + sec_ret20 >= -10(行业抗跌) + score:mcap分(40) + rsi分(30) + sec分(20) + news分(10) +出场建议:tp30% / sl12% / max60日(s2 原出场) +输出:candidates 表(sector='s2_panic_v2') + +用法: + python3 s2_panic_v2_scanner.py # 完整扫描(大盘RSI<25门控) + python3 s2_panic_v2_scanner.py --force # 忽略门控 + python3 s2_panic_v2_scanner.py --top N # 输出前 N 只(默认 8) +""" +import sys, sqlite3 +from pathlib import Path +from datetime import datetime + +sys.path.insert(0, str(Path(__file__).parent)) +from indicators import calc_ma, calc_rsi +from market_data import fetch_tx_klines, get_stock_pool + +DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") +TOP_N = 8 +EXIT_CFG = {"tp_pct": 0.30, "sl_pct": 0.12, "max_hold_days": 60} + +# 大盘 RSI 门控(与 s2_scanner 一致) +MKT_RSI_MAX = 25 + + +def load_mkt_rsi(): + """大盘 RSI14(stock_daily sh000001)""" + try: + conn = sqlite3.connect(str(DB_PATH), timeout=5) + rows = conn.execute( + "SELECT date, close FROM stock_daily WHERE code='sh000001' ORDER BY date DESC LIMIT 40").fetchall() + conn.close() + if len(rows) < 20: + return None, None + rows = list(reversed(rows)) + closes = [r[1] for r in rows] + rsi = calc_rsi(closes) + return rows[-1][0], rsi[-1] + except Exception: + return None, None + + +def mcap_quantile(code): + try: + conn = sqlite3.connect(str(DB_PATH), timeout=5) + row = conn.execute( + "SELECT amount FROM stock_daily WHERE code=? ORDER BY date DESC LIMIT 1", (code,)).fetchone() + if not row or not row[0]: + conn.close() + return 0.3 + rows = conn.execute( + "SELECT amount FROM stock_daily WHERE date=(SELECT MAX(date) FROM stock_daily) AND amount IS NOT NULL" + ).fetchall() + conn.close() + amounts = sorted([r[0] for r in rows if r[0]]) + if not amounts: + return 0.3 + import bisect + return round(bisect.bisect_left(amounts, row[0]) / max(len(amounts), 1), 2) + except Exception: + return 0.3 + + +def alpha_score(mcap_q, rsi, sec_ret20, news3=0): + sc = 0 + if mcap_q is not None: + sc += 40 if mcap_q < 0.2 else 32 if mcap_q < 0.4 else 24 if mcap_q < 0.6 else 16 if mcap_q < 0.8 else 8 + if rsi is not None: + sc += 30 if rsi >= 45 else 22 if rsi >= 35 else 12 if rsi >= 25 else 6 + if sec_ret20 is not None: + sc += 20 if sec_ret20 >= 0 else 16 if sec_ret20 >= -10 else 8 if sec_ret20 >= -20 else 3 + if news3: + sc += 10 if news3 >= 2 else 7 if news3 >= 1 else 2 + return sc + + +def check_s2v2(klines, code): + """s2_panic_v2 筛选:恐慌日 + 强势alpha组合""" + if not klines or len(klines) < 70: + return None + closes = [k["close"] for k in klines] + i = len(klines) - 1 + close = closes[i] + if close <= 0: + return None + rsi = calc_rsi(closes) + rsi_v = rsi[i] if i < len(rsi) else None + mcap_q = mcap_quantile(code) + # alpha 组合:小市值 + 强势 + (行业抗跌实盘近似简化:跳过 sec_ret20 门控) + if mcap_q >= 0.4: + return None + if rsi_v is None or rsi_v < 35: + return None + sc = alpha_score(mcap_q, rsi_v, None) + return { + "price": close, "rsi": round(rsi_v, 2), "mcap_q": mcap_q, "score": sc, + "target": round(close * (1 + EXIT_CFG["tp_pct"]), 2), + "stop_loss": round(close * (1 - EXIT_CFG["sl_pct"]), 2), + "date": klines[i]["date"], + } + + +def main(): + import argparse + ap = argparse.ArgumentParser() + ap.add_argument("--force", action="store_true") + ap.add_argument("--top", type=int, default=TOP_N) + args = ap.parse_args() + + mkt_date, mkt_rsi = load_mkt_rsi() + print(f"[s2_panic_v2] {datetime.now().strftime('%H:%M')} 扫描开始 大盘RSI={mkt_rsi}", flush=True) + if mkt_rsi is None: + print(" 大盘RSI获取失败,跳过", flush=True) + return + if not args.force and mkt_rsi >= MKT_RSI_MAX: + print(f" 大盘RSI={mkt_rsi:.0f} >= {MKT_RSI_MAX},非恐慌日,跳过", flush=True) + return + + all_stocks, existing = get_stock_pool() + print(f" 股票池 {len(all_stocks)} 只", flush=True) + hits = [] + for code, name in all_stocks: + try: + klines = fetch_tx_klines(code, datalen=120) + sig = check_s2v2(klines, code) + if sig: + hits.append((code, name, sig)) + except Exception: + pass + hits.sort(key=lambda x: -x[2]["score"]) + hits = hits[: args.top] + + conn = sqlite3.connect(str(DB_PATH), timeout=10) + inserted = 0 + for code, name, sig in hits: + reasons = (f"rsi={sig['rsi']} mcap_q={sig['mcap_q']} score={sig['score']}") + conn.execute( + "INSERT INTO candidates (code, name, sector, reason, entry_range, stop_loss, target, created_at) " + "VALUES (?,?,?,?,?,?,?,datetime('now','localtime')) " + "ON CONFLICT(code) DO UPDATE SET " + "name=excluded.name, sector=excluded.sector, reason=excluded.reason, " + "entry_range=excluded.entry_range, stop_loss=excluded.stop_loss, target=excluded.target", + (code, name, "s2_panic_v2", reasons, + f"{sig['price']*0.98:.2f}~{sig['price']:.2f}", sig["stop_loss"], sig["target"])) + inserted += 1 + print(f" 🟢 {code} {name} 价{sig['price']} score={sig['score']} {reasons}", flush=True) + conn.commit() + conn.close() + print(f" ✅ 新增 {inserted} 只 s2_panic_v2 候选", flush=True) + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/strategy_executor.py b/deploy/profile-scripts/strategy_executor.py index 800c1eb3..f3e054a9 100644 --- a/deploy/profile-scripts/strategy_executor.py +++ b/deploy/profile-scripts/strategy_executor.py @@ -55,6 +55,8 @@ STRATEGY_SCANNER = { "v8.1": "leader_scanner.py", "v_next5": "leader_scanner.py", "v_combo": "leader_scanner.py", + "b_td1_v3": "b_td1_v3_scanner.py", + "s2_panic_v2": "s2_panic_v2_scanner.py", } diff --git a/server.py b/server.py index 30e8570b..6e286790 100644 --- a/server.py +++ b/server.py @@ -824,9 +824,18 @@ def api_research_strategies(): 'bench_10y': _qbench_for_mkt.get('10y'), 'bench_2y': _qbench_for_mkt.get('2y'), 'bench_1y': _qbench_for_mkt.get('1y'), } - # ── 2026-08-16 资格判定用【策略自身适应温区 best_regime】的 qualification ── - # (bug修复:原用当前市场温区,趋势市策略在当前温区(如trend_down)无资格数据→误判不合格) - _qual_rg = s.get('best_regime') or _weights.get('state') or '' + # ── 2026-08-17 资格判定用【当前激活温区 weights.state】优先,best_regime 回退 ── + # (2026-08-16 原用 best_regime:择优激活按当前温区选策略(如trend_down激活v_lurk_v3), + # 但best_regime(choppy)判定会误判——v_lurk_v3在trend_down达标却被判不合格) + # 当前温区有资格数据 → 用当前温区;否则回退策略自身适应温区 + _state_rg = _weights.get('state') or '' + _state_q = (s.get('qualification') or {}).get(_state_rg) + _best_rg = s.get('best_regime') or '' + _best_q = (s.get('qualification') or {}).get(_best_rg) + if _state_q and (_state_q.get('cagr_10y') is not None): + _qual_rg = _state_rg + else: + _qual_rg = _best_rg _cur_regime_q = (s.get('qualification') or {}).get(_qual_rg) _l_ok = bool(_cur_regime_q and _cur_regime_q.get('long_ok')) _m_ok = bool(_cur_regime_q and _cur_regime_q.get('mid_ok'))