Files
Yuxuan Yan 94ab679a75 feat: add portfolio phase — discretize alpha weights into tradable positions
Adds a fourth pipeline phase modeling A-share microstructure: lot sizes,
the 2023-08-10 Main Board increment change, STAR 200-share minimum/odd-lot
rules, limit-up/down, suspensions, volume caps, costs, and slippage.

Two layers: research (continuous weights → return/Sharpe/turnover/Fitness,
no IC per repo convention) and execution (state-dependent lot rounding +
two-stage greedy exposure repair + next-open reference simulator).

Wires `portfolio build/simulate/eval` into the CLI and adds the
POSITION/FILL/PNL schema contracts. Covered by tests/test_portfolio.py.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 11:23:04 +08:00

101 lines
4.6 KiB
Python

"""CLI for the portfolio construction and execution-simulation phase."""
import os
import click
import pandas as pd
from pipeline.portfolio.constraints import available_constraints, get_constraint
from pipeline.portfolio.construct import construct_positions
from pipeline.portfolio.research import evaluate_portfolio
from pipeline.portfolio.simulator import ReferenceSimulator
@click.group(name="portfolio")
def portfolio():
"""Construct tradable positions from weights and simulate execution."""
@portfolio.command("build")
@click.option("--weights-path", required=True, help="Alpha or combo parquet (signed weights)")
@click.option("--data-path", required=True, help="Data parquet file or dataset directory")
@click.option("--booksize", type=float, required=True, help="Gross dollar exposure B")
@click.option("--portfolio-name", required=True, help="Name for this portfolio run")
@click.option("--price-field", default="close", help="Data column used as construction price")
@click.option("--output-dir", default="portfolio", help="Directory to save the positions parquet")
def build(weights_path, data_path, booksize, portfolio_name, price_field, output_dir):
"""Discretize target weights into a tradable integer position book."""
weights = pd.read_parquet(weights_path)
data = pd.read_parquet(data_path)
result = construct_positions(
weights_df=weights, data_df=data, booksize=booksize,
portfolio_name=portfolio_name, price_field=price_field,
)
os.makedirs(output_dir, exist_ok=True)
out_path = f"{output_dir}/{portfolio_name}.pq"
result.to_parquet(out_path, index=False)
click.echo(f"Saved positions: {out_path} ({len(result):,} rows)")
per_date = result.groupby("date").agg(
gross=("position_value", lambda s: s.abs().sum()),
net=("position_value", "sum"),
)
click.echo(
f"Gross exposure — mean: {per_date['gross'].mean():,.0f} "
f"(target {booksize:,.0f}); |net| mean: {per_date['net'].abs().mean():,.0f}"
)
@portfolio.command("simulate")
@click.option("--positions-path", required=True, help="Positions parquet from `portfolio build`")
@click.option("--data-path", required=True, help="Data parquet file or dataset directory")
@click.option("--constraint", "constraints", multiple=True,
help=f"Trade constraint to apply (repeatable). Options: {available_constraints()}")
@click.option("--cost-bps", type=float, default=0.0, help="Commission in basis points")
@click.option("--slippage-bps", type=float, default=0.0, help="Slippage in basis points")
@click.option("--volume-frac", type=float, default=0.10,
help="Max traded value as a fraction of daily turnover (volume_cap)")
@click.option("--output-dir", default=".", help="Base dir; writes fills/ and pnl/ subdirs")
def simulate(positions_path, data_path, constraints, cost_bps, slippage_bps,
volume_frac, output_dir):
"""Simulate next-open execution under A-share constraints, costs, slippage."""
positions = pd.read_parquet(positions_path)
data = pd.read_parquet(data_path)
name = positions["portfolio_name"].iloc[0] if len(positions) else "portfolio"
built = []
for c in constraints:
params = {"max_frac": volume_frac} if c == "volume_cap" else {}
built.append(get_constraint(c, **params))
sim = ReferenceSimulator(constraints=built, cost_bps=cost_bps, slippage_bps=slippage_bps)
fills, pnl = sim.run(positions, data)
fills_dir = os.path.join(output_dir, "fills")
pnl_dir = os.path.join(output_dir, "pnl")
os.makedirs(fills_dir, exist_ok=True)
os.makedirs(pnl_dir, exist_ok=True)
fills.to_parquet(f"{fills_dir}/{name}.pq", index=False)
pnl.to_parquet(f"{pnl_dir}/{name}.pq", index=False)
click.echo(f"Saved fills: {fills_dir}/{name}.pq ({len(fills):,} rows)")
click.echo(f"Saved pnl: {pnl_dir}/{name}.pq ({len(pnl):,} rows)")
if len(pnl):
click.echo(
f"Total PnL: {pnl['pnl'].sum():,.0f} | total cost: {pnl['cost'].sum():,.0f} "
f"| blocked trades: {int(fills['blocked'].sum()):,}"
)
@portfolio.command("eval")
@click.option("--positions-path", required=True, help="Positions parquet from `portfolio build`")
@click.option("--data-path", required=True, help="Data parquet file or dataset directory")
def eval_(positions_path, data_path):
"""Print Layer-1 research metrics (return/Sharpe/turnover/max-dd/Fitness; no IC)."""
positions = pd.read_parquet(positions_path)
data = pd.read_parquet(data_path)
metrics = evaluate_portfolio(positions, data)
click.echo("Research-portfolio metrics:")
for key, value in metrics.items():
click.echo(f" {key:18s}: {value}")