Compare commits

...

10 Commits

Author SHA1 Message Date
Yuxuan Yan 17fa75495d Restore reversal tutorial wording 2026-06-12 22:58:22 +08:00
Yuxuan Yan 3c58a1372e Use next-open returns for research eval 2026-06-12 18:41:18 +08:00
Yuxuan Yan 16b4988f16 Rewrite reversal report as tutorial 2026-06-12 17:12:29 +08:00
Yuxuan Yan 2c0ca53bd6 Document cost bps as one-way per-trade, not round-trip
The simulator charges (cost_bps + slippage_bps) on each fill, so a full
round trip is charged twice. Correct the cost-model doc, the reversal_5d
report, and the report generator to state the rate is one-way per-trade
(~20 bps round trip for 5+5), rather than mislabeling it round-trip.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 21:46:41 +08:00
Yuxuan Yan 2ceac82325 Add pipeline invariant checks for look-ahead, execution, PnL, and lot rules
Ten network-free correctness tests mapping 1:1 to the review checks:
reversal look-ahead, next-open execution date, PnL decomposition,
realized-not-target threading, blocked-trade zero cost, causal universe
mask, one-way cost bps, raw-price accounting, adjustment-invariant alpha,
and lot-lattice repair.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 21:46:29 +08:00
Yuxuan Yan b7dd94b032 Add 5-day reversal end-to-end pipeline report and repro scripts
Runs the 5-day reversal signal through data→alpha→combo→portfolio on the
full A-share universe and documents the finding: the naive z-score book
loses to outlier concentration, rank weighting on a liquid universe
recovers a real edge, and turnover-driven cost is the binding constraint.
Includes the e2e driver and figure generator that produce the report.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 17:40:52 +08:00
Yuxuan Yan 07ed6ad917 Add outlier-robust reversal_rank alpha and investable-universe filter
reversal_rank weights the 5-day reversal signal by bounded cross-sectional
rank instead of z-score, so a few extreme A-share pct_change outliers
(newly listed / post-suspension / limit-up names) can no longer dominate
the book. compute_alpha gains an optional per-date investable-universe
mask (tradable, non-ST, seasoned, top-liquidity) applied to the signal
before weighting, exposed via --liquid-universe/--universe-top-n.

combo combine now accepts a single alpha as an identity passthrough so a
one-alpha pipeline run needs no synthetic second input.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 17:40:28 +08:00
Yuxuan Yan 0a6f367fbf Evaluate weights against next-period returns to avoid look-ahead
Weights formed from close[t] now earn the t→t+1 return: forward returns
are computed on the full market calendar before selecting signal dates,
so a sparse signal grid earns the next available return rather than the
next signal date, and the final signal date (no forward return) is
dropped. Signal pct_change uses fill_method=None so suspended names do
not inherit stale prices.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 17:39:55 +08:00
Yuxuan Yan 534b91aaa4 Document and abstract portfolio trading costs 2026-06-10 15:41:38 +08:00
Yuxuan Yan 4a477b8f75 Make Backtrader an optional extra 2026-06-10 15:13:11 +08:00
28 changed files with 2737 additions and 57 deletions
+6
View File
@@ -26,6 +26,10 @@ uv run python cli.py portfolio eval --positions-path portfolio/eq_10m.pq --data-
Add a runtime dep with `uv add <pkg>`, a dev/test dep with `uv add --dev <pkg>` (both update `pyproject.toml` + `uv.lock`). Add a runtime dep with `uv add <pkg>`, a dev/test dep with `uv add --dev <pkg>` (both update `pyproject.toml` + `uv.lock`).
Backtrader is optional (`uv sync --extra backtrader`) and is not used by the
current pipeline. Keep `portfolio simulate` as the canonical backtest/execution
path unless an explicit future adapter is requested.
Note: `tests/test_downloader.py` hits the network (live baostock/akshare); `tests/test_alpha.py` and `tests/test_portfolio.py` are pure and fast. Note: `tests/test_downloader.py` hits the network (live baostock/akshare); `tests/test_alpha.py` and `tests/test_portfolio.py` are pure and fast.
## Architecture: one decoupled pipeline ## Architecture: one decoupled pipeline
@@ -65,6 +69,8 @@ Data is stored **long/tidy**, not wide, as a Hive-partitioned dataset keyed by `
`portfolio simulate` must execute `position_shares`, not continuous `target_shares`. It fills at the next available open and clips desired deltas through repeatable constraints (`suspension`, `price_limit`, `volume_cap`). `portfolio eval` uses `target_weight` for a continuous research view, so zero-gross carry dates remain flat there. Keep IC/IR out of portfolio metrics too. `portfolio simulate` must execute `position_shares`, not continuous `target_shares`. It fills at the next available open and clips desired deltas through repeatable constraints (`suspension`, `price_limit`, `volume_cap`). `portfolio eval` uses `target_weight` for a continuous research view, so zero-gross carry dates remain flat there. Keep IC/IR out of portfolio metrics too.
Trading cost uses the simplified open-execution proportional cash-cost model in `docs/portfolio_trading_cost_model.md`: `abs(traded_shares * open) * (cost_bps + slippage_bps) / 10000`. Slippage is cash cost only; do not also adjust execution prices for slippage.
## Alphas: factory + plugin pattern ## Alphas: factory + plugin pattern
Each alpha is a class subclassing `BaseAlpha` (`pipeline/alpha/base.py`), living in its own module. It implements `signal(close) -> wide DataFrame` (the raw score); the base class's `to_weights` cross-sectionally z-scores that into position weights (override for custom normalization). Subclasses declare their own typed `__init__` params (e.g. `lookback`, `vol_window`, or anything custom). Each alpha is a class subclassing `BaseAlpha` (`pipeline/alpha/base.py`), living in its own module. It implements `signal(close) -> wide DataFrame` (the raw score); the base class's `to_weights` cross-sectionally z-scores that into position weights (override for custom normalization). Subclasses declare their own typed `__init__` params (e.g. `lookback`, `vol_window`, or anything custom).
+13 -1
View File
@@ -48,6 +48,13 @@ The env is managed with [uv](https://docs.astral.sh/uv/). `uv sync` builds `.ven
uv sync 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 ## Quick start
```bash ```bash
@@ -203,7 +210,9 @@ uv run python cli.py portfolio build \
Executes the constructed `position_shares` book at the next available open, Executes the constructed `position_shares` book at the next available open,
clipping trades through repeatable constraints. It writes `fills/<name>.pq` and clipping trades through repeatable constraints. It writes `fills/<name>.pq` and
`pnl/<name>.pq`. `pnl/<name>.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 | | Option | Default | Description |
| --- | --- | --- | | --- | --- | --- |
@@ -401,6 +410,9 @@ constructed positions, fills/costs, P&L, and target-weight research metrics.
- [x] **Reference execution simulation** — next-open fills over constructed - [x] **Reference execution simulation** — next-open fills over constructed
`position_shares`, with suspension, price-limit, volume-cap, transaction-cost, `position_shares`, with suspension, price-limit, volume-cap, transaction-cost,
and slippage controls. and slippage controls.
- [ ] **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 - [ ] **Forward / paper trading** — run the same construction logic on live
daily data, track simulated fills and a running P&L without real capital. daily data, track simulated fills and a running P&L without real capital.
- [ ] **Intraday / microstructure data** — bid/ask prices & sizes, mid-price, - [ ] **Intraday / microstructure data** — bid/ask prices & sizes, mid-price,
Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

+140
View File
@@ -0,0 +1,140 @@
# Portfolio Trading Cost Model
This document describes the trading cost model used by `portfolio simulate`.
The current implementation is a simplified open-execution proportional cost
model. It is intentionally small, explicit, and easy to audit.
## Open-Execution Timeline
The simulator runs once per trading day:
1. A constructed portfolio row provides the target book for an execution date.
In the current file layout, a target dated `t` is executed at the next
available market date `d = next(t)`.
2. Trades are executed at `open[d]`.
3. Realized positions are held during the trading day.
4. Daily PnL is marked from `open[d]` to `close[d]` on the newly realized book,
plus any overnight gap from the previous realized holdings.
5. Trading cost is charged only on actually realized `traded_shares`, after all
constraints have clipped the desired trade.
This means a fully blocked order has `traded_shares = 0` and therefore zero
trading cost.
## Current Formula
For each symbol:
```text
trade_value_i = abs(traded_shares_i * execution_price_i)
trade_cost_i = trade_value_i * (cost_bps + slippage_bps) / 10000
```
where:
```text
execution_price_i = open_price_i
```
`cost_bps` is the proportional explicit trading-cost rate in basis points.
`slippage_bps` is modeled as an additional cash cost in basis points. The two
rates are added linearly. The CLI options `--cost-bps` and `--slippage-bps`
both default to `0.0`.
Both rates are **one-way, per-trade**: the combined `(cost_bps + slippage_bps)`
is charged on the traded notional of *each* fill, buy and sell alike. A full
round trip (enter then exit a position) is therefore charged twice — e.g.
`5 + 5` bps becomes ~20 bps over a complete round trip, not 10. Quote any
round-trip figure by doubling, or convert a round-trip budget to a per-trade
rate by halving before passing it in.
Example:
```text
traded_shares = 1000
execution_price = 20 yuan
cost_bps = 10
slippage_bps = 5
abs(1000 * 20) * 15 / 10000 = 30 yuan
```
## Slippage Convention
Slippage is not applied by changing the execution price. It is charged only as
a cash cost through `trade_cost`.
Do not double-count slippage by doing both:
```text
execution_price = open * (1 +/- slippage_bps / 10000)
trade_cost += trade_value * slippage_bps / 10000
```
The simulator should execute at the open price and subtract the slippage cash
cost from PnL.
## Relationship To The Simulator
`ReferenceSimulator.fill()` clips desired trades through constraints first, then
passes the actual `traded_shares` to the cost model. The per-name result is
stored in the fills parquet as `trade_cost`.
`ReferenceSimulator.run()` sums per-name `trade_cost` into the daily PnL row's
`cost` column and subtracts that total from daily PnL:
```text
pnl = overnight + intraday - cost_total
```
## What This Model Does Not Cover
The current model intentionally does not model:
- Minimum commissions.
- Buy/sell asymmetric fees.
- Sell-side stamp duty.
- Exchange handling fees.
- Regulatory fees.
- Transfer fees.
- Date-aware fee schedule changes.
- Nonlinear price impact.
- Auction liquidity / queue effects.
- Partial fills caused by open auction depth.
These omissions are deliberate. The current model is the default reference
model, not a detailed brokerage fee simulator.
## Future Extension
The simulator is structured around a cost model abstraction:
```python
class CostModel:
def compute(
self,
traded_shares,
execution_price,
side,
date,
metadata,
):
...
```
The current implementation is `SimpleProportionalCostModel`.
A future `AShareDetailedCostModel` can add:
- Commission, optionally subject to minimum commission.
- Sell-side stamp duty.
- Transfer fee.
- Exchange handling fee.
- Regulatory fee.
- Date-aware fee rates.
- Separate buy-side and sell-side rates.
- Optional nonlinear slippage / market-impact model.
Any future model must preserve the same high-level simulator contract: costs
are computed from realized trades after constraints, and slippage must not be
counted both through execution-price adjustment and cash cost.
@@ -0,0 +1,509 @@
# Tutorial: Testing a 5-Day Reversal Alpha
This document is a teaching walkthrough for someone who is new to this research
framework and only lightly familiar with quant research. We will use one
concrete experiment, a 5-day reversal alpha on the full downloaded Chinese
A-share universe, to learn how the framework defines an alpha, stores it, tests
it, turns it into a portfolio, and explains the gap between a research result
and simulated trading PnL.
This generated version was refreshed at 2026-06-12T22:52:56.
The important point is not the timestamp; it is the research method.
## The Research Question
A quant research project starts with a hypothesis:
> If a stock fell a lot over the last few trading days, it may rebound soon; if
> it rose a lot, it may cool off soon.
This is called **short-horizon reversal**. It is a simple idea: recent losers
are candidates to buy, and recent winners are candidates to sell or underweight.
In this repo, the tested version looks back 5 trading days.
The central research question is:
> Does this 5-day reversal rule create useful portfolio returns after the
> framework applies realistic storage, portfolio construction, execution
> constraints, and trading costs?
The answer from this run is nuanced:
- The naive built-in version is positive under the tradable
next-open-to-next-open research convention (**41.40%**),
but its stored weights still show that raw z-score weighting is too sensitive
to A-share outliers.
- A rank-weighted version on a liquid, non-ST, tradable universe has a positive
costless research result: **209.58%**
at Sharpe **1.44**.
- The daily-traded implementation is still not tradable after costs because
turnover is too high.
That is a normal research outcome. Good research is not just asking "did the
backtest go up?" It is asking **which layer explains the result**: signal,
weighting, universe, construction, execution, or cost.
## How This Framework Defines An Alpha
In many quant textbooks, an alpha is described as a **prediction** of future
returns. This framework uses a stricter and more practical convention:
> An alpha is a signed cross-sectional position weight.
That sentence is the key to the whole repo.
- **Signed** means positive values are long exposure and negative values are
short exposure.
- **Cross-sectional** means the alpha compares stocks to other stocks on the
same date.
- **Position weight** means the output is already an instruction about what the
portfolio wants to own. It is not merely a score to correlate with future
returns.
The stored alpha file always has this schema:
| column | meaning |
| --- | --- |
| `symbol_id` | Stock identifier such as `sh600000` or `sz000001`. |
| `date` | The signal date. The alpha is formed using information known by this date's close. |
| `alpha_name` | A label for this particular run, such as `reversal_5d_all`. |
| `weight` | Signed desired exposure. Positive means long; negative means short. |
Because the framework treats alphas as position weights, it evaluates them with
portfolio metrics: return, Sharpe, turnover, drawdown, and hit rate. It does
**not** use IC/IR, because IC/IR would treat the alpha as a return predictor.
## The Pipeline In One Picture
Every phase reads parquet files and writes parquet files. That makes the system
easy to inspect and rerun one layer at a time.
```text
daily bars
-> alpha weights
-> combined weights
-> portfolio targets and integer positions
-> simulated fills and PnL
-> evaluation metrics
```
For this experiment, the important phases are:
| phase | command family | what it teaches you |
| --- | --- | --- |
| Data | `cli.py data download` | What market data is available. |
| Alpha compute | `cli.py alpha compute` | How a raw research idea becomes stored weights. |
| Alpha eval | `cli.py alpha eval` | How close-formed weights perform over the tradable next-open-to-next-open interval. |
| Combo | `cli.py combo combine` | How one or more alphas become one combined book. |
| Portfolio build | `cli.py portfolio build` | How weights become target values and integer shares. |
| Portfolio simulate | `cli.py portfolio simulate` | How the integer book trades at next open with constraints and costs. |
| Portfolio eval | `cli.py portfolio eval` | How the continuous target portfolio behaves over the same costless open-to-open research interval. |
In a real research workflow, you should learn to pause after every phase and
inspect the parquet output. Most mistakes are easier to find at the interface
between two phases than at the final PnL line.
## Step 1: Define The Raw Reversal Signal
The built-in 5-day reversal alpha is implemented as:
```python
signal = -close.pct_change(5, fill_method=None)
```
For stock `i` on date `t`, this is approximately:
```text
signal[i, t] = -(close[i, t] / close[i, t-5] - 1)
```
So:
- If a stock rose by 10% over the last 5 trading days, the raw signal is `-10%`.
It becomes a candidate short or underweight.
- If a stock fell by 10% over the last 5 trading days, the raw signal is `+10%`.
It becomes a candidate long or overweight.
Notice the timing. The signal uses prices through date `t`. It must not use the
return from `t` to `t+1`, because that is the future. The costless alpha
evaluator tests the weight formed on date `t` over the tradable interval from
`open[t+1]` to `open[t+2]`; the later execution simulator is the separate layer
that trades the constructed integer book at the next open.
The code lives in `pipeline/alpha/library/reversal.py`:
```python
class ReversalAlpha(BaseAlpha):
name = "reversal"
def __init__(self, lookback: int = 5):
self.lookback = lookback
def signal(self, close: pd.DataFrame) -> pd.DataFrame:
return -close.pct_change(self.lookback, fill_method=None)
```
The alpha class only defines the raw signal. The base class then turns that
signal into weights.
## Step 2: Turn A Signal Into Cross-Sectional Weights
By default, `BaseAlpha.to_weights()` does a cross-sectional z-score each date:
```text
weight[i, t] = (signal[i, t] - mean_signal[t]) / std_signal[t]
```
This means the framework asks:
> On this date, which stocks have stronger reversal scores than the rest of the
> market, and by how much?
That is useful, but it has a weakness. If a few stocks have extreme trailing
returns because they are newly listed, suspended, illiquid, or limit-constrained,
z-scoring can put a very large amount of relative exposure into exactly those
names.
That is visible in the naive full-universe run. Stored weights reached about
`-52` standard deviations. The
result is positive under the open-to-open convention, but it is much weaker and
less robust than the rank-weighted versions:
| run | weighting | research cumulative return | research Sharpe | research turnover/year |
| --- | --- | --- | --- | --- |
| naive z-score, full universe | z-score | 41.40% | 0.4514 | 160x |
The lesson is not "reversal is solved." The lesson is:
> The same raw signal can become a fragile portfolio if the weighting method
> reacts badly to outliers.
## Step 3: Make The Weighting More Robust
The repo also has a rank-weighted version, `reversal_rank`. It uses the same raw
5-day reversal signal, but converts the cross-section to ranks instead of
z-scores:
```python
ranks = signal.rank(axis=1)
weights = ranks.subtract(ranks.mean(axis=1), axis=0)
```
Rank weighting keeps the ordering of stocks but removes the importance of the
exact outlier magnitude. A stock can be "the worst recent loser" or "the best
recent winner," but it cannot become dozens of standard deviations important
just because its raw percentage move is unusual.
The full-universe rank version was much less pathological, but still not a
clean signal:
| run | weighting | research cumulative return | research Sharpe | research turnover/year |
| --- | --- | --- | --- | --- |
| rank, full universe | rank | 73.44% | 0.8860 | 143x |
That tells us the weighting fix helped, but the universe still contains many
names that are poor candidates for a daily reversal strategy.
## Step 4: Define The Investable Universe
An alpha should be tested on stocks that could plausibly be traded. The liquid
run applies a per-date mask before weights are created. A stock must be:
- seasoned, with at least 60 observed closes;
- currently tradable, using `tradestatus == 1`;
- not ST, using `isST == 0`;
- inside the top 1000 names by trailing 20-day average traded amount.
This mask is applied to the signal, not to the price history used to compute the
5-day return. That distinction matters. We still compute `pct_change(5)` on the
full contiguous price history, then decide which names are eligible to hold on
each signal date.
The liquid rank result is the cleanest research result:
| run | weighting | universe | research cumulative return | research Sharpe | hit rate |
| --- | --- | --- | --- | --- | --- |
| rank, liquid subset | rank | top 1000 liquid, tradable, non-ST | 209.58% | 1.4422 | 55.68% |
This is the first point where a researcher can say:
> There appears to be a real 5-day reversal effect in a cleaner A-share
> universe, before trading costs.
That last phrase, **before trading costs**, is essential.
![Research equity](assets/reversal_5d_research_equity.png)
When reading this chart, focus on the shape and relative behavior:
- The naive z-score line shows why outlier-sensitive weighting is fragile.
- The rank full-universe line shows that robust weighting helps, but the full
universe still contains noisy and hard-to-trade names.
- The liquid rank line shows the signal-level edge before execution costs.
## Step 5: Check That The Alpha File Is Sane
Before trusting any metric, inspect the stored alpha artifact. The run checked:
- The columns match `ALPHA_COLUMNS`.
- There are no null weights.
- There are no non-finite weights.
- There are no duplicate `(symbol_id, date)` rows.
- The daily cross-sectional mean is approximately zero.
- A one-alpha combo is an exact identity transform.
| run | schema ok | null w | non-finite w | dup keys | max \|daily mean\| | weight range | combo identity Δ |
| --- | --- | --- | --- | --- | --- | --- | --- |
| naive z-score (full) | True | 0 | 0 | 0 | 3.32e-16 | [-52.2, 19.2] | 0.00e+00 |
| rank (full) | True | 0 | 0 | 0 | 0.00e+00 | [-2603.0, 2603.0] | 0.00e+00 |
| rank (liquid subset) | True | 0 | 0 | 0 | 0.00e+00 | [-498.5, 498.5] | 0.00e+00 |
The rank ranges look numerically large because rank weights scale with the
number of names. That is fine: later evaluation divides by gross exposure, and
portfolio construction normalizes by `sum(abs(weight))`. The important
difference is that rank weights are bounded by cross-sectional rank, not by the
raw size of an abnormal stock move.
![Weight distributions](assets/reversal_5d_weight_distributions.png)
This is a good habit: when a backtest looks strange, plot the weights before
debugging the PnL. A broken or concentrated weight distribution often explains
the result.
## Step 6: Understand The Alpha Evaluation Formula
The costless alpha evaluator now asks:
> If we compute alpha weights after close on date `t`, trade them at `open[t+1]`,
> and hold them until `open[t+2]`, what return would we earn before costs?
This is still a **research-layer approximation**, not the trading simulator. At
this stage the framework has only an alpha weight file. It has not yet rounded
shares, checked limits, clipped trades, or paid costs. The purpose is to answer
a clean signal question: "Do these close-formed weights line up with returns
over the interval we could actually own after next-open execution?"
The daily research return is:
```text
R[t] = sum_i(weight[i, t] * (open[i, t+2] / open[i, t+1] - 1)) / sum_i(abs(weight[i, t]))
```
This has three important consequences:
- The alpha is normalized by its gross exposure, so the scale of raw weights
does not by itself create a higher return.
- The new signal does not receive credit for the overnight gap from `close[t]`
to `open[t+1]`, because it cannot be traded until `open[t+1]`.
- The final two signal dates are dropped from performance metrics because they
do not have a complete next-open-to-next-open holding interval.
Turnover is still measured from the weights:
```text
turnover[t] = sum_i(abs(weight[i, t] - weight[i, t-1])) / sum_i(abs(weight[i, t-1]))
```
The annualized turnover numbers are a warning. Even a positive signal can be
hard to monetize if it asks the portfolio to trade too much every day.
## Step 7: Build A Portfolio From The Alpha
The alpha file is still an abstract research book. `portfolio build` turns it
into target exposures and integer shares.
The main normalization is:
```text
target_weight[i, t] = weight[i, t] / sum_i(abs(weight[i, t]))
target_value[i, t] = booksize * target_weight[i, t]
target_shares[i, t] = target_value[i, t] / construction_price[i, t]
```
Then the framework creates an integer A-share book using lot rules and repair
logic. This is where a research portfolio starts to become a tradable portfolio.
The continuous target portfolio matched the stored alpha almost exactly:
| run | target_value identity max\|Δ\| | alpha→target max\|Δ\| | research corr(alpha,portfolio) | mean integer gross | mean L1 tracking |
| --- | --- | --- | --- | --- | --- |
| naive z-score (full) | 0.0000 | 0.00e+00 | 1.000000 | 9,138,331 | 2,542,655 |
| rank (full) | 0.0000 | 0.00e+00 | 1.000000 | 8,984,098 | 2,678,278 |
| rank (liquid subset) | 0.0000 | 0.00e+00 | 1.000000 | 9,810,256 | 862,303 |
The integer book is not exact because small target positions can be rounded
away. The liquid subset has lower tracking error because it spreads the book
over fewer and more tradable names.
![Portfolio tracking](assets/reversal_5d_portfolio_tracking.png)
When you research a new alpha, ask two separate questions:
- Does the continuous target portfolio match the alpha? It should.
- Does the integer tradable portfolio still resemble the target? It may not,
especially for small books or very broad universes.
## Step 8: Simulate Execution And Costs
Research returns are not the same as tradable PnL. The simulator executes the
integer `position_shares` at the next available open and applies constraints:
- suspension;
- price limit;
- volume cap;
- proportional trading cost.
The cost model is:
```text
cost = abs(traded_shares * open) * (cost_bps + slippage_bps) / 10000
```
For this run, cost is 5 bps commission plus 5 bps slippage. Slippage is treated
as cash cost, not as an additional execution price adjustment.
The execution results explain the final research conclusion:
| run | corr(alpha, exec net) | PnL before cost | total cost | net PnL | mean daily turnover |
| --- | --- | --- | --- | --- | --- |
| naive z-score (full) | 0.8956 | 1,838,974 | 13,032,720 | -11,193,746 | 0.5711 |
| rank (full) | 0.9126 | 5,052,067 | 11,713,451 | -6,661,383 | 0.5133 |
| rank (liquid subset) | 0.8884 | 11,017,842 | 12,733,803 | -1,715,960 | 0.5715 |
For the liquid rank run, simulated PnL before cost is about
11,017,842, but total cost is about
12,733,803. That is why the final net PnL is
weak or negative.
This is not a contradiction. It is exactly what a research pipeline should show:
> The signal can exist in the costless layer, but the daily implementation can
> still trade too much to keep the edge.
![Execution vs research](assets/reversal_5d_exec_vs_research.png)
## Step 9: Read The Headline Metrics Like A Researcher
The complete summary is:
| run | weighting | research cum | research Sharpe | research turn/yr | exec before cost | exec net | exec net Sharpe |
| --- | --- | --- | --- | --- | --- | --- | --- |
| naive z-score (full) | z-score | 41.40% | 0.4514 | 160× | 18.39% | -111.94% | -1.4508 |
| rank (full) | rank | 73.44% | 0.8860 | 143× | 50.52% | -66.61% | -1.1839 |
| rank (liquid subset) | rank | 209.58% | 1.4422 | 148× | 110.18% | -17.16% | -0.2226 |
*Research = costless, no-look-ahead weights over the next-open-to-next-open
holding interval. Execution = next-open fills on the discretized integer book
under suspension / price-limit / volume-cap constraints, 5 bps commission + 5
bps slippage.*
Here is the interpretation:
- **Naive z-score full universe**: positive under open-to-open research, but a
less reliable test of the reversal idea because the weighting scheme lets
outliers dominate parts of the book.
- **Rank full universe**: a better test of the same idea, but still noisy
because the universe includes too many problematic names.
- **Rank liquid subset**: the best signal-level test; it finds the cleanest
costless reversal effect.
- **Execution net**: daily rebalancing remains heavily constrained by cost.
A beginner might look only at the final net PnL and say "the alpha failed." A
researcher should be more precise:
> The raw 5-day reversal idea has signal value in a liquid universe, but the
> current daily trading rule has too much turnover for the assumed cost model.
## Step 10: Reproduce The Experiment
These commands reproduce the important artifacts, assuming the full daily-bar
dataset already exists at `data/daily_bars/all`.
```bash
# Naive z-score baseline: built-in reversal alpha, full universe.
uv run python cli.py alpha compute --data-path data/daily_bars/all \
--alpha-name reversal_5d_all --alpha-type reversal --lookback 5 \
--output-dir alphas
# Rank-weighted full and liquid runs.
bash scripts/run_reversal_rank_e2e.sh
# Regenerate figures, diagnostics, and this tutorial report.
uv run python scripts/generate_reversal_5d_report.py
```
If you are learning the framework, do not run the whole pipeline blindly. Run
one phase, inspect the output parquet, then continue.
## How To Research Your Own Alpha
Use this checklist for a new idea.
1. State the hypothesis in plain language.
Example: "Stocks with poor 5-day returns may rebound over the next day."
2. Write the raw signal.
Implement `signal(close) -> wide DataFrame` in an alpha class. Higher values
should mean stronger long preference.
3. Choose the weighting method.
The default z-score is useful, but it can be fragile. Consider rank weights,
caps, neutralization, or liquidity-aware filters if outliers dominate.
4. Define the investable universe before trusting results.
Make sure the strategy is not depending on suspended, ST, newly listed, or
illiquid names.
5. Evaluate the alpha as a portfolio, not as a prediction.
Check cumulative return, Sharpe, drawdown, hit rate, and turnover over the
next-open-to-next-open holding interval. Do not add IC/IR unless the
framework's alpha convention changes.
6. Build the portfolio and inspect tracking.
Confirm that target weights match the alpha, then check whether integer
shares still track the target book.
7. Simulate execution with costs.
The final research question is not only "is there a signal?" It is "is there
enough signal left after realistic trading?"
8. Diagnose the failure layer.
If results are bad, identify whether the problem is the raw signal, weighting,
universe, construction, execution constraints, turnover, or cost.
For this 5-day reversal study, the diagnosis is clear: **the signal-level result
is most promising after robust weighting and a liquid universe filter, but the
current implementation needs turnover control before it can be considered
tradable.**
## Next Research Directions
The natural next experiments are:
- Add turnover control: no-trade bands, slower rebalancing, or weight smoothing.
- Sweep the lookback window: compare 3-day, 5-day, 10-day, and 20-day reversal.
- Sweep liquidity filters: top 500, top 1000, top 1500 by traded amount.
- Add position caps so no single name can dominate after normalization.
- Compare rank weighting with volatility-scaled reversal.
The most important habit is to keep the layers separate. A good alpha research
workflow does not stop at a single performance number; it explains how the idea
travels from hypothesis, to signal, to weights, to portfolio, to executable PnL.
## Appendix: Phase Timings From This Rerun
| phase | rank full (s) | rank liquid (s) |
| --- | --- | --- |
| alpha compute | 94.1 | 107.8 |
| alpha eval | 93.3 | 96.9 |
| combo combine | 21.6 | 21.7 |
| portfolio build | 537.6 | 236.7 |
| portfolio eval | 95.1 | 88.3 |
| portfolio simulate | 139.6 | 139.1 |
| total | 981.3 | 690.5 |
![Phase timings](assets/reversal_5d_phase_timings.png)
`portfolio build` usually dominates because it iterates per signal date and
repairs a multi-thousand-name integer book under lot rules. The liquid run is
faster because it carries fewer non-zero names per date.
+13 -1
View File
@@ -64,8 +64,17 @@ def list_(alpha_modules):
"--param", "extra_params", multiple=True, "--param", "extra_params", multiple=True,
help="Extra alpha constructor param as name=value (repeatable)", help="Extra alpha constructor param as name=value (repeatable)",
) )
@click.option(
"--liquid-universe", is_flag=True, default=False,
help="Mask weights to a per-date investable universe (tradable, non-ST, "
"seasoned, top liquidity) before normalization",
)
@click.option(
"--universe-top-n", default=1000, type=int,
help="Most-liquid names kept per date when --liquid-universe is set",
)
def compute(data_path, alpha_name, alpha_type, output_dir, lookback, vol_window, def compute(data_path, alpha_name, alpha_type, output_dir, lookback, vol_window,
alpha_modules, extra_params): alpha_modules, extra_params, liquid_universe, universe_top_n):
"""Compute one alpha from raw data and save as parquet.""" """Compute one alpha from raw data and save as parquet."""
for spec in alpha_modules: for spec in alpha_modules:
load_alpha_module(spec) load_alpha_module(spec)
@@ -81,6 +90,8 @@ def compute(data_path, alpha_name, alpha_type, output_dir, lookback, vol_window,
params = {"lookback": lookback, "vol_window": vol_window} params = {"lookback": lookback, "vol_window": vol_window}
params.update(_parse_params(extra_params)) params.update(_parse_params(extra_params))
universe = {"top_n": universe_top_n} if liquid_universe else None
data = pd.read_parquet(data_path) data = pd.read_parquet(data_path)
click.echo(f"Loaded data: {len(data):,} rows from {data_path}") click.echo(f"Loaded data: {len(data):,} rows from {data_path}")
@@ -88,6 +99,7 @@ def compute(data_path, alpha_name, alpha_type, output_dir, lookback, vol_window,
data=data, data=data,
alpha_name=alpha_name, alpha_name=alpha_name,
alpha_type=alpha_type, alpha_type=alpha_type,
universe=universe,
**params, **params,
) )
+119 -16
View File
@@ -25,15 +25,86 @@ def _pivot_close(df: pd.DataFrame) -> pd.DataFrame:
return pivot.sort_index() return pivot.sort_index()
def _daily_returns(close: pd.DataFrame) -> pd.DataFrame: def _pivot_open(df: pd.DataFrame) -> pd.DataFrame:
"""Compute daily returns from wide close DataFrame.""" """Pivot data to wide format: date index, columns = symbol_id, values = open."""
return close.pct_change() pivot = df.pivot_table(
index="date", columns="symbol_id", values="open", aggfunc="first"
)
return pivot.sort_index()
def _forward_open_to_open_returns(open_: pd.DataFrame) -> pd.DataFrame:
"""Return earned by a close-formed signal after next-open execution.
A weight formed after close on date t can first be traded at open[t+1].
With daily retargeting it is then held until open[t+2], so the signal-date
forward return is open[t+2] / open[t+1] - 1.
"""
return open_.shift(-2).divide(open_.shift(-1)) - 1.0
def investable_universe_mask(
data: pd.DataFrame,
template: pd.DataFrame,
*,
top_n: int = 1000,
min_history: int = 60,
require_tradable: bool = True,
exclude_st: bool = True,
) -> pd.DataFrame:
"""Build a per-date investable-universe mask aligned to ``template``.
A ``(date, symbol_id)`` cell is ``True`` when the name is, on that date,
seasoned (at least ``min_history`` prior closes), currently tradable
(``tradestatus == 1``), not flagged ST (``isST == 0``), and inside the
``top_n`` most liquid names by trailing 20-day mean ``amount``. The mask is
applied to the *signal* (computed on full contiguous prices), so it
restricts only what is *held*, never the price history used to form the
signal — that keeps ``pct_change`` correct and look-ahead free.
Args:
data: Long DataFrame with at least ``symbol_id``, ``date``, ``close``,
``amount``, ``isST``, ``tradestatus``.
template: Wide signal (date index × ``symbol_id`` columns) to align to.
top_n: Keep this many most-liquid names per date.
min_history: Minimum number of observed closes before a name is eligible.
require_tradable: Require ``tradestatus == 1`` on the date.
exclude_st: Drop names flagged ``isST == 1``.
Returns:
Boolean wide DataFrame aligned to ``template``.
"""
def _wide(col: str) -> pd.DataFrame:
return (
data.pivot_table(index="date", columns="symbol_id", values=col, aggfunc="first")
.sort_index()
.reindex(index=template.index, columns=template.columns)
)
close = _wide("close")
mask = close.notna()
seasoned = close.notna().cumsum() >= min_history
mask &= seasoned
if exclude_st and "isST" in data.columns:
mask &= _wide("isST").fillna(1) == 0
if require_tradable and "tradestatus" in data.columns:
mask &= _wide("tradestatus").fillna(0) == 1
amount = _wide("amount")
amt_ma = amount.rolling(20, min_periods=10).mean()
liquid_rank = amt_ma.rank(axis=1, ascending=False)
mask &= liquid_rank <= top_n
return mask.fillna(False)
def compute_alpha( def compute_alpha(
data: pd.DataFrame, data: pd.DataFrame,
alpha_name: str, alpha_name: str,
alpha_type: str, alpha_type: str,
universe: dict | None = None,
**params, **params,
) -> pd.DataFrame: ) -> pd.DataFrame:
"""Compute alpha weights from raw data. """Compute alpha weights from raw data.
@@ -42,6 +113,11 @@ def compute_alpha(
data: DataFrame with DATA_COLUMNS. data: DataFrame with DATA_COLUMNS.
alpha_name: Label stored in the ``alpha_name`` output column. alpha_name: Label stored in the ``alpha_name`` output column.
alpha_type: Registry key of the alpha class (e.g. ``reversal``). alpha_type: Registry key of the alpha class (e.g. ``reversal``).
universe: Optional investable-universe filter. When given, the alpha's
raw signal is masked to the investable set (see
:func:`investable_universe_mask`) *before* it is turned into
weights, so unheld names get weight 0. Keys are forwarded as keyword
arguments to :func:`investable_universe_mask`.
**params: Constructor parameters for the alpha (e.g. ``lookback``, **params: Constructor parameters for the alpha (e.g. ``lookback``,
``vol_window``). Only the params the alpha's ``__init__`` accepts are ``vol_window``). Only the params the alpha's ``__init__`` accepts are
used; extras are ignored. used; extras are ignored.
@@ -54,7 +130,12 @@ def compute_alpha(
""" """
alpha = get_alpha(alpha_type, **params) alpha = get_alpha(alpha_type, **params)
close = _pivot_close(data) close = _pivot_close(data)
if universe is None:
weights = alpha.weights(close) weights = alpha.weights(close)
else:
signal = alpha.signal(close)
mask = investable_universe_mask(data, signal, **universe)
weights = alpha.to_weights(signal.where(mask))
# Melt to long format # Melt to long format
weights_melted = weights.reset_index().melt( weights_melted = weights.reset_index().melt(
@@ -82,8 +163,11 @@ def evaluate_alpha(alpha_df: pd.DataFrame, data_df: pd.DataFrame) -> dict:
Computes return, annualized Sharpe, annualized turnover, max drawdown. Computes return, annualized Sharpe, annualized turnover, max drawdown.
Alpha is interpreted as POSITION WEIGHTS, not predictions. Alpha is interpreted as POSITION WEIGHTS, not predictions. A close-formed
Return on date t = sum(weight[s,t] * realized_return[s,t]) / sum(abs(weight[s,t])) weight on date t is assumed tradable at open[t+1] and held until open[t+2].
Return on signal date t = sum(weight[s,t] * open_to_open_return[s,t]) /
sum(abs(weight[s,t])). This matches the execution convention without
crediting the new signal for the overnight gap before it can be traded.
Args: Args:
alpha_df: DataFrame with ALPHA_COLUMNS. alpha_df: DataFrame with ALPHA_COLUMNS.
@@ -93,31 +177,50 @@ def evaluate_alpha(alpha_df: pd.DataFrame, data_df: pd.DataFrame) -> dict:
Dict with metrics: cumulative_return, sharpe_annual, turnover_annual, Dict with metrics: cumulative_return, sharpe_annual, turnover_annual,
max_drawdown, hit_rate, n_dates. max_drawdown, hit_rate, n_dates.
""" """
close = _pivot_close(data_df) open_ = _pivot_open(data_df)
returns = _daily_returns(close) fwd_returns_all = _forward_open_to_open_returns(open_)
# Pivot alpha weights to wide format # Pivot alpha weights to wide format
weights = alpha_df.pivot_table( weights = alpha_df.pivot_table(
index="date", columns="symbol_id", values="weight", aggfunc="first" index="date", columns="symbol_id", values="weight", aggfunc="first"
).sort_index() ).sort_index()
# Align dates # Align weights to signal dates that exist on the market calendar. Compute
common_dates = weights.index.intersection(returns.index) # forward open-to-open returns on the full market calendar first, so sparse
# signal grids still earn the next available open-to-open interval instead
# of the next signal date.
common_dates = weights.index.intersection(open_.index)
weights = weights.loc[common_dates] weights = weights.loc[common_dates]
returns = returns.loc[common_dates] fwd_returns = fwd_returns_all.reindex(common_dates)
if len(common_dates) < 2: if len(common_dates) < 1:
return { return {
"cumulative_return": 0.0, "cumulative_return": 0.0,
"sharpe_annual": 0.0, "sharpe_annual": 0.0,
"turnover_annual": 0.0, "turnover_annual": 0.0,
"max_drawdown": 0.0, "max_drawdown": 0.0,
"hit_rate": 0.0, "hit_rate": 0.0,
"n_dates": len(common_dates), "n_dates": 0,
} }
# Daily portfolio return = sum(w * r) / sum(|w|) — normalized by gross exposure # Daily portfolio return = sum(w_t * r_open[t+1→t+2]) / sum(|w_t|).
daily_returns = (weights * returns).sum(axis=1) / weights.abs().sum(axis=1) # The final two signal dates have no complete next-open holding interval
# and are dropped below.
gross = weights.abs().sum(axis=1)
daily_returns = (
(weights * fwd_returns).sum(axis=1, min_count=1)
/ gross.replace(0.0, np.nan)
)
daily_returns = daily_returns.dropna()
if len(daily_returns) < 2:
return {
"cumulative_return": 0.0,
"sharpe_annual": 0.0,
"turnover_annual": 0.0,
"max_drawdown": 0.0,
"hit_rate": 0.0,
"n_dates": int(len(daily_returns)),
}
# Cumulative return # Cumulative return
cumulative_return = float((1.0 + daily_returns).prod() - 1.0) cumulative_return = float((1.0 + daily_returns).prod() - 1.0)
@@ -130,7 +233,7 @@ def evaluate_alpha(alpha_df: pd.DataFrame, data_df: pd.DataFrame) -> dict:
# Annualized turnover: avg daily turnover * 252 # Annualized turnover: avg daily turnover * 252
# Daily turnover = sum(|w_t - w_{t-1}|) / sum(|w_{t-1}|) # Daily turnover = sum(|w_t - w_{t-1}|) / sum(|w_{t-1}|)
weight_change = weights.diff().abs().sum(axis=1) weight_change = weights.diff().abs().sum(axis=1)
gross_exposure = weights.abs().sum(axis=1).shift(1) gross_exposure = gross.shift(1)
daily_turnover = weight_change / gross_exposure daily_turnover = weight_change / gross_exposure
turnover_annual = float(daily_turnover.mean() * 252) turnover_annual = float(daily_turnover.mean() * 252)
@@ -149,5 +252,5 @@ def evaluate_alpha(alpha_df: pd.DataFrame, data_df: pd.DataFrame) -> dict:
"turnover_annual": turnover_annual, "turnover_annual": turnover_annual,
"max_drawdown": max_drawdown, "max_drawdown": max_drawdown,
"hit_rate": hit_rate, "hit_rate": hit_rate,
"n_dates": len(common_dates), "n_dates": int(len(daily_returns)),
} }
+6 -1
View File
@@ -4,4 +4,9 @@ Importing this package imports each alpha module, which registers the alpha via
the ``@register_alpha`` decorator. Add a new built-in by dropping a module here the ``@register_alpha`` decorator. Add a new built-in by dropping a module here
and importing it below. and importing it below.
""" """
from pipeline.alpha.library import momentum, reversal, reversal_vol # noqa: F401 from pipeline.alpha.library import ( # noqa: F401
momentum,
reversal,
reversal_rank,
reversal_vol,
)
+1 -1
View File
@@ -15,4 +15,4 @@ class MomentumAlpha(BaseAlpha):
self.lookback = lookback self.lookback = lookback
def signal(self, close: pd.DataFrame) -> pd.DataFrame: def signal(self, close: pd.DataFrame) -> pd.DataFrame:
return close.pct_change(self.lookback) return close.pct_change(self.lookback, fill_method=None)
+1 -1
View File
@@ -15,4 +15,4 @@ class ReversalAlpha(BaseAlpha):
self.lookback = lookback self.lookback = lookback
def signal(self, close: pd.DataFrame) -> pd.DataFrame: def signal(self, close: pd.DataFrame) -> pd.DataFrame:
return -close.pct_change(self.lookback) return -close.pct_change(self.lookback, fill_method=None)
+33
View File
@@ -0,0 +1,33 @@
"""Outlier-robust short-horizon reversal alpha."""
import pandas as pd
from pipeline.alpha.base import BaseAlpha
from pipeline.alpha.registry import register_alpha
@register_alpha
class ReversalRankAlpha(BaseAlpha):
"""Reversal weighted by cross-sectional rank instead of z-score.
The signal is the same trailing-return reversal as :class:`ReversalAlpha`,
but :meth:`to_weights` converts it with a cross-sectional rank that is then
demeaned. Rank weighting is bounded and monotone, so it does not dump the
book into a handful of extreme movers the way raw z-scoring does — the
failure mode that makes plain ``reversal`` collapse on the A-share universe,
where newly listed / post-suspension / limit-up names produce huge
``pct_change`` outliers.
"""
name = "reversal_rank"
def __init__(self, lookback: int = 5):
self.lookback = lookback
def signal(self, close: pd.DataFrame) -> pd.DataFrame:
return -close.pct_change(self.lookback, fill_method=None)
def to_weights(self, signal: pd.DataFrame) -> pd.DataFrame:
signal = signal.dropna(how="all")
ranks = signal.rank(axis=1)
weights = ranks.subtract(ranks.mean(axis=1), axis=0)
return weights.fillna(0.0)
+2 -2
View File
@@ -21,6 +21,6 @@ class ReversalVolAlpha(BaseAlpha):
self.vol_window = vol_window self.vol_window = vol_window
def signal(self, close: pd.DataFrame) -> pd.DataFrame: def signal(self, close: pd.DataFrame) -> pd.DataFrame:
reversal = -close.pct_change(self.lookback) reversal = -close.pct_change(self.lookback, fill_method=None)
vol = close.pct_change().rolling(self.vol_window).std() vol = close.pct_change(fill_method=None).rolling(self.vol_window).std()
return reversal / vol return reversal / vol
+2 -2
View File
@@ -26,8 +26,8 @@ def combo():
def combine(alpha_paths, combo_name, method, output_dir): def combine(alpha_paths, combo_name, method, output_dir):
"""Combine multiple alphas and save as parquet.""" """Combine multiple alphas and save as parquet."""
paths = [p.strip() for p in alpha_paths.split(",") if p.strip()] paths = [p.strip() for p in alpha_paths.split(",") if p.strip()]
if len(paths) < 2: if len(paths) < 1:
click.echo("Error: --alpha-paths requires at least 2 comma-separated paths", err=True) click.echo("Error: --alpha-paths requires at least 1 path", err=True)
return return
result = combine_alphas( result = combine_alphas(
+3 -3
View File
@@ -7,9 +7,9 @@ across dates (positions are stateful, unlike alphas/combos), discretizing and
repairing each day's target into a tradable integer book. repairing each day's target into a tradable integer book.
Return-convention note: weights here are *target allocations*. The research Return-convention note: weights here are *target allocations*. The research
evaluation in :mod:`pipeline.portfolio.research` marks them close-to-close on the evaluation in :mod:`pipeline.portfolio.research` marks them from next open to
*next* period (no look-ahead); the execution simulator marks the actually-filled the following open (no look-ahead); the execution simulator marks the
book at the next open. See those modules for details. actually-filled book at the next open. See those modules for details.
""" """
from __future__ import annotations from __future__ import annotations
+50
View File
@@ -0,0 +1,50 @@
"""Trading cost models for portfolio execution simulation."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Mapping
import numpy as np
class CostModel(ABC):
"""Interface for per-name execution cost models."""
@abstractmethod
def compute(
self,
traded_shares: np.ndarray,
execution_price: np.ndarray,
side: np.ndarray,
date,
metadata: Mapping[str, object] | None = None,
) -> np.ndarray:
"""Return per-name trading cost in yuan."""
@dataclass(frozen=True)
class SimpleProportionalCostModel(CostModel):
"""Simplified open-execution proportional cost model.
Slippage is represented as an additional cash cost. The execution price is
not adjusted by slippage, which avoids double-counting.
"""
cost_bps: float = 0.0
slippage_bps: float = 0.0
def compute(
self,
traded_shares: np.ndarray,
execution_price: np.ndarray,
side: np.ndarray,
date,
metadata: Mapping[str, object] | None = None,
) -> np.ndarray:
shares = np.asarray(traded_shares, dtype=np.float64)
price = np.asarray(execution_price, dtype=np.float64)
open_price = np.where(np.isfinite(price), price, 0.0)
trade_value = np.abs(shares * open_price)
return trade_value * (self.cost_bps + self.slippage_bps) / 1e4
+21 -13
View File
@@ -6,9 +6,10 @@ trading constraints. Metrics are return / Sharpe / turnover / max-drawdown /
convention that an alpha is a position weight, not a return predictor. convention that an alpha is a position weight, not a return predictor.
Return convention (documented): the target weight formed from information at Return convention (documented): the target weight formed from information at
date ``t`` earns the *next* period's close-to-close return, i.e. weights are date ``t`` is assumed tradable at ``open[t+1]`` and held until ``open[t+2]``.
shifted one day relative to realized returns, so there is no look-ahead: This is a costless approximation of the next-open execution path: no lots,
``R_t = sum_i w_{i,t} · r_{i,t+1}`` normalized by gross exposure. constraints, or costs, but no credit for an overnight gap that the new signal
could not have owned.
""" """
from __future__ import annotations from __future__ import annotations
@@ -26,39 +27,46 @@ def evaluate_portfolio(positions_df: pd.DataFrame, data_df: pd.DataFrame) -> dic
Args: Args:
positions_df: POSITION_COLUMNS (uses ``target_weight``; zero-gross positions_df: POSITION_COLUMNS (uses ``target_weight``; zero-gross
construction carry dates remain flat in this research view). construction carry dates remain flat in this research view).
data_df: DATA_COLUMNS (uses ``close`` for returns). data_df: DATA_COLUMNS (uses ``open`` for returns).
Returns: Returns:
Dict with ``cumulative_return, sharpe_annual, turnover_annual, Dict with ``cumulative_return, sharpe_annual, turnover_annual,
max_drawdown, fitness, hit_rate, n_dates``. No IC key. max_drawdown, fitness, hit_rate, n_dates``. No IC key.
""" """
close = data_df.pivot_table( open_ = data_df.pivot_table(
index="date", columns="symbol_id", values="close", aggfunc="first" index="date", columns="symbol_id", values="open", aggfunc="first"
).sort_index() ).sort_index()
returns = close.pct_change() fwd = open_.shift(-2).divide(open_.shift(-1)) - 1.0
weights = positions_df.pivot_table( weights = positions_df.pivot_table(
index="date", columns="symbol_id", values="target_weight", aggfunc="first" index="date", columns="symbol_id", values="target_weight", aggfunc="first"
).sort_index() ).sort_index()
common = weights.index.intersection(returns.index) common = weights.index.intersection(open_.index)
weights = weights.loc[common] weights = weights.loc[common]
returns = returns.loc[common] # Compute forward returns on the full market calendar before selecting
# signal dates. This preserves the next available open-to-open holding
# interval when the signal grid is sparser than the data grid.
fwd = fwd.reindex(common)
empty = { empty = {
"cumulative_return": 0.0, "sharpe_annual": 0.0, "turnover_annual": 0.0, "cumulative_return": 0.0, "sharpe_annual": 0.0, "turnover_annual": 0.0,
"max_drawdown": 0.0, "fitness": 0.0, "hit_rate": 0.0, "max_drawdown": 0.0, "fitness": 0.0, "hit_rate": 0.0,
"n_dates": len(common), "n_dates": len(common),
} }
if len(common) < 3: if len(common) < 1:
empty["n_dates"] = 0
return empty return empty
gross = weights.abs().sum(axis=1) gross = weights.abs().sum(axis=1)
# Weights at t earn the return from t to t+1: shift returns back by one. # Weights at t earn the costless tradable interval open[t+1] -> open[t+2].
fwd = returns.shift(-1) daily = (
daily = (weights * fwd).sum(axis=1) / gross.replace(0.0, np.nan) (weights * fwd).sum(axis=1, min_count=1)
/ gross.replace(0.0, np.nan)
)
daily = daily.dropna() daily = daily.dropna()
if len(daily) < 2: if len(daily) < 2:
empty["n_dates"] = int(len(daily))
return empty return empty
cumulative_return = float((1.0 + daily).prod() - 1.0) cumulative_return = float((1.0 + daily).prod() - 1.0)
+19 -7
View File
@@ -4,7 +4,8 @@ Execution model (documented convention): a position book targeted from
information available on date ``t`` is executed at ``open[t+1]``. Trades that information available on date ``t`` is executed at ``open[t+1]``. Trades that
violate a :class:`~pipeline.portfolio.constraints.TradeConstraint` (suspension, violate a :class:`~pipeline.portfolio.constraints.TradeConstraint` (suspension,
price limit, volume cap, …) are clipped; a fully blocked buy leaves the position price limit, volume cap, …) are clipped; a fully blocked buy leaves the position
at its previous level. Realized PnL marks the *actually filled* book. at its previous level. Realized PnL marks the *actually filled* book. Trading
cost defaults to a simplified open-execution proportional cash-cost model.
The simulator is an ABC + a :class:`ReferenceSimulator`; constraints compose by The simulator is an ABC + a :class:`ReferenceSimulator`; constraints compose by
intersecting their per-name signed delta bounds. intersecting their per-name signed delta bounds.
@@ -21,6 +22,7 @@ import pandas as pd
from pipeline.common.schema import FILL_COLUMNS, PNL_COLUMNS from pipeline.common.schema import FILL_COLUMNS, PNL_COLUMNS
from pipeline.portfolio.constraints import TradeConstraint from pipeline.portfolio.constraints import TradeConstraint
from pipeline.portfolio.costs import CostModel, SimpleProportionalCostModel
from pipeline.portfolio.market_rules import MarketRule, compute_limit_status from pipeline.portfolio.market_rules import MarketRule, compute_limit_status
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -65,10 +67,12 @@ class ExecutionSimulator(ABC):
"""Abstract execution layer. Subclasses define how a target gets filled.""" """Abstract execution layer. Subclasses define how a target gets filled."""
def __init__(self, constraints: list[TradeConstraint] | None = None, def __init__(self, constraints: list[TradeConstraint] | None = None,
cost_bps: float = 0.0, slippage_bps: float = 0.0): cost_bps: float = 0.0, slippage_bps: float = 0.0,
cost_model: CostModel | None = None):
self.constraints = constraints or [] self.constraints = constraints or []
self.cost_bps = cost_bps self.cost_model = cost_model or SimpleProportionalCostModel(
self.slippage_bps = slippage_bps cost_bps=cost_bps, slippage_bps=slippage_bps
)
@abstractmethod @abstractmethod
def fill(self, ctx: TradeContext) -> FillResult: def fill(self, ctx: TradeContext) -> FillResult:
@@ -104,9 +108,17 @@ class ReferenceSimulator(ExecutionSimulator):
blocked = (traded != desired).astype(np.int64) blocked = (traded != desired).astype(np.int64)
realized = prev + traded realized = prev + traded
open_px = np.where(np.isfinite(ctx.slice.price), ctx.slice.price, 0.0) cost = self.cost_model.compute(
trade_value = np.abs(traded.astype(np.float64) * open_px) traded_shares=traded,
cost = trade_value * (self.cost_bps + self.slippage_bps) / 1e4 execution_price=ctx.slice.price,
side=np.sign(traded),
date=ctx.slice.date,
metadata={
"symbol_ids": ctx.slice.symbol_ids,
"booksize": ctx.booksize,
"market_slice": ctx.slice,
},
)
return FillResult(realized, traded, cost, blocked) return FillResult(realized, traded, cost, blocked)
def run( def run(
+5 -1
View File
@@ -4,7 +4,6 @@ version = "0.1.0"
description = "A modular Chinese A-share quant research framework (daily frequency)." description = "A modular Chinese A-share quant research framework (daily frequency)."
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"backtrader>=1.9.76.123",
"akshare>=1.14.0", "akshare>=1.14.0",
"baostock>=0.8.8", "baostock>=0.8.8",
"pandas>=2.0.0", "pandas>=2.0.0",
@@ -13,6 +12,11 @@ dependencies = [
"pyarrow>=14.0.0", "pyarrow>=14.0.0",
] ]
[project.optional-dependencies]
backtrader = [
"backtrader>=1.9.76.123",
]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pytest>=7.0.0", "pytest>=7.0.0",
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# End-to-end run of the outlier-robust reversal_rank alpha on the full
# all-universe dataset and on a per-date liquid subset. Records per-phase
# wall-clock time to reports/reversal_rank_timings.json.
set -euo pipefail
cd "$(dirname "$0")/.."
DATA=data/daily_bars/all
BOOK=10000000
TIMINGS=reports/reversal_rank_timings.json
mkdir -p reports
echo "{" > "$TIMINGS"
run() { # run <json_key> <cmd...>
local key="$1"; shift
local t0 t1
t0=$(date +%s.%N)
"$@"
t1=$(date +%s.%N)
printf ' "%s": %.2f,\n' "$key" "$(echo "$t1 - $t0" | bc)" >> "$TIMINGS"
echo ">>> $key took $(echo "$t1 - $t0" | bc)s"
}
# ---- full all-universe, robust rank weighting ----
run full_alpha_compute uv run python cli.py alpha compute --data-path "$DATA" \
--alpha-name reversal_rank_all --alpha-type reversal_rank --lookback 5 --output-dir alphas
run full_alpha_eval uv run python cli.py alpha eval \
--alpha-path alphas/reversal_rank_all.pq --data-path "$DATA"
run full_combo uv run python cli.py combo combine \
--alpha-paths alphas/reversal_rank_all.pq --combo-name reversal_rank_all_combo \
--method equal_weight --output-dir combos
run full_portfolio_build uv run python cli.py portfolio build \
--weights-path combos/reversal_rank_all_combo.pq --data-path "$DATA" \
--booksize "$BOOK" --portfolio-name reversal_rank_all_10m --output-dir portfolio
run full_portfolio_eval uv run python cli.py portfolio eval \
--positions-path portfolio/reversal_rank_all_10m.pq --data-path "$DATA"
run full_portfolio_simulate uv run python cli.py portfolio simulate \
--positions-path portfolio/reversal_rank_all_10m.pq --data-path "$DATA" \
--constraint suspension --constraint price_limit --constraint volume_cap \
--cost-bps 5 --slippage-bps 5 --output-dir portfolio
# ---- liquid subset (per-date investable universe), robust rank weighting ----
run liq_alpha_compute uv run python cli.py alpha compute --data-path "$DATA" \
--alpha-name reversal_rank_liq --alpha-type reversal_rank --lookback 5 \
--liquid-universe --universe-top-n 1000 --output-dir alphas
run liq_alpha_eval uv run python cli.py alpha eval \
--alpha-path alphas/reversal_rank_liq.pq --data-path "$DATA"
run liq_combo uv run python cli.py combo combine \
--alpha-paths alphas/reversal_rank_liq.pq --combo-name reversal_rank_liq_combo \
--method equal_weight --output-dir combos
run liq_portfolio_build uv run python cli.py portfolio build \
--weights-path combos/reversal_rank_liq_combo.pq --data-path "$DATA" \
--booksize "$BOOK" --portfolio-name reversal_rank_liq_10m --output-dir portfolio
run liq_portfolio_eval uv run python cli.py portfolio eval \
--positions-path portfolio/reversal_rank_liq_10m.pq --data-path "$DATA"
run liq_portfolio_simulate uv run python cli.py portfolio simulate \
--positions-path portfolio/reversal_rank_liq_10m.pq --data-path "$DATA" \
--constraint suspension --constraint price_limit --constraint volume_cap \
--cost-bps 5 --slippage-bps 5 --output-dir portfolio
printf ' "_done": true\n}\n' >> "$TIMINGS"
echo "Wrote $TIMINGS"
+173 -4
View File
@@ -6,7 +6,11 @@ import pandas as pd
import pytest import pytest
from pipeline.alpha.base import BaseAlpha from pipeline.alpha.base import BaseAlpha
from pipeline.alpha.compute import compute_alpha, evaluate_alpha from pipeline.alpha.compute import (
compute_alpha,
evaluate_alpha,
investable_universe_mask,
)
from pipeline.alpha.registry import ( from pipeline.alpha.registry import (
available_alphas, available_alphas,
get_alpha, get_alpha,
@@ -51,7 +55,7 @@ def test_reversal_sign_matches_negative_trailing_return():
data = _make_data() data = _make_data()
alpha = compute_alpha(data, "rev5", "reversal", lookback=5) alpha = compute_alpha(data, "rev5", "reversal", lookback=5)
close = data.pivot_table(index="date", columns="symbol_id", values="close").sort_index() close = data.pivot_table(index="date", columns="symbol_id", values="close").sort_index()
raw = -close.pct_change(5) raw = -close.pct_change(5, fill_method=None)
last = raw.index[-1] last = raw.index[-1]
expected_top = raw.loc[last].idxmax() expected_top = raw.loc[last].idxmax()
got = alpha[alpha["date"] == last].set_index("symbol_id")["weight"].idxmax() got = alpha[alpha["date"] == last].set_index("symbol_id")["weight"].idxmax()
@@ -74,6 +78,83 @@ def test_evaluate_alpha_keys():
assert key in metrics assert key in metrics
def test_evaluate_alpha_uses_next_open_to_next_open_returns():
dates = pd.date_range("2024-01-01", periods=5)
data = pd.concat([
pd.DataFrame({
"symbol_id": "sh600000",
"symbol_name": "sh600000",
"date": dates,
"open": [100.0, 100.0, 100.0, 100.0, 200.0],
"high": [100.0, 1000.0, 1000.0, 1000.0, 1000.0],
"low": [100.0, 1000.0, 1000.0, 1000.0, 1000.0],
"close": [100.0, 1000.0, 1000.0, 1000.0, 1000.0],
"volume": 1_000.0,
"amount": 1_000.0,
}),
pd.DataFrame({
"symbol_id": "sz000001",
"symbol_name": "sz000001",
"date": dates,
"open": [100.0, 100.0, 100.0, 200.0, 200.0],
"high": [100.0, 10.0, 10.0, 10.0, 10.0],
"low": [100.0, 10.0, 10.0, 10.0, 10.0],
"close": [100.0, 10.0, 10.0, 10.0, 10.0],
"volume": 1_000.0,
"amount": 1_000.0,
}),
], ignore_index=True)
alpha = pd.DataFrame({
"symbol_id": ["sh600000", "sz000001", "sh600000", "sz000001"],
"date": [dates[1], dates[1], dates[2], dates[2]],
"alpha_name": ["toy"] * 4,
"weight": [-1.0, 1.0, 1.0, -1.0],
})
metrics = evaluate_alpha(alpha, data)
assert metrics["n_dates"] == 2
assert np.isclose(metrics["cumulative_return"], 1.25)
def test_evaluate_alpha_excludes_signal_without_forward_return():
dates = pd.date_range("2024-01-01", periods=3)
data = pd.concat([
pd.DataFrame({
"symbol_id": "sh600000",
"symbol_name": "sh600000",
"date": dates,
"open": [100.0, 100.0, 200.0],
"high": [100.0, 100.0, 200.0],
"low": [100.0, 100.0, 200.0],
"close": [100.0, 100.0, 200.0],
"volume": 1_000.0,
"amount": 1_000.0,
}),
pd.DataFrame({
"symbol_id": "sz000001",
"symbol_name": "sz000001",
"date": dates,
"open": [100.0, 100.0, 100.0],
"high": [100.0, 100.0, 100.0],
"low": [100.0, 100.0, 100.0],
"close": [100.0, 100.0, 100.0],
"volume": 1_000.0,
"amount": 1_000.0,
}),
], ignore_index=True)
alpha = pd.DataFrame({
"symbol_id": ["sh600000", "sz000001", "sh600000", "sz000001"],
"date": [dates[0], dates[0], dates[1], dates[1]],
"alpha_name": ["toy"] * 4,
"weight": [1.0, -1.0, -1.0, 1.0],
})
metrics = evaluate_alpha(alpha, data)
assert metrics["n_dates"] == 1
def test_equal_weight_is_mean_of_alphas(): def test_equal_weight_is_mean_of_alphas():
data = _make_data() data = _make_data()
a = compute_alpha(data, "rev", "reversal", lookback=5) a = compute_alpha(data, "rev", "reversal", lookback=5)
@@ -95,10 +176,25 @@ def test_combine_alphas_schema(tmp_path):
assert (combo["combo_name"] == "eq").all() assert (combo["combo_name"] == "eq").all()
def test_combine_single_alpha_is_identity(tmp_path):
data = _make_data()
a = compute_alpha(data, "rev", "reversal", lookback=5)
a_path = tmp_path / "a.pq"
a.to_parquet(a_path, index=False)
combo = combine_alphas([str(a_path)], "rev_combo", method="equal_weight")
expected = a[["symbol_id", "date", "weight"]].reset_index(drop=True)
got = combo[["symbol_id", "date", "weight"]].reset_index(drop=True)
pd.testing.assert_frame_equal(got, expected)
assert list(combo.columns) == COMBO_COLUMNS
assert (combo["combo_name"] == "rev_combo").all()
# --- registry / factory ----------------------------------------------------- # --- registry / factory -----------------------------------------------------
def test_builtins_are_registered(): def test_builtins_are_registered():
assert {"reversal", "reversal_vol", "momentum"} <= set(available_alphas()) assert {"reversal", "reversal_vol", "momentum", "reversal_rank"} <= set(available_alphas())
def test_get_alpha_filters_unaccepted_params(): def test_get_alpha_filters_unaccepted_params():
@@ -163,7 +259,7 @@ def test_load_external_alpha_module(tmp_path):
self.span = span self.span = span
def signal(self, close: pd.DataFrame) -> pd.DataFrame: def signal(self, close: pd.DataFrame) -> pd.DataFrame:
return -close.pct_change(self.span) return -close.pct_change(self.span, fill_method=None)
''')) '''))
load_alpha_module(str(module_path)) load_alpha_module(str(module_path))
@@ -178,3 +274,76 @@ def test_load_external_alpha_module(tmp_path):
assert list(result.columns) == ALPHA_COLUMNS assert list(result.columns) == ALPHA_COLUMNS
assert (result["alpha_name"] == "ext").all() assert (result["alpha_name"] == "ext").all()
# --- rank reversal + investable universe filter ------------------------------
def _make_rich_data(n_days: int = 70, symbols=("sh600000", "sz000001", "sh600519", "sz300750")):
"""Long-format data with the columns the universe filter needs."""
dates = pd.date_range("2024-01-01", periods=n_days)
rng = np.random.default_rng(1)
frames = []
for i, sym in enumerate(symbols):
close = 100.0 + i * 5 + np.cumsum(rng.standard_normal(n_days))
frames.append(pd.DataFrame({
"symbol_id": sym,
"symbol_name": sym,
"date": dates,
"open": close, "high": close, "low": close, "close": close,
"volume": 1_000.0,
"amount": (1_000.0 + i * 5_000.0) * close, # higher i = more liquid
"isST": 0,
"tradestatus": 1,
}))
return pd.concat(frames, ignore_index=True)
def test_reversal_rank_registered_and_bounded():
data = _make_data(n_days=30)
alpha = compute_alpha(data, "rr", "reversal_rank", lookback=5)
assert list(alpha.columns) == ALPHA_COLUMNS
# Rank-demeaned weights are per-date zero-mean and bounded by the
# cross-section size, never blowing up the way a z-score outlier can.
per_date_mean = alpha.groupby("date")["weight"].mean().abs()
assert (per_date_mean < 1e-9).all()
assert alpha["weight"].abs().max() <= len(data["symbol_id"].unique())
def test_investable_universe_mask_excludes_st_and_suspended():
data = _make_rich_data()
# Flag one name ST throughout, and suspend another on the last date.
data.loc[data["symbol_id"] == "sh600000", "isST"] = 1
last = data["date"].max()
data.loc[(data["symbol_id"] == "sz000001") & (data["date"] == last), "tradestatus"] = 0
close = data.pivot_table(index="date", columns="symbol_id", values="close").sort_index()
mask = investable_universe_mask(data, close, top_n=10, min_history=5)
assert not mask["sh600000"].any() # ST excluded on every date
assert not bool(mask.loc[last, "sz000001"]) # suspended on the last date
assert bool(mask.loc[last, "sh600519"]) # a normal name stays investable
def test_compute_alpha_universe_filter_zeros_excluded_names():
data = _make_rich_data()
data.loc[data["symbol_id"] == "sh600000", "isST"] = 1
alpha = compute_alpha(
data, "rr_liq", "reversal_rank", lookback=5,
universe={"top_n": 10, "min_history": 5},
)
# The ST name is never held; an investable name is.
st_w = alpha.loc[alpha["symbol_id"] == "sh600000", "weight"]
assert (st_w.fillna(0.0) == 0.0).all()
assert alpha.loc[alpha["symbol_id"] == "sz300750", "weight"].abs().sum() > 0.0
def test_universe_filter_does_not_corrupt_signal_history():
# Masking happens on the signal, not the price history, so weights on
# investable names match the unfiltered weights restricted to that set.
data = _make_rich_data()
universe = {"top_n": 2, "min_history": 5} # keep only the 2 most liquid names
filtered = compute_alpha(data, "f", "reversal_rank", lookback=5, universe=universe)
held = set(filtered.loc[filtered["weight"] != 0.0, "symbol_id"].unique())
# The two most liquid names (highest amount) are sh600519, sz300750.
assert held == {"sh600519", "sz300750"}
+305
View File
@@ -0,0 +1,305 @@
"""End-to-end correctness invariants for the reversal_5d pipeline (no network).
Each test maps 1:1 to one of the ten review checks. Naming convention: the
*execution date* ``d`` is the market session on which a target is actually
filled at the open; the *signal date* ``t`` is the session whose close formed
that target. The documented convention is ``d = next(t)`` (see
``docs/portfolio_trading_cost_model.md``), so ``close[d-1] == close[t]``.
"""
import numpy as np
import pandas as pd
from pipeline.alpha.compute import compute_alpha, investable_universe_mask
from pipeline.portfolio.construct import construct_positions
from pipeline.portfolio.discretize import repair_exposure, round_to_valid_lot
from pipeline.portfolio.market_rules import MarketRule
from pipeline.portfolio.costs import SimpleProportionalCostModel
from pipeline.portfolio.constraints import (
SuspensionConstraint,
VolumeCapConstraint,
)
from pipeline.portfolio.simulator import ReferenceSimulator
_SYMBOLS = ("sh600000", "sz000001", "sh688981", "sz300750")
def _panel(n_days=12, symbols=_SYMBOLS, start="2024-01-01", seed=0,
distinct_open=True):
"""Contiguous long-format DATA frame with all columns the pipeline needs.
Open and close differ (so overnight vs intraday PnL terms are separable),
and the calendar is gap-free so each session is the next session's ``d-1``.
"""
dates = pd.date_range(start, periods=n_days)
rng = np.random.default_rng(seed)
frames = []
for i, sym in enumerate(symbols):
close = np.abs(50.0 + i * 10 + np.cumsum(rng.standard_normal(n_days))) + 5.0
open_ = close * (0.99 + 0.02 * rng.random(n_days)) if distinct_open else close.copy()
preclose = np.concatenate([[close[0]], close[:-1]])
frames.append(pd.DataFrame({
"symbol_id": sym,
"symbol_name": sym,
"date": dates,
"open": open_,
"high": np.maximum(open_, close),
"low": np.minimum(open_, close),
"close": close,
"preclose": preclose,
"volume": 1_000_000.0,
"amount": 1_000_000.0 * close,
"tradestatus": 1,
"isST": 0,
}))
return pd.concat(frames, ignore_index=True)
# --- 1. reversal signal uses only close[d-1] and earlier ---------------------
def test_reversal_signal_does_not_peek_at_future_closes():
data = _panel(n_days=12)
base = compute_alpha(data, "rev", "reversal_rank", lookback=5)
# Perturb every close strictly AFTER an interior signal date t; the weight
# dated t (executed at d = t+1) must be unchanged — it may use close[t]
# (== close[d-1]) and earlier only.
t = sorted(data["date"].unique())[6]
future = data.copy()
mask = future["date"] > t
future.loc[mask, ["open", "high", "low", "close"]] *= 1.5
perturbed = compute_alpha(future, "rev", "reversal_rank", lookback=5)
b = base[base["date"] <= t].set_index(["symbol_id", "date"])["weight"]
p = perturbed[perturbed["date"] <= t].set_index(["symbol_id", "date"])["weight"]
pd.testing.assert_series_equal(b.sort_index(), p.sort_index())
# --- 2. the executed (fill/PnL) date is the open-execution date d = next(t) --
def test_fill_date_is_next_session_open_execution_date():
data = _panel(n_days=8)
weights = compute_alpha(data, "c", "reversal_rank", lookback=3)
weights = weights.rename(columns={"alpha_name": "combo_name"})
weights["combo_name"] = "c"
pos = construct_positions(weights, data, booksize=1e6, portfolio_name="run1")
fills, pnl = ReferenceSimulator(cost_bps=5, slippage_bps=5).run(pos, data)
sessions = sorted(data["date"].unique())
nxt = {s: sessions[i + 1] for i, s in enumerate(sessions[:-1])}
# Every executed date equals the session AFTER some position (signal) date.
pos_dates = set(pos["date"].unique())
exec_dates = set(pnl["date"].unique())
assert exec_dates == {nxt[t] for t in pos_dates if t in nxt}
# Execution price is the open of the execution date, not the signal close.
opn = data.pivot_table(index="date", columns="symbol_id", values="open",
aggfunc="first").sort_index()
d = sorted(exec_dates)[1]
row = fills[(fills["date"] == d) & (fills["traded_shares"] != 0)].iloc[0]
sym = row["symbol_id"]
expected_cost = abs(row["traded_shares"]) * opn.loc[d, sym] * (5 + 5) / 1e4
assert np.isclose(row["trade_cost"], expected_cost)
# --- 3. PnL identity: overnight(old book) + intraday(new book) - cost --------
def test_daily_pnl_matches_overnight_plus_intraday_minus_cost():
data = _panel(n_days=8)
weights = compute_alpha(data, "c", "reversal_rank", lookback=3)
weights = weights.rename(columns={"alpha_name": "combo_name"})
weights["combo_name"] = "c"
pos = construct_positions(weights, data, booksize=1e6, portfolio_name="run1")
fills, pnl = ReferenceSimulator(cost_bps=5, slippage_bps=5).run(pos, data)
opn = data.pivot_table(index="date", columns="symbol_id", values="open", aggfunc="first").sort_index()
cls = data.pivot_table(index="date", columns="symbol_id", values="close", aggfunc="first").sort_index()
sessions = list(cls.index)
prev_close_of = {sessions[i]: sessions[i - 1] for i in range(1, len(sessions))}
for d in sorted(pnl["date"].unique()):
day = fills[fills["date"] == d]
prev = day.set_index("symbol_id")["prev_shares"]
realized = day.set_index("symbol_id")["realized_shares"]
cost = day["trade_cost"].sum()
intraday = float((realized * (cls.loc[d] - opn.loc[d]).reindex(realized.index)).sum())
# Overnight gap on the OLD book is taken from the previous *executed*
# date's close. With a gap-free calendar and daily execution that is the
# immediately preceding session; the first executed date has no prior
# book so the term is naturally zero (prev_shares == 0 there).
pc = prev_close_of.get(d)
if pc is not None and (prev != 0).any():
overnight = float((prev * (opn.loc[d] - cls.loc[pc]).reindex(prev.index)).sum())
else:
overnight = 0.0
expected = overnight + intraday - cost
got = float(pnl[pnl["date"] == d]["pnl"].iloc[0])
assert np.isclose(got, expected, rtol=1e-6, atol=1e-3), (d, got, expected)
# --- 4. realized shares (not target shares) are threaded into the next day ---
def test_realized_not_target_threaded_forward():
data = _panel(n_days=6)
weights = compute_alpha(data, "c", "reversal_rank", lookback=2)
weights = weights.rename(columns={"alpha_name": "combo_name"})
weights["combo_name"] = "c"
pos = construct_positions(weights, data, booksize=1e8, portfolio_name="run1")
# A tight volume cap forces partial fills, so realized != target on most
# names — exactly the case where threading target vs realized diverges.
fills, _ = ReferenceSimulator(
constraints=[VolumeCapConstraint(max_frac=1e-6)]
).run(pos, data)
wide_prev = fills.pivot_table(index="date", columns="symbol_id", values="prev_shares", aggfunc="first")
wide_real = fills.pivot_table(index="date", columns="symbol_id", values="realized_shares", aggfunc="first")
exec_dates = list(wide_prev.index)
assert len(exec_dates) >= 2
# Today's prev_shares == yesterday's realized_shares for every name.
for a, b in zip(exec_dates[:-1], exec_dates[1:]):
prev_today = wide_prev.loc[b].dropna()
real_yest = wide_real.loc[a].reindex(prev_today.index).fillna(0.0)
pd.testing.assert_series_equal(
prev_today.astype(float), real_yest.astype(float), check_names=False
)
# And realized actually diverged from target (cap bit), so the test is real.
assert (fills["realized_shares"] != fills["target_shares"]).any()
# --- 5. blocked trades create zero traded_shares and zero trade_cost ---------
def test_blocked_trade_has_zero_shares_and_zero_cost():
data = _panel(n_days=6)
# Suspend one name on every session so any attempt to trade it is blocked.
data.loc[data["symbol_id"] == "sz000001", "tradestatus"] = 0
weights = compute_alpha(data, "c", "reversal_rank", lookback=2)
weights = weights.rename(columns={"alpha_name": "combo_name"})
weights["combo_name"] = "c"
pos = construct_positions(weights, data, booksize=1e6, portfolio_name="run1")
fills, _ = ReferenceSimulator(
constraints=[SuspensionConstraint()], cost_bps=5, slippage_bps=5
).run(pos, data)
blocked = fills[fills["blocked"] == 1]
assert (blocked["traded_shares"] == 0).all()
assert (blocked["trade_cost"] == 0.0).all()
# The suspended name never trades and never accrues cost.
susp = fills[fills["symbol_id"] == "sz000001"]
assert (susp["traded_shares"] == 0).all()
assert (susp["trade_cost"] == 0.0).all()
# --- 6. liquid universe uses only information known before open[d] -----------
def test_investable_universe_mask_is_causal():
data = _panel(n_days=14)
close = data.pivot_table(index="date", columns="symbol_id", values="close", aggfunc="first").sort_index()
full = investable_universe_mask(data, close, top_n=10, min_history=3)
t = sorted(data["date"].unique())[8]
# Recompute the mask from data truncated at the signal date t: the mask row
# for t must be identical, proving it never reads dates > t (i.e. nothing
# from open[d=t+1] onward).
trunc = data[data["date"] <= t]
close_t = close.loc[:t]
mask_t = investable_universe_mask(trunc, close_t, top_n=10, min_history=3)
pd.testing.assert_series_equal(
full.loc[t].sort_index(), mask_t.loc[t].sort_index(), check_names=False
)
# --- 7. cost bps is one-way per-trade (a round trip is charged twice) --------
def test_cost_bps_is_one_way_per_trade():
model = SimpleProportionalCostModel(cost_bps=5, slippage_bps=5)
price = np.array([20.0])
buy = model.compute(np.array([1000]), price, np.array([1]), date=None)
sell = model.compute(np.array([-1000]), price, np.array([-1]), date=None)
one_way = 1000 * 20 * (5 + 5) / 1e4
assert np.isclose(buy[0], one_way) # charged once on the buy leg
assert np.isclose(sell[0], one_way) # charged again on the sell leg
# A full round trip (enter then exit) therefore costs ~2x the one-way rate.
assert np.isclose(buy[0] + sell[0], 2 * one_way)
# --- 8. execution & PnL use raw tradable prices on the same scale as shares --
def test_position_value_is_shares_times_raw_price():
data = _panel(n_days=10)
weights = compute_alpha(data, "c", "reversal_rank", lookback=3)
weights = weights.rename(columns={"alpha_name": "combo_name"})
weights["combo_name"] = "c"
pos = construct_positions(weights, data, booksize=1e6, portfolio_name="run1")
finite = pos["price"] > 0
# The stored value is exactly integer shares × the raw construction price —
# no adjusted-price factor is mixed into the share→value accounting.
expected = pos.loc[finite, "position_shares"] * pos.loc[finite, "price"]
pd.testing.assert_series_equal(
pos.loc[finite, "position_value"].astype(float),
expected.astype(float),
check_names=False,
)
# --- 9. alpha is scale-free (adjusted prices ok); accounting uses raw units --
def test_alpha_weights_invariant_to_per_symbol_price_scaling():
data = _panel(n_days=12)
base = compute_alpha(data, "rev", "reversal_rank", lookback=5)
# A qfq/hfq adjustment is (per symbol) a multiplicative rescaling of the
# price series; pct_change is scale-free, so the alpha weights must not move.
scaled = data.copy()
factor = {"sh600000": 2.0, "sz000001": 0.5, "sh688981": 3.0, "sz300750": 1.25}
for sym, f in factor.items():
m = scaled["symbol_id"] == sym
scaled.loc[m, ["open", "high", "low", "close"]] *= f
scaled_alpha = compute_alpha(scaled, "rev", "reversal_rank", lookback=5)
b = base.set_index(["symbol_id", "date"])["weight"].sort_index()
s = scaled_alpha.set_index(["symbol_id", "date"])["weight"].sort_index()
pd.testing.assert_series_equal(b, s)
# --- 10. repaired book stays on valid A-share lot lattices -------------------
def _on_lattice(q, min_open, increment):
q = np.abs(np.asarray(q, dtype=np.int64))
on = (q == 0) | ((q >= min_open) & ((q - min_open) % increment == 0))
return bool(on.all())
def test_repair_output_stays_on_lot_lattice():
# Pre-2023 main board has a 100-share increment (the strongest lattice
# constraint); STAR uses min 200 / increment 1.
symbols = np.array(["sh600000", "sz000001", "sh688981", "sz300750"], dtype=object)
rule = MarketRule()
on = "2022-06-01" # pre 2023-08-10 → main-board increment is 100
min_open, increment, odd_full, _ = rule.get_rules_vectorized(
symbols, on, np.zeros(len(symbols), dtype=bool)
)
assert min_open[0] == 100 and increment[0] == 100 # main board pre-2023
price = np.array([12.3, 8.7, 45.0, 230.0])
prev = np.zeros(len(symbols), dtype=np.int64)
q_target = np.array([3251.0, -7777.0, 640.0, -415.0])
q_round = round_to_valid_lot(q_target, prev, min_open, increment, odd_full)
assert _on_lattice(q_round, min_open, increment)
repaired = repair_exposure(
q_round, q_target, price, increment, min_open, prev, odd_full,
booksize=float(np.abs(q_target * price).sum()),
)
assert _on_lattice(repaired, min_open, increment)
# Repair never flips a name's sign relative to the rounded book.
nz = q_round != 0
assert np.all(np.sign(repaired[nz]) * np.sign(q_round[nz]) >= 0)
+90 -1
View File
@@ -22,6 +22,7 @@ from pipeline.portfolio.constraints import (
SuspensionConstraint, SuspensionConstraint,
VolumeCapConstraint, VolumeCapConstraint,
) )
from pipeline.portfolio.costs import SimpleProportionalCostModel
from pipeline.portfolio.simulator import ( from pipeline.portfolio.simulator import (
MarketSlice, MarketSlice,
ReferenceSimulator, ReferenceSimulator,
@@ -64,7 +65,7 @@ def _make_data(n_days: int = 40, symbols=_SYMBOLS, start="2024-01-01",
def _make_weights(data: pd.DataFrame, name="combo") -> pd.DataFrame: def _make_weights(data: pd.DataFrame, name="combo") -> pd.DataFrame:
"""Demeaned per-date signed weights so the cross-section is dollar-neutral.""" """Demeaned per-date signed weights so the cross-section is dollar-neutral."""
close = data.pivot_table(index="date", columns="symbol_id", values="close").sort_index() close = data.pivot_table(index="date", columns="symbol_id", values="close").sort_index()
raw = -close.pct_change(5) raw = -close.pct_change(5, fill_method=None)
demeaned = raw.sub(raw.mean(axis=1), axis=0) demeaned = raw.sub(raw.mean(axis=1), axis=0)
long = demeaned.reset_index().melt(id_vars="date", var_name="symbol_id", long = demeaned.reset_index().melt(id_vars="date", var_name="symbol_id",
value_name="weight").dropna() value_name="weight").dropna()
@@ -445,6 +446,7 @@ def test_simulator_blocked_buy_when_suspended():
assert res.traded_shares[0] == 0 assert res.traded_shares[0] == 0
assert res.realized_shares[0] == 0 assert res.realized_shares[0] == 0
assert res.blocked[0] == 1 assert res.blocked[0] == 1
assert res.cost[0] == 0.0
def test_simulator_cost_is_positive_when_trading(): def test_simulator_cost_is_positive_when_trading():
@@ -458,6 +460,69 @@ def test_simulator_cost_is_positive_when_trading():
assert np.isclose(res.cost[0], 1000 * 20 * 15 / 1e4) assert np.isclose(res.cost[0], 1000 * 20 * 15 / 1e4)
def test_simulator_cost_only_on_nonzero_realized_trades():
n = 2
sim = ReferenceSimulator(constraints=[], cost_bps=10)
sl = _slice(n, price=np.array([10.0, 20.0]))
ctx = TradeContext(np.array([100, 100], np.int64),
np.array([100, 150], np.int64), sl, 1e6)
res = sim.fill(ctx)
assert res.traded_shares.tolist() == [0, 50]
assert res.cost[0] == 0.0
assert np.isclose(res.cost[1], 50 * 20 * 10 / 1e4)
def test_simple_cost_model_adds_cost_and_slippage_without_price_adjustment():
model = SimpleProportionalCostModel(cost_bps=10, slippage_bps=5)
cost = model.compute(
traded_shares=np.array([1000, -1000]),
execution_price=np.array([20.0, 20.0]),
side=np.array([1, -1]),
date=dt.date(2024, 1, 2),
)
assert np.allclose(cost, np.array([30.0, 30.0]))
def test_daily_pnl_cost_matches_fill_trade_cost_sum():
dates = pd.to_datetime(["2024-01-01", "2024-01-02"])
positions = pd.DataFrame({
"symbol_id": ["sh600000", "sz000001"],
"date": [dates[0], dates[0]],
"portfolio_name": ["run1", "run1"],
"target_weight": [0.5, -0.5],
"target_value": [1000.0, -1000.0],
"target_shares": [100.0, -50.0],
"position_shares": [100, -50],
"position_value": [1000.0, -1000.0],
"price": [10.0, 20.0],
})
data = pd.DataFrame([
{
"symbol_id": sym,
"date": d,
"open": price,
"close": price,
"preclose": price,
"amount": 1e9,
"tradestatus": 1,
"isST": 0,
}
for d in dates
for sym, price in (("sh600000", 10.0), ("sz000001", 20.0))
])
fills, pnl = ReferenceSimulator(cost_bps=10, slippage_bps=5).run(positions, data)
total_fill_cost = fills["trade_cost"].sum()
assert np.isclose(total_fill_cost, 3.0)
assert np.isclose(pnl["cost"].iloc[0], total_fill_cost)
assert np.isclose(pnl["pnl"].iloc[0], -total_fill_cost)
# --- evaluate_portfolio ------------------------------------------------------ # --- evaluate_portfolio ------------------------------------------------------
def test_evaluate_portfolio_keys_no_ic(): def test_evaluate_portfolio_keys_no_ic():
@@ -470,3 +535,27 @@ def test_evaluate_portfolio_keys_no_ic():
assert key in metrics assert key in metrics
assert "ic" not in metrics assert "ic" not in metrics
assert "rank_ic" not in metrics assert "rank_ic" not in metrics
def test_evaluate_portfolio_excludes_signal_without_forward_return():
dates = pd.date_range("2024-01-01", periods=3)
data = pd.DataFrame([
{"symbol_id": sym, "date": d, "open": price, "close": price}
for d, prices in zip(dates, [(100.0, 100.0), (100.0, 100.0), (200.0, 100.0)])
for sym, price in zip(("sh600000", "sz000001"), prices)
])
positions = pd.DataFrame({
"symbol_id": ["sh600000", "sz000001", "sh600000", "sz000001"],
"date": [dates[0], dates[0], dates[1], dates[1]],
"portfolio_name": ["run1"] * 4,
"target_weight": [0.5, -0.5, -0.5, 0.5],
"target_value": [500.0, -500.0, -500.0, 500.0],
"target_shares": [5.0, -5.0, -2.5, 5.0],
"position_shares": [5, -5, -2, 5],
"position_value": [500.0, -500.0, -400.0, 500.0],
"price": [100.0, 100.0, 200.0, 100.0],
})
metrics = evaluate_portfolio(positions, data)
assert metrics["n_dates"] == 1
Generated
+7 -2
View File
@@ -284,7 +284,6 @@ version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "akshare" }, { name = "akshare" },
{ name = "backtrader" },
{ name = "baostock" }, { name = "baostock" },
{ name = "click" }, { name = "click" },
{ name = "matplotlib" }, { name = "matplotlib" },
@@ -293,6 +292,11 @@ dependencies = [
{ name = "pyarrow" }, { name = "pyarrow" },
] ]
[package.optional-dependencies]
backtrader = [
{ name = "backtrader" },
]
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "pytest" }, { name = "pytest" },
@@ -301,13 +305,14 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "akshare", specifier = ">=1.14.0" }, { name = "akshare", specifier = ">=1.14.0" },
{ name = "backtrader", specifier = ">=1.9.76.123" }, { name = "backtrader", marker = "extra == 'backtrader'", specifier = ">=1.9.76.123" },
{ name = "baostock", specifier = ">=0.8.8" }, { name = "baostock", specifier = ">=0.8.8" },
{ name = "click", specifier = ">=8.0.0" }, { name = "click", specifier = ">=8.0.0" },
{ name = "matplotlib", specifier = ">=3.7.0" }, { name = "matplotlib", specifier = ">=3.7.0" },
{ name = "pandas", specifier = ">=2.0.0" }, { name = "pandas", specifier = ">=2.0.0" },
{ name = "pyarrow", specifier = ">=14.0.0" }, { name = "pyarrow", specifier = ">=14.0.0" },
] ]
provides-extras = ["backtrader"]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=7.0.0" }] dev = [{ name = "pytest", specifier = ">=7.0.0" }]