Skip to content

feat(backtest): expand trade detail to 14 cols and fix profit two-sided commission#1

Open
xuesongjun wants to merge 20 commits into
mainfrom
feat/backtest-trade-detail-expansion
Open

feat(backtest): expand trade detail to 14 cols and fix profit two-sided commission#1
xuesongjun wants to merge 20 commits into
mainfrom
feat/backtest-trade-detail-expansion

Conversation

@xuesongjun

@xuesongjun xuesongjun commented Apr 20, 2026

Copy link
Copy Markdown
Owner

Summary

  • 回测交易明细从 10/11 列扩展到 14 列中文表头(控制台 + --csv 导出统一),新增字段:开盘价(买入日)、收盘价(卖出日)、佣金合计、净值(卖出后账户总资产)。
  • 修复 backtest/engine.py:267profit 计算 bug:原公式只扣了卖出端 order.commission,漏掉买入端佣金,导致 ETF 场景 profit 系统性偏乐观。
  • 通过 TRADE_COLUMNS 常量统一控制台渲染与 CSV 导出 schema,避免双份维护。

Field Schema (14 cols, Chinese)

`代码 | 买入日 | 卖出日 | 开盘价 | 买入价 | 收盘价 | 卖出价 | 份额 | 佣金 | 净盈 | 收益率% | 持仓天数 | 净值 | 卖出原因`

  • 开盘价 = 买入日 `bar.open`(评估"尾盘买 vs 早盘买"择时成本)
  • 收盘价 = 卖出日 `bar.close`(评估"开盘卖 vs 收盘卖"择时收益)
  • 佣金 = 买入佣金 + 卖出佣金(ETF 免印花税/过户费,合并为单列)
  • 净盈 = (卖出价 - 买入价) × 份额 - 佣金合计
  • 净值 = 卖出后 account.total_equity

Why

用户运行 overnight_long 回测后指出交易明细信息太少,要求列全关键价格点 + 费用 + 资产变动。同时暴露了 engine _trades 字段里 profit 公式只扣单边佣金的历史 bug(对 ETF 误差更明显,因为没有印花税/过户费这些本应吃掉乐观偏差的额外费用)。

Test Plan

  • 运行 python scripts/run_backtest.py --strategy overnight_long --codes 513090 --start 2023-01-01 --end 2026-04-07 --csv trades.csv 成功输出 393 笔交易
  • 控制台 14 列表头渲染正常(中英混排宽度对齐)
  • CSV 首行为 代码,买入日,卖出日,开盘价,...,卖出原因(UTF-8 BOM ✓)
  • 单笔验证:(1.059-1.077)*882000 - 188.39 = -16064.39 与 CSV 首行 净盈=-16064.39 完全对得上
  • 佣金验证:首笔买入 amount=1.077×882000=949914,佣金 ≈ 94.99;卖出 amount=1.059×882000=934038,佣金 ≈ 93.40;合计 ≈ 188.39 ✓
  • 在 Excel 打开导出的 CSV,确认中文表头不乱码(依赖 UTF-8 BOM,本地验证需用户操作)

Scope

  • Changed: backtest/engine.py, scripts/run_backtest.py
  • Docs: README.md, plan.md, research.md (new), process.txt
  • Not touched: backtest/account.py, backtest/fee.py, backtest/metrics.py, all strategy files, config files

Summary by CodeRabbit

  • Bug Fixes

    • Profit now deducts both buy and sell commissions; sell-volume validation rejects illegal partial odd-lot sells but allows selling the full tail.
  • New Features

    • Slippage switched to configurable percentage (--slippage-rate); next-open orders truly execute at the next trading-day open.
    • Deterministic paper-trade account IDs to isolate runs.
    • Read-only dashboard API and frontend scaffold for a Phase‑6 dashboard.
    • Daily P&L trade export standardized with Chinese headers (Excel-friendly).
  • Documentation

    • README/specs/research/plan updated with strategy timing, trade-field meanings and new CLI options.
  • Tests

    • New regression/unit tests covering overnight behavior, engines, trading rules and account-id logic.

…ed commission

- Expand trade-detail output to 14 Chinese-headed columns covering both the
  console table and --csv export: code, buy/sell dates, buy-day open, buy
  price, sell-day close, sell price, volume, total commission, net profit,
  return%, holding days, post-trade net equity, and reason.
- Fix latent bug in BacktestEngine where profit only deducted the sell-side
  order.commission; buy-side commission was silently dropped, causing ETF
  profit to be systematically over-optimistic.
  Now profit = (sell_price - buy_price) * volume - buy_commission - sell_commission.
- _buy_records now stores buy-side commission and buy-day open price so sell
  leg can reconstruct the full cost basis.
- Unify column schema via TRADE_COLUMNS constant in run_backtest.py, reused
  by the console renderer and CSV export rename/keep-keys logic.
- Verified on 513090 ETF 2023-01-01~2026-04-07 (393 trades); net profit now
  agrees with equity_curve daily diff.
- Add root-level research.md for accumulated research notes; append a
  "9. 迭代日志" section to plan.md to track iterations.
@coderabbitai

coderabbitai Bot commented Apr 20, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Switched slippage to percentage, introduced pending next-open execution, changed trade recording to per-day aggregation with both-side commission accounting, made overnight_long emit independent BUY (close) and SELL (next_open) signals, added deterministic paper account IDs, tightened sell-volume rules, and updated CLI/CSV/docs/tests.

Changes

Cohort / File(s) Summary
Backtest core & metrics
backtest/engine.py, backtest/metrics.py
Replaced absolute slippage with slippage_rate; added pending next_open queue and _execute_pending_next_open; changed signal processing to per-day actions and finalized daily trades; BacktestResult and calculate_metrics now include/accept initial_capital.
Trading rules & paper engine
backtest/rules.py, trading/paper_engine.py
Added TradingRules.is_valid_sell_volume() and tightened sell-volume validation; sell rounding behavior adjusted; PaperEngine accepts account_id, validates pending/instant SELLs with new rules, resolves pending sell volume from pos.volume.
Strategy & registry
strategy/technical/overnight_long.py, strategy/registry.py, config/strategies.yaml
overnight_long.on_bar now runs independent SELL (emit next_open) and BUY (emit close) checks; BUY filters (min_drop_pct/max_rise_pct) apply only to BUY; load_strategy() merges YAML STRATEGY_CONFIG defaults into strategy config; comment updated.
CLI, I/O & exports
scripts/run_backtest.py, scripts/run_paper_trade.py, .gitignore
Added --slippage-rate CLI and wiring to engine; introduced TRADE_COLUMNS mapping for headers/formatters; standardized console/CSV export (Chinese headers, integer normalization, None/NaN handling); ignored common backtest output and frontend artifacts.
Config & settings
config/__init__.py, config/settings.yaml
Renamed backtest.slippagebacktest.slippage_rate (default 0.0) and updated BacktestConfig attribute and YAML key.
Paper orchestration & account IDs
scripts/run_paper_trade.py, trading/account_id.py, trading/paper_engine.py
Deterministic build_account_id(strategy_key,codes,params) added; run_paper_trade computes/passes account_id; PaperEngine reports account_id and uses it for repo lookups.
Trade-detail schema & docs
README.md, plan.md, process.txt, research.md, docs/superpowers/specs/.../2026-04-19-overnight-long-design.md
Docs updated: Daily P&L Journal schema (expanded trade-detail columns and CSV/BOM notes), overnight_long timing/semantics, CLI usage, changelogs (slippage model, trade schema, next_open behavior).
Data source tweak
data/sources/tushare_source.py
Index mapping: TS codes starting with 399 now append .SZ (Shenzhen) instead of previous rule.
Tests
tests/...
tests/test_overnight_long.py, tests/test_overnight_long_engines.py, tests/test_paper_trade_helpers.py, tests/test_trading_rules.py
Updated/added tests for new overnight signal semantics, engine pending next-open fills, deterministic account IDs, and sell-volume validation; new engine-level fake repo and helper constructors.
Frontend dashboard (new)
api/, frontend/...
api/main.py, api/routers/..., api/schemas.py, api/dependencies.py, frontend/src/*
Added a Phase-6 read-only dashboard API and minimal frontend scaffolding: FastAPI app, dashboard router/schemas/dependencies, frontend Vite+Vue TS project files and client fetch helper.
Storage repo helper
data/storage/repository.py
Added PaperRepository.list_paper_accounts() to enumerate persisted paper accounts ordered by update/create time.
sequenceDiagram
    participant Strategy as OvernightLongStrategy
    participant Engine as BacktestEngine / PaperEngine
    participant Repo as MarketDataRepo
    participant Account as Positions/Account
    participant Output as CSV/Console/DB

    Strategy->>Engine: on_bar(bar) -> SELL check -> emit SIGNAL(next_open)
    Strategy->>Engine: on_bar(bar) -> BUY check -> emit SIGNAL(close)
    Engine->>Account: schedule pending SELL (next_open) / execute BUY (close)
    Engine->>Repo: query fills / prices for execution
    Account-->>Engine: fill events (include buy/sell commissions)
    Engine->>Engine: aggregate daily actions -> finalize daily trade (commission=buy+sell)
    Engine->>Output: append/export daily trade record (TRADE_COLUMNS mapping)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰
I nibble at close, I wait for dawn’s light,
Two fees counted, records tidy and bright,
Fifteen fields hum of trades whole and neat,
Pending sells queue up for morning’s beat,
A rabbit nods — the backtest feels complete!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: expanding trade detail columns and fixing profit calculation to account for two-sided commission in backtest output.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/backtest-trade-detail-expansion

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
scripts/run_backtest.py (1)

224-237: Robust cell rendering with good error handling.

The null/NaN detection at line 228 (raw != raw) is a valid idiom for float NaN. The try/except fallback to str(raw) at lines 231-234 handles unexpected formatter failures gracefully.

💡 Optional: Add `strict=True` to zip for extra safety

Per Ruff B905, adding strict=True documents the expectation that values and TRADE_COLUMNS have equal length:

         line = "|" + "|".join(
-            _pad_str(val, w) for val, (_, _, w, _) in zip(values, TRADE_COLUMNS)
+            _pad_str(val, w) for val, (_, _, w, _) in zip(values, TRADE_COLUMNS, strict=True)
         ) + "|"

This is safe since both are derived from the same iteration, but strict=True makes the contract explicit.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/run_backtest.py` around lines 224 - 237, The code handles None/NaN
and formatter errors for TRADE_COLUMNS correctly, but make the contract explicit
by using zip(..., strict=True) when pairing values with TRADE_COLUMNS to ensure
they have equal length; update the expression that builds line (the zip used
with _pad_str and TRADE_COLUMNS) to pass strict=True so mismatches raise
immediately, referencing TRADE_COLUMNS, values, fmt and _pad_str to locate the
change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@scripts/run_backtest.py`:
- Around line 224-237: The code handles None/NaN and formatter errors for
TRADE_COLUMNS correctly, but make the contract explicit by using zip(...,
strict=True) when pairing values with TRADE_COLUMNS to ensure they have equal
length; update the expression that builds line (the zip used with _pad_str and
TRADE_COLUMNS) to pass strict=True so mismatches raise immediately, referencing
TRADE_COLUMNS, values, fmt and _pad_str to locate the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 735cba73-0637-441c-957d-75ab28d9b608

📥 Commits

Reviewing files that changed from the base of the PR and between 2983649 and efa2552.

📒 Files selected for processing (6)
  • README.md
  • backtest/engine.py
  • plan.md
  • process.txt
  • research.md
  • scripts/run_backtest.py

Rework on_bar decision logic from if/elif to two independent if branches.
Previously the strategy held position only every other day (buy → overnight → sell
→ idle one day → buy), yielding ~50% exposure. New logic generates both SELL
(next_open) and BUY (close) signals on the same bar, so the account rolls over
continuously night-to-night at ~100% exposure, matching the "overnight factor"
intent.

Add limit_pct parameter (default 0.10 for mainboard/cross-border ETFs, 0.20 for
ChiNext/STAR) for one-limit-down detection: if bar.open <= pre_close × (1 -
limit_pct) + 1e-6, both SELL (cannot be matched) and BUY (funds still locked by
position) are skipped for the day. Narrow min_drop_pct / max_rise_pct filter
semantics to BUY only — SELL is a position-exit condition unaffected by entry
filters.

Rely on [SELL, BUY] list ordering and engine's list[Signal] processing (fixed
in previous iteration) so SELL frees cash before BUY consumes it.

Fix logic bug caught by test_position_with_zero_available_skips_sell: initial
implementation treated "no sellable shares" as empty, missing the T+1 frozen
case where position exists but available=0. Use explicit currently_empty =
(pos is None or pos.volume == 0).

Tests: 8 → 11 cases; amended 4 existing assertions, added 3 new
(one_limit_down_blocks_both / limit_down_recovery_next_day /
custom_limit_pct_changes_limit_threshold).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
research.md (1)

26-43: Add language specifier to pseudo-code block.

The code block at line 29 is missing a language specifier.

📝 Proposed fix
-```
+```python
 has_sellable  = pos and pos.available > 0
 sell_blocked  = has_sellable and (bar.open <= limit_down_price + 1e-6)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@research.md` around lines 26 - 43, The fenced code block containing the
pseudo-code (lines showing has_sellable, sell_blocked, currently_empty,
will_sell_all and the two independent if branches) is missing a language
specifier; add the language tag "python" to the opening fence (i.e., change the
opening triple backticks to ```python) and ensure the block still ends with a
closing triple backticks so the pseudo-code is properly highlighted and
rendered.
plan.md (1)

516-527: Add language specifier to fenced code block.

The code block at line 518-520 is missing a language specifier, which triggers markdownlint MD040.

📝 Proposed fix
-```
+```text
 代码 | 买入日 | 卖出日 | 开盘价 | 买入价 | 收盘价 | 卖出价 | 份额 | 佣金 | 净盈 | 收益率% | 持仓天数 | 净值 | 卖出原因
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @plan.md around lines 516 - 527, The fenced code block under the
"输出字段设计(中文表头,14 列)" heading is missing a language specifier and triggers
markdownlint MD040; update the triple-backtick fence that contains the header
row "代码 | 买入日 | 卖出日 | 开盘价 | 买入价 | 收盘价 | 卖出价 | 份额 | 佣金 | 净盈 | 收益率% | 持仓天数 | 净值 |
卖出原因" to include a language tag (e.g., ```text) on the opening fence so the
block is explicitly typed.


</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>🤖 Prompt for all review comments with AI agents</summary>

Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In @plan.md:

  • Around line 516-527: The fenced code block under the "输出字段设计(中文表头,14 列)"
    heading is missing a language specifier and triggers markdownlint MD040; update
    the triple-backtick fence that contains the header row "代码 | 买入日 | 卖出日 | 开盘价 |
    买入价 | 收盘价 | 卖出价 | 份额 | 佣金 | 净盈 | 收益率% | 持仓天数 | 净值 | 卖出原因" to include a language
    tag (e.g., ```text) on the opening fence so the block is explicitly typed.

In @research.md:

  • Around line 26-43: The fenced code block containing the pseudo-code (lines
    showing has_sellable, sell_blocked, currently_empty, will_sell_all and the two
    independent if branches) is missing a language specifier; add the language tag
    "python" to the opening fence (i.e., change the opening triple backticks to
the pseudo-code is properly highlighted and rendered.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e2802e10-9e99-4b8f-bc89-44ffaac58a69

📥 Commits

Reviewing files that changed from the base of the PR and between efa2552 and 40dbf3e.

📒 Files selected for processing (8)
  • README.md
  • config/strategies.yaml
  • docs/superpowers/specs/2026-04-19-overnight-long-design.md
  • plan.md
  • process.txt
  • research.md
  • strategy/technical/overnight_long.py
  • tests/test_overnight_long.py
✅ Files skipped from review due to trivial changes (1)
  • config/strategies.yaml

Mahdi added 6 commits April 20, 2026 15:28
- rename settings.yaml key slippage (0.01) -> slippage_rate (0.0 default)
- rename BacktestConfig.slippage -> BacktestConfig.slippage_rate
- rename BacktestEngine ctor arg slippage -> slippage_rate, use
  `is not None` instead of `or` so explicit 0 is not swallowed
- change formula: exec_price +/- slippage -> exec_price * (1 +/- rate)
  with round(., 3) to fit A-share min tick (0.001 yuan)
- short-circuit `if self.slippage_rate:` so rate=0 exactly preserves
  bar prices (skip the multiply+round entirely)
- add CLI flag --slippage-rate to scripts/run_backtest.py
  (default None so yaml default still wins when flag omitted)
- sync docs: README / plan.md / research.md / process.txt
Previously each row in the trade detail output represented one
round-trip (buy_date -> sell_date), so row 1 spanned two calendar
days (e.g. buy 01-05, sell 01-06), which was easy to misread as
"both buy and sell happened on 01-05" for an overnight strategy.

New schema: one row per trading day, with an explicit "action" column
labeling the row as 建仓 (buy only) / 换仓 (sell-then-buy) / 平仓
(sell only). Empty fields on entry/exit rows express "no such action
today" directly.

Engine changes:
- add self._daily_actions as per-day buffer keyed by code
- _process_signal only places the order and buffers the action
- run() calls a new _finalize_daily_trade(td, bar_map) at the end of
  each day (after record_equity so net_equity is current)

Column changes (14 -> 15 columns):
- buy_date + sell_date merged into single "日期"
- volume split into "卖出份额" + "买入份额" (unequal on swap days
  since the same cash buys a different share count at a new price)
- added "动作" column for the row type
- "卖出原因" renamed to "动作备注"

Commission vs profit intentionally have different semantics now:
commission is cash-flow view (all fills on the day), profit is
round-trip view (prev buy -> this sell, both-side commissions
deducted). Swap-day new-entry commission carries over to the next
close-out's round-trip.

Also fix a minor pandas quirk in the CSV exporter: int columns with
None values were upcast to float and written as "9000.0"; format
them explicitly as int strings or empty.
Put 卖出价 right after 开盘价, and 买入价 right after 收盘价. The
execution price of each action sits next to its reference bar price
now (sell_price ~ open; buy_price ~ close), which matches the
9:25 sell / 14:55 buy event timeline and makes the table easier
to scan.
…action

open and close are the reference bar prices for the day; they should
be filled for every row, not only when a matching buy/sell happened.
buy_price / sell_price remain action-specific.

Previously close was left blank on 建仓 rows (leftover of the
round-trip schema where close meant "sell day close"). Treat close
the same as open now — always bar.close.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
backtest/engine.py (3)

363-365: Simplify the dictionary access.

The key check before access is redundant. Consider using get() with a default value.

♻️ Proposed fix
-                # 清除最近一条买入记录(round-trip 已闭合)
-                if signal.code in self._buy_records and self._buy_records[signal.code]:
-                    self._buy_records[signal.code].pop()
+                # 清除最近一条买入记录(round-trip 已闭合)
+                buy_list = self._buy_records.get(signal.code)
+                if buy_list:
+                    buy_list.pop()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backtest/engine.py` around lines 363 - 365, The explicit key existence check
is redundant; use self._buy_records.get(signal.code, []) to retrieve the list
safely and then pop if it's non-empty. Replace the if block referencing
self._buy_records and signal.code with a retrieval like lst =
self._buy_records.get(signal.code, []) and call lst.pop() only when lst is
truthy to avoid KeyError and simplify the logic.

42-52: Use explicit Optional type hint for slippage_rate parameter.

PEP 484 prohibits implicit Optional. The parameter should explicitly indicate it can be None.

♻️ Proposed fix
     def __init__(
         self,
         strategy: BaseStrategy,
         stock_codes: list[str],
         start_date: date,
         end_date: date,
-        initial_capital: float = None,
-        slippage_rate: float = None,
+        initial_capital: float | None = None,
+        slippage_rate: float | None = None,
     ):
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backtest/engine.py` around lines 42 - 52, The slippage_rate parameter must
explicitly allow None per PEP 484: update the function/method signature that
declares slippage_rate (the constructor where self.slippage_rate is set) to use
Optional[float] instead of float, add the necessary import of Optional from
typing, and keep the current fallback logic that assigns self.slippage_rate =
slippage_rate if slippage_rate is not None else BacktestConfig.slippage_rate
unchanged; ensure hints for other related params remain consistent.

299-312: Consider reusing the existing signal instead of creating a new one for validation.

The code creates a new Signal object with the same values as the original signal (except using exec_price for price). Since the original signal already has most of these values, this could be simplified.

♻️ Proposed simplification
         else:
-            valid, reason = TradingRules.validate_order(
-                Signal(
-                    code=signal.code,
-                    direction=signal.direction,
-                    trade_date=signal.trade_date,
-                    price=exec_price,
-                    volume=volume,
-                ),
+            # Create a validation signal with the computed exec_price and volume
+            validation_signal = Signal(
+                code=signal.code,
+                direction=signal.direction,
+                trade_date=signal.trade_date,
+                price=exec_price,
+                volume=volume,
+            )
+            valid, reason = TradingRules.validate_order(
+                validation_signal,
                 bar,
                 self.account.cash,
                 pos_volume,
                 pos_available,
             )

Alternatively, if TradingRules.validate_order could accept individual parameters instead of a Signal object, that would be cleaner.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backtest/engine.py` around lines 299 - 312, The code constructs a new Signal
just to change price for validation; instead reuse the existing signal by
passing it to TradingRules.validate_order with the updated exec_price (either by
creating a lightweight copy/replace of signal that only changes price or by
adding an optional price_override parameter to TradingRules.validate_order) and
keep the other args (bar, self.account.cash, pos_volume, pos_available) the
same; update validate_order signature if you choose the price_override approach
so callers can pass exec_price without reconstructing Signal.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@data/sources/tushare_source.py`:
- Line 183: The comment string "# tushare 指数代码格式:" contains a fullwidth colon
which triggers Ruff RUF003; replace the fullwidth colon `:` with an ASCII colon
`:` so the line reads "# tushare 指数代码格式:" (locate the comment near the tushare
index code handling, e.g., the comment above the index code parsing logic) to
resolve the lint warning.

In `@plan.md`:
- Line 674: Update the document metadata string "*文档版本:v1.1 | 创建日期:2026-03-17 |
最后更新:2026-04-19*" so the "最后更新" field reflects the actual latest edits (change
to "最后更新:2026-04-20") and ensure the top-of-file metadata and any other
occurrences of that exact metadata string in plan.md are synchronized with the
new date to avoid inconsistent version info.

In `@scripts/run_paper_trade.py`:
- Around line 62-78: build_account_id is producing different IDs for
semantically identical numeric params because parse_params can return ints or
floats (e.g., 3 vs 3.0); to fix this, canonicalize numeric param values before
hashing in build_account_id (or in parse_params) by detecting numeric
types/strings and converting them to a consistent representation (e.g., always
cast to float and format with a stable string format or normalize with Decimal
and a fixed precision), then use that normalized params dict when creating raw
for json.dumps; reference build_account_id and parse_params when making the
change so numeric keys like min_drop_pct are consistently serialized.

---

Nitpick comments:
In `@backtest/engine.py`:
- Around line 363-365: The explicit key existence check is redundant; use
self._buy_records.get(signal.code, []) to retrieve the list safely and then pop
if it's non-empty. Replace the if block referencing self._buy_records and
signal.code with a retrieval like lst = self._buy_records.get(signal.code, [])
and call lst.pop() only when lst is truthy to avoid KeyError and simplify the
logic.
- Around line 42-52: The slippage_rate parameter must explicitly allow None per
PEP 484: update the function/method signature that declares slippage_rate (the
constructor where self.slippage_rate is set) to use Optional[float] instead of
float, add the necessary import of Optional from typing, and keep the current
fallback logic that assigns self.slippage_rate = slippage_rate if slippage_rate
is not None else BacktestConfig.slippage_rate unchanged; ensure hints for other
related params remain consistent.
- Around line 299-312: The code constructs a new Signal just to change price for
validation; instead reuse the existing signal by passing it to
TradingRules.validate_order with the updated exec_price (either by creating a
lightweight copy/replace of signal that only changes price or by adding an
optional price_override parameter to TradingRules.validate_order) and keep the
other args (bar, self.account.cash, pos_volume, pos_available) the same; update
validate_order signature if you choose the price_override approach so callers
can pass exec_price without reconstructing Signal.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 86ecb152-3d67-421e-a3ba-b7d5f76978db

📥 Commits

Reviewing files that changed from the base of the PR and between 40dbf3e and d7c766e.

📒 Files selected for processing (21)
  • .gitignore
  • README.md
  • backtest/engine.py
  • backtest/metrics.py
  • backtest/rules.py
  • config/__init__.py
  • config/settings.yaml
  • config/strategies.yaml
  • data/sources/tushare_source.py
  • plan.md
  • process.txt
  • research.md
  • scripts/run_backtest.py
  • scripts/run_paper_trade.py
  • strategy/registry.py
  • strategy/technical/overnight_long.py
  • tests/test_overnight_long.py
  • tests/test_overnight_long_engines.py
  • tests/test_paper_trade_helpers.py
  • tests/test_trading_rules.py
  • trading/paper_engine.py
✅ Files skipped from review due to trivial changes (1)
  • .gitignore
🚧 Files skipped from review as they are similar to previous changes (1)
  • config/strategies.yaml

# tushare 指数代码格式
if code.startswith("0"):
ts_code = f"{code}.SH"
# tushare 指数代码格式:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Replace fullwidth colon in comment to avoid Ruff RUF003.

Line 183 uses (fullwidth colon), which Ruff flags as ambiguous. Use ASCII : instead.

Proposed fix
-        # tushare 指数代码格式:
+        # tushare 指数代码格式:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# tushare 指数代码格式
# tushare 指数代码格式:
🧰 Tools
🪛 Ruff (0.15.10)

[warning] 183-183: Comment contains ambiguous (FULLWIDTH COLON). Did you mean : (COLON)?

(RUF003)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data/sources/tushare_source.py` at line 183, The comment string "# tushare
指数代码格式:" contains a fullwidth colon which triggers Ruff RUF003; replace the
fullwidth colon `:` with an ASCII colon `:` so the line reads "# tushare
指数代码格式:" (locate the comment near the tushare index code handling, e.g., the
comment above the index code parsing logic) to resolve the lint warning.

Comment thread plan.md
Comment thread scripts/run_paper_trade.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
plan.md (1)

474-476: Markdown 格式优化:代码块语言标识与表格空行。

静态分析工具提示了几处可选的 Markdown 格式改进:

  • 第 474-476 行和 648-650 行的代码块缺少语言标识符(建议添加 text 或省略围栏改用缩进)
  • 第 481 行的表格前后缺少空行(Markdown 最佳实践建议表格前后各留一空行)

这些改进可提升文档渲染一致性,但不影响实际功能。

✨ 建议的格式改进

代码块添加语言标识:

-```
+```text
 代码 日期 动作 开盘价 买入价 收盘价 卖出价 卖出份额 买入份额 佣金 净盈 收益率% 持仓天数 净值 动作备注

以及:

```diff
-```
+```text
 代码 | 买入日 | 卖出日 | 开盘价 | 买入价 | 收盘价 | 卖出价 | 份额 | 佣金 | 净盈 | 收益率% | 持仓天数 | 净值 | 卖出原因

**表格前后添加空行:**

```diff
 **核心改动**:
+
 | 文件 | 改动 |
 |------|------|
 | `backtest/engine.py` | 新增 `_daily_actions` 暂存 + `_finalize_daily_trade` 日末聚合;`_process_signal` 不再直接 append `_trades` |
+
 | `scripts/run_backtest.py` | TRADE_COLUMNS 重排 + CSV 整数列空值处理(避免 `9000.0`) |

Also applies to: 481-485, 648-650

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plan.md` around lines 474 - 476, The markdown has fenced code blocks without
a language specifier (the block containing the line "代码 日期 动作 开盘价 买入价 收盘价 卖出价
卖出份额 买入份额 佣金 �净盈 收益率% 持仓天数 净值 动作备注" and the similar block with "代码 | 买入日 | 卖出日 |
开盘价 | 买入价 | 收盘价 | 卖出价 | 份额 | 佣金 | 净盈 | 收益率% | 持仓天数 | 净值 | 卖出原因") — add a
language identifier (e.g., ```text) or replace fenced blocks with indented code
blocks; additionally ensure there is a blank line before and after the markdown
table that starts around the "表格" section (the table referenced near the snippet
around the row with 买入日/卖出日) so the table is separated by one empty line above
and below; apply the same fixes to the other occurrences noted (the blocks
around the other ranges of the document).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@plan.md`:
- Around line 474-476: The markdown has fenced code blocks without a language
specifier (the block containing the line "代码 日期 动作 开盘价 买入价 收盘价 卖出价 卖出份额 买入份额 佣金
�净盈 收益率% 持仓天数 净值 动作备注" and the similar block with "代码 | 买入日 | 卖出日 | 开盘价 | 买入价 |
收盘价 | 卖出价 | 份额 | 佣金 | 净盈 | 收益率% | 持仓天数 | 净值 | 卖出原因") — add a language identifier
(e.g., ```text) or replace fenced blocks with indented code blocks; additionally
ensure there is a blank line before and after the markdown table that starts
around the "表格" section (the table referenced near the snippet around the row
with 买入日/卖出日) so the table is separated by one empty line above and below; apply
the same fixes to the other occurrences noted (the blocks around the other
ranges of the document).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 53d12637-8c06-4fb6-9b42-64123ac549e9

📥 Commits

Reviewing files that changed from the base of the PR and between d7c766e and c8db2e9.

📒 Files selected for processing (2)
  • plan.md
  • tests/test_paper_trade_helpers.py
✅ Files skipped from review due to trivial changes (1)
  • tests/test_paper_trade_helpers.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
plan.md (1)

831-831: ⚠️ Potential issue | 🟡 Minor

Footer update date is stale.

Line 831 still shows 最后更新:2026-04-19 while this document contains 2026-04-20 updates.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plan.md` at line 831, Update the footer string "*文档版本:v1.1 | 创建日期:2026-03-17
| 最后更新:2026-04-19*" to reflect the correct last-updated date "最后更新:2026-04-20"
so the document footer matches the content updates.
scripts/run_paper_trade.py (1)

248-250: ⚠️ Potential issue | 🟡 Minor

Canonicalize numeric params before hashing account_id.

parse_params() still preserves 3 and 3.0 as different values, so semantically identical configs can map to different account IDs and silently split --status / --history lookups.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/run_paper_trade.py` around lines 248 - 250, The account_id diverges
because parse_params preserves numeric distinctions like "3" vs "3.0" before
build_account_id; canonicalize numeric param values (e.g., normalize integers
and floats to a consistent representation such as converting numeric-like
strings to numbers and formatting floats consistently or casting numeric floats
with integer value to ints) and use those canonicalized params when calling
build_account_id; update either parse_params to normalize numeric values or
insert a normalization step between parse_params(args.params) and
build_account_id(args.strategy, args.codes, params) so semantically identical
configs produce the same account_id.
🧹 Nitpick comments (1)
api/dependencies.py (1)

24-46: Extract account-id param parsing into a shared helper.

_split_params() is now a second copy of the CLI parsing rules in scripts/run_paper_trade.py::parse_params(). Since both paths feed build_account_id(), any drift here will make the dashboard resolve a different account than the CLI created.

Also applies to: 49-62

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/dependencies.py` around lines 24 - 46, The _split_params implementation
duplicates CLI parsing logic from scripts/run_paper_trade.py::parse_params which
can cause account-id drift; refactor by extracting the parsing logic into a
shared helper (e.g., params.parse_params or utils.parse_params) and have
api.dependencies._split_params and scripts.run_paper_trade.parse_params both
call that single function, ensuring the helper returns the same dict shape used
by build_account_id; remove the duplicate code blocks in api.dependencies and in
the script and update imports to reference the new shared helper name to keep
behavior identical.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@trading/account_id.py`:
- Around line 27-28: The current account-id construction uses sha1 truncation
(digest = hashlib.sha1(...).hexdigest()[:10]) which would break existing records
if you switch to SHA256; instead implement a migration-safe approach: introduce
a versioned id format (e.g., prefix the returned id with a version token like
"v1:"/ "v2:" in the function that currently returns f"{strategy_key}:{digest}"),
and update lookup logic to perform dual-read—first check for the new versioned
key, then fall back to the legacy sha1 key (using the existing digest generation
code) and, if found, write back the record under the new versioned id to migrate
it; keep the old sha1 generation code (digest and strategy_key usage) for reads
until migration completes.

---

Duplicate comments:
In `@plan.md`:
- Line 831: Update the footer string "*文档版本:v1.1 | 创建日期:2026-03-17 |
最后更新:2026-04-19*" to reflect the correct last-updated date "最后更新:2026-04-20" so
the document footer matches the content updates.

In `@scripts/run_paper_trade.py`:
- Around line 248-250: The account_id diverges because parse_params preserves
numeric distinctions like "3" vs "3.0" before build_account_id; canonicalize
numeric param values (e.g., normalize integers and floats to a consistent
representation such as converting numeric-like strings to numbers and formatting
floats consistently or casting numeric floats with integer value to ints) and
use those canonicalized params when calling build_account_id; update either
parse_params to normalize numeric values or insert a normalization step between
parse_params(args.params) and build_account_id(args.strategy, args.codes,
params) so semantically identical configs produce the same account_id.

---

Nitpick comments:
In `@api/dependencies.py`:
- Around line 24-46: The _split_params implementation duplicates CLI parsing
logic from scripts/run_paper_trade.py::parse_params which can cause account-id
drift; refactor by extracting the parsing logic into a shared helper (e.g.,
params.parse_params or utils.parse_params) and have
api.dependencies._split_params and scripts.run_paper_trade.parse_params both
call that single function, ensuring the helper returns the same dict shape used
by build_account_id; remove the duplicate code blocks in api.dependencies and in
the script and update imports to reference the new shared helper name to keep
behavior identical.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 20fe882f-91c7-483d-b3e4-04aaf5160a31

📥 Commits

Reviewing files that changed from the base of the PR and between c8db2e9 and c19523f.

📒 Files selected for processing (17)
  • .gitignore
  • api/dependencies.py
  • api/main.py
  • api/routers/__init__.py
  • api/routers/dashboard.py
  • api/schemas.py
  • data/storage/repository.py
  • frontend/package.json
  • frontend/src/api/dashboard.ts
  • frontend/src/types.ts
  • frontend/src/vite-env.d.ts
  • frontend/tsconfig.json
  • frontend/vite.config.ts
  • plan.md
  • research.md
  • scripts/run_paper_trade.py
  • trading/account_id.py
✅ Files skipped from review due to trivial changes (6)
  • .gitignore
  • frontend/package.json
  • frontend/src/vite-env.d.ts
  • frontend/tsconfig.json
  • api/schemas.py
  • frontend/src/types.ts

Comment thread trading/account_id.py
Comment on lines +27 to +28
digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:10]
return f"{strategy_key}:{digest}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify where build_account_id/account_id is used as an identity key.
rg -nP --type=py -C3 '\bbuild_account_id\s*\(|\baccount_id\b'

# Verify PaperAccount unique-key assumptions in ORM model.
rg -nP --type=py -C5 'class\s+PaperAccount|strategy_name\s*=|unique=True' data/models.py

Repository: xuesongjun/Apex

Length of output: 15636


🏁 Script executed:

# Check for migration logic or version handling of account_id format
rg -nP --type=py 'migrate|version|compatible|account_id.*format' --max-count=20

# Check if account_id is ever used as lookup key (would break with hash change)
rg -nP --type=py 'get_paper_account|by.*account_id|where.*strategy_name' --max-count=20

# Check PaperRepository for account lookup implementation
fd -type f -name '*repository*' | xargs -I {} sh -c 'wc -l {} && head -50 {}'

Repository: xuesongjun/Apex

Length of output: 843


🏁 Script executed:

# View the get_paper_account implementation
sed -n '390,420p' data/storage/repository.py

# Check if there's any index or constraint on strategy_name as unique key
rg -nP 'strategy_name.*unique|unique.*strategy_name' --type=py

# See how accounts are inserted (to understand if old format is migrated)
rg -nP 'def.*paper_account|insert.*account|add.*account' data/storage/repository.py -A 5

Repository: xuesongjun/Apex

Length of output: 2427


🏁 Script executed:

# Check if there's any migration logic or account lookup by params
rg -nP 'migrate|upgrade|migration' data/ scripts/ --type=py

# Check if paper_account records are queried/updated elsewhere
rg -nP 'PaperAccountORM|paper_account.*query|update_paper' data/storage/repository.py -A 3

# Verify the critical flow: new run would generate new hash, old account not found
grep -n "get_paper_account\|create_paper_account" trading/paper_account.py data/storage/repository.py scripts/run_paper_trade.py

Repository: xuesongjun/Apex

Length of output: 2093


🏁 Script executed:

# Check for any migration tools or schema versioning
fd -type f -name '*migrat*' -o -name 'alembic*' -o -name 'schema*'

# Check if there's any deprecation or versioning of the hash format
rg -nP 'hash|digest|version.*account|account.*version' trading/account_id.py

# Final check: what happens on second run with same params - does it reuse or create new?
cat -n trading/paper_account.py | sed -n '80,100p'

Repository: xuesongjun/Apex

Length of output: 1428


🏁 Script executed:

# Verify there's no version-aware lookup or fallback mechanism
rg -nP 'strategy_name.*hash|hash.*version|backward.*compat' trading/ data/ api/ --type=py

# Check if account_id is ever parsed/split for version info
rg -nP 'split.*:.*\[|parse.*account' trading/ api/ --type=py

Repository: xuesongjun/Apex

Length of output: 202


Account ID hash truncation risks data loss if changed without migration strategy.

The 40-bit digest (SHA1[:10]) does present collision risk for account isolation. However, the suggested SHA256 upgrade breaks backward compatibility: existing accounts are keyed by strategy_name="{strategy_key}:{old_digest}" in the database. Changing the hash function orphans all existing accounts, causing duplicate account creation on next run with same parameters.

A safe fix requires either:

  1. Dual-read migration: Support both old and new hash formats during lookup, then gradually migrate records.
  2. Accept current risk: If account count stays below ~1M combinations, collision probability remains low by birthday paradox. Document this assumption.
  3. Separate account ID registry: Decouple strategy identity from hash, using a stable integer or UUID primary key instead.

Do not apply the suggested diff without a migration path.

🧰 Tools
🪛 Ruff (0.15.10)

[error] 27-27: Probable use of insecure hash functions in hashlib: sha1

(S324)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@trading/account_id.py` around lines 27 - 28, The current account-id
construction uses sha1 truncation (digest = hashlib.sha1(...).hexdigest()[:10])
which would break existing records if you switch to SHA256; instead implement a
migration-safe approach: introduce a versioned id format (e.g., prefix the
returned id with a version token like "v1:"/ "v2:" in the function that
currently returns f"{strategy_key}:{digest}"), and update lookup logic to
perform dual-read—first check for the new versioned key, then fall back to the
legacy sha1 key (using the existing digest generation code) and, if found, write
back the record under the new versioned id to migrate it; keep the old sha1
generation code (digest and strategy_key usage) for reads until migration
completes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant