first commit
This commit is contained in:
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal 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
270
README.md
Normal 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)
|
||||||
|
- 复权因子管道、回放式模拟盘
|
||||||
|
- 切 TimescaleDB(hypertable + 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 5(K 线)· 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 # ORM(Candle / 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
|
||||||
|
# 或 gunicorn(Linux):uv 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
187
TECH_STACK.md
Normal 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` 覆盖截至 2025,2026+ 需兜底 |
|
||||||
|
| **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 + TimescaleDB(hypertable + 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` 覆盖截至 2025,2026+ 需 extensions/tushare 兜底。
|
||||||
|
- ECharts v6 WebGL 在低端机需回归测试。
|
||||||
17
backend/.env.example
Normal file
17
backend/.env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# ---- Database ----
|
||||||
|
# MVP 默认 SQLite,零配置即可跑
|
||||||
|
DATABASE_URL=sqlite+aiosqlite:///./stock.db
|
||||||
|
|
||||||
|
# 切到你自己的 PostgreSQL(TimescaleDB 是 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
0
backend/app/__init__.py
Normal file
167
backend/app/api.py
Normal file
167
backend/app/api.py
Normal 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 -> None(lightweight-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,
|
||||||
|
)
|
||||||
0
backend/app/backtest/__init__.py
Normal file
0
backend/app/backtest/__init__.py
Normal file
104
backend/app/backtest/broker.py
Normal file
104
backend/app/backtest/broker.py
Normal 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
|
||||||
114
backend/app/backtest/engine.py
Normal file
114
backend/app/backtest/engine.py
Normal 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,
|
||||||
|
}
|
||||||
41
backend/app/backtest/metrics.py
Normal file
41
backend/app/backtest/metrics.py
Normal 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
|
||||||
24
backend/app/backtest/strategies/__init__.py
Normal file
24
backend/app/backtest/strategies/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""策略注册表与工厂。
|
||||||
|
|
||||||
|
新增策略:实现 Strategy(base.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)
|
||||||
20
backend/app/backtest/strategies/base.py
Normal file
20
backend/app/backtest/strategies/base.py
Normal 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:
|
||||||
|
...
|
||||||
74
backend/app/backtest/strategies/ma_strategies.py
Normal file
74
backend/app/backtest/strategies/ma_strategies.py
Normal 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
|
||||||
42
backend/app/backtest/strategies/macd_cross.py
Normal file
42
backend/app/backtest/strategies/macd_cross.py
Normal 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
47
backend/app/commission.py
Normal 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
26
backend/app/config.py
Normal 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()
|
||||||
0
backend/app/data/__init__.py
Normal file
0
backend/app/data/__init__.py
Normal file
54
backend/app/data/aggregation.py
Normal file
54
backend/app/data/aggregation.py
Normal 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()
|
||||||
|
]
|
||||||
38
backend/app/data/akshare_provider.py
Normal file
38
backend/app/data/akshare_provider.py
Normal 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
|
||||||
81
backend/app/data/fetcher.py
Normal file
81
backend/app/data/fetcher.py
Normal 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}
|
||||||
34
backend/app/data/repository.py
Normal file
34
backend/app/data/repository.py
Normal 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())
|
||||||
25
backend/app/data/symbols.py
Normal file
25
backend/app/data/symbols.py
Normal 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"
|
||||||
78
backend/app/data/synthetic.py
Normal file
78
backend/app/data/synthetic.py
Normal 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()
|
||||||
51
backend/app/data/tushare_provider.py
Normal file
51
backend/app/data/tushare_provider.py
Normal 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
20
backend/app/db.py
Normal 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
77
backend/app/domain.py
Normal 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+1:locked_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
56
backend/app/indicators.py
Normal 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:
|
||||||
|
"""RSI(Wilder 平滑)。"""
|
||||||
|
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:
|
||||||
|
"""KDJ(A股常用: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
43
backend/app/main.py
Normal 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
57
backend/app/models.py
Normal 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
87
backend/app/schemas.py
Normal 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
21
backend/pyproject.toml
Normal 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
54
backend/smoke_test.py
Normal 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
|
||||||
|
|
||||||
|
# 必须用 with:lifespan(建表)只在进入上下文时执行
|
||||||
|
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
1159
backend/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
frontend/index.html
Normal file
15
frontend/index.html
Normal 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
28
frontend/package.json
Normal 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
1204
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
frontend/pnpm-workspace.yaml
Normal file
3
frontend/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
|
vue-demi: true
|
||||||
13
frontend/src/App.vue
Normal file
13
frontend/src/App.vue
Normal 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>
|
||||||
29
frontend/src/api/client.ts
Normal file
29
frontend/src/api/client.ts
Normal 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
76
frontend/src/api/types.ts
Normal 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;
|
||||||
|
}
|
||||||
90
frontend/src/components/BacktestForm.vue
Normal file
90
frontend/src/components/BacktestForm.vue
Normal 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>
|
||||||
60
frontend/src/components/EquityChart.vue
Normal file
60
frontend/src/components/EquityChart.vue
Normal 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>
|
||||||
237
frontend/src/components/KLineChart.vue
Normal file
237
frontend/src/components/KLineChart.vue
Normal 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>
|
||||||
20
frontend/src/components/MetricsPanel.vue
Normal file
20
frontend/src/components/MetricsPanel.vue
Normal 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
14
frontend/src/env.d.ts
vendored
Normal 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
22
frontend/src/main.ts
Normal 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');
|
||||||
43
frontend/src/stores/backtest.ts
Normal file
43
frontend/src/stores/backtest.ts
Normal 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
123
frontend/src/style.css
Normal 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; }
|
||||||
45
frontend/src/views/BacktestView.vue
Normal file
45
frontend/src/views/BacktestView.vue
Normal 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
19
frontend/tsconfig.json
Normal 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
16
frontend/vite.config.ts
Normal 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 } },
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user