first commit

This commit is contained in:
2026-08-07 16:08:34 +08:00
commit e0b5228008
51 changed files with 5175 additions and 0 deletions

50
.gitignore vendored Normal file
View File

@@ -0,0 +1,50 @@
# ---------- Python ----------
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
.venv/
venv/
env/
ENV/
*.egg-info/
.eggs/
dist/
build/
.python-version
.ruff_cache/
.mypy_cache/
.pytest_cache/
# ---------- Node / Frontend ----------
node_modules/
frontend/dist/
*.local
.vite/
.cache/
# ---------- Env / Secrets ----------
.env
.env.*
!.env.example
*.pem
*.key
# ---------- Data / DB ----------
*.sqlite
*.sqlite3
*.db
backend/data/
data/parquet/
data/raw/
# ---------- IDE / OS ----------
.idea/
.vscode/
*.swp
.DS_Store
Thumbs.db
# ---------- Claude ----------
.claude/settings.local.json

270
README.md Normal file
View File

@@ -0,0 +1,270 @@
# 股市回测平台
专业的 **历史回测 + 回放式模拟** 平台A 股为主,**不做实盘**)。
输入技术指标参数MACD/RSI/KDJ…→ 后端回测 → 前端可视化 K 线、指标叠加、买卖点、净值曲线与绩效。
技术选型与架构决策详见 [TECH_STACK.md](./TECH_STACK.md)。
---
## 功能特性
**已实现Phase 0 MVP**
- 自定义指标参数回测(当前内置 MACD 金叉死叉策略)
- K 线主图 + 成交量 + MACD 副图DIF/DEA/柱)
- **买卖点标注**(信号回放到 K 线,↑买 ↓卖)
- **周期切换**:日线 / 周线 / 月线 / 年线(日线为基底,应用层 pandas 聚合)
- **悬停弹框**:鼠标移到任意 K 线,显示当日 开/高/低/收、涨跌幅、振幅、成交量(手)、MACD 值(同花顺式)
- 净值曲线 + 绩效指标(总收益、最大回撤、夏普、年化波动、胜率、交易次数)
- A 股真实成本建模:印花税 0.05%(单边卖出)、过户费 0.001%沪深双边、佣金万1最低5元、T+1、100 股整手
- 回测运行注册表(每次回测落库,可复现/审计的基础)
**规划中(见 TECH_STACK.md 路线图)**
- 接入 Tushare/AKShare 真实 A 股数据(替换 DEMO 合成数据)
- 防过拟合体检walk-forward / 样本外 / FDR 多重比较修正)
- 基准归因对比沪深300/中证500超额、信息比率、beta/alpha
- 复权因子管道、回放式模拟盘
- 切 TimescaleDBhypertable + Continuous Aggregates
---
## 技术栈
| 层 | 选型 |
|---|---|
| 后端 | Python 3.12+ · FastAPI · Pydantic v2 · SQLAlchemy 2.0 (async) · uv包管理 |
| 回测引擎 | 自研单一引擎fast/strict 两档),指标纯 numpy/pandas生产可换 TA-Lib |
| 数据库 | MVP 用 SQLite 零配置;生产切 PostgreSQL 16/17 + TimescaleDB |
| 前端 | Vue 3.5 · Vite · TypeScript · Pinia · PrimeVue 5 · pnpm |
| 图表 | lightweight-charts 5K 线)· ECharts 6净值 |
---
## 目录结构
```
stock/
├── TECH_STACK.md # 技术选型与架构决策(必读)
├── README.md # 本文件
├── backend/ # FastAPI + 回测引擎
│ ├── pyproject.toml # uv 依赖声明
│ ├── .env.example # 配置示例(数据库 / 费率)
│ ├── smoke_test.py # 后端全链路自检脚本
│ └── app/
│ ├── main.py # FastAPI 入口(启动建表)
│ ├── config.py # 配置pydantic-settings
│ ├── db.py # async SQLAlchemy 引擎/会话
│ ├── domain.py # 领域契约Bar/Signal/Fill/Position…
│ ├── models.py # ORMCandle / BacktestRun
│ ├── schemas.py # Pydantic DTO= OpenAPI 契约)
│ ├── commission.py # A 股交易成本(已修正、可配置)
│ ├── indicators.py # 指标MACD/RSI/KDJ/布林/均线(单一事实源)
│ ├── api.py # 路由:/health /candles /backtest
│ ├── data/ # DataProvider 适配器 + 合成数据 + 周期聚合
│ └── backtest/ # engine / broker(PaperBroker) / metrics / strategies
└── frontend/ # Vue SPA
├── src/
│ ├── main.ts # PrimeVue(Aura 深色) + Pinia
│ ├── api/ # 类型化客户端 + DTO 镜像
│ ├── stores/ # Pinia 回测状态
│ ├── components/ # KLineChart / EquityChart / MetricsPanel / BacktestForm
│ └── views/ # BacktestView
└── vite.config.ts # /api 代理到 :8000
```
---
## 环境要求
| 工具 | 版本 | 说明 |
|---|---|---|
| Node.js | ≥ 20实测 24 | 前端 |
| pnpm | ≥ 9实测 11 | 前端包管理 |
| Python | ≥ 3.12(实测 3.14 | 后端 |
| uv | 任意(实测 0.12 | 后端包管理,[安装](https://docs.astral.sh/uv/) |
| 可选PostgreSQL | 16/17 | 生产数据库MVP 用 SQLite 无需安装 |
**安装 uv**(若未装):
```bash
pip install uv
```
---
## 快速开始(开发模式)
需要两个终端,分别跑后端与前端。
### 1) 后端
```bash
cd backend
uv sync # 创建 .venv 并安装依赖(首次)
uv run uvicorn app.main:app --reload --port 8000
```
- 首次启动自动建表SQLite`backend/stock.db`)并播种约 500 个交易日的合成 K 线symbol=`DEMO`)。
- 交互式 API 文档http://localhost:8000/docs
**自检**(无需起服务器,验证全链路):
```bash
uv run --with httpx --directory backend python smoke_test.py
```
### 2) 前端
```bash
cd frontend
pnpm install # 首次
pnpm dev # http://localhost:5173
```
前端 `/api` 请求由 Vite 代理到后端 `:8000`(见 `vite.config.ts`),无需处理跨域。
打开 http://localhost:5173 → 选周期、改参数 → 点「开始回测」。
鼠标悬停 K 线可看当日详情弹框;切日线/周线/月线/年线勾「fast 模式」可对比关闭费用/T+1 的差异。
---
## 配置说明
后端配置通过 `backend/.env`(复制 `.env.example`)或环境变量:
```bash
# 数据库MVP 默认 SQLite零配置
DATABASE_URL=sqlite+aiosqlite:///./stock.db
# 切到你自己的 PostgreSQL / TimescaleDB
# DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/stock
# 真实数据源Tushare Pro免费版即可。留空则仅 DEMO 合成数据可用)
TUSHARE_TOKEN=你的token
DATA_ADJUST=qfq # 复权qfq 前复权 / hfq 后复权 / 留空不复权
DATA_DEFAULT_START=20200101
# A 股交易成本(基准日 2026-08可覆盖默认值见 app/config.py
# STAMP_DUTY_RATE=0.0005 # 印花税 0.05%,单边卖出
# TRANSFER_FEE_RATE=0.00001 # 过户费 0.001%,沪深双边
# COMMISSION_RATE=0.0001 # 佣金 万1
# COMMISSION_MIN=5.0 # 最低 5 元
```
### 切到 PostgreSQL + TimescaleDB
1. 目标库执行 `CREATE EXTENSION IF NOT EXISTS timescaledb;`
2. `DATABASE_URL=postgresql+asyncpg://...`
3. TODO`candles` 表升级为 hypertable 并配置 Continuous Aggregates 多周期预聚合schema 已兼容,无需改表结构):
```sql
SELECT create_hypertable('candles', 'ts');
```
> 即便能装在现有 Postgres 上,建议为交易系统单独起一个 PG16/17 实例,便于调优/备份/隔离回测扫描负载。
---
## API
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/health` | 健康检查 |
| GET | `/api/candles/{symbol}?timeframe=1d&limit=1000` | 取 K 线(`timeframe`: `1d`/`1w`/`1M`/`1y` |
| POST | `/api/data/sync` | 拉取并缓存某标的日线Tushare 主 → AKShare 兜底。body: `{symbol, source?, force?}` |
| POST | `/api/backtest` | 跑回测(真实标的首次自动拉取并缓存) |
**回测请求示例**
```json
POST /api/backtest
{
"symbol": "DEMO",
"timeframe": "1d",
"strategy": "macd_cross",
"params": { "fast": 12, "slow": 26, "signal": 9 },
"initial_cash": 100000,
"fast_mode": false
}
```
返回:`candles`K线、`indicators`MACD 三线)、`signals`(买卖点)、`equity`(净值序列)、`metrics`(绩效)、`final_cash`/`final_position`。
> **关于「标的」**`DEMO` 为内置合成数据;其余为真实 A 股代码(如 `000001`、`600519`),首次回测时**自动经 Tushare 拉取并本地缓存**(需配置 `TUSHARE_TOKEN` + 联网),二次回测秒出。默认初始资金 100 万,足够交易高价股(如茅台)。
---
## 生产构建与部署
### 前端构建
```bash
cd frontend
pnpm build # 产物在 frontend/dist
pnpm preview # 本地预览构建产物
```
部署时把 `frontend/dist` 用任意静态服务器nginx / caddy / 对象存储)托管,并把 `/api` 反向代理到后端。
### 后端生产运行
```bash
cd backend
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
# 或 gunicornLinuxuv run gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker
```
生产务必设置 `DATABASE_URL` 指向 PostgreSQL不要用 SQLite。
### 参考docker-compose具备 Docker 后使用)
```yaml
services:
db:
image: timescale/timescaledb:latest-pg17
environment: { POSTGRES_PASSWORD: stock, POSTGRES_DB: stock }
ports: ["5432:5432"]
volumes: ["pgdata:/var/lib/postgresql/data"]
api:
build: ./backend
environment: { DATABASE_URL: postgresql+asyncpg://postgres:stock@db:5432/stock }
ports: ["8000:8000"]
depends_on: [db]
web:
image: nginx:alpine
volumes: ["./frontend/dist:/usr/share/nginx/html:ro", "./nginx.conf:/etc/nginx/conf.d/default.conf:ro"]
ports: ["80:80"]
depends_on: [api]
volumes: { pgdata: {} }
```
---
## 扩展指南
### 加一个指标
在 `backend/app/indicators.py` 增加纯函数(输入 close/high/low Series输出 DataFrame保持函数签名风格即可被策略复用。例已内置 `macd / rsi / kdj / bollinger / ma`。
### 加一个策略
内置策略:`macd_cross`MACD 金叉死叉)、`ma_cross`(双均线交叉)、`single_ma`(单均线,价格穿越均线)。前端下拉自动出现新策略、参数自适应。
在 `backend/app/backtest/strategies/` 新增继承 `Strategy``base.py`)的类,实现 `compute()`(预算指标)与 `on_bar(i, row, broker)`(决策并向 `broker` 下单),再在 `__init__.py` 的 `STRATEGIES` 注册 `{name: Class}`。策略构造参数由请求 `params(dict)` 以 `**kwargs` 传入。策略只产生买卖意图,撮合/费用交给 `PaperBroker`。
### 数据源(已集成)
真实 A 股行情已集成(`backend/app/data/`
- `tushare_provider.py` —— 主数据源,需 `TUSHARE_TOKEN`,默认前复权(`pro_bar`,积分不足时自动退化为不复权日线)
- `akshare_provider.py` —— 兜底,免费免 token默认**未安装**`uv add akshare` 后自动启用
- `fetcher.py` —— 编排Tushare 失败自动切 AKShare拉取后写入 `candles` 表本地缓存(解耦上游停运/限频)
换复权方式改 `.env` 的 `DATA_ADJUST`;换默认拉取起点改 `DATA_DEFAULT_START`。
---
## 常见问题
- **`pnpm install` 报 `Ignored build scripts: esbuild`**:新版 pnpm 默认不运行依赖的安装脚本。已通过 `frontend/pnpm-workspace.yaml` 的 `allowBuilds: { esbuild: true, vue-demi: true }` 放行;若仍提示,运行 `pnpm approve-builds` 选择允许。
- **前端图表不显示**:确认后端已起(`/api/health` 返回 ok浏览器控制台看是否 404/跨域dev 应走 Vite 代理无跨域)。
- **lightweight-charts 报 `addCandlestickSeries is not a function`**:那是 v4 API。本项目用 v5 的 `chart.addSeries(CandlestickSeries, ...)`,请勿混用旧教程。
- **真实标的首次回测慢/拉取失败**:首次需联网请求 Tushare数秒。Tushare 对 `adj_factor` 限频(每小时 1 次),触发时自动退化为不复权日线,不影响回测。数据已本地缓存,二次回测秒出。若 Tushare 积分不足,`uv add akshare` 后将自动用 AKShare 兜底。
- **高价股(如茅台)回测 0 信号**:一手太贵买不起。默认初始资金已设为 100 万;如仍不够,在表单调高「初始资金」。
- **Python 3.14 兼容**MVP 依赖fastapi/sqlalchemy/pandas/numpy均已支持指标用纯 Python 实现,不依赖 TA-Lib 的 C 库Windows 免编译。
---
## 路线图
详见 [TECH_STACK.md](./TECH_STACK.md)「分阶段落地路线」。下一步优先级:
1. 接入 Tushare 真实数据
2. 更多指标/策略RSI/KDJ/布林策略)
3. 防过拟合体检 + 基准归因
4. 切 TimescaleDB、回测结果缓存与对比

187
TECH_STACK.md Normal file
View File

@@ -0,0 +1,187 @@
# 股市回测平台 — 技术选型推荐(纯回测范围)
> **一句话总体推荐****Python 单语言后端**FastAPI + 自研回测引擎)为"大脑与事实源"**Vue 单页前端**为"渲染器与控制台"**PostgreSQL/TimescaleDB + Redis** 承载数据,三者用 **OpenAPI 契约**解耦。
>
> **范围明确**:本平台**只做历史回测 + 回放式模拟交易,不做实盘**。因此砍掉了所有实盘专属基建券商网关、Windows broker、程序化交易报备、限速、Kill Switch、talipp 流式指标等)——这些正是最难维护、风险最高的部分。
>
> 核心判断:**A 股的量化生态(数据源、回测库)几乎只以 Python 存在**——"后端选 Python"是生态约束下的唯一合理解。
---
## 范围变更记录2026-08
原方案含实盘量化。经确认**只做回测 + 回放式模拟,不做实盘**后,移除:
| 移除项 | 原因 |
|---|---|
| vnpy / xtquant / xttrader 实盘网关 | 实盘下单专用,无实盘即不需要 |
| **Windows broker 节点(永久运维分叉)** | QMT 必须常驻 Windows无实盘 → 纯 Linux部署统一 |
| 程序化交易报备 / 硬限速≤299笔/秒) | 只对实盘程序化交易生效,纯回测不受管 |
| Kill Switch / 风控闸门 / 幂等下单 / 断线重连 | 全是"防回测 bug 造成真实亏损"的护栏 |
| talipp 流式增量指标 | 实盘每根新K线增量算用回测批量算用 TA-Lib/自研即可 |
| CTP期货、券商资质、投资者适当性 | 实盘门槛 |
**保留并强化的核心**单一回测引擎fast/strict、防过拟合体检、基准归因、复权因子管道、A股交易成本建模、指标单一事实源。
**澄清**:最早的"对接交易所显示买卖点"——**不需要真连交易所**。回测信号标在历史 K 线上即是买卖点显示。
---
## 一、技术栈总表
| 层 | 选型 | 作用 | 说明 |
|---|---|---|---|
| **前端基座** | Vue 3.5 + Vite + TypeScript + Pinia | SPA、状态管理 | 用户已有 Vue 生态 |
| **UI 组件** | **PrimeVue 5** | 表单/参数面板/大表/筛选 | DataTable 成熟度对数据密集控制台是决定性的 |
| **K 线主图** | **lightweight-charts 5.x** | 纯渲染器:主图 + 指标副图 + `createSeriesMarkers` 标买卖点 | TradingView 出品;不用任何内置指标算法(单一事实源) |
| **分析面板** | ECharts 6.1 + vue-echarts | 净值/回撤/收益分布/热力图 | WebGL 大数据 |
| **前端指标预览** | 防抖调后端算 | 单一指标实现 | 前端不保留任何指标库 |
| **后端服务** | FastAPI + Uvicorn + Pydantic v2 | REST + 自动 OpenAPI 契约 | 契约先于业务锁定 |
| **回测引擎(单一)** | **自研Polars/Numpy 向量化内核 + 事件化规则注入** | 唯一事实源fast/strict 两档 | 不做"快层+真层"双引擎 |
| **指标计算** | 纯 pandas/numpy 实现MVP→ TA-Lib生产 | 回测/图表共享同一份结果 | Windows 上 TA-Lib C 库难装MVP 用纯 Python接口留好可替换 |
| **任务编排** | MVP 同步;长回测引入 Dramatiq + 进程池 | 绕 GIL多进程并行参数扫描 | 进度落库 checkpoint |
| **元数据库** | PostgreSQL 16/17 + async SQLAlchemy 2.0 + Alembic | 用户/策略/回测元数据/回测运行注册表 | — |
| **时序主库** | **TimescaleDB 2.28+**(同一 PG 实例的扩展) | K 线 Hypertable + Continuous Aggregates 多周期 + 列式压缩 | 用户已有 Postgres → 直接装扩展即可MVP 暂用 SQLite 零配置起步 |
| **缓存** | Redis规模化后 | 热点 K 线缓存、任务队列 broker | MVP 不引入 |
| **交易日历** | exchange_calendars`XSHG`+ tushare `trade_cal` 兜底 | 回测/回放对齐、版本化 | `XSHG` 覆盖截至 20252026+ 需兜底 |
| **A股数据源** | Tushare Pro+ AKShare校验+ BaoStock末位 | 历史 K 线/复权/财务/分红 | 多源冗余2025-08 Tushare 曾停摆 |
| **MVP 数据** | 合成数据种子(随机游走 OHLCV | 零依赖即时可跑 | DataProvider 接口下可一行切到 Tushare |
| **港美股数据** | Polygon更名 Massive付费档 | 历史+实时(远期) | 仅开发期 mock 用免费源 |
| **鉴权** | JWT + RBAC多用户时 | 用户/策略级权限 | MVP 单用户可暂缓 |
---
## 二、系统架构
```
┌──────────────────────────────────────────────────────────────────┐
│ 前端 (Vue 3.5 + Vite + TS + Pinia + PrimeVue) │
│ ┌────────────┐ ┌──────────────────┐ ┌────────────────────────┐ │
│ │ PrimeVue 5 │ │ lightweight- │ │ ECharts 6.1 │ │
│ │ 参数表单 │ │ charts 5.x │ │ 净值/回撤/归因/过拟合 │ │
│ │ 策略选择 │ │ K线+指标副图+ │ │ │ │
│ │ │ │ 买卖点markers │ │ │ │
│ └────────────┘ └──────────────────┘ └────────────────────────┘ │
│ shallowRef+markRaw; series 不入响应式; onMounted/onBeforeUnmount │
└──────────────┬───────────────────────────────────────────────────┘
│ REST类型化客户端由 OpenAPI 生成)
┌──────────────────────────────────────────────────────────────────┐
│ 后端 (FastAPI + Uvicorn + Pydantic v2) [纯 Linux] │
│ ┌────────────┐ ┌──────────────────────┐ ┌────────────────────┐ │
│ │ REST │ │ DataProvider 适配器 │ │ 领域模型契约 │ │
│ │ OpenAPI 契约│ │ Tushare/AKShare/ │ │ Instrument/Bar/ │ │
│ │ │ │ Synthetic/复权管道 │ │ Signal/Order/Fill/ │ │
│ │ │ │ │ │ Position/Portfolio/ │ │
│ │ │ │ │ │ Strategy/Universe/ │ │
│ │ │ │ │ │ Calendar │ │
│ └────────────┘ └──────────────────────┘ └────────────────────┘ │
│ │ ┌─────────────────────────────────────────────────┐ │
│ │ │ 单一回测引擎 (Numpy/Polars + 规则注入) │ │
│ │ │ ├ fast 档: 关 T+1/费用, 交互试探 │ │
│ │ │ └ strict档: T+1/涨跌停/封板/成交量约束/全费用 │ │
│ │ │ + 防过拟合体检(walk-fwd/OOS/FDR/敏感性) │ │
│ │ │ + 基准归因(超额/信息比率/beta/alpha) │ │
│ │ │ + 回测运行注册表(run_id→策略版本+参数+数据快照) │ │
│ │ └─────────────────────────────────────────────────┘ │
└────────┼─────────────────────────────────────────────────────────┘
┌───────────────────┐ ┌──────────────────────┐
│ PostgreSQL 16/17 │ │ DataProvider 数据源 │
│ + TimescaleDB │ │ Tushare(主)/AKShare │
│ K线 Hypertable │ │ BaoStock(末位) │
│ CAGG 多周期 │ │ Synthetic(MVP) │
│ + 关系/元数据 │ │ Polygon(港美股,远期) │
└───────────────────┘ └──────────────────────┘
```
### 三个必须分离的关注点
| 关注点 | 落在哪 | 为什么 |
|---|---|---|
| 信号生成(指标+策略) | Python 后端,单一实现 | 回测与图表必须用同一份结果,否则"看着赚回测亏" |
| 订单执行(撮合) | PaperBroker虚拟成交 | 隔离策略逻辑与成交逻辑;无实盘则永远是虚拟 |
| 数据获取 | DataProvider 适配器 + 复权管道 | 上游随时失效,必须可热切换 |
### 先于业务落地的"领域模型契约"MVP 第一周定稿)
`Instrument` / `Bar`(OHLCV+复权标识+周期) / `Signal` / `Order` / `Fill`(含费用明细) / `Position` / `Portfolio` / `Strategy`(+版本) / `Universe`(股票池+成分历史) / `TradingCalendar`
---
## 三、关键取舍裁决
1. **纯 Python 单语言栈。** A 股数据源与回测生态只以 Python 存在;性能瓶颈在向量化计算与多进程,不在 HTTP 层。
2. **数据库TimescaleDB 单库起步。** 它是 Postgres 扩展(用户已有 Postgres 可直接装),同一库内"信号 JOIN 行情 JOIN 订单"+ ACID。**分钟级全市场**≈十亿行确定要做时再把分钟K/Tick 下沉 ClickHouse 分层关系层与日K 留 PG。
3. **K 线主图用 lightweight-charts 5.x纯渲染器** 不用任何内置指标算法;指标值全部后端算好喂给它。不用 klinecharts内置指标会与回测两套算法漂移
4. **决策级指标全在后端 Python。** 前端预览一律调后端,前端不保留指标库实现。
5. **单一回测引擎 + 质量档位fast/strict**,不做双引擎。同一撮合逻辑、同一代码路径,仅开关不同:
- `fast`:关 T+1/费用/成交量约束,纯向量化,交互试探、参数网格。
- `strict`:全开 A 股规则,出可审计报告。
> vectorbt **开源版基于 pandas/NumPy不支持 Polars**Polars 属闭源付费 PRO。向量化内核自研Polars/Numba
---
## 四、A股交易成本表已修正可配置 + 按生效日期版本化,基准日 2026-08
| 费用项 | 费率 | 方向 | 生效依据 |
|---|---|---|---|
| 印花税 | **0.05%(千分之零点五)** | **单边卖出** | 2023-08-28 减半(原 0.1% |
| 过户费 | **0.001%(万分之零点一)** | **沪深双边均收** | 2022 年统一下调(原沪市万 0.2 单边) |
| 佣金 | **万 1 含规费**(最低 5 元) | 双边 | 2026 主流;最低 5 元须建模 |
| 滑点 | 1-2 tick | 双边 | 可配置 |
> 费用必须是**参数表 + 生效日期版本化 + 显式基准日**,非硬编码常量。
---
## 五、分阶段落地路线
### 阶段 0回测 MVP + 可视化(进行中)
跑通"输入 MACD/RSI/KDJ 参数 → 后端回测 → 画 K线+指标+收益+最大回撤+夏普+买卖点"。SQLite 默认库 + 合成数据零依赖可跑。**不引入**Redis、Dramatiq、任何行情/交易网关、K8s。
### 阶段 1完整回测 + 回放式模拟
- strict 档完整性T+1、涨跌停主板±10%/科创创业±20%/北交所±30%)、**成交量约束**(单笔 ≤ bar 成交量 10-25%)、**封板建模**(一字板不可买/不可卖、停牌事件流、100 股整数手、全费用、滑点。
- **股票池 + 成分历史**(生存者偏差):用"当时成分股"而非"今天成分股"。
- **防过拟合体检**回测报告一等公民walk-forward、OOS 切分、参数敏感性、多重比较修正Bonferroni/FDR/White's Reality Check/SPA、样本自由度告警。
- **基准归因**超额收益、信息比率、跟踪误差、beta/alpha、up/down capture。
- **回测运行注册表**`run_id → 策略版本 + 参数快照 + 数据快照 + 环境指纹 + 结果指纹`
- **结果缓存 + 多回测对比**:相同三元组命中缓存;参数微调前后 diff 视图。
- 切真实数据源Tushare 主 / AKShare 校验),全量本地落地缓存;**复权因子管道**(增量 + 回溯回填)。
- 切到用户 Postgres + TimescaleDBhypertable + CAGG
- 回放式模拟盘:选历史区间逐 bar 虚拟交易PaperBroker
### 阶段 2分钟级 + 规模化 + 工程化
- 分钟级全市场回测(≈十亿行):按需引入 ClickHouse 分层分钟K/Tick关系层与日K 留 PG。
- Dramatiq + 进程池跑长回测/参数扫描,进度落库 checkpoint。
- 可观测:结构化日志 + 核心 metric回测耗时 P95、数据延迟→ OpenTelemetry + Grafana。
- K8s + ArgoCD 部署。
---
## 六、关键风险与对策
### 6.1 数据正确性
- **A股复权是最大隐患**:复权因子是每日增量、且会回溯修正的流。维护复权因子管道,查询显式选择前/后复权,否则回测收益与图表价格不一致。
- **生存者偏差**用当时成分股回测Tushare 为主、AKShare 校验,差异超阈值告警。
- **单点数据源风险已验证**2025-08-26 TusharePro 停运,仅依赖 Tushare 的系统当天停摆 → 强制多源 + 本地落地缓存。
- **数据治理**新鲜度监控、质量校验OHLC 不合理/成交量为负/缺口)、数据血缘。
### 6.2 性能瓶颈
- **最致命陷阱:把大数据塞进 Pinia reactive**。必须 `shallowRef + markRaw` + 仅加载可见区间按 pan/zoom 增量拉取;**series 数据不入响应式**;图表实例 onMounted/onBeforeUnmount 严格绑定。
- **GIL 误用**CPU 密集回测走独立进程池FastAPI 路由只 dispatch。
- **十亿级撑爆内存**:谓词下推按 symbol+区间载入;前端服务端聚合/降采样再渲染。
- **TimescaleDB**chunk 对齐查询模式CAGG 刷新滞后读防护(`materialized_only` + 时间戳护栏);并发读副本/连接池。
### 6.3 可维护性
- **避免停维库**backtrader 自 2023 停维、pandas-ta 停滞。指标自研隔离层,便于将来换 TA-Lib。
- **回测可复现性**:每次回测绑定策略版本+参数+数据快照三元组。
### 6.4 合规(已大幅缩水)
- 数据源 ToS 对商业/再分发有约束;公开免费接口(新浪/东财/腾讯)盘中高并发被封,仅作降级。**商用上线前须取得数据授权**Wind/Choice/聚宽/Tushare 高级档)。
### 6.5 版本陷阱
- lightweight-charts v5`createSeriesMarkers(series, markers)` 取代 v4 `setMarkers()`
- TimescaleDB 2.28+ 弃 PG15锁 PG16/17。
- exchange_calendars `XSHG` 覆盖截至 20252026+ 需 extensions/tushare 兜底。
- ECharts v6 WebGL 在低端机需回归测试。

17
backend/.env.example Normal file
View File

@@ -0,0 +1,17 @@
# ---- Database ----
# MVP 默认 SQLite零配置即可跑
DATABASE_URL=sqlite+aiosqlite:///./stock.db
# 切到你自己的 PostgreSQLTimescaleDB 是 Postgres 扩展,目标库执行 CREATE EXTENSION timescaledb; 即可)
# DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/stock
# ---- 真实数据源Tushare Pro免费版即可。留空则仅 DEMO 合成数据可用)----
TUSHARE_TOKEN=你的token
DATA_ADJUST=qfq # 复权qfq 前复权 / hfq 后复权 / 留空不复权
DATA_DEFAULT_START=20200101
# ---- A股交易成本基准日 2026-08可覆盖详见 app/commission.py----
# STAMP_DUTY_RATE=0.0005 # 印花税 0.05%,单边卖出
# TRANSFER_FEE_RATE=0.00001 # 过户费 0.001%,沪深双边
# COMMISSION_RATE=0.0001 # 佣金 万1
# COMMISSION_MIN=5.0 # 最低 5 元

0
backend/app/__init__.py Normal file
View File

167
backend/app/api.py Normal file
View File

@@ -0,0 +1,167 @@
"""HTTP 路由OpenAPI 契约的载体)。
GET /api/health 健康检查
GET /api/candles/{sym} 取 K 线(支持 1d/1w/1M/1y 周期,日线为基底聚合)
POST /api/backtest 跑回测,返回 K线+指标+买卖点+净值+绩效
"""
from __future__ import annotations
import json
import pandas as pd
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from .backtest.engine import BacktestConfig, run_backtest
from .backtest.strategies import build_strategy
from .data import fetcher, repository
from .data.aggregation import bars_per_year, resample_bars
from .data.synthetic import seed_if_empty
from .db import get_session
from .domain import Bar
from .models import BacktestRun
from .schemas import (
BacktestRequest,
BacktestResponse,
CandleOut,
EquityPoint,
IndicatorOut,
MetricsOut,
SignalOut,
SyncRequest,
SyncResponse,
)
router = APIRouter(prefix="/api")
def _series_to_jsonable(s: pd.Series) -> list[float | None]:
"""NaN -> Nonelightweight-charts 的 whitespace data跳过指标预热期"""
out: list[float | None] = []
for v in s.tolist():
if v is None or (isinstance(v, float) and v != v):
out.append(None)
else:
out.append(float(v))
return out
def _rows_to_bars(rows) -> list[Bar]:
return [Bar(ts=r.ts, open=r.open, high=r.high, low=r.low, close=r.close, volume=r.volume) for r in rows]
@router.get("/health")
async def health() -> dict:
return {"status": "ok"}
@router.get("/candles/{symbol}", response_model=list[CandleOut])
async def get_candles(
symbol: str,
timeframe: str = "1d",
limit: int = 5000,
session: AsyncSession = Depends(get_session),
) -> list[CandleOut]:
await seed_if_empty(session, symbol="DEMO")
# 始终以日线为基底,再聚合到目标周期
rows = await repository.get_candles(session, symbol, "1d", limit=limit)
bars = resample_bars(_rows_to_bars(rows), timeframe)
return [CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close, volume=b.volume) for b in bars]
@router.post("/data/sync", response_model=SyncResponse)
async def sync_data(req: SyncRequest, session: AsyncSession = Depends(get_session)) -> SyncResponse:
"""主动拉取并缓存某标的的日线Tushare 主 -> AKShare 兜底)。"""
try:
res = await fetcher.sync_symbol(
session, req.symbol, start=req.start, end=req.end, source=req.source, force=req.force
)
return SyncResponse(**res)
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=502, detail=str(e))
@router.post("/backtest", response_model=BacktestResponse)
async def backtest(
req: BacktestRequest,
session: AsyncSession = Depends(get_session),
) -> BacktestResponse:
await seed_if_empty(session, symbol="DEMO")
# 非演示标的:首次自动拉取真实数据并缓存
if req.symbol != "DEMO" and not await fetcher.is_cached(session, req.symbol):
try:
await fetcher.sync_symbol(session, req.symbol, source="auto")
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"数据拉取失败: {e}")
# 日线为基底,聚合到请求周期
rows = await repository.get_candles(
session, req.symbol, "1d", start=req.start, end=req.end, limit=100000
)
if not rows:
raise HTTPException(status_code=404, detail=f"无数据: symbol={req.symbol}")
bars = resample_bars(_rows_to_bars(rows), req.timeframe)
if len(bars) < 2:
raise HTTPException(status_code=400, detail=f"周期 {req.timeframe} 下数据不足,无法回测")
try:
strategy = build_strategy(req.strategy, req.params)
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=400, detail=f"策略构建失败: {e}")
cfg = BacktestConfig(
initial_cash=req.initial_cash,
fast_mode=req.fast_mode,
bars_per_year=bars_per_year(req.timeframe),
)
result = run_backtest(bars, strategy, cfg)
df: pd.DataFrame = result["df"]
m = result["metrics"]
# 记录到回测运行注册表(可复现/可审计的基础)
session.add(
BacktestRun(
symbol=req.symbol,
strategy=req.strategy,
timeframe=req.timeframe,
params_json=json.dumps(req.params, ensure_ascii=False),
initial_cash=req.initial_cash,
total_return=m["total_return"],
max_drawdown=m["max_drawdown"],
sharpe=m["sharpe"],
num_trades=m["num_trades"],
)
)
await session.commit()
candles = [
CandleOut(ts=r["ts"], open=r["open"], high=r["high"], low=r["low"],
close=r["close"], volume=r["volume"])
for _, r in df.iterrows()
]
signals = [
SignalOut(ts=f.ts, side=f.side.value, price=f.price, qty=f.qty)
for f in result["fills"]
]
indicators = IndicatorOut(
strategy=req.strategy,
data={col: _series_to_jsonable(df[col]) for col in result["indicator_cols"]},
)
equity = [EquityPoint(ts=t.to_pydatetime(), value=float(v))
for t, v in result["equity"].items()]
return BacktestResponse(
symbol=req.symbol,
timeframe=req.timeframe,
strategy=req.strategy,
candles=candles,
indicators=indicators,
signals=signals,
equity=equity,
metrics=MetricsOut(**m),
final_cash=result["final_cash"],
final_position=result["final_position"],
initial_cash=req.initial_cash,
)

View File

View File

@@ -0,0 +1,104 @@
"""PaperBroker —— 回测中的虚拟撮合 / 账户。
建模 A 股规则:
- 100 股整数手1手=100股
- T+1当日买入次日才可卖fast_mode 关闭此约束)
- 印花税卖出、过户费双边、佣金万1 最低5元、滑点
- 撮合价:以当根 bar 收盘价近似阶段1 接 VWAP / 限价单)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from ..commission import CostSchedule, DEFAULT, buy_cost, sell_cost
from ..domain import Fill, Side
LOT = 100 # A股 1 手 = 100 股
@dataclass
class PaperBroker:
initial_cash: float = 100000.0
schedule: CostSchedule = field(default_factory=lambda: DEFAULT)
enable_costs: bool = True
enable_t_plus_1: bool = True
cash: float = field(init=False)
holdings: float = 0.0 # 可卖数量
locked: float = 0.0 # 当日买入T+1 锁定)
avg_price: float = 0.0
fills: list[Fill] = field(default_factory=list)
def __post_init__(self) -> None:
self.cash = self.initial_cash
@property
def position(self) -> float:
return self.holdings + self.locked
def equity(self, price: float) -> float:
return self.cash + self.position * price
@staticmethod
def _to_lots(qty: float) -> int:
return int(qty // LOT) * LOT
def buy_max(self, ts, price: float) -> Fill | None:
"""用当前现金买尽可能多的整手。"""
if price <= 0:
return None
rate = (
self.schedule.commission_rate
+ self.schedule.transfer_fee_rate
+ self.schedule.slippage_rate
) if self.enable_costs else 0.0
affordable_qty = self.cash / (price * (1 + rate))
qty = self._to_lots(affordable_qty)
if qty <= 0:
return None
return self._execute_buy(ts, price, qty)
def sell_all(self, ts, price: float) -> Fill | None:
"""卖出全部可卖持仓(整手)。"""
qty = self._to_lots(self.holdings)
if qty <= 0:
return None
return self._execute_sell(ts, price, qty)
def _execute_buy(self, ts, price: float, qty: int) -> Fill:
if self.enable_costs:
fp, comm, tf = buy_cost(price, qty, self.schedule)
else:
fp, comm, tf = price, 0.0, 0.0
cost = fp * qty + comm + tf
prev_pos = self.position
new_pos = prev_pos + qty
self.avg_price = (self.avg_price * prev_pos + fp * qty) / new_pos if new_pos else 0.0
if self.enable_t_plus_1:
self.locked += qty
else:
self.holdings += qty
self.cash -= cost
f = Fill(ts=ts, side=Side.BUY, price=fp, qty=qty, commission=comm, transfer_fee=tf)
self.fills.append(f)
return f
def _execute_sell(self, ts, price: float, qty: int) -> Fill:
if self.enable_costs:
fp, comm, tf, sd = sell_cost(price, qty, self.schedule)
else:
fp, comm, tf, sd = price, 0.0, 0.0, 0.0
proceeds = fp * qty - comm - tf - sd
self.holdings -= qty
self.cash += proceeds
if self.position == 0:
self.avg_price = 0.0
f = Fill(ts=ts, side=Side.SELL, price=fp, qty=qty, commission=comm, stamp_duty=sd, transfer_fee=tf)
self.fills.append(f)
return f
def release_t_plus_1(self) -> None:
"""每根 bar 结束时调用:当日锁定转为次日可卖。"""
if self.enable_t_plus_1:
self.holdings += self.locked
self.locked = 0.0

View File

@@ -0,0 +1,114 @@
"""单一回测引擎fast/strict 两档,同一代码路径、同一撮合逻辑)。
不做"快层 vectorbt + 真层自研"双引擎——用户必然信任更快的那层,
一旦快层省略 T+1/费用,两套结论背离即反复扯皮"以谁为准"
这里用开关控制规则是否注入,引擎只有一份。
数据流:
bars -> DataFrame -> strategy.compute(指标) -> 逐 bar 喂 strategy.on_bar(broker)
-> broker 产生 fills -> 每根 bar 记录 equity -> metrics
"""
from __future__ import annotations
from dataclasses import dataclass
import pandas as pd
from ..domain import Bar
from .broker import PaperBroker
from .metrics import compute_metrics
@dataclass
class BacktestConfig:
initial_cash: float = 1000000.0
fast_mode: bool = False # True: 关 T+1/费用,交互试探
bars_per_year: int = 252 # 日线 252分钟级另算
def run_backtest(bars: list[Bar], strategy, cfg: BacktestConfig | None = None) -> dict:
cfg = cfg or BacktestConfig()
if not bars:
return _empty_result()
df = pd.DataFrame(
[{"ts": b.ts, "open": b.open, "high": b.high, "low": b.low,
"close": b.close, "volume": b.volume} for b in bars]
).sort_values("ts").reset_index(drop=True)
# 指标预计算(由策略持有,单一事实源)
ind = strategy.compute(df["close"], df["high"], df["low"])
indicator_cols = list(ind.columns)
df = pd.concat([df, ind], axis=1)
broker = PaperBroker(
initial_cash=cfg.initial_cash,
enable_costs=not cfg.fast_mode,
enable_t_plus_1=not cfg.fast_mode,
)
equity_values = []
for i in range(len(df)):
row = df.iloc[i]
strategy.on_bar(i, row, broker)
equity_values.append(broker.equity(row["close"]))
broker.release_t_plus_1()
equity = pd.Series(equity_values, index=df["ts"], name="equity")
metrics = compute_metrics(equity, cfg.bars_per_year)
metrics["num_trades"] = len(broker.fills)
metrics["win_rate"] = _win_rate(broker.fills)
return {
"df": df,
"indicator_cols": indicator_cols,
"fills": broker.fills,
"equity": equity,
"metrics": metrics,
"final_cash": broker.cash,
"final_position": broker.position,
}
def _win_rate(fills) -> float:
"""FIFO 配对计算卖出胜率。"""
from collections import deque
buys: deque = deque()
wins = 0
total_sells = 0
for f in fills:
if f.side.value == "buy":
buys.append((f.price, f.qty))
else: # sell
remaining = f.qty
total_sells += 1
profitable = True
while remaining > 0 and buys:
buy_price, buy_qty = buys[0]
if f.price >= buy_price:
pass
else:
profitable = False
take = min(remaining, buy_qty)
buy_qty -= take
remaining -= take
if buy_qty <= 0:
buys.popleft()
else:
buys[0] = (buy_price, buy_qty)
if profitable and f.qty > 0:
wins += 1
return wins / total_sells if total_sells else 0.0
def _empty_result() -> dict:
return {
"df": pd.DataFrame(),
"indicator_cols": [],
"fills": [],
"equity": pd.Series(dtype=float),
"metrics": {"total_return": 0.0, "max_drawdown": 0.0, "sharpe": 0.0,
"volatility": 0.0, "num_trades": 0, "win_rate": 0.0},
"final_cash": 0.0,
"final_position": 0.0,
}

View File

@@ -0,0 +1,41 @@
"""绩效统计阶段1 补基准归因:超额/信息比率/beta/alpha"""
from __future__ import annotations
import numpy as np
import pandas as pd
def compute_metrics(equity: pd.Series, bars_per_year: int = 252) -> dict:
equity = equity.dropna()
if len(equity) < 2 or equity.iloc[0] == 0:
return {"total_return": 0.0, "max_drawdown": 0.0, "sharpe": 0.0,
"volatility": 0.0, "win_rate": 0.0}
total_return = float(equity.iloc[-1] / equity.iloc[0] - 1)
returns = equity.pct_change().dropna()
cummax = equity.cummax()
drawdown = (equity - cummax) / cummax
max_drawdown = float(abs(drawdown.min()))
std = float(returns.std())
sharpe = float(returns.mean() / std * np.sqrt(bars_per_year)) if std > 0 else 0.0
volatility = std * np.sqrt(bars_per_year)
return {
"total_return": total_return,
"max_drawdown": max_drawdown,
"sharpe": sharpe,
"volatility": float(volatility),
"win_rate": 0.0, # 由 engine 用成交对计算后注入
}
def win_rate_from_fills(fills) -> float:
""""卖出-对应买入"配对估算胜率粗略阶段1 用 FIFO 精确配对)。"""
sells = [f for f in fills if f.side.value == "sell"]
if not sells:
return 0.0
wins = sum(1 for f in fills if f.side.value == "sell" and f.price > 0)
# 简化:有成交即计;真实胜率需配对,这里先返回 0 占位,由 engine 精算
return 0.0

View File

@@ -0,0 +1,24 @@
"""策略注册表与工厂。
新增策略:实现 Strategybase.py在此注册 {name: Class},前端下拉即可选。
策略构造参数由请求的 params(dict) 以 **kwargs 传入。
"""
from __future__ import annotations
from .base import Strategy
from .macd_cross import MACDCrossStrategy
from .ma_strategies import MACrossStrategy, SingleMAStrategy
STRATEGIES: dict[str, type[Strategy]] = {
"macd_cross": MACDCrossStrategy,
"ma_cross": MACrossStrategy,
"single_ma": SingleMAStrategy,
}
def build_strategy(name: str, params: dict | None) -> Strategy:
cls = STRATEGIES.get(name)
if cls is None:
raise ValueError(f"未知策略: {name}(可用: {', '.join(STRATEGIES)}")
kwargs = {k: v for k, v in (params or {}).items()}
return cls(**kwargs)

View File

@@ -0,0 +1,20 @@
"""策略抽象基类。策略只产生买卖意图(向 broker 下单),不负责撮合/费用。"""
from __future__ import annotations
from abc import ABC, abstractmethod
import pandas as pd
from ..broker import PaperBroker
class Strategy(ABC):
"""compute 预算指标on_bar 逐 bar 决策并向 broker 下单。"""
@abstractmethod
def compute(self, close: pd.Series, high: pd.Series, low: pd.Series) -> pd.DataFrame:
...
@abstractmethod
def on_bar(self, i: int, row: pd.Series, broker: PaperBroker) -> None:
...

View File

@@ -0,0 +1,74 @@
"""均线类策略:双均线交叉、单均线(价格上穿/下穿)。"""
from __future__ import annotations
import pandas as pd
from ...indicators import ma
from ..broker import PaperBroker
from .base import Strategy
class MACrossStrategy(Strategy):
"""双均线交叉:快线上穿慢线买入,下穿卖出(金叉/死叉)。"""
def __init__(self, fast: float = 5, slow: float = 20):
self.fast = int(fast)
self.slow = int(slow)
self._ind: pd.DataFrame | None = None
self._prev_fast: float | None = None
self._prev_slow: float | None = None
def compute(self, close: pd.Series, high: pd.Series, low: pd.Series) -> pd.DataFrame:
self._ind = pd.DataFrame({"fast": ma(close, self.fast), "slow": ma(close, self.slow)})
return self._ind
def on_bar(self, i: int, row: pd.Series, broker: PaperBroker) -> None:
f = float(self._ind["fast"].iloc[i])
s = float(self._ind["slow"].iloc[i])
if self._prev_fast is None:
self._prev_fast, self._prev_slow = f, s
return
golden = self._prev_fast <= self._prev_slow and f > s
death = self._prev_fast >= self._prev_slow and f < s
price = float(row["close"])
ts = row["ts"]
if golden and pd.notna(f):
broker.buy_max(ts, price)
elif death and broker.position > 0 and pd.notna(f):
broker.sell_all(ts, price)
self._prev_fast, self._prev_slow = f, s
class SingleMAStrategy(Strategy):
"""单均线:收盘价上穿均线买入,下穿均线卖出。"""
def __init__(self, period: float = 20):
self.period = int(period)
self._ind: pd.DataFrame | None = None
self._prev_above: bool | None = None
def compute(self, close: pd.Series, high: pd.Series, low: pd.Series) -> pd.DataFrame:
self._ind = pd.DataFrame({"ma": ma(close, self.period)})
return self._ind
def on_bar(self, i: int, row: pd.Series, broker: PaperBroker) -> None:
m = float(self._ind["ma"].iloc[i])
price = float(row["close"])
ts = row["ts"]
above = price > m
if self._prev_above is None or pd.isna(m):
self._prev_above = above
return
cross_up = above and not self._prev_above # 上穿
cross_down = (not above) and self._prev_above # 下穿
if cross_up:
broker.buy_max(ts, price)
elif cross_down and broker.position > 0:
broker.sell_all(ts, price)
self._prev_above = above

View File

@@ -0,0 +1,42 @@
"""MACD 金叉死叉策略。"""
from __future__ import annotations
import pandas as pd
from ...indicators import macd
from ..broker import PaperBroker
from .base import Strategy
class MACDCrossStrategy(Strategy):
def __init__(self, fast: float = 12, slow: float = 26, signal: float = 9):
self.fast = int(fast)
self.slow = int(slow)
self.signal = int(signal)
self._ind: pd.DataFrame | None = None
self._prev_dif: float | None = None
self._prev_dea: float | None = None
def compute(self, close: pd.Series, high: pd.Series, low: pd.Series) -> pd.DataFrame:
self._ind = macd(close, self.fast, self.slow, self.signal)
return self._ind
def on_bar(self, i: int, row: pd.Series, broker: PaperBroker) -> None:
dif = float(self._ind["macd"].iloc[i])
dea = float(self._ind["signal"].iloc[i])
if self._prev_dif is None:
self._prev_dif, self._prev_dea = dif, dea
return
golden = self._prev_dif <= self._prev_dea and dif > dea # 金叉
death = self._prev_dif >= self._prev_dea and dif < dea # 死叉
price = float(row["close"])
ts = row["ts"]
if golden and pd.notna(dif):
broker.buy_max(ts, price)
elif death and broker.position > 0 and pd.notna(dif):
broker.sell_all(ts, price)
self._prev_dif, self._prev_dea = dif, dea

47
backend/app/commission.py Normal file
View File

@@ -0,0 +1,47 @@
"""A股交易成本基准日 2026-08已修正历史错误
⚠️ 重要:费率必须是"参数表 + 生效日期版本化 + 显式基准日",不能硬编码成常量。
费率会再变(如 2023-08-28 印花税减半、2022 过户费下调)。
MVP 用单一 CostSchedule阶段1 扩展为按生效日期区间查找的多版本表。
参考(已核实):
印花税 0.05% 单边卖出 —— 2023-08-28 财政部减半(原 0.1%
过户费 0.001% 沪深双边 —— 2022 年统一下调(原沪市万 0.2 单边)
佣金 万1 含规费,最低 5 元 —— 2026 主流
"""
from __future__ import annotations
from dataclasses import dataclass
from .config import settings
@dataclass(frozen=True)
class CostSchedule:
stamp_duty_rate: float = settings.stamp_duty_rate # 印花税,卖出
transfer_fee_rate: float = settings.transfer_fee_rate # 过户费,双边
commission_rate: float = settings.commission_rate # 佣金
commission_min: float = settings.commission_min # 最低佣金
slippage_rate: float = settings.slippage_rate # 滑点(价格比例近似)
DEFAULT = CostSchedule()
def buy_cost(price: float, qty: float, sch: CostSchedule = DEFAULT) -> tuple[float, float, float]:
"""买入成本。返回 (成交价, 佣金, 过户费)。买入无印花税。"""
fill_price = price * (1 + sch.slippage_rate)
gross = fill_price * qty
commission = max(gross * sch.commission_rate, sch.commission_min)
transfer_fee = gross * sch.transfer_fee_rate
return fill_price, commission, transfer_fee
def sell_cost(price: float, qty: float, sch: CostSchedule = DEFAULT) -> tuple[float, float, float, float]:
"""卖出成本。返回 (成交价, 佣金, 过户费, 印花税)。"""
fill_price = price * (1 - sch.slippage_rate)
gross = fill_price * qty
commission = max(gross * sch.commission_rate, sch.commission_min)
transfer_fee = gross * sch.transfer_fee_rate
stamp_duty = gross * sch.stamp_duty_rate
return fill_price, commission, transfer_fee, stamp_duty

26
backend/app/config.py Normal file
View File

@@ -0,0 +1,26 @@
"""应用配置pydantic-settings。可由 .env / 环境变量覆盖。"""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_name: str = "Stock Backtest"
# 默认 SQLite 零配置;切 Postgres/TimescaleDB 只改这一行
database_url: str = "sqlite+aiosqlite:///./stock.db"
# 真实数据源
tushare_token: str = "" # Tushare Pro token主数据源
data_adjust: str = "qfq" # 复权qfq 前复权 / hfq 后复权 / "" 不复权
data_default_start: str = "20200101" # 默认拉取起点(约近 5 年)
# A股交易成本基准日 2026-08——做成可配置参数便于将来按生效日期版本化
stamp_duty_rate: float = 0.0005 # 印花税 0.05%单边卖出2023-08-28 减半)
transfer_fee_rate: float = 0.00001 # 过户费 0.001%沪深双边2022 调整)
commission_rate: float = 0.0001 # 佣金 万1含规费
commission_min: float = 5.0 # 最低 5 元
slippage_rate: float = 0.0005 # 滑点近似(按价格比例)
settings = Settings()

View File

View File

@@ -0,0 +1,54 @@
"""K 线周期聚合:日线 -> 周/月/年。
生产环境用 TimescaleDB Continuous Aggregates 在库里预物化(性能);
MVP 在应用层用 pandas resample 即可,逻辑等价、便于切换。
OHLCV 聚合规则:开=周期内首根开、高=最高、低=最低、收=末根收、量=求和。
"""
from __future__ import annotations
import pandas as pd
from ..domain import Bar
# pandas resample 规则(周一为周首;月/年以首日对齐)
_RULES = {"1w": "W-MON", "1M": "MS", "1y": "YS"}
# 各周期的"年交易日数"(用于夏普等指标的年化)
_BARS_PER_YEAR = {"1d": 252, "1w": 52, "1M": 12, "1y": 1}
def bars_per_year(timeframe: str) -> int:
return _BARS_PER_YEAR.get(timeframe, 252)
def resample_bars(bars: list[Bar], timeframe: str) -> list[Bar]:
"""把日线 bars 聚合为目标周期;日线或未知周期原样返回。"""
if not bars or timeframe in ("1d", "d", "day", "", None):
return bars
rule = _RULES.get(timeframe)
if rule is None:
return bars
df = pd.DataFrame(
[{"ts": b.ts, "open": b.open, "high": b.high, "low": b.low, "close": b.close, "volume": b.volume}
for b in bars]
).set_index("ts").sort_index()
agg = (
df.resample(rule)
.agg({"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
.dropna()
)
return [
Bar(
ts=ts.to_pydatetime(),
open=float(row["open"]),
high=float(row["high"]),
low=float(row["low"]),
close=float(row["close"]),
volume=float(row["volume"]),
)
for ts, row in agg.iterrows()
]

View File

@@ -0,0 +1,38 @@
"""AKShare 数据源(兜底/校验)。免费、无需 token。
默认不安装(依赖较重);如需启用:`uv add akshare`。
fetcher 在 Tushare 失败时会尝试本模块;未安装则该路径自动跳过。
"""
from __future__ import annotations
from datetime import datetime
from ..domain import Bar
from .symbols import plain_code
def fetch_daily(code: str, start: str = "20200101", end: str | None = None,
adjust: str = "qfq") -> list[Bar]:
import akshare as ak # 延迟导入
end = end or datetime.now().strftime("%Y%m%d")
symbol = plain_code(code)
adj_map = {"qfq": "qfq", "hfq": "hfq", "": "", None: ""}
df = ak.stock_zh_a_hist(
symbol=symbol, period="daily",
start_date=start, end_date=end, adjust=adj_map.get(adjust, ""),
)
if df is None or df.empty:
raise RuntimeError(f"AKShare 无数据: {symbol}")
bars: list[Bar] = []
for _, r in df.iterrows():
bars.append(
Bar(
ts=datetime.strptime(str(r["日期"]), "%Y-%m-%d"),
open=float(r["开盘"]), high=float(r["最高"]),
low=float(r["最低"]), close=float(r["收盘"]),
volume=float(r["成交量"]) * 100.0, # AKShare 成交量单位为手 -> 股
)
)
return bars

View File

@@ -0,0 +1,81 @@
"""数据编排拉取Tushare 主 -> AKShare 兜底)+ 本地缓存。
真实行情落库到 candles 表timeframe='1d'),回测统一从库读,与 DEMO 同路径。
"""
from __future__ import annotations
import asyncio
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..domain import Bar
from ..models import Candle
from . import akshare_provider, tushare_provider
DEFAULT_START = settings.data_default_start or "20200101"
def _providers(source: str):
"""按优先级返回 (名称, 同步拉取函数) 列表。"""
seq = []
if source in ("auto", "tushare") and settings.tushare_token:
seq.append(("tushare", tushare_provider.fetch_daily))
if source in ("auto", "akshare"):
seq.append(("akshare", akshare_provider.fetch_daily))
return seq
async def count_cached(session: AsyncSession, symbol: str) -> int:
res = await session.execute(
select(func.count()).select_from(Candle).where(
Candle.symbol == symbol, Candle.timeframe == "1d"
)
)
return int(res.scalar() or 0)
async def is_cached(session: AsyncSession, symbol: str) -> bool:
return await count_cached(session, symbol) > 0
async def sync_symbol(
session: AsyncSession,
code: str,
start: str | None = None,
end: str | None = None,
source: str = "auto",
force: bool = False,
) -> dict:
"""拉取并缓存某标的日线。已缓存且非 force 时直接返回缓存计数。"""
if not force and await is_cached(session, code):
return {"symbol": code, "bars": await count_cached(session, code), "source": "cache"}
start = start or DEFAULT_START
adjust = settings.data_adjust
errors: list[str] = []
bars: list[Bar] = []
used = None
for name, fn in _providers(source):
try:
# tushare/akshare 是同步网络 IO丢到线程池避免阻塞事件循环
bars = await asyncio.to_thread(fn, code, start, end, adjust)
used = name
break
except Exception as e: # noqa: BLE001
errors.append(f"{name}: {e}")
if not bars:
raise RuntimeError("所有数据源均失败 -> " + " | ".join(errors) if errors else "无可用数据源")
# 全量替换该标的日线(避免重复主键)
await session.execute(delete(Candle).where(Candle.symbol == code, Candle.timeframe == "1d"))
for b in bars:
session.add(
Candle(symbol=code, timeframe="1d", ts=b.ts, open=b.open, high=b.high,
low=b.low, close=b.close, volume=b.volume)
)
await session.commit()
return {"symbol": code, "bars": len(bars), "source": used}

View File

@@ -0,0 +1,34 @@
"""K 线数据访问(从库读)。
写入由 DataProvider 适配器负责阶段1 接 Tushare/AKShare
MVP 的数据由 synthetic.seed_if_empty 灌入。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Candle
async def get_candles(
session: AsyncSession,
symbol: str,
timeframe: str = "1d",
start: datetime | None = None,
end: datetime | None = None,
limit: int = 5000,
) -> list[Candle]:
stmt = select(Candle).where(
Candle.symbol == symbol,
Candle.timeframe == timeframe,
)
if start is not None:
stmt = stmt.where(Candle.ts >= start)
if end is not None:
stmt = stmt.where(Candle.ts <= end)
stmt = stmt.order_by(Candle.ts.asc()).limit(limit)
result = await session.execute(stmt)
return list(result.scalars().all())

View File

@@ -0,0 +1,25 @@
"""A 股代码归一化。支持 6 位纯数字或带交易所后缀000001 / 000001.SZ"""
from __future__ import annotations
def plain_code(code: str) -> str:
"""000001.SZ -> 000001"""
return code.strip().upper().split(".")[0]
def to_ts_code(code: str) -> str:
"""转 Tushare ts_code带交易所后缀"""
c = code.strip().upper()
if "." in c:
return c
c = plain_code(c)
# 沪市60xxxx 主板、68xxxx 科创、9xxxxx B 股
if c.startswith(("60", "68", "9")):
return c + ".SH"
# 深市00xxxx 主板/中小、30xxxx 创业、20xxxx B 股
if c.startswith(("00", "30", "20")):
return c + ".SZ"
# 北交所8xxxxx / 4xxxxx
if c.startswith(("8", "4")):
return c + ".BJ"
return c + ".SZ"

View File

@@ -0,0 +1,78 @@
"""合成数据MVP 零依赖可跑)。
生成随机游走 OHLCV灌入 DB。仅用于让回测链路在没有真实数据源时也能跑通演示。
阶段1 接 Tushare/AKShare 后,这里仅保留为"离线测试夹具"
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import numpy as np
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..domain import Bar
from ..models import Candle
def _trading_days(n: int) -> list[datetime]:
"""粗略生成 n 个工作日跳过周末节假日由阶段1 的交易日历服务处理)。"""
start = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=int(n * 1.6))
days: list[datetime] = []
d = start
while len(days) < n:
if d.weekday() < 5:
days.append(d.replace(hour=15, minute=0, second=0, microsecond=0))
d += timedelta(days=1)
return days
def generate_ohlcv(n: int = 500, seed: int = 42) -> list[Bar]:
"""随机游走 + A 股风格的价格区间5~30 元)。"""
rng = np.random.default_rng(seed)
rets = rng.normal(loc=0.0003, scale=0.018, size=n)
price = 10.0 * np.cumprod(1 + rets)
days = _trading_days(n)
bars: list[Bar] = []
for i in range(n):
close = float(price[i])
op = close * (1 + rng.normal(0, 0.005))
hi = max(op, close) * (1 + abs(rng.normal(0, 0.006)))
lo = min(op, close) * (1 - abs(rng.normal(0, 0.006)))
vol = float(rng.integers(1_000_000, 10_000_000))
bars.append(
Bar(
ts=days[i],
open=round(op, 2),
high=round(hi, 2),
low=round(lo, 2),
close=round(close, 2),
volume=vol,
)
)
return bars
async def seed_if_empty(session: AsyncSession, symbol: str = "DEMO", n: int = 500) -> None:
"""若库中无该 symbol 数据,则灌入合成数据。"""
existing = await session.execute(
select(Candle.id).where(Candle.symbol == symbol).limit(1)
)
if existing.scalars().first() is not None:
return
bars = generate_ohlcv(n=n)
for b in bars:
session.add(
Candle(
symbol=symbol,
timeframe="1d",
ts=b.ts,
open=b.open,
high=b.high,
low=b.low,
close=b.close,
volume=b.volume,
)
)
await session.commit()

View File

@@ -0,0 +1,51 @@
"""Tushare 数据源(主)。日线 + 前复权。
token 从 settings.tushare_token 读取(.env。免费版 pro.daily 与 ts.pro_bar 实测可用。
"""
from __future__ import annotations
from datetime import datetime
from ..config import settings
from ..domain import Bar
from .symbols import to_ts_code
def _parse(date_str: str) -> datetime:
return datetime.strptime(str(date_str), "%Y%m%d")
def fetch_daily(code: str, start: str = "20200101", end: str | None = None,
adjust: str = "qfq") -> list[Bar]:
import tushare as ts # 延迟导入:未装/无 token 时 DEMO 仍可用
if not settings.tushare_token:
raise RuntimeError("未配置 TUSHARE_TOKEN")
ts.set_token(settings.tushare_token)
pro = ts.pro_api()
ts_code = to_ts_code(code)
end = end or datetime.now().strftime("%Y%m%d")
# 优先 pro_bar含复权积分不足则退化为 pro.daily不复权
df = None
try:
df = ts.pro_bar(ts_code=ts_code, adj=adjust, start_date=start, end_date=end, freq="D")
except Exception:
df = None
if df is None or df.empty:
df = pro.daily(ts_code=ts_code, start_date=start, end_date=end)
if df is None or df.empty:
raise RuntimeError(f"Tushare 无数据: {ts_code}")
df = df.sort_values("trade_date")
bars: list[Bar] = []
for _, r in df.iterrows():
bars.append(
Bar(
ts=_parse(r["trade_date"]),
open=float(r["open"]), high=float(r["high"]),
low=float(r["low"]), close=float(r["close"]),
volume=float(r["vol"]) * 100.0, # Tushare vol 单位为手 -> 股
)
)
return bars

20
backend/app/db.py Normal file
View File

@@ -0,0 +1,20 @@
"""async SQLAlchemy 引擎与会话。"""
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from .config import settings
class Base(DeclarativeBase):
"""所有 ORM 模型的基类。"""
# echo=False生产环境可用连接池参数调优
engine = create_async_engine(settings.database_url, echo=False, future=True)
async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async def get_session() -> AsyncSession:
"""FastAPI 依赖:提供一个事务会话。"""
async with async_session() as session:
yield session

77
backend/app/domain.py Normal file
View File

@@ -0,0 +1,77 @@
"""领域模型契约(单一事实源的载体)。
MVP 第一周必须定稿的核心类型。回测引擎、指标、API、(未来的)前端类型化客户端
都基于这些契约——保证"图表/回测/实盘"用同一套语义。
注意:回测/图表的指标值由后端唯一计算app.indicators前端不另算。
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
class Side(str, Enum):
BUY = "buy"
SELL = "sell"
class Timeframe(str, Enum):
"""K 线周期。分钟级已预留(用户选了分钟级回测)。"""
M1 = "1m"
M5 = "5m"
M15 = "15m"
M30 = "30m"
H1 = "1h"
D1 = "1d"
W1 = "1w"
@dataclass(frozen=True)
class Bar:
"""一根 K 线OHLCV + 时间戳)。复权标识后续扩展。"""
ts: datetime
open: float
high: float
low: float
close: float
volume: float
@dataclass(frozen=True)
class Signal:
"""策略产生的交易信号(用于在图上标注买卖点)。"""
ts: datetime
side: Side
price: float
strength: float = 1.0
@dataclass(frozen=True)
class Fill:
"""一笔成交(含费用明细)。回测中由 PaperBroker 产生。"""
ts: datetime
side: Side
price: float
qty: float
commission: float = 0.0
stamp_duty: float = 0.0 # 印花税(仅卖出)
transfer_fee: float = 0.0 # 过户费(双边)
@property
def total_cost(self) -> float:
return self.commission + self.stamp_duty + self.transfer_fee
@dataclass
class Position:
"""持仓状态。T+1locked_qty 为当日买入、次日才可卖的部分。"""
symbol: str = ""
holdings: float = 0.0 # 可卖数量
locked: float = 0.0 # 当日买入T+1 锁定)
avg_price: float = 0.0
@property
def qty(self) -> float:
return self.holdings + self.locked

56
backend/app/indicators.py Normal file
View File

@@ -0,0 +1,56 @@
"""技术指标(单一事实源)。
MVP 用纯 pandas/numpy 实现,避免 Windows 上 TA-Lib C 库的安装痛点。
算法正确MACD = 快慢 EMA 之差接口稳定阶段1 在 Linux/Docker 上可换 TA-Lib
只需保持函数签名(输入 close Series输出指标上层无感。
"""
from __future__ import annotations
import numpy as np
import pandas as pd
def ema(series: pd.Series, span: int) -> pd.Series:
"""指数移动平均adjust=False与 TA-Lib 默认一致)。"""
return series.ewm(span=span, adjust=False).mean()
def macd(close: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9) -> pd.DataFrame:
"""MACD返回 DataFrame[DIF, DEA, HIST]。"""
dif = ema(close, fast) - ema(close, slow)
dea = ema(dif, signal)
hist = (dif - dea) * 2 # A股惯例 MACD 柱 = 2*(DIF-DEA)
return pd.DataFrame({"macd": dif, "signal": dea, "hist": hist})
def rsi(close: pd.Series, period: int = 14) -> pd.Series:
"""RSIWilder 平滑)。"""
delta = close.diff()
gain = delta.clip(lower=0.0)
loss = -delta.clip(upper=0.0)
avg_gain = gain.ewm(alpha=1 / period, adjust=False).mean()
avg_loss = loss.ewm(alpha=1 / period, adjust=False).mean()
rs = avg_gain / avg_loss.replace(0, np.nan)
return 100 - (100 / (1 + rs))
def kdj(high: pd.Series, low: pd.Series, close: pd.Series,
n: int = 9, m1: int = 3, m2: int = 3) -> pd.DataFrame:
"""KDJA股常用RSV -> K -> D -> J"""
low_n = low.rolling(n, min_periods=1).min()
high_n = high.rolling(n, min_periods=1).max()
rsv = (close - low_n) / (high_n - low_n).replace(0, np.nan) * 100
k = rsv.ewm(alpha=1 / m1, adjust=False).mean()
d = k.ewm(alpha=1 / m2, adjust=False).mean()
j = 3 * k - 2 * d
return pd.DataFrame({"k": k, "d": d, "j": j})
def bollinger(close: pd.Series, period: int = 20, std: float = 2.0) -> pd.DataFrame:
ma = close.rolling(period, min_periods=1).mean()
sd = close.rolling(period, min_periods=1).std(ddof=0)
return pd.DataFrame({"mid": ma, "upper": ma + std * sd, "lower": ma - std * sd})
def ma(close: pd.Series, period: int) -> pd.Series:
return close.rolling(period, min_periods=1).mean()

43
backend/app/main.py Normal file
View File

@@ -0,0 +1,43 @@
"""FastAPI 入口。
启动时自动建表MVP 用 create_all阶段1 切 Alembic 迁移,含 TimescaleDB hypertable
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .api import router
from .db import Base, engine
from . import models # noqa: F401 —— 注册 ORM 到 Base.metadata
@asynccontextmanager
async def lifespan(app: FastAPI):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
app = FastAPI(
title="Stock Backtest",
description="历史回测 + 回放式模拟平台A 股为主,不做实盘)",
version="0.1.0",
lifespan=lifespan,
)
# 开发期允许前端 dev server 跨域;上线收窄 origins
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(router)
@app.get("/")
async def root() -> dict:
return {"name": "Stock Backtest API", "docs": "/docs"}

57
backend/app/models.py Normal file
View File

@@ -0,0 +1,57 @@
"""ORM 模型。
Candle 表设计与 TimescaleDB hypertable 完全兼容:将来在目标 PG 库执行
SELECT create_hypertable('candles', 'ts');
即可升级为时序表 + Continuous Aggregates 多周期预聚合,无需改表结构。
"""
from datetime import datetime
from sqlalchemy import DateTime, Float, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from .db import Base
def _utcnow() -> datetime:
# naive UTC避免 SQLite 存储时区带来的麻烦
from datetime import timezone
return datetime.now(timezone.utc).replace(tzinfo=None)
class Candle(Base):
__tablename__ = "candles"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
symbol: Mapped[str] = mapped_column(String(16), index=True)
timeframe: Mapped[str] = mapped_column(String(4), default="1d", index=True)
ts: Mapped[datetime] = mapped_column(DateTime, index=True) # bar 开始时间
open: Mapped[float] = mapped_column(Float)
high: Mapped[float] = mapped_column(Float)
low: Mapped[float] = mapped_column(Float)
close: Mapped[float] = mapped_column(Float)
volume: Mapped[float] = mapped_column(Float)
__table_args__ = (
UniqueConstraint("symbol", "timeframe", "ts", name="uq_candle_sym_tf_ts"),
)
class BacktestRun(Base):
"""回测运行注册表(可复现/可审计/可回归对比的基础)。
完整版应记录 策略版本 + 参数快照 + 数据快照(复权/数据源/库版本)+ 环境指纹 + 结果指纹。
MVP 先落关键字段,结构就位。
"""
__tablename__ = "backtest_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow)
symbol: Mapped[str] = mapped_column(String(16))
strategy: Mapped[str] = mapped_column(String(64))
timeframe: Mapped[str] = mapped_column(String(4), default="1d")
params_json: Mapped[str] = mapped_column(String, default="{}")
initial_cash: Mapped[float] = mapped_column(Float, default=100000.0)
total_return: Mapped[float] = mapped_column(Float, default=0.0)
max_drawdown: Mapped[float] = mapped_column(Float, default=0.0)
sharpe: Mapped[float] = mapped_column(Float, default=0.0)
num_trades: Mapped[int] = mapped_column(Integer, default=0)

87
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,87 @@
"""Pydantic DTO —— 这就是 OpenAPI 契约(前端据此生成类型化客户端)。
契约先于业务锁定:字段一旦定下,前端可并行开发,后端实现改动不影响前端。
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
# ---------- Candle ----------
class CandleOut(BaseModel):
ts: datetime
open: float
high: float
low: float
close: float
volume: float
model_config = {"from_attributes": True}
# ---------- Backtest ----------
class BacktestRequest(BaseModel):
symbol: str = "DEMO"
timeframe: str = "1d"
strategy: str = "macd_cross" # macd_cross | ma_cross | single_ma
params: dict[str, float] = Field(default_factory=dict) # 各策略参数
initial_cash: float = 1000000.0
fast_mode: bool = False # True => 关闭 T+1/费用,交互试探
start: datetime | None = None
end: datetime | None = None
class SignalOut(BaseModel):
ts: datetime
side: str # "buy" | "sell"
price: float
qty: float
class EquityPoint(BaseModel):
ts: datetime
value: float
class IndicatorOut(BaseModel):
strategy: str
data: dict[str, list[float | None]] = {} # 列名 -> 序列MACD: macd/signal/hist均线: fast/slow 或 ma
class MetricsOut(BaseModel):
total_return: float
max_drawdown: float
sharpe: float
volatility: float
num_trades: int = 0
win_rate: float = 0.0
class BacktestResponse(BaseModel):
symbol: str
timeframe: str
strategy: str
candles: list[CandleOut]
indicators: IndicatorOut
signals: list[SignalOut]
equity: list[EquityPoint]
metrics: MetricsOut
final_cash: float
final_position: float
initial_cash: float
class SyncRequest(BaseModel):
symbol: str
start: str | None = None # YYYYMMDD
end: str | None = None
source: str = "auto" # auto | tushare | akshare
force: bool = False # True => 忽略缓存重新拉取
class SyncResponse(BaseModel):
symbol: str
bars: int
source: str

21
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,21 @@
[project]
name = "stock-backend"
version = "0.1.0"
description = "Stock backtest platform backend (FastAPI + self-built backtest engine)"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"pydantic>=2.7",
"pydantic-settings>=2.3",
"sqlalchemy>=2.0",
"aiosqlite>=0.20",
"asyncpg>=0.29", # PostgreSQL 异步驱动(连你已有的 Postgres / TimescaleDB
"numpy>=1.26",
"pandas>=2.2",
"tushare>=1.4",
]
[tool.uv]
# 应用型项目(非库):不把自身打包安装,只管理依赖到 .venv
package = false

54
backend/smoke_test.py Normal file
View File

@@ -0,0 +1,54 @@
"""开发自检脚本:跑一遍 /health 与 /backtest打印结果。
用 FastAPI TestClient无需起服务器同进程验证全链路
用法: uv run --with httpx --directory backend python smoke_test.py
"""
import json
from fastapi.testclient import TestClient
from app.main import app
# 必须用 withlifespan建表只在进入上下文时执行
with TestClient(app) as c:
r = c.get("/api/health")
print("== /api/health ==", r.status_code, r.json())
r = c.post(
"/api/backtest",
json={
"symbol": "DEMO",
"strategy": "macd_cross",
"params": {"fast": 12, "slow": 26, "signal": 9},
"initial_cash": 100000.0,
"fast_mode": False,
},
)
print("== /api/backtest ==", r.status_code)
if r.status_code != 200:
print("ERROR:", r.text)
raise SystemExit(1)
d = r.json()
print("candles :", len(d["candles"]))
print("signals :", len(d["signals"]), "(买卖点)")
print("equity pts :", len(d["equity"]))
print("final_cash :", round(d["final_cash"], 2))
print("final_pos :", d["final_position"])
print("metrics :", json.dumps(d["metrics"], ensure_ascii=False, indent=2))
print("first signal :", d["signals"][0] if d["signals"] else None)
assert len(d["candles"]) > 100
# 周期聚合:周线 K 线数应明显少于日线
rw = c.post(
"/api/backtest",
json={"symbol": "DEMO", "timeframe": "1w", "strategy": "macd_cross",
"params": {"fast": 12, "slow": 26, "signal": 9}, "initial_cash": 100000.0},
)
wd = rw.json()
print("== weekly ==", rw.status_code, "candles:", len(wd["candles"]), "vs daily", len(d["candles"]))
assert rw.status_code == 200
assert len(wd["candles"]) < len(d["candles"])
print("\n✅ 后端全链路自检通过(含周期聚合)")

1159
backend/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN" class="app-dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>股市回测平台</title>
<style>
html, body { background-color: #0b0e14; margin: 0; }
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

28
frontend/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "stock-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@primeuix/themes": "^1.0.0",
"echarts": "^6.0.0",
"lightweight-charts": "^5.0.0",
"pinia": "^2.3.0",
"primeicons": "^7.0.0",
"primevue": "^5.0.0",
"vue": "^3.5.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@vitejs/plugin-vue": "^5.2.0",
"typescript": "^5.6.0",
"vite": "^6.0.0",
"vue-tsc": "^2.1.0"
}
}

1204
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
allowBuilds:
esbuild: true
vue-demi: true

13
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import BacktestView from '@/views/BacktestView.vue';
</script>
<template>
<div class="app-shell">
<header class="app-header">
<h1>股市回测平台</h1>
<span class="sub">历史回测 · 回放式模拟 · A股红涨绿跌</span>
</header>
<BacktestView />
</div>
</template>

View File

@@ -0,0 +1,29 @@
import type { BacktestRequest, BacktestResponse, SyncRequest, SyncResponse } from './types';
// dev 用 Vite 代理(/api -> :8000生产构建设 VITE_API_BASE 指向后端地址。
const BASE = import.meta.env.VITE_API_BASE ?? '';
export async function postBacktest(req: BacktestRequest): Promise<BacktestResponse> {
const res = await fetch(`${BASE}/api/backtest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) {
throw new Error(`回测请求失败 (HTTP ${res.status}): ${await res.text()}`);
}
return (await res.json()) as BacktestResponse;
}
export async function syncData(req: SyncRequest): Promise<SyncResponse> {
const res = await fetch(`${BASE}/api/data/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) {
throw new Error(`数据拉取失败 (HTTP ${res.status}): ${await res.text()}`);
}
return (await res.json()) as SyncResponse;
}

76
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,76 @@
// 与后端 app/schemas.py 一一对应的 TypeScript 类型OpenAPI 契约的前端镜像)。
// 后续可由 openapi-typescript-codegen 自动生成MVP 先手写保持同步。
export interface Candle {
ts: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
}
export interface BacktestRequest {
symbol: string;
timeframe: string;
strategy: string;
params: Record<string, number>;
initial_cash: number;
fast_mode: boolean;
start?: string;
end?: string;
}
export interface SignalOut {
ts: string;
side: 'buy' | 'sell';
price: number;
qty: number;
}
export interface EquityPoint {
ts: string;
value: number;
}
export interface IndicatorOut {
strategy: string;
data: Record<string, (number | null)[]>;
}
export interface MetricsOut {
total_return: number;
max_drawdown: number;
sharpe: number;
volatility: number;
num_trades: number;
win_rate: number;
}
export interface BacktestResponse {
symbol: string;
timeframe: string;
strategy: string;
candles: Candle[];
indicators: IndicatorOut;
signals: SignalOut[];
equity: EquityPoint[];
metrics: MetricsOut;
final_cash: number;
final_position: number;
initial_cash: number;
}
export interface SyncRequest {
symbol: string;
start?: string;
end?: string;
source?: string; // auto | tushare | akshare
force?: boolean;
}
export interface SyncResponse {
symbol: string;
bars: number;
source: string;
}

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
import Select from 'primevue/select';
import ToggleSwitch from 'primevue/toggleswitch';
import Button from 'primevue/button';
import type { BacktestRequest } from '@/api/types';
defineProps<{ loading: boolean }>();
const emit = defineEmits<{ run: [req: BacktestRequest] }>();
interface ParamDef { k: string; label: string; def: number; }
const STRATS: { id: string; label: string; params: ParamDef[] }[] = [
{ id: 'ma_cross', label: '双均线交叉', params: [{ k: 'fast', label: '快均线', def: 5 }, { k: 'slow', label: '慢均线', def: 20 }] },
{ id: 'single_ma', label: '单均线(价格穿越)', params: [{ k: 'period', label: '均线周期', def: 20 }] },
{ id: 'macd_cross', label: 'MACD 金叉死叉', params: [{ k: 'fast', label: '快线', def: 12 }, { k: 'slow', label: '慢线', def: 26 }, { k: 'signal', label: '信号线', def: 9 }] },
];
const stratOptions = STRATS.map((s) => ({ label: s.label, value: s.id }));
const form = reactive({
symbol: '000001',
timeframe: '1d',
strategy: 'ma_cross',
params: {} as Record<string, number>,
initial_cash: 1000000,
fast_mode: false,
});
function applyDefaults(stratId: string) {
const s = STRATS.find((x) => x.id === stratId)!;
form.params = Object.fromEntries(s.params.map((p) => [p.k, p.def]));
}
watch(() => form.strategy, (id) => applyDefaults(id));
applyDefaults(form.strategy);
const currentParams = computed(() => STRATS.find((x) => x.id === form.strategy)!.params);
function onRun() {
emit('run', {
symbol: form.symbol,
timeframe: form.timeframe,
strategy: form.strategy,
params: { ...form.params },
initial_cash: form.initial_cash,
fast_mode: form.fast_mode,
} satisfies BacktestRequest);
}
</script>
<template>
<div class="toolbar">
<div class="field">
<label>策略</label>
<Select v-model="form.strategy" :options="stratOptions" optionLabel="label" optionValue="value" size="small" style="width: 170px" />
</div>
<div class="field" v-for="p in currentParams" :key="p.k">
<label>{{ p.label }}</label>
<InputNumber v-model="form.params[p.k]" :min="1" :max="250" size="small" inputStyle="width:64px" />
</div>
<div class="field">
<label>周期</label>
<Select v-model="form.timeframe" :options="[{label:'日线',value:'1d'},{label:'周线',value:'1w'},{label:'月线',value:'1M'},{label:'年线',value:'1y'}]" optionLabel="label" optionValue="value" size="small" style="width: 100px" />
</div>
<div class="field">
<label>标的</label>
<InputText v-model="form.symbol" size="small" style="width: 110px" placeholder="如 000001" />
</div>
<div class="field">
<label>初始资金</label>
<InputNumber v-model="form.initial_cash" :min="1000" :step="100000" size="small" mode="currency" currency="CNY" inputStyle="width:130px" />
</div>
<div class="field">
<label>fast 模式</label>
<ToggleSwitch v-model="form.fast_mode" />
</div>
<div class="spacer"></div>
<Button label="开始回测" icon="pi pi-play" :loading="loading" size="small" @click="onRun" />
</div>
<div class="quick">
<span class="qlabel">快捷</span>
<button v-for="q in [{code:'000001',name:'平安银行'},{code:'600519',name:'贵州茅台'},{code:'000858',name:'五粮液'},{code:'601318',name:'中国平安'},{code:'DEMO',name:'合成数据'}]" :key="q.code" class="qchip" :class="{ active: form.symbol === q.code }" type="button" @click="form.symbol = q.code">
{{ q.code }} <span class="qname">{{ q.name }}</span>
</button>
</div>
<div class="hint">
策略可选 双均线 / 单均线 / MACD参数随策略自适应<code>DEMO</code> 为合成数据其余为真实 A 首次自动经 Tushare 拉取并缓存
</div>
</template>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import * as echarts from 'echarts';
import type { EquityPoint } from '@/api/types';
const props = defineProps<{ equity: EquityPoint[] }>();
const container = ref<HTMLDivElement | null>(null);
let chart: echarts.ECharts | null = null;
function buildOption() {
const dates = props.equity.map(p => p.ts.slice(0, 10));
const vals = props.equity.map(p => Number(p.value.toFixed(2)));
const first = vals.length ? vals[0] : 0;
const last = vals.length ? vals[vals.length - 1] : 0;
const lineColor = last >= first ? '#f6465d' : '#0ecb81'; // A股盈利红、亏损绿
return {
backgroundColor: 'transparent',
grid: { left: 64, right: 18, top: 14, bottom: 26 },
tooltip: {
trigger: 'axis' as const,
backgroundColor: '#1b2230', borderColor: 'rgba(255,255,255,0.1)', borderWidth: 1,
textStyle: { color: '#e6edf3' },
valueFormatter: (v: number) => (v ?? 0).toLocaleString(undefined, { maximumFractionDigits: 0 }),
},
xAxis: {
type: 'category', data: dates, boundaryGap: false,
axisLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } },
axisLabel: { color: '#5c6675' }, axisTick: { show: false },
},
yAxis: {
type: 'value', scale: true,
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.05)' } },
axisLabel: { color: '#5c6675' },
},
series: [{
type: 'line', data: vals, symbol: 'none', smooth: false,
lineStyle: { color: lineColor, width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: lineColor + '40' },
{ offset: 1, color: lineColor + '00' },
]),
},
}],
};
}
onMounted(() => {
if (container.value) chart = echarts.init(container.value, undefined, { renderer: 'canvas' });
chart?.setOption(buildOption());
});
onBeforeUnmount(() => { chart?.dispose(); chart = null; });
watch(() => props.equity, () => chart?.setOption(buildOption(), true), { deep: true });
</script>
<template>
<div ref="container" class="chart-equity"></div>
</template>

View File

@@ -0,0 +1,237 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import {
createChart, CandlestickSeries, HistogramSeries, LineSeries,
createSeriesMarkers, CrosshairMode, LineStyle,
type IChartApi, type ISeriesApi, type ISeriesMarkersPluginApi, type SeriesMarker, type Time,
} from 'lightweight-charts';
import type { Candle, IndicatorOut, SignalOut } from '@/api/types';
const props = defineProps<{
candles: Candle[];
indicators: IndicatorOut;
signals: SignalOut[];
symbol?: string;
timeframe?: string;
strategy?: string;
}>();
const TF_LABEL: Record<string, string> = { '1d': '日线', '1w': '周线', '1M': '月线', '1y': '年线' };
const STRAT_LABEL: Record<string, string> = { macd_cross: 'MACD', ma_cross: '双均线', single_ma: '单均线' };
const WD = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const UP = '#f6465d';
const DOWN = '#0ecb81';
const DIF = '#5b8ff9';
const DEA = '#f6bd16';
const MA_COLORS = ['#5b8ff9', '#f6bd16', '#c084fc', '#34d399'];
const IND_LABEL: Record<string, string> = { macd: 'DIF', signal: 'DEA', hist: 'MACD', fast: '快线', slow: '慢线', ma: '均线' };
const IND_COLOR: Record<string, string> = {
macd: DIF, signal: DEA, hist: '#9aa4b2', fast: DIF, slow: DEA, ma: '#c084fc',
};
const container = ref<HTMLDivElement | null>(null);
let chart: IChartApi | null = null;
let candleSeries: ISeriesApi<'Candlestick'> | null = null;
let volumeSeries: ISeriesApi<'Histogram'> | null = null;
let difSeries: ISeriesApi<'Line'> | null = null;
let deaSeries: ISeriesApi<'Line'> | null = null;
let histSeries: ISeriesApi<'Histogram'> | null = null;
let maSeriesArr: { key: string; series: ISeriesApi<'Line'> }[] = [];
let markersApi: ISeriesMarkersPluginApi<Time> | null = null;
interface DayRec {
open: number; high: number; low: number; close: number; volume: number;
prevClose: number | null; ind: Record<string, number | null>;
}
let byTime: Record<string, DayRec> = {};
const tip = ref<{ visible: boolean; x: number; y: number }>({ visible: false, x: 0, y: 0 });
const tipData = ref<ReturnType<typeof buildTip> | null>(null);
const isMACD = computed(() => props.strategy === 'macd_cross' || 'hist' in (props.indicators.data ?? {}));
const legendChips = computed(() => {
const keys = Object.keys(props.indicators.data ?? {});
if (isMACD.value) return [{ label: 'DIF', color: DIF }, { label: 'DEA', color: DEA }];
return keys.map((k, i) => ({ label: IND_LABEL[k] ?? k, color: MA_COLORS[i % MA_COLORS.length] }));
});
const t = (ts: string): Time => ts.slice(0, 10) as Time;
function timeKey(time: Time): string {
if (typeof time === 'string') return time.slice(0, 10);
const bd = time as { year: number; month: number; day: number };
if (bd && typeof bd === 'object' && 'year' in bd) {
return `${bd.year}-${String(bd.month).padStart(2, '0')}-${String(bd.day).padStart(2, '0')}`;
}
return String(time);
}
const fmt2 = (v: number | null) => (v == null ? '—' : v.toFixed(2));
const fmt3 = (v: number | null) => (v == null ? '—' : v.toFixed(3));
function weekdayOf(s: string) { return WD[new Date(s + 'T00:00:00').getDay()] ?? ''; }
function hexA(hex: string, a: number) {
const n = hex.replace('#', '');
return `rgba(${parseInt(n.slice(0, 2), 16)},${parseInt(n.slice(2, 4), 16)},${parseInt(n.slice(4, 6), 16)},${a})`;
}
function toLine(arr: (number | null)[], c: Candle[]) {
return arr.map((v, i) => (v == null ? { time: t(c[i].ts) } : { time: t(c[i].ts), value: v }));
}
function buildTip(rec: DayRec, key: string) {
const prev = rec.prevClose ?? rec.open;
const change = rec.close - prev;
return {
date: key, weekday: weekdayOf(key),
open: rec.open, high: rec.high, low: rec.low, close: rec.close,
change, chgPct: prev ? (change / prev) * 100 : 0,
amplitude: prev ? ((rec.high - rec.low) / prev) * 100 : 0,
volLots: Math.round(rec.volume / 100),
ind: rec.ind, up: change >= 0,
};
}
function build() {
if (!container.value) return;
const ch = createChart(container.value, {
autoSize: true,
layout: { background: { color: 'transparent' }, textColor: '#9aa4b2', fontSize: 11, attributionLogo: false },
grid: { vertLines: { color: 'rgba(255,255,255,0.04)' }, horzLines: { color: 'rgba(255,255,255,0.04)' } },
crosshair: {
mode: CrosshairMode.Normal,
vertLine: { color: 'rgba(255,255,255,0.25)', width: 1, style: LineStyle.Dashed, labelBackgroundColor: '#2a2e39' },
horzLine: { color: 'rgba(255,255,255,0.25)', width: 1, style: LineStyle.Dashed, labelBackgroundColor: '#2a2e39' },
},
rightPriceScale: { borderColor: 'rgba(255,255,255,0.08)', scaleMargins: { top: 0.08, bottom: 0.28 } },
timeScale: { borderColor: 'rgba(255,255,255,0.08)', rightOffset: 6, barSpacing: 8 },
});
chart = ch;
candleSeries = ch.addSeries(CandlestickSeries, {
upColor: UP, downColor: DOWN, borderUpColor: UP, borderDownColor: DOWN, wickUpColor: UP, wickDownColor: DOWN,
priceFormat: { type: 'price', precision: 2, minMove: 0.01 },
}, 0);
volumeSeries = ch.addSeries(HistogramSeries, { priceFormat: { type: 'volume' }, priceScaleId: 'vol' }, 0);
volumeSeries.priceScale().applyOptions({ scaleMargins: { top: 0.82, bottom: 0 } });
const keys = Object.keys(props.indicators.data ?? {});
if (isMACD.value && props.indicators.data?.hist) {
// MACD 进副图(震荡指标,独立刻度)
difSeries = ch.addSeries(LineSeries, { color: DIF, lineWidth: 2, priceScaleId: 'macd', priceLineVisible: false, lastValueVisible: true }, 1);
deaSeries = ch.addSeries(LineSeries, { color: DEA, lineWidth: 2, priceScaleId: 'macd', priceLineVisible: false, lastValueVisible: true }, 1);
histSeries = ch.addSeries(HistogramSeries, { priceScaleId: 'macd', priceLineVisible: false, lastValueVisible: false }, 1);
try { ch.panes()[1]?.setHeight(140); } catch { /* pane 未就绪 */ }
} else {
// 均线叠加在主图(价格刻度,与 K 线同坐标系)
maSeriesArr = keys.map((k, i) => ({
key: k,
series: ch.addSeries(LineSeries, {
color: MA_COLORS[i % MA_COLORS.length], lineWidth: 1, priceLineVisible: false,
lastValueVisible: false, crosshairMarkerVisible: true,
}, 0),
}));
}
markersApi = createSeriesMarkers(candleSeries, []);
ch.subscribeCrosshairMove((param) => {
const pt = param.point;
if (!param.time || !pt || !container.value) { tip.value.visible = false; return; }
const key = timeKey(param.time);
const rec = byTime[key];
if (!rec) { tip.value.visible = false; return; }
tipData.value = buildTip(rec, key);
const W = container.value.clientWidth, H = container.value.clientHeight;
const TW = 220, TH = 188;
let x = pt.x + 16; if (x + TW > W) x = pt.x - TW - 16; if (x < 4) x = 4;
let y = pt.y + 16; if (y + TH > H) y = H - TH - 6; if (y < 4) y = 4;
tip.value = { visible: true, x, y };
});
fillData();
ch.timeScale().fitContent();
}
function fillData() {
if (!chart || !candleSeries) return;
const c = props.candles;
const keys = Object.keys(props.indicators.data ?? {});
byTime = {};
c.forEach((k, i) => {
const ind: Record<string, number | null> = {};
keys.forEach((key) => { ind[key] = props.indicators.data[key]?.[i] ?? null; });
byTime[t(k.ts) as unknown as string] = {
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
prevClose: i > 0 ? c[i - 1].close : null, ind,
};
});
candleSeries.setData(c.map(k => ({ time: t(k.ts), open: k.open, high: k.high, low: k.low, close: k.close })));
volumeSeries?.setData(c.map(k => ({
time: t(k.ts), value: k.volume, color: k.close >= k.open ? hexA(UP, 0.5) : hexA(DOWN, 0.5),
})));
if (difSeries && deaSeries && histSeries && props.indicators.data?.hist) {
difSeries.setData(toLine(props.indicators.data.macd ?? [], c));
deaSeries.setData(toLine(props.indicators.data.signal ?? [], c));
histSeries.setData((props.indicators.data.hist ?? []).map((v, i) => ({
time: t(c[i].ts), value: v ?? 0, color: (v ?? 0) >= 0 ? hexA(UP, 0.6) : hexA(DOWN, 0.6),
})));
} else {
maSeriesArr.forEach((m) => m.series.setData(toLine(props.indicators.data[m.key] ?? [], c)));
}
const markers: SeriesMarker<Time>[] = props.signals.map(s => ({
time: t(s.ts),
position: s.side === 'buy' ? 'belowBar' : 'aboveBar',
color: s.side === 'buy' ? UP : DOWN,
shape: s.side === 'buy' ? 'arrowUp' : 'arrowDown',
text: s.side === 'buy' ? 'B' : 'S',
}));
markersApi?.setMarkers(markers);
}
function teardown() {
chart?.remove();
chart = null; candleSeries = null; volumeSeries = null;
difSeries = deaSeries = histSeries = null; maSeriesArr = []; markersApi = null;
}
onMounted(build);
onBeforeUnmount(teardown);
watch(() => [props.candles, props.indicators, props.signals, props.strategy], () => { teardown(); build(); }, { deep: true });
</script>
<template>
<div class="kline-wrap">
<div class="lc-legend">
<span class="sym">{{ symbol ?? '—' }}<small>{{ TF_LABEL[timeframe ?? '1d'] ?? timeframe }} · {{ STRAT_LABEL[strategy ?? 'macd_cross'] ?? strategy }}</small></span>
<span v-for="(chip, i) in legendChips" :key="i" class="chip"><i :style="{ background: chip.color }"></i>{{ chip.label }}</span>
</div>
<div v-if="tip.visible && tipData" class="lc-tooltip" :style="{ left: tip.x + 'px', top: tip.y + 'px' }">
<div class="tt-date">{{ tipData.date }} <span class="tt-wd">{{ tipData.weekday }}</span></div>
<div class="tt-grid">
<div> <b :class="tipData.up ? 'pos' : 'neg'">{{ fmt2(tipData.open) }}</b></div>
<div> <b class="pos">{{ fmt2(tipData.high) }}</b></div>
<div> <b class="neg">{{ fmt2(tipData.low) }}</b></div>
<div> <b :class="tipData.up ? 'pos' : 'neg'">{{ fmt2(tipData.close) }}</b></div>
</div>
<div class="tt-row">
涨跌 <b :class="tipData.up ? 'pos' : 'neg'">{{ tipData.change >= 0 ? '+' : '' }}{{ fmt2(tipData.change) }}</b>
· 涨幅 <b :class="tipData.up ? 'pos' : 'neg'">{{ tipData.chgPct.toFixed(2) }}%</b>
</div>
<div class="tt-row">振幅 {{ tipData.amplitude.toFixed(2) }}% · {{ tipData.volLots.toLocaleString() }} </div>
<div class="tt-sep"></div>
<div class="tt-ind">
<span v-for="(v, k) in tipData.ind" :key="k" :style="{ color: IND_COLOR[k] ?? '#9aa4b2' }">
{{ IND_LABEL[k] ?? k }} {{ fmt3(v) }}
</span>
</div>
</div>
<div ref="container" class="chart-kline"></div>
</div>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { MetricsOut } from '@/api/types';
defineProps<{ metrics: MetricsOut }>();
const pct = (x: number) => `${(x * 100).toFixed(2)}%`;
const num = (x: number) => x.toFixed(2);
const sign = (x: number) => (x >= 0 ? 'pos' : 'neg'); // A股正=红、负=绿
</script>
<template>
<div class="stats">
<div class="stat"><span class="label">总收益</span><span class="value" :class="sign(metrics.total_return)">{{ pct(metrics.total_return) }}</span></div>
<div class="stat"><span class="label">最大回撤</span><span class="value neg">{{ pct(metrics.max_drawdown) }}</span></div>
<div class="stat"><span class="label">夏普</span><span class="value" :class="sign(metrics.sharpe)">{{ num(metrics.sharpe) }}</span></div>
<div class="stat"><span class="label">年化波动</span><span class="value">{{ pct(metrics.volatility) }}</span></div>
<div class="stat"><span class="label">胜率</span><span class="value">{{ pct(metrics.win_rate) }}</span></div>
<div class="stat"><span class="label">交易数</span><span class="value">{{ metrics.num_trades }}</span></div>
</div>
</template>

14
frontend/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
export default component;
}
interface ImportMetaEnv {
readonly VITE_API_BASE?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

22
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,22 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import PrimeVue from 'primevue/config';
import Aura from '@primeuix/themes/aura';
import 'primeicons/primeicons.css';
import './style.css';
import App from './App.vue';
const app = createApp(App);
app.use(createPinia());
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
// 以后想做深色:给 <html> 加 .app-dark 即可
darkModeSelector: '.app-dark',
},
},
});
app.mount('#app');

View File

@@ -0,0 +1,43 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { postBacktest, syncData } from '@/api/client';
import type { BacktestRequest, BacktestResponse } from '@/api/types';
export const useBacktestStore = defineStore('backtest', () => {
const loading = ref(false);
const stage = ref<'idle' | 'syncing' | 'backtesting'>('idle');
const note = ref<string | null>(null);
const result = ref<BacktestResponse | null>(null);
const error = ref<string | null>(null);
async function run(req: BacktestRequest) {
loading.value = true;
error.value = null;
result.value = null;
note.value = null;
try {
// 非演示标的:先拉取并缓存真实行情(首次较慢;回测端点也会兜底)
if (req.symbol.trim().toUpperCase() !== 'DEMO') {
stage.value = 'syncing';
note.value = `正在拉取 ${req.symbol} 行情数据(首次较慢,已自动缓存)…`;
try {
await syncData({ symbol: req.symbol, source: 'auto' });
} catch {
/* 忽略:回测端点会兜底拉取或复用缓存 */
}
}
stage.value = 'backtesting';
note.value = '回测中…';
result.value = await postBacktest(req);
} catch (e) {
error.value = e instanceof Error ? e.message : '回测失败';
} finally {
loading.value = false;
stage.value = 'idle';
note.value = null;
}
}
return { loading, stage, note, result, error, run };
});

123
frontend/src/style.css Normal file
View File

@@ -0,0 +1,123 @@
:root {
/* 深色专业交易终端配色A股红涨绿跌 */
--bg: #0b0e14;
--surface: #11151c;
--surface-2: #161c26;
--border: rgba(255, 255, 255, 0.07);
--border-2: rgba(255, 255, 255, 0.12);
--ink: #e6edf3;
--ink-2: #9aa4b2;
--ink-3: #5c6675;
--up: #f6465d; /* A股涨 / 买入 = 红 */
--down: #0ecb81; /* A股跌 / 卖出 = 绿 */
--dif: #5b8ff9; /* MACD DIF */
--dea: #f6bd16; /* MACD DEA */
--radius: 12px;
}
* { box-sizing: border-box; }
html, body, #app { margin: 0; min-height: 100%; }
body {
background:
radial-gradient(1200px 560px at 78% -12%, #182030 0%, rgba(24, 32, 48, 0) 55%),
var(--bg);
color: var(--ink);
font-family: system-ui, -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-variant-numeric: tabular-nums;
-webkit-font-smoothing: antialiased;
}
.app-shell { max-width: 1500px; margin: 0 auto; padding: 16px 20px 40px; }
.app-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.app-header h1 { font-size: 16px; margin: 0; font-weight: 600; letter-spacing: 0.3px; }
.app-header .sub { color: var(--ink-3); font-size: 12px; }
/* 参数工具栏 */
.toolbar {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 11px 14px;
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 16px;
}
.field { display: flex; flex-direction: column; gap: 5px; }
.field label { font-size: 11px; color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.5px; }
.spacer { flex: 1 1 auto; }
.hint { margin-top: 8px; font-size: 12px; color: var(--ink-3); }
.hint code { color: var(--ink-2); background: rgba(255,255,255,0.05); padding: 1px 5px; border-radius: 4px; }
/* 标的快捷选择 */
.quick { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 10px; }
.quick .qlabel { font-size: 12px; color: var(--ink-3); margin-right: 2px; }
.qchip {
font-size: 12px; color: var(--ink-2); background: var(--surface-2);
border: 1px solid var(--border); border-radius: 999px; padding: 3px 10px; cursor: pointer;
font-variant-numeric: tabular-nums; transition: 0.15s;
}
.qchip:hover { color: var(--ink); border-color: var(--border-2); }
.qchip.active { color: #fff; background: var(--dif); border-color: var(--dif); }
.qchip .qname { color: var(--ink-3); margin-left: 4px; }
.qchip.active .qname { color: rgba(255, 255, 255, 0.85); }
/* 绩效指标:紧凑单行数据条 */
.stats { display: flex; gap: 8px; margin-top: 12px; }
.stat {
flex: 1 1 0; min-width: 0;
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 8px 11px; display: flex; align-items: baseline; gap: 7px; white-space: nowrap;
}
.stat .label { font-size: 11px; color: var(--ink-3); }
.stat .value { font-size: 14px; font-weight: 600; }
.stat .value.pos { color: var(--up); }
.stat .value.neg { color: var(--down); }
/* 图表面板 */
.panel {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 10px 10px 6px; margin-top: 14px;
}
.panel-title { font-size: 12px; color: var(--ink-2); margin: 4px 4px 6px; }
.kline-wrap { position: relative; }
.chart-kline { height: 520px; }
.chart-equity { height: 240px; }
/* K 线左上角标识 + 系列图例 */
.lc-legend {
position: absolute; top: 6px; left: 10px; z-index: 5;
display: flex; gap: 14px; align-items: center;
font-size: 12px; color: var(--ink-2); pointer-events: none;
}
.lc-legend .sym { color: var(--ink); font-weight: 600; }
.lc-legend .sym small { color: var(--ink-3); font-weight: 400; margin-left: 6px; }
.lc-legend .chip { display: inline-flex; align-items: center; gap: 5px; }
.lc-legend .chip i { width: 12px; height: 3px; border-radius: 2px; display: inline-block; }
/* 悬停弹框(同花顺式) */
.lc-tooltip {
position: absolute; z-index: 20; pointer-events: none; min-width: 196px;
background: rgba(17, 21, 28, 0.97); border: 1px solid var(--border-2);
border-radius: 8px; padding: 8px 11px; font-size: 11.5px; color: var(--ink-2);
line-height: 1.7; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
}
.lc-tooltip .tt-date { color: var(--ink); font-weight: 600; margin-bottom: 3px; }
.lc-tooltip .tt-date .tt-wd { color: var(--ink-3); font-weight: 400; margin-left: 5px; }
.lc-tooltip .tt-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; }
.lc-tooltip .tt-grid b { color: var(--ink); font-weight: 500; float: right; }
.lc-tooltip .tt-row b { color: var(--ink); font-weight: 500; }
.lc-tooltip .tt-sep { height: 1px; background: var(--border); margin: 5px 0; }
.lc-tooltip .tt-ind span { margin-right: 10px; }
.lc-tooltip .pos { color: var(--up); }
.lc-tooltip .neg { color: var(--down); }
.error-banner {
background: rgba(246, 70, 93, 0.08); color: var(--up);
border: 1px solid rgba(246, 70, 93, 0.3); border-radius: var(--radius);
padding: 10px 14px; font-size: 13px; margin-top: 14px;
}
.placeholder { color: var(--ink-3); font-size: 13px; padding: 56px 0; text-align: center; }

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import BacktestForm from '@/components/BacktestForm.vue';
import KLineChart from '@/components/KLineChart.vue';
import EquityChart from '@/components/EquityChart.vue';
import MetricsPanel from '@/components/MetricsPanel.vue';
import { useBacktestStore } from '@/stores/backtest';
import type { BacktestRequest } from '@/api/types';
const store = useBacktestStore();
function onRun(req: BacktestRequest) {
store.run(req);
}
</script>
<template>
<BacktestForm :loading="store.loading" @run="onRun" />
<div v-if="store.error" class="error-banner">{{ store.error }}</div>
<div v-if="store.loading && store.note" class="placeholder">{{ store.note }}</div>
<template v-if="store.result">
<MetricsPanel :metrics="store.result.metrics" />
<div class="panel">
<KLineChart
:candles="store.result.candles"
:indicators="store.result.indicators"
:signals="store.result.signals"
:symbol="store.result.symbol"
:timeframe="store.result.timeframe"
:strategy="store.result.strategy"
/>
</div>
<div class="panel">
<div class="panel-title">净值曲线</div>
<EquityChart :equity="store.result.equity" />
</div>
</template>
<div v-else-if="!store.loading" class="placeholder">
选好周期与参数开始回测先用 DEMO 合成数据跑通
</div>
</template>

19
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"useDefineForClassFields": true,
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src", "vite.config.ts"]
}

16
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
server: {
port: 5173,
// 前端 /api 直接代理到后端,免去 CORS生产环境可用 VITE_API_BASE 指向真实后端)
proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true } },
},
});