From 0ef84d34d5cdabc19e226d48b87fc1fc1899b7ab Mon Sep 17 00:00:00 2001 From: hmo Date: Tue, 11 Aug 2026 08:20:09 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=B7=A5=E5=85=B7=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E6=8A=BD=E5=8F=96=E5=85=AC=E5=85=B1=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E2=80=94=E2=80=94indicators.py(=E6=8C=87=E6=A0=87)+market=5Fda?= =?UTF-8?q?ta.py(=E6=95=B0=E6=8D=AE),mr/s2=E6=89=AB=E6=8F=8F=E5=99=A8?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=85=AC=E5=85=B1=E6=A8=A1=E5=9D=97import(?= =?UTF-8?q?=E6=B6=88=E9=99=A4=E7=AD=96=E7=95=A5=E6=89=AB=E6=8F=8F=E5=99=A8?= =?UTF-8?q?=E4=BA=92import)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/profile-scripts/indicators.py | 81 +++++++++++++++ deploy/profile-scripts/market_data.py | 80 ++++++++++++++ deploy/profile-scripts/mr_scanner.py | 144 ++------------------------ deploy/profile-scripts/s2_scanner.py | 4 +- 4 files changed, 171 insertions(+), 138 deletions(-) create mode 100644 deploy/profile-scripts/indicators.py create mode 100644 deploy/profile-scripts/market_data.py diff --git a/deploy/profile-scripts/indicators.py b/deploy/profile-scripts/indicators.py new file mode 100644 index 00000000..6a012628 --- /dev/null +++ b/deploy/profile-scripts/indicators.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""indicators.py — 通用技术指标库(2026-08-11 从 mr_scanner 抽取) + +背景:calc_ma/calc_rsi/calc_atr/calc_obv 原定义在 mr_scanner.py, +被 s2_scanner.py import 复用 —— 策略扫描器互相 import 工具函数是坏味道。 +抽取到公共模块,供所有策略扫描器(mr/s2/accumulation/p_oversold)共用。 + +算法与 backtest_framework.py 完全一致(零偏差)。 +""" + + +def calc_ma(series, n): + """简单移动平均。前 n-1 位返回 None。""" + result = [] + for i in range(len(series)): + if i < n - 1: + result.append(None) + else: + result.append(sum(series[i - n + 1:i + 1]) / n) + return result + + +def calc_rsi(series, n=14): + """RSI(相对强弱指数),Wilder 平滑。""" + deltas = [series[i] - series[i - 1] for i in range(1, len(series))] + gains = [d if d > 0 else 0 for d in deltas] + losses = [-d if d < 0 else 0 for d in deltas] + result = [None] * (n + 1) + avg_gain = sum(gains[:n]) / n + avg_loss = sum(losses[:n]) / n + if avg_loss == 0: + result.append(100) + else: + rs = avg_gain / avg_loss + result.append(100 - 100 / (1 + rs)) + for i in range(n, len(gains)): + avg_gain = (avg_gain * (n - 1) + gains[i]) / n + avg_loss = (avg_loss * (n - 1) + losses[i]) / n + if avg_loss == 0: + result.append(100) + else: + rs = avg_gain / avg_loss + result.append(100 - 100 / (1 + rs)) + while len(result) < len(series): + result.insert(0, None) + return result[:len(series)] + + +def calc_atr(klines, n=14): + """ATR(Average True Range),与回测 calc_factors 的 atr_pct 同口径。 + klines: [{high, low, close, ...}]""" + if len(klines) < n + 1: + return None + trs = [] + for i in range(1, len(klines)): + h, l, pc = klines[i]["high"], klines[i]["low"], klines[i - 1]["close"] + tr = max(h - l, abs(h - pc), abs(l - pc)) + trs.append(tr) + atr = sum(trs[-n:]) / n + return atr + + +def calc_obv(klines): + """OBV 能量潮(近20日变化量),资金流向指标。 + klines: [{close, volume, ...}]""" + if len(klines) < 21: + return 0 + obv = 0 + for i in range(1, len(klines)): + if klines[i]["close"] > klines[i - 1]["close"]: + obv += klines[i]["volume"] * 100 # 手→股 + elif klines[i]["close"] < klines[i - 1]["close"]: + obv -= klines[i]["volume"] * 100 + # 近20日 OBV 变化 + obv_now = 0 + for i in range(max(1, len(klines) - 20), len(klines)): + if klines[i]["close"] > klines[i - 1]["close"]: + obv_now += klines[i]["volume"] * 100 + elif klines[i]["close"] < klines[i - 1]["close"]: + obv_now -= klines[i]["volume"] * 100 + return obv_now diff --git a/deploy/profile-scripts/market_data.py b/deploy/profile-scripts/market_data.py new file mode 100644 index 00000000..34ee2f33 --- /dev/null +++ b/deploy/profile-scripts/market_data.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""market_data.py — 通用行情数据获取库(2026-08-11 从 mr_scanner 抽取) + +背景:fetch_tx_klines/get_stock_pool 原定义在 mr_scanner.py, +被 s2_scanner.py import 复用 —— 策略扫描器互相 import 数据函数是坏味道。 +抽取到公共模块,供所有策略扫描器(mr/s2/accumulation/p_oversold)共用。 +""" + +import json +import sqlite3 +import urllib.request +from pathlib import Path + +DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") +UA = "Mozilla/5.0" + + +def fetch_tx_klines(code, datalen=120): + """腾讯前复权日K(qfq),与 stock_daily 数据零偏差,返回 [{date,open,close,high,low,volume}]""" + raw = str(code).strip() + if raw.startswith(("6", "9")): + prefix = "sh" + elif raw.startswith(("0", "3")): + prefix = "sz" + else: + return None + url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param={prefix}{raw},day,,,{datalen},qfq" + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + with opener.open(req, timeout=8) as r: + text = r.read().decode("utf-8", errors="replace").strip() + data = json.loads(text) + node = data.get("data", {}).get(f"{prefix}{raw}", {}) + bars = node.get("qfqday") or node.get("day") or [] + if not bars or len(bars) < 70: + return None + result = [] + for b in bars: + if len(b) < 6: + continue + result.append({ + "date": b[0][:10], + "open": float(b[1]), + "close": float(b[2]), + "high": float(b[3]), + "low": float(b[4]), + "volume": float(b[5]), # 手 + }) + return result + except Exception: + return None + + +# 兼容别名(供外部引用) +fetch_sina_klines = fetch_tx_klines + + +def get_stock_pool(): + """待扫描股票池:stock_daily 的 distinct code(与回测 run_mr_backtest 完全同口径) + + 回测股票池 = SELECT DISTINCT sd.code FROM stock_daily(4266只,含300/688, + 不含301新创业板——数据源未收录)。实盘扫描用同一口径,保证信号 + 覆盖的股票都是回测验证过的。 + """ + conn = sqlite3.connect(str(DB_PATH), timeout=5) + try: + existing = set() + for r in conn.execute("SELECT code FROM holding_strategies WHERE status='active'"): + existing.add(str(r[0])) + for r in conn.execute("SELECT code FROM holdings WHERE is_active=1"): + existing.add(str(r[0])) + # 与回测完全一致:stock_daily 有K线的股票(回测 universe='a' 排除5位港股) + all_stocks = [str(r[0]) for r in + conn.execute("SELECT DISTINCT code FROM stock_daily").fetchall()] + finally: + conn.close() + # 只留 A 股(6位数字),排除港股(5位0开头)—— 与回测 is_hk_code 逻辑一致 + a_stocks = [c for c in all_stocks if len(c) == 6 and c.isdigit()] + return a_stocks, existing diff --git a/deploy/profile-scripts/mr_scanner.py b/deploy/profile-scripts/mr_scanner.py index da1b9240..60930eed 100644 --- a/deploy/profile-scripts/mr_scanner.py +++ b/deploy/profile-scripts/mr_scanner.py @@ -36,6 +36,13 @@ import sys, json, urllib.request, re, time, sqlite3 from pathlib import Path from datetime import datetime +# 2026-08-11 重构:工具函数抽取到公共模块(indicators/market_data) +# 原 calc_ma/calc_rsi/calc_atr/calc_obv → indicators.py +# 原 fetch_tx_klines/get_stock_pool → market_data.py +# 保留本文件的 import 兼容(从公共模块导入同名函数) +from indicators import calc_ma, calc_rsi, calc_atr, calc_obv +from market_data import fetch_tx_klines, get_stock_pool + DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") UA = "Mozilla/5.0" @@ -55,143 +62,6 @@ EXIT_CFG = {"tp_pct": 0.30, "sl_pct": 0.12, "max_hold_days": 40} TOP_N = 10 -# ── 技术指标(与 backtest_framework.py 完全同算法)── - -def calc_ma(series, n): - result = [] - for i in range(len(series)): - if i < n - 1: - result.append(None) - else: - result.append(sum(series[i - n + 1:i + 1]) / n) - return result - - -def calc_rsi(series, n=14): - deltas = [series[i] - series[i - 1] for i in range(1, len(series))] - gains = [d if d > 0 else 0 for d in deltas] - losses = [-d if d < 0 else 0 for d in deltas] - result = [None] * (n + 1) - avg_gain = sum(gains[:n]) / n - avg_loss = sum(losses[:n]) / n - if avg_loss == 0: - result.append(100) - else: - rs = avg_gain / avg_loss - result.append(100 - 100 / (1 + rs)) - for i in range(n, len(gains)): - avg_gain = (avg_gain * (n - 1) + gains[i]) / n - avg_loss = (avg_loss * (n - 1) + losses[i]) / n - if avg_loss == 0: - result.append(100) - else: - rs = avg_gain / avg_loss - result.append(100 - 100 / (1 + rs)) - while len(result) < len(series): - result.insert(0, None) - return result[:len(series)] - - -def calc_atr(klines, n=14): - """ATR(Average True Range),与回测 calc_factors 的 atr_pct 同口径""" - if len(klines) < n + 1: - return None - trs = [] - for i in range(1, len(klines)): - h, l, pc = klines[i]["high"], klines[i]["low"], klines[i - 1]["close"] - tr = max(h - l, abs(h - pc), abs(l - pc)) - trs.append(tr) - atr = sum(trs[-n:]) / n - return atr - - -def calc_obv(klines): - """OBV 能量潮(近20日变化量),资金流向指标""" - if len(klines) < 21: - return 0 - obv = 0 - for i in range(1, len(klines)): - if klines[i]["close"] > klines[i - 1]["close"]: - obv += klines[i]["volume"] * 100 # 手→股 - elif klines[i]["close"] < klines[i - 1]["close"]: - obv -= klines[i]["volume"] * 100 - # 近20日 OBV 变化 - obv_now = 0 - for i in range(max(1, len(klines) - 20), len(klines)): - if klines[i]["close"] > klines[i - 1]["close"]: - obv_now += klines[i]["volume"] * 100 - elif klines[i]["close"] < klines[i - 1]["close"]: - obv_now -= klines[i]["volume"] * 100 - return obv_now - - -# ── 数据获取 ── - -def fetch_tx_klines(code, datalen=120): - """腾讯前复权日K(qfq),与 stock_daily 数据零偏差,返回 [{date,open,close,high,low,volume}]""" - raw = str(code).strip() - if raw.startswith(("6", "9")): - prefix = "sh" - elif raw.startswith(("0", "3")): - prefix = "sz" - else: - return None - url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param={prefix}{raw},day,,,{datalen},qfq" - try: - req = urllib.request.Request(url, headers={"User-Agent": UA}) - opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) - with opener.open(req, timeout=8) as r: - text = r.read().decode("utf-8", errors="replace").strip() - data = json.loads(text) - node = data.get("data", {}).get(f"{prefix}{raw}", {}) - bars = node.get("qfqday") or node.get("day") or [] - if not bars or len(bars) < 70: - return None - result = [] - for b in bars: - if len(b) < 6: - continue - result.append({ - "date": b[0][:10], - "open": float(b[1]), - "close": float(b[2]), - "high": float(b[3]), - "low": float(b[4]), - "volume": float(b[5]), # 手 - }) - return result - except Exception: - return None - - -# 兼容别名(供外部引用) -fetch_sina_klines = fetch_tx_klines - - -def get_stock_pool(): - """待扫描股票池:stock_daily 的 distinct code(与回测 run_mr_backtest 完全同口径) - - 回测股票池 = SELECT DISTINCT sd.code FROM stock_daily(4266只,含300/688, - 不含301新创业板——数据源未收录)。实盘扫描用同一口径,保证 v_mr - 信号覆盖的股票都是回测验证过的。 - """ - conn = sqlite3.connect(str(DB_PATH), timeout=5) - try: - existing = set() - for r in conn.execute("SELECT code FROM holding_strategies WHERE status='active'"): - existing.add(str(r[0])) - for r in conn.execute("SELECT code FROM holdings WHERE is_active=1"): - existing.add(str(r[0])) - # 与回测完全一致:stock_daily 有K线的股票(回测 universe='a' 排除5位港股) - all_stocks = [str(r[0]) for r in - conn.execute("SELECT DISTINCT code FROM stock_daily").fetchall()] - finally: - conn.close() - # 只留 A 股(6位数字),排除港股(5位0开头)—— 与回测 is_hk_code 逻辑一致 - a_stocks = [c for c in all_stocks if len(c) == 6 and c.isdigit()] - return a_stocks, existing - - def load_regime(): """读取 market_regime 最新状态""" try: diff --git a/deploy/profile-scripts/s2_scanner.py b/deploy/profile-scripts/s2_scanner.py index 19699440..a8da9d20 100644 --- a/deploy/profile-scripts/s2_scanner.py +++ b/deploy/profile-scripts/s2_scanner.py @@ -30,7 +30,9 @@ from pathlib import Path from datetime import datetime sys.path.insert(0, str(Path(__file__).parent)) -from mr_scanner import fetch_tx_klines, calc_ma, calc_rsi, get_stock_pool +# 2026-08-11 重构:工具函数从 mr_scanner 抽到公共模块,s2 直接引用公共模块 +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")