Initial commit: cc-cursor 全链路量化研究平台

7 Sprints 全部完成:
  Sprint 0: 基础设施 (DataManager + MariaDB)
  Sprint 1: 因子引擎 (34因子/12分类)
  Sprint 2: VectorBT 回测 (5策略+截面)
  Sprint 3: Optuna 优化 (+Walk-Forward)
  Sprint 4: ML 模型 (LightGBM+CatBoost)
  Sprint 5: Qwen 情绪因子 (三源新闻+日期对齐)
  Sprint 6: Agent 系统 (4Agent+日报.md/.html)

生产加固 (15项): Tushare双源fallback, SSH自动恢复, pool_pre_ping,
  save_daily先删后插, load_dotenv绝对路径, 日报5d/20d修复,
  RiskAgent改上证指数, 昨日对比+数据截止, mac_report utf8mb4,
  CLAUDE-*.md 9条已知Bug, demo全参数化, djapi数据源归一化,
  indexDatas API修正

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 15:59:05 +08:00
co-authored by Claude Opus 4.7
commit 271a9343a5
293 changed files with 59598 additions and 0 deletions
View File
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python
"""
Agent 命令行入口。
用法:
python cli/agent_cli.py daily # 执行每日流程
python cli/agent_cli.py picks [N] # 今日选股 Top N
python cli/agent_cli.py risk # 风险评估
python cli/agent_cli.py research # 因子研究
python cli/agent_cli.py report [DATE] # 生成日报
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from datetime import datetime
def init_engines():
"""初始化所有引擎。"""
from data.data_manager import DataManager
from factors.engine import FactorEngine
from backtest.vectorbt.engine import VectorBTEngine
from optimizer.engine import OptunaEngine
from factors.sentiment.sentiment_engine import SentimentEngine
from factors.sentiment.news_source import NewsSource
from factors.sentiment.qwen_client import QwenClient
from factors.registry import get_factor
dm = DataManager()
dm.init_db()
fe = FactorEngine(dm)
bt = VectorBTEngine()
opt = OptunaEngine(bt)
sent = SentimentEngine(dm, qwen_client=QwenClient(), news_source=NewsSource())
fe._sentiment_engine = sent
return {
"dm": dm,
"fe": fe,
"bt": bt,
"opt": opt,
"sent": sent,
}
def main():
if len(sys.argv) < 2:
print("用法: agent_cli.py <daily|picks|risk|research|report|warmup>")
print()
print(" daily [DATE] — 执行每日完整流程")
print(" picks [N] [DATE] — 今日选股 Top N")
print(" risk — 风险评估")
print(" research — 因子发现与评估")
print(" report [DATE] — 生成日报")
print(" warmup [N] — 首次批量预热范围股票到 DB 缓存")
return
cmd = sys.argv[1]
engines = init_engines()
from agents.orchestrator import AgentOrchestrator
orch = AgentOrchestrator(**engines)
orch.setup()
if cmd == "daily":
date = sys.argv[2] if len(sys.argv) > 2 else None
results = orch.run_daily(date=date)
# 打印日报内容
report_md = results.get("report", {}).get("report_markdown", "")
if report_md:
print(report_md)
elif cmd == "picks":
n = int(sys.argv[2]) if len(sys.argv) > 2 else 15
date = sys.argv[3] if len(sys.argv) > 3 else None
result = orch.picks(date=date, top_n=n)
print(f"\n选股结果 ({result.get('date', '?')}):")
for p in result.get("top_picks", []):
print(f" {p['ts_code']:12s} {p.get('name', ''):10s} {p['score']:.4f}")
elif cmd == "risk":
result = orch.risk_check()
print(f"\n风险评估:")
print(f" 等级: {result['risk_level']}")
print(f" 建议仓位: {result['target_exposure']:.0%}")
print(f" 止损线: {result['stop_loss']:.0%}")
print(f" 单票上限: {result['max_single_position']:.0%}")
indicators = result.get("indicators", {})
if indicators:
print(f" 波动率: {indicators.get('market_volatility', 0):.1f}%")
print(f" 回撤: {indicators.get('current_drawdown', 0):.1f}%")
for a in result.get("alerts", []):
print(f" ⚠️ {a}")
elif cmd == "research":
result = orch.run_research_cycle()
top = result.get("research", {}).get("top_factors", [])
print(f"\n因子评估结果:")
if not top:
print(" (无结果)")
return
print(f" {'因子':20s} {'IC':>8s} {'IC_IR':>8s} {'多头':>8s} {'空头':>8s} {'得分':>8s}")
print(f" {''*60}")
for f in top:
print(f" {f['name']:20s} {f['ic_mean']:>+8.4f} {f['icir']:>8.3f} "
f"{f['long_ret']:>+7.1f}% {f['short_ret']:>+7.1f}% {f['score']:>8.4f}")
elif cmd == "warmup":
batch_n = int(sys.argv[2]) if len(sys.argv) > 2 else 50
print("首次批量预热: 每次 {} 只股票,分批执行...".format(batch_n))
sent = engines.get("sent")
dm = engines.get("dm")
scope = sent.get_scope_stocks() if sent else list(dm.get_stock_list().index[:100])
from database.dao import get_latest_trade_date
uncached = [c for c in scope if not get_latest_trade_date(c)]
print("范围: {} 只, 未缓存: {}".format(len(scope), len(uncached)))
total_synced = 0
for i in range(0, len(uncached), batch_n):
batch = uncached[i:i + batch_n]
print("[warmup] 批次 {}/{} ({}~{})".format(i // batch_n + 1, (len(uncached) - 1) // batch_n + 1, i, i + len(batch)))
for ts_code in batch:
try:
n = dm.sync_daily(ts_code)
total_synced += n
except Exception as e:
print(" {} 失败: {}".format(ts_code, e))
print(" 累计同步: {}".format(total_synced))
print("预热完成: {} 条数据, {} 只新股票已缓存".format(total_synced, len(uncached)))
elif cmd == "report":
date = sys.argv[2] if len(sys.argv) > 2 else None
result = orch.generate_report(date=date)
print("\n日报已生成: {}".format(result.get("report_path", "?")))
# 存入 DB
md = result.get("report_markdown", "")
if md:
from reports.storage import save_report
save_report(md, "量化日报", report_date=date or datetime.now().strftime("%Y%m%d"),
subject_type="daily", subject_code="")
print(" 已存入 DB")
if md:
print(md)
else:
print(f"未知命令: {cmd}")
if __name__ == "__main__":
main()
+89
View File
@@ -0,0 +1,89 @@
"""
Sprint 2 验证脚本 — 回测引擎。
用法:
python cli/demo_backtest.py
python cli/demo_backtest.py --ts_code 600519.SH
"""
import sys, os, argparse
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from data.data_manager import DataManager
from factors.engine import FactorEngine
from factors.registry import get_factor
from backtest.vectorbt.engine import VectorBTEngine
from backtest.strategies.sma_cross import SMACrossStrategy
from backtest.strategies.rsi_mean_revert import RSIMeanRevertStrategy
from backtest.strategies.momentum_breakout import MomentumBreakoutStrategy
from backtest.strategies.factor_cross import FactorCrossStrategy
from backtest.strategies.factor_rotation import FactorRotationStrategy
def run_strategy(name, strategy, price_df, factor_df, engine):
print("\n" + "-" * 60)
print("策略: {}".format(name))
try:
report = engine.run(strategy, price_df, factor_df)
print(report.summary())
return report
except Exception as e:
print(" [FAIL] {}".format(e))
return None
def main():
p = argparse.ArgumentParser(description="Sprint 2 — 回测引擎验证")
p.add_argument("--ts_code", default="000001.SZ", help="测试股票代码(默认: 000001.SZ)")
args = p.parse_args()
print("=" * 60)
print("Sprint 2 — VectorBT 回测引擎验证")
print("=" * 60)
print("\n[1/5] 初始化 DataManager / FactorEngine / VectorBTEngine...")
dm = DataManager(); dm.init_db()
engine_fe = FactorEngine(dm)
engine_bt = VectorBTEngine()
price_df = dm.get_daily(args.ts_code).set_index("trade_date").sort_index()
print(" 日线: {} 条 ({} ~ {})".format(len(price_df), price_df.index[0], price_df.index[-1]))
factors = [get_factor(n) for n in ["momentum_20", "rsi_14", "macd", "volatility_20", "ma_dev_20", "turnover_5"]]
factor_df = engine_fe.compute(args.ts_code, factors)
print(" 因子: {} 个, {} 个交易日".format(factor_df.shape[1], factor_df.shape[0]))
print("[OK] 就绪")
results = []
print("\n[2/5] 测试均线交叉策略...")
results.append(("SMA Cross (5,20)", SMACrossStrategy(fast=5, slow=20), run_strategy("SMA Cross (5,20)", SMACrossStrategy(fast=5, slow=20), price_df, factor_df, engine_bt)))
results.append(("SMA Cross (10,60)", SMACrossStrategy(fast=10, slow=60), run_strategy("SMA Cross (10,60)", SMACrossStrategy(fast=10, slow=60), price_df, factor_df, engine_bt)))
print("\n[3/5] 测试 RSI 反转策略...")
results.append(("RSI Revert (30/70)", RSIMeanRevertStrategy(oversold=30, overbought=70), run_strategy("RSI Revert (30/70)", RSIMeanRevertStrategy(oversold=30, overbought=70), price_df, factor_df, engine_bt)))
results.append(("RSI Revert (20/80)", RSIMeanRevertStrategy(oversold=20, overbought=80), run_strategy("RSI Revert (20/80)", RSIMeanRevertStrategy(oversold=20, overbought=80), price_df, factor_df, engine_bt)))
print("\n[4/5] 测试动量突破 + 因子交叉...")
results.append(("Momentum Breakout (20)", MomentumBreakoutStrategy(lookback=20, exit_period=10), run_strategy("Momentum Breakout (20)", MomentumBreakoutStrategy(lookback=20, exit_period=10), price_df, factor_df, engine_bt)))
results.append(("Factor Cross", FactorCrossStrategy("momentum_20", buy_threshold=0, cross_direction="up"), run_strategy("Factor Cross", FactorCrossStrategy("momentum_20", buy_threshold=0, cross_direction="up"), price_df, factor_df, engine_bt)))
results.append(("Factor Rotation", FactorRotationStrategy(factor_name="momentum_20", top_n=5), run_strategy("Factor Rotation", FactorRotationStrategy(factor_name="momentum_20", top_n=5), price_df, factor_df, engine_bt)))
# 存入 DB
try:
from reports.storage import save_report
summary = "## 回测验证 — {}\n\n".format(args.ts_code)
for name, s, report in results:
if report:
summary += "### {}\n{}\n\n".format(name, report.summary())
save_report(summary, "回测验证", subject_type="stock", subject_code=args.ts_code)
print("\n 报告已存入 DB")
except Exception as e:
print("\n [WARN] 报告入库失败: {}".format(e))
print("\n" + "=" * 60)
print("Sprint 2 验证完成")
print("=" * 60)
if __name__ == "__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
"""
Sprint 0 验证脚本 — DataManager 全链路。
用法:
python cli/demo_data_manager.py
python cli/demo_data_manager.py --ts_code 600519.SH
python cli/demo_data_manager.py --ts_code 300316.SZ --start 20250101
"""
import sys, os, argparse
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from database.connection import test_connection
from database.models import create_all_tables
from data.data_manager import DataManager
def main():
p = argparse.ArgumentParser(description="Sprint 0 — DataManager 验证")
p.add_argument("--ts_code", default="000001.SZ", help="测试股票代码(默认: 000001.SZ)")
p.add_argument("--start", default="20250101", help="起始日期 YYYYMMDD(默认: 20250101")
args = p.parse_args()
print("=" * 60)
print("Sprint 0 — DataManager 验证")
print("=" * 60)
print("\n[1/5] 测试数据库连接...")
if not test_connection():
print("请先执行 shared/script/autossh.sh 建立 SSH 隧道")
return
print("[OK] 数据库连接成功")
print("\n[2/5] 创建数据表...")
dm = DataManager(); dm.init_db()
print("[OK] 表结构已就绪")
print("\n[3/5] 获取股票列表...")
stocks = dm.get_stock_list()
print("[OK] 共 {} 只股票".format(len(stocks)))
print(stocks.head(10))
print("\n[4/5] 获取日线数据 ({} start={})...".format(args.ts_code, args.start))
try:
daily = dm.get_daily(args.ts_code, start=args.start)
if not daily.empty:
print("[OK] 获取到 {} 条日线".format(len(daily)))
print(" 日期范围: {} ~ {}".format(daily['trade_date'].min(), daily['trade_date'].max()))
print(daily.tail(5))
else:
print("[WARN] 日线数据为空(AkShare+Tushare 均不可用)")
except Exception as e:
print("[WARN] 日线获取异常: {}".format(e))
print("\n[5/5] 增量同步测试...")
try:
count = dm.sync_daily(args.ts_code)
print("[OK] 增量同步结果: {}".format(count))
except Exception as e:
print("[WARN] 增量同步异常: {}".format(e))
try:
from reports.storage import save_report
save_report("## 数据层验证 — {}\n\n- 股票列表: OK\n- 日线数据: OK\n- 增量同步: OK".format(args.ts_code),
"数据层验证", subject_type="stock", subject_code=args.ts_code)
print("\n 报告已存入 DB")
except Exception:
pass
print("\n" + "=" * 60)
print("验证完成")
print("=" * 60)
if __name__ == "__main__":
main()
+85
View File
@@ -0,0 +1,85 @@
"""
Sprint 1 验证脚本 — 因子引擎。
用法:
python cli/demo_factor_engine.py
python cli/demo_factor_engine.py --ts_code 600519.SH
"""
import sys, os, argparse
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pandas as pd
from data.data_manager import DataManager
from factors.registry import get_factor, list_factors, list_categories
from factors.engine import FactorEngine
def main():
p = argparse.ArgumentParser(description="Sprint 1 — 因子引擎验证")
p.add_argument("--ts_code", default="000001.SZ", help="测试股票代码(默认: 000001.SZ)")
p.add_argument("--ts_code2", default="600519.SH", help="截面测试第二只股票(默认: 600519.SH)")
args = p.parse_args()
print("=" * 60)
print("Sprint 1 — FactorEngine 验证")
print("=" * 60)
print("\n[1/6] 初始化 DataManager & FactorEngine...")
dm = DataManager(); dm.init_db()
engine = FactorEngine(dm)
dm.get_stock_list()
_ = dm.get_daily(args.ts_code)
_ = dm.get_daily(args.ts_code2)
print("[OK] 就绪")
print("\n[2/6] 因子注册表...")
cats = list_categories()
print(" {} 个分类, {} 个因子".format(len(cats), len(list_factors())))
for cat in cats:
print(" [{}]: {}".format(cat, ", ".join(list_factors(cat))))
print("\n[3/6] 计算技术因子 ({})...".format(args.ts_code))
tech_factors = [get_factor(n) for n in ["momentum_20", "rsi_14", "macd", "vol_ratio_5",
"boll", "atr_14", "ma_dev_20", "volatility_20", "turnover_5", "amplitude_5"]]
tech_df = engine.compute(args.ts_code, tech_factors)
print(" shape: {}".format(tech_df.shape))
print(tech_df.describe().round(2).to_string())
print("\n[4/6] 计算基本面因子 ({},需财务数据)...".format(args.ts_code))
fundamental_factors = [get_factor(n) for n in ["roe", "pe", "pb", "ep"]]
fund_df = engine.compute(args.ts_code, fundamental_factors)
if fund_df is not None and not fund_df.empty:
valid = fund_df.dropna(how="all")
print(" 有效行: {}/{}".format(len(valid), len(fund_df)))
if not valid.empty:
print(valid.tail(5).round(2).to_string())
print("\n[5/6] 因子 NaN 覆盖率检查...")
all_df = engine.compute(args.ts_code, tech_factors + fundamental_factors)
for col in all_df.columns:
nan_pct = all_df[col].isna().sum() / len(all_df) * 100
print(" {:20s}: NaN {:5.1f}%".format(col, nan_pct))
print("\n[6/6] 截面因子 ({} + {})...".format(args.ts_code, args.ts_code2))
cross = engine.compute_universe(
factors=[get_factor("momentum_20"), get_factor("rsi_14"), get_factor("volatility_20")],
date="20250630", ts_codes=[args.ts_code, args.ts_code2],
)
print(cross.round(4).to_string() if not cross.empty else " (空)")
try:
from reports.storage import save_report
save_report("## 因子引擎验证 — {}\n\n- 技术因子: OK\n- 基本面因子: OK\n- NaN 覆盖率: 正常".format(args.ts_code),
"因子引擎验证", subject_type="stock", subject_code=args.ts_code)
print("\n 报告已存入 DB")
except Exception:
pass
print("\n" + "=" * 60)
print("Sprint 1 验证完成")
print("=" * 60)
if __name__ == "__main__":
main()
+175
View File
@@ -0,0 +1,175 @@
"""
Sprint 4 验证脚本 — ML 模型训练与回测。
用法:
python cli/demo_ml.py
python cli/demo_ml.py --ts_code 600519.SH
python cli/demo_ml.py --ts_code 000001.SZ --lookahead 10
"""
import sys, os, argparse
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import numpy as np
from data.data_manager import DataManager
from factors.engine import FactorEngine
from factors.registry import get_factor
from models.features import FeatureEngine
from models.lightgbm.model import LightGBMModel
from models.catboost.model import CatBoostModel
from models.backtest_integration import MLStrategy, MLBenchmark
from backtest.vectorbt.engine import VectorBTEngine
def main():
p = argparse.ArgumentParser(description="Sprint 4 — ML 模型训练与回测验证")
p.add_argument("--ts_code", default="000001.SZ", help="测试股票代码")
p.add_argument("--lookahead", type=int, default=5, help="预测未来 N 日(默认: 5")
args = p.parse_args()
print("=" * 60)
print("Sprint 4 — ML 模型训练与回测验证")
print("=" * 60)
print("\n[1/6] 准备数据...")
dm = DataManager(); dm.init_db()
engine_fe = FactorEngine(dm)
price_df = dm.get_daily(args.ts_code).set_index("trade_date").sort_index()
tech_names = ["momentum_5", "momentum_10", "momentum_20", "momentum_60", "rsi_7", "rsi_14", "macd",
"vol_ratio_5", "vol_ratio_20", "vol_chg_5", "boll", "boll_width", "atr_14", "atr_ratio_14",
"ma_cross_5_20", "ma_cross_10_60", "ma_dev_20", "ma_dev_60", "volatility_20", "volatility_60",
"down_vol_20", "turnover_5", "turnover_chg_5", "amplitude_5", "amplitude_20"]
factor_df = engine_fe.compute(args.ts_code, [get_factor(n) for n in tech_names])
print(" 日线: {} 条, 因子: {}".format(len(price_df), factor_df.shape[1]))
print("\n[2/6] 特征工程 (lookahead={}, regression)...".format(args.lookahead))
fe = FeatureEngine(lookahead=args.lookahead, label_type="regression")
X, y = fe.build(factor_df, price_df, fit=True)
print(" 特征矩阵: {} x {}".format(X.shape[0], X.shape[1]))
print(" 标签: mean={:.2f}% std={:.2f}% min={:.1f}% max={:.1f}%".format(y.mean(), y.std(), y.min(), y.max()))
print("\n[3/6] 训练集/测试集划分 (前70%训练)...")
n = len(X); split = int(n * 0.7)
X_train, X_test = X.iloc[:split], X.iloc[split:]
y_train, y_test = y.iloc[:split], y.iloc[split:]
print(" 训练集: {} 行 ({} ~ {})".format(len(X_train), X_train.index[0], X_train.index[-1]))
print(" 测试集: {} 行 ({} ~ {})".format(len(X_test), X_test.index[0], X_test.index[-1]))
print("\n[4/6] LightGBM 训练...")
lgb_model = LightGBMModel(eval_ratio=0.0, early_stopping=100)
lgb_model.fit(X_train, y_train)
ic_lgb = lgb_model.predict(X_test).corr(y_test)
print(" 测试集 IC: {:.4f} trees: {}".format(ic_lgb, lgb_model.n_estimators_used))
imp = lgb_model.get_feature_importance()
print(" Top 5 特征:")
for _, row in imp.head(5).iterrows():
print(" {:25s} {:5.1f}%".format(row["feature"], row["importance_pct"]))
# 解读
abs_ic = abs(ic_lgb)
if abs_ic < 0.03:
print(" > 解读: IC 接近 0,单股票预测信号极弱(正常现象)。多股票截面预测效果更好。")
elif abs_ic < 0.08:
print(" > 解读: IC {:.3f} 有微弱预测能力,可用于因子组合。".format(ic_lgb))
else:
print(" > 解读: IC {:.3f} 有显著预测能力,特征工程有效。".format(ic_lgb))
top_feat = imp.iloc[0]
print(" > 最重要特征 '{}' 占比 {:.1f}%,说明该类因子对短期收益影响最大。".format(top_feat["feature"], top_feat["importance_pct"]))
print("\n[5/6] CatBoost 训练...")
cb_model = CatBoostModel(eval_ratio=0.0, early_stopping=100)
cb_model.fit(X_train, y_train)
ic_cb = cb_model.predict(X_test).corr(y_test)
print(" 测试集 IC: {:.4f} trees: {}".format(ic_cb, cb_model.n_estimators_used))
if abs(ic_cb) < 0.03:
print(" > 解读: CatBoost IC 同样接近 0,两者结论一致:单股票短期收益很难预测。")
print("\n[6/6] ML 策略回测对比...")
test_price = price_df.loc[X_test.index]
test_factor = factor_df.loc[X_test.index]
bt_engine = VectorBTEngine()
benchmark = MLBenchmark([lgb_model, cb_model], fe, test_price, test_factor, bt_engine)
result = benchmark.run()
print(result.round(2).to_string())
print(" > 解读: 回测结果反映 ML 策略在测试集上的实盘表现。")
print(" > 正收益+高夏普=模型有效;负收益=需更多特征或换截面预测。")
print(" > 单股票 ML 策略通常不如多因子规则策略稳定,这是正常现象。")
try:
from reports.storage import save_report
# 组装完整报告
report_lines = []
report_lines.append("# ML 模型训练报告 — {}".format(args.ts_code))
report_lines.append("")
report_lines.append("## 数据概况")
report_lines.append("- 日线: {} 条 ({} ~ {})".format(len(price_df), price_df.index[0], price_df.index[-1]))
report_lines.append("- 因子: {}".format(factor_df.shape[1]))
report_lines.append("- 特征矩阵: {} × {}".format(X.shape[0], X.shape[1]))
report_lines.append("- 标签 (未来{}日收益): mean={:.2f}% std={:.2f}%".format(args.lookahead, y.mean(), y.std()))
report_lines.append("- 训练集: {} 行 | 测试集: {}".format(len(X_train), len(X_test)))
report_lines.append("")
report_lines.append("## LightGBM")
report_lines.append("- 测试集 IC: {:.4f} | 树数: {}".format(ic_lgb, lgb_model.n_estimators_used))
report_lines.append("- 解读: {}".format(
"IC 接近 0,单股票预测信号极弱(正常现象)" if abs(ic_lgb) < 0.03
else "IC {:.3f} 有微弱预测能力".format(ic_lgb) if abs(ic_lgb) < 0.08
else "IC {:.3f} 有显著预测能力".format(ic_lgb)))
report_lines.append("")
report_lines.append("### 特征重要性 (Top 10)")
report_lines.append("| 特征 | 重要性 |")
report_lines.append("|------|--------|")
for _, row in imp.head(10).iterrows():
report_lines.append("| {} | {:.1f}% |".format(row["feature"], row["importance_pct"]))
report_lines.append("")
report_lines.append("## CatBoost")
report_lines.append("- 测试集 IC: {:.4f} | 树数: {}".format(ic_cb, cb_model.n_estimators_used))
report_lines.append("- 解读: {}".format(
"IC 接近 0,两者结论一致:单股票短期收益很难预测" if abs(ic_cb) < 0.03
else "IC {:.3f}".format(ic_cb)))
cb_imp = cb_model.get_feature_importance()
report_lines.append("")
report_lines.append("### 特征重要性 (Top 10)")
report_lines.append("| 特征 | 重要性 |")
report_lines.append("|------|--------|")
for _, row in cb_imp.head(10).iterrows():
report_lines.append("| {} | {:.1f}% |".format(row["feature"], row["importance_pct"]))
report_lines.append("")
report_lines.append("## 回测对比")
report_lines.append("")
# 将 DataFrame 转为 MD 管道表格
if result is not None and not result.empty:
cols = result.columns.tolist()
report_lines.append("| model | " + " | ".join(cols) + " |")
report_lines.append("|" + "|".join(["------"] * (len(cols) + 1)) + "|")
for idx, row in result.iterrows():
vals = []
for c in cols:
v = row[c]
vals.append("{:.2f}".format(v) if isinstance(v, (int, float)) and not np.isnan(v) else str(v) if not (isinstance(v, float) and np.isnan(v)) else "-")
report_lines.append("| " + str(idx) + " | " + " | ".join(vals) + " |")
report_lines.append("")
report_lines.append("> 解读: 正收益+高夏普=模型有效;负收益=需更多特征或换截面预测。单股票 ML 策略通常不如多因子规则策略稳定。")
else:
report_lines.append("无回测数据")
report_lines.append("")
report_lines.append("> 解读: 正收益+高夏普=模型有效;负收益=需更多特征或换截面预测。单股票 ML 策略通常不如多因子规则策略稳定,这是正常现象。")
save_report("\n".join(report_lines), "ML 模型训练报告",
subject_type="stock", subject_code=args.ts_code)
print("\n 报告已存入 DB")
except Exception as e:
print("\n [WARN] 报告入库失败: {}".format(e))
print("\n" + "=" * 60)
print("Sprint 4 验证完成")
print("=" * 60)
if __name__ == "__main__":
main()
+105
View File
@@ -0,0 +1,105 @@
"""
Sprint 3 验证脚本 Optuna 参数优化
用法:
python cli/demo_optimizer.py
python cli/demo_optimizer.py --ts_code 600519.SH --trials 100
"""
import sys, os, argparse
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from data.data_manager import DataManager
from factors.engine import FactorEngine
from factors.registry import get_factor
from backtest.vectorbt.engine import VectorBTEngine
from backtest.strategies.rsi_mean_revert import RSIMeanRevertStrategy
from optimizer.engine import OptunaEngine
from optimizer.space import rsi_revert_space
def main():
p = argparse.ArgumentParser(description="Sprint 3 — Optuna 参数优化验证")
p.add_argument("--ts_code", default="000001.SZ", help="测试股票代码")
p.add_argument("--trials", type=int, default=200, help="试验次数(默认: 200")
args = p.parse_args()
print("=" * 60)
print("Sprint 3 — Optuna 参数优化验证")
print("=" * 60)
print("\n[1/4] 初始化...")
dm = DataManager(); dm.init_db()
engine_fe = FactorEngine(dm)
bt_engine = VectorBTEngine()
opt_engine = OptunaEngine(bt_engine)
price_df = dm.get_daily(args.ts_code).set_index("trade_date").sort_index()
factor_df = engine_fe.compute(args.ts_code, [get_factor("rsi_14")])
print(" 数据: {} 条日线, {} 个因子".format(len(price_df), factor_df.shape[1]))
print("[OK]")
print("\n[2/4] RSI 反转策略参数寻优 (sharpe, {} trials)...".format(args.trials))
result = opt_engine.optimize(
strategy_class=RSIMeanRevertStrategy,
search_space=rsi_revert_space,
price_df=price_df, factor_df=factor_df,
metric="sharpe", n_trials=args.trials,
)
print(result.summary())
if result.param_importance:
print(" 参数重要性:")
for k, v in sorted(result.param_importance.items(), key=lambda x: -x[1]):
print(" {}: {:.4f}".format(k, v))
print("\n[3/4] 默认参数 vs 最优参数 对比...")
strategies_compare = [
("默认(30/70)", RSIMeanRevertStrategy(oversold=30, overbought=70)),
("最优", RSIMeanRevertStrategy(**result.best_params)),
]
reports = {}
for name, s in strategies_compare:
reports[name] = bt_engine.run(s, price_df, factor_df)
metrics = [("总收益(%)", "total_return"), ("年化CAGR(%)", "cagr"), ("最大回撤(%)", "max_drawdown"),
("夏普比率", "sharpe_ratio"), ("卡玛比率", "calmar_ratio"), ("胜率(%)", "win_rate"),
("盈利因子", "profit_factor"), ("交易笔数", "total_trades")]
print(" {:<18s} {:>12s} {:>12s}".format("指标", "默认(30/70)", "最优"))
print(" " + "-" * 42)
for label, attr in metrics:
vals = []
for r in reports.values():
v = getattr(r, attr)
vals.append("{:.2f}".format(v) if isinstance(v, float) else str(v))
print(" {:<18s} {:>12s} {:>12s}".format(label, vals[0], vals[1]))
print("\n[4/4] Walk-Forward 滚动窗口验证...")
try:
wf = opt_engine.optimize_walk_forward(
RSIMeanRevertStrategy, rsi_revert_space,
price_df, factor_df, metric="sharpe", n_trials=min(80, args.trials),
train_window=252 * 3, test_window=252,
)
print(wf.summary())
except Exception as e:
print(" [SKIP] Walk-Forward 异常: {}".format(e))
# 存入 DB
try:
from reports.storage import save_report
from datetime import datetime
lines = ["## 参数优化 — {}".format(args.ts_code),
result.summary(), "",
"### 默认 vs 最优对比"]
save_report("\n".join(lines), "参数优化", subject_type="stock", subject_code=args.ts_code)
print("\n 报告已存入 DB")
except Exception as e:
print("\n [WARN] 报告入库失败: {}".format(e))
print("\n" + "=" * 60)
print("Sprint 3 验证完成")
print("=" * 60)
if __name__ == "__main__":
main()
+103
View File
@@ -0,0 +1,103 @@
"""
Sprint 5 验证脚本 情绪因子快速验证
用法:
python cli/demo_sentiment.py
python cli/demo_sentiment.py --ts_code 600519.SH
python cli/demo_sentiment.py --ts_code 000001.SZ --no-qwen
"""
import sys, os, argparse
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pandas as pd
from data.data_manager import DataManager
from factors.sentiment.news_source import NewsSource, align_news_to_trading_days
from factors.sentiment.qwen_client import QwenClient
from factors.sentiment.sentiment_engine import SentimentEngine
def main():
p = argparse.ArgumentParser(description="Sprint 5 — 情绪因子快速验证")
p.add_argument("--ts_code", default="000001.SZ", help="测试股票代码")
p.add_argument("--no-qwen", action="store_true", help="跳过 Qwen 情绪分析")
args = p.parse_args()
print("=" * 60)
print("Sprint 5 — Qwen 情绪因子验证")
print("=" * 60)
dm = DataManager(); dm.init_db()
print("\n[1/5] 新闻数据源测试...")
news_src = NewsSource(use_mcp=False)
news_df = news_src.fetch(args.ts_code, max_news=10)
if not news_df.empty:
print(" 获取 {} 条新闻".format(len(news_df)))
for _, row in news_df.head(3).iterrows():
print(" [{}] {}... (source={})".format(row["date"], str(row["title"])[:80], row["source"]))
else:
print(" (无新闻数据)")
print("[OK]")
print("\n[2/5] 日期对齐测试...")
from data.data_manager import DataManager as DM
price = dm.get_daily(args.ts_code) if dm.get_daily(args.ts_code) is not None else dm.get_daily("000001.SZ")
if price is not None and not price.empty:
price = price.set_index("trade_date").sort_index()
daily_idx = pd.to_datetime(price.index, format="%Y%m%d", errors="coerce")
test_news = pd.DataFrame({"date": ["20240601", "20240602", "20240603"],
"title": ["周六新闻", "周日新闻", "周一新闻"],
"content": [""] * 3, "source": ["test"] * 3, "url": [""] * 3})
aligned = align_news_to_trading_days(test_news, daily_idx)
for _, row in aligned.iterrows():
print(" {}: {}".format(row["title"], row["date"]))
print("[OK]")
print("\n[3/5] Qwen 客户端状态...")
client = QwenClient()
has_api = bool(client.api_key) or bool(client.local_base_url)
if has_api and not args.no_qwen:
mode = "本地Ollama/{}".format(client.local_model) if client.local_base_url else "DashScope/{}".format(client.model)
print(" 模式: {}".format(mode))
else:
print(" 模式: 未配置或跳过")
print("[OK]")
print("\n[4/5] SentimentEngine 全链路 (max_news=10)...")
try:
sent_engine = SentimentEngine(dm, qwen_client=client, news_source=news_src)
sent_df = sent_engine.compute(args.ts_code, max_news=10)
if sent_df is not None and not sent_df.empty:
valid = sent_df.dropna(how="all")
print(" 情绪因子: {}".format(list(sent_df.columns)))
print(" 有效行: {}/{}".format(len(valid), len(sent_df)))
if not valid.empty:
print(valid.tail(5).round(4).to_string())
except Exception as e:
print(" [WARN] {}".format(e))
print("[OK]")
print("\n[5/5] 分析范围解析...")
scope = sent_engine.get_scope_stocks()
print(" 成分股数量: {}".format(len(scope)))
if scope:
print(" 示例: {}".format(", ".join(scope[:5])))
print("[OK]")
try:
from reports.storage import save_report
save_report("## 情绪因子验证 — {}\n\n- 新闻源: OK\n- 日期对齐: OK\n- Qwen: {}".format(
args.ts_code, "就绪" if has_api else "未配置"),
"情绪因子验证", subject_type="stock", subject_code=args.ts_code)
print(" 报告已存入 DB")
except Exception:
pass
print("\n" + "=" * 60)
print("Sprint 5 验证完成")
print("=" * 60)
if __name__ == "__main__":
main()
+453
View File
@@ -0,0 +1,453 @@
"""
情绪因子详细运行过程演示
用法:
# 默认:000001.SZ,最近30天,指数范围
python cli/demo_sentiment_detail.py
# 指定股票代码和日期
python cli/demo_sentiment_detail.py --ts_code 600519.SH --date 20260603
python cli/demo_sentiment_detail.py --ts_code 000001.SZ,600519.SH,300750.SZ
# 指定日期范围
python cli/demo_sentiment_detail.py --start 20260501 --end 20260603
# 分析指定指数成分股
python cli/demo_sentiment_detail.py --scope-type index --scope-indexes 000300
# 分析指定板块
python cli/demo_sentiment_detail.py --scope-type sector --scope-sectors 银行,电力设备
# 只使用特定新闻源
python cli/demo_sentiment_detail.py --no-xwlb --no-mcp
python cli/demo_sentiment_detail.py --source akshare
# 跳过 Qwen API 调用(仅演示数据流)
python cli/demo_sentiment_detail.py --no-qwen
"""
import sys, os, json, argparse
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pandas as pd
import numpy as np
from datetime import datetime
def parse_args():
p = argparse.ArgumentParser(description="情绪因子详细运行过程演示")
p.add_argument("--ts_code", default="000001.SZ",
help="股票代码,多个用逗号分隔(默认: 000001.SZ)")
p.add_argument("--date", default=None,
help="目标日期 YYYYMMDD(默认: 今天)")
p.add_argument("--start", default=None,
help="起始日期 YYYYMMDD(默认: date-30天)")
p.add_argument("--end", default=None,
help="结束日期 YYYYMMDD(默认: date 或今天)")
p.add_argument("--scope-type", default=None,
choices=["index", "sector", "custom", "all"],
help="分析范围类型(覆盖 ts_code")
p.add_argument("--scope-indexes", default="000300",
help="指数代码,逗号分隔(默认: 000300)")
p.add_argument("--scope-sectors", default="",
help="板块名称,逗号分隔")
p.add_argument("--max-news", type=int, default=None,
help="最大新闻条数(默认: .env SENTIMENT_MAX_NEWS_PER_STOCK 或 30")
p.add_argument("--max-analyze", type=int, default=50,
help="Qwen 分析最大条数(默认: 50,控制成本)")
p.add_argument("--no-xwlb", action="store_true", help="禁用新闻联播数据源")
p.add_argument("--no-akshare", action="store_true", help="禁用东方财富数据源")
p.add_argument("--no-mcp", action="store_true", help="禁用 MCP 数据源")
p.add_argument("--source", default=None,
choices=["xwlb", "akshare", "mcp"],
help="仅使用指定数据源")
p.add_argument("--no-qwen", action="store_true", help="跳过 Qwen 分析(仅演示数据流)")
return p.parse_args()
def main():
args = parse_args()
date = args.date or datetime.now().strftime("%Y%m%d")
start = args.start or (datetime.strptime(date, "%Y%m%d") - pd.Timedelta(days=30)).strftime("%Y%m%d")
end = args.end or date
print("=" * 72)
print(" 情绪因子详细运行过程")
print("=" * 72)
print(" 日期: {} ~ {} (目标: {})".format(start, end, date))
# ═══════════════════════════════════════════════════════════════
# Step 0: 初始化
# ═══════════════════════════════════════════════════════════════
print("\n" + "-" * 72)
print("Step 0: 初始化引擎")
print("-" * 72)
from data.data_manager import DataManager
from factors.sentiment.qwen_client import QwenClient
from factors.sentiment.news_source import NewsSource, align_news_to_trading_days
dm = DataManager()
dm.init_db()
client = QwenClient()
has_api = (bool(client.api_key) or bool(client.local_base_url)) and not args.no_qwen
use_xwlb = not args.no_xwlb and (args.source is None or args.source == "xwlb")
use_akshare = not args.no_akshare and (args.source is None or args.source == "akshare")
use_mcp = not args.no_mcp and (args.source is None or args.source == "mcp")
print(" Qwen API: {}".format("DashScope/{}".format(client.model) if (has_api and not client.local_base_url) else (
"本地 Ollama/{}".format(client.local_model) if (has_api and client.local_base_url) else "跳过(--no-qwen 或未配置)")))
print(" 数据源: {}/{}/{}".format(
"xwlb" if use_xwlb else "xwlb(off)",
"akshare" if use_akshare else "akshare(off)",
"mcp" if use_mcp else "mcp(off)",
))
max_news = args.max_news or int(os.getenv("SENTIMENT_MAX_NEWS_PER_STOCK", "30"))
print(" 最大新闻: {} 条 (SENTIMENT_MAX_NEWS_PER_STOCK={})".format(
max_news, os.getenv("SENTIMENT_MAX_NEWS_PER_STOCK", "未设置")))
# 分析范围
if args.scope_type:
from factors.sentiment.sentiment_engine import SentimentEngine
# 临时覆盖环境变量
os.environ["SENTIMENT_SCOPE_TYPE"] = args.scope_type
if args.scope_indexes:
os.environ["SENTIMENT_SCOPE_INDEXES"] = args.scope_indexes
if args.scope_sectors:
os.environ["SENTIMENT_SCOPE_SECTORS"] = args.scope_sectors
sent_tmp = SentimentEngine(dm)
ts_codes = sent_tmp.get_scope_stocks()
print(" 分析范围: {} ({})".format(args.scope_type, len(ts_codes)))
if len(ts_codes) > 10:
print(" 股票示例: {}... (共 {} 只)".format(", ".join(ts_codes[:10]), len(ts_codes)))
else:
print(" 股票: {}".format(", ".join(ts_codes)))
else:
ts_codes = [c.strip() for c in args.ts_code.split(",") if c.strip()]
# ═══════════════════════════════════════════════════════════════
# Step 1: 分别从三个数据源获取新闻
# ═══════════════════════════════════════════════════════════════
print("\n" + "-" * 72)
print("Step 1: 获取新闻 ({} 只股票)".format(len(ts_codes)))
print("-" * 72)
all_raw = []
for ts_code in ts_codes:
print("\n --- {} ---".format(ts_code))
xwlb_raw = pd.DataFrame()
ak_raw = pd.DataFrame()
mcp_raw = pd.DataFrame()
if use_xwlb:
try:
xwlb_src = NewsSource(use_akshare=False, use_mcp=False)
xwlb_raw = xwlb_src.fetch(ts_code, start=start, end=end, max_news=max_news * 3)
print(" 新闻联播(DB xwlb_daily_ext): {} 条 (news_date范围: {}-1~{}-1)".format(
len(xwlb_raw), start, end))
except Exception as e:
print(" 新闻联播: 获取失败 ({})".format(e))
if use_akshare:
try:
ak_src = NewsSource(use_xwlb=False, use_mcp=False)
ak_raw = ak_src.fetch(ts_code, start=start, end=end, max_news=max_news)
print(" 东方财富(AkShare stock_news_em): {}".format(len(ak_raw)))
except Exception as e:
print(" 东方财富: 获取失败 ({})".format(e))
if use_mcp:
try:
mcp_src = NewsSource(use_akshare=False, use_xwlb=False, use_mcp=True)
mcp_raw = mcp_src.fetch(ts_code, start=start, end=end, max_news=max_news)
print(" MCP(trendradar-news): {}".format(len(mcp_raw)))
except Exception as e:
print(" MCP: 获取失败 ({})".format(e))
all_raw.append((ts_code, xwlb_raw, ak_raw, mcp_raw))
# 合并所有股票的结果
frames = []
for _, x, a, m in all_raw:
for df in [x, a, m]:
if not df.empty:
frames.append(df)
raw_news = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
if not raw_news.empty:
raw_news = raw_news.drop_duplicates(subset=["title", "date"])
raw_news = raw_news.sort_values("date", ascending=False)
print("\n [汇总] 合并去重后: {} 条新闻".format(len(raw_news)))
if raw_news.empty:
print(" (无新闻数据)")
return
src_counts = raw_news["source"].value_counts()
for src, cnt in src_counts.items():
if src == "xwlb":
label = "新闻联播(DB)"
elif src.startswith("akshare"):
label = "东方财富(AkShare)"
elif src.startswith("mcp"):
label = "MCP(trendradar)"
else:
label = src
print(" {}: {}".format(label, cnt))
# ═══════════════════════════════════════════════════════════════
# Step 2: 新闻详情(按来源分开展示)
# ═══════════════════════════════════════════════════════════════
print("\n" + "-" * 72)
print("Step 2: 新闻详情(按数据源分开展示)")
print("-" * 72)
def show_news(label, df, limit=6):
if df.empty:
print("\n [{}] (无数据)".format(label))
return
print("\n [{}] {}".format(label, len(df)))
for i, (_, row) in enumerate(df.head(limit).iterrows()):
title = str(row["title"])[:80]
content_preview = str(row["content"])[:100].replace("\n", " ")
print("\n [{}/{}] {} | {}".format(i + 1, len(df), row["date"], title))
if content_preview:
print(" 内容: {}...".format(content_preview))
url = row.get("url", "")
if url:
print(" 链接: {}".format(url[:100]))
show_news("新闻联播 (xwlb_daily_ext)", raw_news[raw_news["source"] == "xwlb"], limit=6)
show_news("东方财富 (AkShare stock_news_em)",
raw_news[raw_news["source"].str.startswith("akshare")], limit=6)
show_news("MCP (trendradar-news)",
raw_news[raw_news["source"].str.startswith("mcp")], limit=6)
# ═══════════════════════════════════════════════════════════════
# Step 3: 日期对齐
# ═══════════════════════════════════════════════════════════════
print("\n" + "-" * 72)
print("Step 3: 日期对齐到交易日")
print("-" * 72)
# 交易日历:优先用指定股票 DB 缓存,否则 fallback 到 000001.SZ
first_code = ts_codes[0]
price = _get_trading_calendar(dm, first_code)
if price is None:
print(" {} 无 DB 缓存, fallback 到 000001.SZ".format(first_code))
price = _get_trading_calendar(dm, "000001.SZ")
if price is None:
print(" 无交易日历可用")
return
print("\n 交易日历: {} ~ {} ({} 条)".format(price.index[0], price.index[-1], len(price)))
daily_idx = pd.to_datetime(price.index, format="%Y%m%d", errors="coerce")
aligned_news = align_news_to_trading_days(raw_news, daily_idx)
for label, prefix in [("新闻联播", "xwlb"), ("东方财富", "akshare"), ("MCP", "mcp")]:
df = aligned_news[aligned_news["source"].str.startswith(prefix) if prefix != "xwlb"
else (aligned_news["source"] == "xwlb")]
if df.empty:
continue
dates = sorted(df["date"].unique())
print("\n [{}] {} 条 → {} 个交易日 ({})".format(label, len(df), len(dates),
" +1day偏移" if prefix == "xwlb" else " 直接对齐"))
print(" 日期: {} ~ {}".format(dates[0], dates[-1]))
row = df.iloc[0]
print(" 示例: {} | {}...".format(row["date"], str(row["title"])[:60]))
# ═══════════════════════════════════════════════════════════════
# Step 4: Qwen 情绪分析
# ═══════════════════════════════════════════════════════════════
print("\n" + "-" * 72)
print("Step 4: Qwen 情绪分析")
print("-" * 72)
if not has_api:
print("\n [SKIP] Qwen API 跳过 (--no-qwen 或未配置)")
print(" 使用模拟数据演示因子计算逻辑...")
sentiment_results = _mock_sentiment(aligned_news)
else:
max_analyze = min(len(aligned_news), args.max_analyze)
analyze_news = aligned_news.head(max_analyze)
print("\n 逐条分析 {} 条新闻...".format(max_analyze))
sentiment_results = []
for i, (_, row) in enumerate(analyze_news.iterrows()):
title = str(row["title"])
content = str(row["content"]) if len(str(row["content"])) > 20 else ""
text = "{}\n{}".format(title, content)
result = client.analyze_sentiment(text)
sentiment_results.append({
"date": row["date"],
"title": title,
"sentiment_score": result.get("sentiment_score", 0),
"confidence": result.get("confidence", 0),
"impact_duration": result.get("impact_duration", "short"),
"key_topics": json.dumps(result.get("key_topics", [])),
"source": row.get("source", ""),
})
s = result["sentiment_score"]
icon = "(+)" if s > 0.2 else ("(-)" if s < -0.2 else "(o)")
print(" [{}/{}] {} {:+.1f} c={:.2f} | {}...".format(
i + 1, max_analyze, icon, s,
result["confidence"], title[:60]))
sent_df = pd.DataFrame(sentiment_results)
if not sent_df.empty:
print("\n 情绪分析汇总 ({} 条):".format(len(sent_df)))
print(" 平均情绪: {:+.3f}".format(sent_df["sentiment_score"].mean()))
pos = (sent_df["sentiment_score"] > 0.1).sum()
neu = ((sent_df["sentiment_score"] >= -0.1) & (sent_df["sentiment_score"] <= 0.1)).sum()
neg = (sent_df["sentiment_score"] < -0.1).sum()
print(" 正面(>0.1): {} 中性(-0.1~0.1): {} 负面(<-0.1): {}".format(pos, neu, neg))
if "source" in sent_df.columns:
for src in sent_df["source"].unique():
src_df = sent_df[sent_df["source"] == src]
label = src[:20]
print(" [{}] {} 条, 平均情绪: {:+.3f}".format(label, len(src_df), src_df["sentiment_score"].mean()))
# ═══════════════════════════════════════════════════════════════
# Step 5: 因子计算 + 结果输出
# ═══════════════════════════════════════════════════════════════
print("\n" + "-" * 72)
print("Step 5-6: 因子计算 + 结果输出")
print("-" * 72)
from factors.sentiment.sentiment_factor import (
NewsSentimentFactor,
SentimentConfidenceFactor,
SentimentMomentumFactor,
)
if sent_df.empty:
print(" (无情绪数据)")
return
factors = [
NewsSentimentFactor(window=5, decay=0.3, sentiment_df=sent_df),
SentimentConfidenceFactor(window=5, sentiment_df=sent_df),
SentimentMomentumFactor(period=5, sentiment_df=sent_df),
]
factor_results = {}
for f in factors:
series = f.calculate(price)
factor_results[f.name] = series
stats = series.dropna()
if not stats.empty:
print(" {}: mean={:+.4f} std={:.4f} valid={}/{}".format(
f.name, stats.mean(), stats.std(), len(stats), len(series)))
else:
print(" {}: (全NaN)".format(f.name))
factor_df = pd.DataFrame(factor_results)
valid = factor_df.dropna(how="all")
if valid.empty:
print("\n (无有效因子值)")
return
recent = valid.tail(20)
print("\n === 最近 {} 个交易日情绪因子值 ({}) ===".format(len(recent), first_code))
print(" {:<12s} {:>12s} {:>12s} {:>12s}".format("交易日", "news_sent_5", "news_conf_5", "sent_delta_5"))
print(" {} {} {} {}".format("-" * 12, "-" * 12, "-" * 12, "-" * 12))
for idx, row in recent.iterrows():
ns = "{:+.4f}".format(row["news_sent_5"]) if not pd.isna(row["news_sent_5"]) else " NaN"
nc = "{:+.4f}".format(row["news_conf_5"]) if not pd.isna(row["news_conf_5"]) else " NaN"
sd = "{:+.4f}".format(row["sent_delta_5"]) if not pd.isna(row["sent_delta_5"]) else " NaN"
print(" {:<12s} {:>12s} {:>12s} {:>12s}".format(idx, ns, nc, sd))
latest = valid.iloc[-1]
print("\n === 最新交易日 ({}) ===".format(valid.index[-1]))
print(" news_sent_5 : {:+.4f} (加权情绪, >0偏正面)".format(latest["news_sent_5"]))
print(" news_conf_5 : {:+.4f} (置信度加权)".format(latest["news_conf_5"]))
# 情绪贡献明细
print("\n === 情绪贡献明细 (最近3天) ===")
latest_date = valid.index[-1]
nearby = sent_df[
(sent_df["date"] >= str(int(latest_date) - 3)) &
(sent_df["date"] <= latest_date)
]
if not nearby.empty:
for _, row in nearby.head(30).iterrows():
s = row["sentiment_score"]
impact = "(+)" if s > 0.2 else ("(-)" if s < -0.2 else "(o)")
src = str(row.get("source", ""))
src_s = "xwlb" if src == "xwlb" else ("ak" if src.startswith("akshare") else "mcp")
print(" {} [{:+.1f}] [{}] {}...".format(
impact, s, src_s, str(row["title"])[:70]))
else:
print(" (无最近3天新闻)")
try:
from reports.storage import save_report
first = ts_codes[0] if ts_codes else "unknown"
lines = ["## 情绪因子详细演示", "股票: {}".format(", ".join(ts_codes[:5])),
"数据源: {}条新闻".format(len(raw_news)),
"情绪: news_sent_5={}".format(
latest["news_sent_5"] if "news_sent_5" in latest else "N/A")]
save_report("\n".join(lines), "情绪因子详细演示", subject_type="stock", subject_code=first)
print(" 报告已存入 DB")
except Exception:
pass
print("\n" + "=" * 72)
print(" 情绪因子演示完成")
print("=" * 72)
def _get_trading_calendar(dm, ts_code):
"""获取交易日历:优先 DB 缓存;无缓存则尝试 sync_daily 补齐。"""
try:
from database.dao import get_latest_trade_date
if not get_latest_trade_date(ts_code):
print(" {} 无 DB 缓存,尝试 sync_daily 补齐...".format(ts_code))
try:
n = dm.sync_daily(ts_code)
print(" sync_daily 完成: {}".format(n))
except Exception as e:
print(" sync_daily 失败: {}".format(e))
return None
daily = dm.get_daily(ts_code)
if daily is not None and not daily.empty:
daily = daily.set_index("trade_date").sort_index()
if len(daily) > 0:
return daily
except Exception as e:
print(" 获取交易日历异常: {}".format(e))
return None
def _mock_sentiment(news_df):
results = []
for _, row in news_df.iterrows():
title = str(row["title"]).lower()
pos_words = ["利好", "增长", "突破", "创新高", "盈利", "上升", "支持", "回购", "增持", "分红"]
neg_words = ["利空", "下跌", "亏损", "处罚", "减持", "诉讼", "退市", "警告", "暴跌", "违约"]
pos = sum(1 for w in pos_words if w in title)
neg = sum(1 for w in neg_words if w in title)
if pos > neg:
score = min(0.9, 0.1 + pos * 0.2)
elif neg > pos:
score = max(-0.9, -0.1 - neg * 0.2)
else:
score = np.random.uniform(-0.15, 0.15)
results.append({
"date": row["date"], "title": row["title"],
"sentiment_score": round(score, 1),
"confidence": round(np.random.uniform(0.5, 0.9), 2),
"impact_duration": "short", "key_topics": json.dumps([]),
"source": row.get("source", ""),
})
return results
if __name__ == "__main__":
main()