Fix portfolio construction NaN handling

This commit is contained in:
Yuxuan Yan
2026-06-10 14:58:06 +08:00
parent 94ab679a75
commit 98a4f99300
7 changed files with 109 additions and 20 deletions
+1
View File
@@ -4,6 +4,7 @@ __pycache__/
*.egg-info/ *.egg-info/
.venv/ .venv/
venv/ venv/
.claude
# Pipeline outputs (regenerated by the CLI; can be large) # Pipeline outputs (regenerated by the CLI; can be large)
data/daily_bars/ data/daily_bars/
Symlink
+1
View File
@@ -0,0 +1 @@
CLAUDE.md
+18 -13
View File
@@ -47,18 +47,16 @@ def continuous_targets(
""" """
alpha = np.asarray(alpha, dtype=np.float64) alpha = np.asarray(alpha, dtype=np.float64)
price = np.asarray(price, dtype=np.float64) price = np.asarray(price, dtype=np.float64)
a = np.where(np.isfinite(alpha), alpha, 0.0) tradable = np.isfinite(price) & (price > 0)
a = np.where(np.isfinite(alpha) & tradable, alpha, 0.0)
gross = np.abs(a).sum() gross = np.abs(a).sum()
if gross <= 0: if gross <= 0:
zeros = np.zeros_like(a) zeros = np.zeros_like(a)
return zeros, zeros.copy(), zeros.copy() return zeros, zeros.copy(), zeros.copy()
w = a / gross w = a / gross
v_target = booksize * w v_target = booksize * w
tradable = np.isfinite(price) & (price > 0) q_target = np.zeros_like(v_target)
q_target = np.where(tradable, v_target / np.where(tradable, price, 1.0), 0.0) np.divide(v_target, price, out=q_target, where=tradable)
# Names without a tradable price get no target exposure.
w = np.where(tradable, w, 0.0)
v_target = np.where(tradable, v_target, 0.0)
return w, v_target, q_target return w, v_target, q_target
@@ -86,7 +84,7 @@ def construct_positions(
vector. Each date: continuous targets → state-dependent lot rounding → vector. Each date: continuous targets → state-dependent lot rounding →
two-stage exposure repair. Names absent on a date get weight 0 (which closes two-stage exposure repair. Names absent on a date get weight 0 (which closes
any stale holding). An empty / zero-gross cross-section carries the book any stale holding). An empty / zero-gross cross-section carries the book
unchanged. unchanged in ``position_shares`` while leaving the target fields at 0.
Args: Args:
weights_df: Long frame with ``symbol_id, date, weight`` (ALPHA/COMBO). weights_df: Long frame with ``symbol_id, date, weight`` (ALPHA/COMBO).
@@ -136,13 +134,20 @@ def construct_positions(
) )
w, v_target, q_target = continuous_targets(alpha, price, booksize) w, v_target, q_target = continuous_targets(alpha, price, booksize)
q_round = round_to_valid_lot(q_target, prev_shares, min_open, increment, odd_full) if np.abs(w).sum() <= 0:
pos = repair_exposure( logger.warning(
q_round, q_target, price, increment, min_open, prev_shares, odd_full, "Portfolio '%s': zero-gross target on %s; carrying previous positions.",
booksize=booksize, net_tol=net_tol, gross_tol=gross_tol, portfolio_name, d,
) )
pos = prev_shares.copy()
else:
q_round = round_to_valid_lot(q_target, prev_shares, min_open, increment, odd_full)
pos = repair_exposure(
q_round, q_target, price, increment, min_open, prev_shares, odd_full,
booksize=booksize, net_tol=net_tol, gross_tol=gross_tol,
)
safe_price = np.where(np.isfinite(price), price, 0.0) safe_price = np.where(np.isfinite(price) & (price > 0), price, 0.0)
blocks.append(pd.DataFrame({ blocks.append(pd.DataFrame({
"symbol_id": symbols, "symbol_id": symbols,
"date": d, "date": d,
+7 -4
View File
@@ -82,7 +82,8 @@ def round_to_valid_lot(
def _exposures(q: np.ndarray, price: np.ndarray) -> tuple[np.ndarray, float, float]: def _exposures(q: np.ndarray, price: np.ndarray) -> tuple[np.ndarray, float, float]:
v = q.astype(np.float64) * price safe_price = np.where(np.isfinite(price) & (price > 0), price, 0.0)
v = q.astype(np.float64) * safe_price
return v, float(v.sum()), float(np.abs(v).sum()) return v, float(v.sum()), float(np.abs(v).sum())
@@ -129,7 +130,7 @@ def repair_exposure(
``int64`` repaired positions, length N. ``int64`` repaired positions, length N.
""" """
q = np.asarray(q_round, dtype=np.int64).copy() q = np.asarray(q_round, dtype=np.int64).copy()
price = np.asarray(price, dtype=np.float64) raw_price = np.asarray(price, dtype=np.float64)
increment = np.asarray(increment, dtype=np.int64).astype(np.float64) increment = np.asarray(increment, dtype=np.int64).astype(np.float64)
min_open = np.asarray(min_open, 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) qt = np.asarray(q_target, dtype=np.float64)
@@ -137,8 +138,10 @@ def repair_exposure(
if n == 0: if n == 0:
return q return q
vt = np.where(np.isfinite(qt), qt, 0.0) * price # v_target, NaN-safe tradable = np.isfinite(raw_price) & (raw_price > 0)
tradable = np.isfinite(price) & (price > 0) price = np.where(tradable, raw_price, 0.0)
qt_safe = np.where(np.isfinite(qt), qt, 0.0)
vt = np.where(tradable, qt_safe * price, 0.0) # v_target, NaN-safe
step = np.where(tradable, increment * price, np.inf) # dollar per increment step = np.where(tradable, increment * price, np.inf) # dollar per increment
if max_iters is None: if max_iters is None:
+2 -1
View File
@@ -24,7 +24,8 @@ def evaluate_portfolio(positions_df: pd.DataFrame, data_df: pd.DataFrame) -> dic
"""Evaluate target weights as a continuous research portfolio. """Evaluate target weights as a continuous research portfolio.
Args: Args:
positions_df: POSITION_COLUMNS (uses ``target_weight``). 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 ``close`` for returns).
Returns: Returns:
+2 -2
View File
@@ -122,7 +122,7 @@ class ReferenceSimulator(ExecutionSimulator):
FILL_COLUMNS / PNL_COLUMNS. FILL_COLUMNS / PNL_COLUMNS.
Args: Args:
positions_df: POSITION_COLUMNS (uses ``target_shares``). positions_df: POSITION_COLUMNS (uses constructed ``position_shares``).
data_df: DATA_COLUMNS (open/close/preclose/amount/tradestatus/isST). data_df: DATA_COLUMNS (open/close/preclose/amount/tradestatus/isST).
rule_engine: For per-name price-limit bands; default built if None. rule_engine: For per-name price-limit bands; default built if None.
@@ -145,7 +145,7 @@ class ReferenceSimulator(ExecutionSimulator):
return df.pivot_table(index="date", columns="symbol_id", return df.pivot_table(index="date", columns="symbol_id",
values=col, aggfunc="first").sort_index() values=col, aggfunc="first").sort_index()
tgt = wide(positions_df, "target_shares") tgt = wide(positions_df, "position_shares")
opn = wide(data_df, "open") opn = wide(data_df, "open")
close = wide(data_df, "close") close = wide(data_df, "close")
preclose = wide(data_df, "preclose") if "preclose" in data_df.columns else close.shift(1) preclose = wide(data_df, "preclose") if "preclose" in data_df.columns else close.shift(1)
+78
View File
@@ -1,6 +1,7 @@
"""Tests for the portfolio construction & execution phase (no network).""" """Tests for the portfolio construction & execution phase (no network)."""
import datetime as dt import datetime as dt
import logging
import numpy as np import numpy as np
import pandas as pd import pandas as pd
@@ -150,6 +151,10 @@ def test_continuous_targets_guards_bad_price():
alpha = np.array([1.0, -1.0]) alpha = np.array([1.0, -1.0])
w, v, q = continuous_targets(alpha, np.array([np.nan, 10.0]), 1e6) w, v, q = continuous_targets(alpha, np.array([np.nan, 10.0]), 1e6)
assert w[0] == 0.0 and q[0] == 0.0 assert w[0] == 0.0 and q[0] == 0.0
assert np.isclose(np.abs(w).sum(), 1.0)
assert np.isclose(w[1], -1.0)
assert np.isclose(v[1], -1e6)
assert np.isclose(q[1], -100000.0)
# --- round_to_valid_lot (state-dependent) ------------------------------------ # --- round_to_valid_lot (state-dependent) ------------------------------------
@@ -232,6 +237,25 @@ def test_repair_drives_net_and_gross():
assert abs(gross - B) <= 0.02 * B + price.max() * 1 assert abs(gross - B) <= 0.02 * B + price.max() * 1
def test_repair_ignores_nan_price_exposure():
q_round = np.array([101, -99, 0], dtype=np.int64)
q_target = q_round.astype(float)
price = np.array([10.0, 10.0, np.nan])
inc = np.ones(3, dtype=np.int64)
min_open = np.ones(3, dtype=np.int64)
prev = np.zeros(3, dtype=np.int64)
pos = repair_exposure(q_round, q_target, price, inc, min_open, prev,
booksize=2000.0, net_tol=0.0, gross_tol=0.0)
safe_price = np.nan_to_num(price, nan=0.0)
gross, net = _gross_net(pos, safe_price)
assert np.isfinite(gross)
assert np.isfinite(net)
assert abs(net) <= 10.0
assert not np.array_equal(pos, q_round)
def test_repair_does_not_worsen_tracking_error_grossly(): def test_repair_does_not_worsen_tracking_error_grossly():
rng = np.random.default_rng(2) rng = np.random.default_rng(2)
n = 150 n = 150
@@ -296,6 +320,30 @@ def test_construct_positions_threads_state_and_closes_absent():
assert final.empty or (final["position_shares"] == 0).all() assert final.empty or (final["position_shares"] == 0).all()
def test_construct_positions_carries_book_on_zero_gross(caplog):
dates = pd.to_datetime(["2024-01-01", "2024-01-02"])
symbols = ["sh600000", "sz000001"]
data = pd.DataFrame([
{"symbol_id": sym, "date": d, "close": 10.0, "isST": 0}
for sym in symbols for d in dates
])
weights = pd.DataFrame({
"symbol_id": ["sh600000", "sz000001", "sh600000", "sz000001"],
"date": [dates[0], dates[0], dates[1], dates[1]],
"combo_name": ["combo"] * 4,
"weight": [1.0, -1.0, 0.0, 0.0],
})
caplog.set_level(logging.WARNING)
pos = construct_positions(weights, data, booksize=10000.0, portfolio_name="run1")
shares = pos.pivot_table(index="date", columns="symbol_id",
values="position_shares", aggfunc="first")
assert shares.loc[dates[1], "sh600000"] == shares.loc[dates[0], "sh600000"]
assert shares.loc[dates[1], "sz000001"] == shares.loc[dates[0], "sz000001"]
assert "zero-gross target" in caplog.text
# --- constraints ------------------------------------------------------------- # --- constraints -------------------------------------------------------------
def _slice(n, **over): def _slice(n, **over):
@@ -358,6 +406,36 @@ def test_simulator_next_open_and_blocked_buy_holds_prev():
assert (fills["realized_shares"] == fills["prev_shares"] + fills["traded_shares"]).all() assert (fills["realized_shares"] == fills["prev_shares"] + fills["traded_shares"]).all()
def test_simulator_uses_constructed_position_shares_not_continuous_targets():
positions = pd.DataFrame({
"symbol_id": ["sh600000"],
"date": pd.to_datetime(["2024-01-01"]),
"portfolio_name": ["run1"],
"target_weight": [1.0],
"target_value": [1534.0],
"target_shares": [153.4],
"position_shares": [100],
"position_value": [1000.0],
"price": [10.0],
})
data = pd.DataFrame({
"symbol_id": ["sh600000", "sh600000"],
"date": pd.to_datetime(["2024-01-01", "2024-01-02"]),
"open": [10.0, 10.0],
"close": [10.0, 10.0],
"preclose": [10.0, 10.0],
"amount": [1e9, 1e9],
"tradestatus": [1, 1],
"isST": [0, 0],
})
fills, _ = ReferenceSimulator().run(positions, data)
assert fills["target_shares"].iloc[0] == 100
assert fills["traded_shares"].iloc[0] == 100
assert fills["realized_shares"].iloc[0] == 100
def test_simulator_blocked_buy_when_suspended(): def test_simulator_blocked_buy_when_suspended():
n = 1 n = 1
sim = ReferenceSimulator(constraints=[SuspensionConstraint()]) sim = ReferenceSimulator(constraints=[SuspensionConstraint()])