From fd86190e9afb78ee5d2c6e88856fa72e12915969 Mon Sep 17 00:00:00 2001 From: Yuxuan Yan Date: Sun, 7 Jun 2026 09:21:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20phase=201=20=E2=80=94=20data=20download?= =?UTF-8?q?er,=20backtrader=20runner,=20SMA=20strategy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .gitignore | 6 ++ CLAUDE.md | 16 +++++ README.md | 24 +++++++ analysis/__init__.py | 0 analysis/report.py | 46 ++++++++++++++ backtest/__init__.py | 0 backtest/config.py | 13 ++++ backtest/feed.py | 12 ++++ backtest/runner.py | 68 ++++++++++++++++++++ data/__init__.py | 0 data/downloader.py | 134 +++++++++++++++++++++++++++++++++++++++ data/schema.py | 44 +++++++++++++ requirements.txt | 5 ++ run_example.py | 26 ++++++++ strategies/__init__.py | 0 strategies/base.py | 23 +++++++ tests/__init__.py | 0 tests/test_downloader.py | 18 ++++++ tests/test_runner.py | 21 ++++++ 19 files changed, 456 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 analysis/__init__.py create mode 100644 analysis/report.py create mode 100644 backtest/__init__.py create mode 100644 backtest/config.py create mode 100644 backtest/feed.py create mode 100644 backtest/runner.py create mode 100644 data/__init__.py create mode 100644 data/downloader.py create mode 100644 data/schema.py create mode 100644 requirements.txt create mode 100644 run_example.py create mode 100644 strategies/__init__.py create mode 100644 strategies/base.py create mode 100644 tests/__init__.py create mode 100644 tests/test_downloader.py create mode 100644 tests/test_runner.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a4864c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +*.egg-info/ +.venv/ +venv/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9a2d5f4 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index be8b72a..70f6fd4 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/analysis/__init__.py b/analysis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analysis/report.py b/analysis/report.py new file mode 100644 index 0000000..d3106b0 --- /dev/null +++ b/analysis/report.py @@ -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 diff --git a/backtest/__init__.py b/backtest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backtest/config.py b/backtest/config.py new file mode 100644 index 0000000..30d35c1 --- /dev/null +++ b/backtest/config.py @@ -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" diff --git a/backtest/feed.py b/backtest/feed.py new file mode 100644 index 0000000..a19e0ee --- /dev/null +++ b/backtest/feed.py @@ -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) diff --git a/backtest/runner.py b/backtest/runner.py new file mode 100644 index 0000000..fc5704e --- /dev/null +++ b/backtest/runner.py @@ -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 diff --git a/data/__init__.py b/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/data/downloader.py b/data/downloader.py new file mode 100644 index 0000000..9bfca3f --- /dev/null +++ b/data/downloader.py @@ -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 diff --git a/data/schema.py b/data/schema.py new file mode 100644 index 0000000..6da34da --- /dev/null +++ b/data/schema.py @@ -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, + } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b01bb2b --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run_example.py b/run_example.py new file mode 100644 index 0000000..dc6bfb9 --- /dev/null +++ b/run_example.py @@ -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() diff --git a/strategies/__init__.py b/strategies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/strategies/base.py b/strategies/base.py new file mode 100644 index 0000000..c158bfe --- /dev/null +++ b/strategies/base.py @@ -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() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_downloader.py b/tests/test_downloader.py new file mode 100644 index 0000000..77230a3 --- /dev/null +++ b/tests/test_downloader.py @@ -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 diff --git a/tests/test_runner.py b/tests/test_runner.py new file mode 100644 index 0000000..e55bf29 --- /dev/null +++ b/tests/test_runner.py @@ -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