Skill: yejiming/stock-prediction-daily (version 1.0.0)

--- name: stock-prediction-daily description: "A股个股日线涨跌预测系统。七大能力：训练模型（XGBoost二分类+交叉验证+输出模型文件和报告）、优化模型（扩展特征+特征筛选）、模型预测（腾讯日线）、模型评估（日线T+1验证）、输出网页（Flask五页面仪表盘）、板块分析（直接调用stock-sector-research技能）、个股分析（直接调用stock-watchlist-briefing技能）。Use when: 股票预测, stock prediction, 训练模型, train model, 模型优化, optimize model, 模型预测, predict stock, 模型评估, evaluate model, 预测网页, prediction dashboard, 板块分析, sector research, 个股分析, watchlist briefing, XGBoost, A股, 沪深300, daily prediction, 日线预测" argument-hint: "指定能力：训练/优化/预测/评估/网页/板块/个股，或一键全部执行" --- # A股个股日线涨跌预测系统 基于沪深300成份股日线数据训练 XGBoost 二分类模型，预测个股下一个交易日的价格涨跌方向。预测与评估均统一使用腾讯财经日线数据，预测和评估两个步骤相互独立。除预测主流程外，本 skill 还提供两个研究入口：板块分析直接复用 `stock-sector-research`，个股分析直接复用 `stock-watchlist-briefing`。 ## 使用约束 - 训练、优化、预测、评估、网页能力仅使用当前 skill 目录内的文件和子目录，不要读取、修改或依赖 skill 目录外的实现文件。 - 板块分析能力不在当前目录内重复实现，直接调用 `stock-sector-research` skill。 - 个股分析能力不在当前目录内重复实现，直接调用 `stock-watchlist-briefing` skill。 - 默认工作目录为 `.github/skills/stock-prediction-daily/scripts/`。 - 运行、修改、排查当前 skill 自带功能时，只使用 `scripts/` 和 `references/` 下的内容。 ## 项目结构 ``` .github/skills/stock-prediction-daily/ ├── SKILL.md # 技能说明 ├── references/ # 参考说明与排障文档 └── scripts/ ├── config.py # 配置中心（路径、股票池、超参数） ├── features.py # 特征工程（100+候选技术指标） ├── train.py # 训练流水线（获取数据→特征→选择→交叉验证→保存） ├── predict.py # 预测流水线（腾讯日线） ├── evaluate.py # 评估流水线（日线T+1验证） ├── app.py # Flask网页仪表盘 └── main.py # 一键执行入口 ``` 运行过程中生成的 `data/`、`models/`、`results/`、`templates/`、`static/` 目录都位于 `scripts/` 目录下，由 `scripts/config.py` 自动创建。 其中 `scripts/reports/` 用于保存板块分析和个股分析的文本报告，网页页面会直接读取该目录中的文件进行展示。 ## 技术栈 - **数据源**: akshare Tencent日线 `stock_zh_a_hist_tx` - **模型**: XGBoost `XGBClassifier` 二分类 - **序列化**: joblib（不用 xgboost 原生 `save_model`） - **Python**: 使用 `python3` - **Web**: Flask + Jinja2，端口 5000 ## 能力一：训练模型 ### 触发条件 用户要求训练模型、构建预测系统、或首次搭建项目时执行。 ### 流程 1. **获取训练数据** - 获取沪深300成份股列表：`ak.index_stock_cons()` 或 `ak.index_stock_cons_csindex(symbol="000300")` - 逐只获取日线数据：`ak.stock_zh_a_hist_tx(symbol, start_date, end_date, adjust="qfq")` - 缓存到 `scripts/data/{code}_daily.csv` - 请求节流由 `scripts/config.py` 的 `FETCH_DELAY` 控制 2. **特征工程** - 调用 `features.add_technical_features(df)` 计算技术指标特征 - 调用 `features.create_label(df)` 生成标签：下一期 `close > 当前 close` 则为 1 3. **特征选择** - 使用 XGBoost `feature_importances_` 排序 - 取 `config.TOP_N_FEATURES` 个最重要特征 4. **交叉验证** - 10 折分层交叉验证 `StratifiedKFold` - 指标：Accuracy、Precision、Recall、F1、AUC 5. **保存模型和报告** - 模型：`scripts/models/xgb_stock_model.pkl` - 归一化器：`scripts/models/scaler.pkl` - 特征名：`scripts/models/feature_names.json` - 报告：`scripts/results/model_report.json` ### 关键代码模式 ```python df = ak.stock_zh_a_hist_tx(symbol="sh600436", start_date="20240101", end_date="20260315", adjust="qfq") import joblib joblib.dump(model, config.MODEL_PATH) model = joblib.load(config.MODEL_PATH) ``` ### 缓存策略 训练数据缓存在 `scripts/data/`，重新训练时可直接复用缓存 CSV。 ## 能力二：优化模型效果 ### 触发条件 用户要求提升准确率、增加特征、调参、或模型效果不理想时。 ### 优化维度 1. **扩展候选特征** - 在 `scripts/features.py` 的 `add_technical_features()` 中添加新特征 - 在 `scripts/features.py` 的 `get_all_feature_columns()` 中注册特征名 2. **调整特征数量** - 修改 `scripts/config.py` 中的 `TOP_N_FEATURES` 3. **调整 XGBoost 超参数** - 修改 `scripts/config.py` 中的 `XGB_PARAMS` 4. **重新训练** - 可直接复用缓存日线 CSV 重新训练，无需重新抓取数据 ## 能力三：模型预测 ### 触发条件 用户要求预测特定股票在特定日期的涨跌方向。 ### 预测逻辑 - 只使用腾讯财经日线数据 `stock_zh_a_hist_tx` - 对目标日期取不晚于该日期的最近一个交易日 bar - 在该日线 bar 上计算与训练一致的特征 - 输出下一交易日涨跌方向概率 ### 启动方式 ```bash cd .github/skills/stock-prediction-daily/scripts python3 predict.py "2026-03-13" ``` ### 输出格式 CSV 含字段：股票代码、股票名称、当前时间、预测生成时间、当前价格、预测结果、上涨概率、下跌概率、数据频率。 说明： - `当前时间` 只精确到日期，例如 `2026-03-13` - `预测生成时间` 精确到秒，用于区分同一交易日的多次预测批次 - `数据频率` 固定为腾讯日线 - `scripts/results/predictions.csv` 为去重后的主表，按 `股票代码 + 当前时间` 只保留最新一批预测结果 - 每次预测还会额外生成 `scripts/results/prediction_history/predictions_YYYYMMDD_HHMMSS.csv` 批次快照，用于保留完整历史 ## 能力四：模型评估 ### 触发条件 用户要求评估预测准确率、验证模型效果。 ### 评估逻辑 - 独立读取 `scripts/results/predictions.csv` - 对每条预测重新获取腾讯财经日线数据 - 取预测日期之后的下一个交易日收盘价进行评估 - 计算实际涨跌与预测是否一致 ### 启动方式 ```bash cd .github/skills/stock-prediction-daily/scripts python3 evaluate.py ``` ### 输出格式 CSV 含字段：股票代码、股票名称、预测时间、预测生成时间、当时价格、预测结果、上涨概率、预测数据频率、一天后价格、评估数据频率、实际涨跌、预测是否准确。 说明： - `一天后价格` 表示下一个可用交易日的收盘价 - 若预测后尚无新交易日数据，则该字段为 `N/A` - 若同一支股票同一天存在多次预测，评估时以 `predictions.csv` 主表中的最新批次为准 ## 能力五：输出网页 ### 触发条件 用户要求启动网页、展示结果、或搭建仪表盘。 ### Flask应用结构 - `/predictions` — 日线预测结果表格 - `/report` — 模型训练报告 - `/evaluation` — 日线 T+1 评估结果表格 - `/sector-analysis` — 板块分析报告浏览页，自动读取 [REDACTED_SECRET] 中的报告 - `/stock-analysis` — 个股分析报告浏览页，自动读取 `scripts/reports/stock_analysis/` 中的报告 - `/` — 重定向到 `/predictions` 说明： - 板块分析和个股分析页面都以报告文件为唯一数据源，不再展示独立的介绍页内容。 - 页面左侧提供报告选择菜单，菜单项显示“报告主标题 + 创建时间”，可在多份历史报告之间切换。 - 页面正文会完整渲染报告内容，并自动格式化一级/二级标题、列表、表格。 - 页面会从 Markdown 报告中的表格自动提取结构化信息，生成摘要卡片与简单图表。 - 左侧章节导航只展示一级章节，不展示子章节，避免导航过长。 ### 页面模板 - `base.html`：布局和导航 - `predictions.html`：预测表格与统计卡片 - `report.html`：报告 JSON 展示 - `evaluation.html`：评估表格与准确率统计 - `sector_analysis.html`：板块分析报告页 - `stock_analysis.html`：个股分析报告页 ### 启动方式 ```bash cd .github/skills/stock-prediction-daily/scripts python3 app.py ``` ## 一键执行 ```bash cd .github/skills/stock-prediction-daily/scripts python3 main.py ``` 依次执行：训练 → 预测 → 评估 → 启动网页。 ## 能力六：板块分析 ### 触发条件 用户要求查看近阶段重点板块、板块排行、新闻催化、资金流向、龙头股或下周板块观察清单。 ### 执行方式 - 不在当前 skill 内重复实现板块研究逻辑。 - 直接调用 `stock-sector-research` skill。 - 调用时沿用用户原始问题中的时间范围、主题范围和输出形式要求。 - 调用完成后，将最终输出保存到 [REDACTED_SECRET] 目录。 - 文件格式优先使用 Markdown，命名建议为 `sector_analysis_YYYYMMDD_HHMMSS.md`。 ### 默认输出 - 重点板块排序 - 每个重点板块的催化、资金与交易逻辑 - 每个板块 2 到 3 只核心股 - 下周观察清单或盘前清单 ### 保存要求 - 报告正文必须可直接阅读，不要只保存摘要。 - 报告开头必须包含统一头部，顺序如下：一级标题、单独一行的副标题、单独一行的报告时间。 - 主标题和副标题都必须直接体现报告结论，不能使用“板块分析报告”“个股分析报告”“近7天重点信息汇总”这类泛泛标题。 - 主标题必须简短，控制在一句短结论内，优先写成 6 到 16 个字，不要把全部判断塞进主标题。 - 主标题应概括最重要的结论，例如：`# 主线聚焦新能源`、`# 算力分化创新药走强`。 - 副标题再补充次级判断或执行结论，例如：`> 副标题：风电与锂电强度最高，算力分化加大，创新药更适合中线跟踪`。 - 头部格式仍保持三行结构：主标题、以 `> 副标题：` 开头的副标题、`报告时间：YYYY-MM-DD HH:MM:SS`。 - 优先使用分节标题、列表和表格，便于网页端自动格式化和提取图表数据。 - 主章节优先使用 `##`，例如“重点板块排序”“核心股对比表”“下周观察清单”“风险提示”。 - 若主章节下需要展开细分内容，可使用 `###` 子章节，但网页左侧导航默认只展示 `##` 主章节。 - 同一次分析如有补充说明，合并写入同一个报告文件，不拆成多个零散文件。 ## 能力七：个股分析 ### 触发条件 用户要求分析一组 A 股个股近 3 天、5 天、7 天或 1 个月的重要信息，或要求输出观察清单、优先执行标的和买点止损位。 ### 执行方式 - 不在当前 skill 内重复实现多股票研究逻辑。 - 直接调用 `stock-watchlist-briefing` skill。 - 调用时保留用户给定的股票列表、时间范围和输出深度。 - 调用完成后，将最终输出保存到 `scripts/reports/stock_analysis/` 目录。 - 文件格式优先使用 Markdown，命名建议为 `stock_analysis_YYYYMMDD_HHMMSS.md`。 - 个股分析默认以 A 股为主；若用户给出的名单中包含港股，可保留该标的，但要在报告中单独标注“港股口径”。 - 港股行情或估值接口不稳定时，允许退回到稳定的公开港股报价页、公司业绩公告和主流财经媒体摘要；不要把港股强行套用 A 股接口，也不要伪造精确技术位。 ### 默认输出 - 多股票概览表 - 下周观察清单 - 优先执行标的 - 买点、突破位、止损位、减仓位 ### 保存要求 - 报告正文必须保留股票分组、结论和执行位信息。 - 报告开头必须包含统一头部，顺序如下：一级标题、单独一行的副标题、单独一行的报告时间。 - 主标题和副标题都必须直接体现报告结论，不能使用“个股分析报告”“股票观察清单”“多股汇总”这类泛泛标题。 - 主标题必须简短，控制在一句短结论内，优先写成 6 到 16 个字，不要把多只股票的全部判断都写进主标题。 - 主标题应先给出最核心的股票结论，例如：`# 宁德时代仍是首选`、`# 立讯精密暂不追高`。 - 副标题再补充组合判断、时间范围或执行优先级，例如：`> 副标题：片仔癀偏防守配置，未来7天执行优先级低于宁德时代`。 - 头部格式仍保持三行结构：主标题、以 `> 副标题：` 开头的副标题、`报告时间：YYYY-MM-DD HH:MM:SS`。 - 优先使用分节标题、列表和表格，便于网页端自动格式化和提取图表数据。 - `## 全名单概览表` 若使用 Markdown 表格，表头固定为：`股票 | 近 7 日涨跌幅 | 近 7 日成交额合计 | 最近正式财务口径 | 周内重点信息 | 一句话建议`。 - 不要把上述固定表头改成“近 7 日涨跌”“近 7 日涨跌/状态”等变体。 - 若个别标的无法稳定给出近 7 日涨跌幅，仍保留固定表头，并在对应单元格填写 `N/A`；口径说明写入“研究口径”或“周内重点信息”，不要通过修改列名表达。 - 主章节优先使用 `##`，例如“先看结论”“全名单概览表”“下周观察清单”“优先执行标的”“风险提示”。 - 若主章节下需要展开分组，可使用 `###` 子章节，但网页左侧导航默认只展示 `##` 主章节。 - 同一批股票的研究结果尽量输出为单文件，避免网页端阅读碎片化。 - 若报告包含港股标的，研究口径章节应明确区分 A 股与港股的数据来源；对于无法稳定获取 MA5、MA10、20 日高低点的港股，只写可核对的价格区间和事件驱动，不要硬补精确数值。 ## API注意事项 | API | 函数 | 用途 | 注意 | | ----------- | -------------------- | ---------------- | ------------ | | Tencent日线 | `stock_zh_a_hist_tx` | 训练、预测、评估 | 稳定，不限频 | ## 常见问题 - **`python` 不可用**：macOS 使用 `python3` - **模型加载报错**：必须用 `joblib.load()`，不用 `xgb.load_model()` - **预测字段时间精度**：`当前时间` 只保留日期，不含时分秒 - **次日评估无数据**：如果下一交易日尚未收盘，`一天后价格` 会是 `N/A` - **运行路径**：先进入 `.github/skills/stock-prediction-daily/scripts` 再执行脚本

path: scripts/config.py
content: import os
from datetime import datetime

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "data")
MODEL_DIR = os.path.join(BASE_DIR, "models")
RESULT_DIR = os.path.join(BASE_DIR, "results")
REPORTS_DIR = os.path.join(BASE_DIR, "reports")
SECTOR_REPORTS_DIR = os.path.join(REPORTS_DIR, "sector_analysis")
STOCK_REPORTS_DIR = os.path.join(REPORTS_DIR, "stock_analysis")
PREDICTION_HISTORY_DIR = os.path.join(RESULT_DIR, "prediction_history")
TEMPLATE_DIR = os.path.join(BASE_DIR, "templates")
STATIC_DIR = os.path.join(BASE_DIR, "static")

for d in [
    DATA_DIR,
    MODEL_DIR,
    RESULT_DIR,
    REPORTS_DIR,
    SECTOR_REPORTS_DIR,
    STOCK_REPORTS_DIR,
    PREDICTION_HISTORY_DIR,
    TEMPLATE_DIR,
    STATIC_DIR,
]:
    os.makedirs(d, exist_ok=True)

# Training data date range
TRAIN_START_DATE = "2024-01-01 09:30:00"
TRAIN_END_DATE = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

# Target stocks for prediction
TARGET_STOCKS = {
    "600436": "片仔癀",
    "300750": "宁德时代",
    "002475": "立讯精密",
    "000568": "泸州老窖",
    "000538": "云南白药",
    "000338": "潍柴动力",
    "600031": "三一重工",
    "600111": "北方稀土",
    "002415": "海康威视",
    "000858": "五粮液",
}

# Model paths
MODEL_PATH = os.path.join(MODEL_DIR, "xgb_stock_model.pkl")
FEATURE_PATH = os.path.join(MODEL_DIR, "feature_names.json")
SCALER_PATH = os.path.join(MODEL_DIR, "scaler.pkl")
REPORT_PATH = os.path.join(RESULT_DIR, "model_report.json")
PREDICTION_PATH = os.path.join(RESULT_DIR, "predictions.csv")
EVALUATION_PATH = os.path.join(RESULT_DIR, "evaluation.csv")

