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>
This commit is contained in:
Yuxuan Yan
2026-06-10 11:23:04 +08:00
parent 7faeb77c50
commit 94ab679a75
12 changed files with 1734 additions and 3 deletions
+294
View File
@@ -0,0 +1,294 @@
"""Continuous → tradable position discretization and exposure repair.
Pure-numpy, no I/O. Two steps:
1. :func:`round_to_valid_lot` — snap continuous target shares to the nearest
*valid resting position* given the per-name lot rule AND the current holding
(``prev_shares``). Rounding is state-dependent: a target below the board
minimum cannot *open* a fresh lot, but an existing holding may always be
reduced to 0, and a 科创 odd-lot residual may be sold whole.
2. :func:`repair_exposure` — a two-stage greedy that drives net exposure to ~0
(Stage A) and gross exposure to the booksize (Stage B) while minimizing the
dollar-space tracking error ``sum((v_i - v_target_i)**2)``. Splitting the two
stages avoids the oscillation a single mixed loop suffers (gross repair
breaking net neutrality and vice versa). O(N log N) via lazy heaps.
A "move" adjusts one name by ±``increment`` shares (or closes it to 0 at the
lattice boundary); names are never opened during repair and never flip sign.
"""
from __future__ import annotations
import heapq
import itertools
import numpy as np
def round_to_valid_lot(
target: np.ndarray,
prev_shares: np.ndarray,
min_open: np.ndarray,
increment: np.ndarray,
sell_is_odd_full: np.ndarray | None = None,
) -> np.ndarray:
"""Snap continuous target shares to valid integer resting positions.
The valid resting lattice for a name is ``{0} {min_open + k·increment :
k ≥ 0}`` on each side. Rounding depends on the current holding:
* **Opening** (no holding on the target side) a magnitude below ``min_open``
is not allowed → snaps to 0.
* **Holding** the same side, a sub-minimum target snaps to the nearer of
``{0, min_open}`` (so a reduction can rest at the minimum lot or close out).
* A full liquidation to 0 is always valid (covers the 科创 odd-lot sell:
a residual ``< min_open`` can only be sold whole, i.e. to 0).
* Sign is never flipped unless ``target`` itself flipped sign.
Args:
target: Continuous signed target shares, length N.
prev_shares: Current signed integer holding, length N.
min_open: Per-name minimum open size, length N.
increment: Per-name share increment (> 0), length N.
sell_is_odd_full: Unused for resting validity (odd-lot sells already
resolve to 0); accepted for API symmetry and documentation.
Returns:
``int64`` array of valid resting positions, length N.
"""
target = np.asarray(target, dtype=np.float64)
prev = np.asarray(prev_shares, dtype=np.int64)
min_open = np.asarray(min_open, dtype=np.float64)
increment = np.asarray(increment, dtype=np.float64)
sign = np.sign(target)
mag = np.abs(target)
# Lattice magnitude for mag >= min_open: min_open + round((mag-min_open)/inc)*inc
k = np.maximum(np.round((mag - min_open) / increment), 0.0)
lattice_mag = min_open + k * increment
holding_same_side = (prev != 0) & (np.sign(prev) == sign) & (sign != 0)
# Sub-minimum handling: opening -> 0; holding same side -> nearer of {0, min_open}.
sub_min = mag < min_open
sub_min_mag = np.where(
holding_same_side & (mag >= 0.5 * min_open), min_open, 0.0
)
final_mag = np.where(sub_min, sub_min_mag, lattice_mag)
rounded = sign * final_mag
return rounded.astype(np.int64)
def _exposures(q: np.ndarray, price: np.ndarray) -> tuple[np.ndarray, float, float]:
v = q.astype(np.float64) * price
return v, float(v.sum()), float(np.abs(v).sum())
def repair_exposure(
q_round: np.ndarray,
q_target: np.ndarray,
price: np.ndarray,
increment: np.ndarray,
min_open: np.ndarray,
prev_shares: np.ndarray,
sell_is_odd_full: np.ndarray | None = None,
booksize: float = 1.0,
net_tol: float = 0.02,
gross_tol: float = 0.02,
max_iters: int | None = None,
) -> np.ndarray:
"""Two-stage greedy exposure repair in dollar space.
Stage A drives ``net = sum(v_i)`` toward 0; Stage B drives ``gross =
sum(|v_i|)`` toward ``booksize`` using only moves that keep ``|net|`` within
its tolerance band, so Stage B cannot undo Stage A. Both stages pick, at each
step, the admissible ±``increment`` move with the lowest tracking-error cost
per dollar moved (``ΔTE/|Δv|`` where ``ΔTE = 2·Δv·(v_i - v_target_i) +
Δv²``). Names that round to 0 are never re-opened here.
Tolerances are fractions of ``booksize`` but floored to the lot granularity:
with coarse lots (e.g. pre-2023 100-share main-board lots) exact neutrality
is unreachable, so the floor prevents a deadlock / infinite loop.
Args:
q_round: Integer positions from :func:`round_to_valid_lot`, length N.
q_target: Continuous target shares (the tracking anchor), length N.
price: Per-name price (yuan), length N.
increment: Per-name share increment (> 0), length N.
min_open: Per-name minimum open size, length N.
prev_shares: Current holding (unused directly; reserved for borrow caps).
sell_is_odd_full: Reserved; accepted for API symmetry.
booksize: Target gross exposure ``B``.
net_tol: Net tolerance as a fraction of ``B``.
gross_tol: Gross tolerance as a fraction of ``B``.
max_iters: Hard cap on repair moves (default ``8·N``).
Returns:
``int64`` repaired positions, length N.
"""
q = np.asarray(q_round, dtype=np.int64).copy()
price = np.asarray(price, dtype=np.float64)
increment = np.asarray(increment, dtype=np.int64).astype(np.float64)
min_open = np.asarray(min_open, dtype=np.int64).astype(np.float64)
qt = np.asarray(q_target, dtype=np.float64)
n = len(q)
if n == 0:
return q
vt = np.where(np.isfinite(qt), qt, 0.0) * price # v_target, NaN-safe
tradable = np.isfinite(price) & (price > 0)
step = np.where(tradable, increment * price, np.inf) # dollar per increment
if max_iters is None:
max_iters = 8 * n
# Adaptive absolute tolerances: never finer than the lot granularity.
active_step = step[(q != 0) & tradable]
max_step = float(active_step.max()) if active_step.size else 0.0
min_step = float(active_step.min()) if active_step.size else 0.0
net_tol_abs = max(net_tol * booksize, max_step)
gross_tol_abs = max(gross_tol * booksize, min_step)
net_band = net_tol_abs # Stage B keeps |net| within this band
v, net, gross = _exposures(q, price)
def _move(i: int, grow: bool):
"""Return (dshares, dv, dte) for a grow/shrink move on name i, or None."""
if q[i] == 0 or not tradable[i]:
return None
s = 1 if q[i] > 0 else -1
if grow:
dshares = s * int(increment[i])
else:
mag = abs(int(q[i]))
if mag - increment[i] >= min_open[i]:
dshares = -s * int(increment[i])
else:
dshares = -int(q[i]) # close to 0 (lattice boundary / odd lot)
if dshares == 0:
return None
dv = dshares * price[i]
dte = 2.0 * dv * (v[i] - vt[i]) + dv * dv
return dshares, dv, dte
def _apply(i: int, dshares: int, dv: float):
nonlocal net, gross
old_abs = abs(v[i])
q[i] += dshares
v[i] += dv
net += dv
gross += abs(v[i]) - old_abs
counter = itertools.count()
active_idx = np.nonzero((q != 0) & tradable)[0]
# ---- Stage A: net repair -------------------------------------------------
def _stageA_dir() -> int:
return -1 if net > 0 else 1 # desired sign of dv
iters = 0
while abs(net) > net_tol_abs and iters < max_iters:
want = _stageA_dir() # dv sign we need
heap: list = []
best_key: dict[int, float] = {}
for i in active_idx:
i = int(i)
# For net>0 (want dv<0): shrink longs, grow shorts. Mirror otherwise.
grow = (q[i] < 0) if want < 0 else (q[i] > 0)
mv = _move(i, grow)
if mv is None:
continue
_, dv, dte = mv
if np.sign(dv) != want:
continue
key = dte / abs(dv)
best_key[i] = key
heapq.heappush(heap, (key, next(counter), i, grow))
if not heap:
break
progressed = False
while heap and abs(net) > net_tol_abs and iters < max_iters:
key, _, i, grow = heapq.heappop(heap)
if best_key.get(i) != key:
continue # stale
mv = _move(i, grow)
if mv is None:
best_key.pop(i, None)
continue
dshares, dv, dte = mv
if np.sign(dv) != want:
best_key.pop(i, None)
continue
# Don't overshoot net through 0 by more than the tolerance band.
if abs(net + dv) > abs(net) and abs(net + dv) > net_tol_abs:
best_key.pop(i, None)
continue
_apply(i, dshares, dv)
iters += 1
progressed = True
if q[i] != 0:
nk = _move(i, grow)
if nk is not None:
_, ndv, ndte = nk
if np.sign(ndv) == want:
k2 = ndte / abs(ndv)
best_key[i] = k2
heapq.heappush(heap, (k2, next(counter), i, grow))
continue
best_key.pop(i, None)
if not progressed:
break
# ---- Stage B: gross repair (net-preserving) -----------------------------
iters = 0
active_idx = np.nonzero((q != 0) & tradable)[0]
while abs(gross - booksize) > gross_tol_abs and iters < max_iters:
grow = gross < booksize # need more gross → grow magnitudes; else shrink
heap = []
best_key = {}
for i in active_idx:
i = int(i)
mv = _move(i, grow)
if mv is None:
continue
_, dv, dte = mv
# Net-band filter: never push |net| past the band.
if abs(net + dv) > net_band and abs(net + dv) >= abs(net):
continue
key = dte / abs(dv)
best_key[i] = key
heapq.heappush(heap, (key, next(counter), i, grow))
if not heap:
break
progressed = False
while heap and abs(gross - booksize) > gross_tol_abs and iters < max_iters:
key, _, i, g = heapq.heappop(heap)
if best_key.get(i) != key:
continue
mv = _move(i, g)
if mv is None:
best_key.pop(i, None)
continue
dshares, dv, dte = mv
if abs(net + dv) > net_band and abs(net + dv) >= abs(net):
best_key.pop(i, None)
continue
_apply(i, dshares, dv)
iters += 1
progressed = True
if q[i] != 0:
nk = _move(i, g)
if nk is not None:
_, ndv, ndte = nk
if not (abs(net + ndv) > net_band and abs(net + ndv) >= abs(net)):
k2 = ndte / abs(ndv)
best_key[i] = k2
heapq.heappush(heap, (k2, next(counter), i, g))
continue
best_key.pop(i, None)
if not progressed:
break
return q