256 lines
8.9 KiB
Python
256 lines
8.9 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
小唯每日股票信号 — 五粮液(000858)
|
||
=================================
|
||
每日收盘后自动计算MA20状态,推送操作信号
|
||
|
||
状态说明:
|
||
- LONG: 收盘价在20日均线上方 → 持仓信号
|
||
- SHORT: 收盘价在20日均线下方 → 空仓信号
|
||
- 无信号: 均线附近震荡
|
||
|
||
用法:
|
||
python3 stock_signal.py # 今日五粮液信号
|
||
python3 stock_signal.py --watch # 持续监控模式
|
||
"""
|
||
|
||
import json, sys, urllib.request
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
OUTPUT = Path.home() / ".hermes" / "stock_backtest"
|
||
OUTPUT.mkdir(exist_ok=True)
|
||
FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/446db983-e392-4d2c-bfb8-f9060e5df3ad"
|
||
|
||
# 关注股票名称映射(与 stock_portfolio.py 一致)
|
||
STOCK_NAMES = {
|
||
"000858": "五粮液",
|
||
"600519": "贵州茅台",
|
||
"000568": "泸州老窖",
|
||
"002304": "洋河股份",
|
||
"600036": "招商银行",
|
||
"601318": "中国平安",
|
||
"000001": "平安银行",
|
||
}
|
||
|
||
|
||
def send_feishu(msg):
|
||
payload = json.dumps({"msg_type": "text", "content": {"text": msg}}).encode()
|
||
req = urllib.request.Request(FEISHU_WEBHOOK, data=payload,
|
||
headers={"Content-Type": "application/json"})
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=10):
|
||
pass
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def get_data(code, count=30):
|
||
mc = f"sh{code}" if code.startswith("6") else f"sz{code}"
|
||
today = datetime.now().strftime("%Y-%m-%d")
|
||
url = (f"https://web.ifzq.gtimg.cn/appstock/app/fqkline/get"
|
||
f"?_var=kline_dayqfq¶m={mc},day,{today},{today},{count},qfq")
|
||
try:
|
||
text = urllib.request.urlopen(url, timeout=10).read().decode("utf-8")
|
||
data = json.loads(text.replace("kline_dayqfq=", "", 1))
|
||
qfq = (data.get("data", {}).get(mc, {}).get("qfqday") or
|
||
data.get("data", {}).get(mc, {}).get("day") or [])
|
||
rows = []
|
||
for item in qfq:
|
||
if len(item) < 6:
|
||
continue
|
||
try:
|
||
rows.append({"date": item[0],
|
||
"open": float(item[1]), "close": float(item[2]),
|
||
"high": float(item[3]), "low": float(item[4]),
|
||
"volume": float(item[5])})
|
||
except (ValueError, IndexError):
|
||
continue
|
||
df = pd.DataFrame(rows)
|
||
df["date"] = pd.to_datetime(df["date"])
|
||
df.set_index("date", inplace=True)
|
||
df.sort_index(inplace=True)
|
||
return df
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def get_long_data(code, start, end):
|
||
"""获取一段历史数据用于计算MA20"""
|
||
mc = f"sh{code}" if code.startswith("6") else f"sz{code}"
|
||
url = (f"https://web.ifzq.gtimg.cn/appstock/app/fqkline/get"
|
||
f"?_var=kline_dayqfq¶m={mc},day,{start},{end},500,qfq")
|
||
try:
|
||
text = urllib.request.urlopen(url, timeout=10).read().decode("utf-8")
|
||
data = json.loads(text.replace("kline_dayqfq=", "", 1))
|
||
qfq = (data.get("data", {}).get(mc, {}).get("qfqday") or
|
||
data.get("data", {}).get(mc, {}).get("day") or [])
|
||
rows = []
|
||
for item in qfq:
|
||
if len(item) < 6:
|
||
continue
|
||
try:
|
||
rows.append({"date": item[0],
|
||
"open": float(item[1]), "close": float(item[2]),
|
||
"high": float(item[3]), "low": float(item[4]),
|
||
"volume": float(item[5])})
|
||
except (ValueError, IndexError):
|
||
continue
|
||
df = pd.DataFrame(rows)
|
||
df["date"] = pd.to_datetime(df["date"])
|
||
df.set_index("date", inplace=True)
|
||
df.sort_index(inplace=True)
|
||
return df
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def compute_signal(code="000858", hist_days=60):
|
||
"""计算股票MA20信号"""
|
||
name = STOCK_NAMES.get(code, code)
|
||
end = datetime.now().strftime("%Y-%m-%d")
|
||
start = (datetime.now().replace(year=datetime.now().year-1)).strftime("%Y-%m-%d")
|
||
|
||
df = get_long_data(code, "2025-01-01", end)
|
||
if df is None or len(df) < 25:
|
||
return None
|
||
|
||
ma20 = df["close"].rolling(20).mean()
|
||
last_close = df["close"].iloc[-1]
|
||
last_ma20 = ma20.iloc[-1]
|
||
prev_close = df["close"].iloc[-2]
|
||
prev_ma20 = ma20.iloc[-2]
|
||
|
||
# 计算信号
|
||
if last_close > last_ma20 and prev_close <= prev_ma20:
|
||
signal = "BUY" # 金叉
|
||
signal_text = "🟢 买入信号"
|
||
signal_desc = "收盘价上穿20日均线,金叉买入"
|
||
elif last_close < last_ma20 and prev_close >= prev_ma20:
|
||
signal = "SELL" # 死叉
|
||
signal_text = "🔴 卖出信号"
|
||
signal_desc = "收盘价下穿20日均线,死叉卖出"
|
||
elif last_close > last_ma20:
|
||
signal = "HOLD_LONG"
|
||
signal_text = "🟢 持仓"
|
||
signal_desc = "价格站稳均线上方,持有"
|
||
else:
|
||
signal = "HOLD_SHORT"
|
||
signal_text = "🔴 空仓"
|
||
signal_desc = "价格跌破均线,保持空仓"
|
||
|
||
pct_above = (last_close - last_ma20) / last_ma20 * 100
|
||
macd_fast = df["close"].ewm(span=12).mean().iloc[-1]
|
||
macd_slow = df["close"].ewm(span=26).mean().iloc[-1]
|
||
macd_val = macd_fast - macd_slow
|
||
signal_line = pd.Series(df["close"]).ewm(span=12).mean().iloc[-1] - pd.Series(df["close"]).ewm(span=26).mean().iloc[-1]
|
||
signal_line = pd.Series(pd.Series(df["close"]).ewm(span=12).mean() - pd.Series(df["close"]).ewm(span=26).mean()).ewm(span=9).mean().iloc[-1]
|
||
|
||
return {
|
||
"code": code,
|
||
"name": name,
|
||
"date": str(df.index[-1].date()),
|
||
"close": last_close,
|
||
"ma20": last_ma20,
|
||
"pct_above_ma": pct_above,
|
||
"signal": signal,
|
||
"signal_text": signal_text,
|
||
"signal_desc": signal_desc,
|
||
"macd": macd_val,
|
||
"signal_line": signal_line,
|
||
}
|
||
|
||
|
||
def get_price_simple(code):
|
||
"""快速获取当前价格"""
|
||
mc = f"sh{code}" if code.startswith("6") else f"sz{code}"
|
||
try:
|
||
url = f"https://qt.gtimg.cn/q={mc}"
|
||
data = urllib.request.urlopen(url, timeout=5).read()
|
||
text = data.decode("gbk")
|
||
parts = text.split("~")
|
||
if len(parts) > 10:
|
||
return {
|
||
"name": parts[1],
|
||
"code": parts[2],
|
||
"price": float(parts[3]),
|
||
"yesterday": float(parts[4]),
|
||
"change": float(parts[3]) - float(parts[4]),
|
||
"change_pct": (float(parts[3]) - float(parts[4])) / float(parts[4]) * 100,
|
||
}
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def send_daily_signal(code="000858", auto_trade=False):
|
||
"""发送每日信号到飞书,可选自动执行模拟交易"""
|
||
info = get_price_simple(code)
|
||
sig = compute_signal(code)
|
||
name = STOCK_NAMES.get(code, code)
|
||
|
||
if info is None:
|
||
send_feishu(f"⚠️ 无法获取{name}({code})行情数据")
|
||
return
|
||
|
||
if sig is None:
|
||
send_feishu(f"⚠️ 无法计算{name}({code})技术信号")
|
||
return
|
||
|
||
change_emoji = "📈" if info["change"] > 0 else "📉"
|
||
|
||
msg = f"""🍶 {name}({code}) 每日信号
|
||
{change_emoji} 今日: {info['price']:.2f} ({info['change']:+.2f}, {info['change_pct']:+.2f}%)
|
||
|
||
{sig['signal_text']}
|
||
{sig['signal_desc']}
|
||
|
||
均线状态:
|
||
• 收盘价: {sig['close']:.2f}
|
||
• MA20: {sig['ma20']:.2f}
|
||
• 价格偏离: {sig['pct_above_ma']:+.2f}%
|
||
• MACD: {sig['macd']:+.2f} vs Signal {sig['signal_line']:.2f}
|
||
|
||
{'✅ 可买入' if sig['signal'] == 'BUY' else '❌ 继续观察' if sig['signal'] == 'HOLD_SHORT' else '⏸️ 持仓观望'}
|
||
|
||
生成: {datetime.now().strftime('%Y-%m-%d %H:%M')}
|
||
小唯股票投研 · MA20突破策略"""
|
||
|
||
# 信号 → 模拟账户自动执行(打通两套系统)
|
||
if auto_trade:
|
||
import stock_paper
|
||
# v2:弱势行业金叉半仓参与(2026-08-01 回测优化:全拦截损失 α,半仓最优)
|
||
industry = STOCK_NAMES.get(code, "")
|
||
weak_industry = {"白酒", "医药", "通信", "汽车", "地产"} # 与 stock_portfolio.WEAK_TREND_INDUSTRIES 同步
|
||
if sig["signal"] == "BUY" and industry in weak_industry:
|
||
action, detail = ("BUY_HALF", f"弱势行业({industry}动量负)金叉,半仓买入")
|
||
msg += f"\n\n📝 模拟账户: [⚠️ {action}] {detail}"
|
||
else:
|
||
action, detail = stock_paper.execute_signal(sig)
|
||
msg += f"\n\n📝 模拟账户: [{action}] {detail}"
|
||
|
||
send_feishu(msg)
|
||
print(msg)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
code = "000858"
|
||
auto_trade = False
|
||
if "--code" in sys.argv:
|
||
idx = sys.argv.index("--code")
|
||
if idx + 1 < len(sys.argv):
|
||
code = sys.argv[idx + 1]
|
||
if "--paper" in sys.argv:
|
||
auto_trade = True
|
||
|
||
if "--watch" in sys.argv:
|
||
print("监控模式: 每60秒检查一次 (Ctrl+C退出)")
|
||
import time
|
||
while True:
|
||
send_daily_signal(code, auto_trade)
|
||
time.sleep(60)
|
||
else:
|
||
send_daily_signal(code, auto_trade) |