# XGBoost parameters
XGB_PARAMS = {
    "objective": "binary:logistic",
    "eval_metric": "logloss",
    "max_depth": 6,
    "learning_rate": 0.05,
    "n_estimators": 300,
    "subsample": 0.8,
    "colsample_bytree": 0.8,
    "min_child_weight": 5,
    "reg_alpha": 0.1,
    "reg_lambda": 1.0,
    "random_state": 42,
    "n_jobs": -1,
}

# Feature importance threshold (top N features)
TOP_N_FEATURES = 40

# Cross-validation folds
CV_FOLDS = 10

# Data fetch delay (seconds between requests)
FETCH_DELAY = 1.5


path: scripts/predict.py
content: """Daily prediction pipeline using Tencent daily data only.

For each target stock, load cached or fresh daily OHLCV history,
compute the same technical features used during training, and predict
the next trading day's direction based on the latest bar on or before
the target date.
"""
import json
import os
import sys
import time
from datetime import datetime, timedelta

import akshare as ak
import joblib
import numpy as np
import pandas as pd

import config
from features import add_technical_features


PREDICTION_COLUMNS = [
    "股票代码",
    "股票名称",
    "当前时间",
    "预测生成时间",
    "当前价格",
    "预测结果",
    "上涨概率",
    "下跌概率",
    "数据频率",
]


def load_model():
    model = joblib.load(config.MODEL_PATH)
    with open(config.FEATURE_PATH) as f:
        feature_names = json.load(f)
    scaler = joblib.load(config.SCALER_PATH)
    return model, feature_names, scaler


def code_to_tx_symbol(code):
    code = str(code).zfill(6)
    return f"sh{code}" if code.startswith("6") else f"sz{code}"


def parse_target_date(target_dt=None):
    """Parse a datetime/date string and return a normalized trade-date timestamp."""
    if target_dt is None:
        return pd.Timestamp(datetime.now().date())
    return pd.to_datetime(target_dt).normalize()


def _normalize_prediction_history(df):
    """Normalize prediction history schema for backward-compatible appends."""
    normalized_df = df.copy()
    normalized_df["股票代码"] = normalized_df["股票代码"].astype(str).str.zfill(6)
    normalized_df["当前时间"] = pd.to_datetime(normalized_df["当前时间"]).dt.strftime("%Y-%m-%d")
    if "预测生成时间" not in normalized_df.columns:
        normalized_df["预测生成时间"] = normalized_df["当前时间"] + " 00:00:00"
    normalized_df["预测生成时间"] = pd.to_datetime(normalized_df["预测生成时间"]).dt.strftime("%Y-%m-%d %H:%M:%S")
    return normalized_df.reindex(columns=PREDICTION_COLUMNS)


def _save_prediction_snapshot(batch_df, batch_timestamp):
    """Persist a per-run snapshot alongside the append-only master history."""
    batch_tag = pd.to_datetime(batch_timestamp).strftime("%Y%m%d_%H%M%S")
    snapshot_path = os.path.join(config.PREDICTION_HISTORY_DIR, f"predictions_{batch_tag}.csv")
    batch_df.to_csv(snapshot_path, index=False, encoding="utf-8-sig")
    return snapshot_path


def _dedupe_prediction_master(df):
    """Keep one prediction per stock per trade date, preferring the latest batch."""
    deduped_df = _normalize_prediction_history(df)
    deduped_df = deduped_df.sort_values(
        ["预测生成时间", "当前时间", "股票代码"],
        ascending=[False, False, True],
    )
    deduped_df = deduped_df.drop_duplicates(
        subset=["股票代码", "当前时间"],
        keep="first",
    )
    return deduped_df.reset_index(drop=True)


def _merge_prediction_history(new_rows):
    """Update the prediction master table and persist the current batch snapshot."""
    new_df = _normalize_prediction_history(pd.DataFrame(new_rows))
    if os.path.exists(config.PREDICTION_PATH):
        history_df = _normalize_prediction_history(pd.read_csv(config.PREDICTION_PATH))
        merged_df = pd.concat([history_df, new_df], ignore_index=True)
    else:
        merged_df = new_df

    merged_df = _dedupe_prediction_master(merged_df)
    merged_df.to_csv(config.PREDICTION_PATH, index=False, encoding="utf-8-sig")
    snapshot_path = _save_prediction_snapshot(new_df, new_df.iloc[0]["预测生成时间"])
    return merged_df, snapshot_path


def _normalize_daily_df(df):
    """Normalize Tencent daily columns to the training schema."""
    df = df.copy()
    df.rename(columns={"date": "datetime"}, inplace=True)
    df["datetime"] = pd.to_datetime(df["datetime"])
    if "volume" not in df.columns and "amount" in df.columns:
        df["volume"] = df["amount"]
    for c in ["open", "close", "high", "low", "volume"]:
        if c in df.columns:
            df[c] = pd.to_numeric(df[c], errors="coerce")
    return df.sort_values("datetime").reset_index(drop=True)


def load_cached_daily_data(code):
    """Load cached daily training data if available."""
    cache_path = os.path.join(config.DATA_DIR, f"{str(code).zfill(6)}_daily.csv")
    if not os.path.exists(cache_path):
        return None
    try:
        return _normalize_daily_df(pd.read_csv(cache_path))
    except Exception:
        return None


def fetch_daily_data_tx(code, target_date, lookback_days=450, forward_days=5):
    """Fetch daily data via Tencent API around the target trade date."""
    end = target_date + timedelta(days=forward_days)
    start = target_date - timedelta(days=lookback_days)
    tx_sym = code_to_tx_symbol(code)
    try:
        df = ak.stock_zh_a_hist_tx(
            symbol=tx_sym,
            start_date=start.strftime("%Y%m%d"),
            end_date=end.strftime("%Y%m%d"),
            adjust="qfq",
        )
        if df is not None and not df.empty:
            return _normalize_daily_df(df)
    except Exception:
        pass
    return None


def load_daily_data_for_prediction(code, target_dt):
    """Load enough daily history for feature generation and the target bar."""
    target_date = parse_target_date(target_dt)
    cached_df = load_cached_daily_data(code)
    if cached_df is not None and not cached_df.empty:
        hist_df = cached_df[cached_df["datetime"] <= target_date].copy()
        if len(hist_df) >= 120:
            return hist_df, "daily.tencent"

    fetched_df = fetch_daily_data_tx(code, target_date)
    if fetched_df is None or fetched_df.empty:
        return None, None

    hist_df = fetched_df[fetched_df["datetime"] <= target_date].copy()
    if len(hist_df) < 50:
        return None, None
    return hist_df, "daily.tencent"


def predict_single(code, name, model, feature_names, scaler, target_dt=None):
    """Predict for one stock at target_dt, return result dict or None."""
    target_date = parse_target_date(target_dt)

    df, freq = load_daily_data_for_prediction(code, target_date)
    if df is None:
        print(f"  Warning: Insufficient data for {name}({code})")
        return None

    df = add_technical_features(df)
    for m in feature_names:
        if m not in df.columns:
            df[m] = 0.0

    mask = df["datetime"] <= target_date
    if mask.sum() == 0:
        mask = pd.Series([True] * len(df), index=df.index)

    target_row = df.loc[mask].iloc[[-1]].copy()
    X = target_row[feature_names].values
    X = np.nan_to_num(X, nan=0.0)
    X = scaler.transform(X)
    prob = model.predict_proba(X)[0]
    pred_label = int(prob[1] >= 0.5)

    raw_dt = pd.to_datetime(target_row["datetime"].values[0])
    pred_time = raw_dt.strftime("%Y-%m-%d")

    return {
        "股票代码": code,
        "股票名称": name,
        "当前时间": pred_time,
        "当前价格": round(float(target_row["close"].values[0]), 2),
        "预测结果": "上涨" if pred_label == 1 else "下跌",
        "上涨概率": round(float(prob[1]), 4),
        "下跌概率": round(float(prob[0]), 4),
        "数据频率": freq,
    }


def run_prediction(target_dt=None):
    """Run prediction for all target stocks at the given time."""
    target_dt = parse_target_date(target_dt).strftime("%Y-%m-%d")
    batch_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    print("=" * 60)
    print("  Stock Price Direction Prediction - Daily Prediction Pipeline")
    print(f"  Target date: {target_dt}")
    print(f"  Generated at: {batch_timestamp}")
    print("=" * 60)

    model, feature_names, scaler = load_model()

    results = []
    for code, name in config.TARGET_STOCKS.items():
        print(f"  Predicting {name} ({code}) @ {target_dt} ...")
        r = predict_single(code, name, model, feature_names, scaler, target_dt=target_dt)
        if r:
            r["预测生成时间"] = batch_timestamp
            results.append(r)
        time.sleep(config.FETCH_DELAY)

    if results:
        history_df, snapshot_path = _merge_prediction_history(results)
        current_df = history_df[history_df["预测生成时间"] == batch_timestamp].copy()
        print(f"\n  Prediction history saved -> {config.PREDICTION_PATH}")
        print(f"  Batch snapshot saved -> {snapshot_path}")
        print(current_df.to_string(index=False))
    else:
        print("  No predictions generated.")

    print("\nPrediction complete!\n")
    return results


if __name__ == "__main__":
    dt = sys.argv[1] if len(sys.argv) > 1 else None
    run_prediction(target_dt=dt)


path: scripts/features.py
content: import pandas as pd
import numpy as np


def get_feature_metadata():
    """Return Chinese names, engineering logic, and category for each feature."""
    metadata = {}

    def add(name, chinese_name, logic, category):
        metadata[name] = {
            "chinese_name": chinese_name,
            "logic": logic,
            "category": category,
        }

    for p in [1, 2, 3, 5, 10, 20]:
        add(f"return_{p}", f"{p}日收益率", f"close.pct_change({p})", "价格收益")
    for p in [5, 10, 20, 50, 100]:
        add(f"ma_ratio_{p}", f"收盘价相对{p}日均线偏离率", f"close / MA{p} - 1", "均线偏离")
    for p in [5, 10, 20, 50]:
        add(f"ema_ratio_{p}", f"收盘价相对{p}日EMA偏离率", f"close / EMA{p} - 1", "EMA偏离")

    add("ma_cross_5_10", "5日与10日均线差", "(MA5 - MA10) / close", "均线交叉")
    add("ma_cross_5_20", "5日与20日均线差", "(MA5 - MA20) / close", "均线交叉")
    add("ma_cross_10_20", "10日与20日均线差", "(MA10 - MA20) / close", "均线交叉")
    add("ma_cross_10_50", "10日与50日均线差", "(MA10 - MA50) / close", "均线交叉")

    for p in [6, 14, 24]:
        add(f"rsi_{p}", f"RSI({p})", f"{p}期涨跌幅构造 RSI", "RSI")

    add("macd_norm", "MACD线归一化", "(EMA12 - EMA26) / close", "MACD")
    add("macd_signal_norm", "MACD信号线归一化", "MACD signal / close", "MACD")
    add("macd_hist_norm", "MACD柱体归一化", "(MACD - Signal) / close", "MACD")

    for p in [9, 14]:
        add(f"kdj_k_{p}", f"KDJ-K({p})", f"{p}期 RSV 的平滑 K 值", "KDJ")
        add(f"kdj_d_{p}", f"KDJ-D({p})", "K 值二次平滑得到 D 值", "KDJ")
        add(f"kdj_j_{p}", f"KDJ-J({p})", "3 * K - 2 * D", "KDJ")

    for p in [14, 28]:
        add(f"williams_r_{p}", f"威廉指标%R({p})", f"-{p}期区间内收盘位置", "威廉指标")
    for p in [14, 20]:
        add(f"cci_{p}", f"顺势指标CCI({p})", f"({p}期典型价格 - 均值) / 平均绝对偏差", "CCI")

    add("bb_width", "布林带宽度", "(布林上轨 - 布林下轨) / 布林中轨", "布林带")
    add("bb_position", "布林带位置", "(close - 下轨) / (上轨 - 下轨)", "布林带")

    add("volume_ratio_5", "5日量比", "volume / volume_ma5", "成交量")
    add("volume_ratio_10", "10日量比", "volume / volume_ma10", "成交量")
    add("volume_ratio_20", "20日量比", "volume / volume_ma20", "成交量")
    add("volume_change", "单日成交量变化率", "volume.pct_change()", "成交量")
    add("volume_change_5", "5日成交量变化率", "volume.pct_change(5)", "成交量")
    add("obv_ratio_5", "OBV相对5日均值偏离", "(OBV - OBV_MA5) / |OBV_MA5|", "OBV")
    add("obv_ratio_20", "OBV相对20日均值偏离", "(OBV - OBV_MA20) / |OBV_MA20|", "OBV")
    add("mfi_14", "资金流量指标MFI(14)", "14期正负资金流比构造 MFI", "MFI")
    add("vwap_dev", "VWAP偏离率", "(close - VWAP20) / VWAP20", "VWAP")

    for p in [5, 10, 20]:
        add(f"volatility_{p}", f"{p}日收益波动率", f"return.rolling({p}).std()", "波动率")
    add("volatility_ratio_5_20", "短长波动率比", "volatility_5 / volatility_20", "波动率")

    add("high_low_ratio", "日内振幅比", "(high - low) / close", "K线形态")
    add("close_open_ratio", "开收盘涨跌幅", "(close - open) / open", "K线形态")
    add("upper_shadow", "上影线比例", "(high - max(open, close)) / close", "K线形态")
    add("lower_shadow", "下影线比例", "(min(open, close) - low) / close", "K线形态")
    add("body_ratio", "实体占比", "|close - open| / (high - low)", "K线形态")

    for p in [7, 14]:
        add(f"atr_{p}", f"ATR({p})归一化", f"TR 的 {p}期均值 / close", "ATR")
    for p in [5, 10, 20]:
        add(f"momentum_{p}", f"{p}日动量", f"close / close.shift({p}) - 1", "动量")
    add("return_accel", "收益加速度", "当日收益率的一阶差分", "动量")

    for p in [10, 20]:
        add(f"skew_{p}", f"{p}日收益偏度", f"return.rolling({p}).skew()", "分布统计")
        add(f"kurtosis_{p}", f"{p}日收益峰度", f"return.rolling({p}).kurt()", "分布统计")

    add("consec_count", "连续涨跌计数", "同方向连续涨跌段内的累计长度", "连续性")
    add("consec_dir", "连续涨跌方向", "sign(close.diff())", "连续性")
    add("consec_signed", "带方向连续计数", "consec_count * consec_dir", "连续性")

    for p in [10, 20, 50]:
        add(f"dist_high_{p}", f"距{p}日高点距离", f"(close - rolling_high_{p}) / close", "区间位置")
        add(f"dist_low_{p}", f"距{p}日低点距离", f"(close - rolling_low_{p}) / close", "区间位置")
    for lag in [1, 2, 3, 5]:
        add(f"return_lag_{lag}", f"滞后{lag}日收益率", f"return.shift({lag})", "滞后收益")
    for p in [10, 20]:
        add(f"vol_price_corr_{p}", f"{p}日量价相关系数", f"close.rolling({p}).corr(volume)", "量价关系")

    add("pivot_dev", "枢轴点偏离率", "(close - Pivot) / Pivot", "枢轴点")
    add("pivot_r1_dev", "相对阻力位R1偏离率", "(close - R1) / close", "枢轴点")
    add("pivot_s1_dev", "相对支撑位S1偏离率", "(close - S1) / close", "枢轴点")
    add("adx_14", "ADX趋势强度", "14期方向指标差值平滑", "ADX")
    add("plus_di_14", "+DI(14)", "14期正方向指标", "ADX")
    add("minus_di_14", "-DI(14)", "14期负方向指标", "ADX")
    add("trix", "TRIX三重指数平滑动量", "EMA三次平滑后的一阶收益率", "TRIX")
    add("chaikin_vol", "Chaikin波动率", "(高低价差EMA10).pct_change(10)", "Chaikin波动率")

    for p in [10, 20]:
        add(f"range_position_{p}", f"{p}日区间位置", f"(close - rolling_low_{p}) / (rolling_high_{p} - rolling_low_{p})", "区间位置")

    add("hour", "小时", "datetime.dt.hour", "时间编码")
    add("minute", "分钟", "datetime.dt.minute", "时间编码")
    add("weekday", "星期序号", "datetime.dt.weekday", "时间编码")
    add("month", "月份", "datetime.dt.month", "时间编码")
    add("hour_sin", "小时正弦编码", "sin(2π * hour / 24)", "时间编码")
    add("hour_cos", "小时余弦编码", "cos(2π * hour / 24)", "时间编码")
    add("minute_sin", "分钟正弦编码", "sin(2π * minute / 60)", "时间编码")
    add("minute_cos", "分钟余弦编码", "cos(2π * minute / 60)", "时间编码")
    add("weekday_sin", "星期正弦编码", "sin(2π * weekday / 5)", "时间编码")
    add("weekday_cos", "星期余弦编码", "cos(2π * weekday / 5)", "时间编码")
    add("month_sin", "月份正弦编码", "sin(2π * month / 12)", "时间编码")
    add("month_cos", "月份余弦编码", "cos(2π * month / 12)", "时间编码")

    return metadata


