Spaces:
Running
StockReplay 项目技术文档
1. 项目概述
StockReplay 是一个基于 A 股历史行情的模拟交易复盘系统。用户可以对随机抽取的股票历史 K 线进行模拟交易,验证自己的盘感与交易策略,并在游戏结束后查看详细的交易统计与收益曲线。
1.1 核心功能
- 盲盒选股:系统随机抽取一只符合条件的 A 股(上市满 3 年),隐藏股票名称和代码
- 模拟交易:用户可以使用初始资金(100 万)进行买入、卖出操作
- 行情推演:用户可以手动或自动播放 K 线,逐步揭示后续行情
- 复盘分析:游戏结束后,展示总资产、年化收益、最大回撤、胜率等详细统计数据
- 多市场支持:支持主板、创业板、科创板、北交所、ETF、LOF、REITs、可转债等多个市场
1.2 技术栈
- 前端:Next.js 14 (App Router), React, TypeScript, Tailwind CSS, Zustand (状态管理), KLineCharts (图表库)
- 后端:FastAPI (Python), DuckDB (嵌入式分析数据库), Akshare (金融数据接口)
- 数据存储:Parquet 文件 (按月分区), Hugging Face Dataset (云端存储)
- 部署:Hugging Face Spaces (Docker 容器)
2. 系统架构
2.1 整体架构图
┌──────────────────────────────────────────────────────────────┐
│ Hugging Face Spaces │
│ ┌───────────────────────┐ ┌──────────────────────────┐ │
│ │ Frontend (Next.js) │──────▶│ Backend (FastAPI) │ │
│ │ - React Components │◀──────│ - REST API Endpoints │ │
│ │ - Zustand Store │ │ - Core Game Logic │ │
│ └───────────────────────┘ │ - Database Manager │ │
│ └──────────────┬───────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ DuckDB (In-Memory) │ │
│ │ - stock_list (Table) │ │
│ │ - stock_daily (View) │ │
│ └──────────────┬───────────┘ │
│ │ │
└────────────────────────────────────────────────│──────────────┘
│
▼
┌──────────────────────────────┐
│ Hugging Face Dataset │
│ - data/stock_list.parquet │
│ - data/parquet/YYYY-MM.parquet │
└──────────────────────────────┘
2.2 数据流
- 数据同步:本地运行
sync_data.py,从 Akshare 抓取数据,保存为 Parquet 文件,并上传至 HF Dataset - 应用启动:HF Space 启动时,
DatabaseManager从 HF Dataset 下载 Parquet 文件,并在 DuckDB 中创建视图 - 游戏开始:前端调用
/api/game/start,后端从stock_list中随机筛选股票,查询stock_daily获取 K 线数据,返回给前端 - 交易过程:前端维护交易状态,后端仅提供数据查询服务
3. 核心模块详解
3.1 数据同步模块 (backend/scripts/sync_data.py)
3.1.1 功能
负责从 Akshare 接口抓取全市场(A股、ETF、LOF、REITs、可转债)的列表和日线数据,并以 Parquet 格式存储。
3.1.2 关键技术点
增量同步:
- 查询
stock_daily视图中每个标的的最新日期 - 如果最新日期等于最近一个交易日,则跳过
- 否则,只抓取缺失日期的数据
多数据源适配:
- A股:
ak.stock_zh_a_hist(东方财富) - ETF:
ak.fund_etf_hist_em(东方财富) - LOF:
ak.fund_lof_hist_em(东方财富) - REITs:
ak.reits_hist_em(东方财富) - 可转债:
ak.bond_zh_hs_cov_daily(新浪)
字段标准化:
- 不同接口返回的字段名不同(如
日期vsdate,收盘vsclose) - 使用
rename_map统一转换为标准字段:trade_date,open,high,low,close,volume,amount,pct_chg,turnover_rate
并发控制:
- 使用
ThreadPoolExecutor并发抓取 MAX_WORKERS设为 5,避免触发接口频率限制max_retries设为 3,增强网络容错
3.1.3 核心代码片段
# 增量同步逻辑
existing_latest = db.conn.execute(
"SELECT code, CAST(MAX(trade_date) AS VARCHAR) FROM stock_daily GROUP BY code"
).fetchall()
latest_map = {row[0]: row[1] for row in existing_latest}
pending = []
for t in targets:
code = t['code']
if code in latest_map:
if latest_map[code] >= last_trade_day:
continue
start_dt = (pd.to_datetime(latest_map[code]) + timedelta(days=1)).strftime('%Y-%m-%d')
else:
start_dt = (datetime.now() - timedelta(days=YEARS_OF_DATA * 365)).strftime('%Y-%m-%d')
t['start_dt'] = start_dt
pending.append(t)
3.2 数据库管理模块 (backend/app/database.py)
3.2.1 功能
管理 DuckDB 连接,处理本地与云端两种模式下的数据加载。
3.2.2 关键技术点
双模式支持:
- 本地母本模式:如果存在
DUCKDB_PATH环境变量且文件存在,则直接连接本地 DuckDB 文件。适用于本地开发和全量数据初始化 - 云端无盘模式:否则,从 HF Dataset 下载 Parquet 文件,并在内存中创建 DuckDB 视图。适用于 HF Spaces 部署
视图创建与冲突解决:
- 在创建
stock_daily视图前,必须先删除可能存在的同名表或视图,避免Catalog Error - 代码:
conn.execute("DROP VIEW IF EXISTS stock_daily"); conn.execute("DROP TABLE IF EXISTS stock_daily")
3.2.3 核心代码片段
# 云端模式:下载 Parquet 并创建视图
parquet_files = [f for f in all_files if f.startswith("data/parquet/") and f.endswith(".parquet")]
if parquet_files:
local_paths = []
for f in parquet_files:
path = hf_hub_download(repo_id=DATASET_REPO_ID, filename=f, repo_type="dataset")
local_paths.append(f"'{path}'")
files_sql = ", ".join(local_paths)
conn.execute("DROP VIEW IF EXISTS stock_daily")
conn.execute("DROP TABLE IF EXISTS stock_daily")
conn.execute(f"CREATE OR REPLACE VIEW stock_daily AS SELECT * FROM read_parquet([{files_sql}])")
3.3 游戏核心逻辑 (backend/app/core.py)
3.3.1 功能
处理游戏开始、股票筛选、K线数据获取等核心业务逻辑。
3.3.2 关键技术点
股票筛选:
- 上市满 3 年(
MIN_LISTING_YEARS = 3) - 必须存在日线数据:
EXISTS (SELECT 1 FROM stock_daily sd WHERE sd.code = sl.code)
K线截断:
- 隐藏最后 100 个交易日的数据,作为"未来"行情
- 用户从第
len(klines) - 100根 K 线开始交易
市场分类筛选:
全部:所有符合条件的股票全A股:主板、创业板、科创板、北交所基金:ETF、LOF、REITsETF、LOF、REITs、可转债:单独筛选
3.4 前端状态管理 (frontend/src/store/gameStore.ts)
3.4.1 功能
使用 Zustand 管理全局游戏状态。
3.4.2 状态结构
interface GameState {
isPlaying: boolean; // 游戏是否进行中
isRevealed: boolean; // 是否已揭晓股票名称
isFinished: boolean; // 回测是否结束
realCode: string; // 股票代码
realName: string; // 股票名称
allKlines: KLine[]; // 所有K线数据
currentIndex: number; // 当前K线索引
cash: number; // 可用现金
holdings: number; // 持仓数量
history: Trade[]; // 交易历史
// ... actions
}
3.4.3 Actions
startGame(data):初始化游戏状态,加载 K 线数据nextCandle():推进到下一根 K 线buy(volume):执行买入操作sell(volume):执行卖出操作reveal():揭晓股票名称finish():结束回测reset():重置所有状态回到首页
3.5 前端组件 (frontend/src/components/)
3.5.1 TradePanel.tsx
功能:交易面板,包含买入、卖出、下一天、自动播放、揭晓、重开等功能。
关键逻辑:
- 重开按钮:点击后调用
reset(),将状态重置回首页,不自动开始新游戏 - 自动播放:使用
setInterval每秒调用一次nextCandle() - 仓位控制:提供 1/4、1/2、3/4、全仓快捷按钮
代码示例:
// 重开按钮(已优化)
<button onClick={() => { setAutoPlay(false); reset(); }}>
<RotateCcw size={12} />
<span>重开</span>
</button>
3.5.2 Chart.tsx
功能:K线图表,使用 klinecharts 库。
关键逻辑:
- 数据加载:监听
currentIndex变化,更新图表显示范围 - 指标计算:支持 MA, VOL, MACD 等指标
- 交易标记:在图表上显示买入/卖出标记
3.5.3 StockHeader.tsx
功能:顶部股票信息栏,显示当前股票名称、代码、价格等信息。
4. 关键问题与解决方案(基于历史对话)
4.1 ETF/LOF/REITs 数据缺失
问题描述:日志显示 ETF: 1436 / 0,列表中有数据但日线数据为空
原因分析:
- 使用了不稳定或过时的 Akshare 接口(如
fund_etf_category_sina) - REITs 接口返回的字段名(
今开,最新价)与预期不符,导致解析失败
解决方案:
- 切换到稳定的东方财富接口:
fund_etf_spot_em,fund_etf_hist_em,reits_realtime_em,reits_hist_em - 扩展
rename_map,增加今开->open,最新价->close的映射
4.2 DuckDB Table/View 冲突
问题描述:Catalog Error: Existing object stock_daily is of type Table, trying to replace with type View
原因分析:本地 DuckDB 文件中 stock_daily 是一个表,但云端模式试图创建同名视图,DuckDB 不允许直接覆盖
解决方案:在创建视图前,先执行 DROP VIEW IF EXISTS stock_daily 和 DROP TABLE IF EXISTS stock_daily
4.3 数据同步网络超时
问题描述:大量 Read timed out 和 ConnectionResetError
原因分析:并发过高,触发数据源频率限制
解决方案:
- 降低并发数:
MAX_WORKERS从 10 降为 5 - 增加重试次数:
max_retries从 2 增为 3
4.4 可转债数据解析失败
问题描述:Failed to fetch sh110805 (可转债): 'date'
原因分析:部分可转债代码格式或接口返回异常,缺少 date 列
解决方案:
- 增加代码格式处理:
cov_symbol = code[-6:] if len(code) > 6 else code - 增强字段兼容性:尝试将索引转为列
df.reset_index() - 静默跳过无效数据,避免日志噪音
4.5 重开按钮逻辑优化
需求:点击"重开"或"再来一局"后,回到首页,不自动开始
解决方案:修改 TradePanel.tsx 中的按钮点击事件,移除 handleStartGame() 调用,仅调用 reset()
修改前:
onClick={() => { reset(); handleStartGame(); }}
修改后:
onClick={() => { setAutoPlay(false); reset(); }}
5. 部署与运维
5.1 环境变量
| 变量名 | 说明 | 示例 |
|---|---|---|
HF_TOKEN |
Hugging Face Token | hf_xxxxx |
DATASET_REPO_ID |
HF Dataset 仓库 ID | superxu520/Paper_Trading_Data |
DUCKDB_PATH |
本地 DuckDB 路径(可选) | /app/data/stock_data.duckdb |
5.2 数据同步流程
- 本地配置
HF_TOKEN和DATASET_REPO_ID - 运行
python backend/scripts/sync_data.py - 脚本会自动抓取数据、保存为 Parquet、上传至 HF Dataset
5.3 Hugging Face Spaces 部署
- 项目根目录需包含
Dockerfile Dockerfile中需设置环境变量、安装依赖、启动命令- HF Space 启动后,会自动从 Dataset 下载最新数据
6. 未来优化方向
- 数据源稳定性:寻找更稳定的可转债数据源,或增加多源切换机制
- 自动化同步:使用 GitHub Actions 定时触发
sync_data.py,实现每日自动更新 - 更多指标:支持 BOLL, KDJ, RSI 等更多技术指标
- 策略回测:支持用户编写策略脚本进行自动化回测
- 用户系统:增加用户注册、登录,保存历史战绩
- 社交分享:优化分享卡片,支持更多社交平台
7. 项目目录结构
Paper_Trading/
├── backend/
│ ├── app/
│ │ ├── __init__.py
│ │ ├── core.py # 游戏核心逻辑
│ │ ├── database.py # DuckDB 数据库管理
│ │ └── main.py # FastAPI 主入口
│ ├── scripts/
│ │ └── sync_data.py # 数据同步脚本
│ └── data/ # 本地数据目录
├── frontend/
│ ├── src/
│ │ ├── app/
│ │ │ ├── page.tsx # 主页面
│ │ │ └── layout.tsx # 布局组件
│ │ ├── components/
│ │ │ ├── TradePanel.tsx # 交易面板
│ │ │ ├── Chart.tsx # K线图表
│ │ │ └── StockHeader.tsx # 股票信息栏
│ │ ├── store/
│ │ │ └── gameStore.ts # Zustand 状态管理
│ │ └── lib/
│ │ ├── api.ts # API 封装
│ │ └── resample.ts # K线重采样
│ └── public/ # 静态资源
├── Dockerfile # HF Spaces 部署配置
├── README.md # 项目说明
└── TECHNICAL_DOCUMENTATION.md # 本文档
8. 参考资料
文档版本:2025.02.18 最后更新:优化重开按钮逻辑、修复 DuckDB 冲突、增强 ETF/LOF/REITs 数据同步