229 lines
6.5 KiB
Python
229 lines
6.5 KiB
Python
import copy
|
|
import unittest
|
|
|
|
from quant60.baseline_pipeline import (
|
|
AlphaForecastInput,
|
|
BaselinePipelineError,
|
|
BaselinePipelinePolicy,
|
|
CostForecastInput,
|
|
FIVE_LAYER_ORDER,
|
|
RiskForecastInput,
|
|
SecurityStateInput,
|
|
build_baseline_pipeline,
|
|
verify_layer_trace,
|
|
)
|
|
from quant60.optimizer import PortfolioConstraints
|
|
from quant60.universe import UniverseState
|
|
|
|
|
|
SYMBOLS = (
|
|
"600000.XSHG",
|
|
"000001.XSHE",
|
|
"000333.XSHE",
|
|
)
|
|
|
|
|
|
def inputs(*, first_variance=0.03, first_cost_bps=8.0):
|
|
alpha = [
|
|
AlphaForecastInput(
|
|
SYMBOLS[0],
|
|
expected_excess_return=0.030,
|
|
model_version="ridge-frozen-fixture-v1",
|
|
feature_version="transparent-price-v1",
|
|
),
|
|
AlphaForecastInput(
|
|
SYMBOLS[1],
|
|
expected_excess_return=0.020,
|
|
model_version="ridge-frozen-fixture-v1",
|
|
feature_version="transparent-price-v1",
|
|
),
|
|
AlphaForecastInput(
|
|
SYMBOLS[2],
|
|
expected_excess_return=0.010,
|
|
model_version="ridge-frozen-fixture-v1",
|
|
feature_version="transparent-price-v1",
|
|
),
|
|
]
|
|
risk = [
|
|
RiskForecastInput(
|
|
SYMBOLS[0],
|
|
first_variance,
|
|
"diagonal-risk-fixture-v1",
|
|
),
|
|
RiskForecastInput(SYMBOLS[1], 0.03, "diagonal-risk-fixture-v1"),
|
|
RiskForecastInput(SYMBOLS[2], 0.03, "diagonal-risk-fixture-v1"),
|
|
]
|
|
cost = [
|
|
CostForecastInput(
|
|
SYMBOLS[0],
|
|
first_cost_bps,
|
|
1.0,
|
|
"dated-cost-fixture-v1",
|
|
),
|
|
CostForecastInput(SYMBOLS[1], 8.0, 1.0, "dated-cost-fixture-v1"),
|
|
CostForecastInput(SYMBOLS[2], 8.0, 1.0, "dated-cost-fixture-v1"),
|
|
]
|
|
securities = [
|
|
SecurityStateInput(
|
|
SYMBOLS[0],
|
|
UniverseState.OPENABLE,
|
|
"bank",
|
|
10.0,
|
|
),
|
|
SecurityStateInput(
|
|
SYMBOLS[1],
|
|
UniverseState.OPENABLE,
|
|
"bank",
|
|
20.0,
|
|
),
|
|
SecurityStateInput(
|
|
SYMBOLS[2],
|
|
UniverseState.OPENABLE,
|
|
"consumer",
|
|
50.0,
|
|
),
|
|
]
|
|
return alpha, risk, cost, securities
|
|
|
|
|
|
def policy():
|
|
return BaselinePipelinePolicy(
|
|
constraints=PortfolioConstraints(
|
|
gross_target=0.80,
|
|
single_name_cap=0.80,
|
|
industry_cap=0.80,
|
|
one_way_turnover_cap=0.80,
|
|
risk_aversion=1.0,
|
|
cost_aversion=1.0,
|
|
turnover_aversion=0.0,
|
|
),
|
|
cash_buffer=0.10,
|
|
)
|
|
|
|
|
|
class BaselinePipelineTests(unittest.TestCase):
|
|
def test_canonical_path_emits_exact_five_layer_hash_chain(self):
|
|
alpha, risk, cost, securities = inputs()
|
|
result = build_baseline_pipeline(
|
|
alpha=alpha,
|
|
risk=risk,
|
|
cost=cost,
|
|
securities=securities,
|
|
equity=1_000_000,
|
|
policy=policy(),
|
|
)
|
|
self.assertEqual(
|
|
tuple(event["layer"] for event in result.layer_trace),
|
|
FIVE_LAYER_ORDER,
|
|
)
|
|
self.assertTrue(result.risk_decision.approved)
|
|
self.assertTrue(result.order_deltas)
|
|
verify_layer_trace(result.layer_trace, result.layer_payloads)
|
|
|
|
def test_risk_and_cost_are_causal_inputs_to_portfolio(self):
|
|
alpha, risk, cost, securities = inputs()
|
|
base = build_baseline_pipeline(
|
|
alpha=alpha,
|
|
risk=risk,
|
|
cost=cost,
|
|
securities=securities,
|
|
equity=1_000_000,
|
|
policy=policy(),
|
|
)
|
|
alpha, risk, cost, securities = inputs(first_variance=0.30)
|
|
high_risk = build_baseline_pipeline(
|
|
alpha=alpha,
|
|
risk=risk,
|
|
cost=cost,
|
|
securities=securities,
|
|
equity=1_000_000,
|
|
policy=policy(),
|
|
)
|
|
alpha, risk, cost, securities = inputs(first_cost_bps=250.0)
|
|
high_cost = build_baseline_pipeline(
|
|
alpha=alpha,
|
|
risk=risk,
|
|
cost=cost,
|
|
securities=securities,
|
|
equity=1_000_000,
|
|
policy=policy(),
|
|
)
|
|
symbol = SYMBOLS[0]
|
|
self.assertLess(
|
|
high_risk.solution.weights[symbol],
|
|
base.solution.weights[symbol],
|
|
)
|
|
self.assertLess(
|
|
high_cost.solution.weights[symbol],
|
|
base.solution.weights[symbol],
|
|
)
|
|
self.assertNotEqual(
|
|
base.layer_trace[2]["event_sha256"],
|
|
high_risk.layer_trace[2]["event_sha256"],
|
|
)
|
|
self.assertNotEqual(
|
|
base.layer_trace[2]["event_sha256"],
|
|
high_cost.layer_trace[2]["event_sha256"],
|
|
)
|
|
|
|
def test_missing_layer_input_fails_closed(self):
|
|
alpha, risk, cost, securities = inputs()
|
|
with self.assertRaisesRegex(
|
|
BaselinePipelineError,
|
|
"symbol sets must match exactly",
|
|
):
|
|
build_baseline_pipeline(
|
|
alpha=alpha,
|
|
risk=risk[:-1],
|
|
cost=cost,
|
|
securities=securities,
|
|
equity=1_000_000,
|
|
policy=policy(),
|
|
)
|
|
|
|
def test_smoke_model_cannot_claim_baseline_candidate(self):
|
|
alpha, risk, cost, securities = inputs()
|
|
alpha = [
|
|
AlphaForecastInput(
|
|
item.symbol,
|
|
item.expected_excess_return,
|
|
"portable-momentum-v1",
|
|
item.feature_version,
|
|
)
|
|
for item in alpha
|
|
]
|
|
with self.assertRaisesRegex(
|
|
BaselinePipelineError,
|
|
"platform smoke strategy",
|
|
):
|
|
build_baseline_pipeline(
|
|
alpha=alpha,
|
|
risk=risk,
|
|
cost=cost,
|
|
securities=securities,
|
|
equity=1_000_000,
|
|
policy=policy(),
|
|
)
|
|
|
|
def test_layer_trace_tampering_is_rejected(self):
|
|
alpha, risk, cost, securities = inputs()
|
|
result = build_baseline_pipeline(
|
|
alpha=alpha,
|
|
risk=risk,
|
|
cost=cost,
|
|
securities=securities,
|
|
equity=1_000_000,
|
|
policy=policy(),
|
|
)
|
|
trace = copy.deepcopy(list(result.layer_trace))
|
|
trace[2]["output_sha256"] = "0" * 64
|
|
with self.assertRaisesRegex(
|
|
BaselinePipelineError,
|
|
"output hash mismatch",
|
|
):
|
|
verify_layer_trace(trace, result.layer_payloads)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|