def add_technical_features(df):
    """Calculate technical indicator features from OHLCV data.

    Expects columns: open, close, high, low, volume, datetime/date
    """
    df = df.copy()
    close = df["close"].astype(float)
    high = df["high"].astype(float)
    low = df["low"].astype(float)
    volume = df["volume"].astype(float)
    open_price = df["open"].astype(float)
    returns = close.pct_change()

    # ======== 1. Price Returns ========
    for p in [1, 2, 3, 5, 10, 20]:
        df[f"return_{p}"] = close.pct_change(p)

    # ======== 2. Moving Average Ratios ========
    for p in [5, 10, 20, 50, 100]:
        ma = close.rolling(p).mean()
        df[f"ma_ratio_{p}"] = close / ma - 1

    # ======== 3. EMA Ratios ========
    for p in [5, 10, 20, 50]:
        ema = close.ewm(span=p, adjust=False).mean()
        df[f"ema_ratio_{p}"] = close / ema - 1

    # ======== 4. MA Cross Signals ========
    ma5 = close.rolling(5).mean()
    ma10 = close.rolling(10).mean()
    ma20 = close.rolling(20).mean()
    ma50 = close.rolling(50).mean()
    df["ma_cross_5_10"] = (ma5 - ma10) / (close + 1e-10)
    df["ma_cross_5_20"] = (ma5 - ma20) / (close + 1e-10)
    df["ma_cross_10_20"] = (ma10 - ma20) / (close + 1e-10)
    df["ma_cross_10_50"] = (ma10 - ma50) / (close + 1e-10)

    # ======== 5. RSI ========
    for p in [6, 14, 24]:
        delta = close.diff()
        gain = delta.where(delta > 0, 0.0).rolling(p).mean()
        loss = (-delta.where(delta < 0, 0.0)).rolling(p).mean()
        rs = gain / (loss + 1e-10)
        df[f"rsi_{p}"] = 100 - (100 / (1 + rs))

    # ======== 6. MACD ========
    ema12 = close.ewm(span=12, adjust=False).mean()
    ema26 = close.ewm(span=26, adjust=False).mean()
    macd_line = ema12 - ema26
    macd_signal = macd_line.ewm(span=9, adjust=False).mean()
    macd_hist = macd_line - macd_signal
    df["macd_norm"] = macd_line / close
    df["macd_signal_norm"] = macd_signal / close
    df["macd_hist_norm"] = macd_hist / close

    # ======== 7. KDJ ========
    for p in [9, 14]:
        low_n = low.rolling(p).min()
        high_n = high.rolling(p).max()
        rsv = (close - low_n) / (high_n - low_n + 1e-10) * 100
        k = rsv.ewm(com=2, adjust=False).mean()
        d = k.ewm(com=2, adjust=False).mean()
        j = 3 * k - 2 * d
        df[f"kdj_k_{p}"] = k
        df[f"kdj_d_{p}"] = d
        df[f"kdj_j_{p}"] = j

    # ======== 8. Williams %R ========
    for p in [14, 28]:
        high_n = high.rolling(p).max()
        low_n = low.rolling(p).min()
        df[f"williams_r_{p}"] = (high_n - close) / (high_n - low_n + 1e-10) * -100

    # ======== 9. CCI ========
    tp = (close + high + low) / 3.0
    for p in [14, 20]:
        tp_ma = tp.rolling(p).mean()
        tp_md = tp.rolling(p).apply(lambda x: np.abs(x - x.mean()).mean(), raw=True)
        df[f"cci_{p}"] = (tp - tp_ma) / (0.015 * tp_md + 1e-10)

    # ======== 10. Bollinger Bands ========
    bb_ma = close.rolling(20).mean()
    bb_std = close.rolling(20).std()
    bb_upper = bb_ma + 2 * bb_std
    bb_lower = bb_ma - 2 * bb_std
    df["bb_width"] = (bb_upper - bb_lower) / (bb_ma + 1e-10)
    df["bb_position"] = (close - bb_lower) / (bb_upper - bb_lower + 1e-10)

    # ======== 11. Volume Features ========
    vol_ma5 = volume.rolling(5).mean()
    vol_ma10 = volume.rolling(10).mean()
    vol_ma20 = volume.rolling(20).mean()
    df["volume_ratio_5"] = volume / (vol_ma5 + 1e-10)
    df["volume_ratio_10"] = volume / (vol_ma10 + 1e-10)
    df["volume_ratio_20"] = volume / (vol_ma20 + 1e-10)
    df["volume_change"] = volume.pct_change()
    df["volume_change_5"] = volume.pct_change(5)

    # ======== 12. OBV (On-Balance Volume) ========
    obv = (np.sign(close.diff()) * volume).fillna(0).cumsum()
    obv_ma5 = obv.rolling(5).mean()
    obv_ma20 = obv.rolling(20).mean()
    df["obv_ratio_5"] = (obv - obv_ma5) / (obv_ma5.abs() + 1e-10)
    df["obv_ratio_20"] = (obv - obv_ma20) / (obv_ma20.abs() + 1e-10)

    # ======== 13. MFI (Money Flow Index) ========
    mf = tp * volume
    mf_pos = mf.where(tp.diff() > 0, 0.0)
    mf_neg = mf.where(tp.diff() <= 0, 0.0)
    for p in [14]:
        mf_ratio = mf_pos.rolling(p).sum() / (mf_neg.rolling(p).sum() + 1e-10)
        df[f"mfi_{p}"] = 100 - (100 / (1 + mf_ratio))

    # ======== 14. VWAP deviation ========
    cum_vol = volume.rolling(20).sum()
    cum_tp_vol = (tp * volume).rolling(20).sum()
    vwap = cum_tp_vol / (cum_vol + 1e-10)
    df["vwap_dev"] = (close - vwap) / (vwap + 1e-10)

    # ======== 15. Volatility ========
    for p in [5, 10, 20]:
        df[f"volatility_{p}"] = returns.rolling(p).std()
    # Volatility ratio (short/long)
    df["volatility_ratio_5_20"] = (
        returns.rolling(5).std() / (returns.rolling(20).std() + 1e-10)
    )

    # ======== 16. Price Range ========
    df["high_low_ratio"] = (high - low) / (close + 1e-10)
    df["close_open_ratio"] = (close - open_price) / (open_price + 1e-10)
    df["upper_shadow"] = (high - np.maximum(close, open_price)) / (close + 1e-10)
    df["lower_shadow"] = (np.minimum(close, open_price) - low) / (close + 1e-10)
    df["body_ratio"] = (close - open_price).abs() / (high - low + 1e-10)

    # ======== 17. ATR ========
    prev_close = close.shift(1)
    tr = pd.concat([
        high - low,
        (high - prev_close).abs(),
        (low - prev_close).abs(),
    ], axis=1).max(axis=1)
    for p in [7, 14]:
        df[f"atr_{p}"] = tr.rolling(p).mean() / (close + 1e-10)

    # ======== 18. Momentum / ROC ========
    for p in [5, 10, 20]:
        df[f"momentum_{p}"] = close / close.shift(p) - 1

    # ======== 19. Price acceleration ========
    df["return_accel"] = returns.diff()

    # ======== 20. Rolling skewness & kurtosis ========
    for p in [10, 20]:
        df[f"skew_{p}"] = returns.rolling(p).skew()
        df[f"kurtosis_{p}"] = returns.rolling(p).kurt()

    # ======== 21. Consecutive up/down count ========
    direction = np.sign(close.diff())
    groups = (direction != direction.shift()).cumsum()
    df["consec_count"] = direction.groupby(groups).cumcount() + 1
    df["consec_dir"] = direction  # +1 or -1
    df["consec_signed"] = df["consec_count"] * df["consec_dir"]

    # ======== 22. Distance from N-period high/low ========
    for p in [10, 20, 50]:
        df[f"dist_high_{p}"] = (close - high.rolling(p).max()) / (close + 1e-10)
        df[f"dist_low_{p}"] = (close - low.rolling(p).min()) / (close + 1e-10)

    # ======== 23. Lag features (return lags) ========
    for lag in [1, 2, 3, 5]:
        df[f"return_lag_{lag}"] = returns.shift(lag)

    # ======== 24. Volume-price correlation ========
    for p in [10, 20]:
        df[f"vol_price_corr_{p}"] = close.rolling(p).corr(volume)

    # ======== 25. Pivot points ========
    pivot = (high.shift(1) + low.shift(1) + close.shift(1)) / 3
    df["pivot_dev"] = (close - pivot) / (pivot + 1e-10)
    r1 = 2 * pivot - low.shift(1)
    s1 = 2 * pivot - high.shift(1)
    df["pivot_r1_dev"] = (close - r1) / (close + 1e-10)
    df["pivot_s1_dev"] = (close - s1) / (close + 1e-10)

    # ======== 26. Average Directional Index (ADX approx) ========
    plus_dm = high.diff()
    minus_dm = -low.diff()
    plus_dm = plus_dm.where((plus_dm > minus_dm) & (plus_dm > 0), 0.0)
    minus_dm = minus_dm.where((minus_dm > plus_dm) & (minus_dm > 0), 0.0)
    atr14 = tr.rolling(14).mean()
    plus_di = 100 * plus_dm.rolling(14).mean() / (atr14 + 1e-10)
    minus_di = 100 * minus_dm.rolling(14).mean() / (atr14 + 1e-10)
    dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di + 1e-10)
    df["adx_14"] = dx.rolling(14).mean()
    df["plus_di_14"] = plus_di
    df["minus_di_14"] = minus_di

    # ======== 27. TRIX ========
    ema1 = close.ewm(span=12, adjust=False).mean()
    ema2 = ema1.ewm(span=12, adjust=False).mean()
    ema3 = ema2.ewm(span=12, adjust=False).mean()
    df["trix"] = ema3.pct_change() * 100

    # ======== 28. Chaikin Volatility ========
    hl_ema = (high - low).ewm(span=10, adjust=False).mean()
    df["chaikin_vol"] = hl_ema.pct_change(10)

    # ======== 29. Price position in range ========
    for p in [10, 20]:
        pmax = high.rolling(p).max()
        pmin = low.rolling(p).min()
        df[f"range_position_{p}"] = (close - pmin) / (pmax - pmin + 1e-10)

    # ======== 30. Time Features ========
    df = df.copy()
    dt_col = None
    if "datetime" in df.columns:
        dt_col = "datetime"
    elif "date" in df.columns:
        dt_col = "date"
    if dt_col:
        dt = pd.to_datetime(df[dt_col])
        df["hour"] = dt.dt.hour
        df["minute"] = dt.dt.minute
        df["weekday"] = dt.dt.weekday
        df["month"] = dt.dt.month
        # Cyclical encoding
        df["hour_sin"] = np.sin(2 * np.pi * df["hour"] / 24)
        df["hour_cos"] = np.cos(2 * np.pi * df["hour"] / 24)
        df["minute_sin"] = np.sin(2 * np.pi * df["minute"] / 60)
        df["minute_cos"] = np.cos(2 * np.pi * df["minute"] / 60)
        df["weekday_sin"] = np.sin(2 * np.pi * df["weekday"] / 5)
        df["weekday_cos"] = np.cos(2 * np.pi * df["weekday"] / 5)
        df["month_sin"] = np.sin(2 * np.pi * df["month"] / 12)
        df["month_cos"] = np.cos(2 * np.pi * df["month"] / 12)

    return df


def get_all_feature_columns():
    """Return the full list of feature column names."""
    cols = []
    # Price returns
    for p in [1, 2, 3, 5, 10, 20]:
        cols.append(f"return_{p}")
    # MA ratios
    for p in [5, 10, 20, 50, 100]:
        cols.append(f"ma_ratio_{p}")
    # EMA ratios
    for p in [5, 10, 20, 50]:
        cols.append(f"ema_ratio_{p}")
    # MA cross
    cols.extend(["ma_cross_5_10", "ma_cross_5_20", "ma_cross_10_20", "ma_cross_10_50"])
    # RSI
    for p in [6, 14, 24]:
        cols.append(f"rsi_{p}")
    # MACD
    cols.extend(["macd_norm", "macd_signal_norm", "macd_hist_norm"])
    # KDJ
    for p in [9, 14]:
        cols.extend([f"kdj_k_{p}", f"kdj_d_{p}", f"kdj_j_{p}"])
    # Williams %R
    for p in [14, 28]:
        cols.append(f"williams_r_{p}")
    # CCI
    for p in [14, 20]:
        cols.append(f"cci_{p}")
    # BB
    cols.extend(["bb_width", "bb_position"])
    # Volume
    cols.extend(["volume_ratio_5", "volume_ratio_10", "volume_ratio_20",
                 "volume_change", "volume_change_5"])
    # OBV
    cols.extend(["obv_ratio_5", "obv_ratio_20"])
    # MFI
    cols.append("mfi_14")
    # VWAP
    cols.append("vwap_dev")
    # Volatility
    for p in [5, 10, 20]:
        cols.append(f"volatility_{p}")
    cols.append("volatility_ratio_5_20")
    # Price range
    cols.extend(["high_low_ratio", "close_open_ratio",
                 "upper_shadow", "lower_shadow", "body_ratio"])
    # ATR
    for p in [7, 14]:
        cols.append(f"atr_{p}")
    # Momentum
    for p in [5, 10, 20]:
        cols.append(f"momentum_{p}")
    # Acceleration
    cols.append("return_accel")
    # Skew & kurtosis
    for p in [10, 20]:
        cols.extend([f"skew_{p}", f"kurtosis_{p}"])
    # Consecutive
    cols.extend(["consec_count", "consec_dir", "consec_signed"])
    # Distance from high/low
    for p in [10, 20, 50]:
        cols.extend([f"dist_high_{p}", f"dist_low_{p}"])
    # Return lags
    for lag in [1, 2, 3, 5]:
        cols.append(f"return_lag_{lag}")
    # Volume-price correlation
    for p in [10, 20]:
        cols.append(f"vol_price_corr_{p}")
    # Pivot
    cols.extend(["pivot_dev", "pivot_r1_dev", "pivot_s1_dev"])
    # ADX
    cols.extend(["adx_14", "plus_di_14", "minus_di_14"])
    # TRIX
    cols.append("trix")
    # Chaikin Volatility
    cols.append("chaikin_vol")
    # Range position
    for p in [10, 20]:
        cols.append(f"range_position_{p}")
    # Time
    cols.extend([
        "hour", "minute", "weekday", "month",
        "hour_sin", "hour_cos", "minute_sin", "minute_cos",
        "weekday_sin", "weekday_cos", "month_sin", "month_cos",
    ])
    return cols


def create_label(df):
    """Create binary label: 1 if next period close > current close, else 0."""
    df = df.copy()
    df["label"] = (df["close"].shift(-1) > df["close"]).astype(int)
    return df


path: scripts/train.py
content: """
Training pipeline: fetch CSI-300 stock data, engineer features,
train XGBoost binary classifier with feature-importance selection,
and report 10-fold cross-validation results.

Uses Tencent daily API for training and daily-signal inference.
"""
import json
import os
import time
import warnings
from datetime import datetime

import akshare as ak
import joblib
import numpy as np
import pandas as pd
from sklearn.metrics import (
    accuracy_score, precision_score, recall_score,
    f1_score, roc_auc_score, classification_report,
)
from sklearn.model_selection import StratifiedKFold
from sklearn.preprocessing import StandardScaler
from tqdm import tqdm
from xgboost import XGBClassifier

import config
from features import add_technical_features, create_label, get_all_feature_columns, get_feature_metadata

warnings.filterwarnings("ignore")


# ────────────────────────────── Data fetching ──────────────────────────────

def code_to_tx_symbol(code):
    """Convert 6-digit stock code to Tencent symbol format (sh/sz prefix)."""
    code = str(code).zfill(6)
    if code.startswith("6"):
        return f"sh{code}"
    return f"sz{code}"


def get_hs300_constituents():
    """Get CSI 300 constituent stock codes."""
    print("[1/5] Fetching CSI 300 constituent list ...")
    try:
        df = ak.index_stock_cons(symbol="000300")
        codes = df["品种代码"].astype(str).str.zfill(6).tolist()
        print(f"  Got {len(codes)} constituents via index_stock_cons")
        return codes
    except Exception as e:
        print(f"  index_stock_cons failed: {e}")
    try:
        df = ak.index_stock_cons_csindex(symbol="000300")
        col = [c for c in df.columns if "代码" in c][0]
        codes = df[col].astype(str).str.zfill(6).tolist()
        print(f"  Got {len(codes)} constituents via index_stock_cons_csindex")
        return codes
    except Exception as e:
        print(f"  index_stock_cons_csindex failed: {e}")
    raise RuntimeError("Failed to fetch CSI 300 constituents from akshare")


def fetch_daily_data_tx(code, start_date="20240101", end_date=None, max_retries=3):
    """Fetch daily OHLCV data for a single stock via Tencent API."""
    if end_date is None:
        end_date = datetime.now().strftime("%Y%m%d")
    cache_path = os.path.join(config.DATA_DIR, f"{code}_daily.csv")
    if os.path.exists(cache_path):
        df = pd.read_csv(cache_path)
        if len(df) > 50:
            return df

    tx_symbol = code_to_tx_symbol(code)
    df = None
    for attempt in range(max_retries):
        try:
            df = ak.stock_zh_a_hist_tx(
                symbol=tx_symbol,
                start_date=start_date,
                end_date=end_date,
                adjust="qfq",
            )
            if df is not None and not df.empty:
                break
        except Exception:
            if attempt < max_retries - 1:
                time.sleep(2 * (attempt + 1))
            continue

    if df is None or df.empty:
        return None

    # Normalise columns
    rename_map = {"date": "datetime"}
    df.rename(columns=rename_map, inplace=True)
    # Tencent returns 'amount' (turnover in lots); use as volume proxy
    if "volume" not in df.columns and "amount" in df.columns:
        df["volume"] = df["amount"]
    for c in ["open", "close", "high", "low", "volume"]:
        if c in df.columns:
            df[c] = pd.to_numeric(df[c], errors="coerce")
    df.to_csv(cache_path, index=False)
    return df


