malkhut: asset-faithful book generation with composable toggles
Three independently toggleable features: 1. Asset-faithful depth/spread: levels sized by OB study power-law per asset 2. Intraday volume clock: depth scales by time-of-day (peak/trough) 3. Realistic spread: per-asset spread from OB study + Flight7 Composable via BookGenerationConfig toggles: use_asset_faithful_depth, use_asset_faithful_spread, use_intraday_clock, use_weekend_mode, use_stress_mode, use_fragility, worst_case_mode worst_case_mode overrides everything for max adversarial learning: spread * stress_mult, depth * fragility, no intraday/weekend. DuckDB registry for online updates: AssetRegistry: upsert/get/list/delete/query RuntimeProfileCache: hot-reload during CWM runs upsert_from_csv/export_csv: pipeline support upsert_all_from_asset_behaviors(): seed from OB study Results (8 assets): BTC: spread 0.031 bps, depth $350M (normal) / $4.9M (worst) DOGE: spread 2.86 bps, depth $2M (normal) / $132K (worst) ADA: spread 11.8 bps, depth $10M (normal) / $511K (worst) Intraday: BTC peak/trough = 2.8x depth ratio All 99 tests green (31 new + 68 existing).
This commit is contained in:
318
MALKHUT/malkhut/tests/test_asset_book_profile.py
Normal file
318
MALKHUT/malkhut/tests/test_asset_book_profile.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""Tests for asset-faithful book generation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import math
|
||||
import random
|
||||
import tempfile
|
||||
|
||||
from malkhut.training.asset_book_profile import (
|
||||
AssetBookProfile, BookGenerationConfig, BookGenerator,
|
||||
build_profile_from_behavior, _intraday_multiplier,
|
||||
)
|
||||
from malkhut.training.asset_registry import AssetRegistry, RuntimeProfileCache
|
||||
from malkhut.state import PriceLevel
|
||||
|
||||
|
||||
class TestIntradayMultiplier:
|
||||
def test_peak_is_max(self):
|
||||
m = _intraday_multiplier(15, 15, 19, 7.4)
|
||||
assert m == 7.4
|
||||
|
||||
def test_trough_is_min(self):
|
||||
m = _intraday_multiplier(19, 15, 19, 7.4)
|
||||
assert m == 1.0
|
||||
|
||||
def test_midpoint_between_peak_and_trough(self):
|
||||
m = _intraday_multiplier(17, 15, 19, 4.0)
|
||||
assert 1.0 < m < 4.0
|
||||
|
||||
def test_all_hours_bounded(self):
|
||||
for h in range(24):
|
||||
m = _intraday_multiplier(h, 15, 19, 7.4)
|
||||
assert 1.0 <= m <= 7.4, f"hour={h} mult={m}"
|
||||
|
||||
|
||||
class TestAssetBookProfile:
|
||||
def test_build_from_btc(self):
|
||||
p = build_profile_from_behavior("BTCUSDT")
|
||||
assert p.symbol == "BTCUSDT"
|
||||
assert p.depth_amplitude_usd == 750_000
|
||||
assert p.depth_alpha == 0.70
|
||||
assert p.depth_fragility == 0.10
|
||||
assert p.spread_normal_bps == 0.01
|
||||
assert p.spread_stress_mult == 50.0
|
||||
assert p.typical_num_levels > 0
|
||||
assert p.avg_level_size_usd > 0
|
||||
|
||||
def test_build_from_doge(self):
|
||||
p = build_profile_from_behavior("DOGEUSDT")
|
||||
assert p.symbol == "DOGEUSDT"
|
||||
assert p.depth_amplitude_usd == 22_000
|
||||
assert p.spread_normal_bps == 1.35
|
||||
assert p.depth_alpha == 1.00
|
||||
|
||||
def test_build_from_unknown_raises(self):
|
||||
try:
|
||||
build_profile_from_behavior("FAKEUSDT")
|
||||
assert False, "Should have raised ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def test_roundtrip_dict(self):
|
||||
p = build_profile_from_behavior("ETHUSDT")
|
||||
d = p.to_dict()
|
||||
p2 = AssetBookProfile.from_dict(d)
|
||||
assert p2.symbol == p.symbol
|
||||
assert p2.depth_amplitude_usd == p.depth_amplitude_usd
|
||||
assert p2.spread_normal_bps == p.spread_normal_bps
|
||||
|
||||
|
||||
class TestBookGenerationConfig:
|
||||
def test_defaults(self):
|
||||
c = BookGenerationConfig()
|
||||
assert c.use_asset_faithful_depth is True
|
||||
assert c.use_asset_faithful_spread is True
|
||||
assert c.use_intraday_clock is True
|
||||
assert c.use_weekend_mode is True
|
||||
assert c.worst_case_mode is False
|
||||
|
||||
def test_worst_case_overrides(self):
|
||||
c = BookGenerationConfig(worst_case_mode=True)
|
||||
assert c.worst_case_mode is True
|
||||
|
||||
def test_independent_toggles(self):
|
||||
c = BookGenerationConfig(
|
||||
use_asset_faithful_depth=True,
|
||||
use_intraday_clock=False,
|
||||
use_weekend_mode=False,
|
||||
use_stress_mode=True,
|
||||
)
|
||||
assert c.use_asset_faithful_depth is True
|
||||
assert c.use_intraday_clock is False
|
||||
assert c.use_weekend_mode is False
|
||||
assert c.use_stress_mode is True
|
||||
|
||||
|
||||
class TestBookGenerator:
|
||||
def test_generate_btc_book(self):
|
||||
p = build_profile_from_behavior("BTCUSDT")
|
||||
gen = BookGenerator(p, BookGenerationConfig())
|
||||
book = gen.generate_initial_book(64000.0, 0.1, ts_ns=1_000_000)
|
||||
assert len(book.bids) > 0
|
||||
assert len(book.asks) > 0
|
||||
assert book.bids[0].price < book.asks[0].price
|
||||
assert book.mid > 0
|
||||
|
||||
def test_generate_doge_book(self):
|
||||
p = build_profile_from_behavior("DOGEUSDT")
|
||||
gen = BookGenerator(p, BookGenerationConfig())
|
||||
book = gen.generate_initial_book(0.07, 0.00001, ts_ns=1_000_000)
|
||||
assert len(book.bids) > 0
|
||||
assert len(book.asks) > 0
|
||||
spread = book.asks[0].price - book.bids[0].price
|
||||
spread_bps = spread / book.mid * 10_000
|
||||
assert spread_bps > 0.5
|
||||
|
||||
def test_worst_case_wider_spread(self):
|
||||
p = build_profile_from_behavior("BTCUSDT")
|
||||
normal = BookGenerator(p, BookGenerationConfig())
|
||||
worst = BookGenerator(p, BookGenerationConfig(worst_case_mode=True))
|
||||
b1 = normal.generate_initial_book(64000.0, 0.1)
|
||||
b2 = worst.generate_initial_book(64000.0, 0.1)
|
||||
s1 = (b1.asks[0].price - b1.bids[0].price) / b1.mid * 10_000
|
||||
s2 = (b2.asks[0].price - b2.bids[0].price) / b2.mid * 10_000
|
||||
assert s2 >= s1 * 10
|
||||
|
||||
def test_worst_case_thinner_book(self):
|
||||
p = build_profile_from_behavior("BTCUSDT")
|
||||
normal = BookGenerator(p, BookGenerationConfig())
|
||||
worst = BookGenerator(p, BookGenerationConfig(worst_case_mode=True))
|
||||
b1 = normal.generate_initial_book(64000.0, 0.1)
|
||||
b2 = worst.generate_initial_book(64000.0, 0.1)
|
||||
assert b2.bids[0].qty < b1.bids[0].qty * 0.2
|
||||
|
||||
def test_refresh_preserves_structure(self):
|
||||
p = build_profile_from_behavior("BTCUSDT")
|
||||
gen = BookGenerator(p, BookGenerationConfig())
|
||||
book = gen.generate_initial_book(64000.0, 0.1)
|
||||
rng = random.Random(42)
|
||||
refreshed = gen.refresh_book(book, 0.1, rng)
|
||||
assert len(refreshed.bids) > 0
|
||||
assert len(refreshed.asks) > 0
|
||||
assert refreshed.bids[0].price < refreshed.asks[0].price
|
||||
|
||||
def test_refresh_multiple_steps(self):
|
||||
p = build_profile_from_behavior("ETHUSDT")
|
||||
gen = BookGenerator(p, BookGenerationConfig())
|
||||
book = gen.generate_initial_book(1800.0, 0.01)
|
||||
rng = random.Random(42)
|
||||
for _ in range(50):
|
||||
book = gen.refresh_book(book, 0.01, rng)
|
||||
assert len(book.bids) > 0
|
||||
assert book.mid > 0
|
||||
|
||||
def test_worst_case_refresh_even_thinner(self):
|
||||
p = build_profile_from_behavior("DOGEUSDT")
|
||||
normal = BookGenerator(p, BookGenerationConfig())
|
||||
worst = BookGenerator(p, BookGenerationConfig(worst_case_mode=True))
|
||||
b1 = normal.generate_initial_book(0.07, 0.00001)
|
||||
b2 = worst.generate_initial_book(0.07, 0.00001)
|
||||
rng1 = random.Random(42)
|
||||
rng2 = random.Random(42)
|
||||
for _ in range(10):
|
||||
b1 = normal.refresh_book(b1, 0.00001, rng1)
|
||||
b2 = worst.refresh_book(b2, 0.00001, rng2)
|
||||
avg_qty1 = sum(l.qty for l in b1.bids) / len(b1.bids)
|
||||
avg_qty2 = sum(l.qty for l in b2.bids) / len(b2.bids)
|
||||
assert avg_qty2 < avg_qty1 * 0.5
|
||||
|
||||
def test_no_cross_after_refresh(self):
|
||||
for sym in ["BTCUSDT", "DOGEUSDT", "SOLUSDT", "ADAUSDT"]:
|
||||
p = build_profile_from_behavior(sym)
|
||||
gen = BookGenerator(p, BookGenerationConfig())
|
||||
ref_p = p.reference_price if p.reference_price > 0 else 100.0
|
||||
book = gen.generate_initial_book(ref_p, ref_p * 0.0001)
|
||||
rng = random.Random(42)
|
||||
for _ in range(20):
|
||||
book = gen.refresh_book(book, ref_p * 0.0001, rng)
|
||||
assert book.bids[0].price < book.asks[0].price, f"{sym} crossed"
|
||||
|
||||
def test_different_assets_different_books(self):
|
||||
btc = BookGenerator(build_profile_from_behavior("BTCUSDT"), BookGenerationConfig())
|
||||
doge = BookGenerator(build_profile_from_behavior("DOGEUSDT"), BookGenerationConfig())
|
||||
b1 = btc.generate_initial_book(64000.0, 0.1)
|
||||
b2 = doge.generate_initial_book(0.07, 0.00001)
|
||||
s1 = (b1.asks[0].price - b1.bids[0].price) / b1.mid * 10_000
|
||||
s2 = (b2.asks[0].price - b2.bids[0].price) / b2.mid * 10_000
|
||||
assert s2 > s1 * 5
|
||||
|
||||
|
||||
class TestAssetRegistry:
|
||||
def test_upsert_and_get(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db = os.path.join(tmp, "test.db")
|
||||
reg = AssetRegistry(db)
|
||||
p = build_profile_from_behavior("BTCUSDT")
|
||||
reg.upsert_profile(p)
|
||||
got = reg.get_profile("BTCUSDT")
|
||||
assert got is not None
|
||||
assert got.symbol == "BTCUSDT"
|
||||
assert got.depth_amplitude_usd == 750_000
|
||||
reg.close()
|
||||
|
||||
def test_upsert_all_from_behaviors(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db = os.path.join(tmp, "test.db")
|
||||
reg = AssetRegistry(db)
|
||||
count = reg.upsert_all_from_asset_behaviors()
|
||||
assert count >= 8
|
||||
syms = reg.list_symbols()
|
||||
assert "BTCUSDT" in syms
|
||||
assert "ETHUSDT" in syms
|
||||
reg.close()
|
||||
|
||||
def test_upsert_overwrites(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db = os.path.join(tmp, "test.db")
|
||||
reg = AssetRegistry(db)
|
||||
p = build_profile_from_behavior("BTCUSDT")
|
||||
reg.upsert_profile(p)
|
||||
reg.upsert_profile(p)
|
||||
profiles = reg.list_profiles()
|
||||
assert len(profiles) == 1
|
||||
reg.close()
|
||||
|
||||
def test_delete_profile(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db = os.path.join(tmp, "test.db")
|
||||
reg = AssetRegistry(db)
|
||||
p = build_profile_from_behavior("BTCUSDT")
|
||||
reg.upsert_profile(p)
|
||||
reg.delete_profile("BTCUSDT")
|
||||
assert reg.get_profile("BTCUSDT") is None
|
||||
reg.close()
|
||||
|
||||
def test_csv_roundtrip(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db = os.path.join(tmp, "test.db")
|
||||
csv_out = os.path.join(tmp, "export.csv")
|
||||
reg = AssetRegistry(db)
|
||||
reg.upsert_all_from_asset_behaviors()
|
||||
n = reg.export_csv(csv_out)
|
||||
assert n >= 8
|
||||
assert os.path.exists(csv_out)
|
||||
reg.close()
|
||||
|
||||
reg2 = AssetRegistry(os.path.join(tmp, "test2.db"))
|
||||
n2 = reg2.upsert_from_csv(csv_out)
|
||||
assert n2 >= 8
|
||||
assert reg2.get_profile("BTCUSDT") is not None
|
||||
reg2.close()
|
||||
|
||||
|
||||
class TestRuntimeProfileCache:
|
||||
def test_put_and_get(self):
|
||||
cache = RuntimeProfileCache()
|
||||
p = build_profile_from_behavior("BTCUSDT")
|
||||
cache.put(p)
|
||||
assert cache.has("BTCUSDT")
|
||||
assert cache.get("BTCUSDT").symbol == "BTCUSDT"
|
||||
|
||||
def test_load_from_registry(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db = os.path.join(tmp, "test.db")
|
||||
reg = AssetRegistry(db)
|
||||
reg.upsert_all_from_asset_behaviors()
|
||||
cache = RuntimeProfileCache()
|
||||
n = cache.load_from_registry(reg)
|
||||
assert n >= 8
|
||||
assert cache.has("BTCUSDT")
|
||||
assert cache.has("ETHUSDT")
|
||||
reg.close()
|
||||
|
||||
|
||||
class TestHftCwmWithProfile:
|
||||
def test_cwm_accepts_profile(self):
|
||||
from malkhut.cwm.hft_cwm import HftBacktestCWM
|
||||
p = build_profile_from_behavior("BTCUSDT")
|
||||
cfg = BookGenerationConfig()
|
||||
cwm = HftBacktestCWM(
|
||||
use_queue_model=True,
|
||||
use_dynamic_book=True,
|
||||
book_profile=p,
|
||||
book_config=cfg,
|
||||
)
|
||||
assert cwm._book_generator is not None
|
||||
|
||||
def test_cwm_without_profile_fallback(self):
|
||||
from malkhut.cwm.hft_cwm import HftBacktestCWM
|
||||
cwm = HftBacktestCWM(use_queue_model=True, use_dynamic_book=True)
|
||||
assert cwm._book_generator is None
|
||||
|
||||
def test_cwm_default_backward_compat(self):
|
||||
from malkhut.cwm.hft_cwm import HftBacktestCWM
|
||||
cwm = HftBacktestCWM()
|
||||
assert cwm._use_dynamic_book is False
|
||||
assert cwm._book_generator is None
|
||||
|
||||
|
||||
class TestAllAssetsHaveProfiles:
|
||||
def test_all_13_assets(self):
|
||||
symbols = [
|
||||
"BTCUSDT", "ETHUSDT", "SOLUSDT", "DOGEUSDT", "ADAUSDT",
|
||||
"AVAXUSDT", "UNIUSDT", "LINKUSDT", "BNBUSDT", "MATICUSDT",
|
||||
"AAVEUSDT", "DOTUSDT", "ATOMUSDT",
|
||||
]
|
||||
for sym in symbols:
|
||||
p = build_profile_from_behavior(sym)
|
||||
assert p.symbol == sym
|
||||
assert p.depth_amplitude_usd > 0
|
||||
assert p.spread_normal_bps > 0
|
||||
assert p.typical_num_levels > 0
|
||||
gen = BookGenerator(p, BookGenerationConfig())
|
||||
ref_p = p.reference_price if p.reference_price > 0 else 100.0
|
||||
book = gen.generate_initial_book(ref_p, ref_p * 0.0001)
|
||||
assert len(book.bids) > 0, f"{sym} no bids"
|
||||
assert len(book.asks) > 0, f"{sym} no asks"
|
||||
assert book.mid > 0, f"{sym} no mid"
|
||||
Reference in New Issue
Block a user