MoFin 初始提交

完整数据采集+分析管道:
- market_watch.py:90行业板块采集(同花顺/东方财富)
- 市场精选推荐 cron:全市场分析+候选池+星级推荐
- price_monitor.py:持仓/自选高频价格监控
- refresh_mtf_cache.py:多周期K线缓存
- 策略评估/知识萃取管道

文档:docs/ 含完整需求+架构设计
注意:尚未配置 git remote,笑笑接手后自行配置
This commit is contained in:
知微 (MoFin)
2026-06-20 12:04:21 +08:00
commit aa0f740381
950 changed files with 189006 additions and 0 deletions
+598
View File
@@ -0,0 +1,598 @@
#!/usr/bin/env python3
"""multi_timeframe.py — 多周期技术分析模块
从腾讯API获取日/周/月K线数据,计算:
- 多周期支撑压力位(日线/周线/月线)
- 移动均线(MA5/10/20/60
- 趋势方向判断(上升/下降/震荡)
- 综合策略调整建议
集成到 strategy_lifecycle.py 中使用。
"""
import json
import os
import urllib.request
import urllib.error
from datetime import datetime, date, timedelta
from typing import Optional
DATA_DIR = "/home/hmo/web-dashboard/data"
HISTORY_PATH = os.path.join(DATA_DIR, "price_history.json")
MTF_CACHE_PATH = os.path.join(DATA_DIR, "multi_tf_cache.json") # 多周期缓存独立存储
# 腾讯API K线端点
KLINE_URL = "http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={market}{code},{period},,,{count},qfq"
# 腾讯实时行情端点(用于市场前缀判断)
QUOTE_URL = "http://qt.gtimg.cn/q={market}{code}"
def _market_prefix(code: str) -> str:
"""根据代码确定市场前缀"""
raw = str(code).split("_")[0]
if len(raw) == 5 and raw.isdigit():
return "hk"
if raw.startswith("6") or raw.startswith("5"):
return "sh"
return "sz"
def _user_agent() -> dict:
return {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
}
# 多周期缓存TTL(秒):日K线1小时,周/月K线1天
_KLINE_CACHE_TTL = {"day": 3600, "week": 86400, "month": 86400}
# 模块级缓存:避免每次fetch_kline都重新读/写大文件
_MTF_CACHE_DATA = None # {code: {daily:[], weekly:[], monthly:[], updated_at: float, fundamentals:{}}}
_MTF_CACHE_MTIME = 0 # 文件最后修改时间
def _load_mtf_cache():
"""加载多周期缓存(带模块级缓存,避免频繁读盘)"""
global _MTF_CACHE_DATA, _MTF_CACHE_MTIME
import time
try:
current_mtime = os.path.getmtime(MTF_CACHE_PATH)
if _MTF_CACHE_DATA is not None and current_mtime == _MTF_CACHE_MTIME:
return _MTF_CACHE_DATA
with open(MTF_CACHE_PATH) as f:
_MTF_CACHE_DATA = json.load(f)
_MTF_CACHE_MTIME = current_mtime
return _MTF_CACHE_DATA
except (FileNotFoundError, json.JSONDecodeError, OSError):
_MTF_CACHE_DATA = {}
_MTF_CACHE_MTIME = 0
return {}
def _save_mtf_cache():
"""将模块级缓存写回磁盘"""
global _MTF_CACHE_DATA, _MTF_CACHE_MTIME
if _MTF_CACHE_DATA is None:
return
try:
os.makedirs(os.path.dirname(MTF_CACHE_PATH), exist_ok=True)
with open(MTF_CACHE_PATH, "w") as f:
json.dump(_MTF_CACHE_DATA, f, ensure_ascii=False, indent=2)
import time
_MTF_CACHE_MTIME = os.path.getmtime(MTF_CACHE_PATH) if os.path.exists(MTF_CACHE_PATH) else time.time()
except Exception:
pass
def fetch_kline(code: str, period: str = "day", count: int = 120) -> list:
"""从腾讯API获取K线数据,优先使用本地缓存
Args:
code: 股票代码 (如 "300548")
period: "day" / "week" / "month"
count: 需要多少条
Returns:
list of dict: [{"date":str, "open":float, "close":float,
"high":float, "low":float, "volume":float}, ...]
"""
import time
now = time.time()
# 优先检查本地缓存(模块级,避免重复读盘)
# 注意:缓存中存储的key是'daily'/'weekly'/'monthly',参数period是'day'/'week'/'month'
_PERIOD_MAP = {"day": "daily", "week": "weekly", "month": "monthly"}
cache_data = _load_mtf_cache()
cached = cache_data.get(code, {})
cache_key = _PERIOD_MAP.get(period, period)
cached_klines = cached.get(cache_key, cached.get(period, []))
updated_at = cached.get("updated_at", 0)
if cached_klines and updated_at and (now - updated_at) < _KLINE_CACHE_TTL.get(period, 3600):
return cached_klines
market = _market_prefix(code)
url = KLINE_URL.format(market=market, code=code,
period=period, count=count)
try:
req = urllib.request.Request(url, headers=_user_agent())
with urllib.request.urlopen(req, timeout=10) as resp:
raw = json.loads(resp.read().decode("utf-8"))
except Exception as e:
return {"error": str(e), "code": code, "period": period}
if not isinstance(raw, dict):
return {"error": f"API returned {type(raw).__name__}", "raw": str(raw)[:200]}
api_data = raw.get("data", {})
if not isinstance(api_data, dict):
return {"error": f"data field is {type(api_data).__name__}", "raw": str(api_data)[:200]}
stock_key = f"{market}{code}"
stock_data = api_data.get(stock_key, {})
# 腾讯API的K线字段名: qfqday, qfqweek, qfqmonth
period_key = f"qfq{period}"
klines = stock_data.get(period_key, [])
if not klines:
# 尝试其他字段名
for k in stock_data:
if isinstance(stock_data[k], list) and len(stock_data[k]) > 0:
if isinstance(stock_data[k][0], list) and len(stock_data[k][0]) >= 6:
klines = stock_data[k]
break
result = []
for k in klines:
if len(k) >= 6:
try:
result.append({
"date": str(k[0]),
"open": float(k[1]),
"close": float(k[2]),
"high": float(k[3]),
"low": float(k[4]),
"volume": float(k[5]),
})
except (ValueError, IndexError):
continue
return result
def calc_moving_averages(klines: list, windows: list = [5, 10, 20, 60]) -> dict:
"""计算移动均线
Args:
klines: K线数据(按时间正序或倒序均可,自动处理)
windows: 均线周期列表
Returns:
dict: {ma5: float|None, ma10: float|None, ...}
"""
if not klines:
return {f"ma{w}": None for w in windows}
# 确保按时间正序(旧的在前)
closes = [k["close"] for k in klines]
# 检查是否倒序(最新的在前)
if len(closes) >= 2 and closes[0] > closes[-1]:
closes = list(reversed(closes))
result = {}
for w in windows:
if len(closes) >= w:
result[f"ma{w}"] = round(sum(closes[-w:]) / w, 2)
else:
result[f"ma{w}"] = None
return result
def calc_multi_tf_support_resistance(klines: list, lookback: int = 0) -> dict:
"""基于K线数据计算多周期支撑压力位
使用近期高点和低点作为关键位:
- 强阻力 = 近期最高(或倒数第二高)
- 弱阻力 = 近期中枢上沿
- 弱支撑 = 近期中枢下沿
- 强支撑 = 近期最低(或倒数第二低)
Args:
klines: K线数据
lookback: 取最近多少条(0=全部)
Returns:
dict: {strong_resist, weak_resist, weak_support, strong_support,
high_52w, low_52w, range_pct}
"""
if not klines or len(klines) < 3:
return {}
# 取最近N条(日线看近期,周线/月线看全部)
if lookback <= 0:
lookback = min(len(klines), 20) # 日线默认20天
n = min(len(klines), lookback)
recent = klines[-n:]
# 全量数据(用于52周高低)
all_highs = [k["high"] for k in klines]
all_lows = [k["low"] for k in klines]
highs = [k["high"] for k in recent]
lows = [k["low"] for k in recent]
max_h = max(highs)
min_l = min(lows)
mid = (max_h + min_l) / 2
# 找第二高和第二低作为更稳健的边界
sorted_h = sorted(set(highs), reverse=True)
sorted_l = sorted(set(lows))
strong_resist = sorted_h[0] if sorted_h else max_h
strong_support = sorted_l[0] if sorted_l else min_l
weak_resist = sorted_h[1] if len(sorted_h) > 1 else (max_h + mid) / 2
weak_support = sorted_l[1] if len(sorted_l) > 1 else (min_l + mid) / 2
# 最近20日的振幅比例(判断波动率)
if len(closes := [k["close"] for k in recent]) >= 2:
recent_range = (max_h - min_l) / min_l * 100 if min_l > 0 else 0
else:
recent_range = 0
return {
"strong_resist": round(strong_resist, 2),
"weak_resist": round(weak_resist, 2),
"weak_support": round(weak_support, 2),
"strong_support": round(strong_support, 2),
"high_52w": round(max(all_highs), 2),
"low_52w": round(min(all_lows), 2),
"range_pct": round(recent_range, 1),
}
def assess_trend(klines: list) -> dict:
"""判断趋势方向
Args:
klines: K线数据
Returns:
dict: {trend (up/down/sideways), strength (0~1),
description, ma_trend}
"""
if not klines or len(klines) < 10:
return {"trend": "unknown", "strength": 0, "description": "数据不足"}
closes = [k["close"] for k in klines]
# 确保正序
if len(closes) >= 2 and closes[0] > closes[-1]:
closes = list(reversed(closes))
n = len(closes)
ma20 = sum(closes[-20:]) / 20 if n >= 20 else sum(closes) / n
ma60 = sum(closes[-60:]) / 60 if n >= 60 else None
current = closes[-1]
# 均线多头/空头排列判断
ma5 = sum(closes[-5:]) / 5 if n >= 5 else None
ma10 = sum(closes[-10:]) / 10 if n >= 10 else None
# 趋势判断
up_count = sum(1 for i in range(1, len(closes)) if closes[i] > closes[i-1])
up_ratio = up_count / (len(closes) - 1)
# 价格相对均线位置
above_ma20 = current > ma20 if ma20 else True
if up_ratio > 0.6 and above_ma20:
if ma60 and current > ma60 * 1.2:
trend = "strong_up"
strength = min(1.0, up_ratio + 0.2)
desc = "强势上升"
else:
trend = "up"
strength = up_ratio
desc = "震荡上升"
elif up_ratio < 0.4 and not above_ma20:
if ma60 and current < ma60 * 0.8:
trend = "strong_down"
strength = min(1.0, (1 - up_ratio) + 0.2)
desc = "强势下跌"
else:
trend = "down"
strength = 1 - up_ratio
desc = "震荡下跌"
else:
trend = "sideways"
strength = 0.3
desc = "横盘震荡"
# 均线排列
ma_trend = "unknown"
if ma5 and ma10 and ma20:
if ma5 > ma10 > ma20:
ma_trend = "多头排列"
elif ma5 < ma10 < ma20:
ma_trend = "空头排列"
else:
ma_trend = "粘合/交叉"
return {
"trend": trend,
"strength": round(strength, 2),
"description": desc,
"ma_trend": ma_trend,
"ma5": round(ma5, 2) if ma5 else None,
"ma10": round(ma10, 2) if ma10 else None,
"ma20": round(ma20, 2),
"ma60": round(ma60, 2) if ma60 else None,
"current_above_ma20": current > ma20 if ma20 else None,
}
def full_multi_tf_analysis(code: str) -> dict:
"""完整多周期分析入口
同时获取日/周/月K线,计算:
- 各周期支撑压力位
- 均线系统
- 趋势方向
- 综合策略建议
Args:
code: 股票代码 (如 "300548")
Returns:
dict: 完整分析结果
"""
# 获取三个周期的数据
daily = fetch_kline(code, "day", 120)
weekly = fetch_kline(code, "week", 24)
monthly = fetch_kline(code, "month", 12)
# 如果API失败,检查是否有本地缓存
if isinstance(daily, dict) and "error" in daily:
daily = _load_local_history(code, "daily")
if isinstance(weekly, dict) and "error" in weekly:
weekly = _load_local_history(code, "weekly")
if isinstance(monthly, dict) and "error" in monthly:
monthly = _load_local_history(code, "monthly")
result = {
"code": code,
"analyzed_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
}
# 日线分析
if daily and not (isinstance(daily, dict) and "error" in daily):
result["daily"] = {
"count": len(daily),
"latest": daily[-1] if daily else None,
"support_resistance": calc_multi_tf_support_resistance(daily, lookback=20),
"mas": calc_moving_averages(daily, [5, 10, 20, 60]),
"trend": assess_trend(daily),
}
# 周线分析
if weekly and not (isinstance(weekly, dict) and "error" in weekly):
result["weekly"] = {
"count": len(weekly),
"latest": weekly[-1] if weekly else None,
"support_resistance": calc_multi_tf_support_resistance(weekly, lookback=12),
"mas": calc_moving_averages(weekly, [5, 10]),
"trend": assess_trend(weekly),
}
# 月线分析
if monthly and not (isinstance(monthly, dict) and "error" in monthly):
result["monthly"] = {
"count": len(monthly),
"latest": monthly[-1] if monthly else None,
"support_resistance": calc_multi_tf_support_resistance(monthly, lookback=6),
"mas": calc_moving_averages(monthly, [5]),
"trend": assess_trend(monthly),
}
# 综合策略建议
result["strategy_adjustment"] = _generate_strategy_adjustment(result)
# 写入本地缓存(供离线使用)
_save_local_history(code, daily, weekly, monthly)
return result
def flush_mtf_cache():
"""将模块级缓存显式刷回磁盘(供批量处理后调用)"""
_save_mtf_cache()
def _generate_strategy_adjustment(analysis: dict) -> dict:
"""基于多周期分析生成策略调整建议"""
adj = {
"stop_loss_reference": None,
"take_profit_reference": None,
"trend_alignment": "unknown",
"multi_tf_summary": {},
"cautions": [],
}
daily_trend = analysis.get("daily", {}).get("trend", {})
weekly_trend = analysis.get("weekly", {}).get("trend", {})
monthly_trend = analysis.get("monthly", {}).get("trend", {})
# 均线数据
daily_mas = analysis.get("daily", {}).get("mas", {})
daily_sr = analysis.get("daily", {}).get("support_resistance", {})
weekly_sr = analysis.get("weekly", {}).get("support_resistance", {})
monthly_sr = analysis.get("monthly", {}).get("support_resistance", {})
current = analysis.get("daily", {}).get("latest", {}).get("close", 0)
# 多周期趋势一致性
up_tfs, down_tfs = 0, 0
tf_details = []
for tf_name, tf_data in [("daily", daily_trend), ("weekly", weekly_trend),
("monthly", monthly_trend)]:
t = tf_data.get("trend", "unknown")
desc = tf_data.get("description", "")
ma_t = tf_data.get("ma_trend", "")
tf_details.append(f"{tf_name}:{desc}({ma_t})")
if "up" in t or "strong_up" in t:
up_tfs += 1
elif "down" in t or "strong_down" in t:
down_tfs += 1
adj["multi_tf_summary"] = {
"daily_trend": daily_trend.get("description", "未知"),
"weekly_trend": weekly_trend.get("description", "未知"),
"monthly_trend": monthly_trend.get("description", "未知"),
"daily_ma_trend": daily_trend.get("ma_trend", "未知"),
}
if up_tfs >= 2:
adj["trend_alignment"] = "多周期看多"
elif down_tfs >= 2:
adj["trend_alignment"] = "多周期看空"
elif up_tfs >= 1 and down_tfs >= 1:
adj["trend_alignment"] = "多周期分化"
else:
adj["trend_alignment"] = "震荡/无明显方向"
if not current:
return adj
# ===== 参考止损位(三级递进)=====
# 第一级:MA20(短线交易的生命线)
ma20 = daily_mas.get("ma20")
# 第二级:日线弱支撑(近20天次低点)
daily_ws = daily_sr.get("weak_support")
# 第三级:日线强支撑 / MA60
ma60 = daily_mas.get("ma60")
daily_ss = daily_sr.get("strong_support")
stop_candidates = []
if ma20:
stop_candidates.append(("MA20", ma20, abs(current - ma20) / current * 100))
if daily_ws:
stop_candidates.append(("日弱支撑", daily_ws, abs(current - daily_ws) / current * 100))
if ma60:
stop_candidates.append(("MA60", ma60, abs(current - ma60) / current * 100))
if daily_ss:
stop_candidates.append(("日强支撑", daily_ss, abs(current - daily_ss) / current * 100))
if stop_candidates:
# 选一个合理的止损参考:MA20优先(如果距现价不太近),否则选日弱支撑
best_stop = None
for name, level, dist in stop_candidates:
if level < current: # 止损必须在现价下方
if 2 <= dist <= 15: # 距现价2~15%之间比较合理
best_stop = {"source": name, "level": level,
"distance_pct": round(dist, 2)}
break
if not best_stop:
# 没有2~15%内的,选最近的一个
below = [(n, l, d) for n, l, d in stop_candidates if l < current]
if below:
nearest = min(below, key=lambda x: x[2])
best_stop = {"source": nearest[0], "level": nearest[1],
"distance_pct": round(nearest[2], 2)}
if best_stop:
adj["stop_loss_reference"] = best_stop
# ===== 参考止盈位 =====
take_candidates = []
# 日线阻力
for name, level in [("日弱阻", daily_sr.get("weak_resist")),
("日强阻", daily_sr.get("strong_resist")),
("周强阻", weekly_sr.get("strong_resist")),
("月强阻", monthly_sr.get("strong_resist"))]:
if level and level > current:
dist = (level - current) / current * 100
take_candidates.append((name, level, dist))
if take_candidates:
# 选距现价5~30%内的最高阻力位
best_take = None
for name, level, dist in sorted(take_candidates, key=lambda x: x[1], reverse=True):
if 3 <= dist <= 40:
best_take = {"source": name, "level": level,
"distance_pct": round(dist, 2)}
break
if not best_take:
farthest = max(take_candidates, key=lambda x: x[2])
best_take = {"source": farthest[0], "level": farthest[1],
"distance_pct": round(farthest[2], 2)}
if best_take:
adj["take_profit_reference"] = best_take
# ===== 风险提示 =====
if ma20 and current < ma20:
adj["cautions"].append(f"价格{current}<MA20{ma20},短线转弱")
if ma60 and current < ma60 * 1.05:
if current < ma60:
adj["cautions"].append(f"价格{current}<MA60{ma60},中期趋势转弱")
else:
adj["cautions"].append(f"价格接近MA60{ma60},中期支撑面临考验")
return adj
def _load_local_history(code: str, period: str) -> list:
"""从本地多周期缓存读取历史数据,不修改 price_history.json"""
try:
with open(MTF_CACHE_PATH) as f:
data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return []
stock = data.get(code, {})
return stock.get(period, [])
def _save_local_history(code: str, daily: list, weekly: list, monthly: list):
"""将多周期数据写入模块级缓存(含时间戳),不直接写磁盘"""
import time
global _MTF_CACHE_DATA
cache_data = _load_mtf_cache()
stock = cache_data.get(code, {})
if daily and not (isinstance(daily, dict) and "error" in daily):
stock["daily"] = daily
if weekly and not (isinstance(weekly, dict) and "error" in weekly):
stock["weekly"] = weekly
if monthly and not (isinstance(monthly, dict) and "error" in monthly):
stock["monthly"] = monthly
stock["updated_at"] = time.time() # 缓存时间戳
cache_data[code] = stock
_MTF_CACHE_DATA = cache_data # 更新模块级缓存
def batch_update_all(codes: list):
"""批量更新多只股票的多周期数据"""
results = {}
for code in codes:
try:
r = full_multi_tf_analysis(code)
results[code] = {
"status": "ok",
"periods": [k for k in ["daily", "weekly", "monthly"]
if k in r]
}
except Exception as e:
results[code] = {"status": "error", "error": str(e)}
return results
if __name__ == "__main__":
import sys
codes = sys.argv[1:] or ["300548", "600110"]
for code in codes:
r = full_multi_tf_analysis(code)
print(json.dumps(r, ensure_ascii=False, indent=2))
print("-" * 60)