94ab679a75
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>
101 lines
4.6 KiB
Python
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}")
|