def fetch_all_training_data(codes):
    """Fetch daily data for a list of stock codes and concatenate."""
    print(f"[2/5] Fetching daily data for {len(codes)} stocks (cached results reused) ...")
    all_dfs = []
    failed = 0
    for i, code in enumerate(tqdm(codes, desc="Fetching")):
        df = fetch_daily_data_tx(code)
        if df is not None and len(df) > 50:
            df["stock_code"] = code
            all_dfs.append(df)
        else:
            failed += 1
        time.sleep(0.5)
        # Progress update every 50 stocks
        if (i + 1) % 50 == 0:
            print(f"  ... {i + 1}/{len(codes)}, success so far: {len(all_dfs)}")

    print(f"  Success: {len(all_dfs)}, Failed/skipped: {failed}")
    if not all_dfs:
        raise RuntimeError("No training data fetched")
    return pd.concat(all_dfs, ignore_index=True)


# ────────────────────────────── Feature engineering ──────────────────────────

def prepare_features(raw_df):
    """Engineer features and create labels for every stock in the dataframe."""
    print("[3/5] Engineering features ...")
    groups = []
    for code, grp in raw_df.groupby("stock_code"):
        grp = grp.copy()
        grp["datetime"] = pd.to_datetime(grp["datetime"])
        grp = grp.sort_values("datetime").reset_index(drop=True)
        grp = add_technical_features(grp)
        grp = create_label(grp)
        groups.append(grp)
    df = pd.concat(groups, ignore_index=True)

    feature_cols = get_all_feature_columns()
    feature_cols = [c for c in feature_cols if c in df.columns]
    df = df.dropna(subset=feature_cols + ["label"]).reset_index(drop=True)
    print(f"  Total samples: {len(df)}, features: {len(feature_cols)}")
    return df, feature_cols


# ────────────────────────────── Training ──────────────────────────────

def select_features_by_importance(X, y, feature_cols, top_n):
    """Train a preliminary model and pick top-N features by gain importance."""
    print("  Training preliminary model for feature selection ...")
    prelim = XGBClassifier(**config.XGB_PARAMS)
    prelim.fit(X, y)
    imp = pd.Series(prelim.feature_importances_, index=feature_cols)
    imp = imp.sort_values(ascending=False)
    selected = imp.head(top_n).index.tolist()
    print(f"  Selected {len(selected)} features from {len(feature_cols)}")
    return selected, imp


def cross_validate(X, y, params, n_folds=10):
    """Stratified K-Fold cross-validation, return per-fold and mean metrics."""
    skf = StratifiedKFold(n_splits=n_folds, shuffle=True, random_state=42)
    metrics_list = []
    for fold, (train_idx, val_idx) in enumerate(skf.split(X, y), 1):
        X_tr, X_val = X[train_idx], X[val_idx]
        y_tr, y_val = y[train_idx], y[val_idx]
        model = XGBClassifier(**params)
        model.fit(X_tr, y_tr, eval_set=[(X_val, y_val)], verbose=False)
        y_pred = model.predict(X_val)
        y_prob = model.predict_proba(X_val)[:, 1]
        m = {
            "fold": fold,
            "accuracy": round(accuracy_score(y_val, y_pred), 4),
            "precision": round(precision_score(y_val, y_pred, zero_division=0), 4),
            "recall": round(recall_score(y_val, y_pred, zero_division=0), 4),
            "f1": round(f1_score(y_val, y_pred, zero_division=0), 4),
            "auc": round(roc_auc_score(y_val, y_prob), 4),
        }
        metrics_list.append(m)
        print(f"    Fold {fold:2d} — Acc: {m['accuracy']:.4f}  AUC: {m['auc']:.4f}")
    mean_m = {
        "fold": "mean",
        "accuracy": round(np.mean([m["accuracy"] for m in metrics_list]), 4),
        "precision": round(np.mean([m["precision"] for m in metrics_list]), 4),
        "recall": round(np.mean([m["recall"] for m in metrics_list]), 4),
        "f1": round(np.mean([m["f1"] for m in metrics_list]), 4),
        "auc": round(np.mean([m["auc"] for m in metrics_list]), 4),
    }
    metrics_list.append(mean_m)
    return metrics_list


def train_final_model(X, y, params):
    """Train the final model on all data."""
    model = XGBClassifier(**params)
    model.fit(X, y, verbose=False)
    return model


# ────────────────────────────── EDA summary ──────────────────────────────

def generate_eda(raw_df, feature_df, feature_cols, label_col="label"):
    """Generate exploratory data analysis summary dict."""
    datetime_series = pd.to_datetime(raw_df["datetime"], errors="coerce")
    eda = {
        "num_stocks": int(raw_df["stock_code"].nunique()),
        "date_range": f'{datetime_series.min()} ~ {datetime_series.max()}',
        "total_raw_rows": int(len(raw_df)),
        "total_samples_after_fe": int(len(feature_df)),
        "label_distribution": {
            "up (1)": int((feature_df[label_col] == 1).sum()),
            "down (0)": int((feature_df[label_col] == 0).sum()),
            "up_ratio": round(float((feature_df[label_col] == 1).mean()), 4),
        },
        "feature_stats": {},
    }
    for c in feature_cols:
        if c in feature_df.columns:
            eda["feature_stats"][c] = {
                "mean": round(float(feature_df[c].mean()), 6),
                "std": round(float(feature_df[c].std()), 6),
                "min": round(float(feature_df[c].min()), 6),
                "max": round(float(feature_df[c].max()), 6),
            }
    return eda


# ────────────────────────────── Report ──────────────────────────────

def build_report(eda, selected_features, importance_dict, cv_metrics, params):
    """Build a comprehensive model report dict."""
    feature_metadata = get_feature_metadata()
    selected_set = set(selected_features)
    feature_catalog = []
    for feature_name, importance in sorted(importance_dict.items(), key=lambda item: -item[1]):
        stats = eda.get("feature_stats", {}).get(feature_name, {})
        meta = feature_metadata.get(feature_name, {})
        feature_catalog.append({
            "english_name": feature_name,
            "chinese_name": meta.get("chinese_name", feature_name),
            "logic": meta.get("logic", "-"),
            "category": meta.get("category", "未分类"),
            "importance": round(importance, 6),
            "selected": feature_name in selected_set,
            "stats": stats,
        })

    report = {
        "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "project_background": (
            "本项目基于沪深300成份股的行情数据，构建XGBoost二分类模型，"
            "预测个股下一交易日的价格涨跌方向。训练数据时间范围为2024年至今，"
            "使用akshare获取前复权的OHLCV数据，通过技术指标特征工程生成模型输入，"
            "并依据特征重要性筛选入模特征，最终以十折交叉验证评估模型泛化能力。"
            "模型在预测与评估阶段统一使用腾讯财经日线数据，评估口径为T+1。"
        ),
        "eda": eda,
        "feature_dimensions": {
            "total_candidate_features": len(importance_dict),
            "selected_features": len(selected_features),
            "selected_feature_names": selected_features,
            "feature_catalog": feature_catalog,
            "feature_importance": {
                k: round(v, 6) for k, v in
                sorted(importance_dict.items(), key=lambda x: -x[1])
            },
        },
        "model_performance": {
            "algorithm": "XGBoost (xgboost.XGBClassifier)",
            "hyperparameters": {k: v for k, v in params.items()},
            "cv_folds": len(cv_metrics) - 1,
            "cv_results": cv_metrics,
        },
        "usage_guide": (
            "1. 安装依赖: pip install -r requirements.txt\n"
            "2. 训练模型: python3 train.py\n"
            "3. 日线预测: python3 predict.py 2026-03-12\n"
            "4. T+1评估: python3 evaluate.py\n"
            "5. 启动网页: python3 app.py  → 浏览器访问 http://127.0.0.1:5000\n"
            "6. 一键执行: python3 main.py"
        ),
    }
    return report


# ────────────────────────────── Main ──────────────────────────────

