"""Tests for the portfolio construction & execution phase (no network).""" import datetime as dt import logging import numpy as np import pandas as pd from pipeline.common.schema import FILL_COLUMNS, PNL_COLUMNS, POSITION_COLUMNS from pipeline.portfolio.construct import construct_positions, continuous_targets from pipeline.portfolio.discretize import repair_exposure, round_to_valid_lot from pipeline.portfolio.market_rules import ( Board, LimitStatus, MarketRule, _to_date, compute_limit_status, detect_board, ) from pipeline.portfolio.research import evaluate_portfolio from pipeline.portfolio.constraints import ( TradeConstraint, available_constraints, get_constraint, PriceLimitConstraint, register_constraint, SuspensionConstraint, VolumeCapConstraint, ) from pipeline.portfolio.costs import SimpleProportionalCostModel from pipeline.portfolio.simulator import ( MarketSlice, ReferenceSimulator, TradeContext, ) # --- fixtures ---------------------------------------------------------------- _SYMBOLS = ("sh600000", "sz000001", "sh688981", "sz300750") def _make_data(n_days: int = 40, symbols=_SYMBOLS, start="2024-01-01", st_symbol=None) -> pd.DataFrame: """Synthetic long-format DATA_COLUMNS frame, deterministic prices.""" dates = pd.date_range(start, periods=n_days) rng = np.random.default_rng(0) frames = [] for i, sym in enumerate(symbols): close = 50.0 + i * 10 + np.cumsum(rng.standard_normal(n_days)) close = np.abs(close) + 5.0 # keep strictly positive preclose = np.concatenate([[close[0]], close[:-1]]) frames.append(pd.DataFrame({ "symbol_id": sym, "symbol_name": sym, "date": dates, "open": close, "high": close, "low": close, "close": close, "preclose": preclose, "volume": 1_000_000.0, "amount": 1_000_000.0 * close, "tradestatus": 1, "isST": 1 if sym == st_symbol else 0, })) return pd.concat(frames, ignore_index=True) def _make_weights(data: pd.DataFrame, name="combo") -> pd.DataFrame: """Demeaned per-date signed weights so the cross-section is dollar-neutral.""" close = data.pivot_table(index="date", columns="symbol_id", values="close").sort_index() raw = -close.pct_change(5, fill_method=None) demeaned = raw.sub(raw.mean(axis=1), axis=0) long = demeaned.reset_index().melt(id_vars="date", var_name="symbol_id", value_name="weight").dropna() long["combo_name"] = name return long[["symbol_id", "date", "combo_name", "weight"]] # --- detect_board ------------------------------------------------------------ def test_detect_board(): assert detect_board("sh600000") == Board.MAIN assert detect_board("sz000001") == Board.MAIN assert detect_board("sz002594") == Board.MAIN assert detect_board("sh688981") == Board.STAR assert detect_board("sz300750") == Board.CHINEXT assert detect_board("bj830000") == Board.UNKNOWN assert detect_board("sh") == Board.UNKNOWN # --- MarketRule date transitions --------------------------------------------- def test_main_board_increment_transition(): rules = MarketRule() before = rules.get_rule("sh600000", dt.date(2023, 8, 9)) after = rules.get_rule("sh600000", dt.date(2023, 8, 10)) assert (before.minimum_open_size, before.share_increment) == (100, 100) assert (after.minimum_open_size, after.share_increment) == (100, 1) assert before.price_limit_pct == 0.10 def test_star_rule_and_odd_lot(): rule = MarketRule().get_rule("sh688981", dt.date(2024, 1, 1)) assert rule.minimum_open_size == 200 assert rule.share_increment == 1 assert rule.sell_rule == "odd_lot_full" assert rule.price_limit_pct == 0.20 def test_st_overrides_price_limit(): rule = MarketRule().get_rule("sh600000", dt.date(2024, 1, 1), is_st=True) assert rule.price_limit_pct == 0.05 def test_get_rules_vectorized(): rules = MarketRule() syms = np.array(["sh600000", "sh688981", "sz300750"], dtype=object) min_open, inc, odd, limit = rules.get_rules_vectorized( syms, dt.date(2024, 1, 1), np.array([0, 0, 0]) ) assert list(min_open) == [100, 200, 100] assert list(inc) == [1, 1, 1] assert list(odd) == [False, True, False] assert list(limit) == [0.10, 0.20, 0.20] def test_market_rule_date_coercion_unknown_board_and_st_vector_override(): rules = MarketRule() assert _to_date(dt.datetime(2024, 1, 2, 15, 0)) == dt.date(2024, 1, 2) assert _to_date(pd.Timestamp("2024-01-03 09:30")) == dt.date(2024, 1, 3) assert _to_date("2024-01-04 10:00:00") == dt.date(2024, 1, 4) unknown_rule = rules.get_rule("xx999999", "2024-01-02") assert unknown_rule.minimum_open_size == 100 assert unknown_rule.share_increment == 100 assert unknown_rule.price_limit_pct == 0.10 _, _, _, limit = rules.get_rules_vectorized( np.array(["sh600000", "sh600000"], dtype=object), "2024-01-02", np.array([0, 1]), ) assert limit.tolist() == [0.10, 0.05] def test_compute_limit_status(): price = np.array([110.0, 90.0, 100.0]) preclose = np.array([100.0, 100.0, 100.0]) limit_pct = np.array([0.10, 0.10, 0.10]) status = compute_limit_status(price, preclose, limit_pct) assert status[0] == LimitStatus.UP_LIMIT.value assert status[1] == LimitStatus.DOWN_LIMIT.value assert status[2] == LimitStatus.NORMAL.value def test_compute_limit_status_treats_bad_preclose_as_normal(): status = compute_limit_status( price=np.array([np.nan, 110.0, 90.0]), preclose=np.array([100.0, np.nan, 0.0]), limit_pct=np.array([0.10, 0.10, 0.10]), ) assert status.tolist() == [ LimitStatus.NORMAL.value, LimitStatus.NORMAL.value, LimitStatus.NORMAL.value, ] # --- continuous targets ------------------------------------------------------ def test_continuous_targets_normalization(): alpha = np.array([2.0, -1.0, -1.0, 0.5]) price = np.array([10.0, 20.0, 5.0, 8.0]) w, v_target, q_target = continuous_targets(alpha, price, booksize=1e6) assert np.isclose(np.abs(w).sum(), 1.0) assert np.isclose(w.sum(), alpha.sum() / np.abs(alpha).sum()) assert np.allclose(v_target, 1e6 * w) assert np.allclose(q_target, v_target / price) def test_continuous_targets_demeaned_is_neutral(): alpha = np.array([2.0, -1.0, -1.0]) w, _, _ = continuous_targets(alpha, np.array([10.0, 10.0, 10.0]), 1e6) assert abs(w.sum()) < 1e-12 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) ------------------------------------ def test_round_main_board_pre2023_multiples_of_100(): target = np.array([250.0, -180.0, 40.0]) prev = np.zeros(3, dtype=np.int64) min_open = np.array([100, 100, 100]) inc = np.array([100, 100, 100]) out = round_to_valid_lot(target, prev, min_open, inc) # 250 -> 200 or 300 (nearest is 200? round(150/100)=2 ->300). 250/100 -> k=round(1.5)=2 ->300 assert out[0] in (200, 300) assert out[1] in (-200, -100) assert out[2] == 0 # sub-min, no holding def test_round_post2023_increment_one(): target = np.array([153.4]) out = round_to_valid_lot(target, np.zeros(1, np.int64), np.array([100]), np.array([1])) assert out[0] == 153 def test_round_star_min_200(): target = np.array([150.0, 240.6]) prev = np.zeros(2, dtype=np.int64) out = round_to_valid_lot(target, prev, np.array([200, 200]), np.array([1, 1]), np.array([True, True])) assert out[0] == 0 # below 200, no holding -> cannot open assert out[1] == 241 # 200 + round(40.6) def test_round_reduction_can_liquidate_below_min(): # Holding 300, target wants ~40 shares -> nearest valid resting is 0. target = np.array([40.0]) prev = np.array([300], dtype=np.int64) out = round_to_valid_lot(target, prev, np.array([100]), np.array([100])) assert out[0] == 0 def test_round_star_odd_lot_residual_sells_to_zero(): # Holding 150 STAR shares (odd lot), target reduces -> must go to 0. target = np.array([20.0]) prev = np.array([150], dtype=np.int64) out = round_to_valid_lot(target, prev, np.array([200]), np.array([1]), np.array([True])) assert out[0] == 0 def test_round_no_sign_flip_when_target_same_sign(): target = np.array([500.0]) prev = np.array([-300], dtype=np.int64) out = round_to_valid_lot(target, prev, np.array([100]), np.array([100])) assert out[0] > 0 # follows target sign, not prev # --- repair_exposure (two-stage) --------------------------------------------- def _gross_net(q, price): v = q.astype(float) * price return float(np.abs(v).sum()), float(v.sum()) def test_repair_drives_net_and_gross(): rng = np.random.default_rng(1) n = 200 price = rng.uniform(5, 100, n) alpha = rng.standard_normal(n) alpha -= alpha.mean() B = 1e7 _, _, q_target = continuous_targets(alpha, price, B) min_open = np.full(n, 100) inc = np.full(n, 1) prev = np.zeros(n, dtype=np.int64) q_round = round_to_valid_lot(q_target, prev, min_open, inc) pos = repair_exposure(q_round, q_target, price, inc, min_open, prev, booksize=B, net_tol=0.01, gross_tol=0.01) gross, net = _gross_net(pos, price) assert abs(net) <= 0.02 * B + price.max() * 1 # within band + a step 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 price = rng.uniform(5, 100, n) alpha = rng.standard_normal(n) alpha -= alpha.mean() B = 5e6 _, v_target, q_target = continuous_targets(alpha, price, B) inc = np.full(n, 1) min_open = np.full(n, 100) prev = np.zeros(n, dtype=np.int64) q_round = round_to_valid_lot(q_target, prev, min_open, inc) pos = repair_exposure(q_round, q_target, price, inc, min_open, prev, booksize=B, net_tol=0.01, gross_tol=0.01) te_round = np.sum((q_round * price - v_target) ** 2) te_pos = np.sum((pos * price - v_target) ** 2) # Repair should keep TE comparable (not blow it up by orders of magnitude). assert te_pos <= 5.0 * te_round + B def test_repair_scales_to_4000_names(): rng = np.random.default_rng(3) n = 4000 price = rng.uniform(5, 100, n) alpha = rng.standard_normal(n) alpha -= alpha.mean() B = 1e8 _, _, q_target = continuous_targets(alpha, price, B) inc = np.full(n, 1) min_open = np.full(n, 100) prev = np.zeros(n, dtype=np.int64) q_round = round_to_valid_lot(q_target, prev, min_open, inc) pos = repair_exposure(q_round, q_target, price, inc, min_open, prev, booksize=B) gross, net = _gross_net(pos, price) assert abs(net) <= 0.03 * B assert abs(gross - B) <= 0.03 * B def test_repair_handles_empty_input(): result = repair_exposure( np.array([], dtype=np.int64), np.array([], dtype=float), np.array([], dtype=float), np.array([], dtype=np.int64), np.array([], dtype=np.int64), np.array([], dtype=np.int64), ) assert result.dtype == np.int64 assert result.tolist() == [] def test_repair_respects_max_iters_and_zero_increment_noop(): q_round = np.array([100, -100], dtype=np.int64) q_target = np.array([300.0, -300.0]) price = np.array([10.0, 10.0]) min_open = np.array([100, 100]) prev = np.zeros(2, dtype=np.int64) capped = repair_exposure( q_round, q_target, price, increment=np.array([1, 1]), min_open=min_open, prev_shares=prev, booksize=10_000.0, gross_tol=0.0, max_iters=0, ) zero_increment = repair_exposure( q_round, q_target, price, increment=np.array([0, 0]), min_open=min_open, prev_shares=prev, booksize=0.0, net_tol=0.0, gross_tol=0.0, ) assert capped.tolist() == [100, -100] assert zero_increment.tolist() == [100, -100] def test_repair_gross_growth_obeys_net_band(): pos = repair_exposure( q_round=np.array([100], dtype=np.int64), q_target=np.array([300.0]), price=np.array([10.0]), increment=np.array([1]), min_open=np.array([100]), prev_shares=np.array([0], dtype=np.int64), booksize=2_000.0, net_tol=0.5, gross_tol=0.0, ) assert pos.tolist() == [100] # --- construct_positions ----------------------------------------------------- def test_construct_positions_schema(): data = _make_data() weights = _make_weights(data) pos = construct_positions(weights, data, booksize=1e6, portfolio_name="run1") assert list(pos.columns) == POSITION_COLUMNS assert (pos["portfolio_name"] == "run1").all() assert pos["position_shares"].dtype == np.int64 def test_construct_positions_empty_weights_returns_schema(): data = _make_data(n_days=3) empty_weights = pd.DataFrame(columns=["symbol_id", "date", "combo_name", "weight"]) pos = construct_positions( empty_weights, data, booksize=1e6, portfolio_name="empty", ) assert list(pos.columns) == POSITION_COLUMNS assert pos.empty def test_construct_positions_ignores_absent_or_bad_prices(): dates = pd.to_datetime(["2024-01-01", "2024-01-02"]) data = pd.DataFrame([ {"symbol_id": "sh600000", "date": dates[0], "close": np.nan, "isST": 0}, {"symbol_id": "sz000001", "date": dates[0], "close": 20.0, "isST": 0}, {"symbol_id": "sh600000", "date": dates[1], "close": 10.0, "isST": 0}, {"symbol_id": "sz000001", "date": dates[1], "close": 20.0, "isST": 0}, ]) 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, 1.0, -1.0], }) pos = construct_positions(weights, data, booksize=10000.0, portfolio_name="bad_price") bad_price_rows = pos[ (pos["date"] == dates[0]) & (pos["symbol_id"] == "sh600000") ] assert bad_price_rows.empty or (bad_price_rows["target_weight"] == 0.0).all() assert np.isfinite(pos["target_value"]).all() assert np.isfinite(pos["position_value"]).all() def test_construct_positions_threads_state_and_closes_absent(): data = _make_data() weights = _make_weights(data) # Drop the last 3 dates of one symbol so it goes "absent" → must be closed. sym = "sz300750" last_dates = sorted(weights["date"].unique())[-3:] weights = weights[~((weights["symbol_id"] == sym) & (weights["date"].isin(last_dates)))] pos = construct_positions(weights, data, booksize=1e6, portfolio_name="run1") final_date = pos["date"].max() final = pos[(pos["symbol_id"] == sym) & (pos["date"] == final_date)] # Either no row, or a zeroed position for the absent name on the final date. assert final.empty or (final["position_shares"] == 0).all() def test_construct_positions_closes_absent_short_position(): dates = pd.to_datetime(["2024-01-02", "2024-01-03"]) data = pd.DataFrame([ {"symbol_id": sym, "date": d, "close": price, "isST": 0} for d in dates for sym, price in (("sh600000", 10.0), ("sz000001", 20.0)) ]) weights = pd.DataFrame({ "symbol_id": ["sh600000", "sz000001", "sz000001"], "date": [dates[0], dates[0], dates[1]], "combo_name": ["combo", "combo", "combo"], "weight": [-1.0, 1.0, 1.0], }) pos = construct_positions(weights, data, booksize=20000.0, portfolio_name="absent_short") first_day_short = pos[ (pos["date"] == dates[0]) & (pos["symbol_id"] == "sh600000") ] final_day_short = pos[ (pos["date"] == dates[1]) & (pos["symbol_id"] == "sh600000") ] assert (first_day_short["position_shares"] < 0).all() assert final_day_short.empty or (final_day_short["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): base = dict( symbol_ids=np.array([f"s{i}" for i in range(n)], dtype=object), date=dt.date(2024, 1, 2), price=np.full(n, 10.0), preclose=np.full(n, 10.0), amount=np.full(n, 1e6), tradestatus=np.ones(n), is_st=np.zeros(n), limit_status=np.zeros(n, dtype=np.int8), close=np.full(n, 10.0), ) base.update(over) return MarketSlice(**base) def test_suspension_blocks_all_delta(): n = 2 sl = _slice(n, tradestatus=np.array([1.0, 0.0])) ctx = TradeContext(np.zeros(n, np.int64), np.array([100, 100]), sl, 1e6) low, high = SuspensionConstraint().delta_bounds(ctx) assert low[1] == 0.0 and high[1] == 0.0 assert np.isinf(high[0]) def test_price_limit_blocks_directionally(): n = 2 sl = _slice(n, limit_status=np.array([LimitStatus.UP_LIMIT.value, LimitStatus.DOWN_LIMIT.value], dtype=np.int8)) ctx = TradeContext(np.zeros(n, np.int64), np.array([100, -100]), sl, 1e6) low, high = PriceLimitConstraint().delta_bounds(ctx) assert high[0] == 0.0 # up-limit: cannot buy assert low[1] == 0.0 # down-limit: cannot sell def test_volume_cap_uses_traded_value(): n = 1 # amount=1e6, price=10, max_frac=0.1 -> cap value 1e5 -> cap 1e4 shares. sl = _slice(n, amount=np.array([1e6]), price=np.array([10.0])) ctx = TradeContext(np.zeros(n, np.int64), np.array([99999]), sl, 1e6) low, high = VolumeCapConstraint(max_frac=0.1).delta_bounds(ctx) assert high[0] == 10000.0 assert low[0] == -10000.0 def test_constraints_compose_repeatably_regardless_of_order(): n = 1 sl = _slice( n, tradestatus=np.array([0.0]), limit_status=np.array([LimitStatus.UP_LIMIT.value], dtype=np.int8), amount=np.array([1_000.0]), price=np.array([10.0]), ) ctx = TradeContext(np.zeros(n, np.int64), np.array([500]), sl, 1e6) first_order = ReferenceSimulator( constraints=[ SuspensionConstraint(), PriceLimitConstraint(), VolumeCapConstraint(max_frac=0.1), ], cost_bps=10, ).fill(ctx) reversed_order = ReferenceSimulator( constraints=[ VolumeCapConstraint(max_frac=0.1), PriceLimitConstraint(), SuspensionConstraint(), ], cost_bps=10, ).fill(ctx) assert first_order.traded_shares.tolist() == [0] assert first_order.realized_shares.tolist() == [0] assert first_order.blocked.tolist() == [1] assert np.array_equal(first_order.traded_shares, reversed_order.traded_shares) assert np.array_equal(first_order.realized_shares, reversed_order.realized_shares) assert np.array_equal(first_order.blocked, reversed_order.blocked) assert np.array_equal(first_order.cost, reversed_order.cost) def test_constraint_registry_and_default_adjust_targets(): class _NoopConstraint(TradeConstraint): name = "_coverage_noop_constraint" def delta_bounds(self, ctx): return np.zeros(1), np.ones(1) registered = register_constraint(_NoopConstraint) assert registered is _NoopConstraint assert "_coverage_noop_constraint" in available_constraints() assert isinstance(get_constraint("_coverage_noop_constraint"), _NoopConstraint) assert get_constraint("_coverage_noop_constraint").adjust_targets(object()) is None with np.testing.assert_raises(KeyError): get_constraint("_missing_constraint") with np.testing.assert_raises(TypeError): register_constraint(object) # type: ignore[arg-type] with np.testing.assert_raises(ValueError): class _NoNameConstraint(TradeConstraint): def delta_bounds(self, ctx): return np.zeros(1), np.ones(1) register_constraint(_NoNameConstraint) with np.testing.assert_raises(ValueError): class _DuplicateConstraint(TradeConstraint): name = "_coverage_noop_constraint" def delta_bounds(self, ctx): return np.zeros(1), np.ones(1) register_constraint(_DuplicateConstraint) # --- ReferenceSimulator ------------------------------------------------------ def test_simulator_applies_constraint_target_adjustment(): class _HalveTarget(TradeConstraint): name = "_halve_target" def adjust_targets(self, ctx): return ctx.target_shares // 2 def delta_bounds(self, ctx): return np.full(len(ctx.target_shares), -np.inf), np.full(len(ctx.target_shares), np.inf) sl = _slice(1, price=np.array([10.0])) ctx = TradeContext(np.array([0], np.int64), np.array([100], np.int64), sl, 1e6) result = ReferenceSimulator(constraints=[_HalveTarget()]).fill(ctx) assert result.traded_shares.tolist() == [50] assert result.realized_shares.tolist() == [50] def test_simulator_empty_positions_uses_default_booksize(): data = pd.DataFrame({ "symbol_id": ["sh600000"], "date": [pd.Timestamp("2024-01-02")], "open": [10.0], "close": [10.0], "preclose": [10.0], "amount": [1e9], "tradestatus": [1], "isST": [0], }) positions = pd.DataFrame(columns=POSITION_COLUMNS) fills, pnl = ReferenceSimulator().run(positions, data) assert list(fills.columns) == FILL_COLUMNS assert list(pnl.columns) == PNL_COLUMNS assert fills.empty assert pnl.empty def test_simulator_next_open_and_blocked_buy_holds_prev(): data = _make_data(n_days=15) weights = _make_weights(data) pos = construct_positions(weights, data, booksize=1e6, portfolio_name="run1") sim = ReferenceSimulator(constraints=[SuspensionConstraint()], cost_bps=5, slippage_bps=5) fills, pnl = sim.run(pos, data) assert list(fills.columns) == FILL_COLUMNS assert list(pnl.columns) == PNL_COLUMNS # realized = prev + traded must always hold. 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()]) sl = _slice(n, tradestatus=np.array([0.0])) ctx = TradeContext(np.array([0], np.int64), np.array([500]), sl, 1e6) res = sim.fill(ctx) assert res.traded_shares[0] == 0 assert res.realized_shares[0] == 0 assert res.blocked[0] == 1 assert res.cost[0] == 0.0 def test_simulator_cost_is_positive_when_trading(): n = 1 sim = ReferenceSimulator(constraints=[], cost_bps=10, slippage_bps=5) sl = _slice(n, price=np.array([20.0])) ctx = TradeContext(np.array([0], np.int64), np.array([1000]), sl, 1e6) res = sim.fill(ctx) assert res.traded_shares[0] == 1000 # 1000 * 20 * (15/1e4) = 30 assert np.isclose(res.cost[0], 1000 * 20 * 15 / 1e4) def test_simulator_cost_only_on_nonzero_realized_trades(): n = 2 sim = ReferenceSimulator(constraints=[], cost_bps=10) sl = _slice(n, price=np.array([10.0, 20.0])) ctx = TradeContext(np.array([100, 100], np.int64), np.array([100, 150], np.int64), sl, 1e6) res = sim.fill(ctx) assert res.traded_shares.tolist() == [0, 50] assert res.cost[0] == 0.0 assert np.isclose(res.cost[1], 50 * 20 * 10 / 1e4) def test_simulator_short_to_long_flip_trades_full_delta(): dates = pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]) positions = pd.DataFrame({ "symbol_id": ["sh600000", "sh600000"], "date": [dates[0], dates[1]], "portfolio_name": ["flip", "flip"], "target_weight": [-1.0, 1.0], "target_value": [-1000.0, 1000.0], "target_shares": [-100.0, 100.0], "position_shares": [-100, 100], "position_value": [-1000.0, 1000.0], "price": [10.0, 10.0], }) data = pd.DataFrame({ "symbol_id": ["sh600000"] * 3, "date": dates, "open": [10.0, 10.0, 10.0], "close": [10.0, 10.0, 10.0], "preclose": [10.0, 10.0, 10.0], "amount": [1e9, 1e9, 1e9], "tradestatus": [1, 1, 1], "isST": [0, 0, 0], }) fills, _ = ReferenceSimulator().run(positions, data) by_date = fills.set_index("date") assert by_date.loc[dates[1], "traded_shares"] == -100 assert by_date.loc[dates[1], "realized_shares"] == -100 assert by_date.loc[dates[2], "prev_shares"] == -100 assert by_date.loc[dates[2], "traded_shares"] == 200 assert by_date.loc[dates[2], "realized_shares"] == 100 def test_simulator_volume_cap_partially_fills_sell(): sl = _slice(1, amount=np.array([10_000.0]), price=np.array([10.0])) ctx = TradeContext( np.array([1000], np.int64), np.array([0], np.int64), sl, 1_000_000.0, ) result = ReferenceSimulator( constraints=[VolumeCapConstraint(max_frac=0.10)] ).fill(ctx) assert result.traded_shares.tolist() == [-100] assert result.realized_shares.tolist() == [900] assert result.blocked.tolist() == [1] def test_simulator_missing_next_open_has_zero_cost_and_turnover(): dates = pd.to_datetime(["2024-01-01", "2024-01-02"]) positions = pd.DataFrame({ "symbol_id": ["sh600000"], "date": [dates[0]], "portfolio_name": ["missing_open"], "target_weight": [1.0], "target_value": [1000.0], "target_shares": [100.0], "position_shares": [100], "position_value": [1000.0], "price": [10.0], }) data = pd.DataFrame({ "symbol_id": ["sh600000", "sh600000"], "date": dates, "open": [10.0, np.nan], "close": [10.0, 10.0], "preclose": [10.0, 10.0], "amount": [1e9, 1e9], "tradestatus": [1, 1], "isST": [0, 0], }) fills, pnl = ReferenceSimulator(cost_bps=10, slippage_bps=5).run(positions, data) assert fills["traded_shares"].iloc[0] == 100 assert fills["trade_cost"].iloc[0] == 0.0 assert pnl["cost"].iloc[0] == 0.0 assert pnl["turnover"].iloc[0] == 0.0 assert pnl["gross_exposure"].iloc[0] == 1000.0 def test_simple_cost_model_adds_cost_and_slippage_without_price_adjustment(): model = SimpleProportionalCostModel(cost_bps=10, slippage_bps=5) cost = model.compute( traded_shares=np.array([1000, -1000]), execution_price=np.array([20.0, 20.0]), side=np.array([1, -1]), date=dt.date(2024, 1, 2), ) assert np.allclose(cost, np.array([30.0, 30.0])) def test_daily_pnl_cost_matches_fill_trade_cost_sum(): dates = pd.to_datetime(["2024-01-01", "2024-01-02"]) positions = pd.DataFrame({ "symbol_id": ["sh600000", "sz000001"], "date": [dates[0], dates[0]], "portfolio_name": ["run1", "run1"], "target_weight": [0.5, -0.5], "target_value": [1000.0, -1000.0], "target_shares": [100.0, -50.0], "position_shares": [100, -50], "position_value": [1000.0, -1000.0], "price": [10.0, 20.0], }) data = pd.DataFrame([ { "symbol_id": sym, "date": d, "open": price, "close": price, "preclose": price, "amount": 1e9, "tradestatus": 1, "isST": 0, } for d in dates for sym, price in (("sh600000", 10.0), ("sz000001", 20.0)) ]) fills, pnl = ReferenceSimulator(cost_bps=10, slippage_bps=5).run(positions, data) total_fill_cost = fills["trade_cost"].sum() assert np.isclose(total_fill_cost, 3.0) assert np.isclose(pnl["cost"].iloc[0], total_fill_cost) assert np.isclose(pnl["pnl"].iloc[0], -total_fill_cost) # --- evaluate_portfolio ------------------------------------------------------ def test_evaluate_portfolio_keys_no_ic(): data = _make_data() weights = _make_weights(data) pos = construct_positions(weights, data, booksize=1e6, portfolio_name="run1") metrics = evaluate_portfolio(pos, data) for key in ("cumulative_return", "sharpe_annual", "turnover_annual", "max_drawdown", "fitness", "hit_rate", "n_dates"): assert key in metrics assert "ic" not in metrics assert "rank_ic" not in metrics def test_evaluate_portfolio_excludes_signal_without_forward_return(): dates = pd.date_range("2024-01-01", periods=3) data = pd.DataFrame([ {"symbol_id": sym, "date": d, "open": price, "close": price} for d, prices in zip(dates, [(100.0, 100.0), (100.0, 100.0), (200.0, 100.0)]) for sym, price in zip(("sh600000", "sz000001"), prices) ]) positions = pd.DataFrame({ "symbol_id": ["sh600000", "sz000001", "sh600000", "sz000001"], "date": [dates[0], dates[0], dates[1], dates[1]], "portfolio_name": ["run1"] * 4, "target_weight": [0.5, -0.5, -0.5, 0.5], "target_value": [500.0, -500.0, -500.0, 500.0], "target_shares": [5.0, -5.0, -2.5, 5.0], "position_shares": [5, -5, -2, 5], "position_value": [500.0, -500.0, -400.0, 500.0], "price": [100.0, 100.0, 200.0, 100.0], }) metrics = evaluate_portfolio(positions, data) assert metrics["n_dates"] == 1 def test_evaluate_portfolio_empty_and_single_return_paths(): empty_metrics = evaluate_portfolio( pd.DataFrame(columns=POSITION_COLUMNS), pd.DataFrame(columns=["symbol_id", "date", "open"]), ) assert empty_metrics["n_dates"] == 0 assert empty_metrics["cumulative_return"] == 0.0 dates = pd.date_range("2024-01-01", periods=3) data = pd.DataFrame([ {"symbol_id": "sh600000", "date": d, "open": price} for d, price in zip(dates, [100.0, 100.0, 110.0]) ]) positions = pd.DataFrame({ "symbol_id": ["sh600000"], "date": [dates[0]], "portfolio_name": ["single"], "target_weight": [1.0], "target_value": [1000.0], "target_shares": [10.0], "position_shares": [10], "position_value": [1000.0], "price": [100.0], }) single_metrics = evaluate_portfolio(positions, data) assert single_metrics["n_dates"] == 1 assert single_metrics["cumulative_return"] == 0.0