Files
stockbuddy/scripts/analyze_stock.py

755 lines
25 KiB
Python
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
"""
港股分析脚本 - 使用腾讯财经数据源进行技术面+基本面分析
用法:
python3 analyze_stock.py <股票代码> [--period <周期>] [--output <输出文件>]
示例:
python3 analyze_stock.py 0700.HK
python3 analyze_stock.py 0700.HK --period 6mo --output report.json
python3 analyze_stock.py 9988.HK --period 1y
"""
import sys
import json
import argparse
import time
import hashlib
import urllib.request
import urllib.error
from datetime import datetime, timedelta
from pathlib import Path
try:
import numpy as np
except ImportError:
print("ERROR: numpy 未安装。请运行: pip3 install numpy", file=sys.stderr)
sys.exit(1)
try:
import pandas as pd
except ImportError:
print("ERROR: pandas 未安装。请运行: pip3 install pandas", file=sys.stderr)
sys.exit(1)
# ─────────────────────────────────────────────
# 缓存与重试机制
# ─────────────────────────────────────────────
DATA_DIR = Path.home() / ".stockbuddy"
CACHE_DIR = DATA_DIR / "cache"
CACHE_TTL_SECONDS = 600 # 缓存有效期 10 分钟
LEGACY_CACHE_DIR = Path.home() / ".stock_buddy_cache"
MAX_RETRIES = 3
RETRY_BASE_DELAY = 2
def _cache_key(code: str, period: str) -> str:
"""生成缓存文件名"""
key = f"{code}_{period}"
return hashlib.md5(key.encode()).hexdigest() + ".json"
def _read_cache(code: str, period: str) -> dict | None:
"""读取缓存"""
cache_file = CACHE_DIR / _cache_key(code, period)
if not cache_file.exists():
legacy_cache_file = LEGACY_CACHE_DIR / _cache_key(code, period)
if legacy_cache_file.exists():
try:
DATA_DIR.mkdir(parents=True, exist_ok=True)
CACHE_DIR.mkdir(parents=True, exist_ok=True)
cache_file.write_text(
legacy_cache_file.read_text(encoding="utf-8"),
encoding="utf-8",
)
except OSError:
cache_file = legacy_cache_file
if not cache_file.exists():
return None
try:
with open(cache_file, "r", encoding="utf-8") as f:
cached = json.load(f)
cached_time = datetime.fromisoformat(cached.get("analysis_time", ""))
if (datetime.now() - cached_time).total_seconds() < CACHE_TTL_SECONDS:
cached["_from_cache"] = True
return cached
except (json.JSONDecodeError, ValueError, KeyError):
pass
return None
def _write_cache(code: str, period: str, data: dict):
"""写入缓存"""
DATA_DIR.mkdir(parents=True, exist_ok=True)
CACHE_DIR.mkdir(parents=True, exist_ok=True)
cache_file = CACHE_DIR / _cache_key(code, period)
try:
with open(cache_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2, default=str)
except OSError:
pass
# ─────────────────────────────────────────────
# 腾讯财经数据获取
# ─────────────────────────────────────────────
def normalize_hk_code(code: str) -> tuple[str, str]:
"""标准化港股代码,返回 (原始数字代码, 带.HK后缀代码)"""
code = code.strip().upper().replace(".HK", "")
digits = code.lstrip("0")
if digits.isdigit():
numeric_code = code.zfill(4)
return numeric_code, numeric_code + ".HK"
return code, code + ".HK"
def fetch_tencent_quote(code: str) -> dict:
"""获取腾讯财经实时行情"""
numeric_code, full_code = normalize_hk_code(code)
url = f"http://qt.gtimg.cn/q=hk{numeric_code}"
for attempt in range(MAX_RETRIES):
try:
req = urllib.request.Request(url, headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
})
with urllib.request.urlopen(req, timeout=10) as response:
data = response.read().decode("gb2312", errors="ignore")
return _parse_tencent_quote(data, numeric_code)
except urllib.error.URLError as e:
if attempt < MAX_RETRIES - 1:
time.sleep(RETRY_BASE_DELAY * (attempt + 1))
else:
raise Exception(f"获取实时行情失败: {e}")
return {}
def _parse_tencent_quote(data: str, code: str) -> dict:
"""解析腾讯财经实时行情响应"""
var_name = f"v_hk{code}"
for line in data.strip().split(";"):
line = line.strip()
if not line or var_name not in line:
continue
# 提取引号内的内容
parts = line.split('"')
if len(parts) < 2:
continue
values = parts[1].split("~")
if len(values) < 35: # 至少需要35个字段
continue
def safe_float(idx: int, default: float = 0.0) -> float:
try:
return float(values[idx]) if values[idx] else default
except (ValueError, IndexError):
return default
def safe_str(idx: int, default: str = "") -> str:
return values[idx] if idx < len(values) else default
# 字段映射 (根据腾讯财经API实际数据)
# 0:市场 1:名称 2:代码 3:现价 4:昨收 5:今开 6:成交量
# 30:时间戳 31:涨跌额 32:涨跌幅 33:最高 34:最低
# 39:市盈率 47:市净率 37:总市值 48:52周高 49:52周低
return {
"name": values[1],
"code": values[2],
"price": safe_float(3),
"prev_close": safe_float(4),
"open": safe_float(5),
"volume": safe_float(6),
"high": safe_float(33),
"low": safe_float(34),
"change_amount": safe_float(31),
"change_pct": safe_float(32),
"timestamp": safe_str(30),
"pe": safe_float(39) if len(values) > 39 else None,
"pb": safe_float(47) if len(values) > 47 else None,
"market_cap": safe_str(37),
"52w_high": safe_float(48) if len(values) > 48 else None,
"52w_low": safe_float(49) if len(values) > 49 else None,
}
return {}
def fetch_tencent_kline(code: str, days: int = 120) -> pd.DataFrame:
"""获取腾讯财经K线数据"""
numeric_code, full_code = normalize_hk_code(code)
url = f"https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=hk{numeric_code},day,,,{days},qfq"
for attempt in range(MAX_RETRIES):
try:
req = urllib.request.Request(url, headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
})
with urllib.request.urlopen(req, timeout=15) as response:
data = json.loads(response.read().decode("utf-8"))
return _parse_tencent_kline(data, numeric_code)
except (urllib.error.URLError, json.JSONDecodeError) as e:
if attempt < MAX_RETRIES - 1:
time.sleep(RETRY_BASE_DELAY * (attempt + 1))
else:
raise Exception(f"获取K线数据失败: {e}")
return pd.DataFrame()
def _parse_tencent_kline(data: dict, code: str) -> pd.DataFrame:
"""解析腾讯财经K线数据"""
key = f"hk{code}"
if data.get("code") != 0 or not data.get("data") or key not in data["data"]:
return pd.DataFrame()
day_data = data["data"][key].get("day", [])
if not day_data:
return pd.DataFrame()
# 格式: [日期, 开盘价, 收盘价, 最低价, 最高价, 成交量]
records = []
for item in day_data:
if len(item) >= 6:
records.append({
"Date": item[0],
"Open": float(item[1]),
"Close": float(item[2]),
"Low": float(item[3]),
"High": float(item[4]),
"Volume": float(item[5]),
})
df = pd.DataFrame(records)
if not df.empty:
df["Date"] = pd.to_datetime(df["Date"])
df.set_index("Date", inplace=True)
return df
def period_to_days(period: str) -> int:
"""将周期字符串转换为天数"""
mapping = {
"1mo": 30,
"3mo": 90,
"6mo": 180,
"1y": 250,
"2y": 500,
"5y": 1250,
}
return mapping.get(period, 180)
# ─────────────────────────────────────────────
# 技术指标计算 (保持不变)
# ─────────────────────────────────────────────
def calc_ma(close: pd.Series, windows: list[int] = None) -> dict:
"""计算多周期移动平均线"""
if windows is None:
windows = [5, 10, 20, 60, 120, 250]
result = {}
for w in windows:
if len(close) >= w:
ma = close.rolling(window=w).mean()
result[f"MA{w}"] = round(ma.iloc[-1], 3)
return result
def calc_ema(close: pd.Series, span: int) -> pd.Series:
"""计算指数移动平均线"""
return close.ewm(span=span, adjust=False).mean()
def calc_macd(close: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9) -> dict:
"""计算MACD指标"""
ema_fast = calc_ema(close, fast)
ema_slow = calc_ema(close, slow)
dif = ema_fast - ema_slow
dea = dif.ewm(span=signal, adjust=False).mean()
macd_hist = 2 * (dif - dea)
return {
"DIF": round(dif.iloc[-1], 4),
"DEA": round(dea.iloc[-1], 4),
"MACD": round(macd_hist.iloc[-1], 4),
"signal": _macd_signal(dif, dea, macd_hist),
}
def _macd_signal(dif: pd.Series, dea: pd.Series, macd_hist: pd.Series) -> str:
"""MACD信号判断"""
if len(dif) < 3:
return "中性"
if dif.iloc[-1] > dea.iloc[-1] and dif.iloc[-2] <= dea.iloc[-2]:
return "金叉-买入信号"
if dif.iloc[-1] < dea.iloc[-1] and dif.iloc[-2] >= dea.iloc[-2]:
return "死叉-卖出信号"
if dif.iloc[-1] > 0 and dea.iloc[-1] > 0:
if macd_hist.iloc[-1] > macd_hist.iloc[-2]:
return "多头增强"
return "多头区域"
if dif.iloc[-1] < 0 and dea.iloc[-1] < 0:
if macd_hist.iloc[-1] < macd_hist.iloc[-2]:
return "空头增强"
return "空头区域"
return "中性"
def calc_rsi(close: pd.Series, periods: list[int] = None) -> dict:
"""计算RSI指标"""
if periods is None:
periods = [6, 12, 24]
result = {}
delta = close.diff()
for p in periods:
if len(close) < p + 1:
continue
gain = delta.clip(lower=0).rolling(window=p).mean()
loss = (-delta.clip(upper=0)).rolling(window=p).mean()
rs = gain / loss.replace(0, np.nan)
rsi = 100 - (100 / (1 + rs))
val = round(rsi.iloc[-1], 2)
result[f"RSI{p}"] = val
rsi_main = result.get("RSI12", result.get("RSI6", 50))
if rsi_main > 80:
result["signal"] = "严重超买-卖出信号"
elif rsi_main > 70:
result["signal"] = "超买-注意风险"
elif rsi_main < 20:
result["signal"] = "严重超卖-买入信号"
elif rsi_main < 30:
result["signal"] = "超卖-关注买入"
else:
result["signal"] = "中性"
return result
def calc_kdj(high: pd.Series, low: pd.Series, close: pd.Series, n: int = 9) -> dict:
"""计算KDJ指标"""
if len(close) < n:
return {"K": 50, "D": 50, "J": 50, "signal": "数据不足"}
lowest_low = low.rolling(window=n).min()
highest_high = high.rolling(window=n).max()
rsv = (close - lowest_low) / (highest_high - lowest_low).replace(0, np.nan) * 100
k = pd.Series(index=close.index, dtype=float)
d = pd.Series(index=close.index, dtype=float)
k.iloc[n - 1] = 50
d.iloc[n - 1] = 50
for i in range(n, len(close)):
k.iloc[i] = 2 / 3 * k.iloc[i - 1] + 1 / 3 * rsv.iloc[i]
d.iloc[i] = 2 / 3 * d.iloc[i - 1] + 1 / 3 * k.iloc[i]
j = 3 * k - 2 * d
k_val = round(k.iloc[-1], 2)
d_val = round(d.iloc[-1], 2)
j_val = round(j.iloc[-1], 2)
signal = "中性"
if k_val > d_val and k.iloc[-2] <= d.iloc[-2]:
signal = "金叉-买入信号"
elif k_val < d_val and k.iloc[-2] >= d.iloc[-2]:
signal = "死叉-卖出信号"
elif j_val > 100:
signal = "超买区域"
elif j_val < 0:
signal = "超卖区域"
return {"K": k_val, "D": d_val, "J": j_val, "signal": signal}
def calc_bollinger(close: pd.Series, window: int = 20, num_std: float = 2) -> dict:
"""计算布林带"""
if len(close) < window:
return {"signal": "数据不足"}
ma = close.rolling(window=window).mean()
std = close.rolling(window=window).std()
upper = ma + num_std * std
lower = ma - num_std * std
current = close.iloc[-1]
upper_val = round(upper.iloc[-1], 3)
lower_val = round(lower.iloc[-1], 3)
mid_val = round(ma.iloc[-1], 3)
bandwidth = round((upper_val - lower_val) / mid_val * 100, 2)
signal = "中性"
if current > upper_val:
signal = "突破上轨-超买"
elif current < lower_val:
signal = "突破下轨-超卖"
elif current > mid_val:
signal = "中轨上方-偏强"
else:
signal = "中轨下方-偏弱"
return {
"upper": upper_val,
"middle": mid_val,
"lower": lower_val,
"bandwidth_pct": bandwidth,
"signal": signal,
}
def calc_volume_analysis(volume: pd.Series, close: pd.Series) -> dict:
"""成交量分析"""
if len(volume) < 20:
return {"signal": "数据不足"}
avg_5 = volume.rolling(5).mean().iloc[-1]
avg_20 = volume.rolling(20).mean().iloc[-1]
current = volume.iloc[-1]
vol_ratio = round(current / avg_5, 2) if avg_5 > 0 else 0
price_change = close.iloc[-1] - close.iloc[-2]
signal = "中性"
if vol_ratio > 2 and price_change > 0:
signal = "放量上涨-强势"
elif vol_ratio > 2 and price_change < 0:
signal = "放量下跌-弱势"
elif vol_ratio < 0.5 and price_change > 0:
signal = "缩量上涨-动力不足"
elif vol_ratio < 0.5 and price_change < 0:
signal = "缩量下跌-抛压减轻"
return {
"current_volume": int(current),
"avg_5d_volume": int(avg_5),
"avg_20d_volume": int(avg_20),
"volume_ratio": vol_ratio,
"signal": signal,
}
def calc_ma_trend(close: pd.Series) -> dict:
"""均线趋势分析"""
mas = calc_ma(close, [5, 10, 20, 60])
current = close.iloc[-1]
above_count = sum(1 for v in mas.values() if current > v)
total = len(mas)
if above_count == total and total > 0:
signal = "多头排列-强势"
elif above_count == 0:
signal = "空头排列-弱势"
elif above_count >= total * 0.7:
signal = "偏多"
elif above_count <= total * 0.3:
signal = "偏空"
else:
signal = "震荡"
return {**mas, "trend_signal": signal, "price_above_ma_count": f"{above_count}/{total}"}
# ─────────────────────────────────────────────
# 基本面分析 (基于腾讯数据)
# ─────────────────────────────────────────────
def get_fundamentals(quote: dict) -> dict:
"""基于实时行情数据的基本面分析"""
result = {}
# 估值指标 (腾讯提供的)
pe = quote.get("pe")
pb = quote.get("pb")
result["PE"] = round(pe, 2) if pe else None
result["PB"] = round(pb, 2) if pb else None
result["PS"] = None # 腾讯不提供
# 市值
result["market_cap"] = quote.get("market_cap", "")
# 52周价格区间
result["52w_high"] = quote.get("52w_high")
result["52w_low"] = quote.get("52w_low")
# 公司信息
result["company_name"] = quote.get("name", "未知")
result["sector"] = "港股"
result["industry"] = "港股"
result["currency"] = "HKD"
# 基本面信号
result["fundamental_signal"] = _fundamental_signal(result)
return result
def _fundamental_signal(data: dict) -> str:
"""基本面信号判断 (简化版)"""
score = 0
reasons = []
pe = data.get("PE")
if pe is not None and pe > 0:
if pe < 15:
score += 2
reasons.append(f"PE低估值({pe})")
elif pe < 25:
score += 1
reasons.append(f"PE合理({pe})")
elif pe > 40:
score -= 1
reasons.append(f"PE偏高({pe})")
pb = data.get("PB")
if pb is not None:
if pb < 1:
score += 1
reasons.append(f"PB破净({pb})")
elif pb > 5:
score -= 1
reasons.append(f"PB偏高({pb})")
if score >= 3:
signal = "基本面优秀"
elif score >= 1:
signal = "基本面良好"
elif score >= 0:
signal = "基本面一般"
else:
signal = "基本面较差"
return f"{signal} ({'; '.join(reasons[:3])})" if reasons else signal
# ─────────────────────────────────────────────
# 综合评分与建议
# ─────────────────────────────────────────────
def generate_recommendation(technical: dict, fundamental: dict, current_price: float) -> dict:
"""综合技术面和基本面给出操作建议"""
score = 0
signals = []
# 技术面评分
macd_sig = technical.get("macd", {}).get("signal", "")
if "买入" in macd_sig or "金叉" in macd_sig:
score += 2
signals.append(f"MACD: {macd_sig}")
elif "卖出" in macd_sig or "死叉" in macd_sig:
score -= 2
signals.append(f"MACD: {macd_sig}")
elif "多头" in macd_sig:
score += 1
signals.append(f"MACD: {macd_sig}")
elif "空头" in macd_sig:
score -= 1
signals.append(f"MACD: {macd_sig}")
rsi_sig = technical.get("rsi", {}).get("signal", "")
if "超卖" in rsi_sig:
score += 2
signals.append(f"RSI: {rsi_sig}")
elif "超买" in rsi_sig:
score -= 2
signals.append(f"RSI: {rsi_sig}")
kdj_sig = technical.get("kdj", {}).get("signal", "")
if "买入" in kdj_sig or "金叉" in kdj_sig:
score += 1
signals.append(f"KDJ: {kdj_sig}")
elif "卖出" in kdj_sig or "死叉" in kdj_sig:
score -= 1
signals.append(f"KDJ: {kdj_sig}")
boll_sig = technical.get("bollinger", {}).get("signal", "")
if "超卖" in boll_sig or "下轨" in boll_sig:
score += 1
signals.append(f"布林带: {boll_sig}")
elif "超买" in boll_sig or "上轨" in boll_sig:
score -= 1
signals.append(f"布林带: {boll_sig}")
ma_sig = technical.get("ma_trend", {}).get("trend_signal", "")
if "多头" in ma_sig or "强势" in ma_sig:
score += 2
signals.append(f"均线: {ma_sig}")
elif "空头" in ma_sig or "弱势" in ma_sig:
score -= 2
signals.append(f"均线: {ma_sig}")
elif "偏多" in ma_sig:
score += 1
elif "偏空" in ma_sig:
score -= 1
vol_sig = technical.get("volume", {}).get("signal", "")
if "放量上涨" in vol_sig:
score += 1
signals.append(f"成交量: {vol_sig}")
elif "放量下跌" in vol_sig:
score -= 1
signals.append(f"成交量: {vol_sig}")
# 基本面评分
fund_sig = fundamental.get("fundamental_signal", "")
if "优秀" in fund_sig:
score += 2
signals.append(f"基本面: {fund_sig}")
elif "良好" in fund_sig:
score += 1
signals.append(f"基本面: {fund_sig}")
elif "较差" in fund_sig:
score -= 2
signals.append(f"基本面: {fund_sig}")
# 52周位置
high_52w = fundamental.get("52w_high")
low_52w = fundamental.get("52w_low")
if high_52w and low_52w and high_52w != low_52w:
position = (current_price - low_52w) / (high_52w - low_52w)
if position < 0.2:
score += 1
signals.append(f"52周位置: {position:.0%} (接近低点)")
elif position > 0.9:
score -= 1
signals.append(f"52周位置: {position:.0%} (接近高点)")
else:
signals.append(f"52周位置: {position:.0%}")
# 映射到操作建议
if score >= 5:
action = "强烈买入"
action_en = "STRONG_BUY"
color = "🟢🟢"
elif score >= 2:
action = "买入"
action_en = "BUY"
color = "🟢"
elif score >= -1:
action = "持有/观望"
action_en = "HOLD"
color = "🟡"
elif score >= -4:
action = "卖出"
action_en = "SELL"
color = "🔴"
else:
action = "强烈卖出"
action_en = "STRONG_SELL"
color = "🔴🔴"
return {
"action": action,
"action_en": action_en,
"score": score,
"icon": color,
"key_signals": signals,
"summary": f"{color} {action} (综合评分: {score})",
}
# ─────────────────────────────────────────────
# 主流程
# ─────────────────────────────────────────────
def analyze_stock(code: str, period: str = "6mo", use_cache: bool = True) -> dict:
"""对单只港股进行完整分析"""
numeric_code, full_code = normalize_hk_code(code)
if use_cache:
cached = _read_cache(full_code, period)
if cached:
print(f"📦 使用缓存数据 ({full_code}),缓存有效期 {CACHE_TTL_SECONDS}s", file=sys.stderr)
return cached
result = {"code": full_code, "analysis_time": datetime.now().isoformat(), "error": None}
try:
# 1. 获取实时行情
quote = fetch_tencent_quote(numeric_code)
if not quote or not quote.get("price"):
result["error"] = f"无法获取 {full_code} 的实时行情"
return result
current_price = quote["price"]
result["current_price"] = current_price
result["price_date"] = quote.get("timestamp", "")
result["price_change"] = quote.get("change_amount")
result["price_change_pct"] = quote.get("change_pct")
# 2. 获取K线数据
days = period_to_days(period)
hist = fetch_tencent_kline(numeric_code, days)
if hist.empty or len(hist) < 30:
result["error"] = f"无法获取 {full_code} 的历史K线数据 (仅获得 {len(hist)} 条)"
return result
result["data_points"] = len(hist)
close = hist["Close"]
high = hist["High"]
low = hist["Low"]
volume = hist["Volume"]
# 3. 技术分析
technical = {}
technical["ma_trend"] = calc_ma_trend(close)
technical["macd"] = calc_macd(close)
technical["rsi"] = calc_rsi(close)
technical["kdj"] = calc_kdj(high, low, close)
technical["bollinger"] = calc_bollinger(close)
technical["volume"] = calc_volume_analysis(volume, close)
result["technical"] = technical
# 4. 基本面分析
fundamental = get_fundamentals(quote)
result["fundamental"] = fundamental
# 5. 综合建议
result["recommendation"] = generate_recommendation(technical, fundamental, current_price)
# 6. 写入缓存
if result.get("error") is None:
_write_cache(full_code, period, result)
except Exception as e:
result["error"] = f"分析过程出错: {str(e)}"
return result
def main():
parser = argparse.ArgumentParser(description="港股分析工具 (腾讯财经数据源)")
parser.add_argument("code", help="港股代码 (如 0700.HK, 00700, 腾讯)")
parser.add_argument("--period", default="6mo", help="数据周期 (1mo/3mo/6mo/1y/2y/5y)")
parser.add_argument("--output", help="输出JSON文件路径")
parser.add_argument("--no-cache", action="store_true", help="跳过缓存,强制重新请求数据")
parser.add_argument("--clear-cache", action="store_true", help="清除所有缓存后退出")
args = parser.parse_args()
if args.clear_cache:
import shutil
cleared = False
for path in (CACHE_DIR, LEGACY_CACHE_DIR):
if path.exists():
shutil.rmtree(path)
cleared = True
if cleared:
print("✅ 缓存已清除")
else:
print(" 无缓存可清除")
return
result = analyze_stock(args.code, args.period, use_cache=not args.no_cache)
output = json.dumps(result, ensure_ascii=False, indent=2, default=str)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(output)
print(f"分析结果已保存至 {args.output}")
else:
print(output)
if __name__ == "__main__":
main()