def run_training():
    print("=" * 60)
    print("  Stock Price Direction Prediction — Training Pipeline")
    print("=" * 60)

    # 1. Get constituents
    codes = get_hs300_constituents()

    # 2. Fetch data
    raw_df = fetch_all_training_data(codes)

    # 3. Feature engineering
    feature_df, all_feature_cols = prepare_features(raw_df)

    X_all = feature_df[all_feature_cols].values
    y_all = feature_df["label"].values

    # 4. Feature selection
    print("[4/5] Feature importance selection + 10-fold CV ...")
    selected_features, importance_series = select_features_by_importance(
        X_all, y_all, all_feature_cols, config.TOP_N_FEATURES
    )
    importance_dict = importance_series.to_dict()

    X_sel = feature_df[selected_features].values
    scaler = StandardScaler()
    X_scaled = scaler.fit_transform(X_sel)

    # 10-fold CV
    print(f"  Running {config.CV_FOLDS}-fold cross-validation ...")
    cv_metrics = cross_validate(X_scaled, y_all, config.XGB_PARAMS, config.CV_FOLDS)
    mean_m = cv_metrics[-1]
    print(f"\n  ★ Mean CV — Acc: {mean_m['accuracy']:.4f}  "
          f"AUC: {mean_m['auc']:.4f}  F1: {mean_m['f1']:.4f}")

    # 5. Train final model & save
    print("\n[5/5] Training final model & saving artifacts ...")
    final_model = train_final_model(X_scaled, y_all, config.XGB_PARAMS)
    joblib.dump(final_model, config.MODEL_PATH)
    joblib.dump(scaler, config.SCALER_PATH)
    with open(config.FEATURE_PATH, "w") as f:
        json.dump(selected_features, f, ensure_ascii=False, indent=2)
    print(f"  Model saved → {config.MODEL_PATH}")
    print(f"  Scaler saved → {config.SCALER_PATH}")
    print(f"  Features saved → {config.FEATURE_PATH}")

    # EDA & report
    eda = generate_eda(raw_df, feature_df, selected_features)
    report = build_report(eda, selected_features, importance_dict, cv_metrics, config.XGB_PARAMS)
    with open(config.REPORT_PATH, "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(f"  Report saved → {config.REPORT_PATH}")

    print("\n✅ Training complete!\n")
    return report


if __name__ == "__main__":
    run_training()


path: scripts/app.py
content: """
Flask web app: display prediction results, model report, and evaluation.
"""
import html
import json
import os
import re
from datetime import datetime

import pandas as pd
from flask import Flask, render_template, request
from markupsafe import Markup

import config
from features import get_feature_metadata

app = Flask(
    __name__,
    template_folder=config.TEMPLATE_DIR,
    static_folder=config.STATIC_DIR,
)


def _load_json(path):
    if os.path.exists(path):
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    return None


def _load_csv(path):
    if os.path.exists(path):
        return pd.read_csv(path).to_dict(orient="records")
    return []


def _dedupe_daily_rows(rows, date_field):
    if not rows:
        return []

    df = pd.DataFrame(rows).copy()
    if "股票代码" not in df.columns or date_field not in df.columns:
        return rows

    df["股票代码"] = df["股票代码"].astype(str).str.zfill(6)
    df[date_field] = pd.to_datetime(df[date_field], errors="coerce").dt.strftime("%Y-%m-%d")

    generated_field = "预测生成时间"
    if generated_field not in df.columns:
        df[generated_field] = df[date_field] + " 00:00:00"
    df[generated_field] = pd.to_datetime(df[generated_field], errors="coerce")
    df = df.sort_values([generated_field, date_field, "股票代码"], ascending=[False, False, True])
    df = df.drop_duplicates(subset=["股票代码", date_field], keep="first")
    df[generated_field] = df[generated_field].dt.strftime("%Y-%m-%d %H:%M:%S")
    return df.to_dict(orient="records")


def _get_headers(rows):
    if not rows:
        return []
    return list(rows[0].keys())


def _extract_dates(rows, field_name):
    dates = []
    for row in rows:
        value = row.get(field_name)
        if value and value not in dates:
            dates.append(value)
    return sorted(dates, reverse=True)


def _filter_rows_by_date(rows, field_name, selected_date):
    if not selected_date:
        return rows
    return [row for row in rows if str(row.get(field_name, "")) == selected_date]


def _prepare_report_view(report):
    if not report:
        return None

    eda = report.get("eda", {})
    feature_dimensions = report.get("feature_dimensions", {})
    model_performance = report.get("model_performance", {})
    label_distribution = eda.get("label_distribution", {})
    feature_importance = feature_dimensions.get("feature_importance", {})
    selected_feature_names = feature_dimensions.get("selected_feature_names", [])
    cv_results = model_performance.get("cv_results", [])
    mean_metrics = next((item for item in cv_results if str(item.get("fold")) == "mean"), {})
    fold_metrics = [item for item in cv_results if str(item.get("fold")) != "mean"]

    feature_metadata = get_feature_metadata()
    feature_catalog = []
    report_catalog = feature_dimensions.get("feature_catalog", [])
    if report_catalog:
        for item in report_catalog:
            stats = item.get("stats", {})
            feature_catalog.append({
                "english_name": item.get("english_name"),
                "chinese_name": item.get("chinese_name"),
                "logic": item.get("logic"),
                "category": item.get("category"),
                "importance": item.get("importance", 0),
                "selected": item.get("selected", False),
                "mean": stats.get("mean"),
                "std": stats.get("std"),
                "min": stats.get("min"),
                "max": stats.get("max"),
            })
    else:
        for feature_name, importance in sorted(feature_importance.items(), key=lambda item: item[1], reverse=True):
            meta = feature_metadata.get(feature_name, {})
            stats = eda.get("feature_stats", {}).get(feature_name, {})
            feature_catalog.append({
                "english_name": feature_name,
                "chinese_name": meta.get("chinese_name", feature_name),
                "logic": meta.get("logic", "-"),
                "category": meta.get("category", "未分类"),
                "importance": importance,
                "selected": feature_name in selected_feature_names,
                "mean": stats.get("mean"),
                "std": stats.get("std"),
                "min": stats.get("min"),
                "max": stats.get("max"),
            })

    top_features = sorted(
        feature_catalog,
        key=lambda item: item["importance"],
        reverse=True,
    )[:10]
    max_importance = top_features[0]["importance"] if top_features else 0
    feature_bars = []
    for item in top_features:
        width = (item["importance"] / max_importance * 100) if max_importance else 0
        feature_bars.append({
            "name": item["english_name"],
            "chinese_name": item["chinese_name"],
            "logic": item["logic"],
            "value": item["importance"],
            "width": round(width, 2),
        })

    metrics = [
        {"label": "Accuracy", "value": mean_metrics.get("accuracy", 0)},
        {"label": "Precision", "value": mean_metrics.get("precision", 0)},
        {"label": "Recall", "value": mean_metrics.get("recall", 0)},
        {"label": "F1", "value": mean_metrics.get("f1", 0)},
        {"label": "AUC", "value": mean_metrics.get("auc", 0)},
    ]

    usage_steps = []
    for line in report.get("usage_guide", "").splitlines():
        line = line.strip()
        if line:
            usage_steps.append(line)

    selected_features = feature_dimensions.get("selected_features", 0)
    total_candidate = feature_dimensions.get("total_candidate_features", 0)
    selection_ratio = (selected_features / total_candidate * 100) if total_candidate else 0
    up_ratio = float(label_distribution.get("up_ratio", 0) or 0)
    down_ratio = 1 - up_ratio if up_ratio else 0

    project_background = (
        "本项目面向 A 股个股日线涨跌预测场景，基于沪深300 成份股历史日线数据训练 "
        "XGBoost 二分类模型。训练阶段先生成技术指标特征，再通过特征重要性筛选出核心入模特征，"
        "最终用十折交叉验证评估模型稳定性。预测和评估均统一采用腾讯财经日线数据，"
        "评估口径为 T+1，即用下一交易日收盘价验证预测方向。"
    )

    return {
        "generated_at": report.get("generated_at"),
        "project_background": project_background,
        "eda": eda,
        "feature_dimensions": feature_dimensions,
        "model_performance": model_performance,
        "summary_cards": [
            {"label": "训练股票数", "value": eda.get("num_stocks", "N/A")},
            {"label": "原始样本行数", "value": eda.get("total_raw_rows", "N/A")},
            {"label": "建模样本数", "value": eda.get("total_samples_after_fe", "N/A")},
            {"label": "入模特征数", "value": selected_features},
        ],
        "dataset_cards": [
            {"label": "时间范围", "value": eda.get("date_range", "N/A")},
            {"label": "上涨样本", "value": label_distribution.get("up (1)", 0)},
            {"label": "下跌样本", "value": label_distribution.get("down (0)", 0)},
            {"label": "上涨占比", "value": f"{up_ratio * 100:.2f}%"},
        ],
        "label_mix": {
            "up_width": round(up_ratio * 100, 2),
            "down_width": round(down_ratio * 100, 2),
            "up_count": label_distribution.get("up (1)", 0),
            "down_count": label_distribution.get("down (0)", 0),
        },
        "feature_bars": feature_bars,
        "feature_catalog": feature_catalog,
        "selection_ratio": round(selection_ratio, 2),
        "metrics": metrics,
        "fold_metrics": fold_metrics,
        "usage_steps": usage_steps,
    }


def _apply_inline_formatting(text):
    escaped = html.escape(text)
    escaped = re.sub(r"`([^`]+)`", r"<code>\1</code>", escaped)
    escaped = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", escaped)
    escaped = re.sub(r"\*([^*]+)\*", r"<em>\1</em>", escaped)
    return escaped


def _parse_markdown_table(lines, start_index):
    table_lines = []
    index = start_index
    while index < len(lines):
        current = lines[index].strip()
        if "|" not in current or not current:
            break
        table_lines.append(current)
        index += 1

    if len(table_lines) < 2:
        return None, start_index

    header_cells = [cell.strip() for cell in table_lines[0].strip("|").split("|")]
    separator_cells = [cell.strip() for cell in table_lines[1].strip("|").split("|")]
    if not separator_cells or not all(set(cell) <= {"-", ":"} for cell in separator_cells if cell):
        return None, start_index

    rows = []
    for line in table_lines[2:]:
        cells = [cell.strip() for cell in line.strip("|").split("|")]
        if len(cells) < len(header_cells):
            cells.extend([""] * (len(header_cells) - len(cells)))
        row_data = {header_cells[idx]: cells[idx] if idx < len(cells) else "" for idx in range(len(header_cells))}
        rows.append(row_data)

    return {
        "headers": header_cells,
        "rows": rows,
    }, index


def _render_table_html(table):
    table_html = ["<div class=\"table-wrap\"><table class=\"report-table\"><thead><tr>"]
    for cell in table["headers"]:
        table_html.append(f"<th>{_apply_inline_formatting(cell)}</th>")
    table_html.append("</tr></thead><tbody>")
    for row in table["rows"]:
        table_html.append("<tr>")
        for cell in table["headers"]:
            table_html.append(f"<td>{_apply_inline_formatting(row.get(cell, ''))}</td>")
        table_html.append("</tr>")
    table_html.append("</tbody></table></div>")
    return "".join(table_html)


def _consume_table(lines, start_index):
    table, index = _parse_markdown_table(lines, start_index)
    if not table:
        return None, start_index

    return _render_table_html(table), index


def _extract_tables_from_lines(lines):
    tables = []
    index = 0
    while index < len(lines):
        table, next_index = _parse_markdown_table(lines, index)
        if table:
            tables.append(table)
            index = next_index
            continue
        index += 1
    return tables


def _parse_numeric_value(text):
    if text is None:
        return None

    value = str(text).replace(",", "").strip()
    match = re.search(r"-?\d+(?:\.\d+)?", value)
    if not match:
        return None
    return float(match.group())


def _parse_percent_value(text):
    if text is None:
        return None

    value = str(text).replace(",", "").strip()
    match = re.search(r"(-?\d+(?:\.\d+)?)\s*%", value)
    if not match:
        return None
    return float(match.group(1))


def _build_bar_chart(title, items, subtitle="", signed=False):
    usable_items = [item for item in items if item.get("value") is not None]
    if not usable_items:
        return None

    max_value = max(abs(item["value"]) for item in usable_items) or 1
    normalized_items = []
    for item in usable_items:
        raw_value = item["value"]
        tone = item.get("tone")
        if not tone:
            if signed:
                tone = "up" if raw_value >= 0 else "down"
            else:
                tone = "neutral"
        normalized_items.append({
            "label": item["label"],
            "secondary": item.get("secondary", ""),
            "display": item.get("display", str(raw_value)),
            "width": round(abs(raw_value) / max_value * 100, 2),
            "tone": tone,
        })

    return {
        "title": title,
        "subtitle": subtitle,
        "bars": normalized_items,
    }


def _build_fallback_charts(report):
    section_chart = _build_bar_chart(
        "章节信息密度",
        [
            {
                "label": section["title"],
                "secondary": f"{section.get('table_count', 0)} 张表格",
                "display": f"{section.get('line_count', 0)} 行",
                "value": section.get("line_count", 0),
                "tone": "neutral",
            }
            for section in report.get("sections", [])
        ],
        subtitle="按章节内容行数估算各部分信息密度。",
    )
    return [chart for chart in [section_chart] if chart]


def _build_sector_charts(report):
    charts = []
    ranking_table = next(
        (
            table for table in report.get("tables", [])
            if "板块" in table.get("headers", []) and "梯队" in table.get("headers", [])
        ),
        None,
    )
    if ranking_table:
        ranking_items = []
        total = len(ranking_table["rows"])
        for index, row in enumerate(ranking_table["rows"]):
            ranking_items.append({
                "label": row.get("板块", "-"),
                "secondary": row.get("梯队", ""),
                "display": row.get("交易结论") or row.get("近阶段判断") or "",
                "value": total - index,
                "tone": "neutral",
            })
        charts.append(_build_bar_chart("重点板块排序", ranking_items, "按照报告中的优先顺序展示当前主线。"))

    tag_counts = {}
    for table in report.get("tables", []):
        if "操作标签" not in table.get("headers", []):
            continue
        for row in table["rows"]:
            tag = row.get("操作标签", "").strip() or "未标注"
            tag_counts[tag] = tag_counts.get(tag, 0) + 1

    if tag_counts:
        tag_items = [
            {
                "label": label,
                "secondary": "出现次数",
                "display": f"{count} 次",
                "value": count,
                "tone": "neutral",
            }
            for label, count in sorted(tag_counts.items(), key=lambda item: item[1], reverse=True)
        ]
        charts.append(_build_bar_chart("操作标签分布", tag_items, "统计报告中出现的配置型、观察型、情绪型等标签。"))

    return [chart for chart in charts if chart] or _build_fallback_charts(report)


def _build_stock_charts(report):
    charts = []
    overview_table = next(
        (
            table for table in report.get("tables", [])
            if "股票" in table.get("headers", []) and any(
                ("涨跌幅" in header) or ("涨跌" in header)
                for header in table.get("headers", [])
            )
        ),
        None,
    )
    if overview_table:
        return_header = next(
            (
                header for header in overview_table["headers"]
                if ("涨跌幅" in header) or ("涨跌" in header)
            ),
            None,
        )
        if return_header:
            return_items = []
            sorted_rows = sorted(
                overview_table["rows"],
                key=lambda row: _parse_percent_value(row.get(return_header)) if _parse_percent_value(row.get(return_header)) is not None else -10**9,
                reverse=True,
            )
            for row in sorted_rows[:8]:
                value = _parse_percent_value(row.get(return_header))
                if value is None:
                    continue
                return_items.append({
                    "label": row.get("股票", "-"),
                    "secondary": row.get("一句话建议", ""),
                    "display": f"{value:+.2f}%",
                    "value": value,
                })
            charts.append(_build_bar_chart("近 7 日涨跌幅", return_items, "从全名单概览表提取收益表现最高和最低的个股。", signed=True))

    section_labels = ["第一优先级", "第二优先级", "观察不出手", "回避"]
    section_items = []
    for label in section_labels:
        section = next((item for item in report.get("sections", []) if item["title"].endswith(label)), None)
        if not section:
            continue
        count = sum(len(table["rows"]) for table in section.get("tables", []))
        if count <= 0:
            continue
        section_items.append({
            "label": label,
            "secondary": "股票数量",
            "display": f"{count} 只",
            "value": count,
            "tone": "neutral",
        })
    if section_items:
        charts.append(_build_bar_chart("决策分层分布", section_items, "按报告中的优先级和回避区划分股票数量。"))

    return [chart for chart in charts if chart] or _build_fallback_charts(report)


def _render_markdown_blocks(block_lines):
    html_parts = []
    index = 0
    while index < len(block_lines):
        raw_line = block_lines[index]
        stripped = raw_line.strip()
        if not stripped:
            index += 1
            continue

        table_html, next_index = _consume_table(block_lines, index)
        if table_html:
            html_parts.append(table_html)
            index = next_index
            continue

        if stripped.startswith(("- ", "* ")):
            items = []
            while index < len(block_lines):
                item_line = block_lines[index].strip()
                if not item_line.startswith(("- ", "* ")):
                    break
                items.append(f"<li>{_apply_inline_formatting(item_line[2:].strip())}</li>")
                index += 1
            html_parts.append("<ul class=\"article-list\">" + "".join(items) + "</ul>")
            continue

        if re.match(r"^\d+\.\s+", stripped):
            items = []
            while index < len(block_lines):
                item_line = block_lines[index].strip()
                if not re.match(r"^\d+\.\s+", item_line):
                    break
                cleaned_item = re.sub(r"^\d+\.\s+", "", item_line)
                items.append(f"<li>{_apply_inline_formatting(cleaned_item)}</li>")
                index += 1
            html_parts.append("<ol class=\"article-list\">" + "".join(items) + "</ol>")
            continue

        paragraph_lines = [stripped]
        index += 1
        while index < len(block_lines):
            next_line = block_lines[index].strip()
            if not next_line:
                index += 1
                break
            if next_line.startswith(("- ", "* ")) or re.match(r"^\d+\.\s+", next_line) or next_line.startswith("#"):
                break
            if "|" in next_line:
                break
            paragraph_lines.append(next_line)
            index += 1
        html_parts.append(f"<p>{_apply_inline_formatting(' '.join(paragraph_lines))}</p>")
    return Markup("".join(html_parts))


def _parse_report_document(text, fallback_title):
    lines = text.splitlines()
    title = fallback_title
    subtitle = ""
    report_time = ""
    intro_lines = []
    sections = []
    current_section = None
    current_major_title = ""

    for raw_line in lines:
        stripped = raw_line.strip()
        if stripped.startswith("# ") and title == fallback_title:
            title = stripped[2:].strip()
            continue
        if not subtitle and stripped.startswith(">"):
            subtitle = stripped[1:].strip()
            continue
        if not report_time and re.match(r"^(报告时间|生成时间)\s*[：:]", stripped):
            report_time = re.sub(r"^(报告时间|生成时间)\s*[：:]\s*", "", stripped)
            continue
        if stripped.startswith("## "):
            if current_section:
                sections.append(current_section)
            current_major_title = stripped[3:].strip()
            current_section = {"title": current_major_title, "lines": []}
            continue
        if stripped.startswith("### "):
            subsection_title = stripped[4:].strip()
            if current_section and current_section["lines"]:
                sections.append(current_section)
            composed_title = f"{current_major_title} / {subsection_title}" if current_major_title else subsection_title
            current_section = {"title": composed_title, "lines": []}
            continue

        if current_section:
            current_section["lines"].append(raw_line)
        else:
            intro_lines.append(raw_line)

    if current_section:
        sections.append(current_section)

    intro_html = _render_markdown_blocks(intro_lines)
    intro_tables = _extract_tables_from_lines(intro_lines)
    rendered_sections = []
    highlights = []
    all_tables = list(intro_tables)
    for section in sections:
        section_tables = _extract_tables_from_lines(section["lines"])
        rendered_sections.append({
            "title": section["title"],
            "anchor_id": f"section-{len(rendered_sections) + 1}",
            "body_html": _render_markdown_blocks(section["lines"]),
            "tables": section_tables,
            "table_count": len(section_tables),
            "line_count": len([line for line in section["lines"] if line.strip()]),
        })
        all_tables.extend(section_tables)
        if len(highlights) < 4:
            for line in section["lines"]:
                stripped = line.strip()
                if stripped.startswith(("- ", "* ")):
                    highlights.append(stripped[2:].strip())
                if len(highlights) >= 4:
                    break

    if not rendered_sections and intro_lines:
        rendered_sections.append({
            "title": "报告正文",
            "body_html": intro_html,
        })
        intro_html = Markup("")

    return {
        "title": title,
        "subtitle": subtitle,
        "report_time": report_time,
        "intro_html": intro_html,
        "sections": rendered_sections,
        "highlights": highlights[:4],
        "tables": all_tables,
        "table_count": len(all_tables),
        "bullet_count": sum(1 for line in lines if line.strip().startswith(("- ", "* "))),
        "line_count": len([line for line in lines if line.strip()]),
    }


def _parse_report_datetime(value):
    if not value:
        return None

    text = str(value).strip()
    if not text:
        return None

    for fmt in (
        "%Y-%m-%d %H:%M:%S",
        "%Y-%m-%d %H:%M",
        "%Y-%m-%d",
        "%Y%m%d_%H%M%S",
        "%Y%m%d%H%M%S",
    ):
        try:
            return datetime.strptime(text, fmt)
        except ValueError:
            continue

    parsed = pd.to_datetime(text, errors="coerce")
    if pd.isna(parsed):
        return None
    return parsed.to_pydatetime()


def _build_report_menu_item(path, modified_at, size_kb):
    name = os.path.basename(path)
    fallback_title = name.rsplit(".", 1)[0].replace("_", " ")
    raw_text = _load_report_file(path)
    report = _parse_report_document(raw_text, fallback_title)
    display_time = report.get("report_time") or modified_at
    sort_time = _parse_report_datetime(report.get("report_time")) or _parse_report_datetime(modified_at) or datetime.min
    return {
        "name": name,
        "path": path,
        "modified_at": modified_at,
        "size_kb": size_kb,
        "display_title": report.get("title") or fallback_title,
        "display_time": display_time,
        "report": report,
        "sort_time": sort_time,
    }


def _list_report_files(report_dir):
    files = []
    if not os.path.exists(report_dir):
        return files

    for name in os.listdir(report_dir):
        path = os.path.join(report_dir, name)
        if not os.path.isfile(path):
            continue
        if not name.lower().endswith((".md", ".txt", ".json")):
            continue
        stat = os.stat(path)
        files.append(
            _build_report_menu_item(
                path,
                datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
                round(stat.st_size / 1024, 1),
            )
        )
    files.sort(key=lambda item: item["sort_time"], reverse=True)
    return files


def _load_report_file(path):
    if path.lower().endswith(".json"):
        with open(path, "r", encoding="utf-8") as file_obj:
            payload = json.load(file_obj)
        return json.dumps(payload, ensure_ascii=False, indent=2)

    with open(path, "r", encoding="utf-8") as file_obj:
        return file_obj.read()


def _prepare_analysis_page(report_dir, selected_name, kind):
    file_items = _list_report_files(report_dir)
    selected_item = None
    if file_items:
        selected_item = next((item for item in file_items if item["name"] == selected_name), file_items[0])

    report = selected_item.get("report") if selected_item else None

    if kind == "sector":
        empty_hint = "暂无板块分析报告。调用 stock-sector-research 后，请将结果保存到 scripts/reports/sector_analysis/。"
        page_title = "板块分析"
    else:
        empty_hint = "暂无个股分析报告。调用 stock-watchlist-briefing 后，请将结果保存到 scripts/reports/stock_analysis/。"
        page_title = "个股分析"

    summary_cards = []
    charts = []
    nav_sections = []
    if selected_item and report:
        summary_cards = [
            {"label": "报告章节", "value": len(report.get("sections", []))},
            {"label": "关键表格", "value": report.get("table_count", 0)},
            {"label": "要点条目", "value": report.get("bullet_count", 0)},
            {"label": "文件大小", "value": f"{selected_item['size_kb']} KB"},
        ]
        nav_sections = [section for section in report.get("sections", []) if " / " not in section.get("title", "")]
        if kind == "sector":
            charts = _build_sector_charts(report)
        else:
            charts = _build_stock_charts(report)

    return {
        "title": page_title,
        "files": file_items,
        "selected_file": selected_item,
        "report": report,
        "empty_hint": empty_hint,
        "summary_cards": summary_cards,
        "charts": charts,
        "nav_sections": nav_sections,
    }


@app.route("/")
@app.route("/predictions")
def predictions():
    rows = _dedupe_daily_rows(_load_csv(config.PREDICTION_PATH), "当前时间")
    available_dates = _extract_dates(rows, "当前时间")
    selected_date = request.args.get("date") or (available_dates[0] if available_dates else None)
    filtered_rows = _filter_rows_by_date(rows, "当前时间", selected_date)
    up_count = sum(1 for row in filtered_rows if row.get("预测结果") == "上涨")
    down_count = sum(1 for row in filtered_rows if row.get("预测结果") == "下跌")
    return render_template(
        "predictions.html",
        rows=filtered_rows,
        headers=_get_headers(filtered_rows or rows),
        up_count=up_count,
        down_count=down_count,
        available_dates=available_dates,
        selected_date=selected_date,
        active="predictions",
    )


@app.route("/report")
def report():
    data = _load_json(config.REPORT_PATH)
    return render_template(
        "report.html",
        report=_prepare_report_view(data),
        active="report",
    )


@app.route("/evaluation")
def evaluation():
    rows = _dedupe_daily_rows(_load_csv(config.EVALUATION_PATH), "预测时间")
    available_dates = _extract_dates(rows, "预测时间")
    selected_date = request.args.get("date") or (available_dates[0] if available_dates else None)
    filtered_rows = _filter_rows_by_date(rows, "预测时间", selected_date)
    valid = [r for r in filtered_rows if r.get("预测是否准确") in ("✓", "✗")]
    if valid:
        acc = sum(1 for r in valid if r["预测是否准确"] == "✓") / len(valid)
    else:
        acc = None
    return render_template(
        "evaluation.html",
        rows=filtered_rows,
        headers=_get_headers(filtered_rows or rows),
        accuracy=acc,
        available_dates=available_dates,
        selected_date=selected_date,
        active="evaluation",
    )


@app.route("/sector-analysis")
def sector_analysis():
    return render_template(
        "sector_analysis.html",
        page=_prepare_analysis_page(
            config.SECTOR_REPORTS_DIR,
            request.args.get("file"),
            "sector",
        ),
        active="sector-analysis",
        title="板块分析",
    )


@app.route("/stock-analysis")
def stock_analysis():
    return render_template(
        "stock_analysis.html",
        page=_prepare_analysis_page(
            config.STOCK_REPORTS_DIR,
            request.args.get("file"),
            "stock",
        ),
        active="stock-analysis",
        title="个股分析",
    )


if __name__ == "__main__":
    print("Starting web server at http://127.0.0.1:5000")
    app.run(host="127.0.0.1", port=5000, debug=False)


path: scripts/evaluate.py
content: """Daily evaluation pipeline using Tencent daily data only.

It reads historical predictions, fetches Tencent daily prices around the
prediction date, and evaluates the prediction against the next available
trading day's close.
"""
import time
from datetime import timedelta

import akshare as ak
import pandas as pd

import config


EVALUATION_COLUMNS = [
    "股票代码",
    "股票名称",
    "预测时间",
    "预测生成时间",
    "当时价格",
    "预测结果",
    "上涨概率",
    "预测数据频率",
    "一天后价格",
    "评估数据频率",
    "实际涨跌",
    "预测是否准确",
]


def code_to_tx_symbol(code):
    code = str(code).zfill(6)
    return f"sh{code}" if code.startswith("6") else f"sz{code}"


def _normalize_daily_df(df):
    """Normalize Tencent daily dataframe."""
    df = df.copy()
    df.rename(columns={"date": "datetime"}, inplace=True)
    df["datetime"] = pd.to_datetime(df["datetime"])
    df["close"] = pd.to_numeric(df["close"], errors="coerce")
    return df.sort_values("datetime").reset_index(drop=True)


def fetch_daily_data_around(code, target_dt):
    """Fetch Tencent daily data around the prediction date."""
    dt = pd.to_datetime(target_dt)
    start_d = (dt - timedelta(days=30)).strftime("%Y%m%d")
    end_d = (dt + timedelta(days=10)).strftime("%Y%m%d")
    try:
        tx_sym = code_to_tx_symbol(code)
        df = ak.stock_zh_a_hist_tx(
            symbol=tx_sym,
            start_date=start_d,
            end_date=end_d,
            adjust="qfq",
        )
        if df is not None and not df.empty:
            return _normalize_daily_df(df), "daily.tencent"
    except Exception:
        pass
    return None, None


def get_price_next_trading_day(code, pred_time):
    """Return the close price of the next available trading day."""
    df, freq = fetch_daily_data_around(code, pred_time)
    if df is None or df.empty:
        return None, None

    pred_dt = pd.to_datetime(pred_time).normalize()
    after = df[df["datetime"] > pred_dt]
    if not after.empty:
        return float(after.iloc[0]["close"]), freq
    return None, None


def _merge_evaluation_history(eval_rows):
    """Persist full evaluation history keyed by stock code and prediction date."""
    eval_df = pd.DataFrame(eval_rows)
    eval_df["股票代码"] = eval_df["股票代码"].astype(str).str.zfill(6)
    eval_df["预测时间"] = pd.to_datetime(eval_df["预测时间"]).dt.strftime("%Y-%m-%d")
    if "预测生成时间" not in eval_df.columns:
        eval_df["预测生成时间"] = eval_df["预测时间"] + " 00:00:00"
    eval_df["预测生成时间"] = pd.to_datetime(eval_df["预测生成时间"]).dt.strftime("%Y-%m-%d %H:%M:%S")
    eval_df = eval_df.sort_values(["预测生成时间", "预测时间", "股票代码"], ascending=[False, False, True])
    eval_df = eval_df.drop_duplicates(subset=["股票代码", "预测时间"], keep="first")
    eval_df = eval_df.sort_values(["预测生成时间", "预测时间", "股票代码"], ascending=[False, False, True]).reset_index(drop=True)
    eval_df = eval_df.reindex(columns=EVALUATION_COLUMNS)
    eval_df.to_csv(config.EVALUATION_PATH, index=False, encoding="utf-8-sig")
    return eval_df


def _dedupe_predictions_for_evaluation(pred_df):
    """Keep one prediction per stock per trade date, preferring the latest batch."""
    deduped_df = pred_df.copy()
    deduped_df["股票代码"] = deduped_df["股票代码"].astype(str).str.zfill(6)
    deduped_df["当前时间"] = pd.to_datetime(deduped_df["当前时间"]).dt.strftime("%Y-%m-%d")
    if "预测生成时间" not in deduped_df.columns:
        deduped_df["预测生成时间"] = deduped_df["当前时间"] + " 00:00:00"
    deduped_df["预测生成时间"] = pd.to_datetime(deduped_df["预测生成时间"])
    deduped_df = deduped_df.sort_values(["预测生成时间", "当前时间", "股票代码"], ascending=[False, False, True])
    deduped_df = deduped_df.drop_duplicates(subset=["股票代码", "当前时间"], keep="first")
    deduped_df["预测生成时间"] = deduped_df["预测生成时间"].dt.strftime("%Y-%m-%d %H:%M:%S")
    return deduped_df.reset_index(drop=True)


def run_evaluation():
    print("=" * 60)
    print("  Stock Price Direction Prediction — Daily Evaluation Pipeline")
    print("=" * 60)

    try:
        pred_df = pd.read_csv(config.PREDICTION_PATH)
    except FileNotFoundError:
        print("  ⚠ No prediction file found. Run predict.py first.")
        return []

    pred_df = _dedupe_predictions_for_evaluation(pred_df)

    print(f"  Evaluating {len(pred_df)} deduplicated historical predictions ...\n")
    eval_rows = []
    for _, row in pred_df.iterrows():
        code = str(row["股票代码"]).zfill(6)
        name = row["股票名称"]
        pred_time = row["当前时间"]
        generated_at = row["预测生成时间"]
        pred_price = float(row["当前价格"])
        pred_result = row["预测结果"]
        up_prob = float(row["上涨概率"])
        pred_freq = row.get("数据频率", None)

        print(f"  {name}({code}) @ {pred_time} / {generated_at} [{pred_freq or '?'}] ...", end=" ")
        later_price, eval_freq = get_price_next_trading_day(code, pred_time)
        time.sleep(config.FETCH_DELAY)

        if later_price is None:
            actual_dir = "数据不足"
            correct = "N/A"
            print("数据不足")
        else:
            actual_dir = "上涨" if later_price > pred_price else "下跌"
            correct = "✓" if actual_dir == pred_result else "✗"
            print(f"→ {later_price:.2f} ({actual_dir}) [{eval_freq}] {correct}")

        eval_rows.append({
            "股票代码": code,
            "股票名称": name,
            "预测时间": pred_time,
            "预测生成时间": generated_at,
            "当时价格": pred_price,
            "预测结果": pred_result,
            "上涨概率": up_prob,
            "预测数据频率": pred_freq if pred_freq else "N/A",
            "一天后价格": later_price if later_price else "N/A",
            "评估数据频率": eval_freq if eval_freq else "N/A",
            "实际涨跌": actual_dir,
            "预测是否准确": correct,
        })

    eval_df = _merge_evaluation_history(eval_rows)
    print(f"\n  Evaluation saved → {config.EVALUATION_PATH}")

    # Overall accuracy (exclude N/A)
    valid = [r for r in eval_rows if r["预测是否准确"] in ("✓", "✗")]
    if valid:
        acc = sum(1 for r in valid if r["预测是否准确"] == "✓") / len(valid)
        print(f"\n  ★ 整体准确率: {acc:.2%} ({sum(1 for r in valid if r['预测是否准确'] == '✓')}/{len(valid)})")
    else:
        print("\n  ⚠ 无法计算准确率 (没有可对比的数据)")

    print("\n✅ Evaluation complete!\n")
    return eval_df.to_dict(orient="records")


if __name__ == "__main__":
    run_evaluation()


path: scripts/main.py
content: """One-click daily workflow: train -> predict -> evaluate -> web."""
import sys


def main():
    step = sys.argv[1] if len(sys.argv) > 1 else "all"

    if step in ("all", "train"):
        from train import run_training
        run_training()

    if step in ("all", "predict"):
        from predict import run_prediction
        run_prediction()

    if step in ("all", "evaluate"):
        from evaluate import run_evaluation
        run_evaluation()

    if step in ("all", "web"):
        from app import app
        print("Starting web server at http://127.0.0.1:5000")
        app.run(host="127.0.0.1", port=5000, debug=False)


if __name__ == "__main__":
    main()


path: scripts/templates/evaluation.html
content: {% extends "base.html" %}
{% block content %}
<div class="page-layout">
  <aside class="sidebar">
    <div class="card">
      <h3>评估日期</h3>
      <p class="muted">选择一个日期查看对应批次的历史评估结果。</p>
      <div class="date-list">
        {% for date_item in available_dates %}
        <a class="date-link {% if date_item == selected_date %}active{% endif %}" href="/evaluation?date={{ date_item }}">{{ date_item }}</a>
        {% endfor %}
      </div>
    </div>
  </aside>

  <div class="main-stack">
    <section class="grid">
      <div class="card">
        <div class="muted">所选日期</div>
        <div class="metric">{{ selected_date or 'N/A' }}</div>
      </div>
      <div class="card">
        <div class="muted">评估样本数</div>
        <div class="metric">{{ rows|length }}</div>
      </div>
      <div class="card">
        <div class="muted">可计算准确率</div>
        <div class="metric {% if accuracy is not none and accuracy >= 0.5 %}metric-down{% elif accuracy is not none %}metric-up{% endif %}">
          {% if accuracy is not none %}
          {{ "%.2f%%"|format(accuracy * 100) }}
          {% else %}
          N/A
          {% endif %}
        </div>
      </div>
      <div class="card">
        <div class="muted">评估口径</div>
        <div class="metric">T+1</div>
      </div>
    </section>

    <section class="card">
      <h2>评估结果</h2>
      <p class="muted">展示所有历史预测记录的评估结果。评估使用腾讯财经日线数据，字段已改为“一天后价格”。</p>
      <div class="table-wrap">
        <table>
          <thead>
            <tr>
              {% for header in headers %}
              <th>{{ header }}</th>
              {% endfor %}
            </tr>
          </thead>
          <tbody>
            {% for row in rows %}
            <tr>
              {% for header in headers %}
              <td>
                {% if header in ["预测结果", "实际涨跌"] %}
                <span class="pill {% if row[header] == '上涨' %}pill-up{% elif row[header] == '下跌' %}pill-down{% else %}pill-neutral{% endif %}">{{ row[header] }}</span>
                {% elif header == "预测是否准确" %}
                <span class="pill {% if row[header] == '✓' %}pill-correct{% elif row[header] == '✗' %}pill-wrong{% else %}pill-neutral{% endif %}">{{ row[header] }}</span>
                {% else %}
                {{ row[header] }}
                {% endif %}
              </td>
              {% endfor %}
            </tr>
            {% endfor %}
          </tbody>
        </table>
      </div>
    </section>
  </div>
</div>
{% endblock %}

path: scripts/templates/predictions.html
content: {% extends "base.html" %}
{% block content %}
<div class="page-layout">
  <aside class="sidebar">
    <div class="card">
      <h3>预测日期</h3>
      <p class="muted">选择一个日期查看对应批次的历史预测结果。</p>
      <div class="date-list">
        {% for date_item in available_dates %}
        <a class="date-link {% if date_item == selected_date %}active{% endif %}" href="/predictions?date={{ date_item }}">{{ date_item }}</a>
        {% endfor %}
      </div>
    </div>
  </aside>

  <div class="main-stack">
    <section class="grid">
      <div class="card">
        <div class="muted">所选日期</div>
        <div class="metric">{{ selected_date or 'N/A' }}</div>
      </div>
      <div class="card">
        <div class="muted">样本数</div>
        <div class="metric">{{ rows|length }}</div>
      </div>
      <div class="card">
        <div class="muted">预测上涨</div>
        <div class="metric metric-up">{{ up_count }}</div>
      </div>
      <div class="card">
        <div class="muted">预测下跌</div>
        <div class="metric metric-down">{{ down_count }}</div>
      </div>
    </section>

    <section class="card">
      <h2>预测结果</h2>
      <p class="muted">展示所有历史预测记录。当前时间字段仅保留交易日期，数据频率统一为腾讯财经日线。</p>
      <div class="table-wrap">
        <table>
          <thead>
            <tr>
              {% for header in headers %}
              <th>{{ header }}</th>
              {% endfor %}
            </tr>
          </thead>
          <tbody>
            {% for row in rows %}
            <tr>
              {% for header in headers %}
              <td>
                {% if header == "预测结果" %}
                <span class="pill {% if row[header] == '上涨' %}pill-up{% elif row[header] == '下跌' %}pill-down{% else %}pill-neutral{% endif %}">{{ row[header] }}</span>
                {% else %}
                {{ row[header] }}
                {% endif %}
              </td>
              {% endfor %}
            </tr>
            {% endfor %}
          </tbody>
        </table>
      </div>
    </section>
  </div>
</div>
{% endblock %}

path: scripts/templates/base.html
content: <!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{ title or "A股日线预测" }}</title>
  <style>
    :root {
      --accent: #d84f45;
      --accent-dark: #aa2f28;
      --up: #d84f45;
      --up-soft: rgba(216, 79, 69, 0.14);
      --down: #1f9d55;
      --down-soft: rgba(31, 157, 85, 0.14);
      --ink: #2a201d;
      --muted: #7b6762;
      --line: #eadfdb;
      --bg: linear-gradient(180deg, #fff8f6 0%, #f7fbf7 100%);
      --card: rgba(255, 255, 255, 0.92);
    }
    * { box-sizing: border-box; }
    body {
      margin: 0;
      font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
      color: var(--ink);
      background: var(--bg);
    }
    .shell {
      max-width: 1400px;
      margin: 0 auto;
      padding: 32px 20px 48px;
    }
    .hero {
      padding: 28px;
      border-radius: 24px;
      background: linear-gradient(135deg, rgba(216, 79, 69, 0.14), rgba(31, 157, 85, 0.14));
      border: 1px solid rgba(216, 79, 69, 0.18);
      margin-bottom: 20px;
    }
    .hero h1 {
      margin: 0 0 8px;
      font-size: 30px;
    }
    .hero p {
      margin: 0;
      color: var(--muted);
      font-size: 15px;
    }
    .nav {
      display: flex;
      gap: 12px;
      flex-wrap: wrap;
      margin: 20px 0 24px;
    }
    .nav a {
      text-decoration: none;
      padding: 10px 14px;
      border-radius: 999px;
      color: var(--ink);
      background: rgba(255, 255, 255, 0.72);
      border: 1px solid rgba(20, 50, 42, 0.08);
    }
    .nav a.active {
      background: var(--up);
      color: #fff;
      border-color: transparent;
    }
    .grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
      gap: 16px;
      margin-bottom: 20px;
    }
    .card {
      background: var(--card);
      border: 1px solid var(--line);
      border-radius: 22px;
      padding: 18px 20px;
      box-shadow: 0 12px 40px rgba(20, 50, 42, 0.05);
      backdrop-filter: blur(10px);
    }
    .card h2, .card h3 {
      margin-top: 0;
    }
    .metric {
      font-size: 28px;
      font-weight: 700;
      margin-top: 8px;
    }
    .muted {
      color: var(--muted);
    }
    .table-wrap {
      overflow-x: auto;
    }
    .page-layout {
      display: grid;
      grid-template-columns: 220px minmax(0, 1fr);
      gap: 20px;
      align-items: start;
    }
    .sidebar {
      position: sticky;
      top: 20px;
    }
    .sidebar .card {
      padding: 16px;
    }
    .date-list {
      display: grid;
      gap: 10px;
      margin-top: 12px;
    }
    .date-link {
      display: block;
      padding: 10px 12px;
      border-radius: 14px;
      text-decoration: none;
      color: var(--ink);
      background: rgba(255, 255, 255, 0.7);
      border: 1px solid var(--line);
      font-size: 14px;
    }
    .date-link.active {
      background: var(--up);
      color: #fff;
      border-color: transparent;
    }
    .main-stack {
      display: grid;
      gap: 20px;
    }
    table {
      width: 100%;
      border-collapse: collapse;
      white-space: nowrap;
      font-size: 14px;
    }
    th, td {
      padding: 12px 14px;
      border-bottom: 1px solid var(--line);
      text-align: left;
    }
    th {
      color: var(--muted);
      font-weight: 600;
    }
    .pill {
      display: inline-block;
      padding: 4px 10px;
      border-radius: 999px;
      font-weight: 600;
    }
    .pill-up {
      background: var(--up-soft);
      color: var(--up);
    }
    .pill-down {
      background: var(--down-soft);
      color: var(--down);
    }
    .pill-neutral {
      background: rgba(120, 120, 120, 0.12);
      color: #666;
    }
    .pill-correct {
      background: var(--down-soft);
      color: var(--down);
    }
    .pill-wrong {
      background: var(--up-soft);
      color: var(--up);
    }
    .metric-up {
      color: var(--up);
    }
    .metric-down {
      color: var(--down);
    }
    .text-up {
      color: var(--up);
      font-weight: 600;
    }
    .text-down {
      color: var(--down);
      font-weight: 600;
    }
    .section-title {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      margin-bottom: 14px;
    }
    .section-title p {
      margin: 0;
      color: var(--muted);
      font-size: 14px;
    }
    .story {
      font-size: 15px;
      line-height: 1.8;
      color: var(--ink);
    }
    .stack {
      display: grid;
      gap: 16px;
    }
    .report-flow {
      display: grid;
      gap: 20px;
    }
    .report-flow > section,
    .report-flow > .grid {
      margin: 0;
    }
    .visual-layout {
      display: grid;
      grid-template-columns: minmax(0, 1.2fr) minmax(260px, 0.8fr);
      gap: 20px;
      align-items: stretch;
    }
    .feature-note {
      font-size: 13px;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      color: var(--accent-dark);
      margin-bottom: 10px;
    }
    .visual-card {
      position: relative;
      overflow: hidden;
      min-height: 220px;
      background: linear-gradient(135deg, rgba(216, 79, 69, 0.12), rgba(31, 157, 85, 0.12));
    }
    .visual-card svg {
      width: 100%;
      height: 100%;
      display: block;
    }
    .insight-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
      gap: 16px;
    }
    .insight-card {
      padding: 18px;
      border-radius: 20px;
      border: 1px solid var(--line);
      background: rgba(255, 255, 255, 0.8);
    }
    .insight-card h3 {
      margin: 0 0 10px;
      font-size: 18px;
    }
    .workflow {
      display: grid;
      gap: 14px;
    }
    .workflow-step {
      display: grid;
      grid-template-columns: 52px 1fr;
      gap: 14px;
      align-items: start;
      padding: 16px;
      border: 1px solid var(--line);
      border-radius: 18px;
      background: rgba(255, 255, 255, 0.75);
    }
    .workflow-index {
      width: 52px;
      height: 52px;
      border-radius: 16px;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 20px;
      font-weight: 700;
      color: #fff;
      background: linear-gradient(135deg, var(--up), var(--down));
      box-shadow: 0 12px 24px rgba(216, 79, 69, 0.18);
    }
    .workflow-step p {
      margin: 6px 0 0;
      color: var(--muted);
      line-height: 1.7;
    }
    .chip-list {
      display: flex;
      gap: 12px;
      flex-wrap: wrap;
    }
    .chip {
      display: inline-flex;
      align-items: center;
      padding: 10px 14px;
      border-radius: 16px;
      background: rgba(255, 255, 255, 0.78);
      border: 1px solid var(--line);
      font-size: 14px;
      line-height: 1.5;
    }
    .chip code,
    .story code,
    .article-body code,
    .callout code,
    .muted code {
      padding: 2px 6px;
      border-radius: 8px;
      background: rgba(42, 32, 29, 0.08);
      font-family: "SFMono-Regular", "Menlo", monospace;
      font-size: 0.92em;
    }
    .two-column {
      display: grid;
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: 20px;
    }
    .callout {
      display: grid;
      gap: 10px;
      padding: 18px;
      border-radius: 20px;
      background: linear-gradient(135deg, rgba(255, 255, 255, 0.84), rgba(247, 251, 247, 0.92));
      border: 1px solid var(--line);
    }
    .callout strong {
      font-size: 17px;
    }
    .file-list {
      display: grid;
      gap: 12px;
    }
    .file-card {
      display: grid;
      gap: 8px;
      padding: 14px;
      border-radius: 18px;
      border: 1px solid var(--line);
      background: rgba(255, 255, 255, 0.74);
      text-decoration: none;
      color: var(--ink);
      transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
    }
    .file-card:hover {
      transform: translateY(-2px);
      border-color: rgba(216, 79, 69, 0.32);
      box-shadow: 0 10px 24px rgba(20, 50, 42, 0.06);
    }
    .file-card.active {
      border-color: transparent;
      background: linear-gradient(135deg, rgba(216, 79, 69, 0.16), rgba(31, 157, 85, 0.16));
    }
    .file-meta {
      display: flex;
      gap: 10px;
      flex-wrap: wrap;
      font-size: 12px;
      color: var(--muted);
    }
    .article-body {
      display: grid;
      gap: 18px;
      font-size: 15px;
      line-height: 1.85;
    }
    .article-body p {
      margin: 0;
    }
    .article-list {
      margin: 0;
      padding-left: 20px;
      display: grid;
      gap: 8px;
    }
    .report-section {
      display: grid;
      gap: 14px;
      padding: 18px 0;
      border-top: 1px solid var(--line);
    }
    .report-section:first-child {
      border-top: 0;
      padding-top: 0;
    }
    .empty-state {
      display: grid;
      gap: 14px;
      justify-items: start;
      padding: 24px;
      border-radius: 22px;
      border: 1px dashed rgba(216, 79, 69, 0.32);
      background: rgba(255, 255, 255, 0.76);
    }
    .mini-visual {
      width: 100%;
      max-width: 240px;
      height: auto;
      display: block;
    }
    .summary-inline {
      display: flex;
      gap: 12px;
      flex-wrap: wrap;
    }
    .summary-pill {
      display: inline-flex;
      align-items: center;
      padding: 8px 12px;
      border-radius: 999px;
      background: rgba(255, 255, 255, 0.78);
      border: 1px solid var(--line);
      font-size: 13px;
      color: var(--accent-dark);
    }
    .analysis-hero {
      display: grid;
      gap: 18px;
      padding: 24px 26px;
      background: linear-gradient(135deg, rgba(216, 79, 69, 0.14), rgba(255, 255, 255, 0.88) 48%, rgba(31, 157, 85, 0.14));
    }
    .analysis-hero h2 {
      margin: 0;
      font-size: 30px;
    }
    .analysis-hero p {
      margin: 0;
      color: var(--muted);
      line-height: 1.8;
    }
    .analysis-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
      gap: 18px;
    }
    .chart-card {
      display: grid;
      gap: 16px;
      padding: 20px;
      border-radius: 22px;
      border: 1px solid var(--line);
      background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(248, 252, 249, 0.92));
    }
    .chart-card h3 {
      margin: 0;
      font-size: 19px;
    }
    .chart-card p {
      margin: 0;
      color: var(--muted);
      font-size: 14px;
      line-height: 1.7;
    }
    .chart-list {
      display: grid;
      gap: 12px;
    }
    .chart-row {
      display: grid;
      gap: 8px;
    }
    .chart-meta {
      display: flex;
      justify-content: space-between;
      gap: 10px;
      align-items: baseline;
      flex-wrap: wrap;
    }
    .chart-label {
      display: grid;
      gap: 2px;
    }
    .chart-label strong {
      font-size: 14px;
    }
    .chart-label span {
      font-size: 12px;
      color: var(--muted);
    }
    .chart-value {
      font-size: 13px;
      font-weight: 700;
      color: var(--accent-dark);
    }
    .chart-track {
      height: 10px;
      overflow: hidden;
      border-radius: 999px;
      background: rgba(42, 32, 29, 0.08);
    }
    .chart-fill {
      display: block;
      height: 100%;
      border-radius: 999px;
      background: linear-gradient(90deg, rgba(42, 32, 29, 0.28), rgba(42, 32, 29, 0.48));
    }
    .chart-fill.up {
      background: linear-gradient(90deg, rgba(216, 79, 69, 0.72), rgba(216, 79, 69, 0.98));
    }
    .chart-fill.down {
      background: linear-gradient(90deg, rgba(31, 157, 85, 0.72), rgba(31, 157, 85, 0.98));
    }
    .chart-fill.neutral {
      background: linear-gradient(90deg, rgba(170, 47, 40, 0.55), rgba(42, 32, 29, 0.72));
    }
    .section-nav {
      display: grid;
      gap: 10px;
    }
    .section-link {
      display: block;
      padding: 10px 12px;
      border-radius: 14px;
      text-decoration: none;
      color: var(--ink);
      border: 1px solid var(--line);
      background: rgba(255, 255, 255, 0.74);
      font-size: 14px;
      line-height: 1.5;
    }
    .section-link:hover {
      border-color: rgba(216, 79, 69, 0.32);
    }
    .mix-bar {
      display: flex;
      height: 18px;
      border-radius: 999px;
      overflow: hidden;
      background: #e8f2ee;
      margin: 12px 0;
    }
    .mix-bar .up {
      background: linear-gradient(90deg, #d84f45, #ef7f71);
    }
    .mix-bar .down {
      background: linear-gradient(90deg, #1f9d55, #4fc979);
    }
    .legend {
      display: flex;
      gap: 16px;
      flex-wrap: wrap;
      color: var(--muted);
      font-size: 13px;
    }
    .feature-bar {
      display: grid;
      grid-template-columns: 180px 1fr 72px;
      gap: 12px;
      align-items: center;
      margin-bottom: 10px;
    }
    .feature-bar .track,
    .score-track {
      background: #edf5f1;
      border-radius: 999px;
      overflow: hidden;
      height: 12px;
    }
    .feature-bar .fill,
    .score-fill {
      display: block;
      height: 100%;
      background: linear-gradient(90deg, var(--up), var(--down));
      border-radius: 999px;
    }
    .stats-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
      gap: 14px;
    }
    .report-table {
      width: 100%;
      border-collapse: collapse;
      font-size: 13px;
      white-space: normal;
    }
    .report-table th,
    .report-table td {
      vertical-align: top;
      line-height: 1.6;
    }
    .report-table td strong {
      display: block;
      margin-bottom: 2px;
    }
    .small {
      font-size: 12px;
      color: var(--muted);
    }
    .tag {
      display: inline-flex;
      align-items: center;
      padding: 4px 8px;
      border-radius: 999px;
      background: linear-gradient(90deg, var(--up-soft), var(--down-soft));
      color: var(--accent-dark);
      font-size: 12px;
      font-weight: 600;
    }
    .stat-tile {
      border: 1px solid var(--line);
      border-radius: 18px;
      padding: 14px;
      background: rgba(255, 255, 255, 0.75);
    }
    .stat-tile h4 {
      margin: 0 0 10px;
      font-size: 15px;
    }
    .mini-grid {
      display: grid;
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: 8px 14px;
      font-size: 13px;
      color: var(--muted);
    }
    .score-list {
      display: grid;
      gap: 12px;
    }
    .score-row {
      display: grid;
      grid-template-columns: 96px 1fr 60px;
      gap: 12px;
      align-items: center;
    }
    .fold-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
      gap: 12px;
    }
    .fold-card {
      border: 1px solid var(--line);
      border-radius: 18px;
      padding: 14px;
      background: rgba(255, 255, 255, 0.72);
    }
    .fold-card .fold-no {
      font-size: 12px;
      color: var(--muted);
      margin-bottom: 8px;
    }
    .fold-card .fold-score {
      font-size: 24px;
      font-weight: 700;
      margin-bottom: 4px;
    }
    .steps {
      display: grid;
      gap: 12px;
      padding-left: 0;
      list-style: none;
      margin: 0;
    }
    .steps li {
      display: grid;
      grid-template-columns: 34px 1fr;
      gap: 12px;
      align-items: start;
      padding: 12px 0;
      border-bottom: 1px solid var(--line);
    }
    .steps li:last-child {
      border-bottom: 0;
    }
    .step-index {
      width: 34px;
      height: 34px;
      border-radius: 50%;
      display: flex;
      align-items: center;
      justify-content: center;
      background: linear-gradient(135deg, var(--up-soft), var(--down-soft));
      color: var(--accent-dark);
      font-weight: 700;
    }
    pre {
      margin: 0;
      overflow-x: auto;
      white-space: pre-wrap;
      word-break: break-word;
      font-size: 13px;
      line-height: 1.55;
    }
    @media (max-width: 720px) {
      .shell { padding: 20px 14px 32px; }
      .hero { padding: 20px; }
      .hero h1 { font-size: 24px; }
      .visual-layout,
      .two-column {
        grid-template-columns: 1fr;
      }
      .page-layout {
        grid-template-columns: 1fr;
      }
      .sidebar {
        position: static;
      }
      .feature-bar,
      .score-row {
        grid-template-columns: 1fr;
      }
    }
  </style>
