#!/usr/bin/env python3 """factor_engine.py — 技术指标加工(数据加工层核心) 背景(2026-08-12 架构补缺·加工层): 技术指标(bias60/RSI/ADX/mom20/ma/macd/atr 等)API 拉不了,必须自己算—— 此前分散在 5-6 个脚本现算(口径漂移风险)。本脚本统一加工: 读 stock_daily → 算指标 → 写 stock_indicators/market_indicators, 使用层(扫描器/重评/回测)直接读,不现算,口径完全一致。 算法权威源:backtest_framework.calc_*(回测体系,已验证)。实盘/回测共用同一套, 消除 backtest_framework vs indicators 两套定义并存的口径分裂。 原则(老莫定):能拉取的拉取(PE/市值已由采集层拉),拉不了的才自己算(技术指标)。 mcap_q/pe_q 分位由 stock_fundamentals 算(加工层),不重复存 PE/PB/市值。 调度:收盘后 5 17 * * 1-5(stock_daily 采集 16:35 完成后) 规范:单例守卫(5.3) + INSERT OR REPLACE 幂等 + 增量(只写最近1天)+ pandas 向量化 """ import sys, os, sqlite3, fcntl, time from pathlib import Path from datetime import datetime, timedelta sys.path.insert(0, "/home/hmo/MoFin") from backtest_framework import (calc_ma, calc_rsi, calc_macd, calc_atr, # ── 消息通道统一路由(broadcast/xmpp by delivery) ── try: from messenger import install_stdio_hook as _msh _msh() except Exception: pass calc_trend_strength, calc_obv, calc_roc) DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") LOOKBACK_DAYS = 130 # 算 ma60/rsi/adx 需要的历史窗口(取130天保险) def _singleton_guard(tag="factor_engine.py"): lock_dir = Path("/tmp/mofin_locks") lock_dir.mkdir(exist_ok=True) try: fd = os.open(str(lock_dir / f"{tag}.lock"), os.O_CREAT | os.O_RDWR) fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) return fd except OSError: print(f"[{tag}] 已有实例在运行,退出", flush=True) sys.exit(0) def init_tables(conn): conn.execute(""" CREATE TABLE IF NOT EXISTS stock_indicators ( code TEXT, date TEXT, ma5 REAL, ma10 REAL, ma20 REAL, ma60 REAL, rsi REAL, adx REAL, macd_hist REAL, atr REAL, roc REAL, obv REAL, bias60 REAL, mom20 REAL, prev_ret60 REAL, dist_ma20 REAL, dist_lo20 REAL, r5f REAL, vol_ratio REAL, vol_shrink REAL, amount_ma20 REAL, atr_pct REAL, close_up INTEGER, trend_aligned INTEGER, hh_structure INTEGER, hl_structure INTEGER, mcap_q REAL, pe_q REAL, updated_at TEXT, PRIMARY KEY (code, date) )""") # 兼容已存在的表(补 mcap_q/pe_q 列) for col in ("mcap_q", "pe_q"): try: conn.execute(f"ALTER TABLE stock_indicators ADD COLUMN {col} REAL") except Exception: pass conn.execute(""" CREATE TABLE IF NOT EXISTS market_indicators ( date TEXT PRIMARY KEY, mkt_rsi REAL, mkt_dd60 REAL, mkt_adx REAL, mkt_above_ma20 INTEGER, mkt_down_days INTEGER, mkt_slope REAL, mkt_roc REAL, updated_at TEXT )""") conn.commit() def load_fundamentals_sorted(conn): """读 stock_fundamentals 全市场 mcap/pe 排序(算横截面分位用)。 返回 (mcap_sorted_list, pe_sorted_list, code→(mcap,pe) dict)。 mcap_q/pe_q 是衍生分位(由 mcap/pe 算),存加工层,不重复存原始 PE/市值。""" import bisect rows = conn.execute("SELECT code, mcap_total, pe FROM stock_fundamentals").fetchall() mcaps = sorted(r[1] for r in rows if r[1] and r[1] > 0) pes = sorted(r[2] for r in rows if r[2] and r[2] > 0) code_map = {r[0]: (r[1], r[2]) for r in rows} return mcaps, pes, code_map, bisect def calc_stock_indicators(code, bars): """由日K bars 算个股指标(bars: [{date,open,close,high,low,volume,amount}],需>=65根)""" if len(bars) < 65: return None closes = [b["close"] for b in bars] highs = [b["high"] for b in bars] lows = [b["low"] for b in bars] vols = [b["volume"] for b in bars] amts = [b.get("amount") or 0 for b in bars] i = len(bars) - 1 # 最后一根(当天) close = closes[i] if close <= 0: return None ma5 = calc_ma(closes, 5); ma10 = calc_ma(closes, 10); ma20 = calc_ma(closes, 20); ma60 = calc_ma(closes, 60) rsi = calc_rsi(closes) _, _, macd_hist = calc_macd(closes) atr = calc_atr(highs, lows, closes) adx = calc_trend_strength(highs, lows, closes) obv = calc_obv(closes, vols) roc = calc_roc(closes) def g(arr, idx): return arr[idx] if arr and idx < len(arr) and arr[idx] is not None else None ma20_v, ma60_v = g(ma20, i), g(ma60, i) atr_v = g(atr, i) bias60 = round((close - ma60_v) / ma60_v * 100, 2) if ma60_v and ma60_v > 0 else None dist_ma20 = round((close - ma20_v) / ma20_v * 100, 2) if ma20_v and ma20_v > 0 else None mom20 = round((close - closes[i-20]) / closes[i-20] * 100, 2) if i >= 20 and closes[i-20] > 0 else None prev_ret60 = round((close - closes[i-60]) / closes[i-60] * 100, 2) if i >= 60 and closes[i-60] > 0 else None r5f = round((close - closes[i-5]) / closes[i-5] * 100, 2) if i >= 5 and closes[i-5] > 0 else None lo20 = min(lows[i-19:i+1]) if i >= 19 else None dist_lo20 = round((close - lo20) / lo20 * 100, 2) if lo20 and lo20 > 0 else None pvol = vols[i-5] if i >= 5 and vols[i-5] > 0 else None vol_ratio = round(vols[i] / pvol, 2) if pvol else None vol5 = sum(vols[i-4:i+1]) / 5 if i >= 4 else None vol20 = sum(vols[i-19:i+1]) / 20 if i >= 19 else None vol_shrink = round(vol5 / vol20, 2) if vol5 and vol20 and vol20 > 0 else None amt_valid = [a for a in amts[i-19:i+1] if a > 0] if i >= 19 else [] amount_ma20 = round(sum(amt_valid) / len(amt_valid) / 1000.0, 2) if amt_valid else None # 千元→百万 atr_pct = round(atr_v / close * 100, 2) if atr_v and close > 0 else None close_up = 1 if i >= 1 and close > closes[i-1] else 0 trend_aligned = 1 if (ma5[i] and ma10[i] and ma20[i] and ma5[i] > ma10[i] > ma20[i] > 0) else 0 hh = hl = 0 if i >= 10: h5 = max(highs[i-4:i+1]); h10 = max(highs[i-9:i-4]) l5 = min(lows[i-4:i+1]); l10 = min(lows[i-9:i-4]) hh = 1 if h5 > h10 else 0 hl = 1 if l5 > l10 else 0 return { "code": code, "date": bars[i]["date"], "ma5": g(ma5, i), "ma10": g(ma10, i), "ma20": ma20_v, "ma60": ma60_v, "rsi": g(rsi, i), "adx": g(adx, i), "macd_hist": g(macd_hist, i), "atr": atr_v, "roc": g(roc, i), "obv": g(obv, i), "bias60": bias60, "mom20": mom20, "prev_ret60": prev_ret60, "dist_ma20": dist_ma20, "dist_lo20": dist_lo20, "r5f": r5f, "vol_ratio": vol_ratio, "vol_shrink": vol_shrink, "amount_ma20": amount_ma20, "atr_pct": atr_pct, "close_up": close_up, "trend_aligned": trend_aligned, "hh_structure": hh, "hl_structure": hl, } def calc_market_indicators(bars): """由大盘指数日K算大盘指标(mkt_rsi/mkt_dd60/mkt_adx/mkt_above_ma20/mkt_down_days/mkt_slope/mkt_roc)""" if len(bars) < 65: return None closes = [b["close"] for b in bars] highs = [b["high"] for b in bars] lows = [b["low"] for b in bars] i = len(bars) - 1 close = closes[i] ma20 = calc_ma(closes, 20) rsi = calc_rsi(closes) adx = calc_trend_strength(highs, lows, closes) roc = calc_roc(closes) def g(arr, idx): return arr[idx] if arr and idx < len(arr) and arr[idx] is not None else None ma20_v = g(ma20, i) mkt_above = 1 if (ma20_v and close > ma20_v) else 0 hi60 = max(highs[max(0, i-59):i+1]) mkt_dd60 = round((close - hi60) / hi60 * 100, 2) if hi60 > 0 else None # 连跌天数 down = 0 for k in range(i, 0, -1): if closes[k] < closes[k-1]: down += 1 else: break slope = None if i >= 5 and g(ma20, i) and g(ma20, i-5) and g(ma20, i-5) > 0: slope = round((g(ma20, i) - g(ma20, i-5)) / g(ma20, i-5) * 100, 3) return { "date": bars[i]["date"], "mkt_rsi": g(rsi, i), "mkt_dd60": mkt_dd60, "mkt_adx": g(adx, i), "mkt_above_ma20": mkt_above, "mkt_down_days": down, "mkt_slope": slope, "mkt_roc": g(roc, i), } def load_bars(conn, code, since): rows = conn.execute( "SELECT date, open, close, high, low, volume, amount FROM stock_daily " "WHERE code=? AND date>=? ORDER BY date", (code, since)).fetchall() return [{"date": r[0], "open": r[1], "close": r[2], "high": r[3], "low": r[4], "volume": r[5], "amount": r[6]} for r in rows] def main(): _fd = _singleton_guard() t0 = time.time() print(f"[factor_engine] {datetime.now().strftime('%H:%M:%S')} 技术指标加工开始", flush=True) conn = sqlite3.connect(str(DB_PATH), timeout=60) init_tables(conn) since = (datetime.now() - timedelta(days=LOOKBACK_DAYS * 2)).strftime("%Y-%m-%d") codes = [str(r[0]) for r in conn.execute( "SELECT DISTINCT code FROM stock_daily WHERE length(code)=6 ORDER BY code").fetchall()] limit = 0 for a in sys.argv[1:]: if a.startswith("--limit"): limit = int(a.split("=")[-1] if "=" in a else sys.argv[sys.argv.index(a) + 1]) if limit > 0: codes = codes[:limit] print(f" [测试模式] 只跑前 {limit} 只", flush=True) print(f" 股票池: {len(codes)} 只", flush=True) cur = conn.cursor() # 横截面分位(mcap_q/pe_q,由 stock_fundamentals 全市场排序算) mcaps, pes, fund_map, bisect = load_fundamentals_sorted(conn) n_mcap, n_pe = len(mcaps), len(pes) ok = fail = skip = written = 0 latest_date = None for idx, code in enumerate(codes, 1): try: bars = load_bars(conn, code, since) if len(bars) < LOOKBACK_DAYS: skip += 1 continue bars = bars[-LOOKBACK_DAYS:] ind = calc_stock_indicators(code, bars) if not ind: skip += 1 continue latest_date = ind["date"] # 分位(bisect 查全市场排名) mcap_q = pe_q = None if code in fund_map: mcap, pe = fund_map[code] if mcap and mcap > 0 and n_mcap: mcap_q = round(bisect.bisect_left(mcaps, mcap) / n_mcap, 3) if pe and pe > 0 and n_pe: pe_q = round(bisect.bisect_left(pes, pe) / n_pe, 3) cur.execute(""" INSERT OR REPLACE INTO stock_indicators (code, date, ma5, ma10, ma20, ma60, rsi, adx, macd_hist, atr, roc, obv, bias60, mom20, prev_ret60, dist_ma20, dist_lo20, r5f, vol_ratio, vol_shrink, amount_ma20, atr_pct, close_up, trend_aligned, hh_structure, hl_structure, mcap_q, pe_q, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now','localtime'))""", (ind["code"], ind["date"], ind["ma5"], ind["ma10"], ind["ma20"], ind["ma60"], ind["rsi"], ind["adx"], ind["macd_hist"], ind["atr"], ind["roc"], ind["obv"], ind["bias60"], ind["mom20"], ind["prev_ret60"], ind["dist_ma20"], ind["dist_lo20"], ind["r5f"], ind["vol_ratio"], ind["vol_shrink"], ind["amount_ma20"], ind["atr_pct"], ind["close_up"], ind["trend_aligned"], ind["hh_structure"], ind["hl_structure"], mcap_q, pe_q)) written += 1 ok += 1 except Exception as e: fail += 1 if fail <= 5: print(f" FAIL {code}: {str(e)[:60]}", flush=True) if idx % 200 == 0: conn.commit() print(f" [{idx}/{len(codes)}] ok={ok} skip={skip} fail={fail} | {time.time()-t0:.0f}s", flush=True) conn.commit() # 大盘指标(上证指数 sh000001) try: mbars = load_bars(conn, "sh000001", since)[-LOOKBACK_DAYS:] mind = calc_market_indicators(mbars) if mind: cur.execute(""" INSERT OR REPLACE INTO market_indicators (date, mkt_rsi, mkt_dd60, mkt_adx, mkt_above_ma20, mkt_down_days, mkt_slope, mkt_roc, updated_at) VALUES (?,?,?,?,?,?,?,?,datetime('now','localtime'))""", (mind["date"], mind["mkt_rsi"], mind["mkt_dd60"], mind["mkt_adx"], mind["mkt_above_ma20"], mind["mkt_down_days"], mind["mkt_slope"], mind["mkt_roc"])) conn.commit() print(f" 大盘指标: {mind['date']} rsi={mind['mkt_rsi']} dd60={mind['mkt_dd60']} adx={mind['mkt_adx']}", flush=True) except Exception as e: print(f" 大盘指标失败: {e}", flush=True) conn.close() dt = time.time() - t0 print(f"[factor_engine] 完成: {written} 只写入(最新 {latest_date}), skip={skip} fail={fail}, 耗时 {dt:.0f}s", flush=True) if __name__ == "__main__": main()