Fix portfolio construction NaN handling
This commit is contained in:
@@ -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/
|
||||||
|
|||||||
@@ -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)
|
||||||
|
if np.abs(w).sum() <= 0:
|
||||||
|
logger.warning(
|
||||||
|
"Portfolio '%s': zero-gross target on %s; carrying previous positions.",
|
||||||
|
portfolio_name, d,
|
||||||
|
)
|
||||||
|
pos = prev_shares.copy()
|
||||||
|
else:
|
||||||
q_round = round_to_valid_lot(q_target, prev_shares, min_open, increment, odd_full)
|
q_round = round_to_valid_lot(q_target, prev_shares, min_open, increment, odd_full)
|
||||||
pos = repair_exposure(
|
pos = repair_exposure(
|
||||||
q_round, q_target, price, increment, min_open, prev_shares, odd_full,
|
q_round, q_target, price, increment, min_open, prev_shares, odd_full,
|
||||||
booksize=booksize, net_tol=net_tol, gross_tol=gross_tol,
|
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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()])
|
||||||
|
|||||||
Reference in New Issue
Block a user