diff --git a/.gitignore b/.gitignore index bd8ae0f..3d9e567 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ __pycache__/ *.egg-info/ .venv/ venv/ +.claude # Pipeline outputs (regenerated by the CLI; can be large) data/daily_bars/ diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/pipeline/portfolio/construct.py b/pipeline/portfolio/construct.py index c4acae6..b6842eb 100644 --- a/pipeline/portfolio/construct.py +++ b/pipeline/portfolio/construct.py @@ -47,18 +47,16 @@ def continuous_targets( """ alpha = np.asarray(alpha, 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() if gross <= 0: zeros = np.zeros_like(a) return zeros, zeros.copy(), zeros.copy() w = a / gross v_target = booksize * w - tradable = np.isfinite(price) & (price > 0) - q_target = np.where(tradable, v_target / np.where(tradable, price, 1.0), 0.0) - # 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) + q_target = np.zeros_like(v_target) + np.divide(v_target, price, out=q_target, where=tradable) return w, v_target, q_target @@ -86,7 +84,7 @@ def construct_positions( vector. Each date: continuous targets → state-dependent lot rounding → 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 - unchanged. + unchanged in ``position_shares`` while leaving the target fields at 0. Args: 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) - 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, - ) + 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) + 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({ "symbol_id": symbols, "date": d, diff --git a/pipeline/portfolio/discretize.py b/pipeline/portfolio/discretize.py index 63c8e7f..955cf0e 100644 --- a/pipeline/portfolio/discretize.py +++ b/pipeline/portfolio/discretize.py @@ -82,7 +82,8 @@ def round_to_valid_lot( 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()) @@ -129,7 +130,7 @@ def repair_exposure( ``int64`` repaired positions, length N. """ 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) min_open = np.asarray(min_open, dtype=np.int64).astype(np.float64) qt = np.asarray(q_target, dtype=np.float64) @@ -137,8 +138,10 @@ def repair_exposure( 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) + tradable = np.isfinite(raw_price) & (raw_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 if max_iters is None: diff --git a/pipeline/portfolio/research.py b/pipeline/portfolio/research.py index 25ff995..bbe25df 100644 --- a/pipeline/portfolio/research.py +++ b/pipeline/portfolio/research.py @@ -24,7 +24,8 @@ def evaluate_portfolio(positions_df: pd.DataFrame, data_df: pd.DataFrame) -> dic """Evaluate target weights as a continuous research portfolio. 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). Returns: diff --git a/pipeline/portfolio/simulator.py b/pipeline/portfolio/simulator.py index 38d6308..84934b8 100644 --- a/pipeline/portfolio/simulator.py +++ b/pipeline/portfolio/simulator.py @@ -122,7 +122,7 @@ class ReferenceSimulator(ExecutionSimulator): FILL_COLUMNS / PNL_COLUMNS. 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). 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", values=col, aggfunc="first").sort_index() - tgt = wide(positions_df, "target_shares") + tgt = wide(positions_df, "position_shares") opn = wide(data_df, "open") close = wide(data_df, "close") preclose = wide(data_df, "preclose") if "preclose" in data_df.columns else close.shift(1) diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index b50ee99..c12337a 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -1,6 +1,7 @@ """Tests for the portfolio construction & execution phase (no network).""" import datetime as dt +import logging import numpy as np import pandas as pd @@ -150,6 +151,10 @@ def test_continuous_targets_guards_bad_price(): alpha = np.array([1.0, -1.0]) w, v, q = continuous_targets(alpha, np.array([np.nan, 10.0]), 1e6) 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) ------------------------------------ @@ -232,6 +237,25 @@ def test_repair_drives_net_and_gross(): 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(): rng = np.random.default_rng(2) n = 150 @@ -296,6 +320,30 @@ def test_construct_positions_threads_state_and_closes_absent(): 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 ------------------------------------------------------------- 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() +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(): n = 1 sim = ReferenceSimulator(constraints=[SuspensionConstraint()])