feat: phase 1 — data downloader, backtrader runner, SMA strategy
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
@@ -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
|
||||
@@ -1 +1,25 @@
|
||||
# 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)
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user