# Chinese Equity Quant Research Framework A modular Chinese A-share quant research framework. Daily frequency only (Phase 1). It is a **decoupled, file-based pipeline**: each phase reads parquet and writes parquet, so phases run, cache, and inspect independently. ``` baostock (primary) one weight series akshare (fallback) interpreted as a │ portfolio ▼ ▲ ┌──────────────┐ ┌───────────────┐ ┌───────────────┐ │ │ DATA │ │ ALPHA │ │ COMBO │ ┌────┴─────┐ │ download │─────▶│ compute │─────▶│ combine │ │ EVAL │ │ daily bars │ │ signal→weights│ │ merge alphas │ │ score it │ └──────┬───────┘ └───────┬───────┘ └───────┬───────┘ └────┬─────┘ │ │ │ │ ▼ ▼ ▼ │ data/daily_bars/ alphas/*.pq combos/*.pq │ {universe}/ (ALPHA_COLUMNS) (COMBO_COLUMNS) │ month=YYYY-MM/*.pq │ │ (DATA_COLUMNS) │ │ └──────── price ───────┴───────────────────────────────────────┘ │ ▼ ┌──────────────┐ ┌──────────────┐ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │ PORTFOLIO │ │ SIMULATE │ PAPER TRADING │ construct │──▶│ fills + costs│ │ forward / live │ TODO │ positions │ │ + P&L │ execution └──────┬───────┘ └──────────────┘ └ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ▼ portfolio/*.pq (POSITION_COLUMNS) Each phase reads parquet and writes parquet — run, cache, and inspect independently. The only interface between phases is the parquet schema. Solid boxes are implemented; dashed boxes are on the roadmap (see TODO below). ``` Data comes from **baostock (primary)** with **akshare (fallback)**. ## Install The env is managed with [uv](https://docs.astral.sh/uv/). `uv sync` builds `.venv` from `pyproject.toml` + `uv.lock`; prefix commands with `uv run` (no manual activation needed). ```bash uv sync ``` Backtrader is an optional dependency and is **not used by the current pipeline**. Install it only for future experiments or adapter work: ```bash uv sync --extra backtrader ``` ## Quick start ```bash # 1. Download daily bars for a few symbols (writes a month-partitioned dataset). uv run python cli.py data download \ --universe sh600000,sz000001,sh600519 \ --start-date 2024-01-01 --end-date 2024-03-31 \ --output-dir data/daily_bars # 2. Compute an alpha (position weights) from that data. # --data-path is the dataset DIRECTORY ({output-dir}/{universe}). uv run python cli.py alpha reversal \ --data-path "data/daily_bars/sh600000,sz000001,sh600519" # 3. Evaluate it (return / Sharpe / turnover / drawdown). uv run python cli.py alpha eval \ --alpha-path alphas/reversal_5d.pq \ --data-path "data/daily_bars/sh600000,sz000001,sh600519" # 4. Build tradable integer positions from alpha or combo weights. uv run python cli.py portfolio build \ --weights-path alphas/reversal_5d.pq \ --data-path "data/daily_bars/sh600000,sz000001,sh600519" \ --booksize 1000000 --portfolio-name reversal_port # 5. Simulate next-open execution with A-share constraints, costs, and slippage. uv run python cli.py portfolio simulate \ --positions-path portfolio/reversal_port.pq \ --data-path "data/daily_bars/sh600000,sz000001,sh600519" \ --constraint suspension --constraint price_limit --constraint volume_cap \ --cost-bps 5 --slippage-bps 5 # 6. Evaluate the constructed target weights as a continuous research portfolio. uv run python cli.py portfolio eval \ --positions-path portfolio/reversal_port.pq \ --data-path "data/daily_bars/sh600000,sz000001,sh600519" # Tests uv run python -m pytest tests/ -v # alpha/portfolio tests are network-free; downloader tests hit the network ``` ## CLI reference All commands are subcommands of `uv run python cli.py`. Add `--help` to any of them. ### `data download` — fetch daily bars → partitioned parquet dataset | Option | Default | Description | | --- | --- | --- | | `--universe` | `csi500` | `hs300`, `csi500`, `all` (~5000 A-shares), a file path (one symbol per line), or comma-separated symbols (`sh600000,sz000001`) | | `--start-date` | `2017-01-01` | `YYYY-MM-DD` | | `--end-date` | today | `YYYY-MM-DD` | | `--output-dir` | `data/daily_bars` | Root for the dataset directory | | `--symbols` | `0` | Max symbols to download (`0` = all) | | `--chunk-size` | `300` | Symbols per durability flush (each flush appends `.pq` files) | | `--adjust` | `qfq` | Price adjustment: `qfq` (forward), `hfq` (backward), `none` | Writes a **Hive-partitioned dataset** at `{output_dir}/{universe}/month=YYYY-MM/*.pq` (one partition per calendar month). The `{universe}` directory is rebuilt from scratch on each run. Downloads stream under a single baostock session and flush every `--chunk-size` symbols, so memory stays bounded and a crash keeps the partitions already written. Pass the **dataset directory** (`{output_dir}/{universe}`) as `--data-path` to later phases — `pd.read_parquet` reads the whole partitioned set. Symbols use the internal `sh600000` / `sz000001` form (exchange prefix + code). ### `derived` — daily custom/derived data Derived data is daily-only v1 research data keyed by `symbol_id,date`, with one or more numeric value columns. It can come from user CSV/parquet files or Python plugins, and is written as a single parquet file at `derived/{name}.pq`. The validator normalizes `date` to the trading day, requires unique `symbol_id,date` keys, rejects duplicate columns, and rejects non-numeric value columns. Alpha computation consumes derived data through the existing `--feature-path` flag. ```bash # Validate a user file without writing output. uv run python cli.py derived validate --input-path vendor_factor.csv # Ingest CSV/parquet into the canonical derived/ layout. uv run python cli.py derived ingest \ --input-path vendor_factor.csv \ --derived-name vendor_factor # List built-in and external derived-data plugin types. uv run python cli.py derived list uv run python cli.py derived list --derived-module path/to/my_derived.py # Compute a derived file from daily and/or minute inputs. uv run python cli.py derived compute \ --minute-path data/minute_bars/sh600000 \ --daily-path data/daily_bars/sh600000 \ --derived-type minute_daily_summary \ --derived-name minute_summary # Join derived columns into a feature-aware alpha. uv run python cli.py alpha compute \ --data-path data/daily_bars/sh600000 \ --feature-path derived/minute_summary.pq \ --alpha-type my_feature_aware_alpha \ --alpha-name my_run ``` For compatibility, `feature list` and `feature compute` remain available and delegate to the same derived-data registry. Existing `features/*.pq` files are still valid `--feature-path` inputs when they satisfy the daily numeric contract. ### `alpha list` — show registered alpha types ```bash uv run python cli.py alpha list uv run python cli.py alpha list --alpha-module path/to/my_alpha.py # include an external alpha ``` ### `alpha compute` — alpha class → weights parquet | Option | Default | Description | | --- | --- | --- | | `--data-path` | (required) | Data parquet from `data download` | | `--alpha-name` | (required) | Label stored in the `alpha_name` column / output filename | | `--alpha-type` | (required) | Registry key of the alpha class (see `alpha list`) | | `--output-dir` | `alphas` | Output directory | | `--lookback` | `5` | Lookback days (passed to alphas that accept it) | | `--vol-window` | `20` | Volatility window (passed to alphas that accept it) | | `--feature-path` | — | Daily derived/feature parquet file or dataset to left-join on `symbol_id,date`; repeatable | | `--alpha-module` | — | External module(s) to import first; repeatable. Dotted path or `.py` file | | `--param` | — | Extra constructor param as `name=value`; repeatable | Only the params an alpha's `__init__` accepts are forwarded, so passing extras (e.g. `--vol-window` to a reversal alpha) is harmless. ```bash uv run python cli.py alpha compute \ --data-path .pq \ --alpha-type reversal_vol --alpha-name rv_5_20 \ --lookback 5 --vol-window 20 ``` Shortcuts for the two most common built-ins: ```bash uv run python cli.py alpha reversal --data-path .pq --lookback 5 uv run python cli.py alpha reversal-vol --data-path .pq --lookback 5 --vol-window 20 ``` ### `alpha eval` — score an alpha as a portfolio ```bash uv run python cli.py alpha eval --alpha-path alphas/.pq --data-path .pq ``` Interprets the weights as a portfolio and reports cumulative return, annual Sharpe, annual turnover, max drawdown, and hit rate; also dumps `reports/_eval.json`. There is deliberately **no IC/IR** — alphas are position weights, not return predictors. ### `combo combine` — merge several alphas into one weight | Option | Default | Description | | --- | --- | --- | | `--alpha-paths` | (required) | Comma-separated alpha parquet paths (≥ 2) | | `--combo-name` | (required) | Label stored in the `combo_name` column / output filename | | `--method` | `equal_weight` | Combination method (see `COMBO_METHODS`) | | `--output-dir` | `combos` | Output directory | ```bash uv run python cli.py combo combine \ --alpha-paths alphas/reversal_5d.pq,alphas/reversal_vol_5d_20d.pq \ --combo-name eq --method equal_weight ``` ### `portfolio build` — weights → tradable positions Turns alpha/combo weights into target weights, target yuan exposure, continuous shares, and a lot-valid integer position book under A-share board rules. Non-finite / non-positive construction prices are excluded before target normalization. If a date has zero gross target after filtering, the previous book is carried in `position_shares` and a warning is logged. | Option | Default | Description | | --- | --- | --- | | `--weights-path` | (required) | Alpha or combo parquet with `symbol_id, date, weight` | | `--data-path` | (required) | Data parquet file or partitioned dataset directory | | `--booksize` | (required) | Target gross yuan exposure | | `--portfolio-name` | (required) | Label stored in `portfolio_name` and output filename | | `--price-field` | `close` | Data column used as construction price | | `--output-dir` | `portfolio` | Output directory | ```bash uv run python cli.py portfolio build \ --weights-path combos/eq.pq --data-path data/daily_bars/csi500 \ --booksize 10000000 --portfolio-name eq_10m ``` ### `portfolio simulate` — constructed positions → fills + P&L Executes the constructed `position_shares` book at the next available open, clipping trades through repeatable constraints. It writes `fills/.pq` and `pnl/.pq`. Trading costs use the simplified open-execution proportional cash-cost model documented in [`docs/portfolio_trading_cost_model.md`](docs/portfolio_trading_cost_model.md). | Option | Default | Description | | --- | --- | --- | | `--positions-path` | (required) | Positions parquet from `portfolio build` | | `--data-path` | (required) | Data parquet file or partitioned dataset directory | | `--constraint` | — | Repeatable: `suspension`, `price_limit`, `volume_cap` | | `--cost-bps` | `0.0` | Commission in basis points | | `--slippage-bps` | `0.0` | Slippage in basis points | | `--volume-frac` | `0.10` | Max traded value fraction for `volume_cap` | | `--output-dir` | `.` | Base directory for `fills/` and `pnl/` | ```bash uv run python cli.py portfolio simulate \ --positions-path portfolio/eq_10m.pq --data-path data/daily_bars/csi500 \ --constraint suspension --constraint price_limit --constraint volume_cap \ --cost-bps 5 --slippage-bps 5 ``` ### `portfolio eval` — score constructed target weights ```bash uv run python cli.py portfolio eval \ --positions-path portfolio/eq_10m.pq --data-path data/daily_bars/csi500 ``` Uses `target_weight` for a continuous research view: cumulative return, annual Sharpe, annual turnover, max drawdown, Fitness, hit rate, and date count. There is deliberately **no IC/IR**. Zero-gross carry dates remain flat in this research view even though execution carries `position_shares`. ### `pqcat` — inspect a parquet file, like `cat` Quickly dump any pipeline parquet (a single `.pq` file or a partitioned dataset directory) to stdout, without writing a throwaway script. | Option | Default | Description | | --- | --- | --- | | `-n, --head N` | — | Show only the first `N` rows | | `-t, --tail N` | — | Show only the last `N` rows | | `-c, --columns` | — | Comma-separated subset of columns to show | | `--info` | off | Show shape + dtypes instead of the rows | ```bash uv run python cli.py pqcat alphas/reversal_5d.pq # dump all rows uv run python cli.py pqcat data/daily_bars/csi500 --info # shape + dtypes uv run python cli.py pqcat data/daily_bars/csi500 -n 10 -c symbol_id,date,close,vwap ``` **Standalone command.** `tools/pqcat.py` has no repo dependencies, so it can be run directly. Symlink it onto your `PATH` once and call `pqcat` from anywhere: ```bash ln -sf "$(pwd)/tools/pqcat.py" ~/.local/bin/pqcat # ~/.local/bin must be on PATH pqcat alphas/reversal_5d.pq --info ``` ### `alphaview` — alpha weight(s) alongside bar data for one symbol Join the bar dataset and one or more alpha parquet files on `(symbol, date)` and print them side by side, so you can eyeball how a weight moves against price / volume over a time range. | Option | Default | Description | | --- | --- | --- | | `--data-path` | (required) | Bar dataset dir or parquet file | | `--alpha-path` | (required) | Comma-separated alpha parquet path(s) — each `alpha_name` becomes a column | | `--symbol` | (required) | Symbol id, e.g. `sh600000` | | `--start-date` | — | `YYYY-MM-DD` (inclusive) | | `--end-date` | — | `YYYY-MM-DD` (inclusive) | | `-c, --columns` | `close,volume` | Comma-separated bar columns to show | ```bash uv run python cli.py alphaview \ --data-path data/daily_bars/csi500 \ --alpha-path alphas/reversal_5d.pq,alphas/momentum_5d.pq \ --symbol sh600000 --start-date 2024-01-01 --end-date 2024-03-31 \ -c close,volume,vwap ``` Also standalone like `pqcat` — `ln -sf "$(pwd)/tools/alphaview.py" ~/.local/bin/alphaview`. ## Alphas: the factory / plugin interface An **alpha** is a class that maps a wide close matrix (date index × `symbol_id` columns) to **signed position weights** (positive = long, negative = short). Every alpha subclasses `BaseAlpha` (`pipeline/alpha/base.py`) and is resolved by name through the registry (`pipeline/alpha/registry.py`). ### Minimal alpha ```python import pandas as pd from pipeline.alpha.base import BaseAlpha from pipeline.alpha.registry import register_alpha @register_alpha class MyAlpha(BaseAlpha): name = "my_alpha" # unique registry key (required) def __init__(self, lookback: int = 5): self.lookback = lookback # declare whatever params you need def signal(self, close: pd.DataFrame) -> pd.DataFrame: # Raw score: wide (date × symbol_id), higher = stronger long, NaN where undefined. return -close.pct_change(self.lookback) ``` That is the whole contract: - `name` — the `--alpha-type` key; must be unique. - `signal(close)` — the only required method; return a wide DataFrame. - `to_weights(signal)` — provided by the base class: cross-sectionally z-scores each date into weights (NaN → 0). **Override** it for a different scheme (rank, dollar-neutral caps, etc.). ### Built-in alphas One file per alpha under `pipeline/alpha/library/`: | `--alpha-type` | Params | Description | | --- | --- | --- | | `reversal` | `lookback` | Negative trailing return (oversold scores high) | | `reversal_vol` | `lookback`, `vol_window` | Reversal scaled by trailing volatility | | `momentum` | `lookback` | Positive trailing return | Add a built-in by dropping a module in `pipeline/alpha/library/` and importing it from that package's `__init__.py`. ### Using an alpha written outside this repo Write your `@register_alpha` class in any `.py` file, then register it at runtime with `--alpha-module` (a `.py` path or an importable dotted module). See the worked example in `examples/alphas/mean_reversion.py`: ```bash uv run python cli.py alpha compute \ --alpha-module examples/alphas/mean_reversion.py \ --alpha-type mean_reversion --alpha-name mr20 \ --param window=20 \ --data-path .pq ``` `mean_reversion` declares a `window` param (not `lookback`); `--param window=20` supplies it and the unrelated `--lookback`/`--vol-window` defaults are ignored. ## Parquet schemas The column contracts in `pipeline/common/schema.py` are the only interface between phases (data is stored long/tidy): - **data** (`DATA_COLUMNS`): `symbol_id, symbol_name, date, open, high, low, close, preclose, volume, amount, vwap, turn, pctChg, tradestatus, isST, peTTM, pbMRQ, psTTM, pcfNcfTTM` (`vwap` = `amount / volume` — a **raw**-price daily VWAP, *not* on the adjusted OHLC scale under `qfq`/`hfq`; `turn` is turnover %, `pctChg` daily % change, `tradestatus`/`isST` are 0/1 flags, and `peTTM`/`pbMRQ`/`psTTM`/`pcfNcfTTM` are baostock valuation ratios.) - **derived** (`DERIVED_KEY_COLUMNS` + values): required keys `symbol_id, date`; value columns are user/plugin-defined and must be numeric in v1. - **alpha** (`ALPHA_COLUMNS`): `symbol_id, date, alpha_name, weight` - **combo** (`COMBO_COLUMNS`): `symbol_id, date, combo_name, weight` - **portfolio positions** (`POSITION_COLUMNS`): `symbol_id, date, portfolio_name, target_weight, target_value, target_shares, position_shares, position_value, price` (`target_*` are continuous construction targets; `position_shares` is the discretized + repaired integer book used by execution.) - **fills** (`FILL_COLUMNS`): `symbol_id, date, portfolio_name, prev_shares, target_shares, traded_shares, realized_shares, blocked, trade_cost` (`date` is the execution date, i.e. the next open after the target date.) - **pnl** (`PNL_COLUMNS`): `date, portfolio_name, gross_exposure, net_exposure, pnl, cost, turnover, n_positions` The data phase writes a month-partitioned dataset, so reading the dataset directory yields an extra `month` (`YYYY-MM`) partition column on top of `DATA_COLUMNS`; the alpha phase pivots by name and ignores it. ## Layout - `cli.py` — entry point wiring the file-based phases together - `pipeline/data/` — universe resolution + download → `data/daily_bars/{universe}/month=YYYY-MM/*.pq` - `pipeline/derived/` — daily derived-data ingestion, validation, plugin registry, and built-in derived computations → `derived/*.pq` - `pipeline/alpha/` — `base.py` (`BaseAlpha`), `registry.py` (factory + plugin loader), `library/` (built-in alphas), `compute.py` (`compute_alpha` / `evaluate_alpha`) - `pipeline/features/` — compatibility wrappers for the derived-data registry - `pipeline/combo/` — alpha combination → `combos/*.pq` - `pipeline/portfolio/` — construction, A-share lot/limit rules, constraints, reference next-open simulator, and research metrics - `pipeline/common/schema.py` — parquet column contracts - `data/downloader.py`, `data/universe.py` — baostock/akshare download + constituents - `tools/pqcat.py` — standalone parquet inspector (`pqcat`), also wired as `cli.py pqcat` - `tools/alphaview.py` — standalone alpha-vs-bar viewer (`alphaview`), also wired as `cli.py alphaview` - `examples/alphas/` — example external alpha(s) ## Roadmap / current limits The pipeline is implemented through portfolio construction and a reference daily execution simulator. `alpha eval` remains a fast sanity check on raw weights; use `portfolio build`, `portfolio simulate`, and `portfolio eval` for constructed positions, fills/costs, P&L, and target-weight research metrics. - [x] **Portfolio construction** — turn alpha/combo weights into continuous targets and lot-valid integer positions under A-share board rules. - [x] **Reference execution simulation** — next-open fills over constructed `position_shares`, with suspension, price-limit, volume-cap, transaction-cost, and slippage controls. - [x] **Derived/custom daily data ("Level 2")** — ingest user CSV/parquet files or compute plugin outputs as validated numeric daily datasets under `derived/{name}.pq`; alpha joins continue through `--feature-path`. - [ ] **Optional Backtrader adapter** — Backtrader is available as the `backtrader` extra for possible future event-driven/broker-style experiments, but it is not part of the current canonical portfolio workflow. - [ ] **Forward / paper trading** — run the same construction logic on live daily data, track simulated fills and a running P&L without real capital. - [ ] **Intraday / microstructure data** — bid/ask prices & sizes, mid-price, and intraday VWAP. These need a tick / L1–L2 quote feed (typically a paid or brokerage data tier); the free daily sources here only expose daily bars, so this is a separate data phase rather than extra columns on the daily schema. ### Additional TODOs The following items are intended extensions beyond the current daily alpha-to-portfolio pipeline: - **Long-only portfolio mode** — add a construction option that converts alpha/combo weights into a long-only book while preserving existing lot, price, suspension, and volume-cap handling. - **Index-short hedging mode** — support portfolios that hold long A-share names while shorting an index or index proxy for market exposure control. - **Expanded universe presets** — add explicit universe aliases for CSI 300, CSI 500, CSI 1000, and CSI 1800, while keeping file-based and comma-separated custom universes available. - **Categorical derived data** — extend the numeric-only derived-data v1 contract to support categorical inputs such as industry classifications. In this project, "Level 2" means customized second-level research data produced by users or plugins; it does not necessarily mean exchange order-book/L2 quote feeds. - **Minute bar data** — continue extending the raw minute-bar and feature workflow. The initial Baostock 5-minute download and daily feature plugin path exist; intraday execution and replacing canonical daily bars remain out of scope unless explicitly added later. - **Industry data** — add industry classification inputs for filtering, grouping, exposure reporting, neutralization, or industry-aware portfolio construction.