feat: add Quant OS A-share baseline
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from platforms import joinquant_strategy, qmt_builtin_strategy
|
||||
from tools.bundle_platforms import bundle_all, effective_platform_config
|
||||
from tools.capability_probe import probe_capabilities
|
||||
|
||||
|
||||
class BundleTest(unittest.TestCase):
|
||||
def test_source_wrappers_match_canonical_baseline_config(self):
|
||||
baseline = json.loads(
|
||||
(ROOT / "configs/baseline.json").read_text(encoding="utf-8")
|
||||
)
|
||||
self.assertEqual(
|
||||
joinquant_strategy.CONFIG,
|
||||
effective_platform_config(baseline, "joinquant"),
|
||||
)
|
||||
self.assertEqual(
|
||||
qmt_builtin_strategy.CONFIG,
|
||||
effective_platform_config(baseline, "qmt_builtin"),
|
||||
)
|
||||
|
||||
def test_bundles_inline_same_core_compile_and_match_manifest(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
output = Path(temp)
|
||||
manifest = bundle_all(ROOT, output)
|
||||
manifest_file = output / "bundle_manifest.json"
|
||||
stored = json.loads(manifest_file.read_text(encoding="ascii"))
|
||||
for name, filename in {
|
||||
"joinquant": "joinquant_strategy.py",
|
||||
"qmt_builtin": "qmt_builtin_strategy.py",
|
||||
}.items():
|
||||
artifact = output / filename
|
||||
payload = artifact.read_bytes()
|
||||
compile(payload, str(artifact), "exec")
|
||||
self.assertNotIn(b"from quant60.portable_core import", payload)
|
||||
self.assertEqual(
|
||||
hashlib.sha256(payload).hexdigest(),
|
||||
stored["artifacts"][name]["output_sha256"],
|
||||
)
|
||||
(output / "qmt_builtin_strategy.py").read_bytes().decode("ascii")
|
||||
self.assertEqual(
|
||||
stored["artifacts"]["joinquant"]["core"],
|
||||
"src/quant60/portable_core.py",
|
||||
)
|
||||
self.assertNotIn(
|
||||
"/Users/", json.dumps(stored, ensure_ascii=True)
|
||||
)
|
||||
self.assertEqual(
|
||||
stored["portable_core_sha256"],
|
||||
manifest["portable_core_sha256"],
|
||||
)
|
||||
self.assertEqual(stored["baseline_config"], "configs/baseline.json")
|
||||
self.assertEqual(
|
||||
stored["baseline_config_sha256"],
|
||||
hashlib.sha256(
|
||||
(ROOT / "configs/baseline.json").read_bytes()
|
||||
).hexdigest(),
|
||||
)
|
||||
|
||||
def test_committed_dist_is_byte_for_byte_fresh(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
output = Path(temp)
|
||||
bundle_all(ROOT, output)
|
||||
for name in (
|
||||
"joinquant_strategy.py",
|
||||
"qmt_builtin_strategy.py",
|
||||
"bundle_manifest.json",
|
||||
):
|
||||
self.assertEqual(
|
||||
(ROOT / "dist" / name).read_bytes(),
|
||||
(output / name).read_bytes(),
|
||||
f"committed dist artifact is stale: {name}",
|
||||
)
|
||||
|
||||
def test_joinquant_bundle_ignores_host_shadowed_sum_name(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
output = Path(temp)
|
||||
bundle_all(ROOT, output)
|
||||
source = (output / "joinquant_strategy.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
namespace = {"sum": lambda values: values}
|
||||
exec(compile(source, "joinquant_strategy.py", "exec"), namespace)
|
||||
weights = namespace["capped_target_weights"](
|
||||
{
|
||||
"600000.XSHG": 3.0,
|
||||
"000001.XSHE": 2.0,
|
||||
"300750.XSHE": 1.0,
|
||||
},
|
||||
max_weight=0.4,
|
||||
gross_target=0.95,
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
namespace["_sum_numeric"](weights.values()),
|
||||
0.95,
|
||||
)
|
||||
quantities = namespace["target_quantities"](
|
||||
weights,
|
||||
{symbol: 10.0 for symbol in weights},
|
||||
equity=1_000_000,
|
||||
cash_buffer=0.02,
|
||||
)
|
||||
self.assertTrue(all(value >= 0 for value in quantities.values()))
|
||||
|
||||
|
||||
class CapabilityProbeTest(unittest.TestCase):
|
||||
def test_shallow_probe_does_not_claim_external_verification(self):
|
||||
report = probe_capabilities(ROOT, deep=False)
|
||||
self.assertTrue(report["artifacts"]["portable_core"])
|
||||
self.assertTrue(report["artifacts"]["colab_notebook"])
|
||||
self.assertTrue(report["artifacts"]["jqdata_snapshot_adapter"])
|
||||
self.assertTrue(report["artifacts"]["qmt_shadow_planner"])
|
||||
self.assertTrue(report["artifacts"]["qmt_shadow_cli"])
|
||||
self.assertIn("no external platform", report["verification_boundary"])
|
||||
self.assertTrue(report["matrix"]["xttrader"]["safe_default"])
|
||||
self.assertIn("rejects BJ", report["matrix"]["xttrader"]["market_scope"])
|
||||
for probe in report["optional_runtimes"].values():
|
||||
self.assertIs(probe["import_ok"], None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,460 @@
|
||||
import datetime
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from platforms import joinquant_strategy, qmt_builtin_strategy
|
||||
from platforms.fake_joinquant_harness import FakeJoinQuantHarness, FakePosition
|
||||
from platforms.fake_qmt_harness import FakeQmtContext, FakeQmtHarness
|
||||
|
||||
|
||||
def price_histories():
|
||||
return {
|
||||
"600000.XSHG": [15.0 - index * 0.02 for index in range(21)],
|
||||
"000001.XSHE": [10.0 + index * 0.10 for index in range(21)],
|
||||
"300750.XSHE": [100.0 + index * 1.5 for index in range(21)],
|
||||
"000333.XSHE": [20.0 + index * 0.05 for index in range(21)],
|
||||
}
|
||||
|
||||
|
||||
class JoinQuantContractTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.harness = FakeJoinQuantHarness(price_histories())
|
||||
self.harness.initialize(joinquant_strategy)
|
||||
|
||||
def test_initialize_enables_future_data_guard_and_weekly_open(self):
|
||||
self.assertIs(self.harness.options["avoid_future_data"], True)
|
||||
self.assertIs(self.harness.options["use_real_price"], True)
|
||||
self.assertEqual(
|
||||
self.harness.options["order_volume_ratio"],
|
||||
0.1,
|
||||
)
|
||||
self.assertEqual(self.harness.benchmark, "000905.XSHG")
|
||||
self.assertEqual(self.harness.order_cost["type"], "stock")
|
||||
self.assertAlmostEqual(
|
||||
self.harness.order_cost["cost"].open_commission,
|
||||
0.00021,
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
self.harness.order_cost["cost"].close_tax,
|
||||
0.0005,
|
||||
)
|
||||
self.assertEqual(self.harness.slippage["type"], "stock")
|
||||
self.assertAlmostEqual(
|
||||
self.harness.slippage["slippage"].value,
|
||||
0.0004,
|
||||
)
|
||||
self.assertEqual(len(self.harness.schedules), 1)
|
||||
_, run_time = self.harness.schedules[0]
|
||||
self.assertEqual(run_time, "open")
|
||||
|
||||
def test_rebalance_submits_exact_portable_executable_deltas(self):
|
||||
self.assertIsNone(self.harness.run_scheduled())
|
||||
self.harness.advance_session()
|
||||
plan = self.harness.run_scheduled()
|
||||
self.assertEqual(plan, self.harness.g.quant60_last_plan)
|
||||
self.assertTrue(self.harness.orders)
|
||||
ordered_symbols = {symbol for symbol, _ in self.harness.orders}
|
||||
expected = {
|
||||
symbol: int(delta)
|
||||
for symbol, delta in plan["orders"].items()
|
||||
if int(delta) != 0
|
||||
}
|
||||
self.assertEqual(ordered_symbols, set(expected))
|
||||
self.assertEqual(
|
||||
dict(self.harness.orders),
|
||||
expected,
|
||||
)
|
||||
|
||||
def test_missing_current_data_fails_closed_for_new_position(self):
|
||||
best = "300750.XSHE"
|
||||
del self.harness.current_data[best]
|
||||
self.assertIsNone(self.harness.run_scheduled())
|
||||
self.harness.advance_session()
|
||||
self.harness.run_scheduled()
|
||||
ordered_symbols = {symbol for symbol, _ in self.harness.orders}
|
||||
self.assertNotIn(best, ordered_symbols)
|
||||
self.assertEqual(
|
||||
self.harness.g.quant60_skipped_orders[-1]["reason"],
|
||||
"current_data_unavailable",
|
||||
)
|
||||
|
||||
def test_star_market_buy_uses_daily_high_limit_protection(self):
|
||||
histories = price_histories()
|
||||
star = "688301.XSHG"
|
||||
histories[star] = [20.0 + index * 0.3 for index in range(21)]
|
||||
harness = FakeJoinQuantHarness(histories)
|
||||
harness.current_data[star].high_limit = 31.28
|
||||
harness.initialize(joinquant_strategy)
|
||||
self.assertIsNone(harness.run_scheduled())
|
||||
harness.advance_session()
|
||||
plan = harness.run_scheduled()
|
||||
self.assertGreater(plan["orders"][star], 0)
|
||||
styles = dict(harness.order_styles)
|
||||
self.assertEqual(styles[star].kind, "limit")
|
||||
self.assertAlmostEqual(styles[star].limit_price, 31.28)
|
||||
|
||||
def test_star_market_sell_uses_daily_low_limit_protection(self):
|
||||
histories = price_histories()
|
||||
star = "688301.XSHG"
|
||||
histories[star] = [20.0 + index * 0.3 for index in range(21)]
|
||||
harness = FakeJoinQuantHarness(
|
||||
histories,
|
||||
total_value=1000.0,
|
||||
positions={star: FakePosition(1000)},
|
||||
)
|
||||
harness.current_data[star].low_limit = 17.06
|
||||
harness.initialize(joinquant_strategy)
|
||||
self.assertIsNone(harness.run_scheduled())
|
||||
harness.advance_session()
|
||||
plan = harness.run_scheduled()
|
||||
self.assertEqual(plan["orders"][star], -1000)
|
||||
styles = dict(harness.order_styles)
|
||||
self.assertEqual(styles[star].kind, "limit")
|
||||
self.assertAlmostEqual(styles[star].limit_price, 17.06)
|
||||
|
||||
def test_invalid_star_protection_price_fails_closed(self):
|
||||
histories = price_histories()
|
||||
star = "688301.XSHG"
|
||||
histories[star] = [20.0 + index * 0.3 for index in range(21)]
|
||||
harness = FakeJoinQuantHarness(histories)
|
||||
harness.current_data[star].high_limit = float("nan")
|
||||
harness.initialize(joinquant_strategy)
|
||||
self.assertIsNone(harness.run_scheduled())
|
||||
harness.advance_session()
|
||||
plan = harness.run_scheduled()
|
||||
self.assertGreater(plan["orders"][star], 0)
|
||||
self.assertNotIn(star, dict(harness.orders))
|
||||
self.assertEqual(
|
||||
harness.g.quant60_skipped_orders[-1]["reason"],
|
||||
"star_protection_price_invalid",
|
||||
)
|
||||
|
||||
def test_incomplete_history_aborts_entire_rebalance_without_orders(self):
|
||||
histories = price_histories()
|
||||
histories["300750.XSHE"] = histories["300750.XSHE"][1:]
|
||||
harness = FakeJoinQuantHarness(histories)
|
||||
harness.initialize(joinquant_strategy)
|
||||
self.assertIsNone(harness.run_scheduled())
|
||||
harness.advance_session()
|
||||
with self.assertRaises(joinquant_strategy.PriceHistoryUnavailable):
|
||||
harness.run_scheduled()
|
||||
self.assertEqual(harness.orders, [])
|
||||
|
||||
def test_joinquant_unmanaged_position_aborts_without_orders(self):
|
||||
harness = FakeJoinQuantHarness(
|
||||
price_histories(),
|
||||
positions={"601318.XSHG": FakePosition(1000)},
|
||||
)
|
||||
harness.initialize(joinquant_strategy)
|
||||
harness.g.quant60_config["universe_mode"] = "fixed"
|
||||
self.assertIsNone(harness.run_scheduled())
|
||||
harness.advance_session()
|
||||
with self.assertRaises(joinquant_strategy.UnmanagedPositionError):
|
||||
harness.run_scheduled()
|
||||
self.assertEqual(harness.orders, [])
|
||||
|
||||
def test_executable_sell_delta_preserves_t1_cap_and_odd_lot(self):
|
||||
position = FakePosition(total_amount=1050, closeable_amount=200)
|
||||
harness = FakeJoinQuantHarness(
|
||||
price_histories(),
|
||||
total_value=1000.0,
|
||||
positions={"600000.XSHG": position},
|
||||
)
|
||||
harness.initialize(joinquant_strategy)
|
||||
self.assertIsNone(harness.run_scheduled())
|
||||
harness.advance_session()
|
||||
plan = harness.run_scheduled()
|
||||
self.assertEqual(plan["orders"]["600000.XSHG"], -200)
|
||||
self.assertEqual(dict(harness.orders)["600000.XSHG"], 850)
|
||||
|
||||
def test_one_session_week_executes_on_next_available_session(self):
|
||||
self.assertIsNone(self.harness.run_scheduled())
|
||||
# Model a holiday week with no second session: the next available
|
||||
# session is the first session of the next ISO week.
|
||||
self.harness.advance_session(days=7)
|
||||
plan = self.harness.run_scheduled()
|
||||
self.assertIsNotNone(plan)
|
||||
self.assertTrue(self.harness.orders)
|
||||
self.assertEqual(
|
||||
self.harness.g.quant60_pending_first_session,
|
||||
self.harness.context.current_dt.date(),
|
||||
)
|
||||
|
||||
|
||||
class CrossPlatformParityTest(unittest.TestCase):
|
||||
def test_same_completed_close_produces_same_portable_plan(self):
|
||||
jq = FakeJoinQuantHarness(price_histories())
|
||||
jq.initialize(joinquant_strategy)
|
||||
jq.context.previous_date = datetime.date(2026, 7, 24)
|
||||
jq.context.current_dt = datetime.datetime(2026, 7, 27, 9, 30)
|
||||
jq_plan = joinquant_strategy.compute_plan(jq.context)
|
||||
|
||||
qmt_histories = {
|
||||
symbol.replace(".XSHE", ".SZ").replace(".XSHG", ".SH"): values
|
||||
for symbol, values in price_histories().items()
|
||||
}
|
||||
context = FakeQmtContext(
|
||||
qmt_histories,
|
||||
bar_time=datetime.datetime(2026, 7, 24, 15, 0),
|
||||
)
|
||||
qmt = FakeQmtHarness(context).install(qmt_builtin_strategy)
|
||||
qmt_builtin_strategy.init(context)
|
||||
self.assertEqual(context.commission["type"], 0)
|
||||
self.assertEqual(
|
||||
context.commission["values"],
|
||||
[0.0, 0.0005, 0.00021, 0.00021, 0.0, 5.0],
|
||||
)
|
||||
self.assertEqual(
|
||||
context.slippage,
|
||||
{"type": 2, "value": 0.0002},
|
||||
)
|
||||
qmt_plan = qmt_builtin_strategy.compute_plan(
|
||||
context, datetime.datetime(2026, 7, 24, 15, 0)
|
||||
)
|
||||
self.assertEqual(jq_plan, qmt_plan)
|
||||
self.assertEqual(joinquant_strategy.CONFIG["skip"], 0)
|
||||
self.assertEqual(qmt_builtin_strategy.CONFIG["skip"], 0)
|
||||
self.assertEqual(context.last_market_request["end_time"], "20260724")
|
||||
self.assertIs(context.last_market_request["fill_data"], False)
|
||||
self.assertEqual(
|
||||
jq.last_index_request,
|
||||
{
|
||||
"index_symbol": "000905.XSHG",
|
||||
"date": jq.context.previous_date,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
jq.last_extras_request["info"],
|
||||
"is_st",
|
||||
)
|
||||
self.assertEqual(
|
||||
jq.last_extras_request["end_date"],
|
||||
jq.context.previous_date,
|
||||
)
|
||||
self.assertEqual(
|
||||
context.last_sector_request,
|
||||
{
|
||||
"sector_name": "中证500",
|
||||
"timetag": context.get_bar_timetag(context.barpos),
|
||||
},
|
||||
)
|
||||
|
||||
def test_pit_st_exclusion_is_identical_across_hosted_wrappers(self):
|
||||
st_canonical = "300750.XSHE"
|
||||
jq = FakeJoinQuantHarness(
|
||||
price_histories(),
|
||||
st_symbols={st_canonical},
|
||||
)
|
||||
jq.initialize(joinquant_strategy)
|
||||
jq.context.previous_date = datetime.date(2026, 7, 24)
|
||||
jq.context.current_dt = datetime.datetime(2026, 7, 27, 9, 30)
|
||||
jq_plan = joinquant_strategy.compute_plan(jq.context)
|
||||
|
||||
qmt_histories = {
|
||||
symbol.replace(".XSHE", ".SZ").replace(".XSHG", ".SH"): values
|
||||
for symbol, values in price_histories().items()
|
||||
}
|
||||
qmt_symbol = "300750.SZ"
|
||||
context = FakeQmtContext(
|
||||
qmt_histories,
|
||||
bar_time=datetime.datetime(2026, 7, 24, 15, 0),
|
||||
st_periods={
|
||||
qmt_symbol: {
|
||||
"ST": [["20260701", "20260731"]],
|
||||
}
|
||||
},
|
||||
)
|
||||
FakeQmtHarness(context).install(qmt_builtin_strategy)
|
||||
qmt_builtin_strategy.init(context)
|
||||
qmt_plan = qmt_builtin_strategy.compute_plan(
|
||||
context,
|
||||
datetime.datetime(2026, 7, 24, 15, 0),
|
||||
)
|
||||
self.assertEqual(jq_plan, qmt_plan)
|
||||
self.assertNotIn(st_canonical, jq_plan["scores"])
|
||||
self.assertNotIn(st_canonical, qmt_plan["scores"])
|
||||
|
||||
def test_qmt_live_orders_are_off_by_default(self):
|
||||
qmt_histories = {
|
||||
symbol.replace(".XSHE", ".SZ").replace(".XSHG", ".SH"): values
|
||||
for symbol, values in price_histories().items()
|
||||
}
|
||||
context = FakeQmtContext(
|
||||
qmt_histories,
|
||||
trade_mode="trading",
|
||||
bar_time=datetime.datetime(2026, 7, 24, 15, 0),
|
||||
)
|
||||
harness = FakeQmtHarness(context)
|
||||
self.assertIsNone(harness.run(qmt_builtin_strategy))
|
||||
context._bar_time += datetime.timedelta(days=7)
|
||||
plan = qmt_builtin_strategy.handlebar(context)
|
||||
self.assertIsNotNone(plan)
|
||||
self.assertEqual(harness.orders, [])
|
||||
|
||||
def test_qmt_builtin_live_mutation_cannot_be_enabled_by_parameters(self):
|
||||
qmt_histories = {
|
||||
symbol.replace(".XSHE", ".SZ").replace(".XSHG", ".SH"): values
|
||||
for symbol, values in price_histories().items()
|
||||
}
|
||||
context = FakeQmtContext(
|
||||
qmt_histories,
|
||||
trade_mode="trading",
|
||||
bar_time=datetime.datetime(2026, 7, 24, 15, 0),
|
||||
)
|
||||
harness = FakeQmtHarness(context).install(qmt_builtin_strategy)
|
||||
qmt_builtin_strategy.init(context)
|
||||
context.do_back_test = "False"
|
||||
self.assertFalse(qmt_builtin_strategy._is_backtest(context))
|
||||
self.assertFalse(qmt_builtin_strategy._orders_allowed(context))
|
||||
# Even an injected ContextInfo knob cannot enable broker mutation.
|
||||
context.q60_config = {
|
||||
"enable_live_orders": True,
|
||||
"live_confirmation": "Q60_LIVE_ACK",
|
||||
}
|
||||
self.assertFalse(qmt_builtin_strategy._orders_allowed(context))
|
||||
|
||||
def test_qmt_module_state_survives_contextinfo_rollback_between_bars(self):
|
||||
qmt_histories = {
|
||||
symbol.replace(".XSHE", ".SZ").replace(".XSHG", ".SH"): values
|
||||
for symbol, values in price_histories().items()
|
||||
}
|
||||
first_context = FakeQmtContext(
|
||||
qmt_histories,
|
||||
trade_mode="backtest",
|
||||
bar_time=datetime.datetime(2026, 7, 20, 15, 0),
|
||||
)
|
||||
harness = FakeQmtHarness(first_context).install(qmt_builtin_strategy)
|
||||
qmt_builtin_strategy.init(first_context)
|
||||
self.assertFalse(
|
||||
any(name.startswith("q60_") for name in vars(first_context))
|
||||
)
|
||||
self.assertIsNone(qmt_builtin_strategy.handlebar(first_context))
|
||||
|
||||
# QMT documents that ContextInfo user attributes may roll back between
|
||||
# handlebar calls. Rebuild a clean platform context to model that
|
||||
# boundary; the module-global clock must still recognize a new week.
|
||||
rebuilt_context = FakeQmtContext(
|
||||
qmt_histories,
|
||||
trade_mode="backtest",
|
||||
bar_time=datetime.datetime(2026, 7, 27, 15, 0),
|
||||
)
|
||||
self.assertFalse(
|
||||
any(name.startswith("q60_") for name in vars(rebuilt_context))
|
||||
)
|
||||
plan = qmt_builtin_strategy.handlebar(rebuilt_context)
|
||||
self.assertIsNotNone(plan)
|
||||
self.assertGreater(len(harness.orders), 0)
|
||||
self.assertIs(qmt_builtin_strategy.g.last_plan, plan)
|
||||
|
||||
def test_qmt_backtest_submits_once_per_iso_week(self):
|
||||
qmt_histories = {
|
||||
symbol.replace(".XSHE", ".SZ").replace(".XSHG", ".SH"): values
|
||||
for symbol, values in price_histories().items()
|
||||
}
|
||||
context = FakeQmtContext(qmt_histories, trade_mode="backtest")
|
||||
harness = FakeQmtHarness(context)
|
||||
self.assertIsNone(harness.run(qmt_builtin_strategy))
|
||||
context._bar_time += datetime.timedelta(days=7)
|
||||
first = qmt_builtin_strategy.handlebar(context)
|
||||
first_count = len(harness.orders)
|
||||
second = qmt_builtin_strategy.handlebar(context)
|
||||
self.assertIsNotNone(first)
|
||||
self.assertGreater(first_count, 0)
|
||||
self.assertIsNone(second)
|
||||
self.assertEqual(len(harness.orders), first_count)
|
||||
first_ids = {item["user_order_id"] for item in harness.orders}
|
||||
context._bar_time += datetime.timedelta(days=7)
|
||||
third = qmt_builtin_strategy.handlebar(context)
|
||||
second_ids = {
|
||||
item["user_order_id"] for item in harness.orders[first_count:]
|
||||
}
|
||||
self.assertIsNotNone(third)
|
||||
self.assertTrue(first_ids.isdisjoint(second_ids))
|
||||
self.assertTrue(all(len(value.encode("ascii")) <= 24 for value in second_ids))
|
||||
|
||||
def test_qmt_incomplete_history_aborts_without_passorder(self):
|
||||
qmt_histories = {
|
||||
symbol.replace(".XSHE", ".SZ").replace(".XSHG", ".SH"): values
|
||||
for symbol, values in price_histories().items()
|
||||
}
|
||||
qmt_histories["300750.SZ"] = qmt_histories["300750.SZ"][1:]
|
||||
context = FakeQmtContext(qmt_histories, trade_mode="backtest")
|
||||
harness = FakeQmtHarness(context)
|
||||
self.assertIsNone(harness.run(qmt_builtin_strategy))
|
||||
context._bar_time += datetime.timedelta(days=7)
|
||||
with self.assertRaises(qmt_builtin_strategy.PriceHistoryUnavailable):
|
||||
qmt_builtin_strategy.handlebar(context)
|
||||
self.assertEqual(harness.orders, [])
|
||||
|
||||
def test_qmt_unmanaged_position_aborts_without_passorder(self):
|
||||
qmt_histories = {
|
||||
symbol.replace(".XSHE", ".SZ").replace(".XSHG", ".SH"): values
|
||||
for symbol, values in price_histories().items()
|
||||
}
|
||||
position = type(
|
||||
"Position",
|
||||
(),
|
||||
{
|
||||
"stock_code": "601318.SH",
|
||||
"volume": 1000,
|
||||
"can_use_volume": 1000,
|
||||
},
|
||||
)()
|
||||
context = FakeQmtContext(
|
||||
qmt_histories,
|
||||
positions=[position],
|
||||
trade_mode="backtest",
|
||||
)
|
||||
harness = FakeQmtHarness(context)
|
||||
self.assertIsNone(harness.run(qmt_builtin_strategy))
|
||||
qmt_builtin_strategy.g.config["universe_mode"] = "fixed"
|
||||
context._bar_time += datetime.timedelta(days=7)
|
||||
with self.assertRaises(qmt_builtin_strategy.UnmanagedPositionError):
|
||||
qmt_builtin_strategy.handlebar(context)
|
||||
self.assertEqual(harness.orders, [])
|
||||
|
||||
def test_qmt_minute_driver_is_rejected_before_orders(self):
|
||||
qmt_histories = {
|
||||
symbol.replace(".XSHE", ".SZ").replace(".XSHG", ".SH"): values
|
||||
for symbol, values in price_histories().items()
|
||||
}
|
||||
context = FakeQmtContext(
|
||||
qmt_histories,
|
||||
trade_mode="backtest",
|
||||
params={"period": "1m"},
|
||||
)
|
||||
harness = FakeQmtHarness(context)
|
||||
with self.assertRaises(qmt_builtin_strategy.UnsupportedStrategyPeriod):
|
||||
harness.run(qmt_builtin_strategy)
|
||||
self.assertEqual(harness.orders, [])
|
||||
|
||||
def test_qmt_builtin_do_back_test_flag_enables_backtest_orders(self):
|
||||
qmt_histories = {
|
||||
symbol.replace(".XSHE", ".SZ").replace(".XSHG", ".SH"): values
|
||||
for symbol, values in price_histories().items()
|
||||
}
|
||||
context = FakeQmtContext(qmt_histories, trade_mode="backtest")
|
||||
del context.trade_mode
|
||||
context._param.pop("trade_mode")
|
||||
context.do_back_test = True
|
||||
harness = FakeQmtHarness(context)
|
||||
self.assertIsNone(harness.run(qmt_builtin_strategy))
|
||||
context._bar_time += datetime.timedelta(days=7)
|
||||
plan = qmt_builtin_strategy.handlebar(context)
|
||||
self.assertIsNotNone(plan)
|
||||
self.assertGreater(len(harness.orders), 0)
|
||||
|
||||
def test_qmt_source_is_ascii_despite_gbk_header(self):
|
||||
payload = (ROOT / "platforms/qmt_builtin_strategy.py").read_bytes()
|
||||
payload.decode("ascii")
|
||||
self.assertTrue(payload.startswith(b"# coding: gbk"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,363 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stderr
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from platforms.qlib_runner import (
|
||||
QlibUnavailableError,
|
||||
_main,
|
||||
build_alpha158_lightgbm_task,
|
||||
generate_portable_momentum_signal,
|
||||
run_native_momentum_backtest,
|
||||
)
|
||||
from tools.build_qlib_tiny_fixture import build_default_fixture
|
||||
|
||||
|
||||
class QlibFixtureTest(unittest.TestCase):
|
||||
def test_standard_library_fixture_has_expected_bin_header(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
report = build_default_fixture(Path(temp), days=40)
|
||||
feature = Path(temp) / "features/sh600000/close.day.bin"
|
||||
payload = feature.read_bytes()
|
||||
values = struct.unpack("<" + "f" * (len(payload) // 4), payload)
|
||||
self.assertEqual(values[0], 0.0)
|
||||
self.assertEqual(len(values), 41)
|
||||
self.assertEqual(report["calendar_count"], 40)
|
||||
self.assertTrue((Path(temp) / "calendars/day.txt").is_file())
|
||||
self.assertTrue((Path(temp) / "instruments/csi300.txt").is_file())
|
||||
|
||||
def test_alpha158_lightgbm_config_uses_stable_097_paths(self):
|
||||
config = build_alpha158_lightgbm_task(
|
||||
market="csi300",
|
||||
benchmark="SH000300",
|
||||
train=("2018-01-01", "2020-12-31"),
|
||||
valid=("2021-01-01", "2021-12-31"),
|
||||
test=("2022-01-01", "2022-12-31"),
|
||||
)
|
||||
self.assertEqual(
|
||||
config["task"]["model"]["module_path"],
|
||||
"qlib.contrib.model.gbdt",
|
||||
)
|
||||
self.assertEqual(
|
||||
config["task"]["dataset"]["kwargs"]["handler"]["module_path"],
|
||||
"qlib.contrib.data.handler",
|
||||
)
|
||||
self.assertEqual(
|
||||
config["portfolio_analysis"]["executor"]["class"],
|
||||
"SimulatorExecutor",
|
||||
)
|
||||
self.assertEqual(
|
||||
config["portfolio_analysis"]["strategy"]["class"],
|
||||
"TopkDropoutStrategy",
|
||||
)
|
||||
|
||||
@unittest.skipUnless(
|
||||
importlib.util.find_spec("qlib") is not None
|
||||
and os.environ.get("QUANT60_RUN_QLIB_NATIVE_SMOKE") == "1",
|
||||
"set QUANT60_RUN_QLIB_NATIVE_SMOKE=1 in a pyqlib 0.9.7 env",
|
||||
)
|
||||
def test_optional_native_momentum_smoke(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
build_default_fixture(Path(temp), days=80)
|
||||
result = run_native_momentum_backtest(
|
||||
provider_uri=temp,
|
||||
market="csi300",
|
||||
benchmark="SH000300",
|
||||
start_time="2024-02-01",
|
||||
end_time="2024-04-19",
|
||||
topk=1,
|
||||
n_drop=1,
|
||||
)
|
||||
self.assertGreater(len(result["signal"]), 0)
|
||||
|
||||
def test_missing_provider_fails_before_optional_import(self):
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
run_native_momentum_backtest(
|
||||
provider_uri="/definitely/missing/quant60-provider",
|
||||
market="csi300",
|
||||
benchmark="SH000300",
|
||||
start_time="2024-01-01",
|
||||
end_time="2024-02-01",
|
||||
)
|
||||
|
||||
@unittest.skipUnless(
|
||||
importlib.util.find_spec("pandas") is not None,
|
||||
"pandas is optional outside the Qlib environment",
|
||||
)
|
||||
def test_weekly_signal_uses_true_first_provider_session(self):
|
||||
import pandas
|
||||
|
||||
dates = pandas.bdate_range("2024-01-02", periods=20)
|
||||
index = pandas.MultiIndex.from_product(
|
||||
[["SH600000"], dates],
|
||||
names=["instrument", "datetime"],
|
||||
)
|
||||
frame = pandas.DataFrame(
|
||||
{"$close": [10.0 + index * 0.1 for index in range(len(dates))]},
|
||||
index=index,
|
||||
)
|
||||
|
||||
class FakeD:
|
||||
@staticmethod
|
||||
def features(*args, **kwargs):
|
||||
del args, kwargs
|
||||
return frame
|
||||
|
||||
signal = generate_portable_momentum_signal(
|
||||
FakeD,
|
||||
["SH600000"],
|
||||
start_time="2024-01-02",
|
||||
end_time="2024-01-31",
|
||||
feature_start_time="2024-01-02",
|
||||
lookback=2,
|
||||
rebalance="weekly",
|
||||
)
|
||||
selected_dates = list(
|
||||
signal.index.get_level_values("datetime").unique()
|
||||
)
|
||||
# Warm-up finishes mid-week, so that partial week is skipped. Every
|
||||
# emitted date thereafter is the actual first provider session.
|
||||
self.assertNotIn(pandas.Timestamp("2024-01-04"), selected_dates)
|
||||
for selected in selected_dates:
|
||||
week_dates = [
|
||||
value
|
||||
for value in dates
|
||||
if value.to_period("W") == selected.to_period("W")
|
||||
]
|
||||
self.assertEqual(selected, min(week_dates))
|
||||
|
||||
|
||||
class QlibCliTest(unittest.TestCase):
|
||||
def test_legacy_momentum_cli_remains_default_and_writes_json(self):
|
||||
fake_result = {
|
||||
"qlib_version": "0.9.7",
|
||||
"signal": [0.1, 0.2],
|
||||
"portfolio_metrics": {"1day": object()},
|
||||
"clock_contract": "test-clock",
|
||||
"strategy_fidelity": "signal-only",
|
||||
"a_share_rule_fidelity": "approximation",
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
output = Path(temp) / "nested" / "summary.json"
|
||||
with mock.patch(
|
||||
"platforms.qlib_runner.run_native_momentum_backtest",
|
||||
return_value=fake_result,
|
||||
) as runner, mock.patch("builtins.print") as printer:
|
||||
status = _main(
|
||||
[
|
||||
"--provider-uri",
|
||||
temp,
|
||||
"--start",
|
||||
"2024-01-02",
|
||||
"--end",
|
||||
"2024-03-29",
|
||||
"--feature-start",
|
||||
"2023-12-01",
|
||||
"--lookback",
|
||||
"10",
|
||||
"--skip",
|
||||
"2",
|
||||
"--topk",
|
||||
"20",
|
||||
"--n-drop",
|
||||
"3",
|
||||
"--result-json",
|
||||
str(output),
|
||||
]
|
||||
)
|
||||
self.assertEqual(status, 0)
|
||||
runner.assert_called_once_with(
|
||||
provider_uri=temp,
|
||||
market="csi300",
|
||||
benchmark="SH000300",
|
||||
start_time="2024-01-02",
|
||||
end_time="2024-03-29",
|
||||
feature_start_time="2023-12-01",
|
||||
lookback=10,
|
||||
skip=2,
|
||||
topk=20,
|
||||
n_drop=3,
|
||||
rebalance="weekly",
|
||||
)
|
||||
payload = __import__("json").loads(output.read_text(encoding="utf-8"))
|
||||
self.assertEqual(payload["workflow"], "momentum")
|
||||
self.assertEqual(payload["signal_rows"], 2)
|
||||
self.assertEqual(payload["portfolio_frequencies"], ["1day"])
|
||||
self.assertIn('"workflow": "momentum"', printer.call_args.args[0])
|
||||
|
||||
def test_alpha158_cli_dispatches_with_validated_segments(self):
|
||||
fake_result = {
|
||||
"qlib_version": "0.9.7",
|
||||
"experiment_name": "quant-os-test",
|
||||
"recorder_id": "rec-123",
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
with mock.patch(
|
||||
"platforms.qlib_runner.run_alpha158_lightgbm_workflow",
|
||||
return_value=fake_result,
|
||||
) as runner, mock.patch("builtins.print"):
|
||||
status = _main(
|
||||
[
|
||||
"--workflow",
|
||||
"alpha158",
|
||||
"--provider-uri",
|
||||
temp,
|
||||
"--market",
|
||||
"csi500",
|
||||
"--benchmark",
|
||||
"SH000905",
|
||||
"--train-start",
|
||||
"2018-01-01",
|
||||
"--train-end",
|
||||
"2020-12-31",
|
||||
"--valid-start",
|
||||
"2021-01-01",
|
||||
"--valid-end",
|
||||
"2021-12-31",
|
||||
"--test-start",
|
||||
"2022-01-01",
|
||||
"--test-end",
|
||||
"2022-12-31",
|
||||
"--experiment-name",
|
||||
"quant-os-test",
|
||||
]
|
||||
)
|
||||
self.assertEqual(status, 0)
|
||||
runner.assert_called_once_with(
|
||||
provider_uri=temp,
|
||||
market="csi500",
|
||||
benchmark="SH000905",
|
||||
train=("2018-01-01", "2020-12-31"),
|
||||
valid=("2021-01-01", "2021-12-31"),
|
||||
test=("2022-01-01", "2022-12-31"),
|
||||
experiment_name="quant-os-test",
|
||||
topk=50,
|
||||
n_drop=5,
|
||||
)
|
||||
|
||||
def test_alpha158_cli_rejects_missing_or_overlapping_segments(self):
|
||||
base = [
|
||||
"--workflow",
|
||||
"alpha158",
|
||||
"--provider-uri",
|
||||
"/provider",
|
||||
"--train-start",
|
||||
"2018-01-01",
|
||||
"--train-end",
|
||||
"2020-12-31",
|
||||
"--valid-start",
|
||||
"2020-12-31",
|
||||
"--valid-end",
|
||||
"2021-12-31",
|
||||
"--test-start",
|
||||
"2022-01-01",
|
||||
"--test-end",
|
||||
"2022-12-31",
|
||||
]
|
||||
with mock.patch(
|
||||
"platforms.qlib_runner.run_alpha158_lightgbm_workflow"
|
||||
) as runner, redirect_stderr(StringIO()):
|
||||
with self.assertRaises(SystemExit) as overlap:
|
||||
_main(base)
|
||||
with self.assertRaises(SystemExit) as missing:
|
||||
_main(base[:-2])
|
||||
self.assertEqual(overlap.exception.code, 2)
|
||||
self.assertEqual(missing.exception.code, 2)
|
||||
runner.assert_not_called()
|
||||
|
||||
def test_momentum_cli_rejects_invalid_dates_and_position_counts(self):
|
||||
with mock.patch(
|
||||
"platforms.qlib_runner.run_native_momentum_backtest"
|
||||
) as runner, redirect_stderr(StringIO()):
|
||||
with self.assertRaises(SystemExit) as dates:
|
||||
_main(
|
||||
[
|
||||
"--provider-uri",
|
||||
"/provider",
|
||||
"--start",
|
||||
"2024-02-01",
|
||||
"--end",
|
||||
"2024-01-01",
|
||||
]
|
||||
)
|
||||
with self.assertRaises(SystemExit) as counts:
|
||||
_main(
|
||||
[
|
||||
"--provider-uri",
|
||||
"/provider",
|
||||
"--start",
|
||||
"2024-01-01",
|
||||
"--end",
|
||||
"2024-02-01",
|
||||
"--topk",
|
||||
"2",
|
||||
"--n-drop",
|
||||
"3",
|
||||
]
|
||||
)
|
||||
self.assertEqual(dates.exception.code, 2)
|
||||
self.assertEqual(counts.exception.code, 2)
|
||||
runner.assert_not_called()
|
||||
|
||||
def test_cli_rejects_date_arguments_from_the_other_workflow(self):
|
||||
with mock.patch(
|
||||
"platforms.qlib_runner.run_native_momentum_backtest"
|
||||
) as momentum_runner, redirect_stderr(StringIO()):
|
||||
with self.assertRaises(SystemExit) as momentum:
|
||||
_main(
|
||||
[
|
||||
"--provider-uri",
|
||||
"/provider",
|
||||
"--start",
|
||||
"2024-01-01",
|
||||
"--end",
|
||||
"2024-02-01",
|
||||
"--train-start",
|
||||
"2020-01-01",
|
||||
]
|
||||
)
|
||||
with mock.patch(
|
||||
"platforms.qlib_runner.run_alpha158_lightgbm_workflow"
|
||||
) as alpha_runner, redirect_stderr(StringIO()):
|
||||
with self.assertRaises(SystemExit) as alpha:
|
||||
_main(
|
||||
[
|
||||
"--workflow",
|
||||
"alpha158",
|
||||
"--provider-uri",
|
||||
"/provider",
|
||||
"--start",
|
||||
"2022-01-01",
|
||||
"--train-start",
|
||||
"2018-01-01",
|
||||
"--train-end",
|
||||
"2020-12-31",
|
||||
"--valid-start",
|
||||
"2021-01-01",
|
||||
"--valid-end",
|
||||
"2021-12-31",
|
||||
"--test-start",
|
||||
"2022-01-01",
|
||||
"--test-end",
|
||||
"2022-12-31",
|
||||
]
|
||||
)
|
||||
self.assertEqual(momentum.exception.code, 2)
|
||||
self.assertEqual(alpha.exception.code, 2)
|
||||
momentum_runner.assert_not_called()
|
||||
alpha_runner.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,155 @@
|
||||
import sys
|
||||
import json
|
||||
import hashlib
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from platforms import qmt_research_runner
|
||||
|
||||
|
||||
class FakeFrame:
|
||||
def __len__(self):
|
||||
return 1
|
||||
|
||||
|
||||
class QmtResearchRunnerTest(unittest.TestCase):
|
||||
def test_build_params_normalizes_official_timestamps(self):
|
||||
params = qmt_research_runner.build_qmt_parameters(
|
||||
stock_code="600000.sh",
|
||||
start_time="20260102",
|
||||
end_time="2026-01-30",
|
||||
)
|
||||
self.assertEqual(params["stock_code"], "600000.SH")
|
||||
self.assertEqual(params["start_time"], "2026-01-02 00:00:00")
|
||||
self.assertEqual(params["end_time"], "2026-01-30 23:59:59")
|
||||
self.assertEqual(params["trade_mode"], "backtest")
|
||||
self.assertEqual(params["quote_mode"], "history")
|
||||
self.assertEqual(params["account_id"], "test")
|
||||
self.assertEqual(params["benchmark"], "000905.SH")
|
||||
baseline_bytes = (ROOT / "configs/baseline.json").read_bytes()
|
||||
baseline = json.loads(baseline_bytes)
|
||||
self.assertEqual(
|
||||
params["max_vol_rate"],
|
||||
baseline["execution"]["participation_rate"],
|
||||
)
|
||||
self.assertEqual(
|
||||
params["slippage"],
|
||||
baseline["execution"]["slippage_bps"] / 10_000,
|
||||
)
|
||||
expected_commission = (
|
||||
baseline["fees"]["commission_rate"]
|
||||
+ baseline["fees"]["transfer_fee_rate"]
|
||||
)
|
||||
self.assertEqual(params["open_commission"], expected_commission)
|
||||
self.assertEqual(params["close_commission"], expected_commission)
|
||||
self.assertEqual(
|
||||
params["q60_baseline_config_sha256"],
|
||||
hashlib.sha256(baseline_bytes).hexdigest(),
|
||||
)
|
||||
|
||||
def test_live_mode_overrides_are_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
qmt_research_runner.build_qmt_parameters(
|
||||
stock_code="600000.SH",
|
||||
start_time="20260102",
|
||||
end_time="20260130",
|
||||
overrides={"trade_mode": "trading"},
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
qmt_research_runner.build_qmt_parameters(
|
||||
stock_code="600000.SH",
|
||||
start_time="20260102",
|
||||
end_time="20260130",
|
||||
overrides={"period": "1m"},
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
qmt_research_runner.build_qmt_parameters(
|
||||
stock_code="600000.SH",
|
||||
start_time="20260102",
|
||||
end_time="20260130",
|
||||
overrides={"benchmark": "000300.SH"},
|
||||
)
|
||||
|
||||
def test_non_finite_asset_and_rate_are_rejected(self):
|
||||
for kwargs in (
|
||||
{"asset": float("nan")},
|
||||
{"asset": float("inf")},
|
||||
{"overrides": {"max_vol_rate": float("nan")}},
|
||||
{"overrides": {"open_commission": float("inf")}},
|
||||
):
|
||||
with self.subTest(kwargs=kwargs):
|
||||
with self.assertRaisesRegex(ValueError, "finite"):
|
||||
qmt_research_runner.build_qmt_parameters(
|
||||
stock_code="600000.SH",
|
||||
start_time="20260102",
|
||||
end_time="20260130",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def test_intraday_period_is_rejected_to_prevent_daily_bar_lookahead(self):
|
||||
with self.assertRaisesRegex(ValueError, "period must be 1d"):
|
||||
qmt_research_runner.build_qmt_parameters(
|
||||
stock_code="600000.SH",
|
||||
start_time="20260102",
|
||||
end_time="20260130",
|
||||
period="1m",
|
||||
)
|
||||
|
||||
def test_preflight_and_run_use_param_keyword(self):
|
||||
params = qmt_research_runner.build_qmt_parameters(
|
||||
stock_code="600000.SH",
|
||||
start_time="20260102",
|
||||
end_time="20260130",
|
||||
)
|
||||
calls = {}
|
||||
|
||||
def run_strategy_file(user_script, *, param):
|
||||
calls["user_script"] = user_script
|
||||
calls["param"] = param
|
||||
return object()
|
||||
|
||||
fake_qmttools = types.SimpleNamespace(run_strategy_file=run_strategy_file)
|
||||
|
||||
def get_market_data_ex(**kwargs):
|
||||
calls["data"] = kwargs
|
||||
return {"600000.SH": FakeFrame()}
|
||||
|
||||
fake_xtdata = types.SimpleNamespace(get_market_data_ex=get_market_data_ex)
|
||||
fake_xtquant = types.SimpleNamespace(__version__="fake")
|
||||
|
||||
def import_module(name):
|
||||
return {
|
||||
"xtquant": fake_xtquant,
|
||||
"xtquant.qmttools": fake_qmttools,
|
||||
"xtquant.xtdata": fake_xtdata,
|
||||
}[name]
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
strategy = Path(temp) / "strategy.py"
|
||||
strategy.write_text("def init(C): pass\n", encoding="ascii")
|
||||
with mock.patch.object(
|
||||
qmt_research_runner.importlib.util,
|
||||
"find_spec",
|
||||
return_value=object(),
|
||||
), mock.patch.object(
|
||||
qmt_research_runner.importlib,
|
||||
"import_module",
|
||||
side_effect=import_module,
|
||||
):
|
||||
result = qmt_research_runner.run_qmt_backtest(strategy, params)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(calls["param"], params)
|
||||
self.assertEqual(calls["data"]["start_time"], "20260102000000")
|
||||
self.assertEqual(calls["data"]["end_time"], "20260130235959")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,859 @@
|
||||
import datetime as dt
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from adapters.xttrader_live import (
|
||||
IdempotencyConflict,
|
||||
LiveOrderBlocked,
|
||||
NotConnectedError,
|
||||
ReconnectFailed,
|
||||
XtTraderLiveAdapter,
|
||||
map_order_status,
|
||||
)
|
||||
|
||||
|
||||
class Constants:
|
||||
STOCK_BUY = 23
|
||||
STOCK_SELL = 24
|
||||
FIX_PRICE = 11
|
||||
|
||||
|
||||
class FakeTrader:
|
||||
def __init__(self, connect_code=0, orders=None):
|
||||
self.connect_code = connect_code
|
||||
self.callback = None
|
||||
self.started = False
|
||||
self.stopped = False
|
||||
self.order_calls = []
|
||||
self.cancel_calls = []
|
||||
self.orders = list(orders or [])
|
||||
|
||||
def register_callback(self, callback):
|
||||
self.callback = callback
|
||||
|
||||
def start(self):
|
||||
self.started = True
|
||||
|
||||
def connect(self):
|
||||
return self.connect_code
|
||||
|
||||
def subscribe(self, account):
|
||||
self.account = account
|
||||
return 0
|
||||
|
||||
def stop(self):
|
||||
self.stopped = True
|
||||
|
||||
def query_stock_asset(self, account):
|
||||
del account
|
||||
return SimpleNamespace(
|
||||
account_id="test", cash=10_000, market_value=20_000, total_asset=30_000
|
||||
)
|
||||
|
||||
def query_stock_orders(self, account, cancelable_only=False):
|
||||
del account, cancelable_only
|
||||
return list(self.orders)
|
||||
|
||||
def query_stock_positions(self, account):
|
||||
del account
|
||||
return [
|
||||
SimpleNamespace(
|
||||
account_id="test",
|
||||
stock_code="600000.SH",
|
||||
volume=1000,
|
||||
can_use_volume=700,
|
||||
market_value=10_000,
|
||||
open_price=10,
|
||||
)
|
||||
]
|
||||
|
||||
def query_stock_trades(self, account):
|
||||
del account
|
||||
return []
|
||||
|
||||
def order_stock_async(self, *args):
|
||||
self.order_calls.append(args)
|
||||
return 101
|
||||
|
||||
def cancel_order_stock(self, account, order_id):
|
||||
self.cancel_calls.append((account, order_id))
|
||||
return 0
|
||||
|
||||
|
||||
def passing_live_decision(**overrides):
|
||||
decision = {
|
||||
"authorization_id": "auth-test-001",
|
||||
"snapshot_id": "snapshot-test-001",
|
||||
"as_of": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
"reconciliation_safe": True,
|
||||
"account_allowed": True,
|
||||
"within_notional_limit": True,
|
||||
"within_order_rate_limit": True,
|
||||
"operator_approved": True,
|
||||
"kill_switch_ready": True,
|
||||
"compliance_approved": True,
|
||||
}
|
||||
decision.update(overrides)
|
||||
return decision
|
||||
|
||||
|
||||
def adapter_for(trader, allow=False, live_guard=None):
|
||||
if allow is True and live_guard is None:
|
||||
live_guard = lambda intent: passing_live_decision()
|
||||
return XtTraderLiveAdapter(
|
||||
trader_factory=lambda: trader,
|
||||
account=SimpleNamespace(account_id="test"),
|
||||
constants=Constants,
|
||||
allow_live_orders=allow,
|
||||
live_guard=live_guard,
|
||||
)
|
||||
|
||||
|
||||
class XtTraderLiveAdapterTest(unittest.TestCase):
|
||||
def test_real_status_mapping_uses_domain_spelling(self):
|
||||
self.assertEqual(map_order_status(48), "NEW")
|
||||
self.assertEqual(map_order_status(50), "ACCEPTED")
|
||||
self.assertEqual(map_order_status(51), "CANCEL_PENDING")
|
||||
self.assertEqual(map_order_status(54), "CANCELLED")
|
||||
self.assertEqual(map_order_status(55), "PARTIALLY_FILLED")
|
||||
self.assertEqual(map_order_status(56), "FILLED")
|
||||
self.assertEqual(map_order_status(57), "REJECTED")
|
||||
self.assertEqual(map_order_status(999), "UNKNOWN")
|
||||
|
||||
def test_default_is_dry_run_and_idempotent_without_broker_call(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader)
|
||||
first = adapter.submit_order(
|
||||
client_order_id="decision-001",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
)
|
||||
second = adapter.submit_order(
|
||||
client_order_id="decision-001",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
)
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(first["state"], "DRY_RUN")
|
||||
self.assertEqual(trader.order_calls, [])
|
||||
|
||||
def test_allow_live_orders_requires_a_real_bool(self):
|
||||
trader = FakeTrader()
|
||||
for value in ("False", "true", 1, None):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaisesRegex(TypeError, "must be a bool"):
|
||||
adapter_for(trader, allow=value)
|
||||
self.assertEqual(trader.order_calls, [])
|
||||
|
||||
def test_live_mode_requires_callable_guard_but_dry_run_does_not(self):
|
||||
trader = FakeTrader()
|
||||
dry_run = XtTraderLiveAdapter(
|
||||
trader_factory=lambda: trader,
|
||||
account=SimpleNamespace(account_id="test"),
|
||||
constants=Constants,
|
||||
)
|
||||
self.assertFalse(dry_run.allow_live_orders)
|
||||
with self.assertRaisesRegex(TypeError, "requires a callable live_guard"):
|
||||
XtTraderLiveAdapter(
|
||||
trader_factory=lambda: trader,
|
||||
account=SimpleNamespace(account_id="test"),
|
||||
constants=Constants,
|
||||
allow_live_orders=True,
|
||||
)
|
||||
with self.assertRaisesRegex(TypeError, "live_guard must be callable"):
|
||||
XtTraderLiveAdapter(
|
||||
trader_factory=lambda: trader,
|
||||
account=SimpleNamespace(account_id="test"),
|
||||
constants=Constants,
|
||||
allow_live_orders=True,
|
||||
live_guard="allow",
|
||||
)
|
||||
|
||||
def test_idempotency_key_conflict_is_rejected(self):
|
||||
adapter = adapter_for(FakeTrader())
|
||||
adapter.submit_order(
|
||||
client_order_id="decision-001",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
)
|
||||
with self.assertRaises(IdempotencyConflict):
|
||||
adapter.submit_order(
|
||||
client_order_id="decision-001",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=200,
|
||||
limit_price=10.5,
|
||||
)
|
||||
|
||||
def test_non_finite_live_price_is_rejected_before_broker_call(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
for value in (float("nan"), float("inf"), float("-inf")):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaisesRegex(ValueError, "finite and positive"):
|
||||
adapter.submit_order(
|
||||
client_order_id="bad-price",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=value,
|
||||
confirm_live=True,
|
||||
)
|
||||
self.assertEqual(trader.order_calls, [])
|
||||
|
||||
def test_limit_price_rejects_bool_non_real_and_non_positive(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
for value in (True, False, "10.5", None, 10 + 0j, 0, -1):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaisesRegex(ValueError, "limit_price"):
|
||||
adapter.submit_order(
|
||||
client_order_id="strict-price",
|
||||
symbol="600000.SH",
|
||||
side="SELL",
|
||||
quantity=100,
|
||||
limit_price=value,
|
||||
confirm_live=True,
|
||||
)
|
||||
self.assertEqual(trader.order_calls, [])
|
||||
|
||||
def test_submit_confirm_live_requires_a_real_bool(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
for value in ("False", "true", 1, None):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaisesRegex(TypeError, "must be a bool"):
|
||||
adapter.submit_order(
|
||||
client_order_id="strict-confirm",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=value,
|
||||
)
|
||||
self.assertEqual(trader.order_calls, [])
|
||||
|
||||
def test_quantity_rejects_bool_fraction_float_and_non_positive(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
for value in (True, False, 100.0, 100.5, "100", None, 0, -100):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaisesRegex(ValueError, "positive integer"):
|
||||
adapter.submit_order(
|
||||
client_order_id="strict-quantity",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=value,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
self.assertEqual(trader.order_calls, [])
|
||||
|
||||
def test_real_order_requires_double_opt_in_and_remark_round_trip(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
with self.assertRaises(LiveOrderBlocked):
|
||||
adapter.submit_order(
|
||||
client_order_id="12345678901234567890",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
)
|
||||
result = adapter.submit_order(
|
||||
client_order_id="12345678901234567890",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
self.assertEqual(result["state"], "NEW")
|
||||
self.assertEqual(len(trader.order_calls), 1)
|
||||
remark = trader.order_calls[0][-1]
|
||||
self.assertEqual(remark, "q60:12345678901234567890")
|
||||
self.assertLessEqual(len(remark), 24)
|
||||
self.assertEqual(
|
||||
result["live_policy"]["authorization_id"], "auth-test-001"
|
||||
)
|
||||
|
||||
def test_live_guard_receives_normalized_submit_and_cancel_intents(self):
|
||||
intents = []
|
||||
|
||||
def guard(intent):
|
||||
intents.append(intent)
|
||||
return passing_live_decision(
|
||||
authorization_id=f"auth-{len(intents)}"
|
||||
)
|
||||
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader, allow=True, live_guard=guard)
|
||||
adapter.connect()
|
||||
result = adapter.submit_order(
|
||||
client_order_id="guard-intent",
|
||||
symbol="600000.SH",
|
||||
side="buy",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
adapter.cancel_order(88, confirm_live=True)
|
||||
self.assertEqual(
|
||||
intents,
|
||||
[
|
||||
{
|
||||
"action": "SUBMIT_ORDER",
|
||||
"account_id": "test",
|
||||
"client_order_id": "guard-intent",
|
||||
"symbol": "600000.XSHG",
|
||||
"side": "BUY",
|
||||
"quantity": 100,
|
||||
"limit_price": 10.5,
|
||||
"notional": 1050.0,
|
||||
"strategy_name": "quant60",
|
||||
},
|
||||
{
|
||||
"action": "CANCEL_ORDER",
|
||||
"account_id": "test",
|
||||
"broker_order_id": 88,
|
||||
"strategy_name": "quant60",
|
||||
},
|
||||
],
|
||||
)
|
||||
self.assertEqual(result["live_policy"]["authorization_id"], "auth-1")
|
||||
self.assertEqual(len(trader.order_calls), 1)
|
||||
self.assertEqual(len(trader.cancel_calls), 1)
|
||||
audit = adapter.live_authorization_audit
|
||||
self.assertEqual(len(audit), 2)
|
||||
self.assertEqual(audit[0]["decision"]["account_id"], "test")
|
||||
self.assertEqual(audit[1]["intent"]["action"], "CANCEL_ORDER")
|
||||
self.assertEqual(audit[1]["outcome"], "BROKER_ACCEPTED")
|
||||
|
||||
def test_live_guard_is_bound_to_a_non_empty_account_id(self):
|
||||
trader = FakeTrader()
|
||||
calls = []
|
||||
adapter = XtTraderLiveAdapter(
|
||||
trader_factory=lambda: trader,
|
||||
account=SimpleNamespace(account_id=" "),
|
||||
constants=Constants,
|
||||
allow_live_orders=True,
|
||||
live_guard=lambda intent: (
|
||||
calls.append(intent) or passing_live_decision()
|
||||
),
|
||||
)
|
||||
with self.assertRaisesRegex(NotConnectedError, "account_id"):
|
||||
adapter.connect()
|
||||
self.assertEqual(calls, [])
|
||||
self.assertEqual(trader.order_calls, [])
|
||||
|
||||
def test_live_guard_missing_false_non_bool_or_exception_blocks_submit(self):
|
||||
def raising_guard(intent):
|
||||
del intent
|
||||
raise RuntimeError("policy backend unavailable")
|
||||
|
||||
cases = [
|
||||
(
|
||||
"missing",
|
||||
lambda intent: {
|
||||
key: value
|
||||
for key, value in passing_live_decision().items()
|
||||
if key != "compliance_approved"
|
||||
},
|
||||
),
|
||||
(
|
||||
"false",
|
||||
lambda intent: passing_live_decision(
|
||||
reconciliation_safe=False
|
||||
),
|
||||
),
|
||||
(
|
||||
"non-bool",
|
||||
lambda intent: passing_live_decision(account_allowed=1),
|
||||
),
|
||||
(
|
||||
"empty-id",
|
||||
lambda intent: passing_live_decision(
|
||||
authorization_id=" "
|
||||
),
|
||||
),
|
||||
("exception", raising_guard),
|
||||
]
|
||||
for name, guard in cases:
|
||||
with self.subTest(name=name):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(
|
||||
trader, allow=True, live_guard=guard
|
||||
)
|
||||
adapter.connect()
|
||||
with self.assertRaises(LiveOrderBlocked):
|
||||
adapter.submit_order(
|
||||
client_order_id=f"blocked-{name}",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
self.assertEqual(trader.order_calls, [])
|
||||
|
||||
def test_live_guard_rejects_stale_naive_and_future_decisions(self):
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
cases = {
|
||||
"stale": (now - dt.timedelta(seconds=61)).isoformat(),
|
||||
"naive": now.replace(tzinfo=None).isoformat(),
|
||||
"future": (now + dt.timedelta(seconds=6)).isoformat(),
|
||||
}
|
||||
for name, as_of in cases.items():
|
||||
with self.subTest(name=name):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(
|
||||
trader,
|
||||
allow=True,
|
||||
live_guard=lambda intent, value=as_of: (
|
||||
passing_live_decision(as_of=value)
|
||||
),
|
||||
)
|
||||
adapter.connect()
|
||||
with self.assertRaises(LiveOrderBlocked):
|
||||
adapter.submit_order(
|
||||
client_order_id=f"clock-{name}",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
self.assertEqual(trader.order_calls, [])
|
||||
|
||||
def test_live_guard_failure_blocks_cancel_before_broker(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(
|
||||
trader,
|
||||
allow=True,
|
||||
live_guard=lambda intent: passing_live_decision(
|
||||
kill_switch_ready=False
|
||||
),
|
||||
)
|
||||
adapter.connect()
|
||||
with self.assertRaisesRegex(
|
||||
LiveOrderBlocked, "kill_switch_ready"
|
||||
):
|
||||
adapter.cancel_order(88, confirm_live=True)
|
||||
self.assertEqual(trader.cancel_calls, [])
|
||||
|
||||
def test_concurrent_same_client_id_submits_once(self):
|
||||
class BlockingTrader(FakeTrader):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.submit_entered = threading.Event()
|
||||
self.release_submit = threading.Event()
|
||||
|
||||
def order_stock_async(self, *args):
|
||||
self.order_calls.append(args)
|
||||
self.submit_entered.set()
|
||||
if not self.release_submit.wait(2):
|
||||
raise AssertionError("test did not release broker submit")
|
||||
return 101
|
||||
|
||||
trader = BlockingTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
results = []
|
||||
failures = []
|
||||
|
||||
def submit():
|
||||
try:
|
||||
results.append(
|
||||
adapter.submit_order(
|
||||
client_order_id="decision-001",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
)
|
||||
except BaseException as exc:
|
||||
failures.append(exc)
|
||||
|
||||
first = threading.Thread(target=submit)
|
||||
first.start()
|
||||
self.assertTrue(trader.submit_entered.wait(1))
|
||||
# The broker boundary is inside the idempotency critical section.
|
||||
acquired = adapter._lock.acquire(blocking=False)
|
||||
if acquired:
|
||||
adapter._lock.release()
|
||||
self.assertFalse(acquired)
|
||||
|
||||
second = threading.Thread(target=submit)
|
||||
second.start()
|
||||
trader.release_submit.set()
|
||||
first.join(2)
|
||||
second.join(2)
|
||||
self.assertFalse(first.is_alive())
|
||||
self.assertFalse(second.is_alive())
|
||||
self.assertEqual(failures, [])
|
||||
self.assertEqual(len(trader.order_calls), 1)
|
||||
self.assertEqual(len(results), 2)
|
||||
self.assertEqual(results[0], results[1])
|
||||
|
||||
def test_synchronous_order_error_matches_pre_submit_reservation(self):
|
||||
class SynchronousErrorTrader(FakeTrader):
|
||||
def order_stock_async(self, *args):
|
||||
self.order_calls.append(args)
|
||||
self.callback.on_order_error(
|
||||
SimpleNamespace(
|
||||
order_id=0,
|
||||
order_remark=args[-1],
|
||||
error_id=42,
|
||||
error_msg="synchronous rejection",
|
||||
)
|
||||
)
|
||||
return 101
|
||||
|
||||
trader = SynchronousErrorTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
result = adapter.submit_order(
|
||||
client_order_id="decision-001",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
self.assertEqual(result["state"], "REJECTED")
|
||||
self.assertEqual(result["error_id"], 42)
|
||||
self.assertEqual(len(trader.order_calls), 1)
|
||||
|
||||
def test_synchronous_order_error_by_seq_is_drained_from_orphans(self):
|
||||
class SynchronousErrorTrader(FakeTrader):
|
||||
def order_stock_async(self, *args):
|
||||
self.order_calls.append(args)
|
||||
self.callback.on_order_error(
|
||||
SimpleNamespace(
|
||||
order_id=None,
|
||||
order_remark="",
|
||||
seq=101,
|
||||
error_id=43,
|
||||
error_msg="fast seq rejection",
|
||||
)
|
||||
)
|
||||
return 101
|
||||
|
||||
trader = SynchronousErrorTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
result = adapter.submit_order(
|
||||
client_order_id="decision-002",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
self.assertEqual(result["state"], "REJECTED")
|
||||
self.assertEqual(result["error_id"], 43)
|
||||
self.assertEqual(adapter._orphan_order_errors, [])
|
||||
|
||||
def test_malformed_async_seq_never_leaves_submitting_reservation(self):
|
||||
class MalformedSeqTrader(FakeTrader):
|
||||
def __init__(self, seq):
|
||||
super().__init__()
|
||||
self.seq = seq
|
||||
|
||||
def order_stock_async(self, *args):
|
||||
self.order_calls.append(args)
|
||||
return self.seq
|
||||
|
||||
for index, seq in enumerate(("bad", 1.5, True, object())):
|
||||
with self.subTest(seq=repr(seq)):
|
||||
trader = MalformedSeqTrader(seq)
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
client_id = f"bad-seq-{index}"
|
||||
with self.assertRaisesRegex(
|
||||
Exception, "malformed async request sequence"
|
||||
):
|
||||
adapter.submit_order(
|
||||
client_order_id=client_id,
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
record = adapter.journal.get(client_id)
|
||||
self.assertEqual(record["state"], "UNKNOWN")
|
||||
self.assertNotEqual(record["state"], "SUBMITTING")
|
||||
self.assertEqual(len(trader.order_calls), 1)
|
||||
|
||||
def test_query_normalizes_position_and_disconnect_fails_fast(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader)
|
||||
adapter.connect()
|
||||
position = adapter.query_positions()[0]
|
||||
self.assertEqual(position["sellable"], 700)
|
||||
self.assertEqual(position["symbol"], "600000.XSHG")
|
||||
trader.callback.on_disconnected()
|
||||
with self.assertRaises(NotConnectedError):
|
||||
adapter.query_orders()
|
||||
|
||||
def test_connect_recovers_idempotency_from_broker_remark(self):
|
||||
broker_order = SimpleNamespace(
|
||||
account_id="test",
|
||||
order_id=88,
|
||||
order_sysid="sys-88",
|
||||
order_remark="q60:decision-001",
|
||||
stock_code="600000.SH",
|
||||
order_type=23,
|
||||
order_volume=100,
|
||||
traded_volume=0,
|
||||
price=10.5,
|
||||
traded_price=0,
|
||||
order_status=50,
|
||||
status_msg="",
|
||||
)
|
||||
trader = FakeTrader(orders=[broker_order])
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
recovered = adapter.submit_order(
|
||||
client_order_id="decision-001",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
self.assertEqual(recovered["broker_order_id"], 88)
|
||||
self.assertEqual(recovered["state"], "ACCEPTED")
|
||||
self.assertEqual(trader.order_calls, [])
|
||||
|
||||
def test_broker_payload_conflict_for_same_remark_fails_closed(self):
|
||||
orders = [
|
||||
SimpleNamespace(
|
||||
account_id="test",
|
||||
order_id=88 + index,
|
||||
order_sysid="sys",
|
||||
order_remark="q60:decision-001",
|
||||
stock_code="600000.SH",
|
||||
order_type=23,
|
||||
order_volume=quantity,
|
||||
traded_volume=0,
|
||||
price=10.5,
|
||||
traded_price=0,
|
||||
order_status=50,
|
||||
status_msg="",
|
||||
)
|
||||
for index, quantity in enumerate((100, 200))
|
||||
]
|
||||
adapter = adapter_for(FakeTrader(orders=orders), allow=True)
|
||||
with self.assertRaises(IdempotencyConflict):
|
||||
adapter.connect()
|
||||
self.assertNotEqual(adapter.state, "READY")
|
||||
|
||||
def test_order_error_updates_matching_journal_to_rejected(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
adapter.submit_order(
|
||||
client_order_id="decision-001",
|
||||
symbol="600000.SH",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10.5,
|
||||
confirm_live=True,
|
||||
)
|
||||
trader.callback.on_order_error(
|
||||
SimpleNamespace(
|
||||
order_id=999,
|
||||
order_remark="q60:decision-001",
|
||||
error_id=42,
|
||||
error_msg="rejected",
|
||||
)
|
||||
)
|
||||
record = adapter.journal.get("decision-001")
|
||||
self.assertEqual(record["state"], "REJECTED")
|
||||
self.assertEqual(record["error_id"], 42)
|
||||
|
||||
def test_cancel_none_is_not_reported_as_success(self):
|
||||
trader = FakeTrader()
|
||||
trader.cancel_order_stock = lambda account, order_id: None
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
with self.assertRaisesRegex(Exception, "cancel failed"):
|
||||
adapter.cancel_order(88, confirm_live=True)
|
||||
|
||||
def test_cancel_confirm_live_requires_a_real_bool(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
for value in ("False", "true", 1, None):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaisesRegex(TypeError, "must be a bool"):
|
||||
adapter.cancel_order(88, confirm_live=value)
|
||||
self.assertEqual(trader.cancel_calls, [])
|
||||
|
||||
def test_cancel_order_id_requires_exact_positive_integer(self):
|
||||
trader = FakeTrader()
|
||||
adapter = adapter_for(trader, allow=True)
|
||||
adapter.connect()
|
||||
for value in (True, False, 88.0, 88.9, "88", None, 0, -1):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaisesRegex(
|
||||
ValueError, "positive integer"
|
||||
):
|
||||
adapter.cancel_order(value, confirm_live=True)
|
||||
self.assertEqual(trader.cancel_calls, [])
|
||||
|
||||
def test_stale_order_callback_cannot_regress_filled_terminal_state(self):
|
||||
filled = SimpleNamespace(
|
||||
account_id="test",
|
||||
order_id=88,
|
||||
order_sysid="sys-88",
|
||||
order_remark="q60:decision-001",
|
||||
stock_code="600000.SH",
|
||||
order_type=23,
|
||||
order_volume=100,
|
||||
traded_volume=100,
|
||||
price=10.5,
|
||||
traded_price=10.5,
|
||||
order_status=56,
|
||||
status_msg="filled",
|
||||
)
|
||||
stale = SimpleNamespace(
|
||||
account_id="test",
|
||||
order_id=88,
|
||||
order_sysid="sys-88",
|
||||
order_remark="q60:decision-001",
|
||||
stock_code="600000.SH",
|
||||
order_type=23,
|
||||
order_volume=100,
|
||||
traded_volume=0,
|
||||
price=10.5,
|
||||
traded_price=0,
|
||||
order_status=50,
|
||||
status_msg="stale accepted",
|
||||
)
|
||||
trader = FakeTrader(orders=[filled])
|
||||
adapter = adapter_for(trader)
|
||||
adapter.connect()
|
||||
# An exact duplicate is harmless and idempotent.
|
||||
trader.callback.on_stock_order(filled)
|
||||
with self.assertRaisesRegex(
|
||||
IdempotencyConflict, "cumulative fill regressed"
|
||||
):
|
||||
trader.callback.on_stock_order(stale)
|
||||
record = adapter.journal.get("decision-001")
|
||||
self.assertEqual(adapter.state, "DEGRADED")
|
||||
self.assertEqual(record["state"], "FILLED")
|
||||
self.assertEqual(record["filled_quantity"], 100)
|
||||
self.assertEqual(adapter._orders[88]["state"], "FILLED")
|
||||
|
||||
def test_configured_account_anchors_every_broker_query(self):
|
||||
class WrongAccountTrader(FakeTrader):
|
||||
def query_stock_asset(self, account):
|
||||
del account
|
||||
return SimpleNamespace(
|
||||
account_id="wrong",
|
||||
cash=10_000,
|
||||
market_value=20_000,
|
||||
total_asset=30_000,
|
||||
)
|
||||
|
||||
trader = WrongAccountTrader()
|
||||
adapter = adapter_for(trader)
|
||||
with self.assertRaisesRegex(
|
||||
NotConnectedError,
|
||||
"configured account",
|
||||
):
|
||||
adapter.connect()
|
||||
self.assertEqual(adapter.state, "FAILED")
|
||||
self.assertTrue(trader.stopped)
|
||||
|
||||
def test_trade_query_preserves_account_side_and_stable_trade_id(self):
|
||||
class TradeTrader(FakeTrader):
|
||||
def query_stock_trades(self, account):
|
||||
del account
|
||||
return [
|
||||
SimpleNamespace(
|
||||
account_id="test",
|
||||
traded_id="trade-1",
|
||||
order_id=88,
|
||||
stock_code="600000.SH",
|
||||
order_type=23,
|
||||
traded_volume=100,
|
||||
traded_price=10.5,
|
||||
traded_amount=1050,
|
||||
)
|
||||
]
|
||||
|
||||
adapter = adapter_for(TradeTrader())
|
||||
adapter.connect()
|
||||
self.assertEqual(
|
||||
adapter.query_trades(),
|
||||
[
|
||||
{
|
||||
"account_id": "test",
|
||||
"trade_id": "trade-1",
|
||||
"broker_order_id": 88,
|
||||
"symbol": "600000.XSHG",
|
||||
"side": "BUY",
|
||||
"quantity": 100,
|
||||
"price": 10.5,
|
||||
"amount": 1050.0,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
def test_reconnect_uses_fresh_instance_and_exhausts_bounded_attempts(self):
|
||||
created = []
|
||||
|
||||
def factory():
|
||||
trader = FakeTrader(connect_code=9)
|
||||
created.append(trader)
|
||||
return trader
|
||||
|
||||
adapter = XtTraderLiveAdapter(
|
||||
trader_factory=factory,
|
||||
account=SimpleNamespace(account_id="test"),
|
||||
constants=Constants,
|
||||
)
|
||||
with self.assertRaises(ReconnectFailed):
|
||||
adapter.reconnect(attempts=2)
|
||||
self.assertEqual(len(created), 2)
|
||||
self.assertEqual(adapter.state, "FAILED")
|
||||
|
||||
def test_bj_is_explicitly_outside_v1_live_adapter(self):
|
||||
adapter = adapter_for(FakeTrader())
|
||||
with self.assertRaises(ValueError):
|
||||
adapter.submit_order(
|
||||
client_order_id="bj",
|
||||
symbol="430001.BJ",
|
||||
side="BUY",
|
||||
quantity=100,
|
||||
limit_price=10,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,212 @@
|
||||
import hashlib
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from quant60.backtest import BacktestEngine, verify_artifact_manifest
|
||||
from quant60.cli import main
|
||||
from quant60.config import config_from_dict, load_config
|
||||
from quant60.ledger import HashChainLedger
|
||||
from quant60.synthetic import generate_synthetic_bars
|
||||
|
||||
|
||||
PROJECT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class BacktestCliTests(unittest.TestCase):
|
||||
def test_empty_config_uses_valid_defaults(self):
|
||||
config = config_from_dict({})
|
||||
config.validate()
|
||||
|
||||
def test_non_finite_config_values_are_rejected(self):
|
||||
cases = (
|
||||
{"initial_cash": "NaN"},
|
||||
{"fees": {"commission_rate": float("inf")}},
|
||||
{"execution": {"participation_rate": float("nan")}},
|
||||
{"execution": {"slippage_bps": float("inf")}},
|
||||
{"strategy": {"max_weight": float("nan")}},
|
||||
{"strategy": {"gross_target": float("inf")}},
|
||||
{"strategy": {"cash_buffer": float("nan")}},
|
||||
{"initial_cash": True},
|
||||
{"execution": {"lot_size": 100.0}},
|
||||
{"execution": {"cancel_open_orders_at_end": 1}},
|
||||
{"strategy": {"lookback": True}},
|
||||
{"strategy": {"rebalance_every": 5.0}},
|
||||
{"synthetic": {"trading_days": 90.0}},
|
||||
{"symbols": "600000.XSHG"},
|
||||
)
|
||||
for raw in cases:
|
||||
with self.subTest(raw=raw):
|
||||
with self.assertRaises(ValueError):
|
||||
config_from_dict(raw)
|
||||
|
||||
def test_backtest_is_byte_deterministic(self):
|
||||
config = load_config(PROJECT / "configs" / "baseline.json")
|
||||
bars = generate_synthetic_bars(
|
||||
config.symbols,
|
||||
config.synthetic.start_date,
|
||||
config.synthetic.trading_days,
|
||||
config.synthetic.seed,
|
||||
config.synthetic.base_volume,
|
||||
)
|
||||
first = BacktestEngine(config).run(bars)
|
||||
second = BacktestEngine(config).run(bars)
|
||||
self.assertEqual(first.run_id, second.run_id)
|
||||
self.assertEqual(first.report, second.report)
|
||||
with tempfile.TemporaryDirectory() as left, tempfile.TemporaryDirectory() as right:
|
||||
left_paths = first.write_artifacts(left)
|
||||
right_paths = second.write_artifacts(right)
|
||||
for key in left_paths:
|
||||
left_hash = hashlib.sha256(left_paths[key].read_bytes()).hexdigest()
|
||||
right_hash = hashlib.sha256(right_paths[key].read_bytes()).hexdigest()
|
||||
self.assertEqual(left_hash, right_hash, key)
|
||||
self.assertTrue(HashChainLedger.read_jsonl(left_paths["events"]).verify())
|
||||
report = json.loads(left_paths["report"].read_text(encoding="utf-8"))
|
||||
self.assertFalse(report["investment_value_claim"])
|
||||
self.assertGreater(report["fill_count"], 0)
|
||||
records = [
|
||||
json.loads(line)
|
||||
for line in left_paths["events"].read_text(
|
||||
encoding="utf-8"
|
||||
).splitlines()
|
||||
]
|
||||
timestamps = [
|
||||
datetime.fromisoformat(record["timestamp"]) for record in records
|
||||
]
|
||||
self.assertEqual(timestamps, sorted(timestamps))
|
||||
|
||||
def test_decisions_use_first_trading_session_close_of_each_week(self):
|
||||
config = load_config(PROJECT / "configs" / "baseline.json")
|
||||
bars = generate_synthetic_bars(
|
||||
config.symbols,
|
||||
config.synthetic.start_date,
|
||||
config.synthetic.trading_days,
|
||||
config.synthetic.seed,
|
||||
config.synthetic.base_volume,
|
||||
)
|
||||
result = BacktestEngine(config).run(bars)
|
||||
sessions = sorted({item.trading_date for item in bars})
|
||||
first_by_week = {}
|
||||
for session in sessions:
|
||||
first_by_week.setdefault(session.isocalendar()[:2], session)
|
||||
decision_dates = {
|
||||
datetime.fromisoformat(item["as_of"]).date()
|
||||
for item in result.targets
|
||||
}
|
||||
self.assertTrue(decision_dates)
|
||||
for decision_date in decision_dates:
|
||||
self.assertEqual(
|
||||
decision_date,
|
||||
first_by_week[decision_date.isocalendar()[:2]],
|
||||
)
|
||||
|
||||
def test_incomplete_market_session_fails_closed(self):
|
||||
config = load_config(PROJECT / "configs" / "baseline.json")
|
||||
bars = generate_synthetic_bars(
|
||||
config.symbols,
|
||||
config.synthetic.start_date,
|
||||
config.synthetic.trading_days,
|
||||
config.synthetic.seed,
|
||||
config.synthetic.base_volume,
|
||||
)
|
||||
removed = bars.pop(len(config.symbols) * 30)
|
||||
with self.assertRaisesRegex(
|
||||
ValueError,
|
||||
"incomplete market-data session.*" + removed.symbol.replace(".", r"\."),
|
||||
):
|
||||
BacktestEngine(config).run(bars)
|
||||
|
||||
def test_cli_smoke_and_ledger_verify(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "run"
|
||||
self.assertEqual(
|
||||
main(
|
||||
[
|
||||
"smoke",
|
||||
"--config",
|
||||
str(PROJECT / "configs" / "baseline.json"),
|
||||
"--output",
|
||||
str(output),
|
||||
]
|
||||
),
|
||||
0,
|
||||
)
|
||||
self.assertEqual(main(["verify-ledger", str(output / "events.jsonl")]), 0)
|
||||
self.assertEqual(
|
||||
main(["verify-manifest", str(output / "manifest.json")]),
|
||||
0,
|
||||
)
|
||||
self.assertTrue(
|
||||
verify_artifact_manifest(output / "manifest.json")["ok"]
|
||||
)
|
||||
|
||||
def test_manifest_verifier_rejects_tampered_or_incomplete_runs(self):
|
||||
config = load_config(PROJECT / "configs" / "baseline.json")
|
||||
bars = generate_synthetic_bars(
|
||||
config.symbols,
|
||||
config.synthetic.start_date,
|
||||
config.synthetic.trading_days,
|
||||
config.synthetic.seed,
|
||||
config.synthetic.base_volume,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "run"
|
||||
BacktestEngine(config).run(bars).write_artifacts(output)
|
||||
report_path = output / "report.json"
|
||||
report_path.write_bytes(report_path.read_bytes() + b" ")
|
||||
with self.assertRaisesRegex(ValueError, "report artifact hash mismatch"):
|
||||
verify_artifact_manifest(output / "manifest.json")
|
||||
|
||||
(output / ".quant60-incomplete").write_text(
|
||||
"interrupted\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "publication is incomplete"):
|
||||
verify_artifact_manifest(output / "manifest.json")
|
||||
|
||||
second_output = Path(directory) / "second-run"
|
||||
BacktestEngine(config).run(bars).write_artifacts(second_output)
|
||||
manifest_path = second_output / "manifest.json"
|
||||
unexpected = second_output / "untracked-result.txt"
|
||||
unexpected.write_text("not part of the run\n", encoding="utf-8")
|
||||
with self.assertRaisesRegex(
|
||||
ValueError,
|
||||
"artifact directory is not an exact completed set",
|
||||
):
|
||||
verify_artifact_manifest(manifest_path)
|
||||
unexpected.unlink()
|
||||
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest["engine_sources"]["portable_core.py"] = "0" * 64
|
||||
manifest_path.write_text(
|
||||
json.dumps(manifest, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
ValueError,
|
||||
"engine_sources aggregate hash mismatch",
|
||||
):
|
||||
verify_artifact_manifest(manifest_path)
|
||||
|
||||
def test_cli_backtest_alias(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "run"
|
||||
self.assertEqual(
|
||||
main(
|
||||
[
|
||||
"backtest",
|
||||
"--config",
|
||||
str(PROJECT / "configs" / "baseline.json"),
|
||||
"--output",
|
||||
str(output),
|
||||
]
|
||||
),
|
||||
0,
|
||||
)
|
||||
self.assertTrue((output / "report.json").is_file())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,418 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from adapters.jqdata_local import (
|
||||
JQDataUnavailableError,
|
||||
authenticate,
|
||||
fetch_index_daily_snapshot,
|
||||
)
|
||||
from quant60.data_snapshot import (
|
||||
CanonicalBarRecord,
|
||||
DataQualityError,
|
||||
verify_data_snapshot,
|
||||
)
|
||||
|
||||
|
||||
class FakeFrame:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
|
||||
def iterrows(self):
|
||||
return iter(self.rows)
|
||||
|
||||
|
||||
class FakeJQData:
|
||||
__version__ = "fake-1"
|
||||
|
||||
def __init__(self):
|
||||
self.authenticated = False
|
||||
self.last_get_bars_kwargs = None
|
||||
self.last_get_extras_kwargs = None
|
||||
self.trade_day_queries = []
|
||||
self.full_calendar_queries = 0
|
||||
|
||||
def auth(self, username, password):
|
||||
self.authenticated = username == "user" and password == "password"
|
||||
|
||||
def is_auth(self):
|
||||
return self.authenticated
|
||||
|
||||
def get_trade_days(self, *, start_date, end_date):
|
||||
self.trade_day_queries.append(
|
||||
{
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
}
|
||||
)
|
||||
return [start_date, end_date]
|
||||
|
||||
def get_all_trade_days(self):
|
||||
self.full_calendar_queries += 1
|
||||
return [
|
||||
date(2024, 1, 2),
|
||||
date(2024, 1, 3),
|
||||
date(2024, 1, 4),
|
||||
]
|
||||
|
||||
def get_index_stocks(self, index_symbol, *, date):
|
||||
return ["600000.XSHG", "000001.XSHE"]
|
||||
|
||||
def get_extras(
|
||||
self,
|
||||
info,
|
||||
security_list,
|
||||
*,
|
||||
start_date,
|
||||
end_date,
|
||||
df,
|
||||
):
|
||||
self.last_get_extras_kwargs = {
|
||||
"info": info,
|
||||
"security_list": list(security_list),
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"df": df,
|
||||
}
|
||||
return FakeFrame(
|
||||
[
|
||||
(
|
||||
start_date,
|
||||
{symbol: False for symbol in security_list},
|
||||
),
|
||||
(
|
||||
end_date,
|
||||
{symbol: False for symbol in security_list},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
def get_bars(self, symbol, **kwargs):
|
||||
self.last_get_bars_kwargs = dict(kwargs)
|
||||
offset = 0 if symbol.startswith("600000") else 10
|
||||
return FakeFrame(
|
||||
[
|
||||
(
|
||||
0,
|
||||
{
|
||||
"date": kwargs["start_dt"],
|
||||
"open": 10 + offset,
|
||||
"high": 11 + offset,
|
||||
"low": 9 + offset,
|
||||
"close": 10.5 + offset,
|
||||
"volume": 1000,
|
||||
"money": 10_500 + offset * 1000,
|
||||
"factor": 1.0,
|
||||
"paused": False,
|
||||
"high_limit": 11.5 + offset,
|
||||
"low_limit": 8.5 + offset,
|
||||
"pre_close": 10 + offset,
|
||||
},
|
||||
),
|
||||
(
|
||||
1,
|
||||
{
|
||||
"date": kwargs["end_dt"],
|
||||
"open": 10.5 + offset,
|
||||
"high": 11.5 + offset,
|
||||
"low": 10 + offset,
|
||||
"close": 11 + offset,
|
||||
"volume": 1200,
|
||||
"money": 13_200 + offset * 1200,
|
||||
"factor": 1.0,
|
||||
"paused": False,
|
||||
"high_limit": 12 + offset,
|
||||
"low_limit": 9 + offset,
|
||||
"pre_close": 10.5 + offset,
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class DataJQDataTests(unittest.TestCase):
|
||||
def test_authentication_never_accepts_failed_login(self):
|
||||
sdk = FakeJQData()
|
||||
with self.assertRaisesRegex(JQDataUnavailableError, "not accepted"):
|
||||
authenticate(
|
||||
sdk,
|
||||
username="user",
|
||||
password="wrong",
|
||||
interactive=False,
|
||||
)
|
||||
authenticate(
|
||||
sdk,
|
||||
username="user",
|
||||
password="password",
|
||||
interactive=False,
|
||||
)
|
||||
self.assertTrue(sdk.authenticated)
|
||||
|
||||
def test_fake_jqdata_snapshot_is_canonical_and_verifiable(self):
|
||||
sdk = FakeJQData()
|
||||
authenticate(
|
||||
sdk,
|
||||
username="user",
|
||||
password="password",
|
||||
interactive=False,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
paths = fetch_index_daily_snapshot(
|
||||
sdk,
|
||||
index_symbol="000905.XSHG",
|
||||
start_date=date(2024, 1, 2),
|
||||
end_date=date(2024, 1, 3),
|
||||
output_dir=directory,
|
||||
retrieved_at=datetime(2024, 1, 4, tzinfo=timezone.utc),
|
||||
)
|
||||
verified = verify_data_snapshot(paths["manifest"])
|
||||
self.assertTrue(verified["ok"])
|
||||
self.assertEqual(verified["bar_count"], 4)
|
||||
self.assertEqual(verified["membership_count"], 4)
|
||||
self.assertEqual(
|
||||
sdk.last_get_bars_kwargs["end_dt"].date(),
|
||||
date(2024, 1, 3),
|
||||
)
|
||||
self.assertEqual(
|
||||
sdk.last_get_bars_kwargs["end_dt"].time(),
|
||||
datetime.strptime("23:59:59", "%H:%M:%S").time(),
|
||||
)
|
||||
self.assertIs(
|
||||
sdk.last_get_bars_kwargs["include_now"],
|
||||
True,
|
||||
)
|
||||
self.assertEqual(
|
||||
sdk.last_get_extras_kwargs["info"],
|
||||
"is_st",
|
||||
)
|
||||
manifest = json.loads(
|
||||
Path(paths["manifest"]).read_text(encoding="utf-8")
|
||||
)
|
||||
self.assertEqual(
|
||||
manifest["next_trading_session"],
|
||||
"2024-01-04",
|
||||
)
|
||||
self.assertEqual(
|
||||
manifest["query"]["trading_sessions"],
|
||||
["2024-01-02", "2024-01-03", "2024-01-04"],
|
||||
)
|
||||
self.assertEqual(sdk.full_calendar_queries, 1)
|
||||
content = Path(paths["manifest"]).read_text(encoding="utf-8")
|
||||
self.assertNotIn("password", content)
|
||||
self.assertNotIn("user", content)
|
||||
bars = (
|
||||
Path(directory) / "bars.jsonl"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn('"adjustment_factor":1.0', bars)
|
||||
self.assertIn('"limit_up":', bars)
|
||||
|
||||
def test_next_session_comes_from_provider_calendar_across_holiday(self):
|
||||
class NationalDayCalendarJQData(FakeJQData):
|
||||
def get_all_trade_days(self):
|
||||
self.full_calendar_queries += 1
|
||||
return [
|
||||
date(2024, 9, 27),
|
||||
date(2024, 9, 30),
|
||||
date(2024, 10, 8),
|
||||
date(2024, 10, 9),
|
||||
]
|
||||
|
||||
sdk = NationalDayCalendarJQData()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
paths = fetch_index_daily_snapshot(
|
||||
sdk,
|
||||
index_symbol="000905.XSHG",
|
||||
start_date=date(2024, 9, 27),
|
||||
end_date=date(2024, 9, 30),
|
||||
output_dir=directory,
|
||||
retrieved_at=datetime(2024, 10, 9, tzinfo=timezone.utc),
|
||||
)
|
||||
manifest = json.loads(
|
||||
Path(paths["manifest"]).read_text(encoding="utf-8")
|
||||
)
|
||||
self.assertEqual(
|
||||
manifest["next_trading_session"],
|
||||
"2024-10-08",
|
||||
)
|
||||
self.assertEqual(
|
||||
manifest["query"]["trading_sessions"][-1],
|
||||
"2024-10-08",
|
||||
)
|
||||
|
||||
def test_invalid_next_session_calendar_response_fails_closed(self):
|
||||
class BadCalendarJQData(FakeJQData):
|
||||
def get_all_trade_days(self):
|
||||
return [date(2024, 1, 2), date(2024, 1, 3)]
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with self.assertRaisesRegex(
|
||||
JQDataUnavailableError,
|
||||
"unique next trading session",
|
||||
):
|
||||
fetch_index_daily_snapshot(
|
||||
BadCalendarJQData(),
|
||||
index_symbol="000905.XSHG",
|
||||
start_date=date(2024, 1, 2),
|
||||
end_date=date(2024, 1, 3),
|
||||
output_dir=directory,
|
||||
retrieved_at=datetime(
|
||||
2024,
|
||||
1,
|
||||
4,
|
||||
tzinfo=timezone.utc,
|
||||
),
|
||||
)
|
||||
|
||||
def test_inconsistent_range_and_full_calendars_fail_closed(self):
|
||||
class MissingMiddleSessionJQData(FakeJQData):
|
||||
def get_all_trade_days(self):
|
||||
return [
|
||||
date(2024, 1, 2),
|
||||
date(2024, 1, 3),
|
||||
date(2024, 1, 4),
|
||||
]
|
||||
|
||||
def get_trade_days(self, *, start_date, end_date):
|
||||
return [start_date]
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with self.assertRaisesRegex(
|
||||
JQDataUnavailableError,
|
||||
"inconsistent sessions",
|
||||
):
|
||||
fetch_index_daily_snapshot(
|
||||
MissingMiddleSessionJQData(),
|
||||
index_symbol="000905.XSHG",
|
||||
start_date=date(2024, 1, 2),
|
||||
end_date=date(2024, 1, 3),
|
||||
output_dir=directory,
|
||||
retrieved_at=datetime(
|
||||
2024,
|
||||
1,
|
||||
4,
|
||||
tzinfo=timezone.utc,
|
||||
),
|
||||
)
|
||||
|
||||
def test_same_shanghai_day_is_rejected_before_query(self):
|
||||
sdk = FakeJQData()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with self.assertRaisesRegex(
|
||||
JQDataUnavailableError,
|
||||
"24:00 finalization",
|
||||
):
|
||||
fetch_index_daily_snapshot(
|
||||
sdk,
|
||||
index_symbol="000905.XSHG",
|
||||
start_date=date(2024, 1, 2),
|
||||
end_date=date(2024, 1, 3),
|
||||
output_dir=directory,
|
||||
retrieved_at=datetime(
|
||||
2024,
|
||||
1,
|
||||
3,
|
||||
6,
|
||||
tzinfo=timezone.utc,
|
||||
),
|
||||
)
|
||||
self.assertIsNone(sdk.last_get_bars_kwargs)
|
||||
self.assertIsNone(sdk.last_get_extras_kwargs)
|
||||
|
||||
def test_missing_pit_st_status_fails_closed(self):
|
||||
class MissingStatusJQData(FakeJQData):
|
||||
def get_extras(self, *args, **kwargs):
|
||||
frame = super().get_extras(*args, **kwargs)
|
||||
frame.rows[-1][1].pop("600000.XSHG")
|
||||
return frame
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with self.assertRaisesRegex(
|
||||
JQDataUnavailableError,
|
||||
"omitted required field 600000.XSHG",
|
||||
):
|
||||
fetch_index_daily_snapshot(
|
||||
MissingStatusJQData(),
|
||||
index_symbol="000905.XSHG",
|
||||
start_date=date(2024, 1, 2),
|
||||
end_date=date(2024, 1, 3),
|
||||
output_dir=directory,
|
||||
retrieved_at=datetime(
|
||||
2024,
|
||||
1,
|
||||
4,
|
||||
tzinfo=timezone.utc,
|
||||
),
|
||||
)
|
||||
|
||||
def test_missing_required_provider_rule_field_fails_closed(self):
|
||||
class MissingLimitJQData(FakeJQData):
|
||||
def get_bars(self, symbol, **kwargs):
|
||||
frame = super().get_bars(symbol, **kwargs)
|
||||
del frame.rows[0][1]["high_limit"]
|
||||
return frame
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with self.assertRaisesRegex(
|
||||
JQDataUnavailableError,
|
||||
"omitted required field high_limit",
|
||||
):
|
||||
fetch_index_daily_snapshot(
|
||||
MissingLimitJQData(),
|
||||
index_symbol="000905.XSHG",
|
||||
start_date=date(2024, 1, 2),
|
||||
end_date=date(2024, 1, 3),
|
||||
output_dir=directory,
|
||||
retrieved_at=datetime(
|
||||
2024,
|
||||
1,
|
||||
4,
|
||||
tzinfo=timezone.utc,
|
||||
),
|
||||
)
|
||||
|
||||
def test_membership_without_same_session_bar_fails_quality_gate(self):
|
||||
class MissingEndBarJQData(FakeJQData):
|
||||
def get_bars(self, symbol, **kwargs):
|
||||
frame = super().get_bars(symbol, **kwargs)
|
||||
frame.rows.pop()
|
||||
return frame
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with self.assertRaisesRegex(
|
||||
DataQualityError,
|
||||
"membership has no same-session canonical bar",
|
||||
):
|
||||
fetch_index_daily_snapshot(
|
||||
MissingEndBarJQData(),
|
||||
index_symbol="000905.XSHG",
|
||||
start_date=date(2024, 1, 2),
|
||||
end_date=date(2024, 1, 3),
|
||||
output_dir=directory,
|
||||
retrieved_at=datetime(
|
||||
2024,
|
||||
1,
|
||||
4,
|
||||
tzinfo=timezone.utc,
|
||||
),
|
||||
)
|
||||
|
||||
def test_bad_ohlc_fails_quality_gate(self):
|
||||
with self.assertRaisesRegex(DataQualityError, "open must lie"):
|
||||
CanonicalBarRecord(
|
||||
trading_date=date(2024, 1, 2),
|
||||
symbol="600000.SH",
|
||||
open=12,
|
||||
high=11,
|
||||
low=9,
|
||||
close=10,
|
||||
volume=100,
|
||||
money=1000,
|
||||
paused=False,
|
||||
source="fixture",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,247 @@
|
||||
import unittest
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from quant60.broker import SimBroker
|
||||
from quant60.config import ExecutionConfig, FeeConfig
|
||||
from quant60.domain import (
|
||||
Bar,
|
||||
Fill,
|
||||
Order,
|
||||
OrderStatus,
|
||||
Portfolio,
|
||||
Position,
|
||||
Side,
|
||||
)
|
||||
from quant60.ledger import HashChainLedger
|
||||
|
||||
|
||||
def bar(
|
||||
day,
|
||||
*,
|
||||
symbol="600000.XSHG",
|
||||
price="10",
|
||||
volume=1000,
|
||||
suspended=False,
|
||||
limit_up=None,
|
||||
limit_down=None,
|
||||
):
|
||||
value = Decimal(price)
|
||||
return Bar(
|
||||
trading_date=day,
|
||||
symbol=symbol,
|
||||
open=value,
|
||||
high=value,
|
||||
low=value,
|
||||
close=value,
|
||||
volume=volume,
|
||||
suspended=suspended,
|
||||
limit_up=Decimal(limit_up) if limit_up else None,
|
||||
limit_down=Decimal(limit_down) if limit_down else None,
|
||||
)
|
||||
|
||||
|
||||
class DomainBrokerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.portfolio = Portfolio(Decimal("100000"))
|
||||
self.ledger = HashChainLedger()
|
||||
self.broker = SimBroker(
|
||||
self.portfolio,
|
||||
ExecutionConfig(
|
||||
lot_size=100,
|
||||
participation_rate=0.1,
|
||||
slippage_bps=0,
|
||||
),
|
||||
FeeConfig(
|
||||
commission_rate=0,
|
||||
minimum_commission=0,
|
||||
stamp_duty_rate=0,
|
||||
transfer_fee_rate=0,
|
||||
),
|
||||
self.ledger,
|
||||
)
|
||||
|
||||
def test_duplicate_state_callback_is_idempotent(self):
|
||||
order = Order("O1", "600000.SH", Side.BUY, 100, date(2024, 1, 2))
|
||||
order.transition(OrderStatus.ACCEPTED)
|
||||
order.transition(OrderStatus.ACCEPTED)
|
||||
self.assertEqual(order.status, OrderStatus.ACCEPTED)
|
||||
order.transition(OrderStatus.UNKNOWN)
|
||||
order.transition(OrderStatus.UNKNOWN)
|
||||
order.transition(OrderStatus.ACCEPTED)
|
||||
self.assertEqual(order.status, OrderStatus.ACCEPTED)
|
||||
|
||||
def test_late_fill_during_cancel_pending_is_accepted(self):
|
||||
order = Order("O1", "600000.SH", Side.BUY, 200, date(2024, 1, 2))
|
||||
order.transition(OrderStatus.ACCEPTED)
|
||||
order.transition(OrderStatus.CANCEL_PENDING)
|
||||
order.apply_fill(100, Decimal("10"))
|
||||
self.assertEqual(order.status, OrderStatus.PARTIALLY_FILLED)
|
||||
self.assertEqual(order.filled_quantity, 100)
|
||||
|
||||
order.transition(OrderStatus.CANCEL_PENDING)
|
||||
order.apply_fill(100, Decimal("10.1"))
|
||||
self.assertEqual(order.status, OrderStatus.FILLED)
|
||||
self.assertEqual(order.filled_quantity, 200)
|
||||
|
||||
def test_partial_fill_and_t_plus_one(self):
|
||||
submit_day = date(2024, 1, 2)
|
||||
fill_day = date(2024, 1, 3)
|
||||
self.broker.execute_session(
|
||||
{"600000.XSHG": bar(submit_day, volume=2000)}
|
||||
)
|
||||
self.broker.submit("600000.SH", Side.BUY, 500, submit_day)
|
||||
fills = self.broker.execute_session(
|
||||
{"600000.XSHG": bar(fill_day, volume=100_000)}
|
||||
)
|
||||
self.assertEqual(sum(item.quantity for item in fills), 200)
|
||||
position = self.portfolio.position("600000.XSHG")
|
||||
self.assertEqual(position.quantity, 200)
|
||||
self.assertEqual(position.sellable_quantity, 0)
|
||||
with self.assertRaises(ValueError):
|
||||
position.sell(100)
|
||||
self.portfolio.start_session()
|
||||
self.assertEqual(position.sellable_quantity, 200)
|
||||
|
||||
def test_suspension_and_locked_limit_block(self):
|
||||
submit_day = date(2024, 1, 2)
|
||||
next_day = date(2024, 1, 3)
|
||||
self.broker.execute_session(
|
||||
{"600000.XSHG": bar(submit_day, volume=1000)}
|
||||
)
|
||||
self.broker.submit("600000.SH", Side.BUY, 100, submit_day)
|
||||
self.assertEqual(
|
||||
self.broker.execute_session(
|
||||
{"600000.XSHG": bar(next_day, suspended=True, volume=0)}
|
||||
),
|
||||
[],
|
||||
)
|
||||
reasons = [
|
||||
record.payload["reason"]
|
||||
for record in self.ledger.records
|
||||
if record.event_type == "ORDER_BLOCKED"
|
||||
]
|
||||
self.assertIn("SUSPENDED", reasons)
|
||||
|
||||
other = SimBroker(
|
||||
Portfolio(Decimal("100000")),
|
||||
self.broker.execution,
|
||||
self.broker.fees,
|
||||
HashChainLedger(),
|
||||
)
|
||||
other.execute_session(
|
||||
{"600000.XSHG": bar(submit_day, volume=1000)}
|
||||
)
|
||||
other.submit("600000.SH", Side.BUY, 100, submit_day)
|
||||
self.assertEqual(
|
||||
other.execute_session(
|
||||
{
|
||||
"600000.XSHG": bar(
|
||||
next_day,
|
||||
price="11",
|
||||
limit_up="11",
|
||||
limit_down="9",
|
||||
)
|
||||
}
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_open_fill_capacity_uses_only_prior_session_volume(self):
|
||||
submit_day = date(2024, 1, 2)
|
||||
fill_day = date(2024, 1, 3)
|
||||
|
||||
def executed_quantity(current_day_volume):
|
||||
broker = SimBroker(
|
||||
Portfolio(Decimal("100000")),
|
||||
self.broker.execution,
|
||||
self.broker.fees,
|
||||
HashChainLedger(),
|
||||
)
|
||||
broker.execute_session(
|
||||
{"600000.XSHG": bar(submit_day, volume=2000)}
|
||||
)
|
||||
broker.submit("600000.SH", Side.BUY, 500, submit_day)
|
||||
fills = broker.execute_session(
|
||||
{
|
||||
"600000.XSHG": bar(
|
||||
fill_day,
|
||||
volume=current_day_volume,
|
||||
)
|
||||
}
|
||||
)
|
||||
return sum(item.quantity for item in fills)
|
||||
|
||||
self.assertEqual(executed_quantity(0), 200)
|
||||
self.assertEqual(executed_quantity(10_000_000), 200)
|
||||
|
||||
def test_fill_cash_and_position_conservation(self):
|
||||
fill = Fill(
|
||||
"F1",
|
||||
"O1",
|
||||
date(2024, 1, 3),
|
||||
"600000.SH",
|
||||
Side.BUY,
|
||||
100,
|
||||
Decimal("10"),
|
||||
Decimal("5"),
|
||||
Decimal("0"),
|
||||
Decimal("0.01"),
|
||||
)
|
||||
self.portfolio.apply_fill(fill)
|
||||
self.assertEqual(self.portfolio.position("600000.SH").quantity, 100)
|
||||
self.assertEqual(self.portfolio.cash, Decimal("98994.990000"))
|
||||
|
||||
def test_complete_odd_lot_sell_is_accepted_and_filled(self):
|
||||
submit_day = date(2024, 1, 2)
|
||||
fill_day = date(2024, 1, 3)
|
||||
symbol = "600000.XSHG"
|
||||
self.portfolio.positions[symbol] = Position(
|
||||
symbol,
|
||||
quantity=1050,
|
||||
sellable_quantity=1050,
|
||||
today_bought=0,
|
||||
average_cost=Decimal("9"),
|
||||
)
|
||||
self.broker.execute_session(
|
||||
{symbol: bar(submit_day, volume=200_000)}
|
||||
)
|
||||
order = self.broker.submit(
|
||||
symbol,
|
||||
Side.SELL,
|
||||
1050,
|
||||
submit_day,
|
||||
)
|
||||
self.assertEqual(order.status, OrderStatus.ACCEPTED)
|
||||
fills = self.broker.execute_session(
|
||||
{symbol: bar(fill_day, volume=200_000)}
|
||||
)
|
||||
self.assertEqual([item.quantity for item in fills], [1050])
|
||||
self.assertEqual(order.status, OrderStatus.FILLED)
|
||||
self.assertEqual(self.portfolio.position(symbol).quantity, 0)
|
||||
|
||||
def test_partial_odd_lot_and_star_subminimum_are_rejected(self):
|
||||
submit_day = date(2024, 1, 2)
|
||||
symbol = "600000.XSHG"
|
||||
self.portfolio.positions[symbol] = Position(
|
||||
symbol,
|
||||
quantity=1050,
|
||||
sellable_quantity=1050,
|
||||
today_bought=0,
|
||||
average_cost=Decimal("9"),
|
||||
)
|
||||
odd = self.broker.submit(symbol, Side.SELL, 50, submit_day)
|
||||
self.assertEqual(odd.status, OrderStatus.REJECTED)
|
||||
self.assertEqual(odd.status_reason, "NOT_BOARD_LOT")
|
||||
star = self.broker.submit(
|
||||
"688301.XSHG",
|
||||
Side.BUY,
|
||||
100,
|
||||
submit_day,
|
||||
)
|
||||
self.assertEqual(star.status, OrderStatus.REJECTED)
|
||||
self.assertEqual(star.status_reason, "BELOW_MARKET_MINIMUM")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,104 @@
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from quant60.domain import Side
|
||||
from quant60.execution import (
|
||||
ExecutionBucket,
|
||||
ParentOrderIntent,
|
||||
ResidualPolicy,
|
||||
market_data_is_fresh,
|
||||
plan_guarded_twap_pov,
|
||||
)
|
||||
|
||||
|
||||
UTC8 = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
class ExecutionPlannerTests(unittest.TestCase):
|
||||
def _parent(self, quantity=1000):
|
||||
return ParentOrderIntent(
|
||||
decision_id="D-20260726",
|
||||
symbol="600000.SH",
|
||||
side=Side.BUY,
|
||||
quantity=quantity,
|
||||
limit_price=10.0,
|
||||
window_start=datetime(2026, 7, 27, 9, 35, tzinfo=UTC8),
|
||||
window_end=datetime(2026, 7, 27, 10, 0, tzinfo=UTC8),
|
||||
market_data_as_of=datetime(2026, 7, 27, 9, 34, 59, tzinfo=UTC8),
|
||||
max_participation=0.10,
|
||||
max_child_quantity=400,
|
||||
max_child_notional=3000,
|
||||
residual_policy=ResidualPolicy.DEFER,
|
||||
)
|
||||
|
||||
def test_plan_conserves_quantity_and_honors_all_caps(self):
|
||||
parent = self._parent()
|
||||
buckets = [
|
||||
ExecutionBucket(
|
||||
datetime(2026, 7, 27, 9, 35 + index * 5, tzinfo=UTC8),
|
||||
2000,
|
||||
)
|
||||
for index in range(5)
|
||||
]
|
||||
plan = plan_guarded_twap_pov(parent, buckets)
|
||||
self.assertEqual(
|
||||
plan.planned_quantity + plan.residual_quantity,
|
||||
parent.quantity,
|
||||
)
|
||||
self.assertTrue(plan.children)
|
||||
for child in plan.children:
|
||||
self.assertLessEqual(child.quantity, 200)
|
||||
self.assertLessEqual(child.quantity * child.limit_price, 3000)
|
||||
self.assertLessEqual(
|
||||
child.quantity,
|
||||
child.participation_cap_quantity,
|
||||
)
|
||||
self.assertEqual(
|
||||
len({child.child_id for child in plan.children}),
|
||||
len(plan.children),
|
||||
)
|
||||
|
||||
def test_zero_volume_buckets_leave_explicit_residual(self):
|
||||
parent = self._parent(quantity=500)
|
||||
buckets = [
|
||||
ExecutionBucket(
|
||||
datetime(2026, 7, 27, 9, 35, tzinfo=UTC8),
|
||||
0,
|
||||
)
|
||||
]
|
||||
plan = plan_guarded_twap_pov(parent, buckets)
|
||||
self.assertEqual(plan.planned_quantity, 0)
|
||||
self.assertEqual(plan.residual_quantity, 500)
|
||||
self.assertEqual(plan.residual_policy, ResidualPolicy.DEFER)
|
||||
|
||||
def test_market_data_freshness_rejects_old_and_future_quotes(self):
|
||||
now = datetime(2026, 7, 27, 9, 35, tzinfo=UTC8)
|
||||
self.assertTrue(
|
||||
market_data_is_fresh(
|
||||
as_of=now - timedelta(seconds=2),
|
||||
now=now,
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
market_data_is_fresh(
|
||||
as_of=now - timedelta(seconds=6),
|
||||
now=now,
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
market_data_is_fresh(
|
||||
as_of=now + timedelta(seconds=2),
|
||||
now=now,
|
||||
)
|
||||
)
|
||||
|
||||
def test_parent_identity_is_decision_symbol_side(self):
|
||||
parent = self._parent()
|
||||
self.assertEqual(
|
||||
parent.idempotency_key,
|
||||
"D-20260726:600000.XSHG:BUY",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,46 @@
|
||||
import hashlib
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from quant60.experiment import (
|
||||
run_research_smoke,
|
||||
verify_research_manifest,
|
||||
write_research_artifacts,
|
||||
)
|
||||
|
||||
|
||||
class ResearchExperimentTests(unittest.TestCase):
|
||||
def test_research_smoke_is_deterministic_and_verifiable(self):
|
||||
first = run_research_smoke(trading_days=260, seed=11)
|
||||
second = run_research_smoke(trading_days=260, seed=11)
|
||||
self.assertEqual(first, second)
|
||||
self.assertGreater(first["report"]["fold_count"], 0)
|
||||
self.assertFalse(first["report"]["investment_value_claim"])
|
||||
self.assertTrue(first["targets"])
|
||||
|
||||
with tempfile.TemporaryDirectory() as left_dir, tempfile.TemporaryDirectory() as right_dir:
|
||||
left = write_research_artifacts(first, left_dir)
|
||||
right = write_research_artifacts(second, right_dir)
|
||||
for name in left:
|
||||
self.assertEqual(
|
||||
hashlib.sha256(Path(left[name]).read_bytes()).hexdigest(),
|
||||
hashlib.sha256(Path(right[name]).read_bytes()).hexdigest(),
|
||||
name,
|
||||
)
|
||||
verified = verify_research_manifest(left["manifest"])
|
||||
self.assertTrue(verified["ok"])
|
||||
|
||||
def test_research_verifier_rejects_tamper(self):
|
||||
result = run_research_smoke(trading_days=260, seed=12)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
paths = write_research_artifacts(result, directory)
|
||||
report = Path(paths["report"])
|
||||
report.write_bytes(report.read_bytes() + b" ")
|
||||
with self.assertRaisesRegex(ValueError, "hash mismatch"):
|
||||
verify_research_manifest(paths["manifest"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,91 @@
|
||||
import unittest
|
||||
|
||||
from quant60.factor_risk import (
|
||||
FactorExposureSnapshot,
|
||||
RiskModelError,
|
||||
build_factor_risk_forecast,
|
||||
estimate_factor_returns,
|
||||
factor_stress_loss,
|
||||
portfolio_factor_variance,
|
||||
)
|
||||
|
||||
|
||||
class FactorRiskTests(unittest.TestCase):
|
||||
def _exposure(self):
|
||||
return FactorExposureSnapshot(
|
||||
as_of="2026-07-24T15:00:00+08:00",
|
||||
exposures={
|
||||
"600000.SH": {"market": 1.0, "style": 1.0},
|
||||
"000001.SZ": {"market": 1.0, "style": 1.0},
|
||||
"300750.SZ": {"market": 1.0, "style": -1.0},
|
||||
},
|
||||
)
|
||||
|
||||
def _forecast(self):
|
||||
return build_factor_risk_forecast(
|
||||
factor_return_history=[
|
||||
{"market": 0.01, "style": 0.02},
|
||||
{"market": -0.01, "style": -0.02},
|
||||
{"market": 0.02, "style": 0.01},
|
||||
{"market": -0.02, "style": -0.01},
|
||||
],
|
||||
residual_history={
|
||||
"600000.SH": [0.01, -0.01, 0.005],
|
||||
"000001.SZ": [0.01, -0.01, 0.005],
|
||||
"300750.SZ": [0.01, -0.01, 0.005],
|
||||
},
|
||||
shrinkage=0.2,
|
||||
)
|
||||
|
||||
def test_factor_return_fit_and_residuals_are_complete(self):
|
||||
fit = estimate_factor_returns(
|
||||
returns={
|
||||
"600000.SH": 0.03,
|
||||
"000001.SZ": 0.02,
|
||||
"300750.SZ": -0.01,
|
||||
},
|
||||
exposure=self._exposure(),
|
||||
)
|
||||
self.assertEqual(set(fit.factor_returns), {"market", "style"})
|
||||
self.assertEqual(len(fit.residuals), 3)
|
||||
|
||||
def test_correlated_pair_has_more_risk_than_style_diversified_pair(self):
|
||||
exposure = self._exposure()
|
||||
forecast = self._forecast()
|
||||
correlated = portfolio_factor_variance(
|
||||
portfolio_weights={
|
||||
"600000.SH": 0.5,
|
||||
"000001.SZ": 0.5,
|
||||
},
|
||||
exposure=exposure,
|
||||
forecast=forecast,
|
||||
)
|
||||
diversified = portfolio_factor_variance(
|
||||
portfolio_weights={
|
||||
"600000.SH": 0.5,
|
||||
"300750.SZ": 0.5,
|
||||
},
|
||||
exposure=exposure,
|
||||
forecast=forecast,
|
||||
)
|
||||
self.assertGreater(correlated, diversified)
|
||||
|
||||
def test_missing_risk_input_fails_closed(self):
|
||||
with self.assertRaisesRegex(RiskModelError, "missing"):
|
||||
portfolio_factor_variance(
|
||||
portfolio_weights={"000333.SZ": 1.0},
|
||||
exposure=self._exposure(),
|
||||
forecast=self._forecast(),
|
||||
)
|
||||
|
||||
def test_market_down_stress_is_positive_loss(self):
|
||||
loss = factor_stress_loss(
|
||||
portfolio_weights={"600000.SH": 0.5, "300750.SZ": 0.5},
|
||||
exposure=self._exposure(),
|
||||
factor_shocks={"market": -0.10},
|
||||
)
|
||||
self.assertAlmostEqual(loss, 0.10)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,83 @@
|
||||
import math
|
||||
import unittest
|
||||
from datetime import date
|
||||
|
||||
from quant60.features import (
|
||||
TrainOnlyFeaturePreprocessor,
|
||||
build_executable_label,
|
||||
compute_price_features,
|
||||
)
|
||||
from quant60.synthetic import generate_synthetic_bars
|
||||
|
||||
|
||||
class FeatureTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.bars = generate_synthetic_bars(
|
||||
["600000.XSHG"],
|
||||
start_date="2024-01-02",
|
||||
trading_days_count=150,
|
||||
seed=7,
|
||||
include_market_events=False,
|
||||
)
|
||||
|
||||
def test_feature_as_of_does_not_change_when_future_bars_are_appended(self):
|
||||
decision = self.bars[99].trading_date
|
||||
early = compute_price_features(self.bars[:100], as_of=decision)
|
||||
with_future = compute_price_features(self.bars, as_of=decision)
|
||||
self.assertEqual(early.values, with_future.values)
|
||||
self.assertEqual(early.history_end, decision)
|
||||
|
||||
def test_long_window_features_are_explicitly_missing(self):
|
||||
snapshot = compute_price_features(self.bars[:30])
|
||||
self.assertIsNone(snapshot.values["momentum_60"])
|
||||
self.assertIsNotNone(snapshot.values["momentum_20"])
|
||||
|
||||
def test_preprocessor_uses_training_statistics_and_missing_indicator(self):
|
||||
processor = TrainOnlyFeaturePreprocessor(winsor_fraction=0).fit(
|
||||
[{"a": 1.0}, {"a": 2.0}, {"a": 3.0}]
|
||||
)
|
||||
transformed = processor.transform_one({"a": None})
|
||||
self.assertEqual(transformed["a__missing"], 1.0)
|
||||
self.assertAlmostEqual(transformed["a"], 0.0)
|
||||
extreme = processor.transform_one({"a": 1_000_000.0})
|
||||
expected = processor.transform_one({"a": 3.0})
|
||||
self.assertEqual(extreme["a"], expected["a"])
|
||||
|
||||
def test_executable_label_uses_sessions_after_decision(self):
|
||||
decision_index = 80
|
||||
label = build_executable_label(
|
||||
self.bars,
|
||||
decision_date=self.bars[decision_index].trading_date,
|
||||
horizon_sessions=5,
|
||||
)
|
||||
self.assertEqual(label.status, "OK")
|
||||
self.assertEqual(
|
||||
label.entry_date,
|
||||
self.bars[decision_index + 1].trading_date,
|
||||
)
|
||||
self.assertEqual(
|
||||
label.exit_date,
|
||||
self.bars[decision_index + 6].trading_date,
|
||||
)
|
||||
self.assertTrue(math.isfinite(label.value))
|
||||
|
||||
def test_suspended_entry_is_censored_not_dropped(self):
|
||||
bars = generate_synthetic_bars(
|
||||
["600000.XSHG"],
|
||||
start_date=date(2024, 1, 2),
|
||||
trading_days_count=40,
|
||||
seed=7,
|
||||
include_market_events=True,
|
||||
)
|
||||
decision = bars[10].trading_date
|
||||
label = build_executable_label(
|
||||
bars,
|
||||
decision_date=decision,
|
||||
horizon_sessions=5,
|
||||
)
|
||||
self.assertEqual(label.status, "NON_EXECUTABLE_CENSORED")
|
||||
self.assertIsNone(label.value)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,987 @@
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from quant60.broker import SimBroker
|
||||
from quant60.config import ExecutionConfig, FeeConfig
|
||||
from quant60.contracts import (
|
||||
ContractValidationError as RuntimeContractValidationError,
|
||||
validate_broker_snapshot,
|
||||
)
|
||||
from quant60.domain import Bar, Portfolio, Side
|
||||
from quant60.ledger import (
|
||||
DuplicateEventError,
|
||||
HashChainLedger,
|
||||
LedgerIntegrityError,
|
||||
)
|
||||
from quant60.reconcile import reconcile_snapshot
|
||||
from quant60.replay import (
|
||||
ConflictingDuplicateError,
|
||||
LedgerProjector,
|
||||
LedgerReplayError,
|
||||
OutOfOrderError,
|
||||
OverfillError,
|
||||
UnknownOrderError,
|
||||
project_ledger,
|
||||
)
|
||||
from quant60.snapshot_pipeline import first_executable_window
|
||||
|
||||
|
||||
class ContractValidationError(AssertionError):
|
||||
pass
|
||||
|
||||
|
||||
def validate_json_schema(instance, schema, *, root=None, path="$"):
|
||||
"""Validate the JSON-Schema subset used by the replay contract.
|
||||
|
||||
This deliberately small validator keeps the contract test dependency-free;
|
||||
it is not exposed as a general JSON-Schema implementation.
|
||||
"""
|
||||
|
||||
root = schema if root is None else root
|
||||
if "$ref" in schema:
|
||||
reference = schema["$ref"]
|
||||
if not reference.startswith("#/"):
|
||||
raise ContractValidationError(
|
||||
f"{path}: only local schema references are supported"
|
||||
)
|
||||
target = root
|
||||
for component in reference[2:].split("/"):
|
||||
target = target[component.replace("~1", "/").replace("~0", "~")]
|
||||
validate_json_schema(instance, target, root=root, path=path)
|
||||
return
|
||||
|
||||
expected_type = schema.get("type")
|
||||
if expected_type is not None:
|
||||
expected = (
|
||||
expected_type
|
||||
if isinstance(expected_type, list)
|
||||
else [expected_type]
|
||||
)
|
||||
|
||||
def matches_type(name):
|
||||
if name == "null":
|
||||
return instance is None
|
||||
if name == "object":
|
||||
return isinstance(instance, dict)
|
||||
if name == "array":
|
||||
return isinstance(instance, list)
|
||||
if name == "string":
|
||||
return isinstance(instance, str)
|
||||
if name == "boolean":
|
||||
return isinstance(instance, bool)
|
||||
if name == "integer":
|
||||
return isinstance(instance, int) and not isinstance(instance, bool)
|
||||
if name == "number":
|
||||
return (
|
||||
isinstance(instance, (int, float))
|
||||
and not isinstance(instance, bool)
|
||||
and math.isfinite(float(instance))
|
||||
)
|
||||
raise ContractValidationError(
|
||||
f"{path}: unsupported schema type {name}"
|
||||
)
|
||||
|
||||
if not any(matches_type(name) for name in expected):
|
||||
raise ContractValidationError(
|
||||
f"{path}: expected type {expected}, got {type(instance).__name__}"
|
||||
)
|
||||
|
||||
if "const" in schema and instance != schema["const"]:
|
||||
raise ContractValidationError(
|
||||
f"{path}: expected constant {schema['const']!r}"
|
||||
)
|
||||
if "enum" in schema and instance not in schema["enum"]:
|
||||
raise ContractValidationError(f"{path}: value is outside enum")
|
||||
if "minLength" in schema and len(instance) < schema["minLength"]:
|
||||
raise ContractValidationError(f"{path}: string is too short")
|
||||
if "pattern" in schema and re.search(schema["pattern"], instance) is None:
|
||||
raise ContractValidationError(f"{path}: string does not match pattern")
|
||||
if "minimum" in schema and instance < schema["minimum"]:
|
||||
raise ContractValidationError(f"{path}: value is below minimum")
|
||||
if (
|
||||
"exclusiveMinimum" in schema
|
||||
and instance <= schema["exclusiveMinimum"]
|
||||
):
|
||||
raise ContractValidationError(
|
||||
f"{path}: value is not above exclusive minimum"
|
||||
)
|
||||
|
||||
if schema.get("format") == "date":
|
||||
try:
|
||||
date.fromisoformat(instance)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ContractValidationError(
|
||||
f"{path}: value is not an ISO date"
|
||||
) from exc
|
||||
if schema.get("format") == "date-time":
|
||||
try:
|
||||
parsed = datetime.fromisoformat(instance.replace("Z", "+00:00"))
|
||||
except (AttributeError, ValueError) as exc:
|
||||
raise ContractValidationError(
|
||||
f"{path}: value is not an ISO date-time"
|
||||
) from exc
|
||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||
raise ContractValidationError(
|
||||
f"{path}: date-time has no timezone offset"
|
||||
)
|
||||
|
||||
if isinstance(instance, dict):
|
||||
required = schema.get("required", [])
|
||||
missing = [key for key in required if key not in instance]
|
||||
if missing:
|
||||
raise ContractValidationError(
|
||||
f"{path}: missing required fields {missing}"
|
||||
)
|
||||
properties = schema.get("properties", {})
|
||||
if schema.get("additionalProperties") is False:
|
||||
unknown = sorted(set(instance) - set(properties))
|
||||
if unknown:
|
||||
raise ContractValidationError(
|
||||
f"{path}: unsupported fields {unknown}"
|
||||
)
|
||||
for key, subschema in properties.items():
|
||||
if key in instance:
|
||||
validate_json_schema(
|
||||
instance[key],
|
||||
subschema,
|
||||
root=root,
|
||||
path=f"{path}.{key}",
|
||||
)
|
||||
for trigger, dependencies in schema.get(
|
||||
"dependentRequired",
|
||||
{},
|
||||
).items():
|
||||
if trigger in instance:
|
||||
absent = [key for key in dependencies if key not in instance]
|
||||
if absent:
|
||||
raise ContractValidationError(
|
||||
f"{path}: {trigger} requires {absent}"
|
||||
)
|
||||
|
||||
if "oneOf" in schema:
|
||||
matches = 0
|
||||
failures = []
|
||||
for candidate in schema["oneOf"]:
|
||||
try:
|
||||
validate_json_schema(
|
||||
instance,
|
||||
candidate,
|
||||
root=root,
|
||||
path=path,
|
||||
)
|
||||
matches += 1
|
||||
except ContractValidationError as exc:
|
||||
failures.append(str(exc))
|
||||
if matches != 1:
|
||||
raise ContractValidationError(
|
||||
f"{path}: expected exactly one schema branch, got {matches}; "
|
||||
f"{failures}"
|
||||
)
|
||||
|
||||
|
||||
def order_payload(
|
||||
order_id,
|
||||
side,
|
||||
quantity,
|
||||
status,
|
||||
*,
|
||||
filled=0,
|
||||
average="0",
|
||||
):
|
||||
return {
|
||||
"order_id": order_id,
|
||||
"symbol": "600000.XSHG",
|
||||
"side": side,
|
||||
"quantity": quantity,
|
||||
"filled_quantity": filled,
|
||||
"average_fill_price": average,
|
||||
"status": status,
|
||||
"submitted_at": "2024-01-02",
|
||||
"metadata": {},
|
||||
"reason": None,
|
||||
}
|
||||
|
||||
|
||||
def fill_payload(
|
||||
fill_id,
|
||||
order_id,
|
||||
side,
|
||||
quantity,
|
||||
price,
|
||||
*,
|
||||
commission="5",
|
||||
stamp_duty="0",
|
||||
transfer_fee="0",
|
||||
cash_delta=None,
|
||||
):
|
||||
payload = {
|
||||
"fill_id": fill_id,
|
||||
"order_id": order_id,
|
||||
"trading_date": "2024-01-03",
|
||||
"symbol": "600000.XSHG",
|
||||
"side": side,
|
||||
"quantity": quantity,
|
||||
"price": price,
|
||||
"commission": commission,
|
||||
"stamp_duty": stamp_duty,
|
||||
"transfer_fee": transfer_fee,
|
||||
}
|
||||
if cash_delta is not None:
|
||||
payload["cash_delta"] = cash_delta
|
||||
return payload
|
||||
|
||||
|
||||
def golden_ledger():
|
||||
ledger = HashChainLedger()
|
||||
ledger.append(
|
||||
"ORDER_STATUS",
|
||||
order_payload("O1", "BUY", 200, "ACCEPTED"),
|
||||
timestamp="2024-01-03T09:30:00+08:00",
|
||||
event_id="order:O1:accepted",
|
||||
)
|
||||
ledger.append(
|
||||
"FILL",
|
||||
fill_payload("F1", "O1", "BUY", 100, "10", cash_delta="-1005"),
|
||||
timestamp="2024-01-03T09:31:00+08:00",
|
||||
event_id="fill:F1",
|
||||
)
|
||||
ledger.append(
|
||||
"ORDER_STATUS",
|
||||
order_payload(
|
||||
"O1",
|
||||
"BUY",
|
||||
200,
|
||||
"PARTIALLY_FILLED",
|
||||
filled=100,
|
||||
average="10",
|
||||
),
|
||||
timestamp="2024-01-03T09:31:00+08:00",
|
||||
event_id="order:O1:partial:100",
|
||||
)
|
||||
ledger.append(
|
||||
"FILL",
|
||||
fill_payload("F2", "O1", "BUY", 100, "11", cash_delta="-1105"),
|
||||
timestamp="2024-01-03T09:32:00+08:00",
|
||||
event_id="fill:F2",
|
||||
)
|
||||
ledger.append(
|
||||
"ORDER_STATUS",
|
||||
order_payload(
|
||||
"O1",
|
||||
"BUY",
|
||||
200,
|
||||
"FILLED",
|
||||
filled=200,
|
||||
average="10.5",
|
||||
),
|
||||
timestamp="2024-01-03T09:32:00+08:00",
|
||||
event_id="order:O1:filled",
|
||||
)
|
||||
ledger.append(
|
||||
"ORDER_STATUS",
|
||||
order_payload("O2", "SELL", 100, "ACCEPTED"),
|
||||
timestamp="2024-01-03T09:33:00+08:00",
|
||||
event_id="order:O2:accepted",
|
||||
)
|
||||
ledger.append(
|
||||
"FILL",
|
||||
fill_payload(
|
||||
"F3",
|
||||
"O2",
|
||||
"SELL",
|
||||
100,
|
||||
"12",
|
||||
commission="5",
|
||||
stamp_duty="0.6",
|
||||
transfer_fee="0.01",
|
||||
cash_delta="1194.39",
|
||||
),
|
||||
timestamp="2024-01-03T09:34:00+08:00",
|
||||
event_id="fill:F3",
|
||||
)
|
||||
ledger.append(
|
||||
"ORDER_STATUS",
|
||||
order_payload(
|
||||
"O2",
|
||||
"SELL",
|
||||
100,
|
||||
"FILLED",
|
||||
filled=100,
|
||||
average="12",
|
||||
),
|
||||
timestamp="2024-01-03T09:34:00+08:00",
|
||||
event_id="order:O2:filled",
|
||||
)
|
||||
return ledger
|
||||
|
||||
|
||||
class LedgerReconcileTests(unittest.TestCase):
|
||||
def test_broker_snapshot_schema_requires_fresh_source_and_order_intent(self):
|
||||
snapshot = {
|
||||
"schema_version": "1.0",
|
||||
"snapshot_id": "snapshot-1",
|
||||
"signal_as_of": "2024-01-02T15:00:00+08:00",
|
||||
"next_trading_session": "2024-01-03",
|
||||
"first_executable_window": first_executable_window(
|
||||
date(2024, 1, 3)
|
||||
),
|
||||
"observation_as_of": "2024-01-03T09:10:00+08:00",
|
||||
"as_of": "2024-01-03T09:10:00+08:00",
|
||||
"source_time": "2024-01-03T09:09:59+08:00",
|
||||
"broker": "fake",
|
||||
"account_hash": "account-hash",
|
||||
"cash": 1000.0,
|
||||
"total_asset": 2000.0,
|
||||
"positions": [
|
||||
{
|
||||
"symbol": "600000.XSHG",
|
||||
"quantity": 100,
|
||||
"sellable_quantity": 100,
|
||||
}
|
||||
],
|
||||
"open_orders": [
|
||||
{
|
||||
"order_id": "C1",
|
||||
"broker_order_id": "B1",
|
||||
"symbol": "600000.XSHG",
|
||||
"side": "BUY",
|
||||
"quantity": 100,
|
||||
"filled_quantity": 0,
|
||||
"status": "ACCEPTED",
|
||||
"limit_price": 10.0,
|
||||
"order_type": "LIMIT",
|
||||
"time_in_force": "DAY",
|
||||
}
|
||||
],
|
||||
}
|
||||
validate_broker_snapshot(snapshot)
|
||||
|
||||
no_source = copy.deepcopy(snapshot)
|
||||
del no_source["source_time"]
|
||||
with self.assertRaises(RuntimeContractValidationError):
|
||||
validate_broker_snapshot(no_source)
|
||||
no_price = copy.deepcopy(snapshot)
|
||||
del no_price["open_orders"][0]["limit_price"]
|
||||
with self.assertRaises(RuntimeContractValidationError):
|
||||
validate_broker_snapshot(no_price)
|
||||
|
||||
def test_schema_validates_actual_sim_broker_replay_envelopes(self):
|
||||
ledger = HashChainLedger()
|
||||
broker = SimBroker(
|
||||
Portfolio(Decimal("100000")),
|
||||
ExecutionConfig(
|
||||
lot_size=100,
|
||||
participation_rate=0.1,
|
||||
slippage_bps=0,
|
||||
),
|
||||
FeeConfig(
|
||||
commission_rate=0,
|
||||
minimum_commission=0,
|
||||
stamp_duty_rate=0,
|
||||
transfer_fee_rate=0,
|
||||
),
|
||||
ledger,
|
||||
)
|
||||
broker.submit(
|
||||
"600000.SH",
|
||||
Side.BUY,
|
||||
100,
|
||||
date(2024, 1, 2),
|
||||
)
|
||||
value = Decimal("10")
|
||||
broker.execute_session(
|
||||
{
|
||||
"600000.XSHG": Bar(
|
||||
trading_date=date(2024, 1, 2),
|
||||
symbol="600000.XSHG",
|
||||
open=value,
|
||||
high=value,
|
||||
low=value,
|
||||
close=value,
|
||||
volume=1000,
|
||||
)
|
||||
}
|
||||
)
|
||||
broker.execute_session(
|
||||
{
|
||||
"600000.XSHG": Bar(
|
||||
trading_date=date(2024, 1, 3),
|
||||
symbol="600000.XSHG",
|
||||
open=value,
|
||||
high=value,
|
||||
low=value,
|
||||
close=value,
|
||||
volume=1000,
|
||||
)
|
||||
}
|
||||
)
|
||||
envelopes = [
|
||||
record.as_dict()
|
||||
for record in ledger.records
|
||||
if record.event_type in {"ORDER_STATUS", "FILL"}
|
||||
]
|
||||
self.assertEqual(
|
||||
[record["event_type"] for record in envelopes],
|
||||
["ORDER_STATUS", "FILL", "ORDER_STATUS"],
|
||||
)
|
||||
schema_path = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "schemas"
|
||||
/ "order_event.schema.json"
|
||||
)
|
||||
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||
for envelope in envelopes:
|
||||
with self.subTest(event_id=envelope["event_id"]):
|
||||
validate_json_schema(envelope, schema)
|
||||
|
||||
projection = project_ledger(envelopes)
|
||||
self.assertEqual(projection.cash_delta, Decimal("-1000.000000"))
|
||||
self.assertEqual(
|
||||
projection.position_deltas,
|
||||
{"600000.XSHG": 100},
|
||||
)
|
||||
self.assertEqual(projection.orders["O00000001"].status, "FILLED")
|
||||
|
||||
def test_schema_discriminates_payloads_and_projector_enforces_envelope(self):
|
||||
schema_path = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "schemas"
|
||||
/ "order_event.schema.json"
|
||||
)
|
||||
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||
accepted = golden_ledger().records[0].as_dict()
|
||||
|
||||
flat_legacy = {
|
||||
"schema_version": "1.0",
|
||||
"event_id": accepted["event_id"],
|
||||
"event_time": accepted["timestamp"],
|
||||
**accepted["payload"],
|
||||
}
|
||||
with self.assertRaises(ContractValidationError):
|
||||
validate_json_schema(flat_legacy, schema)
|
||||
with self.assertRaises(LedgerReplayError):
|
||||
project_ledger([flat_legacy])
|
||||
|
||||
crossed = copy.deepcopy(accepted)
|
||||
crossed["event_type"] = "FILL"
|
||||
with self.assertRaises(ContractValidationError):
|
||||
validate_json_schema(crossed, schema)
|
||||
|
||||
unexpected = copy.deepcopy(accepted)
|
||||
unexpected["payload"]["undocumented"] = True
|
||||
with self.assertRaises(ContractValidationError):
|
||||
validate_json_schema(unexpected, schema)
|
||||
with self.assertRaises(LedgerReplayError):
|
||||
project_ledger([unexpected])
|
||||
|
||||
unpaired_hash = copy.deepcopy(accepted)
|
||||
del unpaired_hash["hash"]
|
||||
with self.assertRaises(ContractValidationError):
|
||||
validate_json_schema(unpaired_hash, schema)
|
||||
with self.assertRaises(LedgerReplayError):
|
||||
project_ledger([unpaired_hash])
|
||||
|
||||
no_sequence = copy.deepcopy(accepted)
|
||||
del no_sequence["sequence"]
|
||||
with self.assertRaises(ContractValidationError):
|
||||
validate_json_schema(no_sequence, schema)
|
||||
with self.assertRaises(LedgerReplayError):
|
||||
project_ledger([no_sequence])
|
||||
|
||||
alias_fixture = copy.deepcopy(accepted)
|
||||
del alias_fixture["previous_hash"]
|
||||
del alias_fixture["hash"]
|
||||
alias_fixture["payload"]["order_quantity"] = (
|
||||
alias_fixture["payload"].pop("quantity")
|
||||
)
|
||||
alias_fixture["payload"]["cumulative_filled_quantity"] = (
|
||||
alias_fixture["payload"].pop("filled_quantity")
|
||||
)
|
||||
with self.assertRaises(ContractValidationError):
|
||||
validate_json_schema(alias_fixture, schema)
|
||||
self.assertEqual(
|
||||
project_ledger([alias_fixture]).orders["O1"].quantity,
|
||||
200,
|
||||
)
|
||||
conflicting_alias = copy.deepcopy(accepted)
|
||||
del conflicting_alias["previous_hash"]
|
||||
del conflicting_alias["hash"]
|
||||
conflicting_alias["payload"]["order_quantity"] = 300
|
||||
with self.assertRaises(ConflictingDuplicateError):
|
||||
project_ledger([conflicting_alias])
|
||||
|
||||
def test_hash_chain_duplicate_and_tamper_detection(self):
|
||||
ledger = HashChainLedger()
|
||||
first = ledger.append(
|
||||
"TEST",
|
||||
{"value": 1},
|
||||
timestamp="2024-01-02T15:00:00+08:00",
|
||||
event_id="event-1",
|
||||
)
|
||||
self.assertIs(
|
||||
ledger.append(
|
||||
"TEST",
|
||||
{"value": 1},
|
||||
timestamp="2024-01-02T15:00:00+08:00",
|
||||
event_id="event-1",
|
||||
),
|
||||
first,
|
||||
)
|
||||
with self.assertRaises(DuplicateEventError):
|
||||
ledger.append(
|
||||
"TEST",
|
||||
{"value": 2},
|
||||
timestamp="2024-01-02T15:00:00+08:00",
|
||||
event_id="event-1",
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = ledger.write_jsonl(Path(directory) / "events.jsonl")
|
||||
self.assertTrue(HashChainLedger.read_jsonl(path).verify())
|
||||
line = json.loads(path.read_text(encoding="utf-8").splitlines()[0])
|
||||
line["payload"]["value"] = 99
|
||||
path.write_text(json.dumps(line) + "\n", encoding="utf-8")
|
||||
with self.assertRaises(LedgerIntegrityError):
|
||||
HashChainLedger.read_jsonl(path)
|
||||
|
||||
def test_legacy_reconciliation_api_remains_compatible(self):
|
||||
okay = reconcile_snapshot(
|
||||
local_cash="100",
|
||||
local_positions={"600000.SH": 100},
|
||||
broker_cash="100.001",
|
||||
broker_positions={"SH600000": 100},
|
||||
cash_tolerance="0.01",
|
||||
)
|
||||
self.assertTrue(okay.balanced)
|
||||
self.assertFalse(okay.complete)
|
||||
self.assertFalse(okay.safe_to_open)
|
||||
bad = reconcile_snapshot(
|
||||
local_cash="100",
|
||||
local_positions={"600000.SH": 100},
|
||||
broker_cash="99",
|
||||
broker_positions={"600000.SH": 0},
|
||||
)
|
||||
self.assertFalse(bad.safe_to_open)
|
||||
self.assertEqual(
|
||||
{item.kind for item in bad.differences},
|
||||
{"CASH", "POSITION"},
|
||||
)
|
||||
|
||||
def test_identical_duplicate_open_order_identity_fails_closed(self):
|
||||
order = {
|
||||
"order_id": "DUPLICATE",
|
||||
"broker_order_id": "B-DUPLICATE",
|
||||
"symbol": "600000.XSHG",
|
||||
"side": "BUY",
|
||||
"quantity": 100,
|
||||
"filled_quantity": 0,
|
||||
"status": "ACCEPTED",
|
||||
"limit_price": 10.0,
|
||||
"order_type": "LIMIT",
|
||||
"time_in_force": "DAY",
|
||||
}
|
||||
local = {
|
||||
"cash": "100",
|
||||
"positions": [],
|
||||
"open_orders": [order, copy.deepcopy(order)],
|
||||
"as_of": "2024-01-03T12:00:00+08:00",
|
||||
}
|
||||
broker = {
|
||||
**local,
|
||||
"open_orders": [copy.deepcopy(order), copy.deepcopy(order)],
|
||||
"source_time": "2024-01-03T12:00:00+08:00",
|
||||
}
|
||||
report = reconcile_snapshot(
|
||||
local_snapshot=local,
|
||||
broker_snapshot=broker,
|
||||
now="2024-01-03T12:00:00+08:00",
|
||||
)
|
||||
self.assertTrue(report.complete)
|
||||
self.assertFalse(report.balanced)
|
||||
self.assertFalse(report.safe_to_open)
|
||||
self.assertIn(
|
||||
("DUPLICATE_OPEN_ORDER", "DUPLICATE"),
|
||||
{(item.kind, item.key) for item in report.differences},
|
||||
)
|
||||
|
||||
def test_complete_snapshot_checks_sellable_orders_and_freshness(self):
|
||||
local = {
|
||||
"cash": "1000",
|
||||
"positions": [
|
||||
{
|
||||
"symbol": "600000.XSHG",
|
||||
"quantity": 200,
|
||||
"sellable_quantity": 100,
|
||||
}
|
||||
],
|
||||
"open_orders": [
|
||||
{
|
||||
"order_id": "C1",
|
||||
"broker_order_id": "B-C1",
|
||||
"symbol": "600000.XSHG",
|
||||
"side": "SELL",
|
||||
"quantity": 100,
|
||||
"filled_quantity": 0,
|
||||
"status": "ACCEPTED",
|
||||
"limit_price": 10.0,
|
||||
"order_type": "LIMIT",
|
||||
"time_in_force": "DAY",
|
||||
}
|
||||
],
|
||||
"as_of": "2024-01-03T12:00:09+08:00",
|
||||
}
|
||||
broker = {
|
||||
"cash": "1000.001",
|
||||
"positions": [
|
||||
{
|
||||
"symbol": "600000.SH",
|
||||
"quantity": 200,
|
||||
"sellable_quantity": 100,
|
||||
}
|
||||
],
|
||||
"open_orders": [
|
||||
{
|
||||
"order_id": "C1",
|
||||
"broker_order_id": "B-C1",
|
||||
"symbol": "SH600000",
|
||||
"side": "SELL",
|
||||
"quantity": 100,
|
||||
"filled_quantity": 0,
|
||||
"status": "ACCEPTED",
|
||||
"limit_price": 10.0,
|
||||
"order_type": "LIMIT",
|
||||
"time_in_force": "DAY",
|
||||
}
|
||||
],
|
||||
"as_of": "2024-01-03T12:00:08+08:00",
|
||||
"source_time": "2024-01-03T12:00:05+08:00",
|
||||
}
|
||||
report = reconcile_snapshot(
|
||||
local_snapshot=local,
|
||||
broker_snapshot=broker,
|
||||
now="2024-01-03T12:00:10+08:00",
|
||||
max_snapshot_age_seconds=15,
|
||||
max_source_age_seconds=15,
|
||||
)
|
||||
self.assertTrue(report.safe_to_open)
|
||||
|
||||
wrong_price = copy.deepcopy(broker)
|
||||
wrong_price["open_orders"][0]["limit_price"] = 10.01
|
||||
price_report = reconcile_snapshot(
|
||||
local_snapshot=local,
|
||||
broker_snapshot=wrong_price,
|
||||
now="2024-01-03T12:00:10+08:00",
|
||||
max_snapshot_age_seconds=15,
|
||||
max_source_age_seconds=15,
|
||||
)
|
||||
self.assertFalse(price_report.safe_to_open)
|
||||
self.assertIn(
|
||||
("OPEN_ORDER", "C1"),
|
||||
{
|
||||
(item.kind, item.key)
|
||||
for item in price_report.differences
|
||||
},
|
||||
)
|
||||
|
||||
unsafe_broker = copy.deepcopy(broker)
|
||||
unsafe_broker["positions"][0]["sellable_quantity"] = 50
|
||||
unsafe_broker["open_orders"][0]["status"] = "UNKNOWN"
|
||||
unsafe_broker["source_time"] = "2024-01-03T11:55:00+08:00"
|
||||
unsafe = reconcile_snapshot(
|
||||
local_snapshot=local,
|
||||
broker_snapshot=unsafe_broker,
|
||||
now="2024-01-03T12:00:10+08:00",
|
||||
max_snapshot_age_seconds=15,
|
||||
max_source_age_seconds=15,
|
||||
)
|
||||
self.assertFalse(unsafe.safe_to_open)
|
||||
self.assertEqual(
|
||||
{item.kind for item in unsafe.differences},
|
||||
{"OPEN_ORDER", "SELLABLE", "STALE_SOURCE", "UNKNOWN_ORDER"},
|
||||
)
|
||||
|
||||
def test_complete_snapshot_missing_or_stale_as_of_fails_closed(self):
|
||||
local = {
|
||||
"cash": "100",
|
||||
"positions": [],
|
||||
"open_orders": [],
|
||||
"as_of": "2024-01-03T11:00:00+08:00",
|
||||
}
|
||||
broker = {
|
||||
"cash": "100",
|
||||
"positions": [],
|
||||
"open_orders": [],
|
||||
"as_of": "2024-01-03T11:00:00+08:00",
|
||||
"source_time": "2024-01-03T11:00:00+08:00",
|
||||
}
|
||||
stale = reconcile_snapshot(
|
||||
local_snapshot=local,
|
||||
broker_snapshot=broker,
|
||||
now="2024-01-03T12:00:00+08:00",
|
||||
)
|
||||
self.assertFalse(stale.safe_to_open)
|
||||
self.assertIn(
|
||||
"STALE_SNAPSHOT",
|
||||
{item.kind for item in stale.differences},
|
||||
)
|
||||
missing = copy.deepcopy(broker)
|
||||
del missing["open_orders"]
|
||||
report = reconcile_snapshot(
|
||||
local_snapshot={
|
||||
**local,
|
||||
"as_of": "2024-01-03T12:00:00+08:00",
|
||||
},
|
||||
broker_snapshot={
|
||||
**missing,
|
||||
"as_of": "2024-01-03T12:00:00+08:00",
|
||||
"source_time": "2024-01-03T12:00:00+08:00",
|
||||
},
|
||||
now="2024-01-03T12:00:00+08:00",
|
||||
)
|
||||
self.assertFalse(report.safe_to_open)
|
||||
self.assertIn(
|
||||
("MISSING_FIELD", "broker.open_orders"),
|
||||
{(item.kind, item.key) for item in report.differences},
|
||||
)
|
||||
|
||||
def test_freshness_tolerances_reject_nonfinite_and_negative_values(self):
|
||||
snapshot = {
|
||||
"cash": "100",
|
||||
"positions": [],
|
||||
"open_orders": [],
|
||||
"as_of": "2024-01-03T12:00:00+08:00",
|
||||
"source_time": "2024-01-03T12:00:00+08:00",
|
||||
}
|
||||
base = {
|
||||
"local_snapshot": {
|
||||
key: value
|
||||
for key, value in snapshot.items()
|
||||
if key != "source_time"
|
||||
},
|
||||
"broker_snapshot": snapshot,
|
||||
"now": "2024-01-03T12:00:00+08:00",
|
||||
}
|
||||
tolerance_names = (
|
||||
"max_snapshot_age_seconds",
|
||||
"max_source_age_seconds",
|
||||
"max_clock_skew_seconds",
|
||||
"max_age_seconds",
|
||||
)
|
||||
invalid_values = (
|
||||
float("nan"),
|
||||
float("inf"),
|
||||
float("-inf"),
|
||||
-0.001,
|
||||
)
|
||||
for name in tolerance_names:
|
||||
for value in invalid_values:
|
||||
with self.subTest(tolerance=name, value=value):
|
||||
with self.assertRaisesRegex(
|
||||
ValueError,
|
||||
rf"{name} must be a finite non-negative number",
|
||||
):
|
||||
reconcile_snapshot(**base, **{name: value})
|
||||
|
||||
def test_invalid_specific_tolerance_is_not_hidden_by_age_alias(self):
|
||||
legacy = {
|
||||
"local_cash": "100",
|
||||
"local_positions": {},
|
||||
"broker_cash": "100",
|
||||
"broker_positions": {},
|
||||
"max_age_seconds": 10,
|
||||
}
|
||||
for name in (
|
||||
"max_snapshot_age_seconds",
|
||||
"max_source_age_seconds",
|
||||
):
|
||||
with self.subTest(tolerance=name):
|
||||
with self.assertRaisesRegex(
|
||||
ValueError,
|
||||
rf"{name} must be a finite non-negative number",
|
||||
):
|
||||
reconcile_snapshot(**legacy, **{name: float("nan")})
|
||||
|
||||
def test_replay_frozen_golden_digest_and_accounting(self):
|
||||
projection = project_ledger(golden_ledger())
|
||||
self.assertEqual(projection.cash_delta, Decimal("-915.610000"))
|
||||
self.assertEqual(
|
||||
projection.position_deltas,
|
||||
{"600000.XSHG": 100},
|
||||
)
|
||||
self.assertEqual(projection.orders["O1"].filled_quantity, 200)
|
||||
self.assertEqual(
|
||||
projection.orders["O1"].average_fill_price,
|
||||
Decimal("10.5000"),
|
||||
)
|
||||
self.assertEqual(projection.orders["O2"].position_delta, -100)
|
||||
self.assertTrue(projection.safe_to_reconcile)
|
||||
self.assertEqual(
|
||||
projection.digest,
|
||||
"b9b48e5f11f43262e435318f6b6a39be74d3090506a3e5952bef4af9944545e5",
|
||||
)
|
||||
|
||||
def test_replay_identical_duplicates_are_idempotent(self):
|
||||
ledger = golden_ledger()
|
||||
baseline = project_ledger(ledger)
|
||||
records = [record.as_dict() for record in ledger.records]
|
||||
duplicate_fill = copy.deepcopy(records[6])
|
||||
duplicate_fill["sequence"] = 9
|
||||
duplicate_fill["event_id"] = "fill:F3:duplicate-delivery"
|
||||
duplicate_status = copy.deepcopy(records[7])
|
||||
duplicate_status["sequence"] = 10
|
||||
duplicate_status["event_id"] = "order:O2:filled:duplicate-delivery"
|
||||
replayed = project_ledger(records + [duplicate_fill, duplicate_status])
|
||||
self.assertEqual(replayed.digest, baseline.digest)
|
||||
self.assertEqual(replayed.cash_delta, baseline.cash_delta)
|
||||
self.assertEqual(replayed.fill_ids, baseline.fill_ids)
|
||||
|
||||
def test_replay_duplicate_id_cannot_hide_sequence_jump(self):
|
||||
first = {
|
||||
"sequence": 1,
|
||||
"event_id": "order:e1",
|
||||
"event_type": "ORDER_STATUS",
|
||||
"timestamp": "2024-01-03T09:30:00+08:00",
|
||||
"payload": order_payload(
|
||||
"E1",
|
||||
"BUY",
|
||||
100,
|
||||
"ACCEPTED",
|
||||
),
|
||||
}
|
||||
exact_retry = copy.deepcopy(first)
|
||||
second = copy.deepcopy(first)
|
||||
second["sequence"] = 2
|
||||
second["event_id"] = "order:e2"
|
||||
|
||||
legitimate = LedgerProjector()
|
||||
legitimate.process(first)
|
||||
legitimate.process(exact_retry)
|
||||
legitimate.process(second)
|
||||
projection = legitimate.projection()
|
||||
self.assertEqual(projection.source_last_sequence, 2)
|
||||
self.assertEqual(projection.relevant_event_count, 1)
|
||||
|
||||
renumbered_retry = copy.deepcopy(first)
|
||||
renumbered_retry["sequence"] = 2
|
||||
wrong_sequence = LedgerProjector()
|
||||
wrong_sequence.process(first)
|
||||
with self.assertRaisesRegex(
|
||||
ConflictingDuplicateError,
|
||||
"first observed at sequence 1, not 2",
|
||||
):
|
||||
wrong_sequence.process(renumbered_retry)
|
||||
|
||||
changed_timestamp = copy.deepcopy(first)
|
||||
changed_timestamp["timestamp"] = "2024-01-03T09:30:01+08:00"
|
||||
wrong_timestamp = LedgerProjector()
|
||||
wrong_timestamp.process(first)
|
||||
with self.assertRaises(ConflictingDuplicateError):
|
||||
wrong_timestamp.process(changed_timestamp)
|
||||
|
||||
jumped_retry = copy.deepcopy(first)
|
||||
jumped_retry["sequence"] = 100
|
||||
poisoned = LedgerProjector()
|
||||
poisoned.process(first)
|
||||
with self.assertRaisesRegex(OutOfOrderError, "sequence gap"):
|
||||
poisoned.process(jumped_retry)
|
||||
with self.assertRaisesRegex(
|
||||
LedgerReplayError,
|
||||
"fail-closed after a prior replay error",
|
||||
):
|
||||
poisoned.process(second)
|
||||
|
||||
late_retry = LedgerProjector()
|
||||
late_retry.process(first)
|
||||
late_retry.process(second)
|
||||
with self.assertRaises(OutOfOrderError):
|
||||
late_retry.process(exact_retry)
|
||||
|
||||
def test_replay_rejects_conflicting_duplicates(self):
|
||||
ledger = golden_ledger()
|
||||
records = [record.as_dict() for record in ledger.records]
|
||||
conflict = copy.deepcopy(records[6])
|
||||
conflict["sequence"] = 9
|
||||
conflict["event_id"] = "fill:F3:conflicting-delivery"
|
||||
conflict["payload"]["price"] = "13"
|
||||
with self.assertRaises(ConflictingDuplicateError):
|
||||
project_ledger(records + [conflict])
|
||||
|
||||
event_conflict = copy.deepcopy(records[0])
|
||||
event_conflict["payload"]["quantity"] = 300
|
||||
with self.assertRaises(ConflictingDuplicateError):
|
||||
project_ledger([records[0], event_conflict])
|
||||
|
||||
def test_replay_rejects_unknown_order_overfill_and_out_of_order(self):
|
||||
unknown_fill = {
|
||||
"sequence": 1,
|
||||
"event_id": "fill:unknown",
|
||||
"event_type": "FILL",
|
||||
"timestamp": "2024-01-03T09:31:00+08:00",
|
||||
"payload": fill_payload("FU", "MISSING", "BUY", 100, "10"),
|
||||
}
|
||||
with self.assertRaises(UnknownOrderError):
|
||||
project_ledger([unknown_fill])
|
||||
|
||||
accepted = {
|
||||
"sequence": 1,
|
||||
"event_id": "order:small",
|
||||
"event_type": "ORDER_STATUS",
|
||||
"timestamp": "2024-01-03T09:30:00+08:00",
|
||||
"payload": order_payload("SMALL", "BUY", 100, "ACCEPTED"),
|
||||
}
|
||||
overfill = {
|
||||
"sequence": 2,
|
||||
"event_id": "fill:too-much",
|
||||
"event_type": "FILL",
|
||||
"timestamp": "2024-01-03T09:31:00+08:00",
|
||||
"payload": fill_payload("F-OVER", "SMALL", "BUY", 200, "10"),
|
||||
}
|
||||
with self.assertRaises(OverfillError):
|
||||
project_ledger([accepted, overfill])
|
||||
projector = LedgerProjector()
|
||||
projector.process(accepted)
|
||||
with self.assertRaises(OverfillError):
|
||||
projector.process(overfill)
|
||||
with self.assertRaises(LedgerReplayError):
|
||||
projector.projection()
|
||||
with self.assertRaises(LedgerReplayError):
|
||||
projector.process(
|
||||
{
|
||||
**accepted,
|
||||
"sequence": 3,
|
||||
"event_id": "order:after-failure",
|
||||
}
|
||||
)
|
||||
|
||||
second = copy.deepcopy(accepted)
|
||||
second["sequence"] = 2
|
||||
second["event_id"] = "order:second"
|
||||
first_late = copy.deepcopy(accepted)
|
||||
first_late["event_id"] = "order:first-late"
|
||||
with self.assertRaises(OutOfOrderError):
|
||||
project_ledger([second, first_late])
|
||||
|
||||
regression = copy.deepcopy(accepted)
|
||||
regression["sequence"] = 2
|
||||
regression["event_id"] = "order:regression"
|
||||
regression["payload"]["status"] = "NEW"
|
||||
with self.assertRaises(OutOfOrderError):
|
||||
project_ledger([accepted, regression])
|
||||
|
||||
time_regression = copy.deepcopy(accepted)
|
||||
time_regression["sequence"] = 2
|
||||
time_regression["event_id"] = "order:time-regression"
|
||||
time_regression["timestamp"] = "2024-01-03T09:29:59+08:00"
|
||||
time_regression["payload"]["status"] = "UNKNOWN"
|
||||
with self.assertRaises(OutOfOrderError):
|
||||
project_ledger([accepted, time_regression])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,35 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from tools.export_mock_parity import verify_report, write_report
|
||||
|
||||
|
||||
class MockParityReportTests(unittest.TestCase):
|
||||
def test_report_is_deterministic_and_explicitly_not_platform_evidence(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
first = Path(directory) / "first.json"
|
||||
second = Path(directory) / "second.json"
|
||||
report = write_report(first)
|
||||
write_report(second)
|
||||
self.assertEqual(first.read_bytes(), second.read_bytes())
|
||||
self.assertTrue(report["comparisons"]["whole_plan_exact"])
|
||||
self.assertEqual(report["evidence_class"], "mock_contract_only")
|
||||
self.assertFalse(report["real_platform_pass"])
|
||||
self.assertEqual(report["gate_credit"], [])
|
||||
self.assertTrue(verify_report(first)["ok"])
|
||||
|
||||
def test_tampering_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "report.json"
|
||||
write_report(path)
|
||||
report = json.loads(path.read_text(encoding="utf-8"))
|
||||
report["comparisons"]["whole_plan_exact"] = False
|
||||
path.write_text(json.dumps(report), encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "hash mismatch"):
|
||||
verify_report(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,192 @@
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from quant60.pit import (
|
||||
PITIntegrityError,
|
||||
PITRecord,
|
||||
PointInTimeTable,
|
||||
assert_information_available,
|
||||
)
|
||||
from quant60.universe import (
|
||||
SecurityEligibility,
|
||||
UniverseState,
|
||||
build_universe,
|
||||
classify_security,
|
||||
)
|
||||
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
def record(
|
||||
*,
|
||||
value,
|
||||
effective="2024-03-31T00:00:00+08:00",
|
||||
available="2024-04-30T18:00:00+08:00",
|
||||
revision=0,
|
||||
):
|
||||
return PITRecord(
|
||||
entity_id="600000.XSHG",
|
||||
field="net_profit",
|
||||
effective_time=effective,
|
||||
available_time=available,
|
||||
value=value,
|
||||
source="fixture",
|
||||
source_version="v1",
|
||||
revision=revision,
|
||||
)
|
||||
|
||||
|
||||
class PITUniverseTests(unittest.TestCase):
|
||||
def test_pit_query_never_uses_future_announcement(self):
|
||||
table = PointInTimeTable([record(value=100)])
|
||||
before = table.query(
|
||||
"600000.XSHG",
|
||||
"net_profit",
|
||||
as_of="2024-04-30T17:59:59+08:00",
|
||||
)
|
||||
after = table.query(
|
||||
"600000.XSHG",
|
||||
"net_profit",
|
||||
as_of="2024-04-30T18:00:00+08:00",
|
||||
)
|
||||
self.assertIsNone(before)
|
||||
self.assertEqual(after.value, 100)
|
||||
|
||||
def test_revision_is_visible_only_after_its_own_available_time(self):
|
||||
table = PointInTimeTable(
|
||||
[
|
||||
record(value=100),
|
||||
record(
|
||||
value=90,
|
||||
available="2024-05-05T09:00:00+08:00",
|
||||
revision=1,
|
||||
),
|
||||
]
|
||||
)
|
||||
first = table.query(
|
||||
"600000.XSHG",
|
||||
"net_profit",
|
||||
as_of="2024-05-01T15:00:00+08:00",
|
||||
)
|
||||
revised = table.query(
|
||||
"600000.XSHG",
|
||||
"net_profit",
|
||||
as_of="2024-05-06T15:00:00+08:00",
|
||||
)
|
||||
self.assertEqual(first.value, 100)
|
||||
self.assertEqual(revised.value, 90)
|
||||
|
||||
def test_future_injection_gate_fails_closed(self):
|
||||
future = record(
|
||||
value=100,
|
||||
available="2024-05-02T09:00:00+08:00",
|
||||
)
|
||||
with self.assertRaisesRegex(PITIntegrityError, "future-available"):
|
||||
assert_information_available(
|
||||
[future],
|
||||
as_of="2024-05-01T15:00:00+08:00",
|
||||
)
|
||||
|
||||
def test_naive_timestamps_and_duplicate_identity_are_rejected(self):
|
||||
with self.assertRaisesRegex(PITIntegrityError, "timezone"):
|
||||
record(
|
||||
value=100,
|
||||
effective=datetime(2024, 3, 31),
|
||||
)
|
||||
item = record(value=100)
|
||||
with self.assertRaisesRegex(PITIntegrityError, "duplicate"):
|
||||
PointInTimeTable([item, item])
|
||||
|
||||
def test_data_version_is_order_independent(self):
|
||||
left = record(value=100)
|
||||
right = PITRecord(
|
||||
entity_id="000001.XSHE",
|
||||
field="net_profit",
|
||||
effective_time=datetime(2024, 3, 31, tzinfo=UTC),
|
||||
available_time=datetime(2024, 4, 30, tzinfo=UTC),
|
||||
value=80,
|
||||
source="fixture",
|
||||
source_version="v1",
|
||||
)
|
||||
self.assertEqual(
|
||||
PointInTimeTable([left, right]).data_version,
|
||||
PointInTimeTable([right, left]).data_version,
|
||||
)
|
||||
|
||||
def test_universe_states_keep_untradable_holdings_visible(self):
|
||||
frozen = classify_security(
|
||||
SecurityEligibility(
|
||||
symbol="600000.SH",
|
||||
index_member=False,
|
||||
listing_sessions=500,
|
||||
median_turnover=1_000_000,
|
||||
minimum_turnover=100_000,
|
||||
suspended=True,
|
||||
held_quantity=1000,
|
||||
sellable_quantity=1000,
|
||||
)
|
||||
)
|
||||
sell_only = classify_security(
|
||||
SecurityEligibility(
|
||||
symbol="000001.SZ",
|
||||
index_member=True,
|
||||
listing_sessions=500,
|
||||
median_turnover=1_000_000,
|
||||
minimum_turnover=100_000,
|
||||
is_st=True,
|
||||
held_quantity=1000,
|
||||
sellable_quantity=1000,
|
||||
)
|
||||
)
|
||||
self.assertEqual(frozen.state, UniverseState.FROZEN)
|
||||
self.assertTrue(frozen.can_hold)
|
||||
self.assertFalse(frozen.can_sell)
|
||||
self.assertEqual(sell_only.state, UniverseState.SELL_ONLY)
|
||||
self.assertTrue(sell_only.can_sell)
|
||||
hold_only = classify_security(
|
||||
SecurityEligibility(
|
||||
symbol="000333.SZ",
|
||||
index_member=True,
|
||||
listing_sessions=500,
|
||||
median_turnover=50_000,
|
||||
minimum_turnover=100_000,
|
||||
held_quantity=1000,
|
||||
sellable_quantity=1000,
|
||||
)
|
||||
)
|
||||
self.assertEqual(hold_only.state, UniverseState.HOLD_ONLY)
|
||||
self.assertTrue(hold_only.can_hold)
|
||||
self.assertFalse(hold_only.can_open)
|
||||
|
||||
def test_openable_and_excluded_are_distinct(self):
|
||||
decisions = build_universe(
|
||||
[
|
||||
SecurityEligibility(
|
||||
symbol="600000.SH",
|
||||
index_member=True,
|
||||
listing_sessions=500,
|
||||
median_turnover=1_000_000,
|
||||
minimum_turnover=100_000,
|
||||
),
|
||||
SecurityEligibility(
|
||||
symbol="300750.SZ",
|
||||
index_member=True,
|
||||
listing_sessions=20,
|
||||
median_turnover=1_000_000,
|
||||
minimum_turnover=100_000,
|
||||
),
|
||||
]
|
||||
)
|
||||
self.assertEqual(
|
||||
decisions["600000.XSHG"].state,
|
||||
UniverseState.OPENABLE,
|
||||
)
|
||||
self.assertEqual(
|
||||
decisions["300750.XSHE"].state,
|
||||
UniverseState.EXCLUDED,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,171 @@
|
||||
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()
|
||||
@@ -0,0 +1,49 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class ProjectScaffoldTests(unittest.TestCase):
|
||||
def test_colab_notebook_is_valid_plain_python(self):
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"tools/run_colab_notebook.py",
|
||||
"notebooks/quant_os_colab.ipynb",
|
||||
],
|
||||
cwd=PROJECT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
||||
self.assertTrue(payload["ok"])
|
||||
self.assertGreaterEqual(payload["code_cells"], 5)
|
||||
self.assertFalse(payload["executed"])
|
||||
|
||||
def test_secret_files_are_ignored(self):
|
||||
ignore = (PROJECT / ".gitignore").read_text(encoding="utf-8")
|
||||
for entry in (".env", "secrets/", "credentials/", "userdata_mini/"):
|
||||
self.assertIn(entry, ignore)
|
||||
|
||||
def test_claude_project_contract_exists(self):
|
||||
content = (PROJECT / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
self.assertIn("executable source of truth for Quant OS", content)
|
||||
self.assertIn("make local", content)
|
||||
|
||||
def test_gitea_workflow_is_at_monorepo_root(self):
|
||||
workflow = PROJECT.parent / ".gitea/workflows/quant-os-ci.yml"
|
||||
self.assertTrue(workflow.is_file())
|
||||
content = workflow.read_text(encoding="utf-8")
|
||||
self.assertIn("working-directory: quant-os", content)
|
||||
self.assertIn("bash scripts/validate_all.sh", content)
|
||||
self.assertFalse((PROJECT / ".gitea/workflows/ci.yml").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stderr
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
|
||||
from tools.qmt_shadow_plan import (
|
||||
_load_existing_account_hash_key,
|
||||
_load_or_create_account_hash_key,
|
||||
build_parser,
|
||||
)
|
||||
|
||||
|
||||
class QmtShadowToolTests(unittest.TestCase):
|
||||
def test_account_hash_key_is_created_once_with_private_permissions(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "private" / "account-hash.key"
|
||||
first = _load_or_create_account_hash_key(path)
|
||||
second = _load_or_create_account_hash_key(path)
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(len(first), 32)
|
||||
if os.name != "nt":
|
||||
self.assertEqual(
|
||||
stat.S_IMODE(path.stat().st_mode),
|
||||
0o600,
|
||||
)
|
||||
|
||||
def test_short_account_hash_key_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "account-hash.key"
|
||||
path.write_bytes(b"too-short")
|
||||
path.chmod(0o600)
|
||||
with self.assertRaisesRegex(ValueError, "at least 32 bytes"):
|
||||
_load_or_create_account_hash_key(path)
|
||||
|
||||
def test_verify_key_loader_never_creates_missing_key(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "missing-account-hash.key"
|
||||
with self.assertRaisesRegex(
|
||||
ValueError, "does not exist"
|
||||
):
|
||||
_load_existing_account_hash_key(path)
|
||||
self.assertFalse(path.exists())
|
||||
|
||||
def test_existing_parent_permissions_are_not_changed(self):
|
||||
if os.name == "nt":
|
||||
self.skipTest("POSIX permission invariant")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
parent = Path(directory) / "shared-parent"
|
||||
parent.mkdir(mode=0o755)
|
||||
parent.chmod(0o755)
|
||||
_load_or_create_account_hash_key(parent / "account-hash.key")
|
||||
self.assertEqual(
|
||||
stat.S_IMODE(parent.stat().st_mode),
|
||||
0o755,
|
||||
)
|
||||
|
||||
def test_cli_rejects_widened_safety_thresholds(self):
|
||||
base = [
|
||||
"plan",
|
||||
"--decision",
|
||||
"decision-manifest.json",
|
||||
"--output",
|
||||
"shadow-output",
|
||||
]
|
||||
for option, value in (
|
||||
("--max-query-duration-seconds", "10.0001"),
|
||||
("--market-value-tolerance", "1.0001"),
|
||||
):
|
||||
with self.subTest(option=option), redirect_stderr(StringIO()):
|
||||
with self.assertRaises(SystemExit):
|
||||
build_parser().parse_args(base + [option, value])
|
||||
|
||||
def test_cli_accepts_only_equal_or_stricter_thresholds(self):
|
||||
parsed = build_parser().parse_args(
|
||||
[
|
||||
"plan",
|
||||
"--decision",
|
||||
"decision-manifest.json",
|
||||
"--output",
|
||||
"shadow-output",
|
||||
"--max-query-duration-seconds",
|
||||
"5",
|
||||
"--market-value-tolerance",
|
||||
"0.5",
|
||||
]
|
||||
)
|
||||
self.assertEqual(parsed.max_query_duration_seconds, 5.0)
|
||||
self.assertEqual(parsed.market_value_tolerance, 0.5)
|
||||
|
||||
def test_cli_verify_requires_original_decision(self):
|
||||
with redirect_stderr(StringIO()):
|
||||
with self.assertRaises(SystemExit):
|
||||
build_parser().parse_args(
|
||||
["verify", "--manifest", "shadow-manifest.json"]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,99 @@
|
||||
import math
|
||||
import unittest
|
||||
|
||||
from quant60.research import (
|
||||
DeterministicRidge,
|
||||
NumpyRidge,
|
||||
pearson_correlation,
|
||||
purged_walk_forward,
|
||||
spearman_correlation,
|
||||
)
|
||||
from quant60.risk import check_target_weights, diagonal_variance
|
||||
|
||||
|
||||
class ResearchRiskTests(unittest.TestCase):
|
||||
def test_purged_walk_forward_is_time_ordered(self):
|
||||
folds = list(
|
||||
purged_walk_forward(
|
||||
40,
|
||||
train_size=20,
|
||||
test_size=5,
|
||||
purge=2,
|
||||
embargo=1,
|
||||
)
|
||||
)
|
||||
self.assertTrue(folds)
|
||||
for fold in folds:
|
||||
self.assertLess(max(fold.train), min(fold.purged))
|
||||
self.assertLess(max(fold.purged), min(fold.test))
|
||||
self.assertTrue(set(fold.train).isdisjoint(fold.test))
|
||||
|
||||
def test_risk_check_rejects_concentration(self):
|
||||
result = check_target_weights(
|
||||
{"600000.SH": 0.6, "000001.SZ": 0.2},
|
||||
max_single_weight=0.5,
|
||||
max_gross_weight=0.9,
|
||||
)
|
||||
self.assertFalse(result.approved)
|
||||
self.assertIn("SINGLE_NAME_CAP", {item.code for item in result.violations})
|
||||
|
||||
def test_diagonal_variance_floor(self):
|
||||
result = diagonal_variance(
|
||||
{"600000.SH": [0.01], "000001.SZ": [0.0, 0.0, 0.0]}
|
||||
)
|
||||
self.assertGreater(result["600000.XSHG"], 0)
|
||||
self.assertGreater(result["000001.XSHE"], 0)
|
||||
|
||||
def test_non_finite_weights_fail_closed(self):
|
||||
for value in (float("nan"), float("inf"), float("-inf")):
|
||||
with self.subTest(value=value):
|
||||
result = check_target_weights(
|
||||
{"600000.SH": value},
|
||||
max_single_weight=0.5,
|
||||
max_gross_weight=0.9,
|
||||
)
|
||||
self.assertFalse(result.approved)
|
||||
self.assertIn(
|
||||
"NON_FINITE_WEIGHT",
|
||||
{item.code for item in result.violations},
|
||||
)
|
||||
self.assertTrue(math.isinf(result.gross_weight))
|
||||
self.assertTrue(math.isinf(result.one_way_turnover))
|
||||
|
||||
def test_non_finite_returns_are_rejected(self):
|
||||
for value in (float("nan"), float("inf"), float("-inf")):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaisesRegex(ValueError, "returns must be finite"):
|
||||
diagonal_variance({"600000.SH": [0.01, value]})
|
||||
|
||||
def test_optional_numpy_ridge_when_numpy_is_present(self):
|
||||
try:
|
||||
model = NumpyRidge(alpha=0.1).fit([[0], [1], [2]], [1, 3, 5])
|
||||
except Exception as exc:
|
||||
if type(exc).__name__ == "ResearchDependencyUnavailable":
|
||||
self.skipTest(str(exc))
|
||||
raise
|
||||
prediction = model.predict([[3]])
|
||||
self.assertGreater(float(prediction[0]), 6.0)
|
||||
|
||||
def test_dependency_free_ridge_fits_linear_relationship(self):
|
||||
model = DeterministicRidge(alpha=0.001).fit(
|
||||
[[0], [1], [2], [3]],
|
||||
[1, 3, 5, 7],
|
||||
)
|
||||
prediction = model.predict([[4]])[0]
|
||||
self.assertAlmostEqual(prediction, 9, places=2)
|
||||
|
||||
def test_rank_and_linear_correlation(self):
|
||||
self.assertAlmostEqual(
|
||||
pearson_correlation([1, 2, 3], [2, 4, 6]),
|
||||
1.0,
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
spearman_correlation([10, 30, 20], [1, 3, 2]),
|
||||
1.0,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,544 @@
|
||||
import hashlib
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import quant60.snapshot_backtest as snapshot_backtest_module
|
||||
from quant60.config import BacktestConfig, StrategyConfig
|
||||
from quant60.backtest import verify_artifact_manifest
|
||||
from quant60.data_snapshot import (
|
||||
CanonicalBarRecord,
|
||||
IndexMembershipRecord,
|
||||
build_snapshot_payload,
|
||||
write_data_snapshot,
|
||||
)
|
||||
from quant60.snapshot_pipeline import (
|
||||
SnapshotDecisionError,
|
||||
build_snapshot_decision,
|
||||
first_executable_window,
|
||||
verify_snapshot_decision,
|
||||
write_snapshot_decision,
|
||||
)
|
||||
from quant60.snapshot_backtest import run_snapshot_backtest
|
||||
|
||||
|
||||
def business_days(start: date, count: int) -> list[date]:
|
||||
values = []
|
||||
current = start
|
||||
while len(values) < count:
|
||||
if current.weekday() < 5:
|
||||
values.append(current)
|
||||
current += timedelta(days=1)
|
||||
return values
|
||||
|
||||
|
||||
def build_provider_snapshot(
|
||||
root: Path,
|
||||
*,
|
||||
st_symbol_on_last_session: str | None = None,
|
||||
) -> tuple[str, date]:
|
||||
frozen_calendar = business_days(date(2024, 1, 2), 31)
|
||||
sessions = frozen_calendar[:-1]
|
||||
next_trading_session = frozen_calendar[-1]
|
||||
bars = []
|
||||
memberships = []
|
||||
symbols = ("600000.XSHG", "000001.XSHE")
|
||||
for index, session in enumerate(sessions):
|
||||
for symbol in symbols:
|
||||
if symbol == "600000.XSHG":
|
||||
raw = 10.0 * (1.005**index)
|
||||
factor = 1.0
|
||||
else:
|
||||
unsplit = 20.0 * (1.01**index)
|
||||
raw = unsplit if index < 15 else unsplit / 2.0
|
||||
factor = 1.0 if index < 15 else 2.0
|
||||
bars.append(
|
||||
CanonicalBarRecord(
|
||||
trading_date=session,
|
||||
symbol=symbol,
|
||||
open=raw * 0.999,
|
||||
high=raw * 1.01,
|
||||
low=raw * 0.99,
|
||||
close=raw,
|
||||
volume=1_000_000,
|
||||
money=raw * 1_000_000,
|
||||
paused=False,
|
||||
source="fixture-provider",
|
||||
is_st=(
|
||||
symbol == st_symbol_on_last_session
|
||||
and session == sessions[-1]
|
||||
),
|
||||
adjustment="none",
|
||||
adjustment_factor=factor,
|
||||
previous_close=raw / 1.005,
|
||||
limit_up=raw * 1.10,
|
||||
limit_down=raw * 0.90,
|
||||
)
|
||||
)
|
||||
memberships.append(
|
||||
IndexMembershipRecord(
|
||||
effective_date=session,
|
||||
index_symbol="000905.XSHG",
|
||||
member_symbol=symbol,
|
||||
source="fixture-provider",
|
||||
)
|
||||
)
|
||||
payload = build_snapshot_payload(
|
||||
bars=bars,
|
||||
memberships=memberships,
|
||||
provider="fixture-provider",
|
||||
provider_version="1",
|
||||
retrieved_at=datetime(2024, 3, 1, tzinfo=timezone.utc),
|
||||
next_trading_session=next_trading_session,
|
||||
query={
|
||||
"index_symbol": "000905.XSHG",
|
||||
"start_date": sessions[0].isoformat(),
|
||||
"end_date": sessions[-1].isoformat(),
|
||||
"trading_sessions": [
|
||||
item.isoformat() for item in frozen_calendar
|
||||
],
|
||||
"adjustment": "none",
|
||||
},
|
||||
)
|
||||
paths = write_data_snapshot(payload, root)
|
||||
return paths["manifest"], sessions[-1]
|
||||
|
||||
|
||||
class SnapshotPipelineTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _broker_snapshot(
|
||||
as_of: date,
|
||||
*,
|
||||
observed_at: datetime | None = None,
|
||||
next_session: date = date(2024, 2, 13),
|
||||
) -> dict:
|
||||
signal_timestamp = f"{as_of.isoformat()}T15:00:00+08:00"
|
||||
executable_window = first_executable_window(next_session)
|
||||
observation_timestamp = (
|
||||
observed_at.isoformat()
|
||||
if observed_at is not None
|
||||
else executable_window["start"]
|
||||
)
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"snapshot_id": "broker-fixture",
|
||||
"signal_as_of": signal_timestamp,
|
||||
"next_trading_session": next_session.isoformat(),
|
||||
"first_executable_window": executable_window,
|
||||
"observation_as_of": observation_timestamp,
|
||||
"as_of": observation_timestamp,
|
||||
"source_time": observation_timestamp,
|
||||
"broker": "fixture",
|
||||
"account_hash": "fixture-account-hash",
|
||||
"cash": 1_000_000,
|
||||
"total_asset": 1_000_000,
|
||||
"market_value": 0,
|
||||
"positions": [],
|
||||
"open_orders": [],
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
def test_next_day_broker_observation_binds_to_prior_close_signal(self):
|
||||
config = BacktestConfig(
|
||||
symbols=("600000.XSHG", "000001.XSHE"),
|
||||
strategy=StrategyConfig(lookback=5, top_n=1),
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
snapshot_manifest, as_of = build_provider_snapshot(
|
||||
root / "snapshot"
|
||||
)
|
||||
observed_at = datetime(
|
||||
as_of.year,
|
||||
as_of.month,
|
||||
as_of.day,
|
||||
9,
|
||||
0,
|
||||
tzinfo=timezone(timedelta(hours=8)),
|
||||
) + timedelta(days=1)
|
||||
broker = self._broker_snapshot(
|
||||
as_of,
|
||||
observed_at=observed_at,
|
||||
)
|
||||
result = build_snapshot_decision(
|
||||
snapshot_manifest=snapshot_manifest,
|
||||
config=config,
|
||||
as_of=as_of,
|
||||
broker_snapshot=broker,
|
||||
)
|
||||
self.assertEqual(
|
||||
result["decision"]["signal_as_of"],
|
||||
f"{as_of.isoformat()}T15:00:00+08:00",
|
||||
)
|
||||
self.assertEqual(
|
||||
result["decision"]["broker_snapshot"][
|
||||
"observation_as_of"
|
||||
],
|
||||
observed_at.isoformat(),
|
||||
)
|
||||
paths = write_snapshot_decision(
|
||||
result,
|
||||
root / "decision",
|
||||
)
|
||||
verified = verify_snapshot_decision(paths["manifest"])
|
||||
self.assertEqual(
|
||||
verified["signal_as_of"],
|
||||
f"{as_of.isoformat()}T15:00:00+08:00",
|
||||
)
|
||||
|
||||
def test_verified_snapshot_builds_deterministic_portable_target(self):
|
||||
config = BacktestConfig(
|
||||
symbols=("600000.XSHG", "000001.XSHE"),
|
||||
strategy=StrategyConfig(
|
||||
lookback=5,
|
||||
top_n=1,
|
||||
max_weight=0.50,
|
||||
gross_target=0.50,
|
||||
cash_buffer=0.10,
|
||||
),
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
snapshot_manifest, as_of = build_provider_snapshot(
|
||||
root / "snapshot"
|
||||
)
|
||||
first = build_snapshot_decision(
|
||||
snapshot_manifest=snapshot_manifest,
|
||||
config=config,
|
||||
as_of=as_of,
|
||||
equity=1_000_000,
|
||||
)
|
||||
second = build_snapshot_decision(
|
||||
snapshot_manifest=snapshot_manifest,
|
||||
config=config,
|
||||
as_of=as_of,
|
||||
equity=1_000_000,
|
||||
)
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(len(first["signals"]), 2)
|
||||
self.assertGreater(
|
||||
next(
|
||||
row["score"]
|
||||
for row in first["signals"]
|
||||
if row["symbol"] == "000001.XSHE"
|
||||
),
|
||||
next(
|
||||
row["score"]
|
||||
for row in first["signals"]
|
||||
if row["symbol"] == "600000.XSHG"
|
||||
),
|
||||
)
|
||||
selected = [
|
||||
row
|
||||
for row in first["targets"]
|
||||
if row["target_weight"] > 0
|
||||
]
|
||||
self.assertEqual(
|
||||
[row["symbol"] for row in selected],
|
||||
["000001.XSHE"],
|
||||
)
|
||||
paths = write_snapshot_decision(first, root / "decision")
|
||||
verified = verify_snapshot_decision(paths["manifest"])
|
||||
self.assertTrue(verified["ok"])
|
||||
self.assertEqual(verified["signal_count"], 2)
|
||||
self.assertEqual(verified["target_count"], 2)
|
||||
|
||||
def test_pit_st_member_is_excluded_before_portable_ranking(self):
|
||||
config = BacktestConfig(
|
||||
symbols=("600000.XSHG", "000001.XSHE"),
|
||||
strategy=StrategyConfig(
|
||||
lookback=5,
|
||||
top_n=1,
|
||||
max_weight=0.50,
|
||||
gross_target=0.50,
|
||||
cash_buffer=0.10,
|
||||
),
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
snapshot_manifest, as_of = build_provider_snapshot(
|
||||
Path(directory) / "snapshot",
|
||||
st_symbol_on_last_session="000001.XSHE",
|
||||
)
|
||||
result = build_snapshot_decision(
|
||||
snapshot_manifest=snapshot_manifest,
|
||||
config=config,
|
||||
as_of=as_of,
|
||||
equity=1_000_000,
|
||||
)
|
||||
self.assertEqual(
|
||||
result["decision"]["universe"]["000001.XSHE"]["state"],
|
||||
"excluded",
|
||||
)
|
||||
self.assertIn(
|
||||
"ST_EXCLUDED",
|
||||
result["decision"]["universe"]["000001.XSHE"]["reasons"],
|
||||
)
|
||||
self.assertNotIn(
|
||||
"000001.XSHE",
|
||||
result["decision"]["targets"],
|
||||
)
|
||||
|
||||
def test_missing_exact_membership_fails_closed(self):
|
||||
config = BacktestConfig(
|
||||
symbols=("600000.XSHG", "000001.XSHE"),
|
||||
strategy=StrategyConfig(lookback=5, top_n=1),
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
snapshot_manifest, as_of = build_provider_snapshot(
|
||||
Path(directory) / "snapshot"
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
SnapshotDecisionError,
|
||||
"no next session",
|
||||
):
|
||||
build_snapshot_decision(
|
||||
snapshot_manifest=snapshot_manifest,
|
||||
config=config,
|
||||
as_of=as_of + timedelta(days=1),
|
||||
equity=1_000_000,
|
||||
)
|
||||
|
||||
def test_provider_snapshot_runs_local_event_backtest(self):
|
||||
config = BacktestConfig(
|
||||
symbols=("600000.XSHG", "000001.XSHE"),
|
||||
strategy=StrategyConfig(
|
||||
lookback=5,
|
||||
top_n=1,
|
||||
max_weight=0.50,
|
||||
gross_target=0.50,
|
||||
cash_buffer=0.10,
|
||||
),
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
snapshot_manifest, unused_as_of = build_provider_snapshot(
|
||||
root / "snapshot"
|
||||
)
|
||||
del unused_as_of
|
||||
with patch.object(
|
||||
snapshot_backtest_module,
|
||||
"load_verified_data_snapshot",
|
||||
wraps=(
|
||||
snapshot_backtest_module.load_verified_data_snapshot
|
||||
),
|
||||
) as snapshot_loader:
|
||||
result = run_snapshot_backtest(
|
||||
snapshot_manifest=snapshot_manifest,
|
||||
config=config,
|
||||
)
|
||||
self.assertEqual(snapshot_loader.call_count, 1)
|
||||
self.assertGreater(result.report["decision_count"], 0)
|
||||
self.assertEqual(
|
||||
result.report["evidence_class"],
|
||||
"provider-snapshot-local-backtest",
|
||||
)
|
||||
self.assertFalse(result.report["real_platform_backtest"])
|
||||
self.assertFalse(result.report["investment_value_claim"])
|
||||
self.assertGreater(len(result.signals), 0)
|
||||
paths = result.write_artifacts(root / "backtest")
|
||||
self.assertTrue(
|
||||
verify_artifact_manifest(paths["manifest"])["ok"]
|
||||
)
|
||||
|
||||
def test_decision_tampering_is_rejected(self):
|
||||
config = BacktestConfig(
|
||||
symbols=("600000.XSHG", "000001.XSHE"),
|
||||
strategy=StrategyConfig(lookback=5, top_n=1),
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
snapshot_manifest, as_of = build_provider_snapshot(
|
||||
root / "snapshot"
|
||||
)
|
||||
result = build_snapshot_decision(
|
||||
snapshot_manifest=snapshot_manifest,
|
||||
config=config,
|
||||
as_of=as_of,
|
||||
equity=1_000_000,
|
||||
)
|
||||
paths = write_snapshot_decision(result, root / "decision")
|
||||
decision_path = Path(paths["decision"])
|
||||
decision = json.loads(decision_path.read_text(encoding="utf-8"))
|
||||
decision["orders"] = {}
|
||||
decision_path.write_text(json.dumps(decision), encoding="utf-8")
|
||||
with self.assertRaisesRegex(
|
||||
SnapshotDecisionError,
|
||||
"artifact hash mismatch",
|
||||
):
|
||||
verify_snapshot_decision(paths["manifest"])
|
||||
|
||||
def test_copied_input_calendar_cannot_be_rebound_by_artifact_hash(self):
|
||||
config = BacktestConfig(
|
||||
symbols=("600000.XSHG", "000001.XSHE"),
|
||||
strategy=StrategyConfig(lookback=5, top_n=1),
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
snapshot_manifest, as_of = build_provider_snapshot(
|
||||
root / "snapshot"
|
||||
)
|
||||
result = build_snapshot_decision(
|
||||
snapshot_manifest=snapshot_manifest,
|
||||
config=config,
|
||||
as_of=as_of,
|
||||
equity=1_000_000,
|
||||
)
|
||||
paths = write_snapshot_decision(result, root / "decision")
|
||||
input_path = Path(paths["input_data_manifest"])
|
||||
copied = json.loads(input_path.read_text(encoding="utf-8"))
|
||||
copied["query"]["trading_sessions"][-1] = "2024-02-14"
|
||||
input_path.write_text(
|
||||
json.dumps(
|
||||
copied,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
allow_nan=False,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
manifest_path = Path(paths["manifest"])
|
||||
manifest = json.loads(
|
||||
manifest_path.read_text(encoding="utf-8")
|
||||
)
|
||||
manifest["artifact_sha256"][
|
||||
"input_data_manifest.json"
|
||||
] = hashlib.sha256(input_path.read_bytes()).hexdigest()
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
manifest,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
allow_nan=False,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
SnapshotDecisionError,
|
||||
"calendar manifest hash binding mismatch",
|
||||
):
|
||||
verify_snapshot_decision(manifest_path)
|
||||
|
||||
def test_wrong_window_stale_source_or_duplicate_broker_facts_fail_closed(self):
|
||||
config = BacktestConfig(
|
||||
symbols=("600000.XSHG", "000001.XSHE"),
|
||||
strategy=StrategyConfig(lookback=5, top_n=1),
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
snapshot_manifest, as_of = build_provider_snapshot(
|
||||
Path(directory) / "snapshot"
|
||||
)
|
||||
observed_at = datetime(
|
||||
as_of.year,
|
||||
as_of.month,
|
||||
as_of.day,
|
||||
9,
|
||||
2,
|
||||
tzinfo=timezone(timedelta(hours=8)),
|
||||
) + timedelta(days=1)
|
||||
for source_time, message in (
|
||||
(
|
||||
(observed_at - timedelta(seconds=61)).isoformat(),
|
||||
"more than 60s stale",
|
||||
),
|
||||
(
|
||||
(observed_at + timedelta(seconds=6)).isoformat(),
|
||||
"more than 5s after",
|
||||
),
|
||||
):
|
||||
with self.subTest(source_time=source_time):
|
||||
broker = self._broker_snapshot(
|
||||
as_of,
|
||||
observed_at=observed_at,
|
||||
)
|
||||
broker["source_time"] = source_time
|
||||
with self.assertRaisesRegex(
|
||||
SnapshotDecisionError,
|
||||
message,
|
||||
):
|
||||
build_snapshot_decision(
|
||||
snapshot_manifest=snapshot_manifest,
|
||||
config=config,
|
||||
as_of=as_of,
|
||||
broker_snapshot=broker,
|
||||
)
|
||||
signal_time = datetime(
|
||||
as_of.year,
|
||||
as_of.month,
|
||||
as_of.day,
|
||||
15,
|
||||
0,
|
||||
tzinfo=timezone(timedelta(hours=8)),
|
||||
)
|
||||
for observed_at, message in (
|
||||
(
|
||||
signal_time + timedelta(minutes=1),
|
||||
"outside the unique next session",
|
||||
),
|
||||
(
|
||||
observed_at.replace(hour=9, minute=30),
|
||||
"outside the unique next session",
|
||||
),
|
||||
(
|
||||
observed_at + timedelta(days=3),
|
||||
"outside the unique next session",
|
||||
),
|
||||
):
|
||||
with self.subTest(observed_at=observed_at):
|
||||
broker = self._broker_snapshot(
|
||||
as_of,
|
||||
observed_at=observed_at,
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
SnapshotDecisionError,
|
||||
message,
|
||||
):
|
||||
build_snapshot_decision(
|
||||
snapshot_manifest=snapshot_manifest,
|
||||
config=config,
|
||||
as_of=as_of,
|
||||
broker_snapshot=broker,
|
||||
)
|
||||
for field in ("signal_as_of", "observation_as_of"):
|
||||
with self.subTest(missing=field):
|
||||
broker = self._broker_snapshot(as_of)
|
||||
del broker[field]
|
||||
with self.assertRaisesRegex(
|
||||
SnapshotDecisionError,
|
||||
rf"missing required fields.*{field}",
|
||||
):
|
||||
build_snapshot_decision(
|
||||
snapshot_manifest=snapshot_manifest,
|
||||
config=config,
|
||||
as_of=as_of,
|
||||
broker_snapshot=broker,
|
||||
)
|
||||
duplicate = self._broker_snapshot(as_of)
|
||||
position = {
|
||||
"symbol": "600000.XSHG",
|
||||
"quantity": 100,
|
||||
"sellable_quantity": 100,
|
||||
"average_cost": 10,
|
||||
"market_value": 1000,
|
||||
}
|
||||
duplicate["positions"] = [position, dict(position)]
|
||||
with self.assertRaisesRegex(
|
||||
SnapshotDecisionError,
|
||||
"duplicate position",
|
||||
):
|
||||
build_snapshot_decision(
|
||||
snapshot_manifest=snapshot_manifest,
|
||||
config=config,
|
||||
as_of=as_of,
|
||||
broker_snapshot=duplicate,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user