Files
MoFin/deploy/profile-scripts/factor_engine.py
T

285 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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-5stock_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,
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()