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
|
# 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