172 lines
5.6 KiB
Python
172 lines
5.6 KiB
Python
import unittest
|
|
|
|
from quant60.portable_core import (
|
|
build_rebalance_plan,
|
|
capped_target_weights,
|
|
momentum_score,
|
|
normalize_symbol,
|
|
order_deltas,
|
|
target_quantities,
|
|
)
|
|
|
|
|
|
class PortableCoreTests(unittest.TestCase):
|
|
def test_symbol_round_trip(self):
|
|
inputs = [
|
|
("600000.XSHG", "600000.SH", "SH600000"),
|
|
("000001.XSHE", "000001.SZ", "SZ000001"),
|
|
("830799.XBSE", "830799.BJ", "BJ830799"),
|
|
]
|
|
for canonical, qmt, qlib in inputs:
|
|
self.assertEqual(normalize_symbol(qmt), canonical)
|
|
self.assertEqual(normalize_symbol(qlib), canonical)
|
|
self.assertEqual(normalize_symbol(canonical, "qmt"), qmt)
|
|
self.assertEqual(normalize_symbol(canonical, "qlib"), qlib)
|
|
|
|
def test_momentum_uses_latest_completed_bar(self):
|
|
self.assertAlmostEqual(momentum_score([10, 11, 12], 2, 0), 0.2)
|
|
self.assertAlmostEqual(momentum_score([10, 11, 12], 1, 1), 0.1)
|
|
|
|
def test_caps_and_cash_buffer_are_not_double_counted(self):
|
|
weights = capped_target_weights(
|
|
{"600000.XSHG": 3, "000001.XSHE": 2, "300750.XSHE": 1},
|
|
max_weight=0.4,
|
|
gross_target=0.95,
|
|
)
|
|
self.assertAlmostEqual(sum(weights.values()), 0.95)
|
|
self.assertLessEqual(max(weights.values()), 0.4 + 1e-12)
|
|
quantities = target_quantities(
|
|
weights,
|
|
{symbol: 10 for symbol in weights},
|
|
equity=1_000_000,
|
|
lot_size=100,
|
|
cash_buffer=0.02,
|
|
)
|
|
invested = sum(quantity * 10 for quantity in quantities.values())
|
|
self.assertGreater(invested, 930_000)
|
|
self.assertLessEqual(invested, 980_000)
|
|
|
|
def test_non_finite_or_out_of_range_portfolio_inputs_are_rejected(self):
|
|
for value in (float("nan"), float("inf"), float("-inf")):
|
|
with self.subTest(kind="score", value=value):
|
|
with self.assertRaises(ValueError):
|
|
capped_target_weights({"600000.SH": value})
|
|
with self.subTest(kind="weight", value=value):
|
|
with self.assertRaises(ValueError):
|
|
target_quantities(
|
|
{"600000.SH": value},
|
|
{"600000.SH": 10.0},
|
|
100_000,
|
|
)
|
|
with self.subTest(kind="equity", value=value):
|
|
with self.assertRaises(ValueError):
|
|
target_quantities(
|
|
{"600000.SH": 0.5},
|
|
{"600000.SH": 10.0},
|
|
value,
|
|
)
|
|
for max_weight, gross_target in ((1.1, 0.9), (0.2, 1.1)):
|
|
with self.subTest(max_weight=max_weight, gross_target=gross_target):
|
|
with self.assertRaises(ValueError):
|
|
capped_target_weights(
|
|
{"600000.SH": 1.0},
|
|
max_weight=max_weight,
|
|
gross_target=gross_target,
|
|
)
|
|
|
|
def test_sell_delta_respects_sellable(self):
|
|
deltas = order_deltas(
|
|
{"600000.XSHG": 0},
|
|
{"600000.XSHG": 1000},
|
|
{"600000.XSHG": 300},
|
|
lot_size=100,
|
|
)
|
|
self.assertEqual(deltas, {"600000.XSHG": -300})
|
|
|
|
def test_star_targets_and_partial_orders_respect_200_share_minimum(self):
|
|
self.assertEqual(
|
|
target_quantities(
|
|
{"688301.XSHG": 1.0},
|
|
{"688301.XSHG": 300.0},
|
|
equity=50_000,
|
|
lot_size=100,
|
|
cash_buffer=0.02,
|
|
),
|
|
{"688301.XSHG": 0},
|
|
)
|
|
self.assertEqual(
|
|
target_quantities(
|
|
{"688301.XSHG": 1.0},
|
|
{"688301.XSHG": 200.0},
|
|
equity=50_000,
|
|
lot_size=100,
|
|
cash_buffer=0.02,
|
|
),
|
|
{"688301.XSHG": 200},
|
|
)
|
|
self.assertEqual(
|
|
order_deltas(
|
|
{"688301.XSHG": 300},
|
|
{"688301.XSHG": 200},
|
|
{"688301.XSHG": 200},
|
|
lot_size=100,
|
|
),
|
|
{},
|
|
)
|
|
self.assertEqual(
|
|
order_deltas(
|
|
{"688301.XSHG": 1300},
|
|
{"688301.XSHG": 1400},
|
|
{"688301.XSHG": 1400},
|
|
lot_size=100,
|
|
),
|
|
{},
|
|
)
|
|
|
|
def test_complete_odd_lot_liquidation_is_preserved(self):
|
|
self.assertEqual(
|
|
order_deltas(
|
|
{"600000.XSHG": 0},
|
|
{"600000.XSHG": 1050},
|
|
{"600000.XSHG": 1050},
|
|
lot_size=100,
|
|
),
|
|
{"600000.XSHG": -1050},
|
|
)
|
|
self.assertEqual(
|
|
order_deltas(
|
|
{"688301.XSHG": 0},
|
|
{"688301.XSHG": 150},
|
|
{"688301.XSHG": 150},
|
|
lot_size=100,
|
|
),
|
|
{"688301.XSHG": -150},
|
|
)
|
|
|
|
def test_complete_plan_is_deterministic(self):
|
|
histories = {
|
|
"600000.SH": [10 + index * 0.1 for index in range(25)],
|
|
"000001.SZ": [10 + index * 0.05 for index in range(25)],
|
|
}
|
|
arguments = dict(
|
|
price_history=histories,
|
|
current={},
|
|
sellable={},
|
|
equity=100_000,
|
|
lookback=20,
|
|
skip=0,
|
|
top_n=2,
|
|
max_weight=0.5,
|
|
gross_target=0.95,
|
|
cash_buffer=0.02,
|
|
lot_size=100,
|
|
)
|
|
self.assertEqual(
|
|
build_rebalance_plan(**arguments),
|
|
build_rebalance_plan(**arguments),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|