</head>
<body>
  <div class="shell">
    <section class="hero">
      <h1>stock-prediction-daily</h1>
      <p>基于腾讯财经日线数据的 A 股个股涨跌预测与 T+1 评估</p>
    </section>
    <nav class="nav">
      <a href="/predictions" class="{% if active == 'predictions' %}active{% endif %}">预测结果</a>
      <a href="/evaluation" class="{% if active == 'evaluation' %}active{% endif %}">模型评估</a>
      <a href="/report" class="{% if active == 'report' %}active{% endif %}">训练报告</a>
      <a href="/sector-analysis" class="{% if active == 'sector-analysis' %}active{% endif %}">板块分析</a>
      <a href="/stock-analysis" class="{% if active == 'stock-analysis' %}active{% endif %}">个股分析</a>
    </nav>
    {% block content %}{% endblock %}
  </div>
</body>
</html>

path: scripts/templates/sector_analysis.html
content: {% extends "base.html" %}
{% block content %}
<div class="main-stack">
  {% if page.report and page.selected_file %}
  <section class="card analysis-hero">
    <div>
      <div class="feature-note">{{ page.title }}</div>
      <h2>{{ page.report.title }}</h2>
    </div>
    {% if page.report.subtitle %}
    <p>{{ page.report.subtitle }}</p>
    {% endif %}
    <div class="summary-inline">
      {% if page.report.report_time %}
      <span class="summary-pill">创建时间：{{ page.report.report_time }}</span>
      {% endif %}
      <span class="summary-pill">文件：{{ page.selected_file.name }}</span>
      <span class="summary-pill">章节：{{ page.report.sections|length }}</span>
      <span class="summary-pill">表格：{{ page.report.table_count }}</span>
    </div>
    {% if page.report.highlights %}
    <div class="chip-list">
      {% for item in page.report.highlights %}
      <span class="chip">{{ item }}</span>
      {% endfor %}
    </div>
    {% endif %}
    {% if page.report.intro_html %}
    <div class="article-body">{{ page.report.intro_html }}</div>
    {% endif %}
  </section>

  <section class="grid">
    {% for item in page.summary_cards %}
    <div class="card">
      <div class="muted">{{ item.label }}</div>
      <div class="metric">{{ item.value }}</div>
    </div>
    {% endfor %}
  </section>

  {% if page.charts %}
  <section class="analysis-grid">
    {% for chart in page.charts %}
    <div class="chart-card">
      <div class="stack">
        <h3>{{ chart.title }}</h3>
        {% if chart.subtitle %}
        <p>{{ chart.subtitle }}</p>
        {% endif %}
      </div>
      <div class="chart-list">
        {% for item in chart.bars %}
        <div class="chart-row">
          <div class="chart-meta">
            <div class="chart-label">
              <strong>{{ item.label }}</strong>
              {% if item.secondary %}
              <span>{{ item.secondary }}</span>
              {% endif %}
            </div>
            <span class="chart-value">{{ item.display }}</span>
          </div>
          <div class="chart-track">
            <span class="chart-fill {{ item.tone }}" style="width: {{ item.width }}%;"></span>
          </div>
        </div>
        {% endfor %}
      </div>
    </div>
    {% endfor %}
  </section>
  {% endif %}
  {% endif %}

  <section class="page-layout">
    <aside class="sidebar">
      <div class="card stack">
        <div>
          <div class="feature-note">{{ page.title }}</div>
          <h3>报告选择</h3>
          <p class="muted">左侧菜单按报告主标题和创建时间自动生成。</p>
        </div>
        {% if page.files %}
        <div class="file-list">
          {% for item in page.files %}
          <a class="file-card {% if page.selected_file and item.name == page.selected_file.name %}active{% endif %}" href="/sector-analysis?file={{ item.name }}">
            <strong>{{ item.display_title }}</strong>
            <div class="file-meta">
              <span>创建时间：{{ item.display_time }}</span>
            </div>
          </a>
          {% endfor %}
        </div>
        {% else %}
        <p class="muted">目录内还没有可展示的 Markdown、TXT 或 JSON 报告。</p>
        {% endif %}
      </div>
      {% if page.nav_sections %}
      <div class="card stack" style="margin-top: 16px;">
        <div>
          <h3>章节导航</h3>
          <p class="muted">快速跳转到当前报告的核心部分。</p>
        </div>
        <div class="section-nav">
          {% for section in page.nav_sections %}
          <a class="section-link" href="#{{ section.anchor_id }}">{{ section.title }}</a>
          {% endfor %}
        </div>
      </div>
      {% endif %}
    </aside>

    <div class="main-stack">
      {% if page.report and page.selected_file %}
      <section class="card stack">
        <div class="section-title">
          <div>
            <h2>报告正文</h2>
          </div>
        </div>
        {% for section in page.report.sections %}
        <article class="report-section" id="{{ section.anchor_id }}">
          <h3>{{ section.title }}</h3>
          <div class="article-body">{{ section.body_html }}</div>
        </article>
        {% endfor %}
      </section>
      {% else %}
      <section class="empty-state">
        <h2>暂无板块分析报告</h2>
        <p class="muted">{{ page.empty_hint }}</p>
      </section>
      {% endif %}
    </div>
  </section>
</div>
{% endblock %}

path: scripts/templates/stock_analysis.html
content: {% extends "base.html" %}
{% block content %}
<div class="main-stack">
  {% if page.report and page.selected_file %}
  <section class="card analysis-hero">
    <div>
      <div class="feature-note">{{ page.title }}</div>
      <h2>{{ page.report.title }}</h2>
    </div>
    {% if page.report.subtitle %}
    <p>{{ page.report.subtitle }}</p>
    {% endif %}
    <div class="summary-inline">
      {% if page.report.report_time %}
      <span class="summary-pill">创建时间：{{ page.report.report_time }}</span>
      {% endif %}
      <span class="summary-pill">文件：{{ page.selected_file.name }}</span>
      <span class="summary-pill">章节：{{ page.report.sections|length }}</span>
      <span class="summary-pill">表格：{{ page.report.table_count }}</span>
    </div>
    {% if page.report.highlights %}
    <div class="chip-list">
      {% for item in page.report.highlights %}
      <span class="chip">{{ item }}</span>
      {% endfor %}
    </div>
    {% endif %}
    {% if page.report.intro_html %}
    <div class="article-body">{{ page.report.intro_html }}</div>
    {% endif %}
  </section>

  <section class="grid">
    {% for item in page.summary_cards %}
    <div class="card">
      <div class="muted">{{ item.label }}</div>
      <div class="metric">{{ item.value }}</div>
    </div>
    {% endfor %}
  </section>

  {% if page.charts %}
  <section class="analysis-grid">
    {% for chart in page.charts %}
    <div class="chart-card">
      <div class="stack">
        <h3>{{ chart.title }}</h3>
        {% if chart.subtitle %}
        <p>{{ chart.subtitle }}</p>
        {% endif %}
      </div>
      <div class="chart-list">
        {% for item in chart.bars %}
        <div class="chart-row">
          <div class="chart-meta">
            <div class="chart-label">
              <strong>{{ item.label }}</strong>
              {% if item.secondary %}
              <span>{{ item.secondary }}</span>
              {% endif %}
            </div>
            <span class="chart-value">{{ item.display }}</span>
          </div>
          <div class="chart-track">
            <span class="chart-fill {{ item.tone }}" style="width: {{ item.width }}%;"></span>
          </div>
        </div>
        {% endfor %}
      </div>
    </div>
    {% endfor %}
  </section>
  {% endif %}
  {% endif %}

  <section class="page-layout">
    <aside class="sidebar">
      <div class="card stack">
        <div>
          <div class="feature-note">{{ page.title }}</div>
          <h3>报告选择</h3>
          <p class="muted">左侧菜单按报告主标题和创建时间自动生成。</p>
        </div>
        {% if page.files %}
        <div class="file-list">
          {% for item in page.files %}
          <a class="file-card {% if page.selected_file and item.name == page.selected_file.name %}active{% endif %}" href="/stock-analysis?file={{ item.name }}">
            <strong>{{ item.display_title }}</strong>
            <div class="file-meta">
              <span>创建时间：{{ item.display_time }}</span>
            </div>
          </a>
          {% endfor %}
        </div>
        {% else %}
        <p class="muted">目录内还没有可展示的 Markdown、TXT 或 JSON 报告。</p>
        {% endif %}
      </div>
      {% if page.nav_sections %}
      <div class="card stack" style="margin-top: 16px;">
        <div>
          <h3>章节导航</h3>
          <p class="muted">快速跳转到当前报告的核心部分。</p>
        </div>
        <div class="section-nav">
          {% for section in page.nav_sections %}
          <a class="section-link" href="#{{ section.anchor_id }}">{{ section.title }}</a>
          {% endfor %}
        </div>
      </div>
      {% endif %}
    </aside>

    <div class="main-stack">
      {% if page.report and page.selected_file %}
      <section class="card stack">
        <div class="section-title">
          <div>
            <h2>报告正文</h2>
          </div>
        </div>
        {% for section in page.report.sections %}
        <article class="report-section" id="{{ section.anchor_id }}">
          <h3>{{ section.title }}</h3>
          <div class="article-body">{{ section.body_html }}</div>
        </article>
        {% endfor %}
      </section>
      {% else %}
      <section class="empty-state">
        <h2>暂无个股分析报告</h2>
        <p class="muted">{{ page.empty_hint }}</p>
      </section>
      {% endif %}
    </div>
  </section>
</div>
{% endblock %}

path: scripts/templates/report.html
content: {% extends "base.html" %}
{% block content %}
{% if report %}
<div class="report-flow">
<section class="grid">
  {% for item in report.summary_cards %}
  <div class="card">
    <div class="muted">{{ item.label }}</div>
    <div class="metric">{{ item.value }}</div>
  </div>
  {% endfor %}
</section>

<section class="card">
  <div class="section-title">
    <div>
      <h2>项目背景</h2>
      <p>说明模型目标、数据来源和评估口径</p>
    </div>
    <span class="pill">生成于 {{ report.generated_at }}</span>
  </div>
  <div class="story">{{ report.project_background }}</div>
</section>

<section class="grid">
  {% for item in report.dataset_cards %}
  <div class="card">
    <div class="muted">{{ item.label }}</div>
    <div class="metric"{% if item.label == "时间范围" %} style="font-size:18px;"{% endif %}>{{ item.value }}</div>
  </div>
  {% endfor %}
</section>

<section class="card stack">
  <div class="section-title">
    <div>
      <h2>数据探索性分析</h2>
      <p>从样本覆盖、标签分布和全部特征统计三个角度理解训练数据</p>
    </div>
  </div>
  <div>
    <div class="muted">标签分布</div>
    <div class="mix-bar">
      <div class="up" style="width: {{ report.label_mix.up_width }}%"></div>
      <div class="down" style="width: {{ report.label_mix.down_width }}%"></div>
    </div>
    <div class="legend">
      <span>上涨样本: {{ report.label_mix.up_count }}</span>
      <span>下跌样本: {{ report.label_mix.down_count }}</span>
    </div>
  </div>
  <div class="table-wrap">
    <table class="report-table">
      <thead>
        <tr>
          <th>特征名称</th>
          <th>分类</th>
          <th>均值</th>
          <th>标准差</th>
          <th>最小值</th>
          <th>最大值</th>
        </tr>
      </thead>
      <tbody>
        {% for item in report.feature_catalog %}
        <tr>
          <td>
            <strong>{{ item.chinese_name }}</strong>
            <span class="small">{{ item.english_name }}</span>
          </td>
          <td>{{ item.category }}</td>
          <td>{% if item.mean is not none %}{{ "%.6f"|format(item.mean) }}{% else %}-{% endif %}</td>
          <td>{% if item.std is not none %}{{ "%.6f"|format(item.std) }}{% else %}-{% endif %}</td>
          <td>{% if item.min is not none %}{{ "%.6f"|format(item.min) }}{% else %}-{% endif %}</td>
          <td>{% if item.max is not none %}{{ "%.6f"|format(item.max) }}{% else %}-{% endif %}</td>
        </tr>
        {% endfor %}
      </tbody>
    </table>
  </div>
</section>

<section class="grid">
  <section class="card">
    <div class="section-title">
      <div>
        <h2>特征维度</h2>
        <p>候选特征与最终入模特征的压缩结果</p>
      </div>
    </div>
    <div class="score-list">
      <div class="score-row">
        <div>候选特征</div>
        <div class="score-track"><span class="score-fill" style="width: 100%"></span></div>
        <div>{{ report.feature_dimensions.total_candidate_features }}</div>
      </div>
      <div class="score-row">
        <div>入模特征</div>
        <div class="score-track"><span class="score-fill" style="width: {{ report.selection_ratio }}%"></span></div>
        <div>{{ report.feature_dimensions.selected_features }}</div>
      </div>
    </div>
    <p class="muted">特征筛选保留率 {{ "%.2f%%"|format(report.selection_ratio) }}</p>
  </section>

  <section class="card">
    <div class="section-title">
      <div>
        <h2>重要特征</h2>
        <p>按 XGBoost 重要性排序展示前 10 个特征，并补充中文名和加工逻辑</p>
      </div>
    </div>
    {% for item in report.feature_bars %}
    <div class="feature-bar">
      <div>
        <strong>{{ item.chinese_name }}</strong>
        <div class="small">{{ item.name }}</div>
      </div>
      <div class="track"><span class="fill" style="width: {{ item.width }}%"></span></div>
      <div>{{ "%.4f"|format(item.value) }}</div>
    </div>
    <div class="small" style="margin: -4px 0 8px 0;">{{ item.logic }}</div>
    {% endfor %}
  </section>
</section>

<section class="card stack">
  <div class="section-title">
    <div>
      <h2>特征维度</h2>
      <p>给出全部特征的中文名、英文名、加工逻辑、重要性和是否入模</p>
    </div>
  </div>
  <div class="table-wrap">
    <table class="report-table">
      <thead>
        <tr>
          <th>中文名</th>
          <th>英文名</th>
          <th>分类</th>
          <th>加工逻辑</th>
          <th>重要性</th>
          <th>是否入模</th>
        </tr>
      </thead>
      <tbody>
        {% for item in report.feature_catalog %}
        <tr>
          <td>{{ item.chinese_name }}</td>
          <td>{{ item.english_name }}</td>
          <td>{{ item.category }}</td>
          <td>{{ item.logic }}</td>
          <td>{{ "%.6f"|format(item.importance) }}</td>
          <td>{% if item.selected %}<span class="tag">已入模</span>{% else %}-{% endif %}</td>
        </tr>
        {% endfor %}
      </tbody>
    </table>
  </div>
</section>

<section class="card stack">
  <div class="section-title">
    <div>
      <h2>模型效果</h2>
      <p>展示十折交叉验证的均值表现和各折稳定性</p>
    </div>
  </div>
  <div class="score-list">
    {% for metric in report.metrics %}
    <div class="score-row">
      <div>{{ metric.label }}</div>
      <div class="score-track"><span class="score-fill" style="width: {{ metric.value * 100 }}%"></span></div>
      <div>{{ "%.4f"|format(metric.value) }}</div>
    </div>
    {% endfor %}
  </div>
  <div class="fold-grid">
    {% for fold in report.fold_metrics %}
    <div class="fold-card">
      <div class="fold-no">Fold {{ fold.fold }}</div>
      <div class="fold-score">{{ "%.2f%%"|format(fold.accuracy * 100) }}</div>
      <div class="muted">AUC {{ "%.4f"|format(fold.auc) }}</div>
    </div>
    {% endfor %}
  </div>
</section>

<section class="card">
  <div class="section-title">
    <div>
      <h2>使用说明</h2>
      <p>按流程执行训练、预测、评估和网页查看</p>
    </div>
  </div>
  <ol class="steps">
    {% for step in report.usage_steps %}
    <li>
      <div class="step-index">{{ loop.index }}</div>
      <div>{{ step }}</div>
    </li>
    {% endfor %}
  </ol>
</section>
</div>
{% else %}
<section class="card">
  <h2>训练报告</h2>
  <p class="muted">尚未生成训练报告。</p>
</section>
{% endif %}
{% endblock %}

path: references/troubleshooting.md
content: # 问题排查指南

## API相关

### Tencent日线无volume列
**症状**：Tencent返回的列名有`amount`但无`volume`
**解决**：`df["volume"] = df["amount"]`

## 模型相关

### xgboost save_model/load_model 报错
**症状**：`TypeError` 或 JSON序列化错误
**原因**：sklearn包装器与xgboost原生方法不兼容
**解决**：必须用 `joblib.dump(model, path)` / `joblib.load(path)`

### 预测日线不足
**症状**：预测时报 `Insufficient data`
**原因**：目标股票可用日线历史不足，无法计算长周期特征
**解决**：确保 `scripts/data/` 下已有训练阶段缓存，或重新运行训练补齐日线历史

### 特征缺失 (NaN/0填充)
**症状**：预测时部分特征全为0
**原因**：数据行数不足以计算长周期特征（如ma_ratio_100需100+天）
**解决**：确保获取足够历史（Tencent日线 lookback_days≥400）

### 次日评估返回数据不足
**症状**：`evaluate.py` 的 `一天后价格` 为 `N/A`
**原因**：预测日期之后还没有新的交易日收盘数据
**解决**：等待下一个交易日数据可用后重新执行 `python3 evaluate.py`

## Flask相关

### app.py 报 "can't open file"
**症状**：`python3 app.py` 报文件不存在
**原因**：cwd不正确，Flask使用相对路径加载模板
**解决**：进入 `.github/skills/stock-prediction-daily/scripts` 后执行 `python3 app.py`

### 端口5000被占用
**解决**：`lsof -ti:5000 | xargs kill -9`

### 表格文字换行
**解决**：给 `tbody td` 加 `white-space: nowrap`，容器 `max-width` 调大


path: references/config-reference.md
content: # 配置参考

## config.py 关键配置项

| 配置 | 默认值 | 说明 |
|------|--------|------|
| `TARGET_STOCKS` | 10只A股 | 预测目标股票池（代码→名称dict） |
| `XGB_PARAMS` | max_depth=6, lr=0.05, n=300 | XGBoost超参数 |
| `TOP_N_FEATURES` | 40 | 特征选择保留数量 |
| `CV_FOLDS` | 10 | 交叉验证折数 |
| `FETCH_DELAY` | 1.5 | API请求间隔（秒） |
| `MODEL_PATH` | models/xgb_stock_model.pkl | 模型保存路径 |
| `SCALER_PATH` | models/scaler.pkl | StandardScaler路径 |
| `FEATURE_PATH` | models/feature_names.json | 入模特征列表 |
| `REPORT_PATH` | results/model_report.json | 报告JSON |
| `PREDICTION_PATH` | results/predictions.csv | 预测结果CSV |
| `PREDICTION_HISTORY_DIR` | results/prediction_history/ | 每次预测批次快照目录 |
| `EVALUATION_PATH` | results/evaluation.csv | 评估结果CSV |

## 特征分类（102个候选）

| # | 类别 | 特征数 | 代表特征 |
|---|------|--------|----------|
| 1 | 价格收益率 | 6 | return_1/2/3/5/10/20 |
| 2 | 均线比率 | 5 | ma_ratio_5/10/20/50/100 |
| 3 | EMA比率 | 4 | ema_ratio_5/10/20/50 |
| 4 | 均线交叉 | 4 | ma_cross_5_10/5_20/10_20/10_50 |
| 5 | RSI | 3 | rsi_6/14/24 |
| 6 | MACD | 3 | macd_norm/signal_norm/hist_norm |
| 7 | KDJ | 6 | kdj_k/d/j × 9/14周期 |
| 8 | Williams %R | 2 | williams_r_14/28 |
| 9 | CCI | 2 | cci_14/20 |
| 10 | 布林带 | 2 | bb_width/bb_position |
| 11 | 成交量 | 5 | volume_ratio_5/10/20, change, change_5 |
| 12 | OBV | 2 | obv_ratio_5/20 |
| 13 | MFI | 1 | mfi_14 |
| 14 | VWAP | 1 | vwap_dev |
| 15 | 波动率 | 4 | volatility_5/10/20, ratio_5_20 |
| 16 | K线形态 | 5 | high_low_ratio, close_open_ratio, upper/lower_shadow, body_ratio |
| 17 | ATR | 2 | atr_7/14 |
| 18 | 动量 | 3 | momentum_5/10/20 |
| 19 | 收益加速度 | 1 | return_accel |
| 20 | 偏度/峰度 | 4 | skew_10/20, kurtosis_10/20 |
| 21 | 连涨跌 | 3 | consec_count/dir/signed |
| 22 | 距N日高低点 | 6 | dist_high/low × 10/20/50 |
| 23 | 滞后收益率 | 4 | return_lag_1/2/3/5 |
| 24 | 量价相关性 | 2 | vol_price_corr_10/20 |
| 25 | 枢轴点 | 3 | pivot_dev/r1_dev/s1_dev |
| 26 | ADX/DI | 3 | adx_14, plus_di_14, minus_di_14 |
| 27 | TRIX | 1 | trix |
| 28 | Chaikin | 1 | chaikin_vol |
| 29 | 价格区间位置 | 2 | range_position_10/20 |
| 30 | 时间编码 | 12 | hour/minute/weekday/month × sin/cos + raw |

## 股票池（默认10只）

| 代码 | 名称 | 板块 |
|------|------|------|
| 600436 | 片仔癀 | 沪主板 |
| 300750 | 宁德时代 | 创业板 |
| 002475 | 立讯精密 | 中小板 |
| 000568 | 泸州老窖 | 深主板 |
| 000538 | 云南白药 | 深主板 |
| 000338 | 潍柴动力 | 深主板 |
| 600031 | 三一重工 | 沪主板 |
| 600111 | 北方稀土 | 沪主板 |
| 002415 | 海康威视 | 中小板 |
| 000858 | 五粮液 | 深主板 |


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)