0a6f367fbf
Weights formed from close[t] now earn the t→t+1 return: forward returns are computed on the full market calendar before selecting signal dates, so a sparse signal grid earns the next available return rather than the next signal date, and the final signal date (no forward return) is dropped. Signal pct_change uses fill_method=None so suspended names do not inherit stale prices. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
562 lines
20 KiB
Python
562 lines
20 KiB
Python
"""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,
|
|
compute_limit_status,
|
|
detect_board,
|
|
)
|
|
from pipeline.portfolio.research import evaluate_portfolio
|
|
from pipeline.portfolio.constraints import (
|
|
PriceLimitConstraint,
|
|
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
|
|
|
|
|
|
# --- 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_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
|
|
|
|
|
|
# --- 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
|
|
|
|
|
|
# --- 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_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_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
|
|
|
|
|
|
# --- ReferenceSimulator ------------------------------------------------------
|
|
|
|
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_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, "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[1], dates[1], dates[2], dates[2]],
|
|
"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
|