feat: v6 with profit protection, trend filter, and LLM sentiment
This commit is contained in:
332
data_manager.py
Normal file
332
data_manager.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
data_manager.py — 港股数据管理器
|
||||
支持 yfinance(优先)+ 东方财富备用(免费)
|
||||
带指数退避重试,429限速自动切换源
|
||||
|
||||
用法:
|
||||
python3 data_manager.py --download-all # 下载/更新所有股票
|
||||
python3 data_manager.py --check # 检查数据完整性
|
||||
python3 data_manager.py --force-refresh # 强制重新下载
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
import yfinance as yf
|
||||
import os, time, json, argparse, warnings
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, List
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
CACHE_DIR = "data"
|
||||
CACHE_META = os.path.join(CACHE_DIR, "meta.json")
|
||||
|
||||
STOCKS = {
|
||||
"平安好医生": {"ticker": "1833.HK", "em_code": "116.01833"},
|
||||
"叮当健康": {"ticker": "9886.HK", "em_code": "116.09886"},
|
||||
"中原建业": {"ticker": "9982.HK", "em_code": "116.09982"},
|
||||
"泰升集团": {"ticker": "0687.HK", "em_code": "116.00687"},
|
||||
"阅文集团": {"ticker": "0772.HK", "em_code": "116.00772"},
|
||||
"中芯国际": {"ticker": "0981.HK", "em_code": "116.00981"},
|
||||
}
|
||||
|
||||
# 东方财富 API 配置
|
||||
EM_API_URL = "https://push2his.eastmoney.com/api/qt/stock/kline/get"
|
||||
EM_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
}
|
||||
|
||||
# 重试配置
|
||||
MAX_RETRIES = 5
|
||||
BASE_DELAY = 10 # 基础延迟秒数
|
||||
|
||||
class DataManager:
|
||||
def __init__(self, cache_dir: str = CACHE_DIR):
|
||||
self.cache_dir = cache_dir
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
self.meta = self._load_meta()
|
||||
|
||||
def _load_meta(self) -> Dict:
|
||||
"""加载缓存元数据"""
|
||||
if os.path.exists(CACHE_META):
|
||||
with open(CACHE_META, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def _save_meta(self):
|
||||
"""保存缓存元数据"""
|
||||
with open(CACHE_META, 'w') as f:
|
||||
json.dump(self.meta, f, indent=2, default=str)
|
||||
|
||||
def _get_cache_path(self, sym: str) -> str:
|
||||
"""获取缓存文件路径"""
|
||||
return os.path.join(self.cache_dir, f"{sym}.csv")
|
||||
|
||||
def _exponential_backoff(self, attempt: int) -> float:
|
||||
"""指数退避延迟"""
|
||||
delay = min(BASE_DELAY * (2 ** attempt), 300) # 最大5分钟
|
||||
jitter = delay * 0.1 * (hash(str(time.time())) % 10 / 10)
|
||||
return delay + jitter
|
||||
|
||||
def download_yfinance(self, ticker: str, period: str = "2y",
|
||||
max_retries: int = MAX_RETRIES) -> Optional[pd.DataFrame]:
|
||||
"""从 yfinance 下载数据,带重试"""
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
print(f" 🌐 yfinance: 尝试 {attempt+1}/{max_retries}...")
|
||||
df = yf.download(
|
||||
ticker,
|
||||
period=period,
|
||||
auto_adjust=True,
|
||||
progress=False,
|
||||
timeout=30
|
||||
)
|
||||
if df.empty:
|
||||
print(f" ⚠️ yfinance: 返回空数据")
|
||||
return None
|
||||
|
||||
# 处理多级列名
|
||||
if isinstance(df.columns, pd.MultiIndex):
|
||||
df.columns = df.columns.droplevel(1)
|
||||
|
||||
print(f" ✅ yfinance: 成功获取 {len(df)} 行")
|
||||
return df
|
||||
|
||||
except Exception as e:
|
||||
err_str = str(e).lower()
|
||||
is_rate_limit = any(x in err_str for x in ['too many', '429', 'rate limit', 'limit'])
|
||||
|
||||
if is_rate_limit and attempt < max_retries - 1:
|
||||
delay = self._exponential_backoff(attempt)
|
||||
print(f" ⏳ yfinance: 限速,等待 {delay:.1f}s 后重试...")
|
||||
time.sleep(delay)
|
||||
else:
|
||||
print(f" ❌ yfinance: 失败 - {e}")
|
||||
return None
|
||||
return None
|
||||
|
||||
def download_eastmoney(self, em_code: str, days: int = 500) -> Optional[pd.DataFrame]:
|
||||
"""从东方财富下载数据(备用源)"""
|
||||
try:
|
||||
print(f" 🌐 东方财富: 请求数据...")
|
||||
|
||||
# 构造请求参数
|
||||
end_date = datetime.now().strftime('%Y%m%d')
|
||||
start_date = (datetime.now() - timedelta(days=days*2)).strftime('%Y%m%d')
|
||||
|
||||
params = {
|
||||
"secid": em_code,
|
||||
"fields1": "f1,f2,f3,f4,f5,f6",
|
||||
"fields2": "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61",
|
||||
"klt": "101", # 日K
|
||||
"fqt": "0", # 不复权
|
||||
"beg": start_date,
|
||||
"end": end_date,
|
||||
"_": int(time.time() * 1000)
|
||||
}
|
||||
|
||||
resp = requests.get(EM_API_URL, params=params, headers=EM_HEADERS, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
if 'data' not in data or not data['data'] or 'klines' not in data['data']:
|
||||
print(f" ⚠️ 东方财富: 无数据返回")
|
||||
return None
|
||||
|
||||
klines = data['data']['klines']
|
||||
if not klines:
|
||||
print(f" ⚠️ 东方财富: K线为空")
|
||||
return None
|
||||
|
||||
# 解析数据
|
||||
records = []
|
||||
for line in klines:
|
||||
parts = line.split(',')
|
||||
if len(parts) >= 6:
|
||||
records.append({
|
||||
'Date': parts[0],
|
||||
'Open': float(parts[1]),
|
||||
'Close': float(parts[2]),
|
||||
'Low': float(parts[4]),
|
||||
'High': float(parts[3]),
|
||||
'Volume': float(parts[5])
|
||||
})
|
||||
|
||||
df = pd.DataFrame(records)
|
||||
df['Date'] = pd.to_datetime(df['Date'])
|
||||
df.set_index('Date', inplace=True)
|
||||
df = df.sort_index()
|
||||
|
||||
# 只取最近 N 天
|
||||
if len(df) > days:
|
||||
df = df.iloc[-days:]
|
||||
|
||||
# 列名标准化(和 yfinance 一致)
|
||||
df = df[['Open', 'High', 'Low', 'Close', 'Volume']]
|
||||
|
||||
print(f" ✅ 东方财富: 成功获取 {len(df)} 行")
|
||||
return df
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ 东方财富: 失败 - {e}")
|
||||
return None
|
||||
|
||||
def download_stock(self, name: str, info: Dict,
|
||||
force_refresh: bool = False) -> bool:
|
||||
"""下载单只股票数据,自动切换源"""
|
||||
sym = info['ticker'].replace('.HK', '')
|
||||
cache_path = self._get_cache_path(sym)
|
||||
|
||||
# 检查缓存
|
||||
if not force_refresh and os.path.exists(cache_path):
|
||||
df = pd.read_csv(cache_path, index_col=0, parse_dates=True)
|
||||
print(f" ✅ {name}: 已有缓存 {len(df)} 行,跳过")
|
||||
return True
|
||||
|
||||
print(f"\n 📥 {name} ({info['ticker']})")
|
||||
|
||||
# 尝试 yfinance
|
||||
df = self.download_yfinance(info['ticker'])
|
||||
source = "yfinance"
|
||||
|
||||
# yfinance 失败,切换东方财富
|
||||
if df is None:
|
||||
print(f" 🔄 切换到备用源...")
|
||||
time.sleep(2) # 短暂延迟避免并发
|
||||
df = self.download_eastmoney(info['em_code'])
|
||||
source = "eastmoney"
|
||||
|
||||
if df is None or df.empty:
|
||||
print(f" ❌ {name}: 所有数据源均失败")
|
||||
return False
|
||||
|
||||
# 保存缓存
|
||||
df.to_csv(cache_path)
|
||||
|
||||
# 更新元数据
|
||||
self.meta[sym] = {
|
||||
'name': name,
|
||||
'ticker': info['ticker'],
|
||||
'source': source,
|
||||
'downloaded_at': datetime.now().isoformat(),
|
||||
'rows': len(df),
|
||||
'date_range': {
|
||||
'start': df.index[0].strftime('%Y-%m-%d'),
|
||||
'end': df.index[-1].strftime('%Y-%m-%d')
|
||||
}
|
||||
}
|
||||
self._save_meta()
|
||||
|
||||
print(f" ✅ {name}: 已保存 {len(df)} 行 ({source})")
|
||||
print(f" 范围: {df.index[0].date()} ~ {df.index[-1].date()}")
|
||||
|
||||
return True
|
||||
|
||||
def download_all(self, force_refresh: bool = False) -> Dict[str, bool]:
|
||||
"""下载所有股票数据"""
|
||||
print("="*60)
|
||||
print("📊 Stock Buddy — 数据下载管理器")
|
||||
print(f" 缓存目录: {self.cache_dir}")
|
||||
print(f" 强制刷新: {'是' if force_refresh else '否'}")
|
||||
print("="*60)
|
||||
|
||||
results = {}
|
||||
for i, (name, info) in enumerate(STOCKS.items()):
|
||||
success = self.download_stock(name, info, force_refresh)
|
||||
results[name] = success
|
||||
|
||||
# 下载间隔,避免触发限速
|
||||
if i < len(STOCKS) - 1:
|
||||
delay = 3 if success else 5
|
||||
time.sleep(delay)
|
||||
|
||||
# 打印汇总
|
||||
print("\n" + "="*60)
|
||||
print("📋 下载汇总")
|
||||
print("="*60)
|
||||
success_count = sum(1 for v in results.values() if v)
|
||||
for name, ok in results.items():
|
||||
status = "✅ 成功" if ok else "❌ 失败"
|
||||
print(f" {name}: {status}")
|
||||
print(f"\n 总计: {success_count}/{len(results)} 成功")
|
||||
|
||||
return results
|
||||
|
||||
def check_data(self) -> bool:
|
||||
"""检查数据完整性"""
|
||||
print("="*60)
|
||||
print("🔍 数据完整性检查")
|
||||
print("="*60)
|
||||
|
||||
all_ok = True
|
||||
for name, info in STOCKS.items():
|
||||
sym = info['ticker'].replace('.HK', '')
|
||||
cache_path = self._get_cache_path(sym)
|
||||
|
||||
if not os.path.exists(cache_path):
|
||||
print(f" ❌ {name}: 无缓存文件")
|
||||
all_ok = False
|
||||
continue
|
||||
|
||||
df = pd.read_csv(cache_path, index_col=0, parse_dates=True)
|
||||
|
||||
if len(df) < 60:
|
||||
print(f" ⚠️ {name}: 数据不足 ({len(df)} 行,建议 >=60)")
|
||||
all_ok = False
|
||||
else:
|
||||
print(f" ✅ {name}: {len(df)} 行,最新 {df.index[-1].date()}")
|
||||
|
||||
return all_ok
|
||||
|
||||
def get_cache_info(self) -> pd.DataFrame:
|
||||
"""获取缓存信息表"""
|
||||
if not self.meta:
|
||||
return pd.DataFrame()
|
||||
|
||||
rows = []
|
||||
for sym, info in self.meta.items():
|
||||
rows.append({
|
||||
'股票': info.get('name', sym),
|
||||
'代码': info.get('ticker', sym),
|
||||
'数据源': info.get('source', '-'),
|
||||
'下载时间': info.get('downloaded_at', '-')[:19],
|
||||
'数据条数': info.get('rows', 0),
|
||||
'日期范围': f"{info.get('date_range', {}).get('start', '-')} ~ {info.get('date_range', {}).get('end', '-')}"
|
||||
})
|
||||
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Stock Buddy 数据管理器')
|
||||
parser.add_argument('--download-all', action='store_true', help='下载/更新所有股票数据')
|
||||
parser.add_argument('--check', action='store_true', help='检查数据完整性')
|
||||
parser.add_argument('--force-refresh', action='store_true', help='强制重新下载')
|
||||
parser.add_argument('--info', action='store_true', help='显示缓存信息')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
dm = DataManager()
|
||||
|
||||
if args.download_all:
|
||||
dm.download_all(force_refresh=args.force_refresh)
|
||||
elif args.check:
|
||||
ok = dm.check_data()
|
||||
exit(0 if ok else 1)
|
||||
elif args.info:
|
||||
info = dm.get_cache_info()
|
||||
if info.empty:
|
||||
print("暂无缓存数据")
|
||||
else:
|
||||
print(info.to_string(index=False))
|
||||
else:
|
||||
# 默认行为:检查 + 提示
|
||||
print("Stock Buddy 数据管理器")
|
||||
print("\n常用命令:")
|
||||
print(" python3 data_manager.py --download-all # 下载所有数据")
|
||||
print(" python3 data_manager.py --check # 检查数据完整性")
|
||||
print(" python3 data_manager.py --force-refresh # 强制重新下载")
|
||||
print(" python3 data_manager.py --info # 查看缓存信息")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
153
llm_sentiment.py
Normal file
153
llm_sentiment.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
llm_sentiment.py — LLM舆情分析器
|
||||
批量分析股票新闻情绪,输出标准化分数(-5~+5)
|
||||
|
||||
用法:
|
||||
python3 llm_sentiment.py --init # 初始化基础情绪数据
|
||||
python3 llm_sentiment.py --update # 更新今日情绪(模拟)
|
||||
|
||||
实际接入LLM时,需要提供新闻API或爬虫获取标题列表
|
||||
"""
|
||||
|
||||
import json, os, argparse
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
CACHE_FILE = "data/llm_sentiment.json"
|
||||
|
||||
def init_base_sentiment():
|
||||
"""初始化基础情绪数据(按季度)"""
|
||||
data = {
|
||||
"平安好医生": {
|
||||
"2024-01-01": -2, "2024-04-01": -2, "2024-07-01": -1, "2024-10-01": 0,
|
||||
"2025-01-01": 1, "2025-04-01": 1, "2025-07-01": 2, "2025-10-01": 2,
|
||||
"2026-01-01": 3, "2026-03-01": 3
|
||||
},
|
||||
"叮当健康": {
|
||||
"2024-01-01": -3, "2024-04-01": -3, "2024-07-01": -2, "2024-10-01": -1,
|
||||
"2025-01-01": -1, "2025-04-01": 0, "2025-07-01": 1, "2025-10-01": 1,
|
||||
"2026-01-01": 2, "2026-03-01": 2
|
||||
},
|
||||
"中原建业": {
|
||||
"2024-01-01": -3, "2024-04-01": -4, "2024-07-01": -4, "2024-10-01": -4,
|
||||
"2025-01-01": -4, "2025-04-01": -5, "2025-07-01": -5, "2025-10-01": -5,
|
||||
"2026-01-01": -5, "2026-03-01": -5
|
||||
},
|
||||
"泰升集团": {
|
||||
"2024-01-01": -1, "2024-04-01": -1, "2024-07-01": -1, "2024-10-01": -2,
|
||||
"2025-01-01": -2, "2025-04-01": -2, "2025-07-01": -2, "2025-10-01": -2,
|
||||
"2026-01-01": -2, "2026-03-01": -2
|
||||
},
|
||||
"阅文集团": {
|
||||
"2024-01-01": 1, "2024-04-01": 2, "2024-07-01": 2, "2024-10-01": 2,
|
||||
"2025-01-01": 2, "2025-04-01": 3, "2025-07-01": 3, "2025-10-01": 3,
|
||||
"2026-01-01": 3, "2026-03-01": 3
|
||||
},
|
||||
"中芯国际": {
|
||||
"2024-01-01": 2, "2024-04-01": 3, "2024-07-01": 3, "2024-10-01": 4,
|
||||
"2025-01-01": 4, "2025-04-01": 4, "2025-07-01": 5, "2025-10-01": 5,
|
||||
"2026-01-01": 5, "2026-03-01": 4
|
||||
}
|
||||
}
|
||||
|
||||
# 展开为日级数据(线性插值)
|
||||
daily_data = {}
|
||||
for stock, quarters in data.items():
|
||||
daily_data[stock] = {}
|
||||
dates = sorted(quarters.keys())
|
||||
for i, date_str in enumerate(dates):
|
||||
current_date = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
score = quarters[date_str]
|
||||
|
||||
# 计算该季度的结束日期
|
||||
if i < len(dates) - 1:
|
||||
next_date = datetime.strptime(dates[i+1], "%Y-%m-%d")
|
||||
else:
|
||||
next_date = current_date + timedelta(days=90)
|
||||
|
||||
# 填充该季度的每一天
|
||||
d = current_date
|
||||
while d < next_date:
|
||||
daily_data[stock][d.strftime("%Y-%m-%d")] = score
|
||||
d += timedelta(days=1)
|
||||
|
||||
save_sentiment(daily_data)
|
||||
print(f"✅ 已初始化 {len(daily_data)} 只股票的情绪数据")
|
||||
return daily_data
|
||||
|
||||
def save_sentiment(data):
|
||||
"""保存情绪数据"""
|
||||
os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True)
|
||||
with open(CACHE_FILE, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
def load_sentiment():
|
||||
"""加载情绪数据"""
|
||||
if os.path.exists(CACHE_FILE):
|
||||
with open(CACHE_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def analyze_news_llm(stock_name, news_list):
|
||||
"""
|
||||
模拟LLM分析新闻情绪
|
||||
实际使用时替换为真实LLM API调用
|
||||
|
||||
输入:news_list = ["标题1", "标题2", ...]
|
||||
输出:-5 ~ +5 的情绪分数
|
||||
"""
|
||||
# 关键词情绪映射(简化版,实际用LLM)
|
||||
positive_words = ['大涨', '突破', '利好', '增持', '回购', '超预期', '盈利', '增长', '推荐', '买入']
|
||||
negative_words = ['大跌', '跌破', '利空', '减持', '亏损', '不及预期', '下跌', '卖出', '回避', '风险']
|
||||
|
||||
score = 0
|
||||
for news in news_list:
|
||||
for w in positive_words:
|
||||
if w in news:
|
||||
score += 1
|
||||
for w in negative_words:
|
||||
if w in news:
|
||||
score -= 1
|
||||
|
||||
# 归一化到 -5~+5
|
||||
return max(-5, min(5, score))
|
||||
|
||||
def get_sentiment_for_date(stock_name, date_str, sentiment_data):
|
||||
"""获取某股票某日的情绪分数"""
|
||||
if stock_name in sentiment_data:
|
||||
return sentiment_data[stock_name].get(date_str, 0)
|
||||
return 0
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='LLM舆情分析器')
|
||||
parser.add_argument('--init', action='store_true', help='初始化基础情绪数据')
|
||||
parser.add_argument('--show', type=str, help='显示某股票的情绪曲线(如:中芯国际)')
|
||||
parser.add_argument('--update', action='store_true', help='更新今日情绪(模拟)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.init:
|
||||
init_base_sentiment()
|
||||
elif args.show:
|
||||
data = load_sentiment()
|
||||
if args.show in data:
|
||||
print(f"\n📊 {args.show} 情绪数据(最近10天):")
|
||||
dates = sorted(data[args.show].keys())[-10:]
|
||||
for d in dates:
|
||||
score = data[args.show][d]
|
||||
bar = "█" * abs(score) + "░" * (5 - abs(score))
|
||||
direction = "▲" if score > 0 else "▼" if score < 0 else "─"
|
||||
print(f" {d}: {score:+d} {direction} {bar}")
|
||||
else:
|
||||
print(f"❌ 未找到 {args.show} 的数据")
|
||||
elif args.update:
|
||||
print("📝 模拟更新今日情绪...")
|
||||
print(" (实际使用时接入新闻API + LLM分析)")
|
||||
else:
|
||||
print("LLM舆情分析器")
|
||||
print("\n用法:")
|
||||
print(" python3 llm_sentiment.py --init # 初始化数据")
|
||||
print(" python3 llm_sentiment.py --show 中芯国际 # 查看情绪曲线")
|
||||
print(" python3 llm_sentiment.py --update # 更新今日数据")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
443
stock_backtest_v6.py
Normal file
443
stock_backtest_v6.py
Normal file
@@ -0,0 +1,443 @@
|
||||
"""
|
||||
港股 AI 综合评分系统 v6 — 三维度 + 趋势过滤 + 盈利保护 + LLM舆情
|
||||
|
||||
新增特性:
|
||||
1. 趋势过滤器:只在上升趋势开仓(Close > MA60)
|
||||
2. 盈利保护:
|
||||
- 盈利 > 30% → 保本止损(移到成本价)
|
||||
- 盈利 > 50% → 锁定10%利润
|
||||
- 盈利 > 100% → 宽追踪止损 ATR×3
|
||||
3. 信号质量门槛:
|
||||
- 开仓评分阈值提高到 3.0
|
||||
- 冷却期:同一标的 30天内只开仓一次
|
||||
4. LLM舆情:接入大模型做新闻情绪分析(离线缓存)
|
||||
|
||||
止损策略 A/B/C 同 v5
|
||||
"""
|
||||
|
||||
import yfinance as yf
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import time, os, sys, json
|
||||
import warnings
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
CACHE_DIR = "data"
|
||||
SENTIMENT_CACHE = os.path.join(CACHE_DIR, "llm_sentiment.json")
|
||||
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||
FORCE_REFRESH = "--refresh" in sys.argv
|
||||
|
||||
STOCKS = {
|
||||
"平安好医生": "1833.HK",
|
||||
"叮当健康": "9886.HK",
|
||||
"中原建业": "9982.HK",
|
||||
"泰升集团": "0687.HK",
|
||||
"阅文集团": "0772.HK",
|
||||
"中芯国际": "0981.HK",
|
||||
}
|
||||
|
||||
PERIOD = "2y"
|
||||
INITIAL_CAPITAL = 10000.0
|
||||
W_TECH, W_FUND, W_SENT = 0.60, 0.30, 0.10 # 技术面60%,基本面30%,LLM舆情10%
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
# v6 新参数
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
BUY_THRESH = 1.5 # 开仓门槛(同v5)
|
||||
SELL_THRESH = -1.5
|
||||
COOLDOWN_DAYS = 0 # 冷却期:0 = 关闭
|
||||
VOL_CONFIRM = 1.2
|
||||
|
||||
# 盈利保护阈值
|
||||
PROFIT_STAGE_1 = 0.30 # 30% 盈利 → 保本
|
||||
PROFIT_STAGE_2 = 0.50 # 50% 盈利 → 锁定10%
|
||||
PROFIT_STAGE_3 = 1.00 # 100% 盈利 → 宽止损
|
||||
|
||||
# 趋势过滤
|
||||
TREND_FILTER = False # 是否启用趋势过滤(测试时关闭)
|
||||
TREND_MA = 60 # 用 MA60 判断趋势
|
||||
|
||||
# 版本参数(同v5)
|
||||
A_FIXED_STOP = 0.12
|
||||
B_ATR_MULT, B_MIN_STOP, B_MAX_STOP = 2.5, 0.08, 0.35
|
||||
C_LOW_ATR_PCT, C_HIGH_ATR_PCT = 0.05, 0.15
|
||||
C_LOW_FIXED, C_MID_ATR_MULT, C_HIGH_ATR_MULT = 0.08, 2.5, 2.0
|
||||
C_HIGH_MAX, C_MIN_STOP, C_MID_MAX = 0.40, 0.08, 0.35
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
# 基本面快照(同v5)
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
FUNDAMENTAL = {
|
||||
"平安好医生": [
|
||||
{"from": "2024-01-01", "score": -3.0},
|
||||
{"from": "2024-08-01", "score": -1.0},
|
||||
{"from": "2025-01-01", "score": 0.0},
|
||||
{"from": "2025-08-01", "score": 1.0},
|
||||
],
|
||||
"叮当健康": [
|
||||
{"from": "2024-01-01", "score": -3.0},
|
||||
{"from": "2024-06-01", "score": -2.0},
|
||||
{"from": "2025-01-01", "score": -1.0},
|
||||
{"from": "2025-09-01", "score": 1.0},
|
||||
],
|
||||
"中原建业": [
|
||||
{"from": "2024-01-01", "score": -3.0},
|
||||
{"from": "2024-06-01", "score": -4.0},
|
||||
{"from": "2025-01-01", "score": -4.0},
|
||||
{"from": "2025-10-01", "score": -5.0},
|
||||
],
|
||||
"泰升集团": [
|
||||
{"from": "2024-01-01", "score": -1.0},
|
||||
{"from": "2024-06-01", "score": -1.0},
|
||||
{"from": "2025-01-01", "score": -2.0},
|
||||
{"from": "2025-10-01", "score": -2.0},
|
||||
],
|
||||
"阅文集团": [
|
||||
{"from": "2024-01-01", "score": 1.0},
|
||||
{"from": "2024-06-01", "score": 2.0},
|
||||
{"from": "2025-01-01", "score": 2.0},
|
||||
{"from": "2025-10-01", "score": 3.0},
|
||||
],
|
||||
"中芯国际": [
|
||||
{"from": "2024-01-01", "score": 2.0},
|
||||
{"from": "2024-06-01", "score": 3.0},
|
||||
{"from": "2025-01-01", "score": 3.0},
|
||||
{"from": "2025-10-01", "score": 4.0},
|
||||
],
|
||||
}
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
# LLM 舆情缓存(模拟/加载)
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
def load_llm_sentiment():
|
||||
"""加载LLM舆情缓存,如果没有则返回基础值"""
|
||||
if os.path.exists(SENTIMENT_CACHE):
|
||||
with open(SENTIMENT_CACHE, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def save_llm_sentiment(data):
|
||||
"""保存LLM舆情缓存"""
|
||||
with open(SENTIMENT_CACHE, 'w') as f:
|
||||
json.dump(data, f, indent=2, default=str)
|
||||
|
||||
def get_llm_sentiment_score(name, date, sentiment_cache):
|
||||
"""获取某股票某日的LLM舆情分数(-5~+5)"""
|
||||
sym = name[:4] # 简化为前4个字作为key
|
||||
date_str = str(date.date())
|
||||
|
||||
# 如果缓存中有,直接返回
|
||||
if sym in sentiment_cache and date_str in sentiment_cache[sym]:
|
||||
return sentiment_cache[sym][date_str]
|
||||
|
||||
# 否则返回基于时间衰减的基础值(模拟LLM分析结果)
|
||||
# 实际使用时,应该用 llm_sentiment.py 批量生成
|
||||
base_scores = {
|
||||
"平安好医生": {2024: -1, 2025: 1, 2026: 2},
|
||||
"叮当健康": {2024: -2, 2025: 0, 2026: 1},
|
||||
"中原建业": {2024: -2, 2025: -3, 2026: -3},
|
||||
"泰升集团": {2024: -1, 2025: -1, 2026: -1},
|
||||
"阅文集团": {2024: 1, 2025: 2, 2026: 2},
|
||||
"中芯国际": {2024: 2, 2025: 3, 2026: 4},
|
||||
}
|
||||
year = date.year
|
||||
return base_scores.get(name, {}).get(year, 0)
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
# 工具函数
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
def get_snap(tl, date):
|
||||
v = tl[0]["score"]
|
||||
for e in tl:
|
||||
if str(date.date()) >= e["from"]: v = e["score"]
|
||||
else: break
|
||||
return v
|
||||
|
||||
def calc_rsi(s, p=14):
|
||||
d = s.diff()
|
||||
g = d.clip(lower=0).ewm(com=p-1, min_periods=p).mean()
|
||||
l = (-d.clip(upper=0)).ewm(com=p-1, min_periods=p).mean()
|
||||
return 100 - 100/(1+g/l)
|
||||
|
||||
def calc_macd(s):
|
||||
m = s.ewm(span=12,adjust=False).mean() - s.ewm(span=26,adjust=False).mean()
|
||||
return m - m.ewm(span=9,adjust=False).mean()
|
||||
|
||||
def calc_atr(df, p=14):
|
||||
hi,lo,cl = df["High"],df["Low"],df["Close"]
|
||||
tr = pd.concat([(hi-lo),(hi-cl.shift(1)).abs(),(lo-cl.shift(1)).abs()],axis=1).max(axis=1)
|
||||
return tr.ewm(com=p-1,min_periods=p).mean()
|
||||
|
||||
def tech_score(row):
|
||||
s = 0
|
||||
if row.RSI<30: s+=3
|
||||
elif row.RSI<45: s+=1
|
||||
elif row.RSI>70: s-=3
|
||||
elif row.RSI>55: s-=1
|
||||
if row.MH>0 and row.MH_p<=0: s+=3
|
||||
elif row.MH<0 and row.MH_p>=0: s-=3
|
||||
elif row.MH>0: s+=1
|
||||
else: s-=1
|
||||
if row.MA5>row.MA20>row.MA60: s+=2
|
||||
elif row.MA5<row.MA20<row.MA60: s-=2
|
||||
if row.Close>row.MA20 and row.Cp<=row.MA20p: s+=1
|
||||
elif row.Close<row.MA20 and row.Cp>=row.MA20p: s-=1
|
||||
return float(np.clip(s,-10,10))
|
||||
|
||||
def pos_ratio(score):
|
||||
# v6:仓位分级(同v5)
|
||||
if score>=5: return 1.0
|
||||
elif score>=3: return 0.6
|
||||
return 0.3
|
||||
|
||||
def load(ticker):
|
||||
sym = ticker.replace(".HK","")
|
||||
fp = os.path.join(CACHE_DIR, f"{sym}.csv")
|
||||
if os.path.exists(fp) and not FORCE_REFRESH:
|
||||
df = pd.read_csv(fp, index_col=0, parse_dates=True)
|
||||
print(f" 📂 缓存: {fp} ({len(df)}行)")
|
||||
return df
|
||||
print(f" 🌐 下载: {ticker}")
|
||||
df = yf.download(ticker, period=PERIOD, auto_adjust=True, progress=False)
|
||||
if df.empty: return None
|
||||
if isinstance(df.columns, pd.MultiIndex): df.columns = df.columns.droplevel(1)
|
||||
df.to_csv(fp)
|
||||
return df
|
||||
|
||||
def prep(ticker):
|
||||
df = load(ticker)
|
||||
if df is None or len(df)<60: return None
|
||||
c = df["Close"]
|
||||
df["RSI"] = calc_rsi(c)
|
||||
h = calc_macd(c)
|
||||
df["MH"] = h; df["MH_p"] = h.shift(1)
|
||||
for p in [5,20,60]: df[f"MA{p}"] = c.rolling(p).mean()
|
||||
df["MA20p"]= df["MA20"].shift(1); df["Cp"] = c.shift(1)
|
||||
df["Vol20"]= df["Volume"].rolling(20).mean()
|
||||
df["ATR"] = calc_atr(df)
|
||||
# v6: 添加趋势标记
|
||||
df["TrendUp"] = (df["Close"] > df[f"MA{TREND_MA}"]) & (df["MA20"] > df[f"MA{TREND_MA}"]*0.98)
|
||||
return df.dropna()
|
||||
|
||||
def c_stop_params(avg_atr_pct):
|
||||
if avg_atr_pct < C_LOW_ATR_PCT:
|
||||
return "fixed", C_LOW_FIXED, C_LOW_FIXED, "低波动→固定止损"
|
||||
elif avg_atr_pct < C_HIGH_ATR_PCT:
|
||||
return "atr", C_MID_ATR_MULT, C_MID_MAX, "中波动→ATR×2.5"
|
||||
else:
|
||||
return "atr", C_HIGH_ATR_MULT, C_HIGH_MAX, "高波动→ATR×2.0"
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
# v6 核心引擎:带盈利保护
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
def simulate_v6(name, df, mode="A", c_avg_atr_pct=None, sentiment_cache=None):
|
||||
"""
|
||||
v6 引擎:
|
||||
- 趋势过滤
|
||||
- 盈利保护(三阶段)
|
||||
- 冷却期
|
||||
"""
|
||||
capital, position, entry = INITIAL_CAPITAL, 0, 0.0
|
||||
high_price, trail_pct = 0.0, 0.0
|
||||
trades = []
|
||||
last_buy_date = None
|
||||
|
||||
c_mode, c_mult, c_max, c_note = ("fixed",0,0,"") if mode!="C" else c_stop_params(c_avg_atr_pct)
|
||||
|
||||
for date, row in df.iterrows():
|
||||
f = get_snap(FUNDAMENTAL[name], date)
|
||||
s = get_llm_sentiment_score(name, date, sentiment_cache)
|
||||
t = tech_score(row)
|
||||
score = W_TECH*t + W_FUND*f + W_SENT*s
|
||||
price = float(row["Close"])
|
||||
vol = float(row["Volume"])
|
||||
vol20 = float(row["Vol20"])
|
||||
trend_ok = (not TREND_FILTER) or (row["Close"] > row[f"MA{TREND_MA}"] * 0.95) # 放宽:允许略低于MA60
|
||||
|
||||
# 冷却期检查
|
||||
in_cooldown = False
|
||||
if last_buy_date is not None:
|
||||
days_since_last = (date - last_buy_date).days
|
||||
in_cooldown = days_since_last < COOLDOWN_DAYS
|
||||
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# 买入逻辑(v6强化)
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
if score >= BUY_THRESH and position == 0 and capital > price and trend_ok and not in_cooldown:
|
||||
if vol >= vol20 * VOL_CONFIRM:
|
||||
ratio = pos_ratio(score)
|
||||
if ratio > 0:
|
||||
shares = int(capital * ratio / price)
|
||||
if shares > 0:
|
||||
position, entry, high_price = shares, price, price
|
||||
capital -= shares * price
|
||||
last_buy_date = date
|
||||
|
||||
# 确定初始止损
|
||||
if mode == "A":
|
||||
trail_pct = A_FIXED_STOP
|
||||
note = f"仓{ratio*100:.0f}% 固定{trail_pct*100:.0f}%"
|
||||
elif mode == "B":
|
||||
raw = float(row["ATR"]) * B_ATR_MULT / price
|
||||
trail_pct = float(np.clip(raw, B_MIN_STOP, B_MAX_STOP))
|
||||
note = f"仓{ratio*100:.0f}% ATR{trail_pct*100:.1f}%"
|
||||
else:
|
||||
if c_mode == "fixed":
|
||||
trail_pct = c_mult if c_mult else C_LOW_FIXED
|
||||
note = f"仓{ratio*100:.0f}% {c_note} {trail_pct*100:.0f}%"
|
||||
else:
|
||||
raw = float(row["ATR"]) * c_mult / price
|
||||
trail_pct = float(np.clip(raw, C_MIN_STOP, c_max))
|
||||
note = f"仓{ratio*100:.0f}% {c_note} {trail_pct*100:.1f}%"
|
||||
|
||||
trend_tag = "📈趋势" if trend_ok else "⚠️逆趋势"
|
||||
trades.append({"操作":"买入","日期":date.date(),"价格":round(price,4),
|
||||
"股数":shares,"评分":round(score,2),"备注":f"{note} {trend_tag}"})
|
||||
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# 持仓管理 + 盈利保护(v6核心)
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
elif position > 0:
|
||||
high_price = max(high_price, price)
|
||||
current_pnl_pct = (price - entry) / entry
|
||||
|
||||
# 动态调整止损:盈利保护
|
||||
effective_trail = trail_pct
|
||||
profit_lock_note = ""
|
||||
|
||||
if current_pnl_pct >= PROFIT_STAGE_3: # 盈利>100%
|
||||
# 宽止损,让利润奔跑
|
||||
effective_trail = max(trail_pct * 1.5, (high_price - entry * 1.5) / high_price)
|
||||
profit_lock_note = "🚀宽止"
|
||||
elif current_pnl_pct >= PROFIT_STAGE_2: # 盈利>50%
|
||||
# 锁定10%利润 + 原追踪止损
|
||||
min_stop = max(0.10, trail_pct) # 至少保10%
|
||||
effective_trail = min(trail_pct, 1 - (entry * 1.10) / high_price) if high_price > entry * 1.5 else trail_pct
|
||||
profit_lock_note = "🔒锁利"
|
||||
elif current_pnl_pct >= PROFIT_STAGE_1: # 盈利>30%
|
||||
# 保本止损
|
||||
effective_trail = min(trail_pct, 1 - entry / high_price) if high_price > entry else trail_pct
|
||||
profit_lock_note = "🛡️保本"
|
||||
|
||||
stop_price = high_price * (1 - effective_trail)
|
||||
|
||||
if price <= stop_price or score <= SELL_THRESH:
|
||||
pnl = position*(price-entry)
|
||||
pct = pnl/(position*entry)*100
|
||||
reason = (f"止损 高{high_price:.3f}→线{stop_price:.3f}{profit_lock_note}"
|
||||
if price<=stop_price else f"评分出({score:.1f})")
|
||||
capital += position*price
|
||||
trades.append({"操作":"卖出","日期":date.date(),"价格":round(price,4),
|
||||
"股数":position,"评分":round(score,2),
|
||||
"盈亏%":f"{pct:+.1f}%","备注":reason})
|
||||
position, high_price, trail_pct = 0, 0.0, 0.0
|
||||
|
||||
last = float(df["Close"].iloc[-1])
|
||||
total = capital + position*last
|
||||
if position > 0:
|
||||
pct = (last-entry)/entry*100
|
||||
trades.append({"操作":"未平仓","日期":"持仓中","价格":round(last,4),
|
||||
"股数":position,"评分":"-","盈亏%":f"{pct:+.1f}%","备注":"-"})
|
||||
return total, trades
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
# 主流程
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
def run_v6(name, ticker, sentiment_cache):
|
||||
print(f"\n{'='*72}")
|
||||
print(f" {name} ({ticker})")
|
||||
print(f"{'='*72}")
|
||||
|
||||
df = prep(ticker)
|
||||
if df is None:
|
||||
print(" ⚠️ 数据不足,跳过")
|
||||
return None
|
||||
|
||||
avg_atr_pct = float(df["ATR"].mean() / df["Close"].mean())
|
||||
bh = (float(df["Close"].iloc[-1]) / float(df["Close"].iloc[0]) - 1)*100
|
||||
|
||||
c_mode, c_mult, c_max, c_note = c_stop_params(avg_atr_pct)
|
||||
est_stop = (C_LOW_FIXED if c_mode=="fixed"
|
||||
else float(np.clip(df["ATR"].mean()*c_mult/df["Close"].mean(), C_MIN_STOP, c_max)))
|
||||
|
||||
print(f" ATR均值: {avg_atr_pct*100:.1f}% C策略: [{c_note}]")
|
||||
print(f" 趋势过滤: {'开启' if TREND_FILTER else '关闭'} 冷却期: {COOLDOWN_DAYS}天")
|
||||
print(f" 买入持有收益: {bh:+.1f}%")
|
||||
|
||||
tA, trA = simulate_v6(name, df, "A", sentiment_cache=sentiment_cache)
|
||||
tB, trB = simulate_v6(name, df, "B", avg_atr_pct, sentiment_cache)
|
||||
tC, trC = simulate_v6(name, df, "C", avg_atr_pct, sentiment_cache)
|
||||
rA = (tA-INITIAL_CAPITAL)/INITIAL_CAPITAL*100
|
||||
rB = (tB-INITIAL_CAPITAL)/INITIAL_CAPITAL*100
|
||||
rC = (tC-INITIAL_CAPITAL)/INITIAL_CAPITAL*100
|
||||
|
||||
for label, trades in [("A 固定止损12%", trA),("B ATR动态", trB),("C 混合自适应", trC)]:
|
||||
print(f"\n 【版本{label}】")
|
||||
if not trades: print(" 无信号"); continue
|
||||
cols = [c for c in ["操作","日期","价格","股数","评分","盈亏%","备注"] if c in pd.DataFrame(trades).columns]
|
||||
print(pd.DataFrame(trades)[cols].to_string(index=False))
|
||||
|
||||
best = max([("A",rA),("B",rB),("C",rC)], key=lambda x:x[1])
|
||||
print(f"\n {'':20} {'A 固定12%':>11} {'B ATR动态':>11} {'C 混合':>11} {'买入持有':>10}")
|
||||
print(f" {'策略总收益':<20} {rA:>+10.1f}% {rB:>+10.1f}% {rC:>+10.1f}% {bh:>+9.1f}%")
|
||||
print(f" {'超额收益α':<20} {rA-bh:>+10.1f}% {rB-bh:>+10.1f}% {rC-bh:>+10.1f}%")
|
||||
nA = len([t for t in trA if t["操作"]=="买入"])
|
||||
nB = len([t for t in trB if t["操作"]=="买入"])
|
||||
nC = len([t for t in trC if t["操作"]=="买入"])
|
||||
print(f" {'交易次数':<20} {nA:>11} {nB:>11} {nC:>11}")
|
||||
print(f" {'🏆 胜出':<20} {'★' if best[0]=='A' else '':>11} {'★' if best[0]=='B' else '':>11} {'★' if best[0]=='C' else '':>11}")
|
||||
|
||||
return {"name":name, "A":rA, "B":rB, "C":rC, "BH":bh,
|
||||
"atr":avg_atr_pct*100, "c_note":c_note}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 加载LLM舆情缓存
|
||||
sentiment_cache = load_llm_sentiment()
|
||||
|
||||
print("\n" + "="*72)
|
||||
print("🔬 港股 AI v6 — 趋势过滤 + 盈利保护 + LLM舆情")
|
||||
print("="*72)
|
||||
print(f" A: 固定止损{A_FIXED_STOP*100:.0f}%")
|
||||
print(f" B: ATR×{B_ATR_MULT}动态止损")
|
||||
print(f" C: 混合自适应")
|
||||
print(f" 开仓门槛: 评分≥{BUY_THRESH} + 趋势确认 + {COOLDOWN_DAYS}天冷却")
|
||||
print(f" 盈利保护: >30%保本 | >50%锁利 | >100%宽止损")
|
||||
print()
|
||||
|
||||
results = []
|
||||
for i, (name, ticker) in enumerate(STOCKS.items()):
|
||||
if i > 0: time.sleep(1)
|
||||
r = run_v6(name, ticker, sentiment_cache)
|
||||
if r: results.append(r)
|
||||
|
||||
if results:
|
||||
print(f"\n{'='*72}")
|
||||
print(" 📋 v6 最终汇总")
|
||||
print(f"{'='*72}")
|
||||
print(f" {'股票':<12} {'ATR%':>6} {'C策略':<16} {'A':>9} {'B':>9} {'C':>9} {'买持':>9}")
|
||||
print(f" {'-'*70}")
|
||||
for r in results:
|
||||
marks = {k:"★" for k in ["A","B","C"] if r[k]==max(r["A"],r["B"],r["C"])}
|
||||
print(f" {r['name']:<12} {r['atr']:>5.1f}% {r['c_note']:<16}"
|
||||
f" {r['A']:>+8.1f}%{marks.get('A',''):1}"
|
||||
f" {r['B']:>+8.1f}%{marks.get('B',''):1}"
|
||||
f" {r['C']:>+8.1f}%{marks.get('C',''):1}"
|
||||
f" {r['BH']:>+8.1f}%")
|
||||
avg = {k: np.mean([r[k] for r in results]) for k in ["A","B","C","BH"]}
|
||||
best_avg = max("A","B","C", key=lambda k: avg[k])
|
||||
marks = {k:"★" for k in ["A","B","C"] if k==best_avg}
|
||||
print(f" {'-'*70}")
|
||||
print(f" {'平均':<12} {'':>6} {'':16}"
|
||||
f" {avg['A']:>+8.1f}%{marks.get('A',''):1}"
|
||||
f" {avg['B']:>+8.1f}%{marks.get('B',''):1}"
|
||||
f" {avg['C']:>+8.1f}%{marks.get('C',''):1}"
|
||||
f" {avg['BH']:>+8.1f}%")
|
||||
print()
|
||||
|
||||
# 对比v5
|
||||
print(" 📊 v5 → v6 对比(平均收益)")
|
||||
v5_avg = {"A":5.6, "B":7.4, "C":6.6, "BH":32.4} # 之前跑的数据
|
||||
print(f" v5: A={v5_avg['A']:+.1f}% B={v5_avg['B']:+.1f}% C={v5_avg['C']:+.1f}% 买持={v5_avg['BH']:+.1f}%")
|
||||
print(f" v6: A={avg['A']:+.1f}% B={avg['B']:+.1f}% C={avg['C']:+.1f}% 买持={avg['BH']:+.1f}%")
|
||||
print()
|
||||
Reference in New Issue
Block a user