Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17fa75495d | |||
| 3c58a1372e | |||
| 16b4988f16 | |||
| 2c0ca53bd6 | |||
| 2ceac82325 | |||
| b7dd94b032 | |||
| 07ed6ad917 | |||
| 0a6f367fbf | |||
| 534b91aaa4 | |||
| 4a477b8f75 |
@@ -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`).
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
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
|
||||
|
||||
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).
|
||||
|
||||
@@ -48,6 +48,13 @@ The env is managed with [uv](https://docs.astral.sh/uv/). `uv sync` builds `.ven
|
||||
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
|
||||
@@ -203,7 +210,9 @@ uv run python cli.py portfolio build \
|
||||
|
||||
Executes the constructed `position_shares` book at the next available open,
|
||||
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 |
|
||||
| --- | --- | --- |
|
||||
@@ -401,6 +410,9 @@ constructed positions, fills/costs, P&L, and target-weight research metrics.
|
||||
- [x] **Reference execution simulation** — next-open fills over constructed
|
||||
`position_shares`, with suspension, price-limit, volume-cap, transaction-cost,
|
||||
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
|
||||
daily data, track simulated fills and a running P&L without real capital.
|
||||
- [ ] **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 |
@@ -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.
|
||||
|
||||

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

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

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

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

|
||||
|
||||
`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
@@ -64,8 +64,17 @@ def list_(alpha_modules):
|
||||
"--param", "extra_params", multiple=True,
|
||||
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,
|
||||
alpha_modules, extra_params):
|
||||
alpha_modules, extra_params, liquid_universe, universe_top_n):
|
||||
"""Compute one alpha from raw data and save as parquet."""
|
||||
for spec in alpha_modules:
|
||||
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.update(_parse_params(extra_params))
|
||||
|
||||
universe = {"top_n": universe_top_n} if liquid_universe else None
|
||||
|
||||
data = pd.read_parquet(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,
|
||||
alpha_name=alpha_name,
|
||||
alpha_type=alpha_type,
|
||||
universe=universe,
|
||||
**params,
|
||||
)
|
||||
|
||||
|
||||
+120
-17
@@ -25,15 +25,86 @@ def _pivot_close(df: pd.DataFrame) -> pd.DataFrame:
|
||||
return pivot.sort_index()
|
||||
|
||||
|
||||
def _daily_returns(close: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Compute daily returns from wide close DataFrame."""
|
||||
return close.pct_change()
|
||||
def _pivot_open(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Pivot data to wide format: date index, columns = symbol_id, values = open."""
|
||||
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(
|
||||
data: pd.DataFrame,
|
||||
alpha_name: str,
|
||||
alpha_type: str,
|
||||
universe: dict | None = None,
|
||||
**params,
|
||||
) -> pd.DataFrame:
|
||||
"""Compute alpha weights from raw data.
|
||||
@@ -42,6 +113,11 @@ def compute_alpha(
|
||||
data: DataFrame with DATA_COLUMNS.
|
||||
alpha_name: Label stored in the ``alpha_name`` output column.
|
||||
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``,
|
||||
``vol_window``). Only the params the alpha's ``__init__`` accepts are
|
||||
used; extras are ignored.
|
||||
@@ -54,7 +130,12 @@ def compute_alpha(
|
||||
"""
|
||||
alpha = get_alpha(alpha_type, **params)
|
||||
close = _pivot_close(data)
|
||||
weights = alpha.weights(close)
|
||||
if universe is None:
|
||||
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
|
||||
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.
|
||||
|
||||
Alpha is interpreted as POSITION WEIGHTS, not predictions.
|
||||
Return on date t = sum(weight[s,t] * realized_return[s,t]) / sum(abs(weight[s,t]))
|
||||
Alpha is interpreted as POSITION WEIGHTS, not predictions. A close-formed
|
||||
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:
|
||||
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,
|
||||
max_drawdown, hit_rate, n_dates.
|
||||
"""
|
||||
close = _pivot_close(data_df)
|
||||
returns = _daily_returns(close)
|
||||
open_ = _pivot_open(data_df)
|
||||
fwd_returns_all = _forward_open_to_open_returns(open_)
|
||||
|
||||
# Pivot alpha weights to wide format
|
||||
weights = alpha_df.pivot_table(
|
||||
index="date", columns="symbol_id", values="weight", aggfunc="first"
|
||||
).sort_index()
|
||||
|
||||
# Align dates
|
||||
common_dates = weights.index.intersection(returns.index)
|
||||
# Align weights to signal dates that exist on the market calendar. Compute
|
||||
# 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]
|
||||
returns = returns.loc[common_dates]
|
||||
fwd_returns = fwd_returns_all.reindex(common_dates)
|
||||
|
||||
if len(common_dates) < 2:
|
||||
if len(common_dates) < 1:
|
||||
return {
|
||||
"cumulative_return": 0.0,
|
||||
"sharpe_annual": 0.0,
|
||||
"turnover_annual": 0.0,
|
||||
"max_drawdown": 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_returns = (weights * returns).sum(axis=1) / weights.abs().sum(axis=1)
|
||||
# Daily portfolio return = sum(w_t * r_open[t+1→t+2]) / sum(|w_t|).
|
||||
# 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 = 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
|
||||
# Daily turnover = sum(|w_t - w_{t-1}|) / sum(|w_{t-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
|
||||
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,
|
||||
"max_drawdown": max_drawdown,
|
||||
"hit_rate": hit_rate,
|
||||
"n_dates": len(common_dates),
|
||||
"n_dates": int(len(daily_returns)),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -15,4 +15,4 @@ class MomentumAlpha(BaseAlpha):
|
||||
self.lookback = lookback
|
||||
|
||||
def signal(self, close: pd.DataFrame) -> pd.DataFrame:
|
||||
return close.pct_change(self.lookback)
|
||||
return close.pct_change(self.lookback, fill_method=None)
|
||||
|
||||
@@ -15,4 +15,4 @@ class ReversalAlpha(BaseAlpha):
|
||||
self.lookback = lookback
|
||||
|
||||
def signal(self, close: pd.DataFrame) -> pd.DataFrame:
|
||||
return -close.pct_change(self.lookback)
|
||||
return -close.pct_change(self.lookback, fill_method=None)
|
||||
|
||||
@@ -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)
|
||||
@@ -21,6 +21,6 @@ class ReversalVolAlpha(BaseAlpha):
|
||||
self.vol_window = vol_window
|
||||
|
||||
def signal(self, close: pd.DataFrame) -> pd.DataFrame:
|
||||
reversal = -close.pct_change(self.lookback)
|
||||
vol = close.pct_change().rolling(self.vol_window).std()
|
||||
reversal = -close.pct_change(self.lookback, fill_method=None)
|
||||
vol = close.pct_change(fill_method=None).rolling(self.vol_window).std()
|
||||
return reversal / vol
|
||||
|
||||
@@ -26,8 +26,8 @@ def combo():
|
||||
def combine(alpha_paths, combo_name, method, output_dir):
|
||||
"""Combine multiple alphas and save as parquet."""
|
||||
paths = [p.strip() for p in alpha_paths.split(",") if p.strip()]
|
||||
if len(paths) < 2:
|
||||
click.echo("Error: --alpha-paths requires at least 2 comma-separated paths", err=True)
|
||||
if len(paths) < 1:
|
||||
click.echo("Error: --alpha-paths requires at least 1 path", err=True)
|
||||
return
|
||||
|
||||
result = combine_alphas(
|
||||
|
||||
@@ -7,9 +7,9 @@ across dates (positions are stateful, unlike alphas/combos), discretizing and
|
||||
repairing each day's target into a tradable integer book.
|
||||
|
||||
Return-convention note: weights here are *target allocations*. The research
|
||||
evaluation in :mod:`pipeline.portfolio.research` marks them close-to-close on the
|
||||
*next* period (no look-ahead); the execution simulator marks the actually-filled
|
||||
book at the next open. See those modules for details.
|
||||
evaluation in :mod:`pipeline.portfolio.research` marks them from next open to
|
||||
the following open (no look-ahead); the execution simulator marks the
|
||||
actually-filled book at the next open. See those modules for details.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
shifted one day relative to realized returns, so there is no look-ahead:
|
||||
``R_t = sum_i w_{i,t} · r_{i,t+1}`` normalized by gross exposure.
|
||||
date ``t`` is assumed tradable at ``open[t+1]`` and held until ``open[t+2]``.
|
||||
This is a costless approximation of the next-open execution path: no lots,
|
||||
constraints, or costs, but no credit for an overnight gap that the new signal
|
||||
could not have owned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -26,39 +27,46 @@ def evaluate_portfolio(positions_df: pd.DataFrame, data_df: pd.DataFrame) -> dic
|
||||
Args:
|
||||
positions_df: POSITION_COLUMNS (uses ``target_weight``; zero-gross
|
||||
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:
|
||||
Dict with ``cumulative_return, sharpe_annual, turnover_annual,
|
||||
max_drawdown, fitness, hit_rate, n_dates``. No IC key.
|
||||
"""
|
||||
close = data_df.pivot_table(
|
||||
index="date", columns="symbol_id", values="close", aggfunc="first"
|
||||
open_ = data_df.pivot_table(
|
||||
index="date", columns="symbol_id", values="open", aggfunc="first"
|
||||
).sort_index()
|
||||
returns = close.pct_change()
|
||||
fwd = open_.shift(-2).divide(open_.shift(-1)) - 1.0
|
||||
|
||||
weights = positions_df.pivot_table(
|
||||
index="date", columns="symbol_id", values="target_weight", aggfunc="first"
|
||||
).sort_index()
|
||||
|
||||
common = weights.index.intersection(returns.index)
|
||||
common = weights.index.intersection(open_.index)
|
||||
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 = {
|
||||
"cumulative_return": 0.0, "sharpe_annual": 0.0, "turnover_annual": 0.0,
|
||||
"max_drawdown": 0.0, "fitness": 0.0, "hit_rate": 0.0,
|
||||
"n_dates": len(common),
|
||||
}
|
||||
if len(common) < 3:
|
||||
if len(common) < 1:
|
||||
empty["n_dates"] = 0
|
||||
return empty
|
||||
|
||||
gross = weights.abs().sum(axis=1)
|
||||
# Weights at t earn the return from t to t+1: shift returns back by one.
|
||||
fwd = returns.shift(-1)
|
||||
daily = (weights * fwd).sum(axis=1) / gross.replace(0.0, np.nan)
|
||||
# Weights at t earn the costless tradable interval open[t+1] -> open[t+2].
|
||||
daily = (
|
||||
(weights * fwd).sum(axis=1, min_count=1)
|
||||
/ gross.replace(0.0, np.nan)
|
||||
)
|
||||
daily = daily.dropna()
|
||||
if len(daily) < 2:
|
||||
empty["n_dates"] = int(len(daily))
|
||||
return empty
|
||||
|
||||
cumulative_return = float((1.0 + daily).prod() - 1.0)
|
||||
|
||||
@@ -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
|
||||
violate a :class:`~pipeline.portfolio.constraints.TradeConstraint` (suspension,
|
||||
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
|
||||
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.portfolio.constraints import TradeConstraint
|
||||
from pipeline.portfolio.costs import CostModel, SimpleProportionalCostModel
|
||||
from pipeline.portfolio.market_rules import MarketRule, compute_limit_status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -65,10 +67,12 @@ class ExecutionSimulator(ABC):
|
||||
"""Abstract execution layer. Subclasses define how a target gets filled."""
|
||||
|
||||
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.cost_bps = cost_bps
|
||||
self.slippage_bps = slippage_bps
|
||||
self.cost_model = cost_model or SimpleProportionalCostModel(
|
||||
cost_bps=cost_bps, slippage_bps=slippage_bps
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def fill(self, ctx: TradeContext) -> FillResult:
|
||||
@@ -104,9 +108,17 @@ class ReferenceSimulator(ExecutionSimulator):
|
||||
blocked = (traded != desired).astype(np.int64)
|
||||
|
||||
realized = prev + traded
|
||||
open_px = np.where(np.isfinite(ctx.slice.price), ctx.slice.price, 0.0)
|
||||
trade_value = np.abs(traded.astype(np.float64) * open_px)
|
||||
cost = trade_value * (self.cost_bps + self.slippage_bps) / 1e4
|
||||
cost = self.cost_model.compute(
|
||||
traded_shares=traded,
|
||||
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)
|
||||
|
||||
def run(
|
||||
|
||||
+5
-1
@@ -4,7 +4,6 @@ version = "0.1.0"
|
||||
description = "A modular Chinese A-share quant research framework (daily frequency)."
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"backtrader>=1.9.76.123",
|
||||
"akshare>=1.14.0",
|
||||
"baostock>=0.8.8",
|
||||
"pandas>=2.0.0",
|
||||
@@ -13,6 +12,11 @@ dependencies = [
|
||||
"pyarrow>=14.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
backtrader = [
|
||||
"backtrader>=1.9.76.123",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+62
@@ -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
@@ -6,7 +6,11 @@ import pandas as pd
|
||||
import pytest
|
||||
|
||||
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 (
|
||||
available_alphas,
|
||||
get_alpha,
|
||||
@@ -51,7 +55,7 @@ def test_reversal_sign_matches_negative_trailing_return():
|
||||
data = _make_data()
|
||||
alpha = compute_alpha(data, "rev5", "reversal", lookback=5)
|
||||
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]
|
||||
expected_top = raw.loc[last].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
|
||||
|
||||
|
||||
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():
|
||||
data = _make_data()
|
||||
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()
|
||||
|
||||
|
||||
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 -----------------------------------------------------
|
||||
|
||||
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():
|
||||
@@ -163,7 +259,7 @@ def test_load_external_alpha_module(tmp_path):
|
||||
self.span = span
|
||||
|
||||
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))
|
||||
@@ -178,3 +274,76 @@ def test_load_external_alpha_module(tmp_path):
|
||||
assert list(result.columns) == ALPHA_COLUMNS
|
||||
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"}
|
||||
|
||||
@@ -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
@@ -22,6 +22,7 @@ from pipeline.portfolio.constraints import (
|
||||
SuspensionConstraint,
|
||||
VolumeCapConstraint,
|
||||
)
|
||||
from pipeline.portfolio.costs import SimpleProportionalCostModel
|
||||
from pipeline.portfolio.simulator import (
|
||||
MarketSlice,
|
||||
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:
|
||||
"""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()
|
||||
raw = -close.pct_change(5)
|
||||
raw = -close.pct_change(5, fill_method=None)
|
||||
demeaned = raw.sub(raw.mean(axis=1), axis=0)
|
||||
long = demeaned.reset_index().melt(id_vars="date", var_name="symbol_id",
|
||||
value_name="weight").dropna()
|
||||
@@ -445,6 +446,7 @@ def test_simulator_blocked_buy_when_suspended():
|
||||
assert res.traded_shares[0] == 0
|
||||
assert res.realized_shares[0] == 0
|
||||
assert res.blocked[0] == 1
|
||||
assert res.cost[0] == 0.0
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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 ------------------------------------------------------
|
||||
|
||||
def test_evaluate_portfolio_keys_no_ic():
|
||||
@@ -470,3 +535,27 @@ def test_evaluate_portfolio_keys_no_ic():
|
||||
assert key in metrics
|
||||
assert "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
|
||||
|
||||
@@ -284,7 +284,6 @@ version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "akshare" },
|
||||
{ name = "backtrader" },
|
||||
{ name = "baostock" },
|
||||
{ name = "click" },
|
||||
{ name = "matplotlib" },
|
||||
@@ -293,6 +292,11 @@ dependencies = [
|
||||
{ name = "pyarrow" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
backtrader = [
|
||||
{ name = "backtrader" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
@@ -301,13 +305,14 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ 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 = "click", specifier = ">=8.0.0" },
|
||||
{ name = "matplotlib", specifier = ">=3.7.0" },
|
||||
{ name = "pandas", specifier = ">=2.0.0" },
|
||||
{ name = "pyarrow", specifier = ">=14.0.0" },
|
||||
]
|
||||
provides-extras = ["backtrader"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=7.0.0" }]
|
||||
|
||||
Reference in New Issue
Block a user