""" Tests for asset bridge (directory ↔ classification integration). Covers: - Bridge sync (directory TRADING statuses → AssetProfile.exchanges) - normalize_symbol correctness - ExchangeListing validation - AssetDirectory CRUD + persistence - symbols_for_exchange filtering by status - venue_symbol mapping - Import idempotence - Round-trip load/save - Integration with ScenarioFactory - Edge cases: unknown exchange, empty directory, re-sync """ import pytest from pathlib import Path from malkhut.assets.directory import ( KNOWN_EXCHANGES, AssetDirectory, AssetRecord, ExchangeListing, ListingStatus, normalize_symbol, ) from malkhut.training.asset_bridge import ( sync_asset_to_profile, sync_exchanges_from_directory, get_universe_stats, ) from malkhut.training.asset_classification import ( ASSET_PROFILES, get_asset_profile, ) from malkhut.training.cma_trainer import ScenarioFactory # ============================================================================== # normalize_symbol — canonical form # ============================================================================== class TestNormalizeSymbol: def test_dash_separator(self): assert normalize_symbol("BTC-USDT") == "BTCUSDT" def test_underscore_separator(self): assert normalize_symbol("eth_usdt") == "ETHUSDT" def test_slash_separator(self): assert normalize_symbol("ETH/USDT") == "ETHUSDT" def test_whitespace_stripped(self): assert normalize_symbol(" BTC-USDT ") == "BTCUSDT" def test_already_normalized(self): assert normalize_symbol("BTCUSDT") == "BTCUSDT" def test_lowercase_normalized(self): assert normalize_symbol("band-usdt") == "BANDUSDT" def test_mixed_separators(self): assert normalize_symbol("BAND-USDT_USDC/BTC") == "BANDUSDTUSDCBTC" # ============================================================================== # ExchangeListing — validation # ============================================================================== class TestExchangeListing: def test_valid_listing(self): l = ExchangeListing(venue_symbol="BTC-USDT", status=ListingStatus.TRADING) assert l.venue_symbol == "BTC-USDT" assert l.status == "TRADING" def test_invalid_status_rejected(self): with pytest.raises(ValueError): ExchangeListing(venue_symbol="X", status="INVALID") def test_unknown_status(self): l = ExchangeListing(venue_symbol="X", status=ListingStatus.UNKNOWN) assert l.status == "UNKNOWN" def test_offline_status(self): l = ExchangeListing(venue_symbol="X", status=ListingStatus.OFFLINE) assert l.status == "OFFLINE" # ============================================================================== # AssetRecord — listing queries # ============================================================================== class TestAssetRecord: def test_listed_on_trading(self): r = AssetRecord( symbol="BTCUSDT", exchanges={"BINANCE": ExchangeListing("BTCUSDT", "TRADING")}, ) assert r.listed_on("BINANCE") is True def test_listed_on_offline(self): r = AssetRecord( symbol="BTCUSDT", exchanges={"BINANCE": ExchangeListing("BTCUSDT", "OFFLINE")}, ) assert r.listed_on("BINANCE") is False def test_listed_on_unknown(self): r = AssetRecord( symbol="BTCUSDT", exchanges={"BINANCE": ExchangeListing("BTCUSDT", "UNKNOWN")}, ) assert r.listed_on("BINANCE") is False def test_listed_on_missing(self): r = AssetRecord(symbol="BTCUSDT") assert r.listed_on("BINANCE") is False def test_base_quote_split(self): r = AssetRecord(symbol="BTCUSDT", base="BTC", quote="USDT") assert r.base == "BTC" assert r.quote == "USDT" # ============================================================================== # AssetDirectory — CRUD + persistence # ============================================================================== class TestAssetDirectory: def test_empty_directory(self, tmp_path): d = AssetDirectory(tmp_path / "empty.json") assert len(d) == 0 def test_upsert_creates(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") rec = d.upsert("BTCUSDT") assert rec.symbol == "BTCUSDT" assert rec.base == "BTC" assert rec.quote == "USDT" assert len(d) == 1 def test_upsert_is_idempotent(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") d.upsert("BTCUSDT") d.upsert("BTCUSDT") assert len(d) == 1 def test_upsert_splits_usdt(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") rec = d.upsert("ETHUSDT") assert rec.base == "ETH" assert rec.quote == "USDT" def test_upsert_custom_base_quote(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") rec = d.upsert("XBTC", base="XBT", quote="USD") assert rec.base == "XBT" assert rec.quote == "USD" def test_get_returns_none_for_missing(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") assert d.get("BTCUSDT") is None def test_get_case_insensitive(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") d.upsert("BTCUSDT") assert d.get("btcusdt") is not None assert d.get("BTC-USDT") is not None def test_set_listing_creates_exchange(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") d.set_listing("BTCUSDT", "BINANCE", status=ListingStatus.TRADING) rec = d.get("BTCUSDT") assert rec.listed_on("BINANCE") def test_set_listing_rejects_unknown_exchange(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") with pytest.raises(ValueError): d.set_listing("BTCUSDT", "KRAKEN") def test_set_listing_custom_venue_symbol(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") d.set_listing("ETHUSDT", "BINGX_VST", venue_symbol="ETH-USDT") assert d.venue_symbol("ETHUSDT", "BINGX_VST") == "ETH-USDT" def test_import_symbols_bulk(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") n = d.import_symbols( ["BTCUSDT", "ETHUSDT", "SOLUSDT"], "BINANCE", status=ListingStatus.TRADING, ) assert n == 3 assert len(d) == 3 def test_import_is_idempotent(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") d.import_symbols(["BTCUSDT"], "BINANCE") d.import_symbols(["BTCUSDT"], "BINANCE") assert len(d) == 1 def test_import_preserves_other_exchanges(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") d.import_symbols(["BTCUSDT"], "BINANCE") d.set_listing("BTCUSDT", "BINGX_VST", venue_symbol="BTC-USDT", status=ListingStatus.TRADING) d.import_symbols(["BTCUSDT"], "BINANCE") # re-import rec = d.get("BTCUSDT") assert set(rec.exchanges) == {"BINANCE", "BINGX_VST"} def test_save_and_load(self, tmp_path): p = tmp_path / "d.json" d = AssetDirectory(p) d.import_symbols(["BTCUSDT", "ETHUSDT"], "BINANCE") d.set_listing("BTCUSDT", "BINGX_VST", venue_symbol="BTC-USDT", status=ListingStatus.TRADING) d.save() d2 = AssetDirectory(p) assert len(d2) == 2 assert d2.symbols_for_exchange("BINANCE") == ["BTCUSDT", "ETHUSDT"] assert d2.symbols_for_exchange("BINGX_VST") == ["BTCUSDT"] def test_save_atomic(self, tmp_path): """Save uses tmp-rename — no partial writes.""" p = tmp_path / "d.json" d = AssetDirectory(p) d.import_symbols(["BTCUSDT"], "BINANCE") d.save() assert p.exists() assert not (tmp_path / "d.json.tmp").exists() # ============================================================================== # symbols_for_exchange — filtering by status # ============================================================================== class TestSymbolsForExchange: def test_trading_only(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") d.import_symbols(["BTCUSDT", "ETHUSDT"], "BINANCE") d.set_listing("BTCUSDT", "BINGX_VST", status=ListingStatus.TRADING) d.set_listing("ETHUSDT", "BINGX_VST", status=ListingStatus.OFFLINE) assert d.symbols_for_exchange("BINGX_VST") == ["BTCUSDT"] assert d.symbols_for_exchange("BINGX_VST", status=ListingStatus.OFFLINE) == ["ETHUSDT"] def test_sorted_output(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") d.import_symbols(["SOLUSDT", "BTCUSDT", "ETHUSDT"], "BINANCE") result = d.symbols_for_exchange("BINANCE") assert result == ["BTCUSDT", "ETHUSDT", "SOLUSDT"] def test_unknown_exchange_raises(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") with pytest.raises(ValueError): d.symbols_for_exchange("KRAKEN") def test_empty_exchange(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") d.import_symbols(["BTCUSDT"], "BINANCE") assert d.symbols_for_exchange("BINGX_VST") == [] # ============================================================================== # venue_symbol — venue-local spelling # ============================================================================== class TestVenueSymbol: def test_venue_symbol_when_set(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") d.set_listing("ETHUSDT", "BINGX_VST", venue_symbol="ETH-USDT") assert d.venue_symbol("ETHUSDT", "BINGX_VST") == "ETH-USDT" def test_venue_symbol_defaults_to_canonical(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") d.set_listing("ETHUSDT", "BINANCE") # no venue_symbol → uses canonical assert d.venue_symbol("ETHUSDT", "BINANCE") == "ETHUSDT" def test_venue_symbol_unlisted(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") assert d.venue_symbol("BTCUSDT", "BINANCE") == "" # ============================================================================== # Bridge — directory → profile sync # ============================================================================== class TestBridgeSync: """Tests for directory → profile sync. Each test saves/restores profile state.""" @pytest.fixture(autouse=True) def _save_restore_profiles(self): """Snapshot and restore ASSET_PROFILES after each test.""" snapshot = {k: v for k, v in ASSET_PROFILES.items()} yield ASSET_PROFILES.clear() ASSET_PROFILES.update(snapshot) def test_sync_updates_profile_exchanges(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") d.import_symbols(["BTCUSDT", "ETHUSDT"], "BINANCE") d.save() updated = sync_exchanges_from_directory(d) assert updated >= 2 btc = get_asset_profile("BTCUSDT") assert "BINANCE" in btc.exchanges def test_sync_adds_new_exchanges(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") d.import_symbols(["BTCUSDT"], "BINGX_VST") d.save() sync_exchanges_from_directory(d) btc = get_asset_profile("BTCUSDT") assert "binance" in btc.exchanges # pre-existing (lowercase default) assert "BINGX_VST" in btc.exchanges # newly added def test_sync_does_not_add_offline(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") d.set_listing("BTCUSDT", "BINGX_VST", status=ListingStatus.OFFLINE) d.save() sync_exchanges_from_directory(d) btc = get_asset_profile("BTCUSDT") assert "BINGX_VST" not in btc.exchanges def test_sync_returns_update_count(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") d.import_symbols(["BTCUSDT"], "BINGX_VST") d.save() count = sync_exchanges_from_directory(d) assert count >= 1 def test_sync_preserves_existing_fields(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") d.import_symbols(["BTCUSDT"], "BINGX_VST") d.save() btc_before = get_asset_profile("BTCUSDT") sync_exchanges_from_directory(d) btc_after = get_asset_profile("BTCUSDT") assert btc_after.sector == btc_before.sector assert btc_after.tick_size == btc_before.tick_size assert btc_after.coingecko_id == btc_before.coingecko_id def test_sync_single_asset(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") d.set_listing("BTCUSDT", "BINGX_VST", status=ListingStatus.TRADING) d.save() result = sync_asset_to_profile(d, "BTCUSDT") assert result is True btc = get_asset_profile("BTCUSDT") assert "BINGX_VST" in btc.exchanges def test_sync_unknown_asset_returns_false(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") result = sync_asset_to_profile(d, "NONEXISTENT") assert result is False def test_universe_stats(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") d.import_symbols(["BTCUSDT", "ETHUSDT", "BANDUSDT"], "BINANCE") d.save() stats = get_universe_stats(d) assert stats["directory_assets"] == 3 assert stats["matched_profiles"] >= 2 assert stats["unmatched_assets"] >= 1 assert stats["profile_count"] == 13 # ============================================================================== # Integration with ScenarioFactory # ============================================================================== class TestDirectoryIntegration: @pytest.fixture(autouse=True) def _save_restore_profiles(self): snapshot = {k: v for k, v in ASSET_PROFILES.items()} yield ASSET_PROFILES.clear() ASSET_PROFILES.update(snapshot) def test_scenario_factory_after_sync(self, tmp_path): d = AssetDirectory(tmp_path / "d.json") d.import_symbols(["BTCUSDT", "ETHUSDT"], "BINGX_VST") d.save() sync_exchanges_from_directory(d) btc = get_asset_profile("BTCUSDT") assert "BINGX_VST" in btc.exchanges factory = ScenarioFactory() suite = factory.build_suite(symbols=("BTCUSDT",), steps_per_scenario=3) assert len(suite) >= 30 def test_query_by_new_exchange(self, tmp_path): from malkhut.training.asset_classification import get_assets_on_exchange d = AssetDirectory(tmp_path / "d.json") d.import_symbols(["BTCUSDT", "ETHUSDT"], "BINGX_VST") d.save() sync_exchanges_from_directory(d) vst_assets = get_assets_on_exchange("BINGX_VST") symbols = {p.symbol for p in vst_assets} assert "BTCUSDT" in symbols assert "ETHUSDT" in symbols def test_full_roundtrip(self, tmp_path): from malkhut.training.asset_classification import get_assets_on_exchange d = AssetDirectory(tmp_path / "d.json") d.import_symbols(["BTCUSDT", "ETHUSDT", "SOLUSDT"], "BINGX_VST", status=ListingStatus.TRADING, source="test") d.save() updated = sync_exchanges_from_directory(d) assert updated >= 3 vst = get_assets_on_exchange("BINGX_VST") assert len(vst) >= 3 factory = ScenarioFactory() suite = factory.build_suite(symbols=tuple(p.symbol for p in vst[:2]), steps_per_scenario=3) assert len(suite) >= 60 stats = get_universe_stats(d) assert stats["matched_profiles"] >= 3