Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
476 changes: 92 additions & 384 deletions README.md

Large diffs are not rendered by default.

172 changes: 172 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# 命令行

> 适用版本:v0.1。`hqbacktest run` 命令读取 TOML 配置,运行回测,将结果写入独立目录。

## 1. 安装 / 入口

`hqbacktest` 通过 `pyproject.toml` 的 `[project.scripts]` 注册为 console script:

```bash
hqbacktest run --config configs/moving_average.toml --output results/moving-average
# 等价:
python -m hqbacktest run --config configs/moving_average.toml --output results/moving-average
```

### 1.1 策略模块解析

`hqbacktest run` 把 config 文件所在目录和当前工作目录加入 `sys.path`,让 `[strategy].module = "my_strategy"` 这类**不带点号的写法**可以直接 `import` 成功,行为与 `python -m hqbacktest run` 一致。

### 1.2 `--output` 覆盖

- `--output` 可选;省略时使用配置中 `[output].directory`。
- 提供时覆盖该值(例如 CI 把结果重定向到临时目录)。

### 1.3 `--force`

输出目录已存在且非空时默认拒绝(exit 3);`--force` 强制覆盖:

```bash
hqbacktest run --config FILE --output DIR --force
```

## 2. 配置 schema

```toml
[start]
start_date = "20240102" # YYYYMMDD,必填
end_date = "20240105" # YYYYMMDD,必填

[capital]
initial_cash = "100000" # Decimal 字符串,必填,>= 0

[data]
source = "tushare" # 数据源名称或绝对路径,必填
data_root = "~/.hqdata" # 可选;默认 ~/.hqdata

[strategy]
module = "examples.buy_and_hold" # 可导入的 Python 模块,必填
class_name = "BuyAndHold" # 可选;省略时使用模块内第一个 BaseStrategy 子类
[strategy.kwargs] # 可选:传给策略构造函数的参数表
answer = 42

[output]
directory = "results/run-1" # 必填;不存在则创建;可被 --output 覆盖
```

`[cost_model]` 可选;省略时使用 v0.1 默认费率(见下表)。

### 2.1 `[cost_model]`

| 字段 | 默认 | 说明 |
| --- | --- | --- |
| `commission_rate` | `"0.00025"` | 0.025%,Decimal 字符串 |
| `min_commission` | `"5.00"` | 5 元保底 |
| `stamp_tax_rate` | `"0.001"` | 0.1%,仅 SELL |
| `transfer_fee_rate` | `"0.0"` | 过户费(v0.1 默认 0) |

### 2.2 `source` 路径解析

| 写法 | 含义 |
| --- | --- |
| `source="tushare"` | 裸名,解析为 `{data_root}/{source}` |
| `source="/home/me/.hqdata/tushare"` | 绝对路径,拆为 `(parent_dir, basename)` |
| `source="~/.hqdata/tushare"` | 绝对路径同上(同 `pathlib.Path` 行为) |

不能在一次回测中混用多个数据源。

### 2.3 验证规则

任何未知 section 或 key 触发 `ConfigError` + 退出码 2。详细验证见 §4「错误信息」。

## 3. 输出目录

每次运行在 `directory` 下生成以下文件(互不重叠、可独立读出):

```text
results/run-1/
├── config.toml # 你传入的原始 TOML(精确字节)
├── run_metadata.json # hqbacktest / Python / 平台 / 时间戳 / git commit
├── events.jsonl # 引擎事件日志(每行一条 JSON)
├── equity_curve.csv # 日期、现金、持仓市值、总资产、日收益、回撤
├── orders.csv # 全部订单及状态
├── fills.csv # 全部成交与费用
├── positions.csv # 每日持仓和估值
├── costs.csv # 每次成交的费用拆分
└── summary.json # 配置快照 + 指标 + 因子诊断
```

每个文件的 schema 与字段说明见 [`docs/output.md`](output.md)。

### 3.1 `run_metadata.json`

| 字段 | 来源 | 示例 |
| --- | --- | --- |
| `hqbacktest_version` | `hqbacktest.__version__` | `"0.1.4"` |
| `python_version` | `sys.version` | `"3.11.5 ..."` |
| `platform` | `platform.platform()` | `"Linux-..."` |
| `timestamp_utc` | `datetime.utcnow().isoformat()` | `"2026-08-25T03:11:42+00:00"` |
| `git_commit` | **hqbacktest 自身**的 git commit(不是用户 cwd) | `"a1b2c3d..."` |
| `config_path` | `os.path.relpath(config_path, cwd)` | `"configs/moving_average.toml"` |
| `output_directory` | `os.path.relpath(output_dir, cwd)` | `"results/run-1"` |
| `data_root` | 配置给定 | `"~/.hqdata"` |
| `adjustment_policy` | 配置给定 | `"none"` |

> `summary.json` **不**写入 token / 完整环境 / 本地绝对路径;`config_path` / `output_directory` 在写入时用相对 cwd 路径脱敏。

### 3.2 `summary.json` 稳定性

`summary.json` 跨运行字节级稳定(去掉 `rule_set` 等含内存地址的运行时对象);同输入同数据 → 同输出,可用 `diff` 复核差异。`rule_set` 在 `summary` 序列化时被剥离以避免内存地址入文件。

## 4. 错误信息

退出码分布:

| 退出码 | 类别 |
| --- | --- |
| 0 | 成功 |
| 2 | 配置错误 |
| 3 | 输出目录错误 |
| 4 | 引擎异常 |

具体错误一览:

| 情况 | 退出码 | 错误示例(stderr 唯一一行) |
| --- | --- | --- |
| 配置文件缺失 / 不可读 | 2 | `config file not found: configs/missing.toml` |
| TOML 语法错 | 2 | `config file configs/bad.toml is not valid TOML: ...` |
| 必填字段缺失 | 2 | `[start] missing required key 'start_date'` |
| 未知 section / key | 2 | `unknown config sections: ['extra']; allowed: [...]` |
| 日期格式错 | 2 | `[start].start_date: not a valid calendar date: '20241399' (...)` 或 `must be 8 digits, got '2024-01-02'` |
| `initial_cash = nan` / `inf` | 2 | `[capital].initial_cash=NaN must be a finite number ...` |
| `initial_cash = float` | 2 | `[capital].initial_cash must be int/str/Decimal; float is forbidden ...` |
| 空交易窗口 | 2 | `no trading days in [...] for source 'memory'; ...` |
| 策略模块无法导入 | 2 | `could not import strategy module 'examples.foo': ...` |
| 策略类非 BaseStrategy 子类 | 2 | `MyStrategy is not a BaseStrategy subclass` |
| 策略无 `class_name` 且模块无 BaseStrategy | 2 | `no BaseStrategy subclass found in ...` |
| 输出目录不可创建 / 不可写 | 3 | `cannot create output directory ...: ...` |
| 输出目录已含旧结果文件 | 3 | `output directory ... already contains prior-run files; pass force=True ...` |
| 引擎异常(非 RunFailed) | 4 | `backtest run failed: ...` |
| 成功 | 0 | stdout 一行:`hqbacktest: wrote results to results/run-1` |

> 退出码 ≠ 0 时,唯一一行错误输出到 stderr,便于 CI / shell 脚本处理。

## 5. 复现性

两次相同输入 + 相同数据 + 相同 `data_root` 的运行:

- `events.jsonl` 字节相同
- `equity_curve.csv` / `orders.csv` / `fills.csv` / `positions.csv` / `costs.csv` 字节相同
- `summary.json` 字段值相同(去除内存地址等运行时常量后)
- 唯一的非确定性字段是 `run_metadata.json` 的 `timestamp_utc`(记录运行时刻,非结果数据)

CI 用法:

```bash
hqbacktest run --config configs/baseline.toml --output results/baseline
hqbacktest run --config configs/baseline.toml --output results/baseline-2 --force
diff -r results/baseline results/baseline-2 # 仅 run_metadata.json 的 timestamp_utc 不一致
```

## 6. CLI 测试

CLI 测试覆盖端到端、配置验证、可复现性与错误信息,详见 `tests/cli/`。
14 changes: 7 additions & 7 deletions docs/design/mvp-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@
- `data portal` 通过 `data_root` 与 `source` 解析数据集根目录。v0.1 的固定布局为 `{root}/{source}/calendar.csv`,以及 `stock_list/{YYYYMMDD}.csv`、`stock_daily/{YYYYMMDD}.csv`、`stock_factor/{YYYYMMDD}.csv`;任何缺失、不可读或格式不符的文件必须报错,不得联网回补。
- hqdata CSV 快照是叶子数据边界;更新数据只能在回测运行前通过 hqdata CLI 完成。

### 3.3 数据可见性与缺行语义(任务 14 固化)
### 3.3 数据可见性与缺行语义

| 维度 | v0.1 默认决定 |
| --- | --- |
Expand All @@ -150,7 +150,7 @@
| `Bar.volume` 单位 | **手**(1 手 = 100 股;与 Tushare `hqdata` 适配器口径一致);调用方需要股数时应乘以 `LOT_SIZE`。 |
| 双门户一致性 | `InMemoryDataPortal` 与 `HqDataCsvPortal` 行为完全一致(parity 测试覆盖)。 |

### 3.4 撮合与账本口径(任务 16 固化)
### 3.4 撮合与账本口径

| 维度 | v0.1 默认决定 |
| --- | --- |
Expand All @@ -166,7 +166,7 @@
| `target_quantity_for_value(0)` | 返回 `0`(flatten),与 docstring 一致。 |
| CLI `initial_cash` | 拒绝 `float`(TOML 字面 `100000.0` 报错),与引擎层 `BacktestConfig` 严格度对齐。 |

### 3.5 净值与绩效指标口径(任务 17 固化)
### 3.5 净值与绩效指标口径

| 维度 | v0.1 默认决定 |
| --- | --- |
Expand All @@ -178,17 +178,17 @@
| Decimal 量化 | 所有 `float` 桥接的 Decimal 输出统一 quantize 到 `Decimal('0.000000000001')`,保证 `summary.json` 干净。 |
| `positions.sellable_quantity` | **结转后**(D 行快照为 D+1 起始时可卖数);engine 在 `_snapshot_equity` 前调用 `settle_t1`,D 行的 `sellable_quantity` 即已包含当日成交的滚动。 |

### 3.6 策略隔离与审计完整性(任务 18 固化)
### 3.6 策略隔离与审计完整性

| 维度 | v0.1 默认决定 |
| --- | --- |
| `Order` 不可变 | `@dataclass(frozen=True)`,`fill_ids: tuple[str, ...]`;策略收到 `pending_orders()` 后无法修改任何字段(quantity / avg_fill_price / fill_ids 等);`transition` / `record_fill` 用 `object.__setattr__` 写入冻结字段,仅 engine / broker 可调用。 |
| `DataView.portal` 私有 | 字段名 `_portal`(下划线私有约定),策略**无法通过公开 API**(`view.portal`)访问——该属性已重命名为 `_portal`,访问旧名抛 `AttributeError`。该约定是 Python 的"约定私有"语义,**不**是语言级强制力:策略仍可经 `view._portal`(或 `context._data_view._portal`)触及 raw portal——这是 Python 语言限制而非本项目缺陷;契约依靠约定 + 审计测试守住。所有合规数据访问走 `view.history` / `view.current_price` / `view.universe`。 |
| Universe 生效 | `set_universe(...)` 后,对未声明的符号下单立即拒绝(`RejectReason.OUT_OF_UNIVERSE`,含 ORDER_CREATED + ORDER_REJECTED 事件,Order 不经过 broker、停留在 `_out_of_universe_orders` 并在 result 构造时折入 `orders_table`);**未设 universe 时不限制**。 |
| 历史股票池 | `Context.historical_universe()` 返回 `visible_through` 当日的 portal 股票池(默认排除 `.BJ`),受可见性约束;不暴露 raw portal。 |
| 返回值防御性 | `pending_orders()` / `universe()` / `historical_universe()` 均返回 list 副本;Bar / Factor 跨查询复用(任务 15)。 |
| 返回值防御性 | `pending_orders()` / `universe()` / `historical_universe()` 均返回 list 副本;Bar / Factor 跨查询复用。 |

### 3.7 因子诊断接入与分红偏差显性化(任务 19 固化)
### 3.7 因子诊断接入与分红偏差显性化

| 维度 | v0.1 默认决定 |
| --- | --- |
Expand All @@ -200,7 +200,7 @@
| CLI 警告 | `run_from_config` 末尾若 `result.factor_diagnostics` 非空,打印一行 `warning: N corporate-action factor jumps detected during holding periods; NAV excludes dividends (adjustment_policy=none), see summary.json`。 |
| 文档承诺 | README 显著位置明示:`adjustment_policy=none` 下跨除权日的净值**系统性低估**(少分红现金),长区间结果不可用于收益评估,并链接因子诊断输出(`summary.json` / `events.jsonl`)。 |

### 3.8 CLI 易用性与契约承诺一致性(任务 20 固化)
### 3.8 CLI 易用性与契约承诺一致性

| 维度 | v0.1 默认决定 |
| --- | --- |
Expand Down
90 changes: 90 additions & 0 deletions docs/factor-diagnostics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# 因子诊断与分红偏差显性化

> 适用版本:v0.1。契约层级见 [`docs/design/mvp-contract.md`](design/mvp-contract.md) §3.7。

## 1. 为什么需要这份文档

`adjustment_policy="none"` 下,跨除权日(除息 / 除权日)的回测净值**系统性低估**(少分红现金)。这是数据 + 复权策略的选择决定的,不是 bug:

- 成交、现金账本、v0.1 净值**全部使用未复权价格**。
- 因子复权序列只用于数据质量诊断,不进入账本、不改写 `cash` / `position` / `equity`。
- 因子总回报口径(`factor_total_return`)必须等独立的「公司行为会计」设计与验证完成后才允许加入配置。

v0.1 的合约做法:**让偏差可见,不让偏差消失**。这份文档说明诊断如何接入、怎样读输出。

## 2. 接入条件

| 维度 | 默认决定 |
| --- | --- |
| 启用条件 | `adjustment_policy="none"`(v0.1 唯一接受值) |
| 触发对象 | **当前持仓**(`position.quantity > 0`)的标的;清仓后停止告警(持有期结束) |
| 跳变阈值 | **0.1%**(holdings-period 阈值,`jump_band=(0.999, 1.001)`) |
| 一般诊断阈值 | `(0.5, 2.0)` 用于一般因子质量诊断(不带 holdings 约束) |
| 数据来源 | `portal.get_factor(symbol, today, today)` |
| 缺失处理 | 快照缺失 / 因子 `None` 静默跳过;不影响 run |

## 3. 数据写入位置

引擎在 holdings 期间遇到因子跳变时同时写入三处:

| 位置 | 内容 |
| --- | --- |
| 事件日志 | `EngineEvent(phase=DATA_WARNING, detail={symbol, before, after, ratio})` |
| `BacktestResult.factor_diagnostics` | 完整 `FactorDiagnostic` 列表:symbol / ex_date / before / after / ratio / action |
| `summary.json` 的 `factor_diagnostics` 字段 | 上列的 JSON 形态 |

代码位置:`src/hqbacktest/engine/factor_diagnostics.py`、`src/hqbacktest/result.py`。

## 4. 账本零影响

诊断是**只读观测**:

- `engine._running_cash` / `Portfolio.position[symbol]` / `EquityPoint.total_equity` 完全不受影响。
- baseline(未启用诊断)与启用诊断版本的 `equity_curve.csv` / `orders.csv` / `fills.csv` / `positions.csv` / `costs.csv` / `summary.json` 字节相同(去掉 `summary.factor_diagnostics` 字段后)。
- 测试锁定:`tests/engine/test_factor_diagnostics.py::test_diagnostics_do_not_change_ledger`。

## 5. CLI 警告

`hqbacktest run`(即 `cli/runner.run_from_config`)末尾检查 `result.factor_diagnostics`:

- 非空 → 打印一行 stderr / stdout 提示:
```text
warning: N corporate-action factor jumps detected during holding periods; NAV excludes dividends (adjustment_policy=none), see summary.json
```
- 该提示**不影响退出码**——回测成功仍是 exit 0;诊断的存在不等于运行失败。
- `[--quiet]`(未实现 v0.1 暂不支持)或 `summary.json` 自身的 `factor_diagnostics` 字段是更持久的查询入口。

## 6. 长区间结果的解读

跨除权日长区间(如年度 / 多年)的回测净值:

- **不可**直接用于收益评估,因为它系统性低估现金分红。
- **必须**先评估因子跳变(看 `summary.factor_diagnostics` 与 `events.jsonl` 中的 `DATA_WARNING` 行)。
- 若发现大量 hint 跳变集中于某几个交易日附近,且这些日期符合除权除息节奏,应视为「该结果已被分红偏差影响」的信号。
- 重新评估需要等价的精确公司行为会计能力,**v0.1 不提供**;v0.1 的响应是「看见」而非「修正」。

## 7. API

```python
from hqbacktest.engine.factor_diagnostics import analyze_factor_series

diag: list[FactorDiagnostic] = analyze_factor_series(
symbol="600000.SH",
factors=[Decimal("1.0"), Decimal("1.0"), Decimal("0.85")], # 跨日序列
threshold=(0.999, 1.001), # 0.1%
)
for d in diag:
print(d.ex_date, d.before, d.after, d.ratio)
```

`threshold` 缺省为 `(0.5, 2.0)`(一般诊断);holdings-period 引擎内部传 `(0.999, 1.001)`。

## 8. 手算回归

| 场景 | 测试位置 |
| --- | --- |
| 复刻 `600000.SH` 2026-07-16 除权案例 | `tests/engine/test_factor_diagnostics.py::test_600000_2026_07_16_ex_dividend` |
| 诊断不修改账本(byte-identical ledger) | `tests/engine/test_factor_diagnostics.py::test_diagnostics_do_not_change_ledger` |
| 缺因子 / 无效因子静默跳过 | `tests/engine/test_factor_diagnostics.py::test_missing_factor_no_warning` |
| 清仓后停止告警 | `tests/engine/test_factor_diagnostics.py::test_no_warning_after_position_closed` |
| CLI 末尾打印 `warning: N corporate-action ...` | `tests/cli/test_run_factor_warning.py` |
Loading
Loading