Bug fixes - CompositeEngine no longer misroutes bare Chinese-futures codes like RB2410 to GlobalFuturesEngine. _is_china_futures moved into a shared _market_hooks module with a case-normalized product table and a non-CN exchange guard. 9 new regression cases (RB2410 / rb2410 / RB2410.SHFE / CL.NYMEX / M2412.CBOT / CF2412.ICE / AU2412.COMEX / FG2412.EUREX / bare CN-collision still routes Chinese). - Session FTS5 indexes now persist timestamps so cross-session search can sort by date. Same path also fixes a re-upsert that was wall-clocking every session's started_at on title-rename. - Vite dev-mode proxy gained the missing /alpha entry; AlphaZoo page resolves on npm run dev. Cleanup - Deleted swarm/api_models.py + swarm/mailbox.py + SwarmDashboard.tsx (dead modules / orphaned UI after swarm-via-agent flow). - Deleted SessionService.resume_attempt + get_attempts + get_attempt + SessionStore.list_attempts + Attempt.mark_waiting_user chain (half-baked human-in-the-loop from initial commit). - Deleted ChatLLM.achat (async sibling never called) and _extract_balanced_json (only-tested helper). - Deleted american_exercise_value in options_portfolio.py (driver inlines bs_price logic). - Deleted _compute_ic_series_reference in factor_analysis_core.py (kept-as-reference, no callers). - Collapsed MEMORY_TYPES duplication between cli.py and memory.persistent; restored set-equality invariant assert. - Deleted runSwarm (~197 LOC), swarmDash state, swarmCancelRef write-only ref (orphaned by new agent-driven swarm flow). - Dropped 76 unused 'vw = vwap(...)' boilerplate lines across gtja191 alphas + their now-unused vwap imports. - Cleared 11 F401 unused-import sites in tools/, agent/, providers/. Test infrastructure - test_e2e_harness_v2.py (real-LLM e2e) gated behind VIBE_TRADING_RUN_LIVE_E2E so CI no longer flips shape based on env-key presence. CI workflow now has explicit --ignore. - test_auto_compact_with_long_history: monkeypatch TOKEN_THRESHOLD so the test deterministically triggers compact instead of relying on unrelated LLM behaviour. - test_compact_tool_explicit: assertion corrected — compact tool is special-cased in loop._process_tool_calls and does NOT emit tool_call events. Assert on the 'compact' event instead. - TestSwarmE2E: per-test @pytest.mark.timeout overrides matching the internal 300s poll deadlines; cancel test now polls for terminal state instead of fixed sleep(3). Tooling - ruff per-file-ignores for factors/zoo/** (3783 → 0 F401 noise); F841 stays active in zoo to catch real formula bugs. - frontend tsconfig: noUnusedLocals + noUnusedParameters as regression guards. - highlight.js hoisted to direct dependency (was transitive via rehype-highlight). - Vite proxy: dropped unused /run, /skills, /health, /api, /system; added /alpha. Annotations - direction parameter renamed to _direction in 6 calc_commission signatures (reserved for future borrow-fee asymmetry). - _MARGIN_PER_CONTRACT in global_futures.py annotated as reference data. - Backtest engines' _calc_pct_change variants cross-reference each other in docstrings. Docs - 2026-05-18 news entry added to README.md / README_zh.md / README_ja.md / README_ko.md / README_ar.md; 2026-05-15 entry rolled into the Earlier news <details>. - Contributors heading: v0.1.7 cycle → v0.1.8 cycle in all 5 READMEs. 126 files changed, +363 / -1276.
156 lines
5.4 KiB
Python
156 lines
5.4 KiB
Python
"""A-share (China mainland) backtest engine.
|
|
|
|
Market rules:
|
|
- T+1: cannot sell shares bought today
|
|
- No short selling for retail investors
|
|
- Price limits: ±10% main board, ±20% ChiNext/STAR, ±5% ST
|
|
- Minimum lot: 100 shares (odd lots can only be sold, not bought)
|
|
- Commission: ¥5 minimum, 0.025% bilateral
|
|
- Stamp tax: 0.05% sell-side only
|
|
- Transfer fee: 0.001% bilateral
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pandas as pd
|
|
|
|
from backtest.engines.base import BaseEngine
|
|
|
|
|
|
class ChinaAEngine(BaseEngine):
|
|
"""A-share market engine.
|
|
|
|
Config keys:
|
|
- commission_rate: default 0.00025 (万2.5)
|
|
- commission_min: default 5.0 (RMB)
|
|
- stamp_tax: default 0.0005 (万5, sell-only)
|
|
- transfer_fee: default 0.00001 (万0.1)
|
|
- slippage: default 0.001
|
|
"""
|
|
|
|
def __init__(self, config: dict):
|
|
config = {**config, "leverage": 1.0} # A-shares: no leverage
|
|
super().__init__(config)
|
|
self.commission_rate: float = config.get("commission_rate", 0.00025)
|
|
self.commission_min: float = config.get("commission_min", 5.0)
|
|
self.stamp_tax: float = config.get("stamp_tax", 0.0005)
|
|
self.transfer_fee: float = config.get("transfer_fee", 0.00001)
|
|
self.slippage_rate: float = config.get("slippage", 0.001)
|
|
|
|
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
|
|
"""A-share execution rules.
|
|
|
|
Args:
|
|
symbol: Stock code (e.g. 000001.SZ).
|
|
direction: 1 (buy), -1 (short — always blocked), 0 (sell/close).
|
|
bar: Current bar (needs 'close', 'pre_close' or 'pct_chg').
|
|
|
|
Returns:
|
|
True if the trade is allowed.
|
|
"""
|
|
# 1. No short selling
|
|
if direction == -1:
|
|
return False
|
|
|
|
# 2. T+1: can't sell shares bought today
|
|
if direction == 0:
|
|
pos = self.positions.get(symbol)
|
|
if pos is not None:
|
|
bar_date = _bar_date(bar)
|
|
entry_date = pos.entry_time.date() if hasattr(pos.entry_time, "date") else None
|
|
if bar_date is not None and entry_date is not None and bar_date == entry_date:
|
|
return False
|
|
|
|
# 3. Price limits
|
|
pct_chg = _calc_pct_change(bar)
|
|
if pct_chg is not None:
|
|
limit = _price_limit(symbol)
|
|
if direction == 1 and pct_chg >= limit - 0.001:
|
|
return False # limit-up: can't buy
|
|
if direction == 0 and pct_chg <= -limit + 0.001:
|
|
return False # limit-down: can't sell
|
|
|
|
return True
|
|
|
|
def round_size(self, raw_size: float, price: float) -> float:
|
|
"""Round down to 100-share lots."""
|
|
return max(int(raw_size / 100) * 100, 0)
|
|
|
|
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
|
|
"""A-share fee structure: commission + stamp tax (sell) + transfer fee.
|
|
|
|
``_direction`` is unused today — reserved for future asymmetric
|
|
long/short fee schedules (margin trading, securities lending).
|
|
"""
|
|
notional = size * price
|
|
# Commission: 万2.5, min ¥5
|
|
comm = max(notional * self.commission_rate, self.commission_min)
|
|
# Transfer fee: 万0.1 bilateral
|
|
comm += notional * self.transfer_fee
|
|
# Stamp tax: 万5 sell-only
|
|
if not is_open:
|
|
comm += notional * self.stamp_tax
|
|
return comm
|
|
|
|
def apply_slippage(self, price: float, direction: int) -> float:
|
|
"""A-share slippage (relatively small due to tick size)."""
|
|
return price * (1 + direction * self.slippage_rate)
|
|
|
|
|
|
# ── Helpers ──
|
|
|
|
|
|
def _bar_date(bar: pd.Series):
|
|
"""Extract date from bar, handling various column names."""
|
|
for col in ("trade_date", "date"):
|
|
if col in bar.index:
|
|
val = bar[col]
|
|
if hasattr(val, "date"):
|
|
return val.date()
|
|
try:
|
|
return pd.Timestamp(val).date()
|
|
except Exception:
|
|
pass
|
|
# Fall back to bar name (index timestamp)
|
|
if hasattr(bar, "name") and hasattr(bar.name, "date"):
|
|
return bar.name.date()
|
|
return None
|
|
|
|
|
|
# Note: china_futures and global_futures have variants that prioritise
|
|
# settle/pre_settle (futures-native); see those modules for the
|
|
# futures-specific logic.
|
|
def _calc_pct_change(bar: pd.Series):
|
|
"""Calculate price change percentage from bar data."""
|
|
if "pct_chg" in bar.index:
|
|
val = bar["pct_chg"]
|
|
if pd.notna(val):
|
|
return float(val) / 100.0 # tushare pct_chg is in percentage points
|
|
|
|
close = bar.get("close")
|
|
pre_close = bar.get("pre_close")
|
|
if close is not None and pre_close is not None and pre_close > 0:
|
|
return (float(close) - float(pre_close)) / float(pre_close)
|
|
return None
|
|
|
|
|
|
def _price_limit(symbol: str) -> float:
|
|
"""Determine price limit based on board.
|
|
|
|
Args:
|
|
symbol: Stock code (e.g. 300001.SZ, 688001.SH, 000001.SZ).
|
|
|
|
Returns:
|
|
Limit as fraction (0.10, 0.20, or 0.05).
|
|
"""
|
|
code = symbol.split(".")[0] if "." in symbol else symbol
|
|
# ChiNext (300xxx) / STAR (688xxx): ±20%
|
|
if code.startswith("300") or code.startswith("688"):
|
|
return 0.20
|
|
# ST stocks: ±5% (heuristic: can't fully detect from code alone)
|
|
# Beijing exchange (8xxxxx): ±30% — simplified to 0.30
|
|
if code.startswith("8") and len(code) == 6:
|
|
return 0.30
|
|
# Main board: ±10%
|
|
return 0.10
|