feat: add Quant OS A-share baseline
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
import unittest
|
||||
from datetime import date
|
||||
|
||||
from quant60.config import FeeConfig
|
||||
from quant60.costs import (
|
||||
CostModelParameters,
|
||||
DatedFeeRule,
|
||||
VersionedFeeSchedule,
|
||||
forecast_trade_cost,
|
||||
)
|
||||
from quant60.domain import Side
|
||||
from quant60.optimizer import (
|
||||
OptimizationError,
|
||||
PortfolioCandidate,
|
||||
PortfolioConstraints,
|
||||
optimize_cvxpy,
|
||||
optimize_deterministic,
|
||||
)
|
||||
from quant60.universe import UniverseState
|
||||
|
||||
|
||||
class CostOptimizerTests(unittest.TestCase):
|
||||
def test_fee_schedule_switches_by_effective_date(self):
|
||||
first = DatedFeeRule(
|
||||
date(2024, 1, 1),
|
||||
date(2024, 6, 30),
|
||||
FeeConfig(stamp_duty_rate=0.001),
|
||||
"old",
|
||||
)
|
||||
second = DatedFeeRule(
|
||||
date(2024, 7, 1),
|
||||
None,
|
||||
FeeConfig(stamp_duty_rate=0.0005),
|
||||
"new",
|
||||
)
|
||||
schedule = VersionedFeeSchedule([second, first])
|
||||
self.assertEqual(schedule.at(date(2024, 6, 30)).version, "old")
|
||||
self.assertEqual(schedule.at(date(2024, 7, 1)).version, "new")
|
||||
|
||||
def test_exact_minimum_fee_and_capacity_are_visible(self):
|
||||
rule = DatedFeeRule(
|
||||
date(2024, 1, 1),
|
||||
None,
|
||||
FeeConfig(
|
||||
commission_rate=0.0002,
|
||||
minimum_commission=5,
|
||||
stamp_duty_rate=0.0005,
|
||||
transfer_fee_rate=0.00001,
|
||||
),
|
||||
"account-v1",
|
||||
)
|
||||
forecast = forecast_trade_cost(
|
||||
symbol="600000.SH",
|
||||
side=Side.BUY,
|
||||
quantity=100,
|
||||
price=10,
|
||||
adv_quantity=500,
|
||||
daily_volatility=0.02,
|
||||
fee_rule=rule,
|
||||
parameters=CostModelParameters(max_participation=0.10),
|
||||
)
|
||||
self.assertGreaterEqual(forecast.explicit_fees, 5)
|
||||
self.assertTrue(forecast.capacity_breached)
|
||||
self.assertLess(forecast.low, forecast.base)
|
||||
self.assertLess(forecast.base, forecast.high)
|
||||
|
||||
def _candidates(self):
|
||||
return [
|
||||
PortfolioCandidate(
|
||||
"600000.SH",
|
||||
score=0.04,
|
||||
variance=0.04,
|
||||
current_weight=0.10,
|
||||
cost_bps=10,
|
||||
max_adv_weight=0.30,
|
||||
industry="bank",
|
||||
),
|
||||
PortfolioCandidate(
|
||||
"000001.SZ",
|
||||
score=0.03,
|
||||
variance=0.03,
|
||||
current_weight=0.10,
|
||||
cost_bps=12,
|
||||
max_adv_weight=0.30,
|
||||
industry="bank",
|
||||
),
|
||||
PortfolioCandidate(
|
||||
"300750.SZ",
|
||||
score=0.02,
|
||||
variance=0.05,
|
||||
current_weight=0.05,
|
||||
cost_bps=15,
|
||||
max_adv_weight=0.30,
|
||||
industry="industry",
|
||||
state=UniverseState.FROZEN,
|
||||
),
|
||||
PortfolioCandidate(
|
||||
"000333.SZ",
|
||||
score=-0.01,
|
||||
variance=0.04,
|
||||
current_weight=0.10,
|
||||
cost_bps=10,
|
||||
max_adv_weight=0.30,
|
||||
industry="consumer",
|
||||
state=UniverseState.SELL_ONLY,
|
||||
),
|
||||
]
|
||||
|
||||
def test_deterministic_optimizer_honors_frozen_caps_and_turnover(self):
|
||||
constraints = PortfolioConstraints(
|
||||
gross_target=0.70,
|
||||
single_name_cap=0.30,
|
||||
industry_cap=0.45,
|
||||
one_way_turnover_cap=0.10,
|
||||
)
|
||||
result = optimize_deterministic(self._candidates(), constraints)
|
||||
self.assertAlmostEqual(result.weights["300750.XSHE"], 0.05)
|
||||
self.assertLessEqual(result.weights["000333.XSHE"], 0.10)
|
||||
self.assertLessEqual(result.one_way_turnover, 0.10 + 1e-9)
|
||||
self.assertLessEqual(result.gross_weight, 0.70 + 1e-9)
|
||||
self.assertEqual(result.solver, "deterministic_reference")
|
||||
|
||||
def test_held_excluded_candidate_fails_closed(self):
|
||||
candidate = PortfolioCandidate(
|
||||
"600000.SH",
|
||||
score=0,
|
||||
variance=0.04,
|
||||
current_weight=0.1,
|
||||
cost_bps=1,
|
||||
max_adv_weight=0.2,
|
||||
industry="bank",
|
||||
state=UniverseState.EXCLUDED,
|
||||
)
|
||||
with self.assertRaisesRegex(OptimizationError, "cannot disappear"):
|
||||
optimize_deterministic([candidate])
|
||||
|
||||
def test_cvxpy_path_when_dependency_is_available(self):
|
||||
constraints = PortfolioConstraints(
|
||||
gross_target=0.70,
|
||||
single_name_cap=0.30,
|
||||
industry_cap=0.45,
|
||||
one_way_turnover_cap=0.20,
|
||||
)
|
||||
try:
|
||||
result = optimize_cvxpy(self._candidates(), constraints)
|
||||
except OptimizationError as exc:
|
||||
if "CVXPY is unavailable" in str(exc):
|
||||
self.skipTest(str(exc))
|
||||
raise
|
||||
self.assertIn(result.status, {"OPTIMAL", "OPTIMAL_INACCURATE"})
|
||||
self.assertLessEqual(result.one_way_turnover, 0.20 + 1e-6)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user