diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1990496 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: + - main + - feat/** + - fix/** + - refactor/** + - docs/** + - test/** + - chore/** + pull_request: + +jobs: + backend-tests: + name: Backend Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + cache-dependency-path: | + requirements.txt + requirements-dev.txt + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Run pytest + run: pytest -q + + frontend-build: + name: Frontend Build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + working-directory: frontend + run: npm ci + + - name: Build frontend + working-directory: frontend + run: npm run build diff --git a/.gitignore b/.gitignore index 3c9c849..5a0d2c6 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,8 @@ config/settings.local.yaml # 回测报告和图表 output/ reports/ +trades.csv +result.csv *.png *.jpg *.html @@ -74,5 +76,11 @@ htmlcov/ # ========== Jupyter ========== .ipynb_checkpoints/ +# ========== Frontend ========== +frontend/node_modules/ +frontend/dist/ + # ========== Claude Code ========== .claude/ +.codex +.codex/ diff --git a/README.md b/README.md index 4424665..f4044d4 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,8 @@ A股量化交易系统,涵盖行情数据采集、策略引擎、回测框架 │ └── technical/ # 技术指标策略 │ ├── ma_cross.py # 均线交叉策略 │ ├── macd_strategy.py # MACD 策略 -│ └── limitdown_short.py # 跌停做空策略 +│ ├── limitdown_short.py # 跌停做空策略 +│ └── overnight_long.py # 隔夜多头策略 ├── backtest/ # 回测模块 │ ├── engine.py # 回测引擎 │ ├── account.py # 账户管理 @@ -59,9 +60,12 @@ A股量化交易系统,涵盖行情数据采集、策略引擎、回测框架 │ ├── daily_update.py # 每日数据更新 │ ├── run_backtest.py # 策略回测入口 │ ├── run_paper_trade.py # 模拟盘运行入口 +│ ├── run_live_trade.py # 实盘交易基座入口(dry-run / live) │ ├── run_limitdown_short.py # 开盘做空回测策略(独立脚本) │ └── query_stock.py # 数据查询验证工具 -└── requirements.txt +├── requirements.txt # 基础运行依赖 +├── requirements-dev.txt # 开发 / 测试依赖 +└── requirements-strategy.txt # 可选策略扩展依赖 ``` ## 快速开始 @@ -76,11 +80,47 @@ python -m venv .venv source .venv/bin/activate # Linux/Mac # .venv\Scripts\activate # Windows +# 基础运行依赖 pip install -r requirements.txt + +# 若需要跑测试 / 本地开发 +pip install -r requirements-dev.txt +``` + +### 1.1 依赖分层说明 + +当前依赖已拆分为三层: + +| 文件 | 用途 | +|------|------| +| `requirements.txt` | 基础运行依赖(数据、回测、模拟盘、Web API) | +| `requirements-dev.txt` | 开发与测试依赖(在 `requirements.txt` 基础上增加 pytest 等) | +| `requirements-strategy.txt` | 可选策略扩展依赖(如 `ta-lib`、`pandas-ta`) | + +说明: + +- 日常运行项目:安装 `requirements.txt` +- 开发 / 提交代码 / 跑测试:安装 `requirements-dev.txt` +- 后续实现依赖重型指标库的策略时,再安装 `requirements-strategy.txt` + +```bash +# 若后续要使用 ta-lib / pandas-ta 相关策略 +pip install -r requirements-strategy.txt ``` ### 2. 初始化数据库 +`scripts/init_db.py` 用于创建表结构并初始化基础数据。首次运行建议至少完成一次交易日历、股票列表和目标标的日K拉取。 + +**参数说明:** + +| 参数 | 含义 | +|------|------| +| `--start YYYY-MM-DD` | 历史数据起始日期,默认 `2020-01-01` | +| `--tables-only` | 仅创建表结构,不拉取任何数据 | +| `--stock-list-only` | 仅更新股票列表和交易日历 | +| `--codes CODE [CODE ...]` | 只拉取指定股票/ETF 的日K | + ```bash # 仅创建表结构(快速验证) python scripts/init_db.py --tables-only @@ -99,6 +139,31 @@ python scripts/init_db.py --stock-list-only > 若指定的起止日期超出数据库已有范围,脚本会自动提示并给出补数据命令,按提示操作后重新运行即可。 +`scripts/run_backtest.py` 是统一的回测入口。当前回测执行时序为: + +- `execute_at="close"`:当日收盘价成交 +- `execute_at="open"`:当日开盘价成交 +- `execute_at="next_open"`:**真正挂到下一交易日开盘**成交 + +因此: + +- `ma_cross` / `macd` 这类默认 `next_open` 的策略,信号在 T 日收盘后产生,成交在 T+1 日开盘 +- `overnight_long` 的用户视角是:T 日 14:55 近似收盘价买入,T+1 日开盘卖出,随后 T+1 日 14:55 再买入 + +**参数说明:** + +| 参数 | 含义 | +|------|------| +| `--strategy NAME` | 策略名称,当前支持 `ma_cross` / `macd` / `limitdown_short` / `overnight_long` | +| `--codes CODE [CODE ...]` | 回测标的列表 | +| `--start YYYY-MM-DD` | 回测开始日期 | +| `--end YYYY-MM-DD` | 回测结束日期,默认今天 | +| `--capital N` | 初始资金,默认 `1000000` | +| `--params key=value ...` | 覆盖策略参数;优先级高于 `config/strategies.yaml` | +| `--all` | 显示全部交易明细,默认仅显示最近 20 笔 | +| `--csv FILE` | 导出全部交易明细到 CSV | +| `--slippage-rate RATE` | 覆盖滑点百分比;`0` 表示无滑点,省略则读取 `settings.yaml` | + ```bash # 均线交叉策略 python scripts/run_backtest.py --strategy ma_cross --codes 000001 --start 2023-01-01 @@ -122,20 +187,71 @@ python scripts/run_backtest.py --strategy ma_cross --codes 000001 --start 2023-0 # 导出全部交易明细到 CSV python scripts/run_backtest.py --strategy ma_cross --codes 000001 --start 2023-01-01 --csv trades.csv + +# 覆盖滑点(百分比,单位:小数;0 表示无滑点,默认读 settings.yaml 的 slippage_rate) +python scripts/run_backtest.py --strategy ma_cross --codes 000001 --start 2023-01-01 --slippage-rate 0.0005 ``` +**交易明细字段(15 列中文表头,UTF-8 BOM,Excel 可直接打开):** + +明细按**每日一行**组织(**Daily P&L Journal** 风格):一行 = 一个交易日的所有动作。一行的"动作"列标注 **建仓 / 换仓 / 平仓** 三者之一。 + +| 列 | 含义 | 建仓行 | 换仓行 | 平仓行 | +|----|------|:------:|:------:|:------:| +| 代码 / 日期 / 动作 | 基础上下文 | ✓ | ✓ | ✓ | +| 开盘价 | 当日 open(bar 参考价,恒填) | ✓ | ✓ | ✓ | +| 卖出价 | 当日卖出成交价(按策略 `execute_at`,隔夜多头策略下 ≈ 开盘价) | 空 | ✓ | ✓ | +| 收盘价 | 当日 close(bar 参考价,恒填) | ✓ | ✓ | ✓ | +| 买入价 | 当日买入成交价(按策略 `execute_at`,隔夜多头策略下 ≈ 收盘价) | ✓ | ✓ | 空 | +| 卖出份额 | 当日卖出股数 | 空 | ✓ | ✓ | +| 买入份额 | 当日买入股数(连续隔夜换仓日两者可能不等,因价格变化) | ✓ | ✓ | 空 | +| 佣金 | **当日**所有成交佣金合计(ETF 无印花税/过户费) | ✓ | ✓ | ✓ | +| 净盈 | **本次卖出**完成的 round-trip 净盈 = `(卖出价 - 上次买入价) × 卖出份额 - 两端佣金` | 空 | ✓ | ✓ | +| 收益率% | `(卖出价 / 上次买入价 - 1) × 100` | 空 | ✓ | ✓ | +| 持仓天数 | 本次卖出日 − 上次买入日 | 空 | ✓ | ✓ | +| 净值 | 当日收盘后账户总资产 | ✓ | ✓ | ✓ | +| 动作备注 | 策略返回的 `reason` 字段(主要用于卖出动作) | 空 | ✓ | ✓ | + +**口径说明**: +- "佣金"是**当日口径**(建仓日=买佣金;换仓日=卖佣金+新买佣金;平仓日=卖佣金) +- "净盈"是**round-trip 口径**,只扣该 round-trip 两端佣金(上次买 + 本次卖),不含当日新建仓的买入佣金 +- 两者因此不严格对齐——想知道"今天一共花了多少手续费"看佣金列;想知道"这笔持仓赚了多少"看净盈列 + ```bash -# 隔夜多头策略(尾盘买 / 次日集合竞价卖) +# 隔夜多头策略(T 日 14:55 买 + T+1 日开盘卖) python scripts/run_backtest.py --strategy overnight_long --codes 513090 --start 2023-01-01 --capital 500000 -# 启用涨跌幅过滤(跌幅 ≥ 3% 才买) +# 启用涨跌幅过滤(跌幅 ≥ 3% 才买,只作用于 BUY 分支) python scripts/run_backtest.py --strategy overnight_long --codes 513090 --start 2023-01-01 --params min_drop_pct=3.0 + +# CLI 参数会覆盖 config/strategies.yaml 的默认配置 +python scripts/run_backtest.py --strategy overnight_long --codes 513090 --start 2023-01-01 \ + --params min_drop_pct=3.0 max_rise_pct=2.0 ``` +**策略语义**: + +- **用户视角**:T 日尾盘买入,T+1 日开盘卖出;若 T+1 日收盘继续满足条件,则再次买入并预约 T+2 日开盘卖出 +- **信号时序**:空仓日返回 `BUY @ close + SELL @ next_open`;持仓日返回 `SELL @ next_open` +- **过滤参数** `min_drop_pct` / `max_rise_pct` 仅作用于 BUY(是否在今日尾盘建仓) +- `limit_pct` 已不再作为策略参数;次日开盘卖出能否成交由引擎按真实开盘涨跌停规则判定 + ### 4. 开盘做空策略回测 > 模拟"集合竞价挂跌停价卖出(实际以开盘价成交)+ 收盘前买入"的日内做空逻辑。若指定日期超出数据库范围,会自动提示补数据。 +**参数说明:** + +| 参数 | 含义 | +|------|------| +| `--code CODE` | 单一股票/ETF 代码 | +| `--start YYYY-MM-DD` | 回测开始日期 | +| `--end YYYY-MM-DD` | 回测结束日期,默认今天 | +| `--capital N` | 初始资金 | +| `--shares N` | 固定每笔股数;默认 `0` 表示按可用资金全仓 | +| `--all` | 显示全部交易明细 | +| `--csv FILE` | 导出全部交易明细 | + ```bash # 基本用法(以开盘价卖出,收盘价买回,每日必然触发) python scripts/run_limitdown_short.py --code 000001 --start 2023-01-01 @@ -196,6 +312,17 @@ python scripts/init_db.py --codes 513090 513100 --start 2023-01-01 # 跨 ### 6. 查询验证数据 +`scripts/query_stock.py` 用于核对本地数据库与网络接口数据。 + +**参数说明:** + +| 参数 | 含义 | +|------|------| +| `--code, -c CODE` | 股票代码 | +| `--start, -s YYYY-MM-DD` | 起始日期,默认近 20 天 | +| `--end, -e YYYY-MM-DD` | 结束日期,默认今天 | +| `--source db|api|both` | 查询本地、网络或两者对比 | + ```bash # 从本地数据库查询 python scripts/query_stock.py -c 000001 -s 2023-01-03 -e 2023-01-10 @@ -209,6 +336,16 @@ python scripts/query_stock.py -c 000001 -s 2023-01-03 -e 2023-01-10 --source bot ### 7. 每日数据更新 +`scripts/daily_update.py` 用于收盘后增量更新。 + +**参数说明:** + +| 参数 | 含义 | +|------|------| +| `--codes CODE [CODE ...]` | 仅更新指定标的 | +| `--validate` | 更新后校验最近 7 天数据质量 | +| `--days N` | 从今天向前回补 N 天数据 | + ```bash # 增量更新所有股票(每个交易日收盘后运行) python scripts/daily_update.py @@ -303,14 +440,49 @@ strategies: fast_period: 12 slow_period: 26 signal_period: 9 + overnight_long: + min_drop_pct: null + max_rise_pct: null ``` +说明: + +- `strategy/registry.py` 现在会自动读取 `config/strategies.yaml` 作为每个策略的默认参数 +- 命令行 `--params key=value ...` 会覆盖 `strategies.yaml` +- `overnight_long` 当前可配参数只有: + - `min_drop_pct`:当日跌幅至少达到该值才允许尾盘买入 + - `max_rise_pct`:当日涨幅超过该值则不在尾盘买入 + ## 模拟盘交易 > 每日收盘后运行一次,自动执行昨日挂单、生成明日委托,账户状态持久化到数据库。 > **前提**:模拟盘依赖本地数据库中的行情和交易日历数据。运行前请先执行 `python scripts/daily_update.py` 确保数据已更新至当日。若提示"不是交易日"或"无行情数据",通常是数据库未更新所致。 +`scripts/run_paper_trade.py` 当前会根据: + +- `--strategy` +- `--codes` +- `--params` + +生成稳定的 `账户ID`。这意味着: + +- 相同策略 / 参数 / 标的组合会继续使用同一个模拟盘账户 +- 只要参数或股票池不同,就会自动隔离为不同账户 +- `--status` / `--history` 必须使用和运行时**完全相同**的 `--strategy --codes --params` 才能查到同一账户 + +**参数说明:** + +| 参数 | 含义 | +|------|------| +| `--strategy NAME` | 策略名称 | +| `--codes CODE [CODE ...]` | 股票代码列表;会参与账户ID生成 | +| `--capital N` | 初始资金,仅首次创建该账户实例时生效 | +| `--date YYYY-MM-DD` | 指定运行日期,默认今天;可用于补跑历史 | +| `--params key=value ...` | 覆盖策略参数;会参与账户ID生成 | +| `--status` | 只查看账户状态、持仓和待执行订单 | +| `--history N` | 查看最近 N 天净值记录 | + ```bash # 0. 运行前先更新数据(每个交易日收盘后执行) python scripts/daily_update.py @@ -324,6 +496,15 @@ python scripts/run_paper_trade.py --strategy limitdown_short --codes 513090 --ca # 隔夜多头策略(尾盘买 / 次日开盘卖) python scripts/run_paper_trade.py --strategy overnight_long --codes 513090 --capital 1000000 +# 使用相同组合查看该账户状态 +python scripts/run_paper_trade.py --strategy overnight_long --codes 513090 --status + +# 带过滤参数时,会生成另一个独立账户 +python scripts/run_paper_trade.py --strategy overnight_long --codes 513090 \ + --params min_drop_pct=3.0 max_rise_pct=2.0 --capital 1000000 +python scripts/run_paper_trade.py --strategy overnight_long --codes 513090 \ + --params min_drop_pct=3.0 max_rise_pct=2.0 --status + # 查看账户当前状态、持仓、待执行订单 python scripts/run_paper_trade.py --strategy ma_cross --codes 000001 --status @@ -347,9 +528,49 @@ python scripts/run_paper_trade.py --strategy ma_cross --codes 000001 --date 2024 | 2 | 加载今日 K 线(停牌标的自动跳过) | | 3 | 执行昨日挂单(以今日开盘价成交,涨跌停/停牌自动取消) | | 4 | 按今日收盘价更新持仓估值 | -| 5 | 运行策略,生成信号:`execute_at="next_open"` → 明日挂单;`execute_at="open"/"close"` → 当日立即成交 | +| 5 | 运行策略:`execute_at="next_open"` → 明日挂单;`execute_at="open"/"close"` → 当日立即成交 | | 6 | 记录今日净值快照 | +### overnight_long 模拟盘运行说明 + +`overnight_long` 在模拟盘中的用户视角是: + +1. **T 日 14:55**:以收盘价近似买入 +2. **T+1 日开盘**:执行昨日预约的 `SELL @ next_open` +3. **T+1 日收盘后**:若满足买入条件,再次买入并预约 T+2 日开盘卖出 + +建议操作流程: + +```bash +# 第一次运行前,先把目标标的数据补齐 +python scripts/init_db.py --codes 513090 --start 2023-01-01 + +# 每个交易日收盘后先更新数据 +python scripts/daily_update.py --codes 513090 + +# 运行 overnight_long 模拟盘 +python scripts/run_paper_trade.py --strategy overnight_long --codes 513090 --capital 1000000 + +# 查看账户状态(必须使用相同 --strategy/--codes/--params 组合) +python scripts/run_paper_trade.py --strategy overnight_long --codes 513090 --status +``` + +若你给 `overnight_long` 加过滤参数,例如: + +```bash +python scripts/run_paper_trade.py --strategy overnight_long --codes 513090 \ + --params min_drop_pct=3.0 +``` + +那么后续查看状态也必须带同样的参数: + +```bash +python scripts/run_paper_trade.py --strategy overnight_long --codes 513090 \ + --params min_drop_pct=3.0 --status +``` + +否则查到的是另一套账户实例。 + **cron 注册(每个工作日 16:30 先更新数据,16:35 再运行模拟盘):** ```bash @@ -368,7 +589,7 @@ python scripts/run_paper_trade.py --strategy ma_cross --codes 000001 --date 2024 | `ma_cross` | 均线交叉 | 趋势跟踪,短均线金叉/死叉长均线,次日开盘执行 | | `macd` | MACD | 趋势动量,DIF/DEA 金叉死叉,次日开盘执行 | | `limitdown_short` | 跌停做空 | 每日开盘卖出(集合竞价)+ 收盘买入,当日执行 | -| `overnight_long` | 隔夜多头 | 尾盘买入(14:55 近似收盘价)+ 次日集合竞价挂跌停价卖出(开盘成交) | +| `overnight_long` | 隔夜多头(连续) | 日终视角为“尾盘买入并预约次日开盘卖出”;用户视角为“每日 14:55 买、次日开盘卖” | | KDJ | — | 待开发 | | 布林带 | — | 待开发 | @@ -391,7 +612,7 @@ python scripts/run_paper_trade.py --strategy ma_cross --codes 000001 --date 2024 | execute_at | 含义 | |-----------|------| -| `"next_open"`(默认)| 次日开盘价执行,适合趋势策略 | +| `"next_open"`(默认)| **下一交易日**开盘价执行,会先进入 pending 队列 | | `"open"` | 当日开盘价执行(模拟集合竞价挂单) | | `"close"` | 当日收盘价执行(模拟尾盘成交) | @@ -402,7 +623,7 @@ python scripts/run_paper_trade.py --strategy ma_cross --codes 000001 --date 2024 | T+1 | 当日买入次日才能卖出 | | 涨跌停 | 主板 ±10%、创业板/科创板 ±20%、北交所 ±30%、ST ±5%、跨境ETF ±15% | | 涨停/跌停 | 涨停无法买入,跌停无法卖出 | -| 最小单位 | 买入必须为 100 股整数倍 | +| 最小单位 | 买入必须为 100 股整数倍;卖出超过 100 股时必须为 100 股整数倍,除非一次性卖出全部零股 | | 费用 | 佣金 + 印花税(卖出)+ 过户费 | ## 开发路线图 @@ -415,8 +636,261 @@ python scripts/run_paper_trade.py --strategy ma_cross --codes 000001 --date 2024 - [ ] Phase 3: 策略库扩展(KDJ、布林带、RSI、多因子) - [ ] Phase 4: 风控模块(仓位控制、止损止盈、黑名单) - [x] Phase 5: 模拟交易(`scripts/run_paper_trade.py`) -- [ ] Phase 6: Web 可视化(FastAPI + Vue 3 + ECharts) -- [ ] Phase 7: 实盘对接(QMT/miniQMT) +- [x] Phase 6: Web 可视化最小闭环(FastAPI Dashboard API + Vue Dashboard 首屏) +- [x] Phase 7A: 实盘交易基座(broker 抽象 + DryRunBroker + live engine) +- [x] Phase 7B: QMT 适配器与计划单激活链(仍需真实环境联调) + +## Phase 6 启动方式 + +当前已落地 **Phase 6A + 6B 最小闭环**: + +- 后端:FastAPI 只读 Dashboard API +- 前端:Vue 3 + TypeScript + Vite 的 Dashboard 首屏 + +当前 Dashboard 可展示: + +- 模拟盘账户列表 +- 账户概览 +- 当前持仓 +- 待执行订单 +- 最近净值曲线 + +### 后端启动 + +```bash +# 项目根目录 +uvicorn api.main:app --reload +``` + +默认访问: + +- 健康检查:`http://127.0.0.1:8000/health` +- OpenAPI 文档:`http://127.0.0.1:8000/docs` + +主要接口: + +- `GET /api/dashboard/accounts` +- `GET /api/dashboard` + +说明: + +- Dashboard API 只读,直接复用现有 `PaperRepository` +- 若数据库中还没有模拟盘账户,页面会显示“暂无模拟盘账户” +- 建议先运行至少一次 `scripts/run_paper_trade.py` 创建账户后再打开 Dashboard + +### 前端启动 + +```bash +cd frontend +npm install +npm run dev +``` + +默认访问: + +- `http://127.0.0.1:5173` + +如需自定义后端地址,可设置环境变量: + +```bash +VITE_API_BASE=http://127.0.0.1:8000 npm run dev +``` + +### 推荐体验顺序 + +```bash +# 1. 先确保数据库已有模拟盘账户 +python scripts/run_paper_trade.py --strategy overnight_long --codes 513090 --capital 1000000 + +# 2. 启后端 +uvicorn api.main:app --reload + +# 3. 启前端 +cd frontend +npm install +npm run dev +``` + +### 当前限制 + +- 目前只做只读 Dashboard,不包含策略管理写接口 +- 暂未实现 WebSocket,页面数据默认通过 REST 获取 +- 风控中心、回测中心完整页面、实时行情页仍未开始 + +## Phase 7A / 7B 实盘执行链 + +当前已落地 **Phase 7A:实盘交易基座**,但这不是“真实券商已接通”的完成态,而是先把: + +`策略信号 -> 统一订单请求 -> broker adapter -> 订单结果落库` + +这条链路搭起来。 + +### 当前能力 + +- 统一 broker 抽象: + - `trading/broker/base.py` +- Dry-run broker: + - `trading/broker/dry_run.py` +- QMT 适配器: + - `trading/broker/qmt_broker.py` +- 实盘执行引擎骨架: + - `trading/live_engine.py` +- 实盘/准实盘持久化表: + - `live_account` + - `live_position` + - `live_order` +- 实盘 CLI: + - `scripts/run_live_trade.py` + +### 当前限制与边界 + +- `dry_run` 仍然是当前推荐模式 +- `QmtBroker` 已实现账户查询 / 持仓查询 / 下单 / 撤单 / 订单查询映射 +- 但 **尚未在真实 QMT / miniQMT 环境中完成联调** +- DryRunBroker 会把订单请求落库,但**不会模拟真实成交** +- 邮件通知已接入 live chain,但企业微信 / 个人微信仍未接入 +- 因此当前更适合验证: + - 信号是否正确翻译成统一订单请求 + - live engine 与 broker 抽象是否合理 + - QMT adapter 在 fake 环境中的映射逻辑是否正确 + +### broker 配置 + +编辑 `config/settings.yaml`: + +```yaml +broker: + mode: "dry_run" # dry_run / live + provider: "qmt" # qmt / miniqmt / dummy + account_id: "" + endpoint: "" + timeout: 5 + qmt: + userdata_path: "" + session_id: 100001 + account_type: "STOCK" + dynamic_price_type: "LATEST_PRICE" + strategy_name: "Apex" + order_remark_prefix: "Apex" +``` + +说明: + +- `mode`: + - `dry_run`:当前推荐,安全验证执行链 + - `live`:启用真实 broker adapter +- `provider`: + - `qmt`:已实现 adapter,但需要真实环境联调 + - `dummy`:可继续作为 dry-run / 占位用途 + +### CLI 用法 + +`scripts/run_live_trade.py` 用于运行实盘交易基座。 + +**参数说明:** + +| 参数 | 含义 | +|------|------| +| `--strategy NAME` | 策略名称 | +| `--codes CODE [CODE ...]` | 股票代码列表 | +| `--capital N` | 初始资金(仅 dry-run 首次建账户时生效) | +| `--date YYYY-MM-DD` | 指定运行日期,默认今天 | +| `--params key=value ...` | 覆盖策略参数 | +| `--mode dry_run|live` | broker 模式,默认读 `settings.yaml` | +| `--provider NAME` | broker 提供方,默认读 `settings.yaml` | + +### 推荐使用方式(当前阶段) + +```bash +# 1. 确保数据库中已有行情 +python scripts/init_db.py --codes 513090 --start 2023-01-01 + +# 2. dry-run 跑一次实盘基座(当前最推荐) +python scripts/run_live_trade.py --strategy overnight_long --codes 513090 --capital 1000000 +``` + +示例输出会包含: + +- 实例ID +- broker 类型 +- 生成信号数 +- 构建订单数 +- 提交成功/失败数 + +### dry-run 的意义 + +当前 `dry_run` 模式不会直接接真实券商,也不会模拟成交回报;它的作用是: + +1. 验证策略信号到订单请求的翻译是否正确 +2. 验证订单生命周期能否统一落库 +3. 为后续 `QmtBroker` 真实实现提供稳定接口目标 + +### QMT live 模式说明 + +在真实 QMT 环境中,可切到: + +```bash +python scripts/run_live_trade.py --strategy overnight_long --codes 513090 \ + --mode live --provider qmt +``` + +但要满足: + +- 已安装 `xtquant` +- 已有可用 QMT / miniQMT 运行环境 +- `broker.account_id`、`broker.qmt.userdata_path` 等配置完整 + +当前代码已支持: + +- 账户查询 +- 持仓查询 +- 即时订单提交 +- 撤单 +- 订单查询 +- `next_open` 计划单到期激活链路 +- 邮件通知: + - 日报摘要 + - 订单提交失败 + - 计划单激活失败 + +但由于当前开发环境没有真实 QMT 客户端,仍需你后续在真实环境完成最终联调。 + +### 后续仍建议继续补的内容 + +- 不重写 `live_engine` +- 再逐步补成交回报、对账、风控联动、通知 + +### 邮件通知(live chain) + +当前 `run_live_trade.py` 和 `LiveEngine` 已接入邮件通知。 + +支持的通知场景: + +- `run_live_trade.py` 运行成功后的日报摘要 +- `run_live_trade.py` 的致命异常 +- broker 提交失败 +- planned `next_open` 订单激活失败 + +配置方式: + +```yaml +notification: + email: + enabled: true + smtp_server: "smtp.example.com" + smtp_port: 465 + sender: "your@example.com" + password: "app-password" + receiver: "receiver@example.com" +``` + +说明: + +- 建议使用邮箱的 SMTP 授权码 / app password +- 端口 `465` 走 `SMTP_SSL` +- 其他端口默认走 `SMTP + STARTTLS` + +启用后,无需额外 CLI 参数,`run_live_trade.py` 会自动读取配置并发送通知。 ## 免责声明 diff --git a/api/dependencies.py b/api/dependencies.py new file mode 100644 index 0000000..9617c48 --- /dev/null +++ b/api/dependencies.py @@ -0,0 +1,67 @@ +""" +API dependencies and query parsing helpers +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends, HTTPException, Query + +from data.storage.repository import PaperRepository +from trading.account_id import build_account_id + + +def get_paper_repository() -> PaperRepository: + return PaperRepository() + + +def _split_codes(raw_codes: str | None) -> list[str]: + if not raw_codes: + return [] + return [part.strip() for part in raw_codes.split(",") if part.strip()] + + +def _split_params(raw_params: str | None) -> dict: + if not raw_params: + return {} + + params: dict[str, int | float | str] = {} + for item in raw_params.split(","): + item = item.strip() + if not item or "=" not in item: + continue + key, value = item.split("=", 1) + key = key.strip() + value = value.strip() + if not key: + continue + try: + parsed: int | float | str = int(value) + except ValueError: + try: + parsed = float(value) + except ValueError: + parsed = value + params[key] = parsed + return params + + +def resolve_account_id( + account_id: Annotated[str | None, Query(description="稳定账户ID")] = None, + strategy: Annotated[str | None, Query(description="策略 key,如 overnight_long")] = None, + codes: Annotated[str | None, Query(description="代码列表,逗号分隔")] = None, + params: Annotated[str | None, Query(description="参数列表,key=value 逗号分隔")] = None, +) -> str | None: + if account_id: + return account_id + if strategy: + parsed_codes = _split_codes(codes) + if not parsed_codes: + raise HTTPException(status_code=400, detail="提供 strategy 时必须同时提供 codes") + parsed_params = _split_params(params) + return build_account_id(strategy, parsed_codes, parsed_params) + return None + + +PaperRepoDep = Annotated[PaperRepository, Depends(get_paper_repository)] +AccountIdDep = Annotated[str | None, Depends(resolve_account_id)] diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..97c3d9a --- /dev/null +++ b/api/main.py @@ -0,0 +1,39 @@ +""" +FastAPI application entrypoint for Phase 6 dashboard. +""" +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from api.routers import dashboard_router +from config import setup_logging +from data.models import init_db + +setup_logging() +init_db() + +app = FastAPI( + title="Apex API", + version="0.1.0", + description="Apex dashboard and paper trading read-only API", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:5173", + "http://127.0.0.1:5173", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/health", tags=["system"]) +def health(): + return {"status": "ok"} + + +app.include_router(dashboard_router) diff --git a/api/routers/__init__.py b/api/routers/__init__.py index e69de29..6cdbccf 100644 --- a/api/routers/__init__.py +++ b/api/routers/__init__.py @@ -0,0 +1,3 @@ +from api.routers.dashboard import router as dashboard_router + +__all__ = ["dashboard_router"] diff --git a/api/routers/dashboard.py b/api/routers/dashboard.py new file mode 100644 index 0000000..37bcd74 --- /dev/null +++ b/api/routers/dashboard.py @@ -0,0 +1,161 @@ +""" +Dashboard read-only API +""" +from __future__ import annotations + +import json + +from fastapi import APIRouter, HTTPException, Query + +from api.dependencies import AccountIdDep, PaperRepoDep +from api.schemas import ( + AccountOption, + DashboardOverview, + DashboardPayload, + NavPoint, + PendingOrderItem, + PositionItem, +) + +router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) + + +def _strategy_key_from_account_id(account_id: str) -> str: + return account_id.split(":", 1)[0] + + +def _load_stock_codes(raw: str | None) -> list[str]: + if not raw: + return [] + try: + data = json.loads(raw) + return [str(item) for item in data] if isinstance(data, list) else [] + except json.JSONDecodeError: + return [] + + +def _stock_codes_with_fallback(raw: str | None, positions: list | None = None) -> list[str]: + codes = _load_stock_codes(raw) + if codes: + return codes + if positions: + return sorted({str(p.code) for p in positions if getattr(p, "code", None)}) + return [] + + +@router.get("/accounts", response_model=list[AccountOption]) +def list_accounts(repo: PaperRepoDep): + rows = repo.list_paper_accounts() + items: list[AccountOption] = [] + for row in rows: + positions = repo.get_paper_positions(row.strategy_name) + items.append( + AccountOption( + account_id=row.strategy_name, + strategy_key=_strategy_key_from_account_id(row.strategy_name), + stock_codes=_stock_codes_with_fallback(row.stock_codes, positions), + updated_at=row.updated_at, + created_at=row.created_at, + ) + ) + return items + + +@router.get("", response_model=DashboardPayload) +def get_dashboard( + repo: PaperRepoDep, + account_id: AccountIdDep, + days: int = Query(60, ge=1, le=365, description="净值曲线天数"), +): + accounts = list_accounts(repo) + selected = account_id or (accounts[0].account_id if accounts else None) + + if not selected: + return DashboardPayload(accounts=accounts, selected_account_id=None) + + account_row = repo.get_paper_account(selected) + if account_row is None: + raise HTTPException(status_code=404, detail=f"账户不存在: {selected}") + + positions = repo.get_paper_positions(selected) + order_history = repo.get_order_history(selected) + pending_orders = [o for o in order_history if o.status == "pending"] + nav_rows = repo.get_nav_series(selected) + nav_rows = nav_rows[-days:] if days > 0 else nav_rows + + market_value = sum((p.volume or 0) * (p.current_price or 0.0) for p in positions) + total_equity = (account_row.cash or 0.0) + market_value + initial_capital = account_row.initial_capital or 0.0 + total_profit = total_equity - initial_capital + total_profit_pct = (total_profit / initial_capital * 100) if initial_capital > 0 else 0.0 + latest_trade_date = nav_rows[-1].trade_date if nav_rows else None + + overview = DashboardOverview( + account_id=selected, + strategy_key=_strategy_key_from_account_id(selected), + stock_codes=_stock_codes_with_fallback(account_row.stock_codes, positions), + initial_capital=round(initial_capital, 2), + cash=round(account_row.cash or 0.0, 2), + market_value=round(market_value, 2), + total_equity=round(total_equity, 2), + nav=round(total_equity / initial_capital, 6) if initial_capital > 0 else 1.0, + total_profit=round(total_profit, 2), + total_profit_pct=round(total_profit_pct, 2), + total_commission=round(account_row.total_commission or 0.0, 2), + total_tax=round(account_row.total_tax or 0.0, 2), + position_count=len(positions), + pending_count=len(pending_orders), + latest_trade_date=latest_trade_date, + ) + + position_items = [ + PositionItem( + code=p.code, + volume=p.volume or 0, + available=p.available or 0, + cost_price=round(p.cost_price or 0.0, 3), + current_price=round(p.current_price or 0.0, 3), + market_value=round((p.volume or 0) * (p.current_price or 0.0), 2), + profit=round(((p.current_price or 0.0) - (p.cost_price or 0.0)) * (p.volume or 0), 2), + profit_pct=round((((p.current_price or 0.0) / (p.cost_price or 1.0)) - 1) * 100, 2) + if (p.cost_price or 0.0) > 0 + else 0.0, + buy_date=p.buy_date, + ) + for p in positions + ] + + pending_items = [ + PendingOrderItem( + order_id=o.order_id, + code=o.code, + direction=o.direction, + req_volume=o.req_volume or 0, + execute_date=o.execute_date, + signal_date=o.signal_date, + status=o.status, + reason=o.reason or "", + ) + for o in pending_orders + ] + + nav_points = [ + NavPoint( + trade_date=row.trade_date, + total_equity=round(row.total_equity or 0.0, 2), + cash=round(row.cash or 0.0, 2), + market_value=round(row.market_value or 0.0, 2), + nav=round(row.nav or 0.0, 6) if row.nav is not None else None, + daily_pnl=round(row.daily_pnl or 0.0, 2), + ) + for row in nav_rows + ] + + return DashboardPayload( + accounts=accounts, + selected_account_id=selected, + overview=overview, + positions=position_items, + pending_orders=pending_items, + nav=nav_points, + ) diff --git a/api/schemas.py b/api/schemas.py new file mode 100644 index 0000000..d9e74b6 --- /dev/null +++ b/api/schemas.py @@ -0,0 +1,75 @@ +""" +Phase 6 Dashboard API schemas +""" +from __future__ import annotations + +from datetime import date, datetime + +from pydantic import BaseModel + + +class AccountOption(BaseModel): + account_id: str + strategy_key: str + stock_codes: list[str] + updated_at: datetime | None = None + created_at: datetime | None = None + + +class DashboardOverview(BaseModel): + account_id: str + strategy_key: str + stock_codes: list[str] + initial_capital: float + cash: float + market_value: float + total_equity: float + nav: float + total_profit: float + total_profit_pct: float + total_commission: float + total_tax: float + position_count: int + pending_count: int + latest_trade_date: date | None = None + + +class PositionItem(BaseModel): + code: str + volume: int + available: int + cost_price: float + current_price: float + market_value: float + profit: float + profit_pct: float + buy_date: date | None = None + + +class PendingOrderItem(BaseModel): + order_id: str + code: str + direction: str + req_volume: int + execute_date: date | None = None + signal_date: date + status: str + reason: str + + +class NavPoint(BaseModel): + trade_date: date + total_equity: float + cash: float | None = None + market_value: float | None = None + nav: float | None = None + daily_pnl: float | None = None + + +class DashboardPayload(BaseModel): + accounts: list[AccountOption] + selected_account_id: str | None = None + overview: DashboardOverview | None = None + positions: list[PositionItem] = [] + pending_orders: list[PendingOrderItem] = [] + nav: list[NavPoint] = [] diff --git a/backtest/engine.py b/backtest/engine.py index a415c90..f7e18c5 100644 --- a/backtest/engine.py +++ b/backtest/engine.py @@ -2,6 +2,7 @@ 回测引擎 核心模块:加载历史数据 → 按时间回放 → 驱动策略产生信号 → 撮合成交 → 记录结果 """ +from collections import defaultdict from datetime import date, timedelta from typing import Optional @@ -39,13 +40,16 @@ def __init__( start_date: date, end_date: date, initial_capital: float = None, - slippage: float = None, + slippage_rate: float = None, ): self.strategy = strategy self.stock_codes = stock_codes self.start_date = start_date self.end_date = end_date - self.slippage = slippage or BacktestConfig.slippage + # 滑点百分比:None → 回落到 yaml 默认;0 表示无滑点 + self.slippage_rate = ( + slippage_rate if slippage_rate is not None else BacktestConfig.slippage_rate + ) capital = initial_capital or BacktestConfig.initial_capital self.account = Account(capital) @@ -56,6 +60,10 @@ def __init__( self._orders: list[OrderData] = [] # 买入记录(用于计算卖出盈亏) self._buy_records: dict[str, list[dict]] = {} + # 当日动作暂存:{code: {"buy": {...}, "sell": {...}}};每日末尾聚合后清空 + self._daily_actions: dict[str, dict] = {} + # 次日开盘待执行信号:{execute_date: [Signal, ...]} + self._pending_next_open: dict[date, list[Signal]] = defaultdict(list) def run(self) -> "BacktestResult": """ @@ -92,10 +100,13 @@ def run(self) -> "BacktestResult": self.account.new_trading_day(td) bars_today = all_bars[td] - signals: list[Signal] = [] - # 构建当日 K 线字典(code → bar),price_map 顺带生成 bar_map: dict[str, BarData] = {bar.code: bar for bar in bars_today} + + # 先执行昨日收盘后预约的 next_open 信号(真正的次日开盘撮合) + self._execute_pending_next_open(td, bar_map) + + signals: list[Signal] = [] self.account.update_prices({code: bar.close for code, bar in bar_map.items()}) # 同步账户状态给策略 @@ -110,18 +121,30 @@ def run(self) -> "BacktestResult": self.strategy._update_bar(bar) signals.extend(self.strategy.on_bar(bar)) - # 处理信号(先卖后买,释放资金) - sell_signals = [s for s in signals if s.direction == Direction.SELL] - buy_signals = [s for s in signals if s.direction == Direction.BUY] + # 处理当日信号: + # - open / close:当日立即撮合 + # - next_open:挂到下一交易日开盘 + intraday_signals = [s for s in signals if s.execute_at in ("open", "close")] + next_open_signals = [s for s in signals if s.execute_at == "next_open"] + + # 当日撮合仍按先卖后买,释放资金 + sell_signals = [s for s in intraday_signals if s.direction == Direction.SELL] + buy_signals = [s for s in intraday_signals if s.direction == Direction.BUY] for signal in sell_signals: self._process_signal(signal, bar_map) for signal in buy_signals: self._process_signal(signal, bar_map) + next_td = trade_dates[i + 1] if i + 1 < len(trade_dates) else None + self._queue_next_open_signals(next_open_signals, next_td) + # 记录当日资产 self.account.record_equity(td) + # 聚合当日动作为一行交易明细(需在 record_equity 后,以便读取当日 total_equity) + self._finalize_daily_trade(td, bar_map) + # 进度日志 if (i + 1) % 100 == 0: logger.info( @@ -139,6 +162,42 @@ def run(self) -> "BacktestResult": logger.info("=" * 60) return result + def _execute_pending_next_open(self, td: date, bar_map: dict[str, BarData]): + """执行上一交易日预约到今天开盘的信号。""" + pending = self._pending_next_open.pop(td, []) + if not pending: + return + + sell_signals = [s for s in pending if s.direction == Direction.SELL] + buy_signals = [s for s in pending if s.direction == Direction.BUY] + for signal in sell_signals: + self._process_signal(signal, bar_map, validate_at="open") + for signal in buy_signals: + self._process_signal(signal, bar_map, validate_at="open") + + def _queue_next_open_signals( + self, + signals: list[Signal], + execute_date: Optional[date], + ): + """把 next_open 信号挂到下一交易日。""" + if execute_date is None: + return + + for signal in signals: + self._pending_next_open[execute_date].append( + Signal( + code=signal.code, + direction=signal.direction, + trade_date=execute_date, + price=signal.price, + volume=signal.volume, + reason=signal.reason, + confidence=signal.confidence, + execute_at="open", + ) + ) + def _load_data(self) -> dict[date, list[BarData]]: """ 加载所有股票的历史数据,按交易日分组 @@ -175,7 +234,12 @@ def _load_data(self) -> dict[date, list[BarData]]: return all_bars - def _process_signal(self, signal: Signal, bar_map: dict[str, BarData]): + def _process_signal( + self, + signal: Signal, + bar_map: dict[str, BarData], + validate_at: Optional[str] = None, + ): """处理交易信号:验证 → 撮合 → 成交""" bar = bar_map.get(signal.code) if bar is None: @@ -190,11 +254,13 @@ def _process_signal(self, signal: Signal, bar_map: dict[str, BarData]): # "open" 和 "next_open" 均以当日开盘价模拟 exec_price = bar.open - # 加入滑点 - if signal.direction == Direction.BUY: - exec_price += self.slippage - else: - exec_price -= self.slippage + # 加入滑点(百分比模型):买入向上偏,卖出向下偏;slippage_rate=0 时无摩擦 + if self.slippage_rate: + if signal.direction == Direction.BUY: + exec_price *= 1 + self.slippage_rate + else: + exec_price *= 1 - self.slippage_rate + exec_price = round(exec_price, 3) exec_price = max(exec_price, 0.01) # 确定交易量 @@ -218,19 +284,32 @@ def _process_signal(self, signal: Signal, bar_map: dict[str, BarData]): pos_available = pos.available if pos else 0 # 验证订单合法性 - valid, reason = TradingRules.validate_order( - Signal( + check_mode = validate_at or signal.execute_at + if check_mode == "open": + valid, reason = self._validate_open_order( code=signal.code, direction=signal.direction, - trade_date=signal.trade_date, - price=exec_price, + exec_price=exec_price, volume=volume, - ), - bar, - self.account.cash, - pos_volume, - pos_available, - ) + bar=bar, + available_cash=self.account.cash, + position_volume=pos_volume, + position_available=pos_available, + ) + else: + valid, reason = TradingRules.validate_order( + Signal( + code=signal.code, + direction=signal.direction, + trade_date=signal.trade_date, + price=exec_price, + volume=volume, + ), + bar, + self.account.cash, + pos_volume, + pos_available, + ) if not valid: logger.debug(f"订单拒绝: {signal.code} {signal.direction.value} - {reason}") @@ -248,44 +327,147 @@ def _process_signal(self, signal: Signal, bar_map: dict[str, BarData]): if signal.direction == Direction.BUY: order = self.account.process_buy(order) if order.status == "filled": - # 记录买入 - if signal.code not in self._buy_records: - self._buy_records[signal.code] = [] - self._buy_records[signal.code].append({ + # 记录买入:补存 commission(用于卖出时计算 round-trip 盈亏) + self._buy_records.setdefault(signal.code, []).append({ "date": signal.trade_date, "price": exec_price, "volume": volume, + "commission": order.commission, }) + # 暂存当日买入动作(日末聚合到 _trades) + self._daily_actions.setdefault(signal.code, {})["buy"] = { + "price": exec_price, + "volume": volume, + "commission": order.commission, + } else: - # 计算卖出盈亏 - buy_info = self._buy_records.get(signal.code, [{}]) - buy_price = buy_info[-1].get("price", 0) if buy_info else 0 - buy_date = buy_info[-1].get("date", signal.trade_date) if buy_info else signal.trade_date + # 取最近一笔买入用于 round-trip 结算 + buy_info = self._buy_records.get(signal.code, []) + last_buy = buy_info[-1] if buy_info else {} + rt_buy_price = last_buy.get("price", 0) + rt_buy_date = last_buy.get("date", signal.trade_date) + rt_buy_commission = last_buy.get("commission", 0.0) order = self.account.process_sell(order) if order.status == "filled": - profit = (exec_price - buy_price) * volume - order.commission - holding_days = (signal.trade_date - buy_date).days - self._trades.append({ - "code": signal.code, - "direction": "SELL", - "buy_price": buy_price, - "sell_price": exec_price, + # 暂存当日卖出动作(含 round-trip 上下文,日末聚合时结算 profit) + self._daily_actions.setdefault(signal.code, {})["sell"] = { + "price": exec_price, "volume": volume, - "profit": round(profit, 2), - "profit_pct": round((exec_price / buy_price - 1) * 100, 2) if buy_price > 0 else 0, - "holding_days": holding_days, - "buy_date": buy_date, - "sell_date": signal.trade_date, + "commission": order.commission, "reason": signal.reason, - }) - # 清除买入记录 - if signal.code in self._buy_records: + "rt_buy_price": rt_buy_price, + "rt_buy_date": rt_buy_date, + "rt_buy_commission": rt_buy_commission, + } + # 清除最近一条买入记录(round-trip 已闭合) + if signal.code in self._buy_records and self._buy_records[signal.code]: self._buy_records[signal.code].pop() self._orders.append(order) self.strategy.on_order(order) + def _validate_open_order( + self, + code: str, + direction: Direction, + exec_price: float, + volume: int, + bar: BarData, + available_cash: float, + position_volume: int, + position_available: int, + ) -> tuple[bool, str]: + """按开盘撮合规则校验订单,避免把 open 订单错误套用到 close 涨跌停判定。""" + if bar.pre_close > 0: + up_limit, down_limit = TradingRules.calc_limit_prices(bar.pre_close, code) + if direction == Direction.BUY and exec_price >= up_limit: + return False, "开盘涨停,无法买入" + if direction == Direction.SELL and exec_price <= down_limit: + return False, "开盘跌停,无法卖出" + + if direction == Direction.BUY: + rounded = TradingRules.round_volume(volume, Direction.BUY) + if rounded <= 0: + return False, f"买入数量不足最小交易单位({TradingRules.MIN_TRADE_UNIT}股)" + cost = exec_price * rounded + if cost > available_cash: + return False, f"资金不足:需要 {cost:.2f},可用 {available_cash:.2f}" + return True, "通过" + + if position_volume <= 0: + return False, "无持仓可卖" + if position_available <= 0: + return False, "T+1 限制:今日买入的股票明日才可卖出" + if volume > position_available: + return False, f"卖出数量超出可用持仓:委托 {volume},可用 {position_available}" + if not TradingRules.is_valid_sell_volume(volume, position_available): + return False, "卖出数量不合法:超过100股时必须为100的整数倍,除非一次性卖出全部零股" + return True, "通过" + + def _finalize_daily_trade(self, td: date, bar_map: dict[str, BarData]): + """将当日暂存的买/卖动作聚合为 _trades 一行,按 code 分行""" + for code, actions in self._daily_actions.items(): + buy = actions.get("buy") + sell = actions.get("sell") + if not buy and not sell: + continue + bar = bar_map.get(code) + if bar is None: + continue + + commission_total = 0.0 + if buy: + commission_total += buy["commission"] + if sell: + commission_total += sell["commission"] + + # round-trip 结算归属到"本日卖出"行 + profit = None + profit_pct = None + holding_days = None + reason = "" + if sell: + rt_bp = sell["rt_buy_price"] + rt_bd = sell["rt_buy_date"] + rt_bc = sell["rt_buy_commission"] + sp = sell["price"] + sv = sell["volume"] + sc = sell["commission"] + if rt_bp > 0: + profit = (sp - rt_bp) * sv - rt_bc - sc + profit_pct = (sp / rt_bp - 1) * 100 + holding_days = (td - rt_bd).days + reason = sell["reason"] + + # 动作类型:只买=建仓;只卖=平仓;买+卖=换仓 + if buy and sell: + action = "换仓" + elif buy: + action = "建仓" + else: + action = "平仓" + + self._trades.append({ + "code": code, + "trade_date": td, + "open": round(bar.open, 3), + "close": round(bar.close, 3), + "buy_price": round(buy["price"], 3) if buy else None, + "sell_price": round(sell["price"], 3) if sell else None, + "sell_volume": sell["volume"] if sell else None, + "buy_volume": buy["volume"] if buy else None, + "commission": round(commission_total, 2), + "profit": round(profit, 2) if profit is not None else None, + "profit_pct": round(profit_pct, 2) if profit_pct is not None else None, + "holding_days": holding_days, + "net_equity": round(self.account.total_equity, 2), + "action": action, + "reason": reason, + }) + + self._daily_actions = {} + def _build_result(self, trade_dates: list[date]) -> "BacktestResult": """构建回测结果""" # 获取基准数据 @@ -304,12 +486,14 @@ def _build_result(self, trade_dates: list[date]) -> "BacktestResult": benchmark_returns=benchmark_returns, total_commission=self.account.total_commission, total_tax=self.account.total_tax, + initial_capital=self.account.initial_capital, ) return BacktestResult( strategy_name=self.strategy.name, start_date=self.start_date, end_date=self.end_date, + initial_capital=self.account.initial_capital, metrics=metrics, equity_curve=self.account.equity_curve, trades=self._trades, @@ -325,6 +509,7 @@ def __init__( strategy_name: str, start_date: date, end_date: date, + initial_capital: float, metrics: BacktestMetrics, equity_curve: list[dict], trades: list[dict], @@ -333,6 +518,7 @@ def __init__( self.strategy_name = strategy_name self.start_date = start_date self.end_date = end_date + self.initial_capital = initial_capital self.metrics = metrics self.equity_curve = equity_curve self.trades = trades @@ -344,7 +530,7 @@ def print_summary(self): # 获取期末账户数据 final = self.equity_curve[-1] if self.equity_curve else {} - initial_capital = self.equity_curve[0]["total_equity"] if self.equity_curve else 0 + initial_capital = self.initial_capital final_equity = final.get("total_equity", 0) final_cash = final.get("cash", 0) final_market_value = final.get("market_value", 0) @@ -408,6 +594,7 @@ def empty(cls, strategy_name: str) -> "BacktestResult": strategy_name=strategy_name, start_date=date.today(), end_date=date.today(), + initial_capital=0.0, metrics=_empty_metrics(0, 0), equity_curve=[], trades=[], diff --git a/backtest/metrics.py b/backtest/metrics.py index f3eff88..75093c1 100644 --- a/backtest/metrics.py +++ b/backtest/metrics.py @@ -54,6 +54,7 @@ def calculate_metrics( benchmark_returns: pd.Series = None, total_commission: float = 0.0, total_tax: float = 0.0, + initial_capital: float | None = None, ) -> BacktestMetrics: """ 计算回测绩效指标 @@ -73,7 +74,7 @@ def calculate_metrics( df = df.sort_values("date").reset_index(drop=True) equity = df["total_equity"].values - initial = equity[0] + initial = initial_capital if initial_capital and initial_capital > 0 else equity[0] final = equity[-1] trading_days = len(equity) @@ -137,7 +138,7 @@ def calculate_metrics( calmar_ratio = annual_return / abs(max_drawdown) if max_drawdown != 0 else 0.0 # ========== 交易指标 ========== - sell_trades = [t for t in trades if t.get("direction") == "SELL"] + sell_trades = [t for t in trades if t.get("profit") is not None] total_trades = len(sell_trades) wins = [t for t in sell_trades if t.get("profit", 0) > 0] losses = [t for t in sell_trades if t.get("profit", 0) <= 0] diff --git a/backtest/rules.py b/backtest/rules.py index 18c6311..a4d407b 100644 --- a/backtest/rules.py +++ b/backtest/rules.py @@ -113,17 +113,32 @@ def round_volume(volume: int, direction: Direction) -> int: 调整交易数量为合法值 买入:必须为100的整数倍 - 卖出:可以不足100股(零股一次性卖出) + 卖出:保留原始数量,由 validate_order 决定是否合法 """ unit = TradingRules.MIN_TRADE_UNIT if direction == Direction.BUY: return (volume // unit) * unit else: - # 卖出时,不足100股可以一次性卖出,超过100股的部分必须为100的整数倍 - if volume <= unit: - return volume - remainder = volume % unit - return volume # 卖出允许零股 + return volume + + @staticmethod + def is_valid_sell_volume(volume: int, position_available: int) -> bool: + """ + 检查卖出数量是否合法。 + + 规则: + - 1~100 股:允许一次性卖出 + - 100 的整数倍:允许 + - 非整手但等于全部可卖持仓:允许(一次性卖出零股/尾股) + """ + unit = TradingRules.MIN_TRADE_UNIT + if volume <= 0: + return False + if volume <= unit: + return True + if volume % unit == 0: + return True + return volume == position_available @staticmethod def validate_order( @@ -165,5 +180,7 @@ def validate_order( return False, "T+1 限制:今日买入的股票明日才可卖出" if signal.volume > position_available: return False, f"卖出数量超出可用持仓:委托 {signal.volume},可用 {position_available}" + if not TradingRules.is_valid_sell_volume(signal.volume, position_available): + return False, "卖出数量不合法:超过100股时必须为100的整数倍,除非一次性卖出全部零股" return True, "通过" diff --git a/config/__init__.py b/config/__init__.py index 81fa75a..08f4549 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -92,7 +92,36 @@ class BacktestConfig: _bt = _settings.get("backtest", {}) initial_capital: float = _bt.get("initial_capital", 1000000.0) benchmark: str = _bt.get("benchmark", "000300") - slippage: float = _bt.get("slippage", 0.01) + slippage_rate: float = _bt.get("slippage_rate", 0.0) + + +# ========== Broker 配置 ========== +class BrokerConfig: + _br = _settings.get("broker", {}) + mode: str = _br.get("mode", "dry_run") + provider: str = _br.get("provider", "qmt") + account_id: str = _br.get("account_id", "") + endpoint: str = _br.get("endpoint", "") + timeout: int = _br.get("timeout", 5) + _qmt = _br.get("qmt", {}) + qmt_userdata_path: str = _qmt.get("userdata_path", "") + qmt_session_id: int = _qmt.get("session_id", 100001) + qmt_account_type: str = _qmt.get("account_type", "STOCK") + qmt_dynamic_price_type: str = _qmt.get("dynamic_price_type", "LATEST_PRICE") + qmt_strategy_name: str = _qmt.get("strategy_name", "Apex") + qmt_order_remark_prefix: str = _qmt.get("order_remark_prefix", "Apex") + + +# ========== 通知配置 ========== +class NotificationConfig: + _nt = _settings.get("notification", {}) + _email = _nt.get("email", {}) + email_enabled: bool = _email.get("enabled", False) + email_smtp_server: str = _email.get("smtp_server", "") + email_smtp_port: int = _email.get("smtp_port", 465) + email_sender: str = _email.get("sender", "") + email_password: str = _email.get("password", "") + email_receiver: str = _email.get("receiver", "") # ========== 策略配置 ========== diff --git a/config/settings.yaml b/config/settings.yaml index a1559aa..0cc8b8a 100644 --- a/config/settings.yaml +++ b/config/settings.yaml @@ -74,8 +74,23 @@ backtest: initial_capital: 1000000.0 # 基准指数 benchmark: "000300" # 沪深300 - # 滑点(元) - slippage: 0.01 + # 滑点(百分比,例如 0.0005 = 万5);0 表示无滑点 + slippage_rate: 0.0 + +# 券商 / 实盘执行配置 +broker: + mode: "dry_run" # dry_run / live + provider: "qmt" # qmt / miniqmt / dummy + account_id: "" # 券商账户ID(可为空,后续本地配置覆盖) + endpoint: "" # 预留给远程网关或本地桥接服务 + timeout: 5 # 秒 + qmt: + userdata_path: "" # QMT userdata 目录 + session_id: 100001 # 会话ID + account_type: "STOCK" # STOCK / CREDIT 等 + dynamic_price_type: "LATEST_PRICE" # 当 req_price=0 时使用 + strategy_name: "Apex" + order_remark_prefix: "Apex" # 日志配置 logging: diff --git a/config/strategies.yaml b/config/strategies.yaml index 91a962b..7b15750 100644 --- a/config/strategies.yaml +++ b/config/strategies.yaml @@ -37,7 +37,7 @@ strategies: overbought: 70 oversold: 30 - # 隔夜多头策略(尾盘买 / 次日开盘卖) + # 隔夜多头策略(连续隔夜:T日尾盘买 + T+1日开盘卖) overnight_long: enabled: true min_drop_pct: null # 当日跌幅阈值(正数百分比),null = 禁用 diff --git a/data/models.py b/data/models.py index a8f7dcd..30c56d0 100644 --- a/data/models.py +++ b/data/models.py @@ -282,6 +282,91 @@ class PaperNav(Base): ) +# ========== 实盘账户 ========== +class LiveAccount(Base): + """ + 实盘/准实盘账户表 + 记录某策略实例在 live/dry-run broker 下的账户快照 + """ + __tablename__ = "live_account" + + id = Column(Integer, primary_key=True, autoincrement=True) + instance_id = Column(String(64), nullable=False, unique=True, comment="策略实例ID(稳定哈希)") + strategy_key = Column(String(50), nullable=False, comment="策略 key,如 overnight_long") + broker_provider = Column(String(32), nullable=False, comment="qmt / miniqmt / dummy") + broker_account_id = Column(String(64), default="", comment="券商账户ID") + initial_capital = Column(Float, nullable=False, comment="初始资金") + cash = Column(Float, default=0.0, comment="账户现金") + total_equity = Column(Float, default=0.0, comment="账户总资产") + stock_codes = Column(Text, comment="标的列表(JSON 序列化)") + status = Column(String(16), default="active", comment="active / paused / closed") + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + __table_args__ = ( + Index("ix_live_account_strategy", "strategy_key"), + Index("ix_live_account_provider", "broker_provider"), + ) + + +# ========== 实盘持仓 ========== +class LivePosition(Base): + """ + 实盘/准实盘持仓快照 + """ + __tablename__ = "live_position" + + id = Column(Integer, primary_key=True, autoincrement=True) + instance_id = Column(String(64), nullable=False, comment="策略实例ID") + code = Column(String(10), nullable=False, comment="股票代码") + volume = Column(Integer, nullable=False, default=0, comment="总持仓量") + available = Column(Integer, nullable=False, default=0, comment="可卖量") + cost_price = Column(Float, nullable=False, default=0.0, comment="成本价") + current_price = Column(Float, default=0.0, comment="当前价") + source = Column(String(16), default="broker", comment="broker / manual") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + __table_args__ = ( + UniqueConstraint("instance_id", "code", name="uix_live_pos_instance_code"), + Index("ix_live_pos_instance", "instance_id"), + ) + + +# ========== 实盘订单 ========== +class LiveOrder(Base): + """ + 实盘/准实盘订单生命周期记录 + """ + __tablename__ = "live_order" + + id = Column(Integer, primary_key=True, autoincrement=True) + order_id = Column(String(32), nullable=False, unique=True, comment="系统内部订单ID") + instance_id = Column(String(64), nullable=False, comment="策略实例ID") + strategy_key = Column(String(50), nullable=False, comment="策略 key") + broker_provider = Column(String(32), nullable=False, comment="券商提供方") + broker_order_id = Column(String(64), default="", comment="券商侧订单ID") + code = Column(String(10), nullable=False, comment="股票代码") + direction = Column(String(4), nullable=False, comment="BUY / SELL") + signal_date = Column(Date, nullable=False, comment="信号日期") + planned_execute_date = Column(Date, comment="计划执行日") + execute_at = Column(String(16), nullable=False, comment="open / close / next_open") + req_price = Column(Float, default=0.0, comment="请求价格") + req_volume = Column(Integer, nullable=False, comment="请求数量") + status = Column(String(20), nullable=False, default="created", + comment="created / planned / submitted / accepted / rejected / cancelled / filled") + filled_price = Column(Float, default=0.0, comment="成交价格") + filled_volume = Column(Integer, default=0, comment="成交数量") + commission = Column(Float, default=0.0, comment="手续费") + reason = Column(Text, comment="下单原因 / 拒绝原因") + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + __table_args__ = ( + Index("ix_live_order_instance_status", "instance_id", "status"), + Index("ix_live_order_execute_date", "planned_execute_date"), + ) + + # ========== 数据库引擎与会话 ========== def get_engine(): diff --git a/data/sources/tushare_source.py b/data/sources/tushare_source.py index c8833fe..f2abe50 100644 --- a/data/sources/tushare_source.py +++ b/data/sources/tushare_source.py @@ -180,9 +180,11 @@ def get_index_daily( self, code: str, start_date: date, end_date: date ) -> pd.DataFrame: """获取指数日K线""" - # tushare 指数代码格式 - if code.startswith("0"): - ts_code = f"{code}.SH" + # tushare 指数代码格式: + # - 上交所指数常见为 000xxx.SH + # - 深交所指数常见为 399xxx.SZ + if code.startswith("399"): + ts_code = f"{code}.SZ" else: ts_code = f"{code}.SH" logger.debug(f"Tushare: 获取指数日K线 {ts_code}") diff --git a/data/storage/repository.py b/data/storage/repository.py index cf70090..0af4b43 100644 --- a/data/storage/repository.py +++ b/data/storage/repository.py @@ -16,6 +16,9 @@ from data.models import ( AdjFactor, IndexDaily, + LiveAccount as LiveAccountORM, + LiveOrder as LiveOrderORM, + LivePosition as LivePositionORM, PaperAccount as PaperAccountORM, PaperNav, PaperOrder as PaperOrderORM, @@ -375,6 +378,18 @@ class PaperRepository: # ========== paper_account ========== + def list_paper_accounts(self) -> list[PaperAccountORM]: + """列出全部模拟盘账户,按最近更新时间倒序。""" + session = get_session() + try: + return ( + session.query(PaperAccountORM) + .order_by(PaperAccountORM.updated_at.desc(), PaperAccountORM.created_at.desc()) + .all() + ) + finally: + session.close() + def get_paper_account(self, strategy_name: str) -> Optional[PaperAccountORM]: """获取模拟盘账户""" session = get_session() @@ -413,17 +428,21 @@ def update_paper_account( cash: float, total_commission: float, total_tax: float, + stock_codes: Optional[list[str]] = None, ) -> None: """更新账户资金状态""" session = get_session() try: - session.query(PaperAccountORM).filter_by( - strategy_name=strategy_name - ).update({ + update_fields = { "cash": cash, "total_commission": total_commission, "total_tax": total_tax, - }) + } + if stock_codes is not None: + update_fields["stock_codes"] = json.dumps(stock_codes) + session.query(PaperAccountORM).filter_by( + strategy_name=strategy_name + ).update(update_fields) session.commit() except Exception as e: session.rollback() @@ -599,3 +618,221 @@ def get_nav_series( return q.order_by(PaperNav.trade_date).all() finally: session.close() + + +class LiveRepository: + """实盘/准实盘数据仓库,封装 live_* 表操作。""" + + # ========== live_account ========== + + def list_live_accounts(self) -> list[LiveAccountORM]: + session = get_session() + try: + return ( + session.query(LiveAccountORM) + .order_by(LiveAccountORM.updated_at.desc(), LiveAccountORM.created_at.desc()) + .all() + ) + finally: + session.close() + + def get_live_account(self, instance_id: str) -> Optional[LiveAccountORM]: + session = get_session() + try: + return session.query(LiveAccountORM).filter_by(instance_id=instance_id).first() + finally: + session.close() + + def create_live_account( + self, + instance_id: str, + strategy_key: str, + broker_provider: str, + broker_account_id: str, + initial_capital: float, + stock_codes: list[str], + cash: float, + total_equity: float, + ) -> None: + session = get_session() + try: + row = LiveAccountORM( + instance_id=instance_id, + strategy_key=strategy_key, + broker_provider=broker_provider, + broker_account_id=broker_account_id, + initial_capital=initial_capital, + cash=cash, + total_equity=total_equity, + stock_codes=json.dumps(stock_codes), + ) + session.add(row) + session.commit() + except Exception as e: + session.rollback() + logger.error(f"创建 live_account 失败 {instance_id}: {e}") + raise + finally: + session.close() + + def update_live_account( + self, + instance_id: str, + cash: float, + total_equity: float, + stock_codes: Optional[list[str]] = None, + broker_account_id: Optional[str] = None, + status: Optional[str] = None, + ) -> None: + session = get_session() + try: + update_fields = { + "cash": cash, + "total_equity": total_equity, + } + if stock_codes is not None: + update_fields["stock_codes"] = json.dumps(stock_codes) + if broker_account_id is not None: + update_fields["broker_account_id"] = broker_account_id + if status is not None: + update_fields["status"] = status + session.query(LiveAccountORM).filter_by(instance_id=instance_id).update(update_fields) + session.commit() + except Exception as e: + session.rollback() + logger.error(f"更新 live_account 失败 {instance_id}: {e}") + raise + finally: + session.close() + + # ========== live_position ========== + + def get_live_positions(self, instance_id: str) -> list[LivePositionORM]: + session = get_session() + try: + return ( + session.query(LivePositionORM) + .filter_by(instance_id=instance_id) + .order_by(LivePositionORM.code) + .all() + ) + finally: + session.close() + + def replace_live_positions(self, instance_id: str, positions: list[dict]) -> None: + session = get_session() + try: + keep_codes = {p["code"] for p in positions} + if keep_codes: + session.query(LivePositionORM).filter( + LivePositionORM.instance_id == instance_id, + ~LivePositionORM.code.in_(keep_codes), + ).delete(synchronize_session=False) + else: + session.query(LivePositionORM).filter_by(instance_id=instance_id).delete() + + if positions: + stmt = sqlite_insert(LivePositionORM).values(positions) + stmt = stmt.on_conflict_do_update( + index_elements=["instance_id", "code"], + set_={ + "volume": stmt.excluded.volume, + "available": stmt.excluded.available, + "cost_price": stmt.excluded.cost_price, + "current_price": stmt.excluded.current_price, + "source": stmt.excluded.source, + }, + ) + session.execute(stmt) + session.commit() + except Exception as e: + session.rollback() + logger.error(f"替换 live_position 失败 {instance_id}: {e}") + raise + finally: + session.close() + + # ========== live_order ========== + + def save_live_order(self, order: dict) -> None: + session = get_session() + try: + session.add(LiveOrderORM(**order)) + session.commit() + except Exception as e: + session.rollback() + logger.error(f"保存 live_order 失败: {e}") + raise + finally: + session.close() + + def get_live_order(self, order_id: str) -> Optional[LiveOrderORM]: + session = get_session() + try: + return session.query(LiveOrderORM).filter_by(order_id=order_id).first() + finally: + session.close() + + def update_live_order( + self, + order_id: str, + status: str, + broker_order_id: str = "", + filled_price: float = 0.0, + filled_volume: int = 0, + commission: float = 0.0, + reason: str = "", + ) -> None: + session = get_session() + try: + session.query(LiveOrderORM).filter_by(order_id=order_id).update({ + "status": status, + "broker_order_id": broker_order_id, + "filled_price": filled_price, + "filled_volume": filled_volume, + "commission": commission, + "reason": reason, + }) + session.commit() + except Exception as e: + session.rollback() + logger.error(f"更新 live_order 失败 {order_id}: {e}") + raise + finally: + session.close() + + def get_live_orders( + self, + instance_id: str, + status: Optional[str] = None, + start_date=None, + end_date=None, + ) -> list[LiveOrderORM]: + session = get_session() + try: + q = session.query(LiveOrderORM).filter_by(instance_id=instance_id) + if status: + q = q.filter_by(status=status) + if start_date: + q = q.filter(LiveOrderORM.signal_date >= start_date) + if end_date: + q = q.filter(LiveOrderORM.signal_date <= end_date) + return q.order_by(LiveOrderORM.created_at.desc()).all() + finally: + session.close() + + def get_due_live_orders(self, instance_id: str, execute_date: date) -> list[LiveOrderORM]: + session = get_session() + try: + return ( + session.query(LiveOrderORM) + .filter_by( + instance_id=instance_id, + status="planned", + planned_execute_date=execute_date, + ) + .order_by(LiveOrderORM.created_at) + .all() + ) + finally: + session.close() diff --git a/docs/superpowers/specs/2026-04-19-overnight-long-design.md b/docs/superpowers/specs/2026-04-19-overnight-long-design.md index 52e54e4..f95927f 100644 --- a/docs/superpowers/specs/2026-04-19-overnight-long-design.md +++ b/docs/superpowers/specs/2026-04-19-overnight-long-design.md @@ -2,10 +2,12 @@ **日期:** 2026-04-19 **作者:** Mahdi(与 Claude 协作) -**状态:** 已定稿,待实现 +**状态:** 已定稿并完成实现;2026-04-20 从"模式 A 间隔隔夜"切换为"模式 B 连续隔夜"(见文末变更日志) **本 spec 范围:** 回测 + 模拟盘接入(B1) **后续立项:** Phase 7 实盘底座(独立 spec) +> **2026-04-20 重要修订**:原实现每日只生成 SELL **或** BUY 之一(if/elif),导致"买→过夜→卖→休一天→再买"的间隔持仓模式(持仓率 ~50%)。现修正为每日生成 SELL **和** BUY(双独立 if),真正实现"连续隔夜":每天 9:25 开盘卖 + 14:55 尾盘再买。详见 §10 变更日志。 + --- ## 1. 目标 @@ -290,3 +292,25 @@ python scripts/run_paper_trade.py --strategy overnight_long --codes 513090 --sta 2. **一字跌停套牢**:引擎自动取消卖单、次日继续挂卖;极端行情下可能连续多日无法脱困 3. **单标的限制**:策略当前针对单只 513090,多标的场景需调整资金分配逻辑 4. **T+1 规则依赖**:策略假设引擎正确处理 T+1 解冻;`pos.available > 0` 判定是必要守卫 + +--- + +## 10. 变更日志 + +### 2026-04-20 — 模式 A → 模式 B(连续隔夜) + +**背景**:用户实测 2026-01-01~2026-01-20 交易明细后发现模式 A 的"间隔持仓"语义不符合真实的"隔夜多头因子"策略意图,期望每日都持仓过夜。 + +**改动**: +- `on_bar` 的 `if / elif` 改为双独立 `if`,同一根 bar 可返回 `[SELL @ next_open, BUY @ close]` 两个信号 +- 新增 `limit_pct`(默认 0.10)参数,用于一字跌停判定:`bar.open <= pre_close × (1 - limit_pct) + 1e-6` 视为挂卖失败 +- 一字跌停被套时 SELL 与 BUY 同时阻塞(资金被持仓占用),当日零交易 +- 涨跌幅过滤 `min_drop_pct` / `max_rise_pct` 语义收窄为"仅作用于 BUY 分支"(进场条件),SELL 不受影响 +- 单测从 8 → 11,修正 4 个断言 + 新增 3 个(一字跌停阻塞 / 被套次日恢复 / limit_pct 参数) + +**回归验证**: +- 11 个单测全绿 +- 513090 2026-01-01~2026-04-07 回测买卖日期连续(`row[N].sell_date == row[N+1].buy_date`) +- 交易数较模式 A 约翻倍(~393 → ~780 笔) + +**语义修订**:`§1` 的交易规则 2 从"次日开盘卖完即结束"修订为"次日开盘卖完 + 当日尾盘再次满仓买"。`§9` 的风险 2(一字跌停套牢)新增"一字跌停当日 BUY 也跳过"说明。 diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..b2a6045 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1466 @@ +{ + "name": "apex-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "apex-frontend", + "version": "0.1.0", + "dependencies": { + "echarts": "^5.6.0", + "vue": "^3.5.13" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.8.3", + "vite": "^5.4.19", + "vue-tsc": "^2.2.8" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz", + "integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.33", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz", + "integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz", + "integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.33", + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.10", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz", + "integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz", + "integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz", + "integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz", + "integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/runtime-core": "3.5.33", + "@vue/shared": "3.5.33", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz", + "integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "vue": "3.5.33" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz", + "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-sfc": "3.5.33", + "@vue/runtime-dom": "3.5.33", + "@vue/server-renderer": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6f5176e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "apex-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "echarts": "^5.6.0", + "vue": "^3.5.13" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.8.3", + "vite": "^5.4.19", + "vue-tsc": "^2.2.8" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..765eb0b --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts new file mode 100644 index 0000000..66bcd15 --- /dev/null +++ b/frontend/src/api/dashboard.ts @@ -0,0 +1,15 @@ +import type { DashboardPayload } from "../types"; + +const API_BASE = import.meta.env.VITE_API_BASE ?? "http://127.0.0.1:8000"; + +export async function fetchDashboard(accountId?: string): Promise { + const url = new URL("/api/dashboard", API_BASE); + if (accountId) { + url.searchParams.set("account_id", accountId); + } + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`Dashboard API 请求失败: ${response.status}`); + } + return response.json(); +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..b670de8 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,4 @@ +import { createApp } from "vue"; +import App from "./App.vue"; + +createApp(App).mount("#app"); diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..0a4553c --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,66 @@ +export interface AccountOption { + account_id: string; + strategy_key: string; + stock_codes: string[]; + updated_at?: string | null; + created_at?: string | null; +} + +export interface DashboardOverview { + account_id: string; + strategy_key: string; + stock_codes: string[]; + initial_capital: number; + cash: number; + market_value: number; + total_equity: number; + nav: number; + total_profit: number; + total_profit_pct: number; + total_commission: number; + total_tax: number; + position_count: number; + pending_count: number; + latest_trade_date?: string | null; +} + +export interface PositionItem { + code: string; + volume: number; + available: number; + cost_price: number; + current_price: number; + market_value: number; + profit: number; + profit_pct: number; + buy_date?: string | null; +} + +export interface PendingOrderItem { + order_id: string; + code: string; + direction: string; + req_volume: number; + execute_date?: string | null; + signal_date: string; + status: string; + reason: string; +} + +export interface NavPoint { + trade_date: string; + total_equity: number; + cash?: number | null; + market_value?: number | null; + nav?: number | null; + daily_pnl?: number | null; +} + +export interface DashboardPayload { + accounts: AccountOption[]; + selected_account_id?: string | null; + overview?: DashboardOverview | null; + positions: PositionItem[]; + pending_orders: PendingOrderItem[]; + nav: NavPoint[]; +} diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue new file mode 100644 index 0000000..317a966 --- /dev/null +++ b/frontend/src/views/DashboardView.vue @@ -0,0 +1,549 @@ + + + + + diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..2b049b8 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Node", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "types": ["node"] + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..e38f364 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5173, + }, +}); diff --git a/notification/__init__.py b/notification/__init__.py new file mode 100644 index 0000000..aa1fd65 --- /dev/null +++ b/notification/__init__.py @@ -0,0 +1,3 @@ +from notification.email_notifier import EmailNotifier, Notifier + +__all__ = ["EmailNotifier", "Notifier"] diff --git a/notification/email_notifier.py b/notification/email_notifier.py new file mode 100644 index 0000000..1c1a322 --- /dev/null +++ b/notification/email_notifier.py @@ -0,0 +1,69 @@ +""" +邮件通知模块 +""" +from __future__ import annotations + +import smtplib +from email.message import EmailMessage +from typing import Protocol + +from config import NotificationConfig + + +class Notifier(Protocol): + def notify(self, subject: str, body: str) -> None: + ... + + +class EmailNotifier: + def __init__( + self, + smtp_server: str, + smtp_port: int, + sender: str, + password: str, + receiver: str, + ): + self.smtp_server = smtp_server + self.smtp_port = smtp_port + self.sender = sender + self.password = password + self.receiver = receiver + + @classmethod + def from_config(cls) -> "EmailNotifier | None": + if not NotificationConfig.email_enabled: + return None + required = [ + NotificationConfig.email_smtp_server, + NotificationConfig.email_sender, + NotificationConfig.email_password, + NotificationConfig.email_receiver, + ] + if not all(required): + raise ValueError("邮件通知已启用,但 notification.email 配置不完整") + return cls( + smtp_server=NotificationConfig.email_smtp_server, + smtp_port=NotificationConfig.email_smtp_port, + sender=NotificationConfig.email_sender, + password=NotificationConfig.email_password, + receiver=NotificationConfig.email_receiver, + ) + + def notify(self, subject: str, body: str) -> None: + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = self.sender + msg["To"] = self.receiver + msg.set_content(body) + + if self.smtp_port == 465: + with smtplib.SMTP_SSL(self.smtp_server, self.smtp_port, timeout=10) as smtp: + smtp.login(self.sender, self.password) + smtp.send_message(msg) + else: + with smtplib.SMTP(self.smtp_server, self.smtp_port, timeout=10) as smtp: + smtp.ehlo() + smtp.starttls() + smtp.login(self.sender, self.password) + smtp.send_message(msg) diff --git a/plan.md b/plan.md index e473b3d..1002979 100644 --- a/plan.md +++ b/plan.md @@ -295,6 +295,71 @@ class Signal: | 容器化 | Docker + Docker Compose | | 日志监控 | ELK Stack 或 Loki + Grafana | +### 4.1 Web 技术路线 + +基于当前项目现状(核心业务已用 Python 实现,`api/` 基本为空,`frontend/` 尚未初始化),Web 技术路线确定为: + +- **后端**:FastAPI + Pydantic + SQLAlchemy +- **前端**:Vue 3 + TypeScript + Vite +- **图表**: + - 第一阶段:ECharts(净值、收益、统计图) + - 第二阶段:TradingView Lightweight Charts(如需专业 K 线) +- **通信方式**: + - 第一阶段:REST API + 轮询 + - 第二阶段:WebSocket(实时推送) +- **部署方式**:前后端分离,前端静态构建,Nginx/Caddy 反向代理 + +**不采用的路线:** + +- 不把现有 Python 核心业务重写成 Go 单体 +- 不采用 Next.js/全 TypeScript 全栈来承接现有回测与模拟盘逻辑 + +理由: + +1. 现有数据、回测、模拟盘逻辑已经稳定在 Python 中 +2. FastAPI 最容易复用现有 repository / ORM / paper 账户能力 +3. Vue 3 + Vite 足以支撑 Dashboard / 回测中心 / 策略管理台 +4. 可以先用 REST 快速出结果,后续再逐步补实时能力 + +### 4.2 通知技术路线 + +通知路线优先级确定为: + +1. **邮件通知** +2. **企业微信机器人** +3. **不做个人微信** + +当前第一阶段只规划 **邮件通知**。 + +**邮件通知路线:** + +- **实现方式**:Python 标准库 `smtplib` + `email.message` +- **配置来源**:`config/settings.yaml` 中的 `notification.email` +- **敏感信息管理**: + - 优先使用邮箱 SMTP 授权码 / app password + - 不直接使用邮箱登录密码 +- **设计要求**: + - 提供统一通知接口,后续可平滑扩展企业微信机器人 + - 失败时必须记录日志,不允许静默吞没 + - 默认只做文本通知,HTML 富文本可后续再加 + +**第一批接入点:** + +- `scripts/run_paper_trade.py`:每日运行摘要 / 异常失败 +- `scripts/daily_update.py`:数据更新异常 / 缺失校验异常 +- 系统级异常:关键流程失败时告警 + +**后续扩展:** + +- 第二阶段再接企业微信机器人(群 Webhook) +- 若未来需要 Web UI 中的通知中心,再统一接入数据库或消息中心 + +**不采用个人微信通知的原因:** + +- 缺少稳定、官方、可长期维护的服务端接口 +- 常见方案依赖逆向协议或客户端自动化,稳定性和合规性都较差 +- 不适合作为交易/模拟盘系统的正式通知链路 + --- ## 5. 项目目录结构 @@ -366,26 +431,29 @@ a-stock-trading-system/ ## 6. 开发路线图 +> 说明:本节已按 **2026-04-20 当前代码状态** 同步。以下勾选反映的是仓库里**已经落地并可运行/验证**的内容,不是最初草案状态。 + ### Phase 1 — 数据基础(第 1-2 周) - [ ] 搭建 PostgreSQL + TimescaleDB 数据库 -- [ ] 实现 Tushare / AKShare 数据源适配器 -- [ ] 完成股票日K线、基础信息、交易日历的采集与存储 -- [ ] 实现增量更新和复权因子维护 -- [ ] 编写数据质量校验脚本 +- [x] 实现 Tushare / AKShare 数据源适配器 +- [x] 完成股票日K线、基础信息、交易日历的采集与存储 +- [x] 实现增量更新和复权因子维护 +- [x] 编写数据质量校验脚本 ### Phase 2 — 回测引擎(第 3-5 周) -- [ ] 实现策略基类和信号定义 -- [ ] 实现回测撮合引擎(含A股特殊规则) -- [ ] 实现账户管理(资金、持仓、冻结) -- [ ] 实现费用模型(佣金、印花税、过户费) -- [ ] 实现绩效评估指标计算 -- [ ] 用均线交叉策略跑通完整回测流程 +- [x] 实现策略基类和信号定义 +- [x] 实现回测撮合引擎(含A股特殊规则) +- [x] 实现账户管理(资金、持仓、冻结) +- [x] 实现费用模型(佣金、印花税、过户费) +- [x] 实现绩效评估指标计算 +- [x] 用均线交叉策略跑通完整回测流程 ### Phase 3 — 策略库(第 6-8 周,持续迭代) -- [ ] 实现 3-5 个经典技术指标策略 +- [x] 已实现部分策略:`ma_cross` / `macd` / `limitdown_short` / `overnight_long` +- [ ] 继续补齐 3-5 个经典技术指标策略(KDJ、布林带、RSI 等) - [ ] 实现基本面筛选策略 - [ ] 实现多因子评分策略 - [ ] 策略参数优化工具(网格搜索 / 遗传算法) @@ -401,10 +469,10 @@ a-stock-trading-system/ ### Phase 5 — 模拟交易(第 11-12 周) -- [ ] 实现模拟交易接口 -- [ ] 策略信号 → 模拟下单全流程打通 -- [ ] 实时监控持仓和盈亏 -- [ ] 交易日志记录与复盘 +- [x] 实现模拟交易接口 +- [x] 策略信号 → 模拟下单全流程打通 +- [x] 提供基础监控能力(CLI 状态/持仓/净值/挂单查询) +- [x] 交易日志记录与复盘(订单、持仓、净值持久化) ### Phase 6 — Web 可视化(第 13-16 周) @@ -459,6 +527,431 @@ a-stock-trading-system/ - [ ] 开户券商是哪家?(影响交易接口选择) - [ ] 部署环境(本地 / 云服务器)? +### 当前进度总结(2026-04-20) + +- 已完成: + - 数据采集与落库主链路 + - 回测引擎主链路 + - 模拟盘主链路 + - `overnight_long` 全链路修复(回测 T 日收盘买、T+1 日开盘卖;模拟盘 14:55 买、次日开盘卖) + - 交易明细 Daily P&L Journal 输出 + - 回测统计、初始资金口径、卖出整手规则、模拟盘账户隔离 + +- 进行中: + - 文档与计划同步维护 + - 策略库逐步扩展 + +- 未开始 / 未完成: + - 风控模块正式落地 + - FastAPI / Web 前端 + - 实盘对接 + +### Phase 7 后续 TODO(按优先级) + +- [ ] 在真实 QMT / miniQMT 环境完成联调: + - 验证 `xtquant` 可导入 + - 验证 connect / subscribe + - 验证账户查询 + - 验证持仓查询 + - 验证最小测试单下单 / 撤单 / 订单状态查询 +- [ ] 接入邮件通知到 live chain: + - `run_live_trade.py` 运行摘要 + - broker 连接失败 + - 订单提交失败 + - planned 订单激活失败 +- [ ] 增加 live 对账能力: + - 本地 `live_account / live_position / live_order` 与券商状态只读比对 + - 输出差异报告 +- [ ] 增加 live 风控兜底: + - 单票最大仓位检查 + - 总仓位上限检查 + - 非交易时段禁止提交 + - 非法价格 / 数量拦截 +- [ ] 在 Web 中增加 live 只读看板: + - live 账户概览 + - live 持仓 + - live 订单状态 + - planned / submitted / accepted / rejected / cancelled 状态展示 +- [ ] 再进入更完整的实盘策略运行与小资金验证 + +--- + +## 9. 迭代日志 + +### 2026-04-23 — Phase 7B(QMT / miniQMT adapter)推进 + +**目标**:在不依赖真实 QMT 环境的前提下,把 `QmtBroker` 从占位壳推进到“可联调状态”。 + +**关联研究**:见 `research.md` → "2026-04-23 Phase 7B(QMT / miniQMT adapter)研究补充" + +**本轮范围**: + +| 模块 | 范围 | +|------|------| +| QMT Adapter | connect / subscribe / query account / query positions / submit order / cancel / list orders | +| Live Engine | planned `next_open` 到期激活 | +| CLI | `--mode live --provider qmt|miniqmt` 接通 | +| 测试 | fake xtquant 映射测试 + 计划单激活测试 | + +**不做**: + +- 真实 QMT / miniQMT 联调 +- 成交回报 callback 持久化 +- 自动对账修复 +- 风控联动 + +**验收标准**: + +1. `QmtBroker` 不再是空壳 +2. `miniqmt` 与 `qmt` 共享 adapter 路径 +3. planned `next_open` 订单会在执行日激活 +4. fake xtquant 测试通过 +5. 全量 `pytest` 通过 + +### 2026-04-23 — Phase 7(实盘交易基座)启动计划 + +**目标**:启动 Phase 7,但第一阶段只做“实盘交易基座”,不直接进入真实券商下单。 + +**关联研究**:见 `research.md` → "2026-04-23 Phase 7(实盘交易基座)启动研究" + +**当前判断**: + +- `trading/broker/` 为空,缺少统一 broker 适配层 +- 当前只有 paper 链路,没有 live 链路 +- `risk/` 尚未落地,因此 Phase 7A 不能依赖完整风控模块 +- 真实券商(QMT / miniQMT)细节尚未最终确定,先做 dry-run 基座更稳 + +**本轮建议范围(待你确认后执行)**: + +| 模块 | 范围 | +|------|------| +| Broker 抽象 | 定义统一 broker 接口与数据结构 | +| Live Engine | 新增实盘执行引擎骨架 | +| DryRun | 提供 `DryRunBroker` 作为 Phase 7A 默认执行器 | +| 数据持久化 | 新增 live 账户 / 订单等最小表结构 | +| CLI | 新增 `scripts/run_live_trade.py` | +| 配置 | 在 `settings.yaml` 新增 broker 段 | + +**拟改动文件**: + +| 文件 | 改动 | +|------|------| +| `trading/broker/base.py` | 新增统一 broker 抽象 | +| `trading/broker/dry_run.py` | 新增 DryRunBroker | +| `trading/broker/qmt_broker.py` | 新增 QMT 适配器占位或最小壳 | +| `trading/live_engine.py` | 新增实盘执行引擎骨架 | +| `data/models.py` | 新增 live 账户 / 订单表 | +| `data/storage/repository.py` | 新增 live 表 CRUD | +| `config/settings.yaml` | 新增 `broker` 配置段 | +| `config/__init__.py` | 新增 broker 配置加载 | +| `scripts/run_live_trade.py` | 新增实盘执行 CLI | +| `README.md` | 补充 Phase 7A 启动说明 | +| `process.txt` | 记录本次 Phase 7A 基座变更 | + +**实现顺序**: + +1. 先定义 broker 抽象和数据结构 +2. 再补 live ORM / repository +3. 实现 `DryRunBroker` +4. 实现 `live_engine` +5. 增加 `run_live_trade.py` +6. 最后补 README / process / 基础测试 + +**本轮不做**: + +- 真实资金下单 +- 完整 QMT / miniQMT 联调 +- 完整成交回报同步 +- 风控中心联动 +- 实盘 GUI 交易台 + +**验收标准**: + +1. 存在统一 broker 抽象层 +2. 存在 `DryRunBroker` +3. 存在 `run_live_trade.py` +4. 策略信号能被翻译成统一订单请求并通过 DryRunBroker 执行 +5. 订单结果能落库并可查询 +6. 后续切换到真实 broker 时,不需要重写 live engine + +**等待确认**:按项目规范,Phase 7 属于中大型任务。若你确认按上述 Phase 7A 范围推进,请直接回复 `GO`,我再开始编码。 + +### 2026-04-20 — Phase 6(Web 可视化)启动计划 + +**目标**:开始 Phase 6,但不一次性铺满全部 Web 功能;先落地“后端 API + 前端 Dashboard 首屏”的最小闭环。 + +**关联研究**:见 `research.md` → "2026-04-20 Phase 6(Web 可视化)启动研究" + +**当前判断**: + +- `api/` 基本为空,需要从 FastAPI 应用入口开始搭 +- `frontend/` 尚不存在,需要初始化 Vue 3 + TypeScript + Vite +- `risk/` 仍未实现,因此本轮不做真正的风控中心,只保留 Phase 6 路线中的后续项 + +**本轮建议范围(待你确认后执行)**: + +| 模块 | 范围 | +|------|------| +| 后端 | FastAPI 应用入口、只读 dashboard API、OpenAPI docs | +| 前端 | Vue 3 脚手架、Dashboard 首屏、账户概览/持仓/挂单/净值曲线 | +| 数据来源 | 仅复用现有 `PaperRepository`,不新增交易逻辑 | + +**拟改动文件**: + +| 文件 | 改动 | +|------|------| +| `api/main.py` | 新增 FastAPI 入口,挂载路由 | +| `api/schemas.py` | 新增接口返回 schema | +| `api/dependencies.py` | 新增 account_id/参数解析等依赖 | +| `api/routers/dashboard.py` | 新增 Dashboard 只读接口 | +| `api/routers/__init__.py` | 导出路由 | +| `frontend/package.json` | 新增前端依赖与脚本 | +| `frontend/vite.config.ts` | 新增 Vite 配置 | +| `frontend/src/main.ts` | 前端入口 | +| `frontend/src/App.vue` | 根组件 | +| `frontend/src/views/DashboardView.vue` | Dashboard 页面 | +| `frontend/src/api/dashboard.ts` | 前端调用后端 API 的封装 | +| `README.md` | 补充 Phase 6 启动方式(后端/前端开发命令) | +| `process.txt` | 记录本次 Phase 6 启动变更 | + +**实现顺序**: + +1. 先搭 FastAPI 应用骨架和 `/docs` +2. 暴露 Dashboard 所需只读接口 +3. 初始化 Vue 3 + TypeScript + Vite +4. 做 Dashboard 首屏 +5. 接通前后端 +6. 补测试 / 基本运行验证 + +**本轮不做**: + +- 回测中心完整页面 +- 策略管理写接口 +- 风控中心真实功能 +- WebSocket 实时行情 + +**验收标准**: + +1. 后端可通过 `uvicorn api.main:app --reload` 启动 +2. `/docs` 可访问 +3. 前端可通过 `npm run dev` 启动 +4. Dashboard 页面能展示: + - 账户概览 + - 当前持仓 + - 待执行订单 + - 最近净值曲线 +5. 桌面端和移动端可正常显示 + +**等待确认**:按项目规范,Phase 6 属于中大型任务。若你确认按上述范围推进,请直接回复 `GO`,我再开始编码。 + +### 2026-04-20 — 交易明细重构:round-trip 视角 → 每日视角(Daily P&L Journal) + +**目标**:修正用户体验问题——原 schema 第一行横跨两天(`买入日=01-05 / 卖出日=01-06`),被直觉误读为"01-05 既买又卖"。改为每行 = 一个交易日。 + +**关联研究**:见 `research.md` → "2026-04-20 交易明细重构:round-trip 视角 → 每日视角" + +**新 schema(15 列)** + +``` +代码 日期 动作 开盘价 买入价 收盘价 卖出价 卖出份额 买入份额 佣金 净盈 收益率% 持仓天数 净值 动作备注 +``` + +**动作分类**:建仓(只买)/ 换仓(先卖后买)/ 平仓(只卖)。 + +**核心改动**: +| 文件 | 改动 | +|------|------| +| `backtest/engine.py` | 新增 `_daily_actions` 暂存 + `_finalize_daily_trade` 日末聚合;`_process_signal` 不再直接 append `_trades` | +| `scripts/run_backtest.py` | TRADE_COLUMNS 重排 + CSV 整数列空值处理(避免 `9000.0`) | +| `README.md` | 字段说明表扩展为建仓/换仓/平仓三态 | + +**口径分离**: +- `佣金` = 当日现金流口径(买+卖都计) +- `净盈` = round-trip 口径(上次买→本次卖,只扣两端佣金) +- 两者**不严格对齐**——换仓日新建仓佣金归属到下次平仓 + +**验证**:pytest 11/11;513090 2026-01-05~01-12 6 行输出 = 1 建仓 + 5 换仓,跨周末持仓天数=3。 + +--- + +### 2026-04-20 — 滑点模型:绝对值 → 百分比 + CLI 可配 + +**目标**:修正 `slippage: 0.01`(绝对元)的两个设计缺陷——低价 ETF 摩擦过重、默认无法关闭。 + +**关联研究**:见 `research.md` → "2026-04-20 滑点模型:绝对值 → 百分比 + CLI 可配" + +**核心改动**: + +| 文件 | 改动 | +|------|------| +| `config/settings.yaml` | `slippage: 0.01` → `slippage_rate: 0.0`(默认关闭) | +| `config/__init__.py` | `BacktestConfig.slippage` → `BacktestConfig.slippage_rate` | +| `backtest/engine.py` | 公式改 `exec_price × (1 ± slippage_rate)` + `round(3)`;`or` 改 `is not None` 防 falsy 坑;rate=0 时短路跳过 | +| `scripts/run_backtest.py` | 新增 `--slippage-rate RATE`,`default=None` 区分"未传" vs "传 0" | + +**优先级**:`CLI --slippage-rate N > settings.yaml slippage_rate > 代码兜底 0.0` + +**验证**:pytest 11/11 全绿;513090 冒烟回测 rate=0 时 buy_price=bar.close 精确匹配,rate=0.0005 时 1.741→1.742、1.728→1.727 符合 `round(raw × 1.0005, 3)`。 + +--- + +### 2026-04-20 — overnight_long 切换为连续隔夜模式(模式 A → B) + +> 历史记录:本段描述的是“模式 A → 模式 B”的中间迭代,后续已被“overnight_long 全链路 bug 修复(时序 / 统计 / 配置)”一节覆盖。若以当前代码为准,请优先阅读后文的全链路修复条目。 + +**目标**:修正 overnight_long 策略语义。原版实现"间隔一天持仓"(持仓率 50%),用户期望"每天都持仓过夜"(持仓率 100%),即每天 9:25 开盘卖 + 14:55 尾盘再买。 + +**关联研究**:见 `research.md` → "2026-04-20 overnight_long 切换为连续隔夜模式" + +**核心改动**: + +| 文件 | 改动 | +|------|------| +| `strategy/technical/overnight_long.py` | `on_bar` 从 `if / elif` 改为双独立 `if`;新增 `limit_pct` 参数 + 一字跌停判定(浮点容差 1e-6) | +| `config/strategies.yaml` | overnight_long 节新增 `limit_pct: 0.10` | +| `tests/test_overnight_long.py` | 8 → 11 用例:改 4 个断言(held_position / min_drop / max_rise / two_day_cycle),新增 3 个(一字跌停阻塞 / 被套次日恢复 / limit_pct 参数) | +| `research.md` / `plan.md` / `README.md` / `process.txt` / spec | 文档同步 | + +**不改动**:`backtest/engine.py` / `backtest/account.py` / 交易明细输出(信号协议不变,引擎/账户对策略透明)。 + +**策略边界规则**: + +- **有持仓 + 正常 bar** → 生成 `[SELL @ next_open, BUY @ close]`,引擎按列表顺序撮合(先卖释放资金,后买满仓) +- **有持仓 + 一字跌停**(`bar.open <= pre_close × (1 - limit_pct) + 1e-6`)→ SELL 阻塞,BUY 阻塞,当日零信号 +- **T+1 冻结日**(持仓存在但 `available = 0`)→ SELL 跳过,BUY 也跳过(仓位仍在,资金被占) +- **涨跌幅过滤**(`min_drop_pct` / `max_rise_pct`)仅作用于 BUY 分支,SELL 不受影响 + +**撮合顺序依赖**:同根 bar 返回 `[SELL, BUY]` 顺序关键,依赖上次迭代已修复的 list[Signal] 处理。 + +**验收**: + +- 11 个单测全绿(其中 1 个测试揭示了 `currently_empty` 逻辑 bug,修正后通过) +- 513090 2026-01-01~2026-04-07 回测:64 笔交易,买卖日连续(`row[N].sell_date == row[N+1].buy_date`) +- 交易数约为模式 A 两倍(从 ~393 → ~780 笔全区间预估) + +--- + +### 2026-04-20 — overnight_long 全链路 bug 修复(时序 / 统计 / 配置) + +**目标**:把 `overnight_long` 修回用户确认的严格语义: + +- 回测:T 日 `close` 买入,T+1 日 `open` 卖出 +- 模拟盘:T 日 14:55 近似 `close` 买入,T+1 日集合竞价挂跌停价卖出,成交价等价于 `open` + +同时修复与该策略直接相关的高优先级基础设施 bug:`next_open` 语义、paper 执行链、策略配置注入、回测统计口径、paper 账户隔离、卖出整手规则。 + +**关联研究**:见 `research.md` → "2026-04-20 overnight_long 全链路 bug 修复研究" + +**改动文件**: + +| 文件 | 改动 | +|------|------| +| `strategy/technical/overnight_long.py` | 重写 `on_bar`:从“持仓日返回 SELL+BUY”改为“空仓日返回 BUY@close + SELL@next_open,持仓日只返回 SELL@next_open” | +| `backtest/engine.py` | 新增真正的 `next_open` pending 队列;次日开盘先执行 pending,再运行策略;回测结果显式携带 `initial_capital` | +| `trading/paper_engine.py` | 修正 `next_open` 卖单量解析与创建逻辑,使“今日收盘买、明日开盘卖”在 paper 端闭环成立 | +| `strategy/registry.py` | 合并 `config/strategies.yaml` 中对应策略默认参数(忽略 `enabled`) | +| `backtest/metrics.py` | 适配 Daily P&L Journal schema,按“存在卖出闭环”统计交易次数/胜率/持仓天数;接收真实 `initial_capital` | +| `scripts/run_paper_trade.py` | 生成稳定 `account_id`,让模拟盘账户按策略 / 参数 / 标的自动隔离 | +| `backtest/rules.py` | 显式校验卖出数量合法性,修复“>100 股非整手部分卖出”被放行的问题 | +| `data/sources/tushare_source.py` | 顺手修复深市指数代码错误拼成 `.SH` 的问题 | +| `tests/test_overnight_long.py` | 更新策略单测断言为新语义 | +| `tests/test_overnight_long_engines.py` | 覆盖 overnight_long 在 backtest/paper 的真实时序 | +| `tests/test_paper_trade_helpers.py` | 锁定 account_id 的稳定性与隔离性 | +| `tests/test_trading_rules.py` | 锁定卖出整手规则 | +| `README.md` / `research.md` / `process.txt` | 文档与变更日志同步 | + +**影响评估**: + +1. **`overnight_long` 行为会变化** + - 旧版:依赖错误的回测 `next_open` 语义,模拟盘会跑偏 + - 新版:回测与模拟盘统一到“今日尾盘买,明早开盘卖” + +2. **其它默认使用 `next_open` 的策略会被一并修正** + - `ma_cross` + - `macd` + 这是正向修复:原实现存在 lookahead bias,修复后会变成真正“次日开盘成交” + +3. **回测摘要数字会变化** + - `total_trades` / `win_rate` / `avg_holding_days` 会从错误值修为真实值 + - `初始资金` / `总收益率` / `总盈亏` 会去掉首日手续费污染 + +4. **模拟盘账户将自动隔离** + - 相同策略 key 但不同 `--params` / `--codes` 会生成不同账户 + - `--status` / `--history` 需用与运行时相同的策略 / 参数 / 标的组合查询 + +**不改动**: + +- 费用模型费率本身(`backtest/fee.py`) +- `limitdown_short` 的理论回测模型 +- API / risk 占位模块 + +**执行顺序**: + +1. 先修 `load_strategy()`,让 `strategies.yaml` 真正生效 +2. 重构 `overnight_long` 信号语义 +3. 修回测引擎 `next_open` 为真正跨日执行 +4. 修 paper engine 的 pending 创建与量解析 +5. 修 paper 账户隔离与卖出整手规则 +6. 修 metrics / summary +7. 补集成测试并跑 `pytest` +8. 更新 README / process.txt + +**验收标准**: + +1. `load_strategy('overnight_long').config` 默认包含 `strategies.yaml` 中的参数 +2. backtest 中 `overnight_long` 首日为 `BUY@close`,次日卖价精确取次日 `open` +3. paper 中 Day1 运行后有 1 笔 `BUY@close` + 1 笔 Day2 `pending SELL`;Day2 运行后该卖单成交且可再次生成 Day3 卖单 +4. 回测摘要中的 `total_trades` 不再恒为 0 +5. 模拟盘账户 ID 对相同策略/参数/标的稳定,对不同参数或标的不同 +6. 非法部分 odd-lot 卖单会被拒绝,整手卖出与“一次性卖出全部零股”仍允许 +7. pytest 全绿 + +### 2026-04-19 — 交易明细扩展 + profit 计算修正 + +**目标**:让回测产出的交易明细包含用户要求的 11 个字段,同时修复引擎 `profit` 只扣单边佣金的 bug。 + +**关联研究**:见 `research.md` → "2026-04-19 交易明细扩展 / profit 计算修正" + +**改动清单**: + +| 文件 | 改动 | +|------|------| +| `backtest/engine.py` | `_buy_records` 补 `commission` + `bar_open`;`_trades` 新增 `buy_open / sell_close / commission / net_equity`;修正 `profit` 扣两边佣金 | +| `scripts/run_backtest.py` | `_print_trades` 重写为 14 列中文表头;CSV 导出前 rename 为中文列名 | +| `research.md` | 新建,记录研究结论 | +| `README.md` | 补充交易明细新格式说明 | +| `process.txt` | 追加变更日志 | + +**不改动**:`backtest/account.py` / `backtest/fee.py` / `backtest/metrics.py` / 所有策略文件 / 配置文件。 + +**输出字段设计**(中文表头,14 列): + +``` +代码 | 买入日 | 卖出日 | 开盘价 | 买入价 | 收盘价 | 卖出价 | 份额 | 佣金 | 净盈 | 收益率% | 持仓天数 | 净值 | 卖出原因 +``` + +- 开盘价 = 买入日 `bar.open` +- 收盘价 = 卖出日 `bar.close` +- 佣金 = 买入佣金 + 卖出佣金(ETF 无印花税/过户费,合并为单列) +- 净盈 = `(卖出价-买入价)*份额 - 佣金合计` +- 净值 = 卖出日 `account.equity_curve[date].total_equity` + +**费用模型确认**(无需代码改动): + +- 万1费率:`settings.yaml:35 commission_rate: 0.0001` ✓ +- 免5最低:`settings.yaml:37 min_commission: 0.0` ✓ +- ETF 免印花税:`fee.py:57` + `account.py:82,147` 自动传 `is_etf` ✓ +- ETF 免过户费:`fee.py:62` ✓ + +**验收标准**: + +1. 控制台和 CSV 明细均为 14 列中文表头 +2. `profit` 扣两边佣金后,与 `account.equity_curve` 每日资产差分一致 +3. 513090 场景下"印花税/过户费"= 0(已合入 commission 单列) +4. `metrics` 指标(win_rate / avg_holding_days / sharpe 等)不变 + +**执行顺序**:research.md → plan.md → **用户 GO** → engine 改动 → run_backtest 改动 → 回测验证 → README + process.txt 更新 → commit + push。 + --- -*文档版本:v1.0 | 创建日期:2026-03-17* +*文档版本:v1.1 | 创建日期:2026-03-17 | 最后更新:2026-04-19* diff --git a/process.txt b/process.txt index 31b5f26..4e55b35 100644 --- a/process.txt +++ b/process.txt @@ -1,3 +1,55 @@ +[2026-04-24 00:10] test 增加 Dashboard API 路由逻辑测试 +- 改动文件:tests/test_api_dashboard.py, process.txt +- 改动说明:为 Phase 6 Dashboard 只读接口补充回归测试,避免后续继续演进 live / paper / Web 时把账户列表与概览接口逻辑改坏。新增 `tests/test_api_dashboard.py`,覆盖四类行为:`health()` 健康检查返回、`/api/dashboard/accounts` 在 `stock_codes` 缺失时从持仓回退推导标的列表、`/api/dashboard` 正常返回 overview/positions/pending_orders/nav 结构,以及未知 `account_id` 时抛出 404。由于当前环境下 `FastAPI TestClient` 存在不稳定卡住现象,本轮测试采用直接调用路由函数的方式锁定接口逻辑本身。验证结果:新增 API 测试 4 条通过,全量 `pytest -q` 为 27 passed。 + +[2026-04-24 00:20] chore 收紧 Web 测试相关依赖版本,规避 ASGI 测试卡住问题 +- 改动文件:requirements.txt, process.txt +- 改动说明:进一步处理 FastAPI/ASGI 测试在当前环境组合下不稳定卡住的问题。根因是本地环境实际安装到了 `httpx 0.28.1`,而当前项目的 `requirements.txt` 仅写了 `httpx>=0.27.0`,允许安装到 0.28.x;在当前 FastAPI/Starlette/httpx 组合下,最小 ASGITransport 例子也会卡住。为避免本地与 CI 随机拉到不稳定版本,本轮将 Web 相关依赖收紧为:`fastapi>=0.109.0,<0.137`、`httpx>=0.27.0,<0.28`。这样后续重新安装开发依赖后,ASGI/TestClient 测试会回到稳定区间。当前 API 测试仍保留为路由逻辑测试,保证测试链不中断;后续若统一升级 FastAPI/Starlette/httpx,再恢复更完整的 ASGI 集成测试。 + +[2026-04-23 12:40] feat 接入邮件通知到 live chain +- 改动文件:config/__init__.py, notification/__init__.py, notification/email_notifier.py, scripts/run_live_trade.py, trading/live_engine.py, tests/test_email_notifier.py, tests/test_live_trade.py, README.md, process.txt +- 改动说明:按既定通知路线优先实现邮件通知,并接入实盘基座链路。新增 `NotificationConfig` 读取 `notification.email` 配置;新增 `EmailNotifier`,基于 Python 标准库 `smtplib + email.message` 实现文本邮件发送,并支持 `465 -> SMTP_SSL`、其他端口 -> `SMTP + STARTTLS`。在 `run_live_trade.py` 中接入通知初始化、成功日报摘要邮件和 fatal 异常邮件;在 `LiveEngine` 中接入 broker 提交失败与 planned 订单激活失败告警。为保证后续不回归,新增 `tests/test_email_notifier.py` 验证 SMTP 发送链路与配置开关,并扩展 `tests/test_live_trade.py` 验证 planned 订单激活失败时会触发 notifier。README 同步补充了 live chain 邮件通知的配置方式、触发场景和使用说明。验证结果:`pytest -q` 为 23 passed。 + +[2026-04-23 12:10] feat 推进 Phase 7B(QMT adapter + 计划单到期激活链) +- 改动文件:config/settings.yaml, config/__init__.py, data/storage/repository.py, trading/broker/qmt_broker.py, trading/broker/dry_run.py, trading/live_engine.py, scripts/run_live_trade.py, README.md, tests/test_live_trade.py, tests/test_qmt_broker.py +- 改动说明:在 Phase 7A 基座之上继续推进到可联 QMT 的 Phase 7B。新增 `broker.qmt` 配置段(`userdata_path/session_id/account_type/dynamic_price_type/strategy_name/order_remark_prefix`),并补到 `BrokerConfig`。`QmtBroker` 从占位壳升级为真实 adapter:支持 xtquant 懒加载、连接、订阅、账户查询、持仓查询、即时订单提交、撤单和订单列表映射;当 `execute_at=next_open` 时先按 planned 落库,等待到期后由 `LiveEngine` 激活。为此补充了 `LiveRepository.get_live_order()` / `get_due_live_orders()`,并在 `LiveEngine` 中加入“计划单到期激活”链路;`DryRunBroker` 也同步支持已存在 planned 订单在到期日转为 submitted。新增测试 `tests/test_qmt_broker.py`,用 fake xtquant 模块验证账户/持仓/下单映射;同时扩展 `tests/test_live_trade.py`,验证 planned 订单会在执行日被激活。验证结果:`pytest -q` 为 20 passed。说明:当前 QMT adapter 已具备代码能力,但仍需真实 QMT / miniQMT 环境完成最终联调。 + +[2026-04-23 11:45] chore 配置 PR/Push 自动化 CI 并完成依赖分层 +- 改动文件:.github/workflows/ci.yml, requirements.txt, requirements-dev.txt, requirements-strategy.txt, process.txt +- 改动说明:为后续 PR / Push 引入最小可用的 GitHub Actions 自动化测试,同时直接消除此前遗留的 `requirements-ci.txt` 过渡方案。新增 `backend-tests` 和 `frontend-build` 两个 job:后端使用 Python 3.11 安装 `requirements-dev.txt` 后执行 `pytest -q`,前端使用 Node 20 执行 `npm ci && npm run build`。依赖体系正式调整为三层:`requirements.txt` 作为基础运行依赖,`requirements-dev.txt` 用于开发/测试,`requirements-strategy.txt` 收纳 `ta-lib` / `pandas-ta` 这类当前仓库未实际引用、但未来策略扩展可能使用的重型依赖。这样既保持主 CI 稳定,又把技术债真正清掉,避免以后忘记处理。 + +[2026-04-23 11:20] feat 启动 Phase 7A 实盘交易基座(broker 抽象 + dry-run 执行链) +- 改动文件:config/settings.yaml, config/__init__.py, data/models.py, data/storage/repository.py, trading/broker/__init__.py, trading/broker/base.py, trading/broker/dry_run.py, trading/broker/qmt_broker.py, trading/live_engine.py, scripts/run_live_trade.py, README.md, research.md, plan.md, tests/test_live_trade.py +- 改动说明:按已确认的 Phase 7A 范围先落“实盘交易基座”,而不是直接联真实券商。新增 `broker` 配置段并补 `BrokerConfig`;在 ORM 层增加 `live_account` / `live_position` / `live_order` 三张表,并在 `LiveRepository` 中提供 live 账户、持仓、订单的 CRUD。新增统一 broker 抽象 `BaseBroker` 与标准数据结构 `BrokerAccount` / `BrokerPosition` / `BrokerOrderRequest` / `BrokerOrder`,并实现 `DryRunBroker` 用于把订单请求落库;同时新增 `QmtBroker` 占位壳,明确 Phase 7A 还未接真实 QMT。新增 `LiveEngine`,将策略信号翻译为统一订单请求并交由 broker 执行;其中为兼容 `overnight_long`,补了“空仓日 BUY@close + SELL@next_open”场景下的次日卖单量估算逻辑。新增 `scripts/run_live_trade.py` 作为实盘基座 CLI,支持按策略 / 参数 / 标的生成稳定实例ID并运行 dry-run broker。README 同步补充了 Phase 7A 的定位、限制、配置和运行方式。新增测试 `tests/test_live_trade.py`,验证 DryRunBroker 落单和 `overnight_long` 在 live_engine 中能生成 `BUY@close + SELL@next_open` 两笔统一订单请求。验证结果:`pytest -q` 为 18 passed。 + +[2026-04-23 10:40] feat 启动 Phase 6 最小 Web Dashboard 闭环 +- 改动文件:api/main.py, api/dependencies.py, api/schemas.py, api/routers/__init__.py, api/routers/dashboard.py, data/storage/repository.py, trading/account_id.py, trading/paper_account.py, trading/paper_engine.py, scripts/run_paper_trade.py, frontend/package.json, frontend/tsconfig.json, frontend/vite.config.ts, frontend/index.html, frontend/src/main.ts, frontend/src/App.vue, frontend/src/views/DashboardView.vue, frontend/src/api/dashboard.ts, frontend/src/types.ts, .gitignore, README.md +- 改动说明:按已确认的 Web 技术路线(FastAPI + Vue 3 + TypeScript + Vite + ECharts)落地 Phase 6A/6B 的最小可运行闭环。后端新增 FastAPI 入口、CORS 配置、`/health`、`/api/dashboard/accounts`、`/api/dashboard` 三个只读接口,直接复用 `PaperRepository` 提供模拟盘账户、持仓、待执行订单和净值数据;同时将模拟盘 `account_id` 生成逻辑抽到 `trading/account_id.py` 供 CLI 与 Web API 共用。为让 Dashboard 正确展示标的列表,补充了 `PaperRepository.list_paper_accounts()`,并在 `PaperAccount.save()` / `update_paper_account()` 链路中把 `stock_codes` 同步写回数据库;旧账户若历史上未写入 `stock_codes`,API 会自动回退到持仓推导。前端新增 Vue 3 + Vite + TypeScript 骨架和一个最薄的 Dashboard 首屏,支持账户切换、概览卡片、持仓表、挂单表与净值曲线展示。README 同步补充了 Phase 6 的启动方式、接口列表、前后端开发命令和当前限制。验证结果:后端应用可导入,路由存在 `/health`、`/api/dashboard/accounts`、`/api/dashboard`;Repository/API 级读取验证通过;前端 `npm install` 和 `npm run build` 通过。由于当前环境端口绑定受限,未完成本机 `uvicorn` 实际监听验证。 + +[2026-04-20 16:05] fix 收尾修复模拟盘账户隔离、卖出整手规则并同步文档 +- 改动文件:scripts/run_paper_trade.py, trading/paper_engine.py, backtest/rules.py, backtest/engine.py, config/strategies.yaml, README.md, tests/test_paper_trade_helpers.py, tests/test_trading_rules.py, process.txt, .gitignore +- 改动说明:补齐上一轮 review 后剩余的两个功能性问题和一轮文档收尾。(1) **问题:模拟盘账户之前默认用 `strategy.name` 做主键**,同一策略不同参数/不同标的会串用同一账户,`--status/--history` 也可能查错账户。**修复方案:** 在 `run_paper_trade.py` 中新增稳定 `account_id = 策略key + 参数 + 标的列表` 的哈希生成逻辑,并传给 `PaperEngine` 作为实际账户名;摘要、状态页、历史页统一显示账户ID。**影响:** 相同策略/参数/标的组合会复用同一账户,不同组合自动隔离;查询状态时必须带相同 `--strategy --codes --params` 组合。(2) **问题:卖出数量规则的实现与注释不一致**,之前 `>100 股但不是整手且又不是全部清仓` 的卖单会被错误放行。**修复方案:** 在 `TradingRules` 中新增 `is_valid_sell_volume()`,明确允许三种情形:`<=100` 股、`100` 的整数倍、一次性卖出全部尾股;并把该校验接入 backtest 与 paper 两条执行链。**影响:** 非法部分 odd-lot 卖单现在会被拒绝,整手卖出和一次性卖出全部零股仍然可用。(3) **文档同步:** README 全面补齐 `init_db.py` / `daily_update.py` / `run_backtest.py` / `run_limitdown_short.py` / `query_stock.py` / `run_paper_trade.py` 的参数说明,明确 `next_open` 已改为真正次日开盘执行,补充 `overnight_long` 的回测与模拟盘使用方法、账户隔离说明;同时清理 `config/strategies.yaml` 中已废弃的 `limit_pct` 配置项,并将 `.codex/` 加入 `.gitignore`。新增 2 组测试覆盖账户ID稳定性与卖出整手规则。`pytest -q` 结果:16 passed。 + +[2026-04-20 15:10] fix 修复 overnight_long 全链路时序与回测统计口径 +- 改动文件:strategy/registry.py, strategy/technical/overnight_long.py, backtest/engine.py, backtest/metrics.py, trading/paper_engine.py, data/sources/tushare_source.py, tests/test_overnight_long.py, tests/test_overnight_long_engines.py, research.md, plan.md, process.txt +- 改动说明:围绕用户确认的目标语义“回测 T 日收盘买 / T+1 日开盘卖;实战 14:55 买 / 次日竞价卖”修复核心执行链。具体包括:(1) `load_strategy()` 现在会合并 `config/strategies.yaml` 默认参数,修复 overnight_long 的 `limit_pct` 等配置不生效问题;(2) 重写 `overnight_long.on_bar()`,从旧版“持仓日返回 SELL@next_open + BUY@close”改为正确的“空仓日返回 BUY@close + SELL@next_open,持仓日只返回 SELL@next_open”,避免在真正跨日执行模型下重复加仓;(3) 回测引擎新增真正的 `next_open` pending 队列,信号不再落在当前 bar.open,而是次日开盘先执行 pending,再运行策略,修复默认 `next_open` 策略的 lookahead bias;(4) paper 引擎修正 next_open 卖单的挂单量解析,允许“今日尾盘刚买、明早开盘卖”的仓位正确生成卖单,不再错误依赖 `available`;(5) 回测 metrics 改为按 Daily P&L Journal 中 `profit is not None` 识别闭环卖出,修复 `total_trades/win_rate/avg_holding_days` 因 `direction` 字段消失而统计为 0 的问题,同时显式使用真实 `initial_capital`,不再把首日成交后的净值当作初始资金;(6) 顺手修复 Tushare 深市指数代码被错误拼成 `.SH` 的问题。新增 2 组回归测试:策略级单测验证 overnight_long 新语义,引擎级集成测试分别锁定 backtest 的“收盘买 / 次日开盘卖”和 paper 的“收盘买入后生成次日 pending SELL”。`pytest -q` 结果:12 passed。 + +[2026-04-20 refactor] 交易明细重构为每日视角(Daily P&L Journal) +- 改动文件:backtest/engine.py, scripts/run_backtest.py, README.md, research.md, plan.md, process.txt +- 改动说明:用户反馈原明细第一行 `买入日=01-05 / 卖出日=01-06` 跨越两天,直觉误读为"01-05 既买又卖"。将 schema 从 round-trip 视角(每行一次买入日→卖出日配对)改为每日视角(每行一个交易日的所有动作)。新增"动作"列标识 建仓(只买)/ 换仓(先卖后买)/ 平仓(只卖)。字段重排:买入日+卖出日 合并为"日期";份额拆为"卖出份额+买入份额"(换仓日两者因价格变化不相等)。建仓行的卖出列空缺,平仓行的买入列空缺。引擎改造:新增 self._daily_actions 暂存每日 buy/sell;_process_signal 只下单不再直接 append _trades;run() 每日末尾(record_equity 后)调 _finalize_daily_trade 按 code 聚合为一行。佣金与净盈口径有意分离:佣金=当日现金流(买+卖都计),净盈=round-trip(上次买→本次卖);换仓日新建仓的买入佣金归属到下次平仓的 round-trip,两者不严格对齐。CSV 导出修正 pandas int 列遇 None 升 float 的 `9000.0` 瑕疵。513090 2026-01-05~01-12 冒烟回测 6 行输出:1 建仓 + 5 换仓,跨周末持仓天数=3 正确。pytest 11/11 全绿(测试针对策略 on_bar 不过引擎,不受影响)。 + +[2026-04-20 refactor] 滑点模型由绝对值改为百分比 + CLI 可配 +- 改动文件:backtest/engine.py, config/settings.yaml, config/__init__.py, scripts/run_backtest.py, README.md, research.md, plan.md, process.txt +- 改动说明:用户发现交易明细中 `买入价 = bar.close + 0.01`(settings.yaml 原值 slippage=0.01 绝对元),指出绝对滑点对不同价位摩擦不一致(2 元股 50bp vs 200 元股 0.5bp),且默认强制生效缺少"关闭"开关。改为百分比模型 `exec_price × (1 ± slippage_rate)` 并 round(3) 贴合 A 股最小价位;新增 `--slippage-rate` CLI 参数,优先级 CLI > settings.yaml > 代码兜底 0.0。默认值从 0.01(绝对)改为 0.0(百分比),显式启用才生效。修复构造函数 `slippage or BacktestConfig.slippage` 的 `or` 坑——`slippage_rate=0` 会被 falsy 判为未设从而回落 yaml,改为 `is not None` 显式判断。引入短路 `if self.slippage_rate:` 让 rate=0 时完全跳过乘法+round,exec_price 严格等于原 bar.close/bar.open。513090 2026-04 4 笔冒烟回测:rate=0 时 buy_price=bar.close 精确匹配;rate=0.0005 时实测 1.741→1.742 / 1.728→1.727 符合公式。pytest 11/11 全绿。 + [2026-04-19 feat] 新增 overnight_long 隔夜多头策略(同步修复 paper_engine 信号列表处理 bug) - 改动文件:strategy/technical/overnight_long.py (新增), strategy/registry.py, config/strategies.yaml, trading/paper_engine.py, README.md, tests/__init__.py (新增), tests/test_overnight_long.py (新增), docs/superpowers/specs/2026-04-19-overnight-long-design.md (新增), docs/superpowers/plans/2026-04-19-overnight-long.md (新增), process.txt (新增) - 改动说明:实现"T 日 14:55 近似收盘价全仓买入 + T+1 日 9:20 集合竞价挂跌停价卖出"的隔夜持股策略,主要针对 513090 恒生互联网科技 ETF。插件化接入回测与模拟盘双通道,零引擎改动复用。可选涨跌幅过滤参数 min_drop_pct/max_rise_pct 默认禁用。单元测试 8 个用例覆盖所有信号分支(空仓/持仓/T+1冻结/过滤关闭/min_drop/max_rise/两日时序)。本次为 B1 阶段(回测 + 模拟盘),Phase 7 实盘底座将于下一阶段独立立项。顺手修复 trading/paper_engine.py:_run_strategy 中把 list[Signal] 当作单个 Signal 处理的既有 bug(BaseStrategy.on_bar 返回 list,历史代码误用 append + 直接访问 .direction 属性)。 + +[2026-04-20 refactor] overnight_long 切换为连续隔夜模式(模式 A → 模式 B) +- 改动文件:strategy/technical/overnight_long.py, config/strategies.yaml, tests/test_overnight_long.py, research.md, plan.md, README.md, process.txt, docs/superpowers/specs/2026-04-19-overnight-long-design.md +- 改动说明:用户反馈原策略语义错误——实际表现为"间隔一天持仓"(买→过夜→卖→休一天→再买,持仓率 ~50%),与"隔夜多头"真实意图不符。用户期望"连续隔夜":每天 9:25 开盘卖 + 14:55 尾盘再买(持仓率 ~100%)。重构 on_bar 核心逻辑:if/elif 互斥 → 双独立 if,共享"本 bar 即将清仓"的状态假设。新增 limit_pct 参数(默认 0.10,创业板 ETF 应传 0.20)用于一字跌停判定:bar.open <= pre_close×(1-limit_pct) + 1e-6 时 SELL 被阻塞且 BUY 也被阻塞(资金被持仓占用)。涨跌幅过滤 min_drop/max_rise 语义收窄为"只作用于 BUY 分支",持仓出场不受影响。同 bar 返回 [SELL, BUY] 顺序依赖引擎按列表顺序撮合(上次迭代已修 list-vs-single bug)。单测从 8 → 11 用例,其中 test_position_with_zero_available_skips_sell 揭示首次实现把"T+1 冻结(available=0)"误判为"空仓"的逻辑 bug,修正为显式 `currently_empty = pos is None or pos.volume == 0`。513090 2026-01-01~2026-04-07 回测验证买卖日期连续(row N.sell_date == row N+1.buy_date),交易数较模式 A 翻倍。 + +[2026-04-20 feat] 扩展回测交易明细输出(14 列中文表头)+ 修正 profit 计算只扣单边佣金的 bug +- 改动文件:backtest/engine.py, scripts/run_backtest.py, research.md (新增), plan.md, README.md, process.txt +- 改动说明:按用户需求将回测交易明细从 10/11 列扩展为 14 列(代码/买入日/卖出日/开盘价/买入价/收盘价/卖出价/份额/佣金/净盈/收益率%/持仓天数/净值/卖出原因),中文表头同时覆盖控制台打印和 --csv 导出。开盘价指买入日 open、收盘价指卖出日 close,用于评估策略择时成本。佣金为买入佣金+卖出佣金合计(ETF 无印花税/过户费自动合并为单列)。净值取卖出后账户 total_equity。顺手修复 backtest/engine.py:267 的 profit 计算 bug:原公式只扣了卖出端 order.commission,漏掉买入端佣金,导致 ETF 场景 profit 系统性偏乐观;修正后 profit = (卖出价-买入价)×份额 - 买入佣金 - 卖出佣金。新增 research.md 累积研究笔记,plan.md 追加 "9. 迭代日志" 章节记录本次改动。513090 区间 2023-01-01~2026-04-07 回测验证通过(393 笔交易,净盈与账户 equity_curve 差分对得上)。 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..0f1e253 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt + +# 开发 / 测试依赖 +pytest>=8.0.0 +pytest-asyncio>=0.23.0 diff --git a/requirements-strategy.txt b/requirements-strategy.txt new file mode 100644 index 0000000..636a7cc --- /dev/null +++ b/requirements-strategy.txt @@ -0,0 +1,6 @@ +-r requirements.txt + +# 可选策略扩展依赖 +# 当前仓库中尚未直接引用这些库,后续实现 KDJ / RSI / 布林带等重型指标策略时再安装。 +ta-lib>=0.4.28 +pandas-ta>=0.3.14b diff --git a/requirements.txt b/requirements.txt index 7e4d544..d0de412 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ +# 基础运行依赖 + # 数据采集 akshare>=1.12.0 tushare>=1.4.0 @@ -11,12 +13,8 @@ alembic>=1.13.0 pandas>=2.1.0 numpy>=1.26.0 -# 技术指标 -ta-lib>=0.4.28 -pandas-ta>=0.3.14b - # Web 框架 -fastapi>=0.109.0 +fastapi>=0.109.0,<0.137 uvicorn>=0.27.0 websockets>=12.0 @@ -29,13 +27,11 @@ redis>=5.0.0 # 工具 pyyaml>=6.0 loguru>=0.7.0 -httpx>=0.27.0 +# httpx 0.28.x 在当前环境组合下会导致 ASGI/TestClient 测试不稳定卡住, +# 先约束到 0.27.x,等后续统一升级 FastAPI/Starlette/httpx 时再放开。 +httpx>=0.27.0,<0.28 python-dotenv>=1.0.0 # 可视化(回测报告) matplotlib>=3.8.0 mplfinance>=0.12.0 - -# 测试 -pytest>=8.0.0 -pytest-asyncio>=0.23.0 diff --git a/research.md b/research.md new file mode 100644 index 0000000..a0eebae --- /dev/null +++ b/research.md @@ -0,0 +1,874 @@ +# 研究笔记 + +本文件记录每次迭代的研究结论与关键发现,按时间倒序追加。 + +--- + +## 2026-04-23 Phase 7B(QMT / miniQMT adapter)研究补充 + +### 当前可落地范围 + +在当前环境里无法完成真正的 QMT 联机验证,因为: + +- 未安装 `xtquant` +- 无真实 QMT / miniQMT 客户端环境 +- 无真实账户 + +但这并不妨碍把 **adapter 代码层** 做到可联调状态。 + +### 本轮应达到的目标 + +1. `QmtBroker` 不再是空壳 +2. 支持: + - connect / subscribe + - 账户查询 + - 持仓查询 + - 即时下单 + - 撤单 + - 订单列表查询 +3. `next_open` 计划单由 `LiveEngine` 在执行日激活 +4. `miniqmt` 作为 `qmt` 的同路径别名支持 + +### 明确不在本轮范围 + +- 真实环境联调 +- 成交回报 callback 持久化 +- 自动对账修复 +- 风控联动拦截 +- 生产级异常恢复 + +### 设计结论 + +- `QmtBroker` 应保持 **懒加载 xtquant** + - 避免在无 QMT 环境时影响其它功能 +- `run_live_trade.py --mode live --provider qmt|miniqmt` + - 应能直接构造 `QmtBroker` +- 若 `xtquant` 缺失,应报出清晰错误,而不是静默失败 +- planned `next_open` 订单不要直接交给 `QmtBroker` + - 应先落 `planned` + - 到执行日由 `LiveEngine` 激活并再次调用 `submit_order()` + +### 验证策略 + +真实环境不可用时,用 fake `xtquant` 模块验证: + +- 账户映射 +- 持仓映射 +- 下单参数映射 +- planned 订单在执行日激活 + +这样至少能把 adapter 逻辑锁住,后续真实联调只剩环境问题。 + +--- + +## 2026-04-23 Phase 7(实盘交易基座)启动研究 + +### 当前代码现状 + +仓库中与交易执行直接相关的能力目前只有: + +- `trading/paper_engine.py`:模拟盘每日引擎 +- `trading/paper_account.py`:模拟盘持久化账户 +- `data/models.py` / `data/storage/repository.py`:`paper_account / paper_position / paper_order / paper_nav` + +当前缺失: + +- `trading/broker/` 为空,没有真实 broker 适配层 +- 没有统一的“实盘订单请求 / 券商订单状态 / 券商持仓 / 券商账户”抽象 +- 没有 `live_engine` / `executor` 这种把策略信号翻译成券商委托的基座 +- 没有 dry-run / live 切换机制 +- 没有实盘订单持久化表 +- 风控模块还未实现,因此 Phase 7 第一阶段不能假设有完整风控可复用 + +### 结论:Phase 7 第一阶段必须先做“基座”,不能直接做完整实盘 + +现阶段如果直接做: + +- QMT 下单 +- 券商账户同步 +- 实盘持仓管理 +- 风控联动 + +会把 broker 细节、通知、风控、订单生命周期全部耦在一起,后面很难维护。 + +因此 Phase 7 第一阶段应只做 **实盘交易基座**,目标是把“策略信号 -> 统一订单请求 -> broker 适配器 -> 订单回报 / 状态持久化”这条链打通。 + +### 推荐的第一阶段范围(Phase 7A) + +1. **统一 broker 抽象层** + - `BaseBroker` + - `BrokerAccount` + - `BrokerPosition` + - `BrokerOrder` + - `BrokerOrderRequest` + +2. **实盘执行引擎骨架** + - 新增 `trading/live_engine.py` + - 职责: + - 加载策略 + - 读取账户/持仓 + - 生成统一委托请求 + - 调用 broker 下单 + - 记录结果 + +3. **dry-run broker** + - 第一阶段先做一个 `DryRunBroker` + - 不连真实券商 + - 只把委托请求落库 / 打日志 / 发送通知 + - 用它验证基座结构是否合理 + +4. **QMT/miniQMT 适配器占位** + - 新增 `QmtBroker` 桩实现或最小接口壳 + - 明确 TODO,不在第一阶段硬接真实客户端 + +5. **实盘订单持久化** + - 新增真实交易相关表,至少包括: + - `live_account` + - `live_order` + - `live_fill`(如本阶段不做可先留后续) + +6. **通知挂点** + - 只挂邮件接口位置 + - 第一阶段不把完整邮件系统做完 + +### 为什么先做 DryRunBroker + +因为你还没有最终确认真实券商接口细节(QMT / miniQMT / 其他),而且当前环境也不适合直接联真实交易端。 + +DryRunBroker 的价值: + +- 验证实盘基座结构 +- 不引入真实资金风险 +- 给后续 QMT 适配器一个明确接口目标 +- 可先通过 CLI / API / Web 面板查看“准备下什么单” + +### 推荐的代码结构 + +建议第一阶段新增或扩展: + +- `trading/broker/base.py` +- `trading/broker/dry_run.py` +- `trading/broker/qmt_broker.py`(占位) +- `trading/live_engine.py` +- `trading/models.py`(若不放到 `data/models.py`) +- `scripts/run_live_trade.py` + +若继续沿用现有 ORM 风格,也可以把 live 表直接加到 `data/models.py`。 + +### 配置建议 + +`config/settings.yaml` 新增: + +```yaml +broker: + mode: "dry_run" # dry_run / live + provider: "qmt" # qmt / miniqmt / dummy + account_id: "" + endpoint: "" + timeout: 5 +``` + +说明: + +- `mode` 用于全局切换 dry-run 与 live +- `provider` 指定 broker 适配器 +- 凭据类内容不要直接硬编码到仓库里,建议后续走本地配置或环境变量 + +### 第一阶段不做的内容 + +- 不直接接真实资金下单 +- 不做完整成交回报同步 +- 不做复杂风控联动 +- 不做持仓对账自动修复 +- 不做 GUI/可视化的实盘交易台 + +### 验收标准(Phase 7A) + +1. 有统一 broker 抽象接口 +2. 有 `DryRunBroker` +3. 有 `run_live_trade.py` 或等价 CLI +4. 策略信号可被翻译成统一订单请求并通过 DryRunBroker 执行 +5. 订单结果能落库并可查询 +6. 后续接 QMT 时无需重写 live engine,只需实现 broker adapter + +### 风险提示 + +Phase 7 一旦进入真实券商对接,就是高风险区域。第一阶段一定要把目标限定在“基座 + dry-run”,不要把“真实下单成功”作为首要目标。 + +--- + +## 2026-04-20 Phase 6(Web 可视化)启动研究 + +### 当前代码基础 + +已确认可直接复用的后端能力: + +- **数据库与 ORM**:`data/models.py` 已包含股票、行情、复权、模拟盘账户/持仓/订单/净值模型 +- **数据读取**:`data/storage/repository.py` 已有 `StockRepository` / `PaperRepository` +- **回测结果**:`backtest/engine.py` 已能输出净值曲线与 Daily P&L Journal +- **模拟盘状态**:`trading/paper_engine.py` 与 `scripts/run_paper_trade.py` 已有账户、持仓、挂单、净值查询链路 + +当前缺口: + +- `api/` 基本为空,尚无 FastAPI 入口、路由、schema、依赖注入 +- 仓库中尚无 `frontend/` 目录,也没有 Node/Vite/TypeScript 工具链 +- `risk/` 仍为占位模块,因此 Phase 6 中“风控中心”暂时只能做占位页或只读接口 + +### 结论:Phase 6 不应一次性全做完 + +按当前项目状态,Phase 6 必须拆成可落地的增量: + +1. **Phase 6A:后端 API 基础设施 + Dashboard 只读接口** + - FastAPI 应用入口 + - 健康检查 + - Dashboard 总览接口(账户总览、持仓、待执行订单、净值) + - 回测结果查询接口(只读) + - OpenAPI / docs + +2. **Phase 6B:前端脚手架 + Dashboard 首屏** + - Vue 3 + TypeScript + Vite + - 首页仪表盘 + - 账户概览卡片 + - 持仓表格 + - 净值曲线 + +3. **Phase 6C:回测中心** + - 历史回测任务列表 + - 回测摘要与交易明细查看 + +4. **Phase 6D:策略管理 / 风控中心** + - 策略列表 + - 参数查看/编辑 + - 风控中心先做占位或只读视图 + +### 推荐本次编码范围 + +建议本轮只做 **Phase 6A + 6B 的最小闭环**,理由: + +- 当前后端已有数据模型和查询能力,最容易快速出结果 +- 先有 API,再接 Vue 前端,结构最稳 +- `risk/` 尚未落地,风控中心现在直接做会制造大量假接口 + +### 推荐 UI 范围(第一版) + +第一版页面只做一个 **Dashboard**: + +- 账户总览:总资产、现金、持仓市值、累计收益 +- 持仓列表:代码、份数、成本价、现价、浮盈亏 +- 待执行订单:方向、代码、数量、执行日、原因 +- 净值曲线:最近 N 天 + +数据来源全部基于现有 `PaperRepository`,避免引入额外业务逻辑。 + +### 主要技术决策 + +- **后端**:FastAPI + Pydantic v2 风格 schema +- **前端**:Vue 3 + TypeScript + Vite +- **图表**:首版优先 ECharts(项目已在规划中提到,接入成本较低) +- **接口风格**:只读 GET API 优先,先不做写接口 +- **运行方式**: + - 后端:`uvicorn api.main:app --reload` + - 前端:`npm run dev` + +### 需要避免的陷阱 + +1. **直接把 CLI 逻辑搬进 API** + - CLI 负责打印和交互;API 应直接依赖 repository/service 返回结构化数据 + +2. **一次上来做完整策略管理 / 风控中心** + - 当前 `risk/` 没有实现,直接做前端会导致大量伪功能 + +3. **把 account_id 逻辑只放在前端** + - 后端接口必须显式支持按 `strategy / codes / params` 或稳定 `account_id` 查询 + +### 建议的第一批文件 + +若进入编码,建议第一批只涉及: + +- `api/main.py` +- `api/schemas.py` +- `api/dependencies.py` +- `api/routers/dashboard.py` +- `api/routers/__init__.py` +- `frontend/package.json` +- `frontend/vite.config.ts` +- `frontend/src/main.ts` +- `frontend/src/App.vue` +- `frontend/src/views/DashboardView.vue` +- `frontend/src/api/dashboard.ts` + +### 验收标准(第一阶段) + +1. 启动 FastAPI 后可访问 `/docs` +2. 启动前端后首页能正常加载 +3. Dashboard 能展示一个模拟盘账户的: + - 账户概览 + - 持仓列表 + - 待执行订单 + - 最近净值曲线 +4. 移动端与桌面端都能正常显示 + +--- + +## 2026-04-20 overnight_long 全链路 bug 修复研究 + +### 用户目标语义(明确口径) + +用户确认的目标不是“信号层面大致类似”,而是严格的交易时序: + +1. **回测** + - T 日按 `close` 买入 + - T+1 日按 `open` 卖出 + +2. **实战 / 模拟盘** + - T 日 14:55 买入,近似 `close` + - T+1 日集合竞价挂跌停价卖出,成交价等价于 `open` + +因此,系统必须支持“**收盘建仓 -> 次日开盘平仓**”这个跨日时序,不能再依赖当前实现里对 `next_open` 的“同 bar 开盘价立即成交”近似。 + +### 核心问题 1:回测引擎的 `next_open` 语义错误 + +当前 `backtest/engine.py` 中,策略在处理当天 bar 后立即处理信号: + +- `execute_at="close"` → 当前 bar `close` +- 其余(含 `next_open`)→ 当前 bar `open` + +这意味着 `next_open` 并没有被挂到下一交易日执行,而是被错误地在**当前 bar**用 `open` 成交。对多数“当日收盘出信号、次日开盘成交”的策略,这会产生标准的 lookahead bias。 + +`overnight_long` 之所以“看起来大致正确”,只是因为它当前在 **T+1 bar** 上生成 `SELL @ next_open`,引擎又把这个信号落在同一根 **T+1 bar.open** 成交,结果碰巧接近了用户想要的卖出价。但这不是一个可复用、可证明正确的机制。 + +### 核心问题 2:`overnight_long` 的当前信号设计依赖了上述错误语义 + +当前 `strategy/technical/overnight_long.py` 在“有可卖仓位且非一字跌停”时返回: + +- `SELL @ next_open` +- `BUY @ close` + +这是建立在“同一根 bar 上既能看到今天的收盘,又能把 `next_open` 直接落到今天开盘”这个错误机制上的。 + +若把 `next_open` 修正为真正跨日挂单,当前策略会变成: + +- 今天持仓未卖出 +- 今天收盘又买入 +- 明天开盘才卖 + +即仓位被重复累加,模拟盘更会直接跑偏。 + +### 正确的 `overnight_long` 策略语义 + +在“收盘后统一运行”的引擎模型下,`overnight_long` 的日终信号应改为: + +1. **若今日收盘后将持有隔夜仓位** + - 生成 `SELL @ next_open`(给明早) + +2. **若当前收盘时为空仓且买入过滤通过** + - 生成 `BUY @ close`(今天尾盘) + +换句话说,空仓日应返回 **`[BUY @ close, SELL @ next_open]`**,而不是当前的“有仓日返回 `[SELL @ next_open, BUY @ close]`”。 + +这样才能在 paper/backtest 两端都满足: + +- Day T 收盘买入 +- Day T+1 开盘卖出 +- Day T+1 收盘再次买入 + +### 核心问题 3:paper engine 与 `overnight_long` 当前严重不一致 + +`trading/paper_engine.py` 的执行顺序是: + +1. 执行昨日 pending(今日开盘) +2. 当日收盘后运行策略 +3. 当日执行 `open/close` +4. 为明天创建 `next_open` pending + +在这个真实跨日模型下,当前 `overnight_long` 返回的 `SELL @ next_open + BUY @ close` 会导致: + +- 当日先买 +- 卖单推迟到明天 +- 甚至因为挂单量按 `available` 解析,卖不掉当日刚买的仓位 + +这是本次修复的最高优先级 bug。 + +### 核心问题 4:策略配置文件未实际生效 + +`config/strategies.yaml` 虽被加载到 `config.STRATEGY_CONFIG`,但 `strategy/registry.py:load_strategy()` 并未合并该配置,只用了注册表硬编码默认值 + CLI `--params`。 + +这导致: + +- `overnight_long.limit_pct` 在无 CLI 覆盖时不会进入实例配置 +- README 中“编辑 `config/strategies.yaml`”的描述与实际行为不一致 + +### 核心问题 5:回测统计口径在交易明细重构后失真 + +交易明细从 round-trip schema 改成 Daily P&L Journal 后,`backtest/engine.py` 写入的是: + +- `action` +- `sell_price` +- `buy_price` +- `profit` + +不再写入原先的 `direction` + +但 `backtest/metrics.py` 仍按 `t.get("direction") == "SELL"` 识别成交闭环,导致: + +- `total_trades` +- `win_rate` +- `profit_loss_ratio` +- `avg_holding_days` + +在当前版本下都可能退化为 0 或明显失真。 + +### 核心问题 6:回测初始资金口径被第一天手续费污染 + +`BacktestResult.print_summary()` 和 `calculate_metrics()` 都把 `equity_curve[0]["total_equity"]` 当作“初始资金”。但 `equity_curve` 的第一条记录是在**首个交易日成交之后**才写入的。 + +对 `overnight_long` 这类首日大概率会买入的策略,首日手续费会让第一条净值低于真实初始资金,从而污染: + +- 初始资金展示 +- 总收益率 +- 年化收益率 +- 总盈亏 + +### 核心问题 7:模拟盘账户未按参数 / 标的隔离 + +`run_paper_trade.py` 和 `trading/paper_engine.py` 之前都默认把 `strategy.name` 当作模拟盘账户主键。 + +这会导致: + +- 同一策略不同参数共享同一账户 +- 同一策略不同股票池共享同一账户 +- `--status` / `--history` 查到的账户可能不是当前实验那一套 + +对 `overnight_long` 这种 `name` 固定为 `"overnight_long"` 的策略尤其危险,多次试验会出现“串仓 / 串挂单 / 串净值”。 + +### 核心问题 8:卖出整手规则实现与注释不一致 + +`TradingRules.round_volume()` 的注释写的是: + +- 卖出可以不足 100 股一次性卖出 +- 超过 100 股的部分必须为 100 的整数倍 + +但实现实际上对卖出直接 `return volume`,没有任何校验。 + +这意味着以下非法卖单会被错误放行: + +- 持仓 350 股,只卖 250 股 +- 持仓 900 股,只卖 550 股 + +而这些都不符合注释声明的交易规则。 + +### 核心问题 9:Tushare 深市指数代码后缀错误 + +`data/sources/tushare_source.py` 在获取指数数据时,把所有指数都拼成 `.SH`。 + +因此若 AKShare 失败、系统回退到 Tushare: + +- `399001` +- `399006` + +这类深市指数会被错误查询为上交所代码。 + +### 修复策略 + +本次修复按以下顺序落地: + +1. **先修执行时序** + - backtest 引入真正的 `next_open` pending 队列 + - paper 保留 pending 模型,但修正卖单量解析与 `overnight_long` 信号语义 + +2. **再修策略语义** + - `overnight_long` 改为“空仓日返回 `BUY @ close + SELL @ next_open`” + - “持仓日”只负责为明早挂卖单,不再同日追加 `BUY @ close` + +3. **最后修统计与配置** + - `load_strategy()` 合并 `strategies.yaml` + - metrics 改为按“有卖出闭环”的 daily journal 识别交易 + - metrics/summary 显式使用真实 `initial_capital` + - paper 账户实例按“策略 key + 参数 + 标的”稳定隔离 + - 卖出整手规则显式校验,不再默认放行 + +### 预期影响面 + +| 文件 | 改动 | +|------|------| +| `strategy/technical/overnight_long.py` | 重写 `on_bar` 信号语义 | +| `backtest/engine.py` | 引入 `next_open` pending 跨日执行;修正 summary 初始资金 | +| `trading/paper_engine.py` | 修正 `next_open` 挂单量解析与 `overnight_long` 执行时序 | +| `strategy/registry.py` | 合并 `config/strategies.yaml` 默认参数 | +| `backtest/metrics.py` | 适配 daily journal 交易统计口径;接收真实初始资金 | +| `scripts/run_paper_trade.py` | 生成稳定 `account_id`,让 paper 账户按参数 / 标的隔离 | +| `backtest/rules.py` | 显式实现卖出数量合法性校验 | +| `data/sources/tushare_source.py` | 修复深市指数 `.SZ` / `.SH` 后缀 | +| `tests/test_overnight_long.py` | 更新策略单测断言 | +| `tests/test_paper_trade_helpers.py` | 锁定 paper 账户 ID 的稳定性与隔离性 | +| `tests/test_trading_rules.py` | 锁定卖出整手规则 | +| `tests/` 新增集成测试 | 覆盖 overnight_long 在 backtest/paper 的真实时序 | + +--- + +## 2026-04-20 交易明细重构:round-trip 视角 → 每日视角(Daily P&L Journal) + +### 背景 + +方案 A 切换触发:用户看到 `2026-01-05 | 2026-01-06 | 开盘价 2.046 | 买入价 2.107 | 收盘价 2.232 | 卖出价 2.107` 这一行,直觉理解为"01-05 当天既买又卖",追问"第一次建仓 第一天不应该有卖出"。 + +### 根本矛盾 + +原明细是 **round-trip 视角**:每行 = 一次"买入日 → 卖出日"配对。在连续隔夜策略下,01-05 的建仓买入被 hoist 到行 1 里和 01-06 的卖出组成一个 pair,但视觉上像是"01-05 这一天发生了买卖"。 + +对长期持仓策略这种 schema 问题不大(买入日和卖出日差几十天),但对"每天都换手"的隔夜策略,第一行"跨越两个交易日"的语义极易误读。 + +### 新 schema:Daily P&L Journal(按日视角) + +每行 = 一个交易日的所有动作。动作分三种: + +| 动作 | 条件 | 典型场景 | +|------|------|----------| +| 建仓 | 当日只有买入 | 第一次进场;或上次被一字跌停套完后恢复 | +| 换仓 | 当日先卖后买 | 连续隔夜策略的"常态"每日形态 | +| 平仓 | 当日只有卖出 | 最后一日策略停更买;或过滤器挡住 BUY | + +### 字段重排(14 → 15 列) + +``` +代码 日期 动作 开盘价 买入价 收盘价 卖出价 卖出份额 买入份额 佣金 净盈 收益率% 持仓天数 净值 动作备注 +``` + +关键变化: +1. `买入日 + 卖出日` 合并为单列 `日期`(同日) +2. `份额` 拆为 `卖出份额 + 买入份额`——换仓日两者可能**不等**(卖 9000 股收回的现金 × 涨价后 = 买 8500 股) +3. 新增 `动作` 列标识行类型 +4. 字段的"空缺规则"表达"该行无此动作": + - 建仓:卖出列空(`收盘价 / 卖出价 / 卖出份额 / 净盈 / 收益率 / 持仓天数`) + - 平仓:买入列空(`买入价 / 买入份额`) + +### 口径分离:当日佣金 vs round-trip 净盈 + +**佣金列** = 当日实际支付佣金(建仓=买佣金;换仓=卖+新买;平仓=卖佣金)。 + +**净盈列** = 本次卖出对应 round-trip 的净盈(当日卖 vs 上次建仓买 两端佣金都扣)。 + +两者**不对齐**——换仓日的"佣金"含了新建仓的买入佣金,但这笔"新建仓佣金"归属到下次平仓的 round-trip 里。 + +这个设计取舍: +- 若坚持 round-trip 全对齐(净盈 - 佣金 = 净收益),则佣金列也要只算 round-trip 两端 → 换仓日的"新建仓买佣金"无处归属 +- 所以分离:佣金是"现金流口径",净盈是"交易闭环口径" + +### 实现:日末聚合 + +改动位置 `backtest/engine.py`: + +1. `__init__`:新增 `self._daily_actions: dict[str, dict]` +2. `_process_signal`:BUY/SELL 成功后只写 `_daily_actions[code]["buy"/"sell"]`,不再直接 append `_trades` +3. `run()` 每日循环末尾(`record_equity` 之后)调 `_finalize_daily_trade(td, bar_map)` +4. `_finalize_daily_trade`:按 code 聚合 buy/sell 两组动作为一条 `_trades` 记录,action 判定为 "建仓/换仓/平仓" + +为什么 `_finalize_daily_trade` 放在 `record_equity` 之后:明细里的"净值"列取当日收盘的 `account.total_equity`,必须在资产更新完成后再读。 + +### CSV 导出的 pandas 坑 + +`sell_volume / buy_volume / holding_days` 是 int 类型,但建仓/平仓行有 None 值。pandas 遇 None 会把 int 列升 float,CSV 输出 `9000.0` 而非 `9000`。解决:导出前把这三列 `.map(lambda v: "" if None else str(int(v)))`,让它变成 object/string 列再写。 + +控制台打印侧已有空值容错(`if raw is None or (isinstance(raw, float) and raw != raw): ""`),不需改动。 + +### 影响面 + +| 文件 | 改动 | +|------|------| +| `backtest/engine.py` | 新增 `_daily_actions` + `_finalize_daily_trade`;`_process_signal` 不再直接 append `_trades` | +| `scripts/run_backtest.py` | TRADE_COLUMNS 重排 + CSV 整数列空值处理 | +| `README.md` / `plan.md` / `process.txt` | 同步字段说明 | + +### 验收 + +- 11/11 pytest 全绿(测试针对 on_bar 不过引擎,不受影响) +- 513090 2026-01-05~01-12 冒烟回测 6 行输出:第 1 行"建仓"卖出列全空 ✓;2-6 行"换仓"双面字段齐全 ✓;持仓天数跨周末=3 ✓ +- CSV 导出 `sell_volume=9000` 而非 `9000.0` ✓ + +--- + +## 2026-04-20 滑点模型:绝对值 → 百分比 + CLI 可配 + +### 背景 + +用户在交易明细中发现 `买入价 2.242 ≠ 买入日 close 2.232` 的差异 0.010 元——恰好是 `settings.yaml` 里的 `slippage: 0.01`(绝对值)。该模型有两个设计缺陷: + +1. **绝对滑点在不同价位上的摩擦系数不一致**:2 元股的 0.01 元 = 50bp;200 元股的 0.01 元仅 0.5bp。策略跨品种回测时摩擦成本失真。 +2. **默认值强制施加**:用户没有选择"无滑点"的途径,且 0.01 的默认对低价 ETF(如 513090 in ~2元区间)明显偏高。 + +### 新模型 + +绝对值 → **百分比 × 基价**,buy 向上偏,sell 向下偏: + +```python +if self.slippage_rate: + if signal.direction == Direction.BUY: + exec_price *= 1 + self.slippage_rate + else: + exec_price *= 1 - self.slippage_rate + exec_price = round(exec_price, 3) +``` + +- **`if self.slippage_rate:` 短路**:`slippage_rate=0` 时完全跳过,`exec_price` 保持原 bar 浮点精度(避免无意义的 round 损失) +- **`round(..., 3)`**:A 股最小价位 0.001 元,与真实撮合一致 +- **默认值改为 `0.0`**:用户在 `settings.yaml` 或 CLI 显式设置才启用;避免"藏在默认值里的假设"影响策略判断 + +### 配置路径(3 层覆盖优先级) + +``` +CLI --slippage-rate N > settings.yaml slippage_rate > 代码兜底 0.0 +``` + +CLI 参数用 `type=float, default=None` 让"未传" vs "传 0"可区分——传了 0 走 `BacktestEngine.slippage_rate=0`(真的无滑点),没传时 `None` 交给 `__init__` 读 yaml。 + +### 与 `or` 的一个坑 + +构造函数旧代码 `self.slippage = slippage or BacktestConfig.slippage`——若 `slippage=0` 会被 `or` 当 falsy 掉进 yaml 默认,永远关不掉滑点。新代码改为 `None` 显式判断: + +```python +self.slippage_rate = ( + slippage_rate if slippage_rate is not None else BacktestConfig.slippage_rate +) +``` + +### 验证 + +| 场景 | buy_price 预期 | sell_price 预期 | +|------|---------------|----------------| +| slippage_rate=0 | bar.close | bar.open | +| slippage_rate=0.0005 | round(bar.close × 1.0005, 3) | round(bar.open × 0.9995, 3) | + +实测 513090 2026-04-01 bar.close=1.741 → 1.742(+0.0005);2026-04-02 bar.open=1.728 → 1.727(-0.0005)。符合。 + +### 影响面 + +| 文件 | 改动 | +|------|------| +| `config/settings.yaml` | `slippage: 0.01` → `slippage_rate: 0.0` | +| `config/__init__.py` | `BacktestConfig.slippage` → `BacktestConfig.slippage_rate` | +| `backtest/engine.py` | 构造参数重命名 + 公式改乘法 + `if self.slippage_rate:` 短路 | +| `scripts/run_backtest.py` | 新增 `--slippage-rate` CLI 参数 | +| `README.md` / `plan.md` / `process.txt` | 同步文档 | + +--- + +## 2026-04-20 overnight_long 切换为连续隔夜模式(模式 A → B) + +### 背景 + +用户回测后发现交易间隔一天(01-05 买 / 01-06 卖 / **01-06 空仓** / 01-07 买),与真实"隔夜因子"策略语义不符。用户期望每天都持仓过夜:01-05 买 → 01-06 开盘卖 → **01-06 尾盘再买** → 01-07 开盘卖 → … + +### 原有逻辑(模式 A,间隔持仓) + +`strategy/technical/overnight_long.py:28-63` 用 `if / elif` 互斥结构: + +```python +if pos and pos.available > 0: + → SELL @ next_open +elif not has_position(...): + → BUY @ close +``` + +持仓状态下只走 SELL 分支,BUY 分支被 `elif` 跳过。即使当 bar 内 SELL 成交释放了资金,BUY 已不会再被评估。结果:持仓率 ~50%。 + +### 新逻辑(模式 B,连续隔夜) + +核心改动:`if / elif` → **双独立 if**,再加一个"本 bar 即将清仓"的状态假设。 + +```python +has_sellable = pos and pos.available > 0 +sell_blocked = has_sellable and (bar.open <= limit_down_price + 1e-6) + +# 独立判断 1:挂卖单 +if has_sellable and not sell_blocked: + → SELL @ next_open + +# 独立判断 2:挂买单(空仓 OR 即将通过 SELL 清仓) +currently_empty = pos is None or pos.volume == 0 +will_sell_all = has_sellable and not sell_blocked +if (currently_empty or will_sell_all) and 过滤通过: + → BUY @ close +``` + +### 一字跌停判定 + +`BarData` 无 limit 字段,需自行算: + +```python +limit_down_price = round(pre_close * (1 - limit_pct), 3) +sell_blocked = bar.open <= limit_down_price + 1e-6 +``` + +- **浮点容差 1e-6**:防止 `bar.open=2.0970001` 与 `limit_down=2.097` 的末位误差导致误判 +- **limit_pct 参数化**:默认 0.10(主板 / 跨境 ETF),创业板/科创板 ETF 传 0.20 +- 一字跌停时:SELL 阻塞(卖不出)+ BUY 阻塞(持仓占用资金),当日无任何信号 + +### 一个漏网 bug(已修) + +首次实现里 `will_be_empty = (not has_sellable) or (...)` 把"没有可卖仓位"等同于"空仓",漏了 **T+1 冻结日**(pos 存在但 available=0)这种"仓位仍在只是锁着"的状态。单测 `test_position_with_zero_available_skips_sell` 直接捕获了这个逻辑错误。 + +修正为显式判空:`currently_empty = pos is None or pos.volume == 0`。 + +### 涨跌幅过滤的语义变化 + +**原(模式 A)**:过滤生效时整根 bar 跳过(return 前 BUY 分支尚未生成) + +**新(模式 B)**:过滤**只作用于 BUY 分支**。持仓状态下即使 BUY 被过滤挡住,SELL 仍会照常挂单。语义上"过滤是进场条件,不是持仓条件"——被套时依然按常规出场逻辑卖。 + +### 撮合顺序依赖 + +同一根 bar 策略返回 `[SELL, BUY]`,引擎按列表顺序处理: +1. SELL @ open 执行 → 资金释放 +2. BUY @ close 执行 → 用释放的资金满仓买 + +这个顺序依赖的前提是**上次迭代已修的 `_run_strategy` list-vs-single-Signal bug**(`trading/paper_engine.py` 和 `backtest/engine.py`)。如果那个 bug 还在,第二个信号会被吞掉。 + +### 影响面 + +| 文件 | 改动 | +|------|------| +| `strategy/technical/overnight_long.py` | 重写 `on_bar`,18 → 35 行 | +| `config/strategies.yaml` | 新增 `limit_pct: 0.10` | +| `tests/test_overnight_long.py` | 改 4 个断言 + 新增 3 个用例,共 11 个(原 8) | +| 引擎 / 账户 / 交易明细输出 | **不改**(信号协议不变) | + +### 验收 + +- 11 个单测全绿(其中 1 个发现了 currently_empty 的 bug,修后通过) +- 513090 2026-01-01~2026-04-07 回测:64 笔交易,买卖日期连续(row N sell_date = row N+1 buy_date) +- 交易数约为模式 A 的 2 倍(~50% vs ~100% 持仓率) + +--- + +## 2026-04-19 交易明细扩展 / profit 计算修正 + +### 背景 + +用户运行 overnight_long 策略回测后反馈:交易明细信息太少,要求包含 11 列: +开盘价、卖出价、卖出份额、买入价、买入份额、收盘价、佣金、净盈、收益率、持仓天数、净值。 + +### 现状定位 + +项目根目录的 `trades.csv` 是**用户手工放置的参考样例**(日期 2020-03-26,而项目数据库从 2023-01-01 起),**不是本回测引擎产出的**。引擎真实产出在两个地方: + +| 产出点 | 代码位置 | 现有字段数 | +|--------|----------|-----------| +| 控制台表格 | `scripts/run_backtest.py:192-220` 的 `_print_trades` | 10 列 | +| CSV 导出 | `scripts/run_backtest.py:164` 的 `trades_df.to_csv()` | 11 列(英文) | + +所有 trade 字典在 `backtest/engine.py:269-281` 组装,结构为: +```python +{ + "code", "direction", + "buy_price", "sell_price", "volume", + "profit", "profit_pct", "holding_days", + "buy_date", "sell_date", "reason", +} +``` + +### 需求字段映射 + +| 需求字段 | 现状 | 数据源 | 备注 | +|---------|------|--------|------| +| 开盘价(买入日开盘价) | 未存 | `BarData.open`(买入日) | 方案 A:反映"尾盘买 vs 早盘买"择时成本 | +| 卖出价 | ✅ `sell_price` | 卖出 order `exec_price` | | +| 卖出份额 | ✅ 借用 `volume` | 成交量 | 与买入份额相等(策略全仓买卖) | +| 买入价 | ✅ `buy_price` | 买入 order `exec_price` | | +| 买入份额 | ✅ 借用 `volume` | 成交量 | 同上 | +| 收盘价(卖出日收盘价) | 未存 | `BarData.close`(卖出日) | 方案 A:反映"开盘卖 vs 收盘卖"择时收益 | +| 佣金(合计) | 未存 | 买入 order.commission + 卖出 order.commission | ETF 无印花税无过户费,仅佣金两项 | +| 净盈 | ⚠️ `profit` 有 bug | `(sell_price-buy_price)*volume - 合计佣金` | 见下方 BUG | +| 收益率% | ✅ `profit_pct` | `(sell_price/buy_price-1)*100` | | +| 持仓天数 | ✅ `holding_days` | `(sell_date - buy_date).days` | | +| 净值(卖出后) | 未存 | `Account.equity_curve` 按 sell_date 查 | 当日 `total_equity` | + +### 已发现 BUG + +**位置**:`backtest/engine.py:267` + +```python +profit = (exec_price - buy_price) * volume - order.commission +``` + +**问题**: +- 只减了**卖出订单**的 commission +- 漏减了**买入订单**的 commission +- 对 ETF(无印花税、无过户费),买入佣金是唯一遗漏项,导致 profit 偏乐观 +- 对个股,还漏了印花税 + 过户费(但 `Account.process_sell` 在账户层面扣了,净值数据正确;仅 `_trades` 里的 profit 字段偏乐观) + +**修正方案**: +- 在 `_buy_records` 里补存 `commission`,卖出时读出 +- 重写:`profit = (sell_price - buy_price) * volume - buy_commission - sell_commission` +- ETF 只减两笔佣金;个股仍少算 tax/transfer_fee,但这一部分本任务不扩展(后续另行评估) + +### 费用模型确认 + +查 `backtest/fee.py` 和 `config/settings.yaml` 确认: + +| 配置项 | 值 | 说明 | +|--------|-----|------| +| `commission_rate` | `0.0001` | 万1 ✓ | +| `min_commission` | `0.0` | 免5(无最低) ✓ | +| `stamp_tax_rate` | `0.001` | 仅个股卖出 | +| `transfer_fee_rate` | `0.00002` | 仅个股 | +| `FeeModel.calculate(is_etf=True)` | ETF 跳过 tax/transfer_fee | `fee.py:57, 62` | +| `Account.process_buy/sell` | 自动识别 ETF | `account.py:82, 147` | + +**结论**:费用模型代码无需改动,万1免5 + ETF 免征已全部正确。 + +### 用户决策(2026-04-19 对话) + +1. **字段语言**:中文表头 +2. **开/收盘价语义**:方案 A(开盘价 = 买入日 open,收盘价 = 卖出日 close) +3. **佣金展示**:单列"佣金合计" +4. **净值**:只记卖出后净值(1 列) + +### 数据流与新增 `_trades` 字段设计 + +```python +{ + "code": str, + "buy_date": date, # 买入日 + "sell_date": date, # 卖出日 + "buy_open": float, # 新增:买入日开盘价 + "buy_price": float, # 买入成交价(= 买入日收盘价,overnight_long 特性) + "sell_price": float, # 卖出成交价(= 卖出日开盘价,overnight_long 特性) + "sell_close": float, # 新增:卖出日收盘价 + "volume": int, # 份额 + "commission": float, # 新增:买入佣金 + 卖出佣金 合计 + "profit": float, # 修正:已扣两边佣金的净盈 + "profit_pct": float, # 收益率%(基于 buy_price/sell_price,未扣佣金) + "holding_days": int, + "net_equity": float, # 新增:卖出日 account.total_equity + "reason": str, # 卖出原因 +} +``` + +### 控制台列设计(中文,14 列) + +``` +代码 | 买入日 | 卖出日 | 开盘价 | 买入价 | 收盘价 | 卖出价 | 份额 | 佣金 | 净盈 | 收益率% | 持仓天数 | 净值 | 卖出原因 +``` + +### CSV 列(与控制台一致的中文表头) + +通过 `trades_df.rename(columns=...)` 后再 `to_csv(encoding="utf-8-sig")`,保持 Excel 兼容。 + +### 影响面 + +| 文件 | 改动类型 | 规模 | +|------|---------|------| +| `backtest/engine.py` | 改 | 约 30 行(扩 _buy_records + 扩 _trades + 修 profit) | +| `scripts/run_backtest.py` | 改 | 约 40 行(重写 _print_trades + CSV 表头映射) | +| `backtest/account.py` | 不改 | — | +| `backtest/fee.py` | 不改 | — | +| `backtest/metrics.py` | 不改 | — | +| 策略文件 | 不改 | 对策略透明 | + +### 验收标准 + +1. 控制台表格新增 3 列(开盘价 / 收盘价 / 佣金 / 净值),中文表头 +2. CSV 导出与控制台列一致(中文表头) +3. profit 字段扣两边佣金后,与账户 equity_curve 计算的卖出当日资产变化**对得上** +4. 513090 场景下"印花税" "过户费"恒为 0(不单列,合入 commission=0) +5. `avg_holding_days` 等 metrics 指标不受影响(未改 metrics.py) diff --git a/scripts/run_backtest.py b/scripts/run_backtest.py index 9e74117..6934ec4 100644 --- a/scripts/run_backtest.py +++ b/scripts/run_backtest.py @@ -26,6 +26,27 @@ from strategy.registry import STRATEGY_REGISTRY, load_strategy +# 交易明细列定义:英文 key → 中文表头 + 宽度 + 格式化函数 +# 同时供控制台表格渲染和 CSV 导出复用 +TRADE_COLUMNS = [ + ("code", "代码", 8, lambda v: str(v)), + ("trade_date", "日期", 12, lambda v: str(v)), + ("action", "动作", 6, lambda v: str(v)), + ("open", "开盘价", 9, lambda v: f"{float(v):.3f}"), + ("sell_price", "卖出价", 9, lambda v: f"{float(v):.3f}"), + ("close", "收盘价", 9, lambda v: f"{float(v):.3f}"), + ("buy_price", "买入价", 9, lambda v: f"{float(v):.3f}"), + ("sell_volume", "卖出份额", 10, lambda v: f"{int(v):,d}"), + ("buy_volume", "买入份额", 10, lambda v: f"{int(v):,d}"), + ("commission", "佣金", 9, lambda v: f"{float(v):.2f}"), + ("profit", "净盈", 12, lambda v: f"{float(v):+,.2f}"), + ("profit_pct", "收益率%", 8, lambda v: f"{float(v):+.2f}"), + ("holding_days", "持仓天数", 8, lambda v: str(int(v))), + ("net_equity", "净值", 14, lambda v: f"{float(v):,.2f}"), + ("reason", "动作备注", 38, lambda v: str(v)), +] + + def parse_params(param_strings: list[str]) -> dict: """解析命令行参数字符串 'key=value' 为字典""" params = {} @@ -96,6 +117,13 @@ def main(): metavar="FILE", help="导出全部交易明细到 CSV 文件,如 --csv result.csv", ) + parser.add_argument( + "--slippage-rate", + type=float, + default=None, + metavar="RATE", + help="滑点百分比,如 0.0005 表示万5;0 表示无滑点;省略则使用 settings.yaml 默认", + ) args = parser.parse_args() setup_logging() @@ -124,6 +152,7 @@ def main(): start_date=start_date, end_date=end_date, initial_capital=args.capital, + slippage_rate=args.slippage_rate, ) # 检查每只股票的数据库日期范围 @@ -161,7 +190,19 @@ def main(): display_df = trades_df if args.show_all else trades_df.tail(20) _print_trades(display_df, len(result.trades)) if args.csv: - trades_df.to_csv(args.csv, index=False, encoding="utf-8-sig") + # 按 TRADE_COLUMNS 顺序挑列并重命名为中文,保持与控制台一致 + rename_map = {key: name for key, name, _, _ in TRADE_COLUMNS} + keep_keys = [key for key, *_ in TRADE_COLUMNS if key in trades_df.columns] + export_df = trades_df[keep_keys].copy() + # 整数列(volume/holding_days)pandas 遇 None 会升 float 输出 9000.0; + # 统一格式化为整数字符串或空串,避免 CSV 里出现 "9000.0" + for col in ("sell_volume", "buy_volume", "holding_days"): + if col in export_df.columns: + export_df[col] = export_df[col].map( + lambda v: "" if v is None or (isinstance(v, float) and v != v) else str(int(v)) + ) + export_df = export_df.rename(columns=rename_map) + export_df.to_csv(args.csv, index=False, encoding="utf-8-sig") print(f"\n[已导出] 全部 {len(trades_df)} 笔交易明细 → {args.csv}") else: print("无交易记录") @@ -182,41 +223,35 @@ def _pad_str(s: str, width: int, align: str = ">") -> str: def _print_trades(df, total_count: int): - """格式化打印交易明细""" + """格式化打印交易明细(按 TRADE_COLUMNS 统一列定义渲染)""" show_count = len(df) if show_count == total_count: print(f"\n【交易明细】(全部 {total_count} 笔)") else: print(f"\n【交易明细】(显示最近 {show_count} 笔,共 {total_count} 笔)") - # 列定义: (表头, 宽度) - cols = [ - ("代码", 8), ("买入日", 12), ("卖出日", 12), ("买入价", 9), - ("卖出价", 9), ("数量(股)", 10), ("盈亏", 12), - ("收益率%", 8), ("持仓天数", 8), ("卖出原因", 38), - ] - - sep = "+" + "+".join("-" * w for _, w in cols) + "+" - header = "|" + "|".join(_pad_str(name, w, "^") for name, w in cols) + "|" + sep = "+" + "+".join("-" * w for _, _, w, _ in TRADE_COLUMNS) + "+" + header = "|" + "|".join(_pad_str(name, w, "^") for _, name, w, _ in TRADE_COLUMNS) + "|" print(sep) print(header) print(sep) for _, row in df.iterrows(): - values = [ - row.get("code", ""), - str(row.get("buy_date", "")), - str(row.get("sell_date", "")), - f"{row.get('buy_price', 0):.3f}", - f"{row.get('sell_price', 0):.3f}", - f"{row.get('volume', 0):,d}", - f"{row.get('profit', 0):+,.2f}", - f"{row.get('profit_pct', 0):+.2f}", - str(row.get("holding_days", 0)), - str(row.get("reason", "")), - ] - line = "|" + "|".join(_pad_str(val, w) for val, (_, w) in zip(values, cols)) + "|" + values = [] + for key, _name, _w, fmt in TRADE_COLUMNS: + raw = row.get(key, "") + # 空值/None 容错:直接转空串,避免 fmt 对 None/NaN 抛错 + if raw is None or (isinstance(raw, float) and raw != raw): + values.append("") + else: + try: + values.append(fmt(raw)) + except (ValueError, TypeError): + values.append(str(raw)) + line = "|" + "|".join( + _pad_str(val, w) for val, (_, _, w, _) in zip(values, TRADE_COLUMNS) + ) + "|" print(line) print(sep) diff --git a/scripts/run_live_trade.py b/scripts/run_live_trade.py new file mode 100644 index 0000000..50bf310 --- /dev/null +++ b/scripts/run_live_trade.py @@ -0,0 +1,204 @@ +""" +实盘交易基座运行入口(Phase 7A) + +当前默认通过 DryRunBroker 验证执行链,不直接连接真实券商。 +""" +import argparse +import sys +from datetime import date, datetime +from pathlib import Path + +ROOT_DIR = Path(__file__).parent.parent +sys.path.insert(0, str(ROOT_DIR)) + +from loguru import logger + +from config import BrokerConfig, setup_logging +from data.models import init_db +from notification import EmailNotifier +from strategy.registry import STRATEGY_REGISTRY, load_strategy +from trading.account_id import build_account_id +from trading.broker import DryRunBroker, QmtBroker +from trading.live_engine import LiveEngine + + +def parse_params(param_strings: list[str]) -> dict: + params = {} + for p in (param_strings or []): + if "=" not in p: + continue + key, value = p.split("=", 1) + try: + value = int(value) + except ValueError: + try: + value = float(value) + except ValueError: + pass + params[key] = value + return params + + +def print_summary(summary: dict): + if not summary: + return + + print("\n" + "=" * 64) + print(f" 实盘交易基座日报:{summary['strategy']} {summary['run_date']}") + print(f" 实例ID:{summary['instance_id']}") + print(f" Broker:{summary['broker']}") + print("=" * 64) + print(f"\n 生成信号:{summary['signals_generated']} 个") + print(f" 激活计划单:{summary['planned_orders_activated']} 笔") + print(f" 计划单失败:{summary['planned_orders_failed']} 笔") + print(f" 构建委托:{summary['orders_built']} 笔") + print(f" 提交成功:{summary['orders_submitted']} 笔") + print(f" 提交失败:{summary['orders_rejected']} 笔") + print(f"\n 账户总资产:{summary['total_equity']:>14,.2f}") + print(f" 可用现金: {summary['cash']:>14,.2f}") + print(f" 持仓数量: {summary['position_count']:>14d}") + print("=" * 64) + + +def build_broker( + mode: str, + provider: str, + instance_id: str, + strategy_key: str, + stock_codes: list[str], + initial_capital: float, +): + if mode == "dry_run": + return DryRunBroker( + instance_id=instance_id, + strategy_key=strategy_key, + stock_codes=stock_codes, + initial_capital=initial_capital, + broker_account_id=BrokerConfig.account_id, + ) + + if mode == "live" and provider in {"qmt", "miniqmt"}: + return QmtBroker( + instance_id=instance_id, + strategy_key=strategy_key, + stock_codes=stock_codes, + initial_capital=initial_capital, + provider_name=provider, + account_id=BrokerConfig.account_id, + userdata_path=BrokerConfig.qmt_userdata_path, + session_id=BrokerConfig.qmt_session_id, + account_type=BrokerConfig.qmt_account_type, + dynamic_price_type=BrokerConfig.qmt_dynamic_price_type, + strategy_name=BrokerConfig.qmt_strategy_name, + order_remark_prefix=BrokerConfig.qmt_order_remark_prefix, + ) + + raise ValueError(f"未知 broker 组合: mode={mode}, provider={provider}") + + +def send_email_safe(notifier, subject: str, body: str): + if not notifier: + return + try: + notifier.notify(subject, body) + except Exception as e: + logger.error(f"邮件通知发送失败: {e}") + + +def format_summary_email(summary: dict) -> tuple[str, str]: + subject = f"[Apex][Live][Summary] {summary['strategy']} {summary['run_date']}" + body = ( + f"实例ID: {summary['instance_id']}\n" + f"策略: {summary['strategy']}\n" + f"Broker: {summary['broker']}\n" + f"运行日期: {summary['run_date']}\n" + f"激活计划单: {summary['planned_orders_activated']}\n" + f"计划单失败: {summary['planned_orders_failed']}\n" + f"生成信号: {summary['signals_generated']}\n" + f"构建委托: {summary['orders_built']}\n" + f"提交成功: {summary['orders_submitted']}\n" + f"提交失败: {summary['orders_rejected']}\n" + f"账户总资产: {summary['total_equity']:.2f}\n" + f"可用现金: {summary['cash']:.2f}\n" + f"持仓数量: {summary['position_count']}\n" + ) + return subject, body + + +def main(): + parser = argparse.ArgumentParser(description="A股实盘交易基座(Phase 7A)") + parser.add_argument("--strategy", "-s", required=True, + help=f"策略名称:{', '.join(STRATEGY_REGISTRY.keys())}") + parser.add_argument("--codes", "-c", nargs="+", required=True, + help="股票代码(空格分隔)") + parser.add_argument("--capital", type=float, default=1_000_000, + help="初始资金(dry-run 首次创建账户时生效)") + parser.add_argument("--date", default=None, + help="指定运行日期(默认今天),格式 YYYY-MM-DD") + parser.add_argument("--params", nargs="*", + help="策略参数 key=value 格式") + parser.add_argument("--mode", default=BrokerConfig.mode, + help="broker 模式:dry_run / live(默认读 settings.yaml)") + parser.add_argument("--provider", default=BrokerConfig.provider, + help="broker 提供方:qmt / miniqmt / dummy(默认读 settings.yaml)") + args = parser.parse_args() + + setup_logging() + init_db() + notifier = EmailNotifier.from_config() + + params = parse_params(args.params) + strategy = load_strategy(args.strategy, params) + instance_id = build_account_id(args.strategy, args.codes, params) + run_date = ( + datetime.strptime(args.date, "%Y-%m-%d").date() + if args.date else date.today() + ) + + logger.info( + f"启动实盘交易基座 strategy={args.strategy} " + f"instance={instance_id} mode={args.mode} provider={args.provider}" + ) + + broker = build_broker( + mode=args.mode, + provider=args.provider, + instance_id=instance_id, + strategy_key=args.strategy, + stock_codes=args.codes, + initial_capital=args.capital, + ) + + engine = LiveEngine( + strategy=strategy, + stock_codes=args.codes, + broker=broker, + instance_id=instance_id, + strategy_key=args.strategy, + notifier=notifier, + run_date=run_date, + ) + try: + summary = engine.run_daily() + except Exception as e: + send_email_safe( + notifier, + subject=f"[Apex][Live][Fatal] {args.strategy} {run_date}", + body=( + f"实例ID: {instance_id}\n" + f"策略: {args.strategy}\n" + f"Broker: mode={args.mode}, provider={args.provider}\n" + f"运行日期: {run_date}\n" + f"错误: {e}\n" + ), + ) + raise + + print_summary(summary) + if summary: + subject, body = format_summary_email(summary) + send_email_safe(notifier, subject, body) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_paper_trade.py b/scripts/run_paper_trade.py index 1f45865..4a17dfa 100644 --- a/scripts/run_paper_trade.py +++ b/scripts/run_paper_trade.py @@ -36,6 +36,7 @@ from data.storage.repository import PaperRepository, StockRepository from strategy.base import Direction from strategy.registry import STRATEGY_REGISTRY, load_strategy +from trading.account_id import build_account_id from trading.paper_account import PaperAccount from trading.paper_engine import PaperEngine @@ -57,6 +58,7 @@ def parse_params(param_strings: list[str]) -> dict: return params + # ── 输出工具(中文对齐)────────────────────────────────────────────────── def _cw(s: str) -> int: @@ -81,18 +83,18 @@ def _rjust(s: str, width: int) -> str: # ── 状态查看 ───────────────────────────────────────────────────────────── -def print_status(strategy_name: str): +def print_status(account_id: str, strategy_label: str): """打印账户当前状态、持仓和待执行订单""" repo = PaperRepository() stock_repo = StockRepository() - account_row = repo.get_paper_account(strategy_name) + account_row = repo.get_paper_account(account_id) if account_row is None: - print(f"\n[错误] 账户 '{strategy_name}' 不存在,请先运行不带 --status 的命令初始化账户") + print(f"\n[错误] 账户 '{account_id}' 不存在,请先用相同策略/参数/股票列表运行一次") return - positions = repo.get_paper_positions(strategy_name) - pending = [o for o in repo.get_order_history(strategy_name) if o.status == "pending"] + positions = repo.get_paper_positions(account_id) + pending = [o for o in repo.get_order_history(account_id) if o.status == "pending"] total_market_value = sum( p.volume * p.current_price for p in positions @@ -102,7 +104,8 @@ def print_status(strategy_name: str): W = 60 print("\n" + "=" * W) - print(f" 模拟盘账户状态:{strategy_name}") + print(f" 模拟盘账户状态:{strategy_label}") + print(f" 账户ID:{account_id}") print("=" * W) print("\n【账户概览】") @@ -157,20 +160,21 @@ def print_status(strategy_name: str): print("=" * W) -def print_history(strategy_name: str, days: int): +def print_history(account_id: str, strategy_label: str, days: int): """打印最近 N 天的净值记录""" repo = PaperRepository() - nav_series = repo.get_nav_series(strategy_name) + nav_series = repo.get_nav_series(account_id) if not nav_series: - print(f"\n账户 '{strategy_name}' 无净值记录") + print(f"\n账户 '{account_id}' 无净值记录") return rows = nav_series[-days:] - account_row = repo.get_paper_account(strategy_name) + account_row = repo.get_paper_account(account_id) initial = account_row.initial_capital if account_row else 1.0 W = 72 - print(f"\n净值记录:{strategy_name}(最近 {len(rows)} 条,共 {len(nav_series)} 条)") + print(f"\n净值记录:{strategy_label}(最近 {len(rows)} 条,共 {len(nav_series)} 条)") + print(f"账户ID:{account_id}") print("─" * W) C = [12, 12, 12, 10, 10, 10, 8] SEP = " " @@ -204,6 +208,7 @@ def print_summary(summary: dict): nav = summary.get("nav", 1.0) print("\n" + "=" * W) print(f" 模拟盘日报:{summary['strategy']} {summary['run_date']}") + print(f" 账户ID:{summary['account_id']}") print("=" * W) print(f"\n 今日执行:{summary['orders_filled']} 笔成交 / " f"{summary['orders_cancelled']} 笔取消 / " @@ -240,17 +245,16 @@ def main(): setup_logging() init_db() # 确保含新增的4张模拟盘表都已建好 - strategy = load_strategy(args.strategy, parse_params(args.params)) - - # 账户名 = 策略名(不含参数,同策略共享一个账户) - # 若需要同策略多套参数隔离运行,可以手动修改此处拼接参数后缀 + params = parse_params(args.params) + strategy = load_strategy(args.strategy, params) + account_id = build_account_id(args.strategy, args.codes, params) if args.status: - print_status(strategy.name) + print_status(account_id, strategy.name) return if args.history > 0: - print_history(strategy.name, args.history) + print_history(account_id, strategy.name, args.history) return run_date = ( @@ -263,6 +267,7 @@ def main(): stock_codes=args.codes, initial_capital=args.capital, run_date=run_date, + account_id=account_id, ) summary = engine.run_daily() print_summary(summary) diff --git a/strategy/registry.py b/strategy/registry.py index d4bdcff..6b40f08 100644 --- a/strategy/registry.py +++ b/strategy/registry.py @@ -7,6 +7,7 @@ import importlib from typing import Optional +from config import STRATEGY_CONFIG from strategy.base import BaseStrategy # ── 策略注册表 ──────────────────────────────────────────────────────────────── @@ -59,7 +60,12 @@ def load_strategy(name: str, params: Optional[dict] = None) -> BaseStrategy: module = importlib.import_module(module_path) cls = getattr(module, class_name) - config = {**info["default_params"], **(params or {})} + yaml_defaults = { + k: v + for k, v in STRATEGY_CONFIG.get(name, {}).items() + if k != "enabled" + } + config = {**info["default_params"], **yaml_defaults, **(params or {})} return cls(config) diff --git a/strategy/technical/overnight_long.py b/strategy/technical/overnight_long.py index 5245b71..d25e2b0 100644 --- a/strategy/technical/overnight_long.py +++ b/strategy/technical/overnight_long.py @@ -1,16 +1,22 @@ """ -隔夜多头策略(尾盘买 / 次日开盘卖) +隔夜多头策略(连续隔夜模式:今日尾盘买,明日开盘卖) -交易规则: - - T 日 14:55 以卖1价全仓买入(近似为 T 日收盘价买入) - - T+1 日 9:20 集合竞价挂跌停价卖(实际成交于 T+1 日开盘价) +严格时序: + - T 日收盘前 14:55 买入(近似为 T 日 close) + - T+1 日集合竞价挂跌停价卖出(实际成交价近似为 T+1 日 open) -边界处理: - - 空仓才买入(C 项守卫),避免重复加仓 - - 有可卖仓位才挂卖,T+1 冻结期跳过 - - 被一字跌停套牢时,次日 available 解冻后继续挂卖(A 方案) +在“收盘后统一运行”的引擎模型下,策略在 T 日日终需要完成两件事: + 1. 若今晚将持有仓位,则预约 1 笔明早的 SELL @ next_open + 2. 若当前空仓且买入过滤通过,则执行 1 笔 BUY @ close -可选过滤参数(默认禁用): +因此空仓日会返回两个信号: + - BUY @ close (今天尾盘建仓) + - SELL @ next_open (预约明早开盘平仓) + +而持仓日只返回一个信号: + - SELL @ next_open (预约明早开盘平仓) + +可选过滤参数(仅作用于 BUY 分支): - min_drop_pct: 当日跌幅必须 ≥ 该值才买入(抄反弹) - max_rise_pct: 当日涨幅必须 ≤ 该值才买入(避免追高) """ @@ -29,35 +35,40 @@ def on_bar(self, bar: BarData) -> list[Signal]: signals: list[Signal] = [] min_drop: Optional[float] = self.config.get("min_drop_pct") max_rise: Optional[float] = self.config.get("max_rise_pct") - - # 1) 卖出:有可卖仓位 → 次日集合竞价挂跌停价卖(开盘成交) pos = self.get_position(bar.code) - if pos and pos.available > 0: + holding = pos is not None and pos.volume > 0 + + should_buy = False + if not holding and bar.pre_close > 0: + pct = (bar.close - bar.pre_close) / bar.pre_close * 100 + if min_drop is not None and -pct < min_drop: + should_buy = False + elif max_rise is not None and pct > max_rise: + should_buy = False + else: + should_buy = True + + if should_buy: signals.append(Signal( code=bar.code, - direction=Direction.SELL, + direction=Direction.BUY, trade_date=bar.trade_date, price=0, volume=0, - reason="次日集合竞价挂跌停价卖(开盘价成交)", - execute_at="next_open", + reason="尾盘 14:55 卖1价全仓买入", + execute_at="close", )) - # 2) 买入:空仓 + 通过过滤 → 当日收盘价全仓买(近似 14:55 卖1价) - if not self.has_position(bar.code) and bar.pre_close > 0: - pct = (bar.close - bar.pre_close) / bar.pre_close * 100 - if min_drop is not None and -pct < min_drop: - return signals - if max_rise is not None and pct > max_rise: - return signals + # 今晚会持仓(已有仓,或即将尾盘买入)→ 预约明早集合竞价卖出 + if holding or should_buy: signals.append(Signal( code=bar.code, - direction=Direction.BUY, + direction=Direction.SELL, trade_date=bar.trade_date, price=0, volume=0, - reason="尾盘 14:55 卖1价全仓买入", - execute_at="close", + reason="次日集合竞价挂跌停价卖(开盘价成交)", + execute_at="next_open", )) return signals diff --git a/tests/test_api_dashboard.py b/tests/test_api_dashboard.py new file mode 100644 index 0000000..93493f3 --- /dev/null +++ b/tests/test_api_dashboard.py @@ -0,0 +1,150 @@ +""" +Dashboard API 路由逻辑测试 + +说明: +- 当前环境下 `httpx 0.28.1` + FastAPI/Starlette 组合会导致 ASGI 测试链不稳定卡住 +- 仓库已通过 `requirements.txt` 收紧到 `httpx<0.28` +- 在开发环境按新依赖重建之前,这里先锁路由逻辑本身,保证回归稳定 +""" +from __future__ import annotations + +from datetime import date, datetime +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from api.main import health +from api.routers.dashboard import get_dashboard, list_accounts + + +class FakePaperRepository: + def __init__(self): + self._accounts = [ + SimpleNamespace( + strategy_name="overnight_long:abcd1234", + initial_capital=100000.0, + cash=20000.0, + total_commission=123.45, + total_tax=0.0, + stock_codes="", + updated_at=datetime(2026, 4, 24, 10, 0, 0), + created_at=datetime(2026, 4, 23, 10, 0, 0), + ) + ] + self._positions = { + "overnight_long:abcd1234": [ + SimpleNamespace( + code="513090", + volume=9000, + available=9000, + cost_price=1.05, + current_price=1.08, + buy_date=date(2026, 4, 23), + ) + ] + } + self._orders = { + "overnight_long:abcd1234": [ + SimpleNamespace( + order_id="o1", + code="513090", + direction="SELL", + req_volume=9000, + execute_date=date(2026, 4, 25), + signal_date=date(2026, 4, 24), + status="pending", + reason="test-order", + ), + SimpleNamespace( + order_id="o2", + code="513090", + direction="BUY", + req_volume=9000, + execute_date=date(2026, 4, 24), + signal_date=date(2026, 4, 24), + status="filled", + reason="filled-order", + ), + ] + } + self._nav = { + "overnight_long:abcd1234": [ + SimpleNamespace( + trade_date=date(2026, 4, 23), + total_equity=100500.0, + cash=25000.0, + market_value=75500.0, + nav=1.005, + daily_pnl=500.0, + ), + SimpleNamespace( + trade_date=date(2026, 4, 24), + total_equity=101200.0, + cash=20000.0, + market_value=81200.0, + nav=1.012, + daily_pnl=700.0, + ), + ] + } + + def list_paper_accounts(self): + return self._accounts + + def get_paper_positions(self, strategy_name: str): + return self._positions.get(strategy_name, []) + + def get_paper_account(self, strategy_name: str): + return next((a for a in self._accounts if a.strategy_name == strategy_name), None) + + def get_order_history(self, strategy_name: str, start_date=None, end_date=None): + return self._orders.get(strategy_name, []) + + def get_nav_series(self, strategy_name: str, start_date=None, end_date=None): + return self._nav.get(strategy_name, []) + + +def test_health_returns_ok(): + assert health() == {"status": "ok"} + + +def test_list_accounts_uses_position_fallback_when_stock_codes_missing(): + payload = list_accounts(FakePaperRepository()) + + assert len(payload) == 1 + assert payload[0].account_id == "overnight_long:abcd1234" + assert payload[0].strategy_key == "overnight_long" + assert payload[0].stock_codes == ["513090"] + + +def test_get_dashboard_returns_overview_positions_orders_and_nav(): + payload = get_dashboard( + repo=FakePaperRepository(), + account_id="overnight_long:abcd1234", + days=1, + ) + + assert payload.selected_account_id == "overnight_long:abcd1234" + assert payload.overview is not None + assert payload.overview.strategy_key == "overnight_long" + assert payload.overview.position_count == 1 + assert payload.overview.pending_count == 1 + assert len(payload.positions) == 1 + assert payload.positions[0].code == "513090" + assert len(payload.pending_orders) == 1 + assert payload.pending_orders[0].status == "pending" + assert len(payload.nav) == 1 + assert payload.nav[0].trade_date == date(2026, 4, 24) + + +def test_get_dashboard_raises_404_for_unknown_account(): + with pytest.raises(HTTPException) as exc: + get_dashboard( + repo=FakePaperRepository(), + account_id="missing-account", + days=60, + ) + + assert exc.value.status_code == 404 + assert "账户不存在" in str(exc.value.detail) diff --git a/tests/test_email_notifier.py b/tests/test_email_notifier.py new file mode 100644 index 0000000..5545e7f --- /dev/null +++ b/tests/test_email_notifier.py @@ -0,0 +1,59 @@ +""" +邮件通知测试 +""" +from email.message import EmailMessage + +from config import NotificationConfig +from notification.email_notifier import EmailNotifier + + +class FakeSMTP: + def __init__(self, host, port, timeout=10): + self.host = host + self.port = port + self.timeout = timeout + self.logged_in = None + self.messages: list[EmailMessage] = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def login(self, user, password): + self.logged_in = (user, password) + + def send_message(self, msg: EmailMessage): + self.messages.append(msg) + + +def test_email_notifier_sends_message_via_smtp_ssl(monkeypatch): + fake = FakeSMTP("smtp.example.com", 465) + monkeypatch.setattr("smtplib.SMTP_SSL", lambda host, port, timeout=10: fake) + + notifier = EmailNotifier( + smtp_server="smtp.example.com", + smtp_port=465, + sender="sender@example.com", + password="app-password", + receiver="receiver@example.com", + ) + notifier.notify("Test Subject", "Hello Apex") + + assert fake.logged_in == ("sender@example.com", "app-password") + assert len(fake.messages) == 1 + msg = fake.messages[0] + assert msg["Subject"] == "Test Subject" + assert msg["From"] == "sender@example.com" + assert msg["To"] == "receiver@example.com" + assert "Hello Apex" in msg.get_content() + + +def test_email_notifier_from_config_returns_none_when_disabled(): + original = NotificationConfig.email_enabled + NotificationConfig.email_enabled = False + try: + assert EmailNotifier.from_config() is None + finally: + NotificationConfig.email_enabled = original diff --git a/tests/test_live_trade.py b/tests/test_live_trade.py new file mode 100644 index 0000000..8fd2aa5 --- /dev/null +++ b/tests/test_live_trade.py @@ -0,0 +1,251 @@ +""" +Phase 7A 实盘交易基座测试 +""" +from datetime import date + +import pandas as pd + +from config import DatabaseConfig +from data.models import init_db +from data.storage.repository import LiveRepository +from strategy.base import Direction +from strategy.technical.overnight_long import OvernightLongStrategy +from trading.broker.base import BrokerOrderRequest +from trading.broker.dry_run import DryRunBroker +from trading.live_engine import LiveEngine + + +class RecordingNotifier: + def __init__(self): + self.messages: list[tuple[str, str]] = [] + + def notify(self, subject: str, body: str) -> None: + self.messages.append((subject, body)) + + +class FakeStockRepository: + def __init__(self, bars_by_code: dict[str, list[dict]]): + self._bars_by_code = bars_by_code + self._trade_dates = { + row["trade_date"] + for rows in bars_by_code.values() + for row in rows + } + + def get_daily_bars(self, code: str, start_date: date, end_date: date) -> pd.DataFrame: + rows = [ + row + for row in self._bars_by_code.get(code, []) + if start_date <= row["trade_date"] <= end_date + ] + return pd.DataFrame(rows) + + def is_trade_date(self, check_date: date) -> bool: + return check_date in self._trade_dates + + +def make_bars() -> dict[str, list[dict]]: + return { + "513090": [ + { + "trade_date": date(2026, 1, 5), + "open": 1.00, + "high": 1.06, + "low": 0.99, + "close": 1.05, + "pre_close": 1.00, + "volume": 1_000_000, + "amount": 1_050_000.0, + "turnover": 0.0, + "pct_change": 5.0, + }, + { + "trade_date": date(2026, 1, 6), + "open": 1.08, + "high": 1.10, + "low": 1.00, + "close": 1.02, + "pre_close": 1.05, + "volume": 1_100_000, + "amount": 1_122_000.0, + "turnover": 0.0, + "pct_change": -2.86, + }, + ] + } + + +def setup_temp_db(tmp_path, monkeypatch): + db_path = tmp_path / "live_trade.db" + monkeypatch.setattr(DatabaseConfig, "engine", "sqlite") + monkeypatch.setattr(DatabaseConfig, "sqlite_url", f"sqlite:///{db_path}") + init_db() + + +def test_dry_run_broker_submit_order_persists_live_order(tmp_path, monkeypatch): + setup_temp_db(tmp_path, monkeypatch) + + broker = DryRunBroker( + instance_id="overnight_long:test", + strategy_key="overnight_long", + stock_codes=["513090"], + initial_capital=10_000.0, + ) + request = BrokerOrderRequest( + order_id="test-order-1", + instance_id="overnight_long:test", + strategy_key="overnight_long", + code="513090", + direction=Direction.BUY, + signal_date=date(2026, 1, 5), + execute_at="close", + planned_execute_date=date(2026, 1, 5), + price=1.05, + volume=9000, + reason="unit-test", + ) + + order = broker.submit_order(request) + + repo = LiveRepository() + live_orders = repo.get_live_orders("overnight_long:test") + + assert order.status == "submitted" + assert len(live_orders) == 1 + assert live_orders[0].order_id == "test-order-1" + assert live_orders[0].status == "submitted" + assert live_orders[0].req_volume == 9000 + + +def test_live_engine_builds_buy_close_and_sell_next_open_for_overnight_long(tmp_path, monkeypatch): + setup_temp_db(tmp_path, monkeypatch) + + broker = DryRunBroker( + instance_id="overnight_long:test", + strategy_key="overnight_long", + stock_codes=["513090"], + initial_capital=10_000.0, + ) + engine = LiveEngine( + strategy=OvernightLongStrategy(), + strategy_key="overnight_long", + stock_codes=["513090"], + broker=broker, + instance_id="overnight_long:test", + run_date=date(2026, 1, 5), + ) + engine._repo = FakeStockRepository(make_bars()) + + summary = engine.run_daily() + + repo = LiveRepository() + live_orders = repo.get_live_orders("overnight_long:test") + assert summary["signals_generated"] == 2 + assert summary["orders_built"] == 2 + assert summary["orders_submitted"] == 2 + assert len(live_orders) == 2 + + by_execute_at = {row.execute_at: row for row in live_orders} + assert by_execute_at["close"].direction == "BUY" + assert by_execute_at["close"].req_price == 1.05 + assert by_execute_at["close"].req_volume == 9000 + assert by_execute_at["close"].status == "submitted" + + assert by_execute_at["next_open"].direction == "SELL" + assert by_execute_at["next_open"].planned_execute_date == date(2026, 1, 6) + assert by_execute_at["next_open"].req_volume == 9000 + assert by_execute_at["next_open"].status == "planned" + + +def test_live_engine_activates_due_planned_orders_on_execute_date(tmp_path, monkeypatch): + setup_temp_db(tmp_path, monkeypatch) + + broker = DryRunBroker( + instance_id="overnight_long:test", + strategy_key="overnight_long", + stock_codes=["513090"], + initial_capital=10_000.0, + ) + repo = LiveRepository() + repo.save_live_order({ + "order_id": "planned-order-1", + "instance_id": "overnight_long:test", + "strategy_key": "overnight_long", + "broker_provider": "dry_run", + "broker_order_id": "", + "code": "513090", + "direction": "SELL", + "signal_date": date(2026, 1, 5), + "planned_execute_date": date(2026, 1, 6), + "execute_at": "next_open", + "req_price": 1.08, + "req_volume": 9000, + "status": "planned", + "reason": "activate-next-day", + }) + + engine = LiveEngine( + strategy=OvernightLongStrategy(), + strategy_key="overnight_long", + stock_codes=["513090"], + broker=broker, + instance_id="overnight_long:test", + run_date=date(2026, 1, 6), + ) + engine._repo = FakeStockRepository(make_bars()) + + summary = engine.run_daily() + row = repo.get_live_order("planned-order-1") + + assert summary["planned_orders_activated"] == 1 + assert row.status == "submitted" + + +def test_live_engine_notifies_when_activation_fails_due_to_missing_bar(tmp_path, monkeypatch): + setup_temp_db(tmp_path, monkeypatch) + + broker = DryRunBroker( + instance_id="overnight_long:test", + strategy_key="overnight_long", + stock_codes=["513090"], + initial_capital=10_000.0, + ) + repo = LiveRepository() + repo.save_live_order({ + "order_id": "planned-order-2", + "instance_id": "overnight_long:test", + "strategy_key": "overnight_long", + "broker_provider": "dry_run", + "broker_order_id": "", + "code": "000001", + "direction": "SELL", + "signal_date": date(2026, 1, 5), + "planned_execute_date": date(2026, 1, 6), + "execute_at": "next_open", + "req_price": 1.08, + "req_volume": 9000, + "status": "planned", + "reason": "activate-next-day", + }) + + notifier = RecordingNotifier() + engine = LiveEngine( + strategy=OvernightLongStrategy(), + strategy_key="overnight_long", + stock_codes=["513090"], + broker=broker, + instance_id="overnight_long:test", + notifier=notifier, + run_date=date(2026, 1, 6), + ) + engine._repo = FakeStockRepository(make_bars()) + + summary = engine.run_daily() + row = repo.get_live_order("planned-order-2") + + assert summary["planned_orders_failed"] == 1 + assert row.status == "rejected" + assert len(notifier.messages) == 1 + subject, body = notifier.messages[0] + assert "Activation Failed" in subject + assert "000001" in body diff --git a/tests/test_overnight_long.py b/tests/test_overnight_long.py index c817d0b..88ab153 100644 --- a/tests/test_overnight_long.py +++ b/tests/test_overnight_long.py @@ -1,25 +1,28 @@ """ overnight_long 策略单元测试 -覆盖所有信号分支,构造 BarData 直接喂给策略,不依赖数据库/引擎。 +覆盖新语义: +- 空仓日:BUY @ close + SELL @ next_open +- 持仓日:SELL @ next_open +- 过滤参数仅作用于 BUY 分支 """ from datetime import date -import pytest - from strategy.base import BarData, Direction, Position +from strategy.registry import load_strategy from strategy.technical.overnight_long import OvernightLongStrategy -def make_bar(code="513090", trade_date=None, close=1.0, pre_close=1.0): - """构造一根 BarData 便于测试""" +def make_bar(code="513090", trade_date=None, open_=None, close=1.0, pre_close=1.0): + """构造一根 BarData,open 缺省等于 pre_close。""" td = trade_date or date(2026, 1, 5) + o = open_ if open_ is not None else pre_close return BarData( code=code, trade_date=td, - open=pre_close, - high=max(pre_close, close), - low=min(pre_close, close), + open=o, + high=max(o, close, pre_close), + low=min(o, close, pre_close), close=close, pre_close=pre_close, volume=1_000_000, @@ -27,126 +30,144 @@ def make_bar(code="513090", trade_date=None, close=1.0, pre_close=1.0): ) -def test_empty_position_generates_buy_signal(): - """空仓 + 默认参数 → 产生 1 个 BUY 信号,execute_at='close',volume=0 由引擎解析""" +def make_position(code="513090", volume=10000, available=10000, buy_date=None): + return Position( + code=code, + volume=volume, + available=available, + cost_price=1.80, + current_price=1.80, + buy_date=buy_date or date(2026, 1, 4), + ) + + +def test_load_strategy_merges_yaml_defaults(): + """registry 应合并 strategies.yaml 默认参数,而不只用硬编码 default_params。""" + strategy = load_strategy("overnight_long") + + assert strategy.config["min_drop_pct"] is None + assert strategy.config["max_rise_pct"] is None + assert "limit_pct" not in strategy.config + + +def test_empty_position_generates_buy_and_next_open_sell(): + """空仓 + 默认参数 → 当日 BUY @ close,同时预约明早 SELL @ next_open。""" strategy = OvernightLongStrategy() bar = make_bar(close=1.80, pre_close=1.82) signals = strategy.on_bar(bar) - assert len(signals) == 1 - s = signals[0] - assert s.direction == Direction.BUY - assert s.code == "513090" - assert s.execute_at == "close" - assert s.volume == 0 - assert s.price == 0 + assert len(signals) == 2 + assert signals[0].direction == Direction.BUY + assert signals[0].execute_at == "close" + assert signals[1].direction == Direction.SELL + assert signals[1].execute_at == "next_open" -def test_held_position_generates_sell_signal(): - """有可卖仓位 → 产生 1 个 SELL 信号,execute_at='next_open'""" +def test_held_position_generates_only_next_open_sell(): + """已有持仓时,只需要预约明早卖出,不应同日再买。""" strategy = OvernightLongStrategy() - pos = Position( - code="513090", - volume=10000, - available=10000, - cost_price=1.80, - current_price=1.80, - buy_date=date(2026, 1, 4), + strategy._sync_account( + positions={"513090": make_position()}, + cash=0.0, + total_value=18000.0, ) - strategy._sync_account(positions={"513090": pos}, cash=0.0, total_value=18000.0) bar = make_bar(close=1.85, pre_close=1.80) signals = strategy.on_bar(bar) assert len(signals) == 1 - s = signals[0] - assert s.direction == Direction.SELL - assert s.execute_at == "next_open" - assert s.volume == 0 # 0 = 全部可卖持仓,引擎解析 + assert signals[0].direction == Direction.SELL + assert signals[0].execute_at == "next_open" -def test_position_with_zero_available_skips_sell(): - """T+1 未解冻(available=0)→ 不产 SELL 信号,也不产 BUY(因为 has_position=True)""" +def test_position_with_zero_available_still_schedules_next_open_sell(): + """即便 available=0,只要今晚仍会持仓,也应预约明早卖出。""" strategy = OvernightLongStrategy() - pos = Position( - code="513090", - volume=10000, - available=0, # 今日刚买入,T+1 未解冻 - cost_price=1.80, - current_price=1.80, - buy_date=date(2026, 1, 5), + strategy._sync_account( + positions={"513090": make_position(available=0, buy_date=date(2026, 1, 5))}, + cash=0.0, + total_value=18000.0, ) - strategy._sync_account(positions={"513090": pos}, cash=0.0, total_value=18000.0) bar = make_bar(close=1.85, pre_close=1.80) signals = strategy.on_bar(bar) - assert len(signals) == 0 + assert len(signals) == 1 + assert signals[0].direction == Direction.SELL + assert signals[0].execute_at == "next_open" def test_filters_disabled_by_default_even_on_big_rise(): - """默认两参数 None → 当日涨 5% 也产 BUY(过滤未生效)""" + """默认过滤关闭:空仓日即使大涨,也会 BUY + 预约 SELL。""" strategy = OvernightLongStrategy() - bar = make_bar(close=1.05, pre_close=1.00) # 涨 5% + bar = make_bar(close=1.05, pre_close=1.00) signals = strategy.on_bar(bar) - assert len(signals) == 1 + assert len(signals) == 2 assert signals[0].direction == Direction.BUY + assert signals[1].direction == Direction.SELL -def test_min_drop_pct_blocks_when_drop_insufficient(): - """min_drop_pct=3 + 当日跌 2% → 不产 BUY(跌幅不足)""" +def test_min_drop_pct_blocks_flat_entry(): + """空仓 + 跌幅不足 → 不买,也不应预约明早卖出。""" strategy = OvernightLongStrategy(config={"min_drop_pct": 3.0}) - bar = make_bar(close=0.98, pre_close=1.00) # 跌 2% + bar = make_bar(close=0.98, pre_close=1.00) + signals = strategy.on_bar(bar) - assert len(signals) == 0 + + assert signals == [] -def test_min_drop_pct_allows_when_drop_sufficient(): - """min_drop_pct=3 + 当日跌 5% → 产 BUY(跌幅达标)""" +def test_min_drop_pct_keeps_sell_when_holding(): + """过滤参数只影响 BUY 分支,持仓出场不受影响。""" strategy = OvernightLongStrategy(config={"min_drop_pct": 3.0}) - bar = make_bar(close=0.95, pre_close=1.00) # 跌 5% + strategy._sync_account( + positions={"513090": make_position()}, + cash=0.0, + total_value=18000.0, + ) + bar = make_bar(close=0.98, pre_close=1.00) + signals = strategy.on_bar(bar) + assert len(signals) == 1 + assert signals[0].direction == Direction.SELL + + +def test_min_drop_pct_allows_flat_entry_when_drop_sufficient(): + """空仓 + 跌幅满足阈值 → BUY + 预约 SELL。""" + strategy = OvernightLongStrategy(config={"min_drop_pct": 3.0}) + bar = make_bar(close=0.95, pre_close=1.00) + + signals = strategy.on_bar(bar) + + assert len(signals) == 2 assert signals[0].direction == Direction.BUY + assert signals[1].direction == Direction.SELL -def test_max_rise_pct_blocks_when_rise_exceeds(): - """max_rise_pct=3 + 当日涨 5% → 不产 BUY(涨幅超限)""" +def test_max_rise_pct_blocks_flat_entry(): + """空仓 + 涨幅超阈值 → 不买,也不应生成 SELL。""" strategy = OvernightLongStrategy(config={"max_rise_pct": 3.0}) - bar = make_bar(close=1.05, pre_close=1.00) # 涨 5% + bar = make_bar(close=1.05, pre_close=1.00) + signals = strategy.on_bar(bar) - assert len(signals) == 0 + assert signals == [] -def test_two_day_cycle_buy_then_sell_no_rebuy(): - """ - T 日空仓 → 产 BUY - T+1 日持仓(模拟已买入、T+1 已解冻)→ 产 SELL 且不再产 BUY(C 项守卫) - """ - strategy = OvernightLongStrategy() - # T 日:空仓 - bar_t = make_bar(trade_date=date(2026, 1, 5), close=1.80, pre_close=1.82) - signals_t = strategy.on_bar(bar_t) - assert len(signals_t) == 1 - assert signals_t[0].direction == Direction.BUY - - # T+1 日:模拟持仓已买入并解冻 - pos = Position( - code="513090", - volume=10000, - available=10000, - cost_price=1.80, - current_price=1.80, - buy_date=date(2026, 1, 5), +def test_max_rise_pct_keeps_sell_when_holding(): + """持仓状态下,即使涨幅超阈值,仍需预约明早卖出。""" + strategy = OvernightLongStrategy(config={"max_rise_pct": 3.0}) + strategy._sync_account( + positions={"513090": make_position()}, + cash=0.0, + total_value=18000.0, ) - strategy._sync_account(positions={"513090": pos}, cash=0.0, total_value=18000.0) + bar = make_bar(close=1.05, pre_close=1.00) - bar_t1 = make_bar(trade_date=date(2026, 1, 6), close=1.85, pre_close=1.80) - signals_t1 = strategy.on_bar(bar_t1) + signals = strategy.on_bar(bar) - assert len(signals_t1) == 1 - assert signals_t1[0].direction == Direction.SELL - assert signals_t1[0].execute_at == "next_open" + assert len(signals) == 1 + assert signals[0].direction == Direction.SELL diff --git a/tests/test_overnight_long_engines.py b/tests/test_overnight_long_engines.py new file mode 100644 index 0000000..0b87c31 --- /dev/null +++ b/tests/test_overnight_long_engines.py @@ -0,0 +1,175 @@ +""" +overnight_long 引擎级回归测试 + +锁定用户目标语义: +- 回测:T 日 close 买入,T+1 日 open 卖出 +- 模拟盘:T 日 close 买入后生成 T+1 open 的 pending SELL +""" +from datetime import date + +import pandas as pd + +from backtest.engine import BacktestEngine +from config import DatabaseConfig +from data.models import init_db +from data.storage.repository import PaperRepository +from strategy.technical.overnight_long import OvernightLongStrategy +from trading.paper_engine import PaperEngine + + +class FakeStockRepository: + def __init__(self, bars_by_code: dict[str, list[dict]]): + self._bars_by_code = bars_by_code + self._trade_dates = { + row["trade_date"] + for rows in bars_by_code.values() + for row in rows + } + + def get_daily_bars(self, code: str, start_date: date, end_date: date) -> pd.DataFrame: + rows = [ + row + for row in self._bars_by_code.get(code, []) + if start_date <= row["trade_date"] <= end_date + ] + return pd.DataFrame(rows) + + def is_trade_date(self, check_date: date) -> bool: + return check_date in self._trade_dates + + +def make_bars() -> dict[str, list[dict]]: + return { + "513090": [ + { + "trade_date": date(2026, 1, 5), + "open": 1.00, + "high": 1.06, + "low": 0.99, + "close": 1.05, + "pre_close": 1.00, + "volume": 1_000_000, + "amount": 1_050_000.0, + "turnover": 0.0, + "pct_change": 5.0, + }, + { + "trade_date": date(2026, 1, 6), + "open": 1.08, + "high": 1.10, + "low": 1.00, + "close": 1.02, + "pre_close": 1.05, + "volume": 1_100_000, + "amount": 1_122_000.0, + "turnover": 0.0, + "pct_change": -2.86, + }, + { + "trade_date": date(2026, 1, 7), + "open": 1.03, + "high": 1.05, + "low": 1.00, + "close": 1.01, + "pre_close": 1.02, + "volume": 1_050_000, + "amount": 1_060_500.0, + "turnover": 0.0, + "pct_change": -0.98, + }, + ] + } + + +def test_backtest_executes_close_buy_then_next_day_open_sell(): + strategy = OvernightLongStrategy() + engine = BacktestEngine( + strategy=strategy, + stock_codes=["513090"], + start_date=date(2026, 1, 5), + end_date=date(2026, 1, 6), + initial_capital=10_000.0, + slippage_rate=0.0, + ) + engine.repo = FakeStockRepository(make_bars()) + + result = engine.run() + trades = result.get_trades_df() + + assert len(trades) == 2 + + first = trades.iloc[0] + second = trades.iloc[1] + + assert first["action"] == "建仓" + assert first["buy_price"] == 1.05 + assert pd.isna(first["sell_price"]) + + assert second["action"] == "换仓" + assert second["sell_price"] == 1.08 + assert second["buy_price"] == 1.02 + assert second["holding_days"] == 1 + + # metrics 不应再因为 direction 字段缺失而把交易次数统计成 0 + assert result.metrics.total_trades == 1 + assert result.metrics.win_count == 1 + + +def test_paper_engine_creates_next_day_open_sell_for_close_buy(tmp_path, monkeypatch): + db_path = tmp_path / "paper_overnight_long.db" + monkeypatch.setattr(DatabaseConfig, "engine", "sqlite") + monkeypatch.setattr(DatabaseConfig, "sqlite_url", f"sqlite:///{db_path}") + init_db() + + fake_repo = FakeStockRepository(make_bars()) + + engine_day1 = PaperEngine( + strategy=OvernightLongStrategy(), + stock_codes=["513090"], + initial_capital=10_000.0, + run_date=date(2026, 1, 5), + ) + engine_day1._repo = fake_repo + summary_day1 = engine_day1.run_daily() + + repo = PaperRepository() + day1_orders = repo.get_order_history("overnight_long") + pending_day2 = repo.get_pending_orders("overnight_long", date(2026, 1, 6)) + + assert summary_day1["orders_filled"] == 1 + assert summary_day1["pending_for_tomorrow"] == 1 + assert summary_day1["position_count"] == 1 + assert len(pending_day2) == 1 + assert pending_day2[0].direction == "SELL" + assert pending_day2[0].req_volume == 9000 + + filled_day1_buys = [ + o for o in day1_orders + if o.direction == "BUY" and o.status == "filled" and o.execute_date == date(2026, 1, 5) + ] + assert len(filled_day1_buys) == 1 + assert filled_day1_buys[0].filled_price == 1.05 + + engine_day2 = PaperEngine( + strategy=OvernightLongStrategy(), + stock_codes=["513090"], + initial_capital=10_000.0, + run_date=date(2026, 1, 6), + ) + engine_day2._repo = fake_repo + summary_day2 = engine_day2.run_daily() + + day2_orders = repo.get_order_history("overnight_long") + filled_day2_sells = [ + o for o in day2_orders + if o.direction == "SELL" and o.status == "filled" and o.execute_date == date(2026, 1, 6) + ] + pending_day3 = repo.get_pending_orders("overnight_long", date(2026, 1, 7)) + + assert summary_day2["orders_filled"] == 2 + assert summary_day2["pending_for_tomorrow"] == 1 + assert summary_day2["position_count"] == 1 + assert len(filled_day2_sells) == 1 + assert filled_day2_sells[0].filled_price == 1.08 + assert len(pending_day3) == 1 + assert pending_day3[0].direction == "SELL" diff --git a/tests/test_paper_trade_helpers.py b/tests/test_paper_trade_helpers.py new file mode 100644 index 0000000..275d811 --- /dev/null +++ b/tests/test_paper_trade_helpers.py @@ -0,0 +1,22 @@ +""" +模拟盘辅助逻辑测试 +""" +from scripts.run_paper_trade import build_account_id + + +def test_build_account_id_is_stable_and_order_insensitive(): + params = {"min_drop_pct": 3.0, "max_rise_pct": 2.0} + + a = build_account_id("overnight_long", ["513090", "159915"], params) + b = build_account_id("overnight_long", ["159915", "513090"], {"max_rise_pct": 2.0, "min_drop_pct": 3.0}) + + assert a == b + + +def test_build_account_id_changes_when_params_or_codes_change(): + base = build_account_id("overnight_long", ["513090"], {"min_drop_pct": 3.0}) + diff_params = build_account_id("overnight_long", ["513090"], {"min_drop_pct": 5.0}) + diff_codes = build_account_id("overnight_long", ["159915"], {"min_drop_pct": 3.0}) + + assert base != diff_params + assert base != diff_codes diff --git a/tests/test_qmt_broker.py b/tests/test_qmt_broker.py new file mode 100644 index 0000000..f8684b7 --- /dev/null +++ b/tests/test_qmt_broker.py @@ -0,0 +1,184 @@ +""" +QmtBroker 适配层测试 + +通过 fake xtquant 模块验证: +- 账户查询映射 +- 持仓查询映射 +- 订单提交映射 +""" +from __future__ import annotations + +from datetime import date +from types import ModuleType, SimpleNamespace + +from config import DatabaseConfig +from data.models import init_db +from data.storage.repository import LiveRepository +from strategy.base import Direction +from trading.broker.base import BrokerOrderRequest +from trading.broker.qmt_broker import QmtBroker + + +def setup_temp_db(tmp_path, monkeypatch): + db_path = tmp_path / "qmt_broker.db" + monkeypatch.setattr(DatabaseConfig, "engine", "sqlite") + monkeypatch.setattr(DatabaseConfig, "sqlite_url", f"sqlite:///{db_path}") + init_db() + + +def install_fake_xtquant(monkeypatch): + xtquant_pkg = ModuleType("xtquant") + xttrader_mod = ModuleType("xtquant.xttrader") + xttype_mod = ModuleType("xtquant.xttype") + xtconstant_mod = ModuleType("xtquant.xtconstant") + + class FakeCallback: + pass + + class FakeStockAccount: + def __init__(self, account_id, account_type): + self.account_id = account_id + self.account_type = account_type + + class FakeTrader: + def __init__(self, userdata_path, session_id): + self.userdata_path = userdata_path + self.session_id = session_id + self.orders = [] + + def register_callback(self, callback): + self.callback = callback + + def start(self): + return 0 + + def connect(self): + return 0 + + def subscribe(self, account): + self.account = account + return 0 + + def query_stock_asset(self, account): + return SimpleNamespace(cash=120000.0, total_asset=150000.0) + + def query_stock_positions(self, account): + return [ + SimpleNamespace( + stock_code="513090.SH", + volume=9000, + can_use_volume=9000, + open_price=1.05, + last_price=1.08, + ) + ] + + def order_stock(self, account, stock_code, order_type, volume, price_type, price, strategy_name, remark): + self.orders.append({ + "stock_code": stock_code, + "order_type": order_type, + "volume": volume, + "price_type": price_type, + "price": price, + "strategy_name": strategy_name, + "remark": remark, + }) + return 10001 + + def cancel_order_stock(self, account, broker_order_id): + return 0 + + def query_stock_orders(self, account): + return [ + SimpleNamespace( + order_id=10001, + stock_code="513090.SH", + order_type=23, + order_volume=9000, + traded_volume=0, + order_status=50, + price=1.05, + traded_price=0.0, + commission=0.0, + order_remark="test", + ) + ] + + xttrader_mod.XtQuantTrader = FakeTrader + xttrader_mod.XtQuantTraderCallback = FakeCallback + xttype_mod.StockAccount = FakeStockAccount + xtconstant_mod.STOCK_BUY = 23 + xtconstant_mod.STOCK_SELL = 24 + xtconstant_mod.FIX_PRICE = 11 + xtconstant_mod.LATEST_PRICE = 5 + xtconstant_mod.ORDER_REPORTED = 50 + xtconstant_mod.ORDER_CANCELED = 54 + xtconstant_mod.ORDER_REJECTED = 57 + + import importlib + + real_import_module = importlib.import_module + + def fake_import_module(name, package=None): + if name == "xtquant": + return xtquant_pkg + if name == "xtquant.xttrader": + return xttrader_mod + if name == "xtquant.xttype": + return xttype_mod + if name == "xtquant.xtconstant": + return xtconstant_mod + return real_import_module(name, package) + + monkeypatch.setattr(importlib, "import_module", fake_import_module) + + +def test_qmt_broker_queries_account_positions_and_submits_order(tmp_path, monkeypatch): + setup_temp_db(tmp_path, monkeypatch) + install_fake_xtquant(monkeypatch) + + broker = QmtBroker( + instance_id="overnight_long:test", + strategy_key="overnight_long", + stock_codes=["513090"], + initial_capital=100000.0, + provider_name="qmt", + account_id="demo-account", + userdata_path="/tmp/qmt", + session_id=100001, + account_type="STOCK", + dynamic_price_type="LATEST_PRICE", + ) + + account = broker.get_account() + positions = broker.get_positions() + order = broker.submit_order( + BrokerOrderRequest( + order_id="qmt-order-1", + instance_id="overnight_long:test", + strategy_key="overnight_long", + code="513090", + direction=Direction.BUY, + signal_date=date(2026, 1, 5), + execute_at="close", + planned_execute_date=date(2026, 1, 5), + price=1.05, + volume=9000, + reason="unit-test", + ) + ) + + repo = LiveRepository() + saved = repo.get_live_order("qmt-order-1") + + assert account.broker_provider == "qmt" + assert account.cash == 120000.0 + assert account.total_equity == 150000.0 + assert len(positions) == 1 + assert positions[0].code == "513090" + assert positions[0].available == 9000 + assert order.broker_order_id == "10001" + assert order.status == "submitted" + assert saved is not None + assert saved.broker_order_id == "10001" + assert saved.status == "submitted" diff --git a/tests/test_trading_rules.py b/tests/test_trading_rules.py new file mode 100644 index 0000000..c0cb0ff --- /dev/null +++ b/tests/test_trading_rules.py @@ -0,0 +1,63 @@ +""" +交易规则回归测试 +""" +from datetime import date + +from backtest.rules import TradingRules +from strategy.base import BarData, Direction, Signal + + +def make_bar(): + return BarData( + code="000001", + trade_date=date(2026, 1, 6), + open=10.0, + high=10.2, + low=9.8, + close=10.0, + pre_close=10.0, + volume=1_000_000, + amount=10_000_000.0, + ) + + +def test_validate_order_rejects_invalid_partial_sell_odd_lot(): + signal = Signal( + code="000001", + direction=Direction.SELL, + trade_date=date(2026, 1, 6), + price=10.0, + volume=250, + ) + + valid, reason = TradingRules.validate_order( + signal=signal, + bar=make_bar(), + available_cash=0.0, + position_volume=350, + position_available=350, + ) + + assert valid is False + assert "卖出数量不合法" in reason + + +def test_validate_order_allows_selling_all_remaining_odd_lot(): + signal = Signal( + code="000001", + direction=Direction.SELL, + trade_date=date(2026, 1, 6), + price=10.0, + volume=150, + ) + + valid, reason = TradingRules.validate_order( + signal=signal, + bar=make_bar(), + available_cash=0.0, + position_volume=150, + position_available=150, + ) + + assert valid is True + assert reason == "通过" diff --git a/trading/account_id.py b/trading/account_id.py new file mode 100644 index 0000000..e27f261 --- /dev/null +++ b/trading/account_id.py @@ -0,0 +1,28 @@ +""" +模拟盘账户 ID 生成工具 + +用稳定哈希把“策略 + 参数 + 标的”映射为唯一账户实例 ID。 +""" +from __future__ import annotations + +import hashlib +import json + + +def build_account_id(strategy_key: str, codes: list[str], params: dict) -> str: + """ + 生成稳定的模拟盘账户 ID。 + + 账户隔离维度: + - 策略 key(如 overnight_long) + - 标的列表(排序后) + - 参数字典(按 key 排序) + """ + payload = { + "strategy": strategy_key, + "codes": sorted(codes), + "params": {k: params[k] for k in sorted(params)}, + } + raw = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), sort_keys=True) + digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:10] + return f"{strategy_key}:{digest}" diff --git a/trading/broker/__init__.py b/trading/broker/__init__.py index e69de29..f84b38e 100644 --- a/trading/broker/__init__.py +++ b/trading/broker/__init__.py @@ -0,0 +1,19 @@ +from trading.broker.base import ( + BaseBroker, + BrokerAccount, + BrokerOrder, + BrokerOrderRequest, + BrokerPosition, +) +from trading.broker.dry_run import DryRunBroker +from trading.broker.qmt_broker import QmtBroker + +__all__ = [ + "BaseBroker", + "BrokerAccount", + "BrokerOrder", + "BrokerOrderRequest", + "BrokerPosition", + "DryRunBroker", + "QmtBroker", +] diff --git a/trading/broker/base.py b/trading/broker/base.py new file mode 100644 index 0000000..e63d762 --- /dev/null +++ b/trading/broker/base.py @@ -0,0 +1,92 @@ +""" +统一 broker 抽象层 +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import date +from typing import Optional + +from strategy.base import Direction + + +@dataclass +class BrokerAccount: + broker_provider: str + broker_account_id: str + cash: float + total_equity: float + status: str = "ready" + + +@dataclass +class BrokerPosition: + code: str + volume: int + available: int + cost_price: float = 0.0 + current_price: float = 0.0 + + +@dataclass +class BrokerOrderRequest: + order_id: str + instance_id: str + strategy_key: str + code: str + direction: Direction + signal_date: date + execute_at: str + planned_execute_date: Optional[date] + price: float = 0.0 + volume: int = 0 + reason: str = "" + + +@dataclass +class BrokerOrder: + order_id: str + broker_provider: str + code: str + direction: str + signal_date: date + execute_at: str + planned_execute_date: Optional[date] + req_price: float + req_volume: int + status: str + broker_order_id: str = "" + filled_price: float = 0.0 + filled_volume: int = 0 + commission: float = 0.0 + reason: str = "" + + +class BaseBroker(ABC): + """统一 broker 接口。""" + + @property + @abstractmethod + def name(self) -> str: + ... + + @abstractmethod + def get_account(self) -> BrokerAccount: + ... + + @abstractmethod + def get_positions(self) -> list[BrokerPosition]: + ... + + @abstractmethod + def submit_order(self, request: BrokerOrderRequest) -> BrokerOrder: + ... + + @abstractmethod + def cancel_order(self, order_id: str) -> BrokerOrder | None: + ... + + @abstractmethod + def list_orders(self, status: Optional[str] = None) -> list[BrokerOrder]: + ... diff --git a/trading/broker/dry_run.py b/trading/broker/dry_run.py new file mode 100644 index 0000000..dafda5b --- /dev/null +++ b/trading/broker/dry_run.py @@ -0,0 +1,180 @@ +""" +Dry-run broker + +不接真实券商,只负责把统一订单请求落库并返回标准订单结果。 +""" +from __future__ import annotations + +import json +import uuid + +from data.storage.repository import LiveRepository +from trading.broker.base import ( + BaseBroker, + BrokerAccount, + BrokerOrder, + BrokerOrderRequest, + BrokerPosition, +) + + +class DryRunBroker(BaseBroker): + def __init__( + self, + instance_id: str, + strategy_key: str, + stock_codes: list[str], + initial_capital: float, + broker_account_id: str = "", + ): + self.instance_id = instance_id + self.strategy_key = strategy_key + self.stock_codes = stock_codes + self.initial_capital = initial_capital + self.broker_account_id = broker_account_id or "DRYRUN" + self._repo = LiveRepository() + self._ensure_account() + + @property + def name(self) -> str: + return "dry_run" + + def _ensure_account(self) -> None: + account = self._repo.get_live_account(self.instance_id) + if account is None: + self._repo.create_live_account( + instance_id=self.instance_id, + strategy_key=self.strategy_key, + broker_provider=self.name, + broker_account_id=self.broker_account_id, + initial_capital=self.initial_capital, + stock_codes=self.stock_codes, + cash=self.initial_capital, + total_equity=self.initial_capital, + ) + + def get_account(self) -> BrokerAccount: + self._ensure_account() + row = self._repo.get_live_account(self.instance_id) + return BrokerAccount( + broker_provider=self.name, + broker_account_id=row.broker_account_id or self.broker_account_id, + cash=row.cash or 0.0, + total_equity=row.total_equity or 0.0, + status=row.status or "active", + ) + + def get_positions(self) -> list[BrokerPosition]: + rows = self._repo.get_live_positions(self.instance_id) + return [ + BrokerPosition( + code=row.code, + volume=row.volume or 0, + available=row.available or 0, + cost_price=row.cost_price or 0.0, + current_price=row.current_price or 0.0, + ) + for row in rows + ] + + def submit_order(self, request: BrokerOrderRequest) -> BrokerOrder: + existing = self._repo.get_live_order(request.order_id) + broker_order_id = f"DRY-{uuid.uuid4().hex[:12].upper()}" + status = "planned" if request.execute_at == "next_open" else "submitted" + + if existing is None: + self._repo.save_live_order({ + "order_id": request.order_id, + "instance_id": request.instance_id, + "strategy_key": request.strategy_key, + "broker_provider": self.name, + "broker_order_id": broker_order_id, + "code": request.code, + "direction": request.direction.value, + "signal_date": request.signal_date, + "planned_execute_date": request.planned_execute_date, + "execute_at": request.execute_at, + "req_price": request.price, + "req_volume": request.volume, + "status": status, + "reason": request.reason, + }) + else: + self._repo.update_live_order( + order_id=request.order_id, + status=status, + broker_order_id=existing.broker_order_id or broker_order_id, + filled_price=existing.filled_price or 0.0, + filled_volume=existing.filled_volume or 0, + commission=existing.commission or 0.0, + reason=request.reason or existing.reason or "", + ) + broker_order_id = existing.broker_order_id or broker_order_id + + return BrokerOrder( + order_id=request.order_id, + broker_provider=self.name, + broker_order_id=broker_order_id, + code=request.code, + direction=request.direction.value, + signal_date=request.signal_date, + execute_at=request.execute_at, + planned_execute_date=request.planned_execute_date, + req_price=request.price, + req_volume=request.volume, + status=status, + reason=request.reason, + ) + + def cancel_order(self, order_id: str) -> BrokerOrder | None: + rows = self._repo.get_live_orders(self.instance_id) + target = next((row for row in rows if row.order_id == order_id), None) + if target is None: + return None + + self._repo.update_live_order( + order_id=order_id, + status="cancelled", + broker_order_id=target.broker_order_id or "", + reason=target.reason or "", + ) + return BrokerOrder( + order_id=target.order_id, + broker_provider=target.broker_provider, + broker_order_id=target.broker_order_id or "", + code=target.code, + direction=target.direction, + signal_date=target.signal_date, + execute_at=target.execute_at, + planned_execute_date=target.planned_execute_date, + req_price=target.req_price or 0.0, + req_volume=target.req_volume or 0, + status="cancelled", + filled_price=target.filled_price or 0.0, + filled_volume=target.filled_volume or 0, + commission=target.commission or 0.0, + reason=target.reason or "", + ) + + def list_orders(self, status: str | None = None) -> list[BrokerOrder]: + rows = self._repo.get_live_orders(self.instance_id, status=status) + return [ + BrokerOrder( + order_id=row.order_id, + broker_provider=row.broker_provider, + broker_order_id=row.broker_order_id or "", + code=row.code, + direction=row.direction, + signal_date=row.signal_date, + execute_at=row.execute_at, + planned_execute_date=row.planned_execute_date, + req_price=row.req_price or 0.0, + req_volume=row.req_volume or 0, + status=row.status, + filled_price=row.filled_price or 0.0, + filled_volume=row.filled_volume or 0, + commission=row.commission or 0.0, + reason=row.reason or "", + ) + for row in rows + ] diff --git a/trading/broker/qmt_broker.py b/trading/broker/qmt_broker.py new file mode 100644 index 0000000..c692bb5 --- /dev/null +++ b/trading/broker/qmt_broker.py @@ -0,0 +1,370 @@ +""" +QMT broker adapter + +Phase 7B: +- 连接 xtquant / QMT 客户端 +- 查询账户、持仓、订单 +- 提交 / 撤销即时订单 +- `next_open` 计划单仍先落库,等待到期后由 live_engine 激活 +""" +from __future__ import annotations + +import importlib +import uuid +from datetime import date, datetime +from typing import Optional + +from data.storage.repository import LiveRepository +from trading.broker.base import ( + BaseBroker, + BrokerAccount, + BrokerOrder, + BrokerOrderRequest, + BrokerPosition, +) + + +class QmtBroker(BaseBroker): + def __init__( + self, + instance_id: str, + strategy_key: str, + stock_codes: list[str], + initial_capital: float, + provider_name: str, + account_id: str, + userdata_path: str, + session_id: int, + account_type: str = "STOCK", + dynamic_price_type: str = "LATEST_PRICE", + strategy_name: str = "Apex", + order_remark_prefix: str = "Apex", + ): + if not account_id: + raise ValueError("QMT 实盘模式必须提供 broker.account_id") + if not userdata_path: + raise ValueError("QMT 实盘模式必须提供 broker.qmt.userdata_path") + + self.instance_id = instance_id + self.strategy_key = strategy_key + self.stock_codes = stock_codes + self.initial_capital = initial_capital + self.provider_name = provider_name + self.account_id = account_id + self.userdata_path = userdata_path + self.session_id = session_id + self.account_type = account_type + self.dynamic_price_type = dynamic_price_type + self.strategy_name = strategy_name + self.order_remark_prefix = order_remark_prefix + + self._repo = LiveRepository() + self._xtconstant = None + self._trader = None + self._stock_account = None + + @property + def name(self) -> str: + return self.provider_name + + def _load_xtquant(self): + try: + xttrader = importlib.import_module("xtquant.xttrader") + xttype = importlib.import_module("xtquant.xttype") + xtconstant = importlib.import_module("xtquant.xtconstant") + except Exception as e: + raise RuntimeError( + "未检测到 xtquant / QMT Python SDK,请先在真实交易环境中安装并配置。" + ) from e + return xttrader, xttype, xtconstant + + def _ensure_ready(self) -> None: + if self._trader is not None: + return + + xttrader, xttype, xtconstant = self._load_xtquant() + self._xtconstant = xtconstant + + trader_cls = getattr(xttrader, "XtQuantTrader") + callback_cls = getattr(xttrader, "XtQuantTraderCallback", object) + stock_account_cls = getattr(xttype, "StockAccount") + + class _Callback(callback_cls): + pass + + self._trader = trader_cls(self.userdata_path, self.session_id) + if hasattr(self._trader, "register_callback"): + self._trader.register_callback(_Callback()) + if hasattr(self._trader, "start"): + self._trader.start() + + connect_result = self._trader.connect() + if connect_result != 0: + raise RuntimeError(f"QMT connect 失败,返回码={connect_result}") + + self._stock_account = stock_account_cls(self.account_id, self.account_type) + subscribe_result = self._trader.subscribe(self._stock_account) + if subscribe_result != 0: + raise RuntimeError(f"QMT subscribe 失败,返回码={subscribe_result}") + + def _normalize_code(self, code: str) -> str: + if "." in code: + return code + if code.startswith(("6", "5")): + return f"{code}.SH" + if code.startswith(("8", "4")): + return f"{code}.BJ" + return f"{code}.SZ" + + def _denormalize_code(self, code: str) -> str: + return code.split(".", 1)[0] + + def _const(self, name: str, fallback): + if self._xtconstant is None: + return fallback + return getattr(self._xtconstant, name, fallback) + + def _order_type(self, direction: str): + if direction == "BUY": + return self._const("STOCK_BUY", 23) + return self._const("STOCK_SELL", 24) + + def _price_type(self, req_price: float): + if req_price and req_price > 0: + return self._const("FIX_PRICE", 11) + return self._const(self.dynamic_price_type, self._const("LATEST_PRICE", 5)) + + def _map_status(self, raw_status, filled_volume: int = 0, req_volume: int = 0) -> str: + if filled_volume > 0 and req_volume > 0 and filled_volume >= req_volume: + return "filled" + + cancelled = { + self._const("ORDER_CANCELED", -10), + self._const("ORDER_PART_CANCEL", -11), + } + rejected = { + self._const("ORDER_REJECTED", -20), + self._const("ORDER_JUNK", -21), + } + accepted = { + self._const("ORDER_REPORTED", 50), + self._const("ORDER_REPORTED_CANCEL", 51), + self._const("ORDER_PART_SUCC", 55), + self._const("ORDER_SUCCEEDED", 56), + } + + if raw_status in cancelled: + return "cancelled" + if raw_status in rejected: + return "rejected" + if raw_status in accepted: + return "accepted" + return "submitted" + + def get_account(self) -> BrokerAccount: + self._ensure_ready() + asset = self._trader.query_stock_asset(self._stock_account) + if asset is None: + raise RuntimeError("QMT 查询账户失败:返回空对象") + + cash = float(getattr(asset, "cash", 0.0) or 0.0) + total_equity = float( + getattr(asset, "total_asset", getattr(asset, "total_equity", cash)) or cash + ) + return BrokerAccount( + broker_provider=self.name, + broker_account_id=self.account_id, + cash=cash, + total_equity=total_equity, + status="ready", + ) + + def get_positions(self) -> list[BrokerPosition]: + self._ensure_ready() + rows = self._trader.query_stock_positions(self._stock_account) or [] + positions: list[BrokerPosition] = [] + for row in rows: + positions.append( + BrokerPosition( + code=self._denormalize_code(str(getattr(row, "stock_code", ""))), + volume=int(getattr(row, "volume", 0) or 0), + available=int(getattr(row, "can_use_volume", getattr(row, "available", 0)) or 0), + cost_price=float(getattr(row, "open_price", getattr(row, "avg_price", 0.0)) or 0.0), + current_price=float(getattr(row, "last_price", getattr(row, "market_value", 0.0)) or 0.0), + ) + ) + return positions + + def submit_order(self, request: BrokerOrderRequest) -> BrokerOrder: + existing = self._repo.get_live_order(request.order_id) + if request.execute_at == "next_open": + broker_order_id = existing.broker_order_id if existing else "" + if existing is None: + self._repo.save_live_order({ + "order_id": request.order_id, + "instance_id": request.instance_id, + "strategy_key": request.strategy_key, + "broker_provider": self.name, + "broker_order_id": broker_order_id, + "code": request.code, + "direction": request.direction.value, + "signal_date": request.signal_date, + "planned_execute_date": request.planned_execute_date, + "execute_at": request.execute_at, + "req_price": request.price, + "req_volume": request.volume, + "status": "planned", + "reason": request.reason, + }) + return BrokerOrder( + order_id=request.order_id, + broker_provider=self.name, + broker_order_id=broker_order_id, + code=request.code, + direction=request.direction.value, + signal_date=request.signal_date, + execute_at=request.execute_at, + planned_execute_date=request.planned_execute_date, + req_price=request.price, + req_volume=request.volume, + status="planned", + reason=request.reason, + ) + + self._ensure_ready() + + broker_order_id = self._trader.order_stock( + self._stock_account, + self._normalize_code(request.code), + self._order_type(request.direction.value), + int(request.volume), + self._price_type(request.price), + float(request.price or 0.0), + self.strategy_name, + f"{self.order_remark_prefix}:{request.reason}"[:128], + ) + if broker_order_id in (-1, None): + raise RuntimeError("QMT 下单失败,返回空订单号") + + if existing is None: + self._repo.save_live_order({ + "order_id": request.order_id, + "instance_id": request.instance_id, + "strategy_key": request.strategy_key, + "broker_provider": self.name, + "broker_order_id": str(broker_order_id), + "code": request.code, + "direction": request.direction.value, + "signal_date": request.signal_date, + "planned_execute_date": request.planned_execute_date, + "execute_at": request.execute_at, + "req_price": request.price, + "req_volume": request.volume, + "status": "submitted", + "reason": request.reason, + }) + else: + self._repo.update_live_order( + order_id=request.order_id, + status="submitted", + broker_order_id=str(broker_order_id), + reason=request.reason or existing.reason or "", + ) + + return BrokerOrder( + order_id=request.order_id, + broker_provider=self.name, + broker_order_id=str(broker_order_id), + code=request.code, + direction=request.direction.value, + signal_date=request.signal_date, + execute_at=request.execute_at, + planned_execute_date=request.planned_execute_date, + req_price=request.price, + req_volume=request.volume, + status="submitted", + reason=request.reason, + ) + + def cancel_order(self, order_id: str) -> BrokerOrder | None: + self._ensure_ready() + row = self._repo.get_live_order(order_id) + if row is None: + return None + + broker_order_id = row.broker_order_id or "" + if broker_order_id: + result = self._trader.cancel_order_stock(self._stock_account, int(broker_order_id)) + if result != 0: + raise RuntimeError(f"QMT 撤单失败,返回码={result}") + + self._repo.update_live_order( + order_id=order_id, + status="cancelled", + broker_order_id=broker_order_id, + reason=row.reason or "", + ) + return BrokerOrder( + order_id=row.order_id, + broker_provider=row.broker_provider, + broker_order_id=broker_order_id, + code=row.code, + direction=row.direction, + signal_date=row.signal_date, + execute_at=row.execute_at, + planned_execute_date=row.planned_execute_date, + req_price=row.req_price or 0.0, + req_volume=row.req_volume or 0, + status="cancelled", + filled_price=row.filled_price or 0.0, + filled_volume=row.filled_volume or 0, + commission=row.commission or 0.0, + reason=row.reason or "", + ) + + def list_orders(self, status: Optional[str] = None) -> list[BrokerOrder]: + self._ensure_ready() + rows = self._trader.query_stock_orders(self._stock_account) or [] + orders: list[BrokerOrder] = [] + for row in rows: + req_volume = int(getattr(row, "order_volume", getattr(row, "volume", 0)) or 0) + filled_volume = int(getattr(row, "traded_volume", getattr(row, "filled_volume", 0)) or 0) + mapped_status = self._map_status(getattr(row, "order_status", None), filled_volume, req_volume) + if status and mapped_status != status: + continue + + raw_time = getattr(row, "order_time", None) + if isinstance(raw_time, date): + signal_date = raw_time + elif isinstance(raw_time, (int, float)): + signal_date = datetime.fromtimestamp(raw_time).date() + elif isinstance(raw_time, str) and raw_time: + try: + signal_date = datetime.fromisoformat(raw_time).date() + except ValueError: + signal_date = date.today() + else: + signal_date = date.today() + + orders.append( + BrokerOrder( + order_id=str(getattr(row, "order_remark", getattr(row, "order_id", "")) or ""), + broker_provider=self.name, + broker_order_id=str(getattr(row, "order_id", "")), + code=self._denormalize_code(str(getattr(row, "stock_code", ""))), + direction="BUY" + if int(getattr(row, "order_type", self._order_type("BUY"))) == self._order_type("BUY") + else "SELL", + signal_date=signal_date, + execute_at="open", + planned_execute_date=None, + req_price=float(getattr(row, "price", 0.0) or 0.0), + req_volume=req_volume, + status=mapped_status, + filled_price=float(getattr(row, "traded_price", getattr(row, "filled_price", 0.0)) or 0.0), + filled_volume=filled_volume, + commission=float(getattr(row, "commission", 0.0) or 0.0), + reason=str(getattr(row, "status_msg", getattr(row, "order_remark", "")) or ""), + ) + ) + return orders diff --git a/trading/live_engine.py b/trading/live_engine.py new file mode 100644 index 0000000..3a85866 --- /dev/null +++ b/trading/live_engine.py @@ -0,0 +1,457 @@ +""" +实盘/准实盘执行引擎骨架 + +Phase 7A 目标: +- 统一策略信号 -> broker 订单请求 +- 通过 broker adapter 执行 +- 把账户/持仓/订单状态持久化 +""" +from __future__ import annotations + +import uuid +from datetime import date, timedelta +from typing import Optional + +from loguru import logger + +from backtest.rules import TradingRules +from data.storage.repository import LiveRepository, StockRepository +from notification import Notifier +from strategy.base import BarData, BaseStrategy, Direction, Position, Signal +from trading.broker import BaseBroker, BrokerOrderRequest, BrokerPosition + +_LOOKBACK_DAYS = 120 + + +class LiveEngine: + def __init__( + self, + strategy: BaseStrategy, + stock_codes: list[str], + broker: BaseBroker, + instance_id: str, + strategy_key: Optional[str] = None, + notifier: Optional[Notifier] = None, + run_date: Optional[date] = None, + ): + self.strategy = strategy + self.strategy_key = strategy_key or strategy.name + self.stock_codes = stock_codes + self.broker = broker + self.instance_id = instance_id + self.notifier = notifier + self.run_date = run_date or date.today() + + self._repo = StockRepository() + self._live_repo = LiveRepository() + + def run_daily(self) -> dict: + run_date = self.run_date + + if not self._repo.is_trade_date(run_date): + logger.info(f"{run_date} 不是交易日,跳过实盘基座运行") + return {} + + logger.info( + f"=== 实盘基座运行 [{self.strategy.name}] {run_date} " + f"| broker={self.broker.name} | instance={self.instance_id} ===" + ) + + bar_map = self._load_bars(run_date) + if not bar_map: + logger.warning(f"{run_date} 所有标的均无行情数据,跳过") + return {} + + activation_result = self._submit_due_planned_orders(run_date, bar_map) + + broker_account = self.broker.get_account() + broker_positions = self.broker.get_positions() + self._sync_live_snapshot(broker_account, broker_positions) + + self._rebuild_bar_history(run_date) + self.strategy._sync_account( + self._to_strategy_positions(broker_positions), + broker_account.cash, + broker_account.total_equity, + ) + + signals: list[Signal] = [] + for code in self.stock_codes: + bar = bar_map.get(code) + if bar is None: + continue + self.strategy._update_bar(bar) + signals.extend(self.strategy.on_bar(bar)) + + requests = self._build_order_requests( + signals=signals, + bar_map=bar_map, + positions=broker_positions, + available_cash=broker_account.cash, + signal_date=run_date, + ) + + submitted = 0 + rejected = 0 + for request in requests: + try: + order = self.broker.submit_order(request) + logger.info( + f"broker 提交成功 {order.direction} {order.code} " + f"{order.req_volume}股 status={order.status}" + ) + submitted += 1 + except Exception as e: + logger.error(f"broker 提交失败 {request.code} {request.direction.value}: {e}") + self._notify( + subject=f"[Apex][Live][Submit Failed] {self.strategy_key} {request.code}", + body=( + f"实例ID: {self.instance_id}\n" + f"策略: {self.strategy.name}\n" + f"Broker: {self.broker.name}\n" + f"订单ID: {request.order_id}\n" + f"方向: {request.direction.value}\n" + f"代码: {request.code}\n" + f"执行时机: {request.execute_at}\n" + f"计划执行日: {request.planned_execute_date}\n" + f"价格: {request.price}\n" + f"数量: {request.volume}\n" + f"原因: {e}\n" + ), + ) + rejected += 1 + + summary = { + "run_date": run_date, + "instance_id": self.instance_id, + "strategy": self.strategy.name, + "broker": self.broker.name, + "planned_orders_activated": activation_result["submitted"], + "planned_orders_failed": activation_result["rejected"], + "signals_generated": len(signals), + "orders_built": len(requests), + "orders_submitted": submitted, + "orders_rejected": rejected, + "cash": broker_account.cash, + "total_equity": broker_account.total_equity, + "position_count": len(broker_positions), + } + logger.info( + f"实盘基座运行完成 | 信号 {len(signals)} 个 | " + f"委托 {len(requests)} 笔 | 成功 {submitted} | 拒绝 {rejected} | " + f"计划单激活 {activation_result['submitted']}/{activation_result['total']}" + ) + return summary + + def _submit_due_planned_orders( + self, + run_date: date, + bar_map: dict[str, BarData], + ) -> dict: + due_orders = self._live_repo.get_due_live_orders(self.instance_id, run_date) + result = {"total": len(due_orders), "submitted": 0, "rejected": 0} + for row in due_orders: + bar = bar_map.get(row.code) + if bar is None: + self._live_repo.update_live_order( + order_id=row.order_id, + status="rejected", + broker_order_id=row.broker_order_id or "", + reason="到期执行时无行情数据", + ) + self._notify( + subject=f"[Apex][Live][Activation Failed] {self.strategy_key} {row.code}", + body=( + f"实例ID: {self.instance_id}\n" + f"策略: {self.strategy.name}\n" + f"Broker: {self.broker.name}\n" + f"订单ID: {row.order_id}\n" + f"代码: {row.code}\n" + f"执行日: {run_date}\n" + "原因: 到期执行时无行情数据\n" + ), + ) + result["rejected"] += 1 + continue + + req_price = row.req_price or bar.open + request = BrokerOrderRequest( + order_id=row.order_id, + instance_id=row.instance_id, + strategy_key=row.strategy_key, + code=row.code, + direction=Direction(row.direction), + signal_date=row.signal_date, + execute_at="open", + planned_execute_date=run_date, + price=req_price, + volume=row.req_volume or 0, + reason=row.reason or "", + ) + + try: + self.broker.submit_order(request) + result["submitted"] += 1 + except Exception as e: + self._live_repo.update_live_order( + order_id=row.order_id, + status="rejected", + broker_order_id=row.broker_order_id or "", + reason=str(e), + ) + self._notify( + subject=f"[Apex][Live][Activation Failed] {self.strategy_key} {row.code}", + body=( + f"实例ID: {self.instance_id}\n" + f"策略: {self.strategy.name}\n" + f"Broker: {self.broker.name}\n" + f"订单ID: {row.order_id}\n" + f"代码: {row.code}\n" + f"执行日: {run_date}\n" + f"原因: {e}\n" + ), + ) + result["rejected"] += 1 + return result + + def _load_bars(self, trade_date: date) -> dict[str, BarData]: + bar_map: dict[str, BarData] = {} + for code in self.stock_codes: + df = self._repo.get_daily_bars(code, trade_date, trade_date) + if df.empty: + continue + row = df.iloc[0] + bar_map[code] = BarData( + code=code, + trade_date=row["trade_date"], + open=row["open"], + high=row["high"], + low=row["low"], + close=row["close"], + pre_close=row.get("pre_close") or row["close"], + volume=int(row.get("volume") or 0), + amount=float(row.get("amount") or 0.0), + ) + return bar_map + + def _rebuild_bar_history(self, run_date: date) -> None: + history_start = run_date - timedelta(days=_LOOKBACK_DAYS * 2) + history_end = run_date - timedelta(days=1) + for code in self.stock_codes: + df = self._repo.get_daily_bars(code, history_start, history_end) + if df.empty: + continue + for _, row in df.iterrows(): + self.strategy._update_bar( + BarData( + code=code, + trade_date=row["trade_date"], + open=row["open"], + high=row["high"], + low=row["low"], + close=row["close"], + pre_close=row.get("pre_close") or row["close"], + volume=int(row.get("volume") or 0), + amount=float(row.get("amount") or 0.0), + ) + ) + + def _to_strategy_positions( + self, + broker_positions: list[BrokerPosition], + ) -> dict[str, Position]: + return { + p.code: Position( + code=p.code, + volume=p.volume, + available=p.available, + cost_price=p.cost_price, + current_price=p.current_price, + ) + for p in broker_positions + } + + def _sync_live_snapshot(self, broker_account, broker_positions: list[BrokerPosition]) -> None: + live_account = self._live_repo.get_live_account(self.instance_id) + if live_account is None: + self._live_repo.create_live_account( + instance_id=self.instance_id, + strategy_key=self.strategy_key, + broker_provider=self.broker.name, + broker_account_id=broker_account.broker_account_id, + initial_capital=broker_account.total_equity, + stock_codes=self.stock_codes, + cash=broker_account.cash, + total_equity=broker_account.total_equity, + ) + else: + self._live_repo.update_live_account( + instance_id=self.instance_id, + cash=broker_account.cash, + total_equity=broker_account.total_equity, + stock_codes=self.stock_codes, + broker_account_id=broker_account.broker_account_id, + status=broker_account.status, + ) + + self._live_repo.replace_live_positions( + self.instance_id, + [ + { + "instance_id": self.instance_id, + "code": p.code, + "volume": p.volume, + "available": p.available, + "cost_price": p.cost_price, + "current_price": p.current_price, + "source": "broker", + } + for p in broker_positions + ], + ) + + def _build_order_requests( + self, + signals: list[Signal], + bar_map: dict[str, BarData], + positions: list[BrokerPosition], + available_cash: float, + signal_date: date, + ) -> list[BrokerOrderRequest]: + pos_map = {p.code: p for p in positions} + sell_signals = [s for s in signals if s.direction == Direction.SELL] + buy_signals = [s for s in signals if s.direction == Direction.BUY] + ordered_signals = sell_signals + buy_signals + planned_buy_volumes = self._estimate_buy_volumes( + buy_signals=buy_signals, + bar_map=bar_map, + available_cash=available_cash, + ) + + requests: list[BrokerOrderRequest] = [] + for signal in ordered_signals: + bar = bar_map.get(signal.code) + if bar is None: + continue + + planned_execute_date = ( + self._next_trade_date(signal_date) + if signal.execute_at == "next_open" + else signal_date + ) + req_price = self._resolve_price(signal, bar) + volume = self._resolve_volume( + signal, + bar, + pos_map, + available_cash, + planned_buy_volumes=planned_buy_volumes, + ) + + if volume <= 0: + continue + + if signal.direction == Direction.SELL: + pos = pos_map.get(signal.code) + available = pos.available if pos else 0 + if available <= 0 and signal.execute_at == "next_open": + available = planned_buy_volumes.get(signal.code, 0) + if available <= 0 or volume > available: + continue + if not TradingRules.is_valid_sell_volume(volume, available): + continue + + requests.append( + BrokerOrderRequest( + order_id=uuid.uuid4().hex, + instance_id=self.instance_id, + strategy_key=self.strategy_key, + code=signal.code, + direction=signal.direction, + signal_date=signal_date, + execute_at=signal.execute_at, + planned_execute_date=planned_execute_date, + price=req_price, + volume=volume, + reason=signal.reason or "", + ) + ) + return requests + + def _estimate_buy_volumes( + self, + buy_signals: list[Signal], + bar_map: dict[str, BarData], + available_cash: float, + ) -> dict[str, int]: + estimates: dict[str, int] = {} + cash_cursor = available_cash + for signal in buy_signals: + bar = bar_map.get(signal.code) + if bar is None: + continue + volume = self._resolve_volume( + signal, + bar, + {}, + cash_cursor, + planned_buy_volumes={}, + ) + if volume <= 0: + continue + estimates[signal.code] = volume + ref_price = signal.price if signal.price > 0 else bar.close + cash_cursor = max(0.0, cash_cursor - ref_price * volume) + return estimates + + def _resolve_price(self, signal: Signal, bar: BarData) -> float: + if signal.price > 0: + return signal.price + if signal.execute_at == "close": + return round(bar.close, 3) + if signal.execute_at == "open": + return round(bar.open, 3) + return 0.0 + + def _resolve_volume( + self, + signal: Signal, + bar: BarData, + pos_map: dict[str, BrokerPosition], + available_cash: float, + planned_buy_volumes: dict[str, int], + ) -> int: + if signal.volume > 0: + if signal.direction == Direction.BUY: + return TradingRules.round_volume(signal.volume, Direction.BUY) + return signal.volume + + if signal.direction == Direction.BUY: + ref_price = signal.price if signal.price > 0 else bar.close + if ref_price <= 0: + return 0 + max_vol = int(available_cash * 0.95 / ref_price) + return TradingRules.round_volume(max_vol, Direction.BUY) + + pos = pos_map.get(signal.code) + if pos and pos.available > 0: + return pos.available + if signal.execute_at == "next_open": + return planned_buy_volumes.get(signal.code, 0) + return 0 + + def _next_trade_date(self, current_date: date) -> Optional[date]: + check = current_date + timedelta(days=1) + for _ in range(30): + if self._repo.is_trade_date(check): + return check + check += timedelta(days=1) + return None + + def _notify(self, subject: str, body: str) -> None: + if not self.notifier: + return + try: + self.notifier.notify(subject, body) + except Exception as e: + logger.error(f"邮件通知发送失败: {e}") diff --git a/trading/paper_account.py b/trading/paper_account.py index d4f09e6..56d7356 100644 --- a/trading/paper_account.py +++ b/trading/paper_account.py @@ -36,7 +36,12 @@ class PaperAccount: total_tax 累计印花税 """ - def __init__(self, strategy_name: str, initial_capital: float = None): + def __init__( + self, + strategy_name: str, + initial_capital: float = None, + stock_codes: Optional[list[str]] = None, + ): """ 参数: strategy_name: 账户唯一标识(同策略+参数组合的名称) @@ -44,6 +49,7 @@ def __init__(self, strategy_name: str, initial_capital: float = None): """ self.strategy_name = strategy_name self._initial_capital_hint = initial_capital + self._stock_codes = stock_codes or [] self._repo = PaperRepository() self._fee_model = FeeModel() @@ -89,7 +95,7 @@ def load_or_create(self) -> None: f"账户 '{self.strategy_name}' 不存在,首次创建必须提供 initial_capital" ) self._repo.create_paper_account( - self.strategy_name, self._initial_capital_hint, [] + self.strategy_name, self._initial_capital_hint, self._stock_codes ) account_row = self._repo.get_paper_account(self.strategy_name) else: @@ -240,6 +246,7 @@ def save(self) -> None: cash=self._cash, total_commission=self._total_commission, total_tax=self._total_tax, + stock_codes=self._stock_codes, ) # 更新持仓(upsert 现有持仓) diff --git a/trading/paper_engine.py b/trading/paper_engine.py index d577a2f..018fb6b 100644 --- a/trading/paper_engine.py +++ b/trading/paper_engine.py @@ -43,6 +43,7 @@ def __init__( stock_codes: list[str], initial_capital: float = 1_000_000.0, run_date: Optional[date] = None, + account_id: Optional[str] = None, ): self.strategy = strategy self.stock_codes = stock_codes @@ -51,8 +52,9 @@ def __init__( self._repo = StockRepository() self._paper_repo = PaperRepository() - # 账户名 = 策略名(同策略实例可共享同一账户,跨日恢复状态) - self.strategy_name = strategy.name + # 账户名默认回落到 strategy.name;CLI 入口会传入更稳定的 account_id。 + self.strategy_name = account_id or strategy.name + self.strategy_label = strategy.name # ── 主入口 ──────────────────────────────────────────────────────────── @@ -77,7 +79,11 @@ def run_daily(self) -> dict: logger.info(f"=== 模拟盘运行 [{self.strategy_name}] {run_date} ===") # ── 步骤1:初始化账户,T+1 解冻 ── - account = PaperAccount(self.strategy_name, self.initial_capital) + account = PaperAccount( + self.strategy_name, + self.initial_capital, + stock_codes=self.stock_codes, + ) account.load_or_create() account.new_trading_day(run_date) @@ -121,7 +127,8 @@ def run_daily(self) -> dict: total_rejected = exec_result["rejected"] + intraday_result["rejected"] summary = { "run_date": run_date, - "strategy": self.strategy_name, + "strategy": self.strategy_label, + "account_id": self.strategy_name, "orders_executed": exec_result["total"], "orders_filled": total_filled, "orders_cancelled": exec_result["cancelled"], @@ -214,6 +221,18 @@ def _execute_pending_orders( result["cancelled"] += 1 continue + if direction == Direction.SELL: + pos = account.positions.get(order_row.code) + available = pos.available if pos else 0 + if not TradingRules.is_valid_sell_volume(order_row.req_volume, available): + self._paper_repo.update_paper_order( + order_row.order_id, + "rejected", + reason="卖出数量不合法:超过100股时必须为100的整数倍,除非一次性卖出全部零股", + ) + result["rejected"] += 1 + continue + # 构建 OrderData 并交给 account 处理 od = OrderData( code=order_row.code, @@ -286,6 +305,10 @@ def _execute_intraday_signals( logger.debug(f"当日卖出信号跳过 {signal.code}:T+1 限制") result["rejected"] += 1 continue + if not TradingRules.is_valid_sell_volume(volume, pos.available): + logger.debug(f"当日卖出信号跳过 {signal.code}:卖出数量不合法") + result["rejected"] += 1 + continue od = OrderData( code=signal.code, @@ -395,16 +418,20 @@ def _create_pending_orders( """将今日信号转为明日 pending 订单,返回创建的订单数""" count = 0 for signal in signals: - volume = self._resolve_volume(signal, account, bar_map) + volume = self._resolve_volume(signal, account, bar_map, pending_next_open=True) if volume <= 0: logger.debug(f"跳过信号 {signal.code}:量为0(资金不足或无可卖持仓)") continue - # 卖出 T+1 检查:available=0 的持仓不能挂卖单 + # next_open 卖单允许为“今日尾盘刚买、明早开盘卖”的仓位预约挂单, + # 因此创建 pending 时只要求期末有持仓,不要求 available>0。 if signal.direction == Direction.SELL: pos = account.positions.get(signal.code) - if not pos or pos.available <= 0: - logger.debug(f"跳过卖出信号 {signal.code}:T+1 限制,无可卖持仓") + if not pos or pos.volume <= 0: + logger.debug(f"跳过卖出信号 {signal.code}:无持仓") + continue + if not TradingRules.is_valid_sell_volume(volume, pos.volume): + logger.debug(f"跳过卖出信号 {signal.code}:卖出数量不合法") continue self._paper_repo.save_paper_order({ @@ -430,12 +457,15 @@ def _resolve_volume( signal: Signal, account: PaperAccount, bar_map: dict[str, BarData], + pending_next_open: bool = False, ) -> int: """ 解析委托数量: - signal.volume > 0:使用策略指定量(四舍五入到100股整数倍) - signal.volume == 0 且买入:用账户 95% 可用现金估算最大量 - - signal.volume == 0 且卖出:默认全部可卖持仓 + - signal.volume == 0 且卖出: + - 当日信号:默认全部可卖持仓 + - next_open pending:默认卖出当前总持仓(包括今日尾盘刚买、明早解冻的仓位) """ if signal.volume > 0: return TradingRules.round_volume(signal.volume, signal.direction) @@ -449,7 +479,9 @@ def _resolve_volume( return TradingRules.round_volume(max_vol, Direction.BUY) else: pos = account.positions.get(signal.code) - return pos.available if pos else 0 + if not pos: + return 0 + return pos.volume if pending_next_open else pos.available # ── 工具 ──────────────────────────────────────────────────────────────