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).
319 lines
12 KiB
Python
319 lines
12 KiB
Python
"""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"
|