#!/usr/bin/env python3 """technical_analysis.py — 技术面分析模块 v2 基于多日价格数据计算支撑位/压力位: 1. 缓存每日 HLC 到 price_history.json 2. 使用 5 日最高/最低计算枢轴点 3. 结合振幅自动调整区间宽度 使用方式: from technical_analysis import full_analysis result = full_analysis("603259") # 自动识别A股/港股 """ import json import os import sys import urllib.request from datetime import datetime, date # 确保本文件所在目录可导入(market_config 与之同目录;本模块会被 MoFin 根脚本跨目录 import) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from market_config import kline_symbol # 腾讯API字段索引 F = { "name": 1, "code": 2, "price": 3, "close_yest": 4, "open": 5, "volume": 6, "timestamp": 30, "change": 31, "change_pct": 32, "high": 33, "low": 34, "amplitude": 43, "turnover": 38, "pe": 39, "pb": 46, "limit_up": 47, "limit_down": 48, "avg_price": 51, "inner_vol": 52, "outer_vol": 53, } HISTORY_PATH = "/home/hmo/web-dashboard/data/price_history.json" HISTORY_DAYS = 60 # 使用最近 N 天的 HLC 数据 def _load_history(): """读取价格历史缓存""" try: return json.load(open(HISTORY_PATH)) except (FileNotFoundError, json.JSONDecodeError): return {} def _save_history(h): json.dump(h, open(HISTORY_PATH, "w"), ensure_ascii=False, indent=2) def _market_prefix(code): """根据代码确定腾讯API前缀(统一走 market_config.kline_symbol,规则以它为准)""" sym = kline_symbol(code) if not sym: return "sz" # 兼容旧兜底(非5/6位数字等) return sym[:2] def get_quote(code): """获取行情数据。先拿DB的价格和涨跌幅,再调腾讯API拿HLC全量数据""" import time _cache = get_quote.__dict__.get("_cache", {}) now = time.time() cached = _cache.get(code) if cached and (now - cached["ts"]) < 60: return cached["data"] # 先从DB拿基础价格(快速,不阻塞) db_price = None db_chg = None try: from mofin_db import get_price_from_db p, chg = get_price_from_db(code) if p: db_price, db_chg = p, chg except: pass # 腾讯API获取全量HLC数据 raw = str(code).split("_")[0] prefix = _market_prefix(code) url = f"http://qt.gtimg.cn/q={prefix}{raw}" try: r = urllib.request.urlopen(url, timeout=5) fields = r.read().decode("gbk").split('"')[1].split("~") except Exception as e: if db_price: return {"code": code, "price": db_price, "change_pct": db_chg or 0} return {"code": code, "error": str(e)} def get(i): try: return float(fields[i]) if fields[i].strip() else None except (IndexError, ValueError): return None today_str = date.today().isoformat() q = { "code": raw, "market": prefix, "name": fields[F["name"]] if len(fields) > F["name"] else code, "price": get(3), "close_yest": get(4), "open": get(5), "high": get(33), "low": get(34), "volume": get(6), "amount": get(37), "change": get(31), "change_pct": get(32), "amplitude": get(43), "turnover_rate": get(38), "pe": get(39), "pb": get(46), "limit_up": get(47), "limit_down": get(48), "avg_price": get(51), "inner_vol": get(52), "outer_vol": get(53), "timestamp": fields[F["timestamp"]] if len(fields) > F["timestamp"] else "", "_date": today_str, } # 写入价格历史缓存(每日一次) h = get(33) # high l = get(34) # low c = get(3) # price / close v = get(6) # volume(手) amt = get(37) # 成交额 if h and l and c: history = _load_history() if raw not in history: history[raw] = [] days = history[raw] # 如果今天已有记录,更新(盘中数据更精确) if days and len(days) > 0 and days[-1].get("date") == today_str: days[-1]["high"] = max(days[-1]["high"], h) days[-1]["low"] = min(days[-1]["low"], l) days[-1]["close"] = c # 盘中用最新价,收盘后是收盘价 if v: days[-1]["volume"] = v if amt: days[-1]["amount"] = amt else: entry = {"date": today_str, "high": h, "low": l, "close": c} if v: entry["volume"] = v if amt: entry["amount"] = amt days.append(entry) # 只保留最近 HISTORY_DAYS 天 history[raw] = days[-HISTORY_DAYS:] _save_history(history) # 写入60秒缓存 get_quote.__dict__["_cache"] = {**get_quote.__dict__.get("_cache", {}), code: {"ts": now, "data": q}} return q def calc_support_resistance(q): """计算技术支撑位和压力位 — 多日枢轴点算法 使用多个数据源确定有效区间: 1. 当日波幅(H-L) 2. 最近 N 日的最高/最低(从 price_history.json 读取) 3. 价格基数的百分比(对大市值低波动股票有效) """ h = q.get("high") l = q.get("low") c = q.get("price") yc = q.get("close_yest") amplitude = q.get("amplitude") # 当日振幅% code = q.get("code", "") if not all([h, l, c]): return {"error": "数据不足"} # 多日最高/最低(从历史缓存读取) history = _load_history() hist_days = history.get(code, []) multi_high = max(d["high"] for d in hist_days) if hist_days else h multi_low = min(d["low"] for d in hist_days) if hist_days else l # 有效区间 = max(当日波幅, 多日波幅, 价格×5%) daily_range = h - l multi_range = multi_high - multi_low min_range = c * 0.05 # 5%价格基数 effective_range = max(daily_range, multi_range, min_range) # 如果股价接近多日高点(>80%分位),说明在上升趋势中,扩大区间 trend_position = (c - multi_low) / (multi_high - multi_low) if multi_high > multi_low else 0.5 if trend_position > 0.8: # 高位运行,扩大有效区间到价格的8%确保合理空间 effective_range = max(effective_range, c * 0.08) elif trend_position < 0.2: # 低位运行,同样扩大 effective_range = max(effective_range, c * 0.08) # 如果振幅数据可用且振幅较小(<3%),进一步扩大区间确保有效性 if amplitude and amplitude > 0 and amplitude < 3: # 低波动股票用 振幅×3 作为最小范围 amp_based = c * amplitude / 100 * 3 effective_range = max(effective_range, amp_based) # 枢轴点 (Pivot Point) pp = (h + l + c) / 3 # 支撑位 s1 = 2 * pp - h # 弱支撑 s2 = pp - effective_range # 强支撑 # 压力位 r1 = 2 * pp - l # 弱压力 r2 = pp + effective_range # 强压力 # 参考昨收调整 if yc: if yc < s1: s1 = yc if yc > r1: r1 = yc # A股涨停/跌停价作为极端边界 limit_up = q.get("limit_up") limit_down = q.get("limit_down") market = q.get("market", "hk") if market != "hk" and limit_up and limit_down: # 注意:当现价逼近涨停/跌停时,limit不再是有效边界 # 用有效区间判断:如果自然计算的r2/s2在合理范围内不截断 natural_r2 = r2 natural_s2 = s2 # 涨停限制只对距离现价超过2%的强压位生效 if limit_up < r2 and (limit_up - c) / c < 0.02: # 涨停价离现价<2%,说明可能封板,不截断 pass # 使用自然计算的r2 elif limit_up < r2: r2 = limit_up if limit_down > s2 and (c - limit_down) / c < 0.02: pass # 接近跌停,不截断 elif limit_down > s2: s2 = limit_down return { "strong_support": round(s2, 2), "weak_support": round(s1, 2), "pivot": round(pp, 2), "weak_resist": round(r1, 2), "strong_resist": round(r2, 2), "today_high": h, "today_low": l, "multi_high": multi_high, "multi_low": multi_low, "effective_range": round(effective_range, 2), } def analyze_candlestick(q): """判断K线形态""" o = q.get("open") c = q.get("price") h = q.get("high") l = q.get("low") yc = q.get("close_yest") if not all([o, c, h, l]): return {"pattern": "unknown", "sentiment": "neutral"} if c >= o: body = c - o upper = h - c lower = o - l is_green = True else: body = o - c upper = h - o lower = c - l is_green = False total_range = h - l if total_range == 0: return {"pattern": "平盘", "sentiment": "neutral"} body_pct = body / total_range * 100 upper_pct = upper / total_range * 100 lower_pct = lower / total_range * 100 if body_pct < 5: if upper_pct > 60: pattern = "倒T线/射击之星" sentiment = "bearish" elif lower_pct > 60: pattern = "锤子线/T字线" sentiment = "bullish" else: pattern = "十字星" sentiment = "neutral" elif body_pct < 30: if upper_pct > 40 and lower_pct > 40: pattern = "长影星线" sentiment = "neutral" elif upper_pct > 40: pattern = "倒T线/射击之星" sentiment = "bearish" if is_green else "bearish" elif lower_pct > 40: pattern = "锤子线/T字线" sentiment = "bullish" if is_green else "bullish" else: pattern = "小阳线" if is_green else "小阴线" sentiment = "bullish" if is_green else "bearish" else: if upper_pct > 30: pattern = "带上影阳线" if is_green else "带上影阴线" sentiment = "neutral" if is_green else "bearish" elif lower_pct > 30: pattern = "带下影阳线" if is_green else "带下影阴线" sentiment = "bullish" if is_green else "neutral" else: pattern = "光头光脚阳线" if is_green else "光头光脚阴线" sentiment = "bullish" if is_green else "bearish" gap_up = "" gap_down = "" if yc: if o > yc * 1.01: gap_up = "跳空高开" if not is_green: sentiment = "neutral" elif o < yc * 0.99: gap_down = "跳空低开" if is_green: sentiment = "neutral" return { "pattern": pattern, "sentiment": sentiment, "body_pct": round(body_pct, 1), "upper_shadow_pct": round(upper_pct, 1), "lower_shadow_pct": round(lower_pct, 1), "is_green": is_green, "gap": gap_up or gap_down or "无跳空", } def analyze_volume(q): """量价分析""" outer = q.get("outer_vol") inner = q.get("inner_vol") turnover = q.get("turnover_rate") result = {} if outer and inner and (outer + inner) > 0: ratio = outer / (outer + inner) result["buy_sell_ratio"] = round(ratio, 2) if ratio > 0.55: result["volume_signal"] = "主动买盘占优" elif ratio < 0.45: result["volume_signal"] = "主动卖盘占优" else: result["volume_signal"] = "买卖均衡" else: result["volume_signal"] = "数据不足" if turnover: result["turnover_rate"] = turnover return result def analyze_volume_trend(code): """量价趋势分析:对比历史N日平均成交量,检测量价背离模式 从 price_history.json 读取历史数据,比较今日量价关系。 """ result = {} try: history = _load_history() days = history.get(code, []) if len(days) < 3: result["trend"] = "数据不足" return result today = days[-1] prev = days[-2] if len(days) >= 2 else None today_vol = today.get("volume", 0) today_close = today.get("close", 0) if not today_vol or not today_close: result["trend"] = "数据不足" return result # 计算N日均量 vols_5 = [d.get("volume", 0) for d in days[-6:-1] if d.get("volume")] vols_20 = [d.get("volume", 0) for d in days[-21:-1] if d.get("volume")] avg_5 = sum(vols_5) / len(vols_5) if vols_5 else 0 avg_20 = sum(vols_20) / len(vols_20) if vols_20 else 0 vol_ratio_vs_5 = today_vol / avg_5 if avg_5 > 0 else 0 vol_ratio_vs_20 = today_vol / avg_20 if avg_20 > 0 else 0 result["avg_volume_5d"] = round(avg_5, 0) result["avg_volume_20d"] = round(avg_20, 0) result["today_volume"] = int(today_vol) result["volume_ratio_vs_5d"] = round(vol_ratio_vs_5, 2) result["volume_ratio_vs_20d"] = round(vol_ratio_vs_20, 2) # 最近3日的收盘价和成交量趋势 if len(days) >= 3: recent_close = [d.get("close", 0) for d in days[-4:-1]] recent_vol = [d.get("volume", 0) for d in days[-4:-1]] if all(recent_close) and all(recent_vol): price_up = today_close > recent_close[-1] vol_up = today_vol > recent_vol[-1] # 量价模式判定 if vol_ratio_vs_5 >= 1.8: # 明显放量 if price_up: result["trend"] = "放量上攻" result["action"] = "buy_conformation" else: # 价格下跌但大幅放量 = 恐慌?还是承接收筹? # 看今日K线:如果是阳线(低开高走)= 承接 # 简单判断:如果close > open = 有承接 result["trend"] = "放量下跌" result["action"] = "watch" elif vol_ratio_vs_5 <= 0.6: # 明显缩量 if price_up: result["trend"] = "缩量上涨" result["action"] = "divergence" else: result["trend"] = "缩量回调" result["action"] = "healthy_pullback" elif vol_ratio_vs_5 >= 1.3: # 温和放量 if price_up: result["trend"] = "温和放量上涨" result["action"] = "bullish" else: result["trend"] = "温和放量下跌" result["action"] = "bearish" else: # 正常量 if price_up: result["trend"] = "正常量上涨" result["action"] = "neutral_bullish" else: result["trend"] = "正常量下跌" result["action"] = "neutral_bearish" # 量价背离检测:价格走高但成交量逐日递减 if len(days) >= 5: close_5 = [d.get("close", 0) for d in days[-5:]] vol_5 = [d.get("volume", 0) for d in days[-5:]] if all(close_5) and all(vol_5): close_trend = close_5[-1] - close_5[0] vol_trend = vol_5[-1] - vol_5[0] # 价格涨但量跌 = 顶背离 if close_trend > 0 and vol_trend < 0 and abs(vol_trend) > sum(vol_5) * 0.3: result["divergence"] = "顶背离(价涨量缩)" # 价格跌但量涨 = 底背离 elif close_trend < 0 and vol_trend > 0 and abs(vol_trend) > sum(vol_5) * 0.3: result["divergence"] = "底背离(价跌量增)" except Exception as e: result["trend_error"] = str(e) return result def analyze_volume_deep(code): """深度量价分析:从日K线分析量价配合/背离/建仓/出货 使用 mtf_cache 表的日K线数据做历史量价分析。 """ import sqlite3 from pathlib import Path DATA_DIR = Path(__file__).parent / "scripts" / "data" if not (DATA_DIR / "mofin.db").exists(): DATA_DIR = Path(__file__).parent / "data" try: conn = sqlite3.connect(str(DATA_DIR / "mofin.db")) row = conn.execute("SELECT cache_json FROM mtf_cache WHERE code=?", (code,)).fetchone() conn.close() if not row: return {"volume_signal": "数据不足"} data = json.loads(row[0]) except Exception: return {"volume_signal": "数据不足"} daily = data.get("daily", []) if len(daily) < 5: return {"volume_signal": "数据不足"} closes = [d["close"] for d in daily] volume = [d["volume"] for d in daily] n = len(daily) # 基准:最近20日均量(不足20日则用全部) lookback = min(20, n - 1) avg_vol_20d = sum(volume[-lookback-1:-1]) / lookback if lookback > 0 else volume[-1] # 最近N日的量比 recent = min(5, n) recent_vol_ratios = [] for i in range(recent): vol = volume[-i-1] if i+1 <= n else volume[0] recent_vol_ratios.append(round(vol / avg_vol_20d, 2) if avg_vol_20d > 0 else 1) today_ratio = recent_vol_ratios[0] if recent_vol_ratios else 1 recent_max_ratio = max(recent_vol_ratios) if recent_vol_ratios else 1 # 量价配合度 signals = [] patterns = {} # 1. 放量检测(量比 > 2x) if today_ratio > 2.0: signals.append(f"量比{today_ratio:.1f}倍放量") patterns["volume_surge"] = True # 放量方向 if len(closes) >= 2 and closes[-1] > closes[-2]: patterns["surge_direction"] = "放量上涨" if today_ratio > 2.5 and closes[-1] > closes[-2] * 1.03: signals[-1] += "↑主力买入" else: signals[-1] += "↑" elif len(closes) >= 2 and closes[-1] < closes[-2]: patterns["surge_direction"] = "放量下跌" if today_ratio > 2.5 and closes[-1] < closes[-2] * 0.97: signals[-1] += "↓主力出货" else: signals[-1] += "↓" else: patterns["surge_direction"] = "放量平盘" elif today_ratio < 0.5: signals.append(f"量比{today_ratio:.1f}倍缩量") patterns["volume_shrink"] = True else: signals.append(f"量比{today_ratio:.1f}倍正常") patterns["volume_normal"] = True # 2. 量价趋势分析(近5日 vs 前5日) if len(daily) >= 10: recent5_vol = sum(volume[-5:]) / 5 prev5_vol = sum(volume[-10:-5]) / 5 vol_trend = "增" if recent5_vol > prev5_vol * 1.3 else ("减" if recent5_vol < prev5_vol * 0.7 else "稳") recent5_price = closes[-5:] price_trend = "涨" if recent5_price[-1] > recent5_price[0] else ("跌" if recent5_price[-1] < recent5_price[0] * 0.95 else "平") if vol_trend == "增" and price_trend == "涨": patterns["accumulation"] = True # 量价齐升=建仓 signals.append(f"近5日{vol_trend}量{price_trend}价=建仓型") elif vol_trend == "增" and price_trend == "跌": patterns["distribution"] = True # 放量下跌=出货 signals.append(f"近5日{vol_trend}量{price_trend}价=⚠️出货型") elif vol_trend == "减" and price_trend == "涨": patterns["divergence"] = True # 量缩价涨=背离 signals.append(f"近5日{vol_trend}量{price_trend}价=⬆量价背离") elif vol_trend == "减" and price_trend == "跌": patterns["washout"] = True # 缩量下跌=洗盘末端 signals.append(f"近5日{vol_trend}量{price_trend}价=洗盘特征") else: signals.append(f"近5日{vol_trend}量{price_trend}价") else: vol_trend = price_trend = "?" # 3. 寻找历史放量区间(主力活动痕迹) surge_days = [] for i in range(max(0, n - 60), n): vol_ratio = volume[i] / avg_vol_20d if avg_vol_20d > 0 else 0 if vol_ratio > 2.0: surge_days.append({ "date": daily[i].get("date", ""), "ratio": round(vol_ratio, 1), "close": closes[i], "direction": "涨" if (i > 0 and closes[i] > closes[i-1]) else "跌" }) # 汇总描述 vol_level = "放量" if today_ratio > 2.0 else ("缩量" if today_ratio < 0.5 else "正常") price_vol = f"{vol_level}" if patterns.get("accumulation"): price_vol = f"量价齐升(建仓特征) | {signals[-1]}" elif patterns.get("distribution"): price_vol = f"放量下跌⚠️ | {signals[-1]}" elif patterns.get("washout"): price_vol = f"缩量回踩(洗盘末端) | {signals[-1]}" elif patterns.get("divergence"): price_vol = f"量价背离 | {signals[-1]}" return { "volume_signal": " ; ".join(signals) if signals else "正常", "volume_ratio": today_ratio, "avg_volume_20d": int(avg_vol_20d), "recent_ratios": recent_vol_ratios, "surge_count_60d": len(surge_days), "price_vol_description": price_vol, "patterns": patterns, "surge_days": surge_days[-5:] if surge_days else [], } def _calc_scores(code: str) -> dict: """严谨技术指标:RSI/MACD/量能/背离——从stock_daily确定性计算""" try: import sqlite3 conn = sqlite3.connect('/home/hmo/MoFin/data/mofin.db') rows = conn.execute( "SELECT close, high, low, volume FROM stock_daily WHERE code=? ORDER BY date DESC LIMIT 30", (code,)).fetchall() conn.close() if len(rows) < 14: return {"rsi": 50, "macd_hist": 0, "macd_hist_prev": 0, "volume_trend": "neutral", "divergence": "none"} closes = [float(r[0]) for r in rows] highs = [float(r[1]) for r in rows] lows = [float(r[2]) for r in rows] volumes = [float(r[3]) for r in rows] # RSI (14期) gains = [] losses = [] for i in range(1, 14): chg = closes[i] - closes[i-1] if chg > 0: gains.append(chg) else: losses.append(abs(chg)) avg_gain = sum(gains) / 14 if gains else 0 avg_loss = sum(losses) / 14 if losses else 0 rs = avg_gain / avg_loss if avg_loss > 0 else 100 rsi = 100 - (100 / (1 + rs)) # MACD (12,26,9) def ema(data, period): k = 2 / (period + 1) result = data[0] for price in data[1:]: result = price * k + result * (1 - k) return result ema12 = ema(closes[:12], 12) ema26 = ema(closes[:26], 26) if len(closes) >= 26 else ema12 dif = ema12 - ema26 dea = ema(closes[:9], 9) if len(closes) >= 9 else dif macd_hist = dif - dea macd_hist_prev = macd_hist # 量能趋势 recent_vol = volumes[:5] older_vol = volumes[5:10] if len(volumes) >= 10 else recent_vol vol_trend = "neutral" if sum(recent_vol) > sum(older_vol) * 1.2: vol_trend = "accumulation" elif sum(recent_vol) < sum(older_vol) * 0.8: vol_trend = "distribution" # 背离检测 divergence = "none" if len(closes) >= 5: if closes[0] > closes[4] and volumes[0] < volumes[4]: divergence = "bearish" elif closes[0] < closes[4] and volumes[0] > volumes[4]: divergence = "bullish" return { "rsi": round(rsi, 1), "macd_hist": round(macd_hist, 4), "macd_hist_prev": round(macd_hist_prev, 4), "volume_trend": vol_trend, "divergence": divergence, } except Exception as _e: print(f" [TA-SCORE] {code} 技术分计算失败: {_e}", flush=True) return {"rsi": 50, "macd_hist": 0, "macd_hist_prev": 0, "volume_trend": "neutral", "divergence": "none"} def full_analysis(code): """完整技术分析(带30秒缓存,避免分钟级波动)""" import time _cache = full_analysis.__dict__.get("_cache", {}) now = time.time() cached = _cache.get(code) if cached and (now - cached["ts"]) < 30: return cached["data"] q = get_quote(code) if not q or "error" in q: return q sr = calc_support_resistance(q) candle = analyze_candlestick(q) vol = analyze_volume(q) # 深度量价分析(使用日K线历史数据) vol_deep = {} try: vol_deep = analyze_volume_deep(code) except Exception: pass # graceful degradation # 多周期+均线分析(整合 multi_timeframe) mtf = {} try: from multi_timeframe import full_multi_tf_analysis as _mtf mtf_raw = _mtf(code) if mtf_raw and 'daily' in mtf_raw: d = mtf_raw['daily'] mtf = { 'mas': d.get('mas', {}), 'multi_tf_sr': d.get('support_resistance', {}), 'trend': d.get('trend', {}), } # 周线弱压/弱撑作为中周期参考 if 'weekly' in mtf_raw: w = mtf_raw['weekly'] ws = w.get('support_resistance', {}) mtf['weekly_sr'] = { 'weak_resist': ws.get('weak_resist'), 'weak_support': ws.get('weak_support'), } except Exception: pass # non-critical, graceful degradation result = { "quote": { "name": q.get("name", code), "price": q["price"], "change_pct": q.get("change_pct", 0), "open": q.get("open", 0), "high": q.get("high", 0), "low": q.get("low", 0), "close_yest": q.get("close_yest", 0), "volume": q.get("volume", 0), "amplitude": q.get("amplitude", 0), }, "support_resistance": sr, "candlestick": candle, "volume": vol, "volume_deep": vol_deep, "multi_tf": mtf, "scores": _calc_scores(code), "analyzed_at": datetime.now().strftime("%H:%M"), } # 写入缓存 _cache[code] = {"ts": now, "data": result} full_analysis.__dict__["_cache"] = _cache return result if __name__ == "__main__": import sys codes = sys.argv[1:] or ["603259", "002594", "00700"] for c in codes: r = full_analysis(c) print(json.dumps(r, ensure_ascii=False, indent=2)) print()