feat: phase 1 — data downloader, backtrader runner, SMA strategy

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Yuxuan Yan
2026-06-07 09:21:16 +08:00
parent 338c912029
commit fd86190e9a
19 changed files with 456 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.py[cod]
.pytest_cache/
*.egg-info/
.venv/
venv/
+16
View File
@@ -0,0 +1,16 @@
# Chinese Equity Quant Research Framework
## Architecture
- backtrader is the backtesting engine — never reimplement backtest logic
- akshare primary data source, baostock secondary fallback
- Daily frequency only (Phase 1)
## Key Commands
- `python3 run_example.py` — smoke test
- `python3 -m pytest tests/ -v` — run tests
- `pip install -r requirements.txt` — install deps
## Code Standards
- Type hints on public functions
- Google-style docstrings
- 4-space indentation for Python
+24
View File
@@ -1 +1,25 @@
# Chinese Equity Quant Research Framework # Chinese Equity Quant Research Framework
A modular Chinese A-share quant research framework built on
[backtrader](https://www.backtrader.com/) for backtesting, with
akshare (primary) and baostock (fallback) for daily bar data.
## Install
```bash
pip install -r requirements.txt
```
## Quick start
```bash
python3 run_example.py # end-to-end smoke test (SMA crossover on 浦发银行)
python3 -m pytest tests/ -v # run tests
```
## Layout
- `data/` — unified downloader (akshare -> baostock fallback) and data schema
- `backtest/` — config, pandas->backtrader feed adapter, and `BacktestRunner`
- `strategies/` — example `SmaCross` strategy
- `analysis/` — performance reporting (sharpe, drawdown, returns, trades)
View File
+46
View File
@@ -0,0 +1,46 @@
"""Performance analysis and reporting for backtest results."""
from typing import Any
def print_results(results: list, initial_cash: float = 1_000_000.0) -> dict[str, Any]:
"""Print and return key performance metrics from a backtrader run result."""
if not results:
print("No results to report.")
return {}
result = results[0]
report = {}
# Sharpe ratio
sharpe = result.analyzers.sharpe.get_analysis()
report["sharpe"] = sharpe.get("sharperatio", "N/A")
# Drawdown
dd = result.analyzers.drawdown.get_analysis()
report["max_drawdown"] = dd.get("max", {}).get("drawdown", "N/A")
report["max_drawdown_len"] = dd.get("max", {}).get("len", "N/A")
# Returns
rets = result.analyzers.returns.get_analysis()
report["total_return"] = rets.get("rtot", "N/A")
report["avg_return"] = rets.get("ravg", "N/A")
# Trades
trades = result.analyzers.trades.get_analysis()
report["total_trades"] = trades.get("total", {}).get("total", 0)
report["won_trades"] = trades.get("won", {}).get("total", 0)
report["lost_trades"] = trades.get("lost", {}).get("total", 0)
# Print
print("=" * 50)
print("BACKTEST RESULTS")
print("=" * 50)
print(f"Sharpe Ratio: {report['sharpe']}")
print(f"Total Return: {report['total_return']:.4%}" if isinstance(report['total_return'], float) else f"Total Return: {report['total_return']}")
print(f"Max Drawdown: {report['max_drawdown']:.2%}" if isinstance(report['max_drawdown'], float) else f"Max Drawdown: {report['max_drawdown']}")
print(f"Max DD Length: {report['max_drawdown_len']}")
print(f"Total Trades: {report['total_trades']}")
print(f"Won/Lost: {report['won_trades']}/{report['lost_trades']}")
print("=" * 50)
return report
View File
+13
View File
@@ -0,0 +1,13 @@
from dataclasses import dataclass, field
from datetime import date
@dataclass
class BacktestConfig:
symbols: list[str] = field(default_factory=lambda: ["sh600000"])
start_date: str = "2023-01-01"
end_date: str = "2024-12-31"
initial_cash: float = 1_000_000.0
commission: float = 0.0003 # 0.03% for Chinese A-shares
stamp_duty: float = 0.001 # 0.1% stamp duty on sells only (handled in strategy)
adjust: str = "qfq"
+12
View File
@@ -0,0 +1,12 @@
"""Convert pandas DataFrames to backtrader data feeds."""
import backtrader as bt
import pandas as pd
def df_to_bt_feed(df: pd.DataFrame) -> bt.feeds.PandasData:
"""Convert a standardized OHLCV DataFrame to a backtrader PandasData feed."""
df = df.copy()
df["date"] = pd.to_datetime(df["date"])
df = df.set_index("date")
df = df[["open", "high", "low", "close", "volume"]]
return bt.feeds.PandasData(dataname=df)
+68
View File
@@ -0,0 +1,68 @@
"""BacktestRunner: orchestrates data loading, cerebro setup, and execution."""
import logging
import backtrader as bt
from typing import Optional
from backtest.config import BacktestConfig
from backtest.feed import df_to_bt_feed
from data.downloader import download_daily
logger = logging.getLogger(__name__)
class BacktestRunner:
"""Run backtrader backtests with Chinese equity data."""
def __init__(self, config: BacktestConfig):
self.config = config
self.cerebro = bt.Cerebro()
self._results: Optional[list] = None
def add_data(self, symbol: str) -> None:
"""Download data for a symbol and add to cerebro as a feed."""
df = download_daily(
symbol=symbol,
start=self.config.start_date,
end=self.config.end_date,
adjust=self.config.adjust,
)
feed = df_to_bt_feed(df)
self.cerebro.adddata(feed, name=symbol)
logger.info(f"Added {symbol}: {len(df)} bars")
def add_strategy(self, strategy_cls, **kwargs) -> None:
"""Add a strategy class to cerebro."""
self.cerebro.addstrategy(strategy_cls, **kwargs)
def setup(self, strategy_cls, strategy_kwargs: Optional[dict] = None) -> None:
"""Full setup: load data for all symbols, configure cerebro, add strategy."""
# Load data for all symbols
for sym in self.config.symbols:
self.add_data(sym)
# Configure cerebro
self.cerebro.broker.setcash(self.config.initial_cash)
self.cerebro.broker.setcommission(commission=self.config.commission)
# Add analyzers
self.cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name="sharpe", riskfreerate=0.02)
self.cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown")
self.cerebro.addanalyzer(bt.analyzers.Returns, _name="returns")
self.cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trades")
# Add strategy
strategy_kwargs = strategy_kwargs or {}
self.cerebro.addstrategy(strategy_cls, **strategy_kwargs)
def run(self, strategy_cls, strategy_kwargs: Optional[dict] = None) -> list:
"""Setup and run the backtest. Returns cerebro run results."""
self.setup(strategy_cls, strategy_kwargs)
start_val = self.cerebro.broker.getvalue()
logger.info(f"Starting portfolio value: {start_val:,.2f}")
self._results = self.cerebro.run()
end_val = self.cerebro.broker.getvalue()
logger.info(f"Ending portfolio value: {end_val:,.2f}")
return self._results
def get_results(self) -> Optional[list]:
return self._results
View File
+134
View File
@@ -0,0 +1,134 @@
"""Unified data downloader: akshare primary, baostock fallback."""
import logging
from datetime import date, datetime
from typing import Optional
import pandas as pd
import akshare as ak
import baostock as bs
logger = logging.getLogger(__name__)
BAOSTOCK_FREQ_MAP = {"d": "d", "w": "w", "m": "m"} # baostock only supports daily
def _download_akshare(symbol: str, start: str, end: str, adjust: str = "qfq") -> Optional[pd.DataFrame]:
"""Download daily bars from akshare. Returns DataFrame with OHLCV columns."""
try:
# symbol format: 'sh600000' in akshare stock_zh_a_hist expects raw code like '600000'
# strip exchange prefix for akshare
raw = symbol.replace("sh", "").replace("sz", "")
df = ak.stock_zh_a_hist(
symbol=raw,
period="daily",
start_date=start,
end_date=end,
adjust=adjust,
)
if df is None or df.empty:
return None
# Standardize columns
col_map = {
"日期": "date",
"开盘": "open",
"最高": "high",
"最低": "low",
"收盘": "close",
"成交量": "volume",
"成交额": "amount",
}
df = df.rename(columns=col_map)
df["symbol"] = symbol
return df[["symbol", "date", "open", "high", "low", "close", "volume", "amount"]]
except Exception as e:
logger.warning(f"akshare download failed for {symbol}: {e}")
return None
def _download_baostock(symbol: str, start: str, end: str, frequency: str = "d") -> Optional[pd.DataFrame]:
"""Download daily bars from baostock as fallback."""
try:
bs.login()
# baostock format: sh.600000
code = f"{symbol[:2]}.{symbol[2:]}"
rs = bs.query_history_k_data_plus(
code=code,
fields="date,open,high,low,close,volume,amount",
start_date=start,
end_date=end,
frequency=frequency,
adjustflag="2", # qfq
)
if rs.error_code != "0":
logger.warning(f"baostock error for {symbol}: {rs.error_msg}")
return None
data_list = []
while rs.next():
data_list.append(rs.get_row_data())
bs.logout()
if not data_list:
return None
df = pd.DataFrame(data_list, columns=["date", "open", "high", "low", "close", "volume", "amount"])
df[["open", "high", "low", "close", "volume", "amount"]] = df[
["open", "high", "low", "close", "volume", "amount"]
].astype(float)
df["symbol"] = symbol
return df[["symbol", "date", "open", "high", "low", "close", "volume", "amount"]]
except Exception as e:
logger.warning(f"baostock download failed for {symbol}: {e}")
try:
bs.logout()
except Exception:
pass
return None
def download_daily(
symbol: str,
start: str,
end: str,
adjust: str = "qfq",
source: str = "auto",
) -> pd.DataFrame:
"""
Download daily OHLCV data. Tries akshare first, falls back to baostock.
Args:
symbol: Stock symbol like 'sh600000' or 'sz000001'
start: Start date 'YYYY-MM-DD'
end: End date 'YYYY-MM-DD'
adjust: 'qfq' (forward-adjusted), 'hfq' (backward), '' (none)
source: 'auto' (akshare then baostock fallback), 'akshare' only,
or 'baostock' only
Returns:
DataFrame with columns: symbol, date, open, high, low, close, volume, amount
"""
df = None
if source in ("akshare", "auto"):
df = _download_akshare(symbol, start, end, adjust)
if df is None and source in ("baostock", "auto"):
df = _download_baostock(symbol, start, end)
if df is None or df.empty:
raise RuntimeError(f"Failed to download data for {symbol} from {start} to {end}")
df["date"] = pd.to_datetime(df["date"])
df = df.sort_values("date").reset_index(drop=True)
return df
def download_batch(
symbols: list[str],
start: str,
end: str,
adjust: str = "qfq",
) -> dict[str, pd.DataFrame]:
"""Download daily data for multiple symbols. Returns {symbol: DataFrame}."""
results = {}
for sym in symbols:
try:
results[sym] = download_daily(sym, start, end, adjust)
logger.info(f"Downloaded {sym}: {len(results[sym])} bars")
except Exception as e:
logger.error(f"Failed {sym}: {e}")
return results
+44
View File
@@ -0,0 +1,44 @@
from dataclasses import dataclass, field
from datetime import date
from typing import Optional
import pandas as pd
@dataclass
class DailyBar:
"""Single daily bar for one stock."""
symbol: str
date: date
open: float
high: float
low: float
close: float
volume: float
amount: float # turnover in yuan
@classmethod
def from_dataframe(cls, df: pd.DataFrame, symbol_col: str = "symbol") -> list["DailyBar"]:
"""Convert akshare/baostock DataFrame to list of DailyBar."""
bars = []
for _, row in df.iterrows():
bars.append(cls(
symbol=row.get(symbol_col, ""),
date=pd.Timestamp(row["date"]).date(),
open=float(row["open"]),
high=float(row["high"]),
low=float(row["low"]),
close=float(row["close"]),
volume=float(row["volume"]),
amount=float(row.get("amount", 0)),
))
return bars
def to_series(self) -> dict:
return {
"date": self.date,
"open": self.open,
"high": self.high,
"low": self.low,
"close": self.close,
"volume": self.volume,
}
+5
View File
@@ -0,0 +1,5 @@
backtrader>=1.9.76.123
akshare>=1.14.0
baostock>=0.8.8
pandas>=2.0.0
pytest>=7.0.0
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""End-to-end smoke test: download data -> backtest SMA crossover -> print results."""
import logging
from backtest.config import BacktestConfig
from backtest.runner import BacktestRunner
from strategies.base import SmaCross
from analysis.report import print_results
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
def main():
config = BacktestConfig(
symbols=["sh600000"], # 浦发银行
start_date="2023-01-01",
end_date="2024-12-31",
initial_cash=1_000_000,
)
runner = BacktestRunner(config)
results = runner.run(SmaCross)
print_results(results, config.initial_cash)
if __name__ == "__main__":
main()
View File
+23
View File
@@ -0,0 +1,23 @@
"""Base strategy and example SMA crossover for Chinese equities."""
import backtrader as bt
class SmaCross(bt.Strategy):
"""Simple SMA crossover strategy: buy when fast crosses above slow, sell when below."""
params = (
("fast", 10),
("slow", 30),
)
def __init__(self):
self.fast_ma = bt.indicators.SMA(self.data.close, period=self.params.fast)
self.slow_ma = bt.indicators.SMA(self.data.close, period=self.params.slow)
self.crossover = bt.indicators.CrossOver(self.fast_ma, self.slow_ma)
def next(self):
if not self.position:
if self.crossover > 0: # fast crosses above slow
self.buy()
elif self.crossover < 0: # fast crosses below slow
self.close()
View File
+18
View File
@@ -0,0 +1,18 @@
import pytest
from data.downloader import download_daily
def test_download_single_stock():
"""Smoke test: download data for 浦发银行 for a short window."""
df = download_daily("sh600000", "2024-01-01", "2024-01-31")
assert df is not None
assert len(df) > 0
assert list(df.columns) == ["symbol", "date", "open", "high", "low", "close", "volume", "amount"]
assert df["close"].notna().all()
def test_download_baostock_fallback():
"""Test baostock works as secondary source."""
df = download_daily("sz000001", "2024-06-01", "2024-06-15", source="baostock")
assert df is not None
assert len(df) > 0
+21
View File
@@ -0,0 +1,21 @@
import pytest
from backtest.config import BacktestConfig
from backtest.runner import BacktestRunner
from strategies.base import SmaCross
def test_backtest_smoke():
"""Smoke test: run a minimal backtest and check results exist."""
config = BacktestConfig(
symbols=["sh600000"],
start_date="2024-01-01",
end_date="2024-03-31",
initial_cash=100_000,
)
runner = BacktestRunner(config)
results = runner.run(SmaCross)
assert results is not None
assert len(results) == 1
# Check analyzers exist
sharpe = results[0].analyzers.sharpe.get_analysis()
assert "sharperatio" in sharpe