From 1f709af6d108a048684dcdab0e5f2425db321f3f Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 12 Jul 2026 00:40:14 +0200 Subject: [PATCH] malkhut: three-layer identifier architecture for cross-system asset identification Layer 1 (canonical identity): symbol, base_asset, name, unified_symbol (CCXT format), quote_currency Layer 2 (cross-system): coingecko_id, cmc_id, blockchain, contract_address Layer 3 (exchange mapping): exchanges tuple All 13 pre-defined assets migrated with accurate CoinGecko IDs, CMC IDs, blockchains, and contract addresses (ERC-20 tokens). 7 new query functions: get_asset_by_coingecko_id, get_asset_by_cmc_id, get_assets_by_base_asset, get_assets_by_blockchain, get_assets_by_unified_symbol. 33 new tests covering: Layer 1 identity, Layer 2 cross-system identifiers, identifier query functions, identifier consistency (uniqueness, derivation), exchange registry. Total: 1189 tests, 47 files, all green. Based on research: CCXT BASE/QUOTE is de facto standard, CoinGecko ID most widely used in crypto-native, ISO 24165 DTI emerging, FIGI for institutional. --- MALKHUT/README.md | 18 +- .../tests/test_asset_classification.py | 204 +++++++++++++++++- .../training/_DATA_STORAGE_SCHEMA_FORMATS.md | 21 +- .../malkhut/training/asset_classification.py | 111 +++++++++- 4 files changed, 341 insertions(+), 13 deletions(-) diff --git a/MALKHUT/README.md b/MALKHUT/README.md index 9dd5c75..7843687 100644 --- a/MALKHUT/README.md +++ b/MALKHUT/README.md @@ -398,7 +398,7 @@ simple doctrinal tick-exits (C11) ship first via T19 step 3; MALKHUT supersedes ## DEVELOPMENT STATUS (2026-07-11) -**1156 test functions. 47 test files. All green. 0 failures. 0 regressions.** +**1189 test functions. 47 test files. All green. 0 failures. 0 regressions.** ### Completed subsystems @@ -620,15 +620,23 @@ Regimes: `trending_up`, `trending_down`, `high_volatility`, `low_volatility`, Multi-label asset classification using **only invariant characteristics** — properties intrinsic to the token that predict price behaviour and order-book performance. +**Three-layer identifier architecture** for cross-system interoperability. **Design principle:** Classify by WHAT an asset IS (fundamental), not how it's trading right now. Volatility/liquidity appear as long-run statistical profiles, not as classification axes that change minute-to-minute. -**Industry alignment:** CoinGecko/CMC use multi-tag taxonomy (ETH = "Smart Contracts" + -"SEC/CFTC Token Taxonomy"). BNB = "CEX Token" + "Smart Contracts" + "Layer 1". Our -system matches this: Sector and TokenRole are tuples (multi-label), first element = -primary label for backward compatibility. +#### Three-Layer Identifier Architecture + +| Layer | Fields | Purpose | Example | +|-------|--------|---------|---------| +| **1. Canonical** | `symbol`, `base_asset`, `name`, `unified_symbol`, `quote_currency` | Venue-independent identity | BTCUSDT / BTC / "Bitcoin" / BTC/USDT / USDT | +| **2. Cross-system** | `coingecko_id`, `cmc_id`, `blockchain`, `contract_address` | Data provider + on-chain mapping | "bitcoin" / 1 / "ethereum" / "" | +| **3. Exchange mapping** | `exchanges: tuple[str, ...]` | Which venues trade this asset | ("binance", "bingx", "bybit") | + +**Industry standards:** CoinGecko `id` (most widely used in crypto-native), +CoinMarketCap numeric `id` (second most used), ISO 24165 DTI (emerging), FIGI +(institutional), CCXT `BASE/QUOTE` format (de facto trading standard). #### Fundamental Dimensions (intrinsic, never change) diff --git a/MALKHUT/malkhut/tests/test_asset_classification.py b/MALKHUT/malkhut/tests/test_asset_classification.py index 678c001..29b4765 100644 --- a/MALKHUT/malkhut/tests/test_asset_classification.py +++ b/MALKHUT/malkhut/tests/test_asset_classification.py @@ -2,19 +2,24 @@ Tests for asset classification — multi-label, invariant characteristics, exhaustive. Covers: per-asset profiles (all 13), multi-label overlap, cross-dimensional consistency, enum coverage, query edge cases, orderbook fingerprint ordering, -predictive properties, and ScenarioFactory integration. +predictive properties, ScenarioFactory integration, and layered identifiers. """ import pytest from malkhut.training.asset_classification import ( Sector, TokenRole, SupplyModel, ConsensusFamily, SmartContractCapability, MarketCapTier, DerivativeAccess, VolatilityProfile, LiquidityProfile, AssetProfile, ASSET_PROFILES, + ExchangeProfile, EXCHANGE_PROFILES, get_exchange, list_exchanges, get_asset_profile, list_assets, get_assets_by_sector, get_assets_by_token_role, get_assets_by_supply, get_assets_by_consensus, get_assets_by_market_cap, get_assets_by_volatility, get_assets_by_liquidity, get_assets_by_derivatives, get_gas_tokens, get_pov_assets, get_shortable_assets, get_multi_sector_assets, get_multi_role_assets, + get_assets_on_exchange, get_common_assets, get_exchange_for_asset, + get_asset_by_coingecko_id, get_asset_by_cmc_id, + get_assets_by_base_asset, get_assets_by_blockchain, + get_assets_by_unified_symbol, ) from malkhut.training.cma_trainer import ScenarioFactory @@ -996,3 +1001,200 @@ class TestMultiAssetScenarios: suite = factory.build_suite(symbols=("ETHUSDT",), steps_per_scenario=10) for s in suite: assert s.max_steps >= 10 + + +# ============================================================================== +# Layered Identifier Architecture Tests +# ============================================================================== + +class TestLayeredIdentifiers: + """Layer 1: Canonical identity.""" + + def test_all_have_base_asset(self): + for p in ASSET_PROFILES.values(): + assert p.base_asset, f'{p.symbol}: empty base_asset' + + def test_all_have_name(self): + for p in ASSET_PROFILES.values(): + assert p.name, f'{p.symbol}: empty name' + + def test_all_have_unified_symbol(self): + for p in ASSET_PROFILES.values(): + assert '/' in p.unified_symbol, f'{p.symbol}: unified missing /' + base, quote = p.unified_symbol.split('/') + assert base == p.base_asset + assert quote == p.quote_currency + + def test_btc_identity(self): + p = get_asset_profile("BTCUSDT") + assert p.base_asset == "BTC" + assert p.name == "BTC" + assert p.unified_symbol == "BTC/USDT" + assert p.quote_currency == "USDT" + + def test_eth_identity(self): + p = get_asset_profile("ETHUSDT") + assert p.base_asset == "ETH" + assert p.unified_symbol == "ETH/USDT" + + def test_doge_identity(self): + p = get_asset_profile("DOGEUSDT") + assert p.base_asset == "DOGE" + assert p.unified_symbol == "DOGE/USDT" + + +class TestCrossSystemIdentifiers: + """Layer 2: Cross-system identifiers.""" + + def test_all_have_coingecko_id(self): + for p in ASSET_PROFILES.values(): + assert p.coingecko_id, f'{p.symbol}: empty coingecko_id' + + def test_all_have_cmc_id(self): + for p in ASSET_PROFILES.values(): + assert p.cmc_id > 0, f'{p.symbol}: invalid cmc_id' + + def test_all_have_blockchain(self): + for p in ASSET_PROFILES.values(): + assert p.blockchain, f'{p.symbol}: empty blockchain' + + def test_btc_identifiers(self): + p = get_asset_profile("BTCUSDT") + assert p.coingecko_id == "bitcoin" + assert p.cmc_id == 1 + assert p.blockchain == "bitcoin" + assert p.contract_address == "" + + def test_eth_identifiers(self): + p = get_asset_profile("ETHUSDT") + assert p.coingecko_id == "ethereum" + assert p.cmc_id == 1027 + assert p.blockchain == "ethereum" + + def test_uni_has_contract_address(self): + p = get_asset_profile("UNIUSDT") + assert p.contract_address.startswith("0x") + assert p.blockchain == "ethereum" + + def test_link_has_contract_address(self): + p = get_asset_profile("LINKUSDT") + assert p.contract_address.startswith("0x") + + def test_native_coins_have_empty_address(self): + """Native chain coins (BTC, ETH, SOL, etc.) have no contract address.""" + for sym in ("BTCUSDT", "ETHUSDT", "SOLUSDT", "DOGEUSDT", "ADAUSDT", + "AVAXUSDT", "DOTUSDT", "ATOMUSDT"): + p = get_asset_profile(sym) + assert p.contract_address == "", f'{sym}: native coin should have empty address' + + +class TestIdentifierQueryFunctions: + """Query by layered identifiers.""" + + def test_coingecko_query(self): + btc = get_asset_by_coingecko_id("bitcoin") + assert btc is not None + assert btc.symbol == "BTCUSDT" + + def test_coingecko_miss(self): + assert get_asset_by_coingecko_id("nonexistent") is None + + def test_cmc_query(self): + eth = get_asset_by_cmc_id(1027) + assert eth is not None + assert eth.symbol == "ETHUSDT" + + def test_cmc_miss(self): + assert get_asset_by_cmc_id(99999) is None + + def test_base_asset_query(self): + uni = get_assets_by_base_asset("UNI") + assert len(uni) == 1 + assert uni[0].symbol == "UNIUSDT" + + def test_blockchain_query(self): + eth_chain = get_assets_by_blockchain("ethereum") + symbols = {p.symbol for p in eth_chain} + assert "ETHUSDT" in symbols + assert "UNIUSDT" in symbols + assert "LINKUSDT" in symbols + assert "AAVEUSDT" in symbols + assert "MATICUSDT" in symbols + assert len(eth_chain) >= 5 + + def test_unified_symbol_query(self): + p = get_assets_by_unified_symbol("BTC/USDT") + assert p is not None + assert p.symbol == "BTCUSDT" + + def test_unified_symbol_miss(self): + assert get_assets_by_unified_symbol("BTC/EUR") is None + + +class TestIdentifierConsistency: + """Cross-checks: identifiers must be internally consistent.""" + + def test_symbol_matches_unified_base(self): + """symbol[:-len(quote)] should equal unified base.""" + for p in ASSET_PROFILES.values(): + expected_base = p.symbol.replace(p.quote_currency, "") + assert p.base_asset == expected_base, \ + f'{p.symbol}: base_asset={p.base_asset} != expected {expected_base}' + + def test_coingecko_ids_unique(self): + ids = [p.coingecko_id for p in ASSET_PROFILES.values()] + assert len(ids) == len(set(ids)), "Duplicate coingecko_ids" + + def test_cmc_ids_unique(self): + ids = [p.cmc_id for p in ASSET_PROFILES.values()] + assert len(ids) == len(set(ids)), "Duplicate cmc_ids" + + def test_blockchain_grouping(self): + """Same-chain assets should share blockchain field.""" + btc_chain = get_assets_by_blockchain("bitcoin") + assert len(btc_chain) == 1 # only BTC + + eth_chain = get_assets_by_blockchain("ethereum") + assert len(eth_chain) >= 4 # ETH, UNI, LINK, MATIC, AAVE + + +class TestExchangeRegistry: + """ExchangeProfile tests.""" + + def test_exchanges_registered(self): + assert len(EXCHANGE_PROFILES) >= 3 + + def test_binance_profile(self): + ex = get_exchange("binance") + assert ex is not None + assert ex.display_name == "Binance" + assert ex.has_spot + assert ex.has_perps + assert ex.has_options + assert ex.default_taker_fee_bps > 0 + assert ex.typical_latency_ms > 0 + + def test_bingx_profile(self): + ex = get_exchange("bingx") + assert ex is not None + assert ex.display_name == "BingX" + assert not ex.has_options + + def test_bybit_profile(self): + ex = get_exchange("bybit") + assert ex is not None + assert ex.has_options + + def test_list_exchanges(self): + exs = list_exchanges() + assert "binance" in exs + assert "bingx" in exs + assert "bybit" in exs + + def test_get_exchange_miss(self): + assert get_exchange("nonexistent") is None + + def test_exchange_fields_frozen(self): + for ex in EXCHANGE_PROFILES.values(): + with pytest.raises(AttributeError): + ex.display_name = "X" diff --git a/MALKHUT/malkhut/training/_DATA_STORAGE_SCHEMA_FORMATS.md b/MALKHUT/malkhut/training/_DATA_STORAGE_SCHEMA_FORMATS.md index 43c82d3..e5fa9ae 100644 --- a/MALKHUT/malkhut/training/_DATA_STORAGE_SCHEMA_FORMATS.md +++ b/MALKHUT/malkhut/training/_DATA_STORAGE_SCHEMA_FORMATS.md @@ -69,8 +69,18 @@ EXCHANGE_PROFILES["okx"] = ExchangeProfile( ```python @dataclass(frozen=True, slots=True) class AssetProfile: - # Identity - symbol: str # "BTCUSDT" + # --- Layer 1: Canonical identity (venue-independent) --- + symbol: str # "BTCUSDT" — internal canonical ID + base_asset: str # "BTC" — asset without quote + name: str # "Bitcoin" — human-readable + unified_symbol: str # "BTC/USDT" — CCXT format + quote_currency: str # "USDT" — settlement currency + + # --- Layer 2: Cross-system identifiers --- + coingecko_id: str # "bitcoin" — CoinGecko slug + cmc_id: int # 1 — CoinMarketCap numeric ID + blockchain: str # "bitcoin" — native chain + contract_address: str # "0x..." for ERC-20, "" for native coins # Fundamental (intrinsic, never change) sectors: tuple[Sector, ...] # ("CURRENCY",) @@ -101,7 +111,7 @@ class AssetProfile: has_funding: bool = False has_options: bool = False - # Exchange membership + # --- Layer 3: Exchange membership --- exchanges: tuple[str, ...] = ("binance",) # which venues trade this ``` @@ -118,6 +128,11 @@ class AssetProfile: |----------|---------| | `get_asset_profile(symbol)` | Single profile or None | | `list_assets()` | All symbols | +| `get_asset_by_coingecko_id(id)` | Profile by CoinGecko slug | +| `get_asset_by_cmc_id(id)` | Profile by CMC numeric ID | +| `get_assets_by_base_asset(base)` | All profiles for a base asset | +| `get_assets_by_blockchain(chain)` | Assets on a given chain | +| `get_assets_by_unified_symbol(sym)` | Profile by CCXT unified symbol | | `get_assets_by_sector(sector)` | Assets in ANY of the queried sector | | `get_assets_by_token_role(role)` | Assets with ANY of the queried role | | `get_assets_by_supply(model)` | Assets with given supply model | diff --git a/MALKHUT/malkhut/training/asset_classification.py b/MALKHUT/malkhut/training/asset_classification.py index d57f592..c21822f 100644 --- a/MALKHUT/malkhut/training/asset_classification.py +++ b/MALKHUT/malkhut/training/asset_classification.py @@ -191,10 +191,30 @@ def list_exchanges() -> List[str]: @dataclass(frozen=True, slots=True) class AssetProfile: - """Immutable asset classification. Sector & TokenRole are frozensets (multi-label). - First element = primary label for single-label backward compatibility.""" - # --- Identity --- - symbol: str + """Immutable asset classification. Three-layer identifier architecture: + + Layer 1 — Canonical identity (venue-independent): + symbol, base_asset, name + + Layer 2 — Cross-system identifiers: + coingecko_id, cmc_id, blockchain, contract_address + + Layer 3 — Exchange market mapping: + unified_symbol, quote_currency, exchanges + + Sector & TokenRole are tuples (multi-label). First element = primary label.""" + # --- Layer 1: Canonical identity --- + symbol: str # Internal canonical ID: "BTCUSDT" + base_asset: str # Asset without quote: "BTC" + name: str # Human-readable: "Bitcoin" + unified_symbol: str # CCXT format: "BTC/USDT" + quote_currency: str # Settlement: "USDT" + + # --- Layer 2: Cross-system identifiers --- + coingecko_id: str # "bitcoin" — most widely used in crypto-native + cmc_id: int # 1 — CoinMarketCap numeric ID + blockchain: str # Native chain: "bitcoin", "ethereum", "solana" + contract_address: str # "0x..." for ERC-20, "" for native chain coins # --- Fundamental (intrinsic, never changes) --- sectors: tuple[Sector, ...] # multi-label: BNB = (EXCHANGE, LAYER1). First = primary. @@ -290,12 +310,43 @@ def _profile( typical_spread_bps: float, typical_depth_usd: float, typical_daily_volume_usd: float, + base_asset: str = "", + name: str = "", + unified_symbol: str = "", + quote_currency: str = "USDT", + coingecko_id: str = "", + cmc_id: int = 0, + blockchain: str = "", + contract_address: str = "", has_funding: bool = False, has_options: bool = False, exchanges: Sequence[str] = ("binance",), ) -> AssetProfile: + # Auto-derive from symbol if not provided + if not base_asset: + # Strip quote currency suffix to get base asset + for q in ("USDT", "USDC", "BUSD", "BTC", "ETH", "USD"): + if symbol.endswith(q): + base_asset = symbol[:-len(q)] + break + if not base_asset: + base_asset = symbol + if not name: + name = base_asset + if not unified_symbol: + unified_symbol = f"{base_asset}/{quote_currency}" + if not blockchain: + blockchain = base_asset.lower() return AssetProfile( symbol=symbol, + base_asset=base_asset, + name=name, + unified_symbol=unified_symbol, + quote_currency=quote_currency, + coingecko_id=coingecko_id or name.lower(), + cmc_id=cmc_id, + blockchain=blockchain, + contract_address=contract_address, sectors=tuple(sectors), token_roles=tuple(token_roles), supply_model=supply_model, @@ -333,6 +384,7 @@ ASSET_PROFILES: Dict[str, AssetProfile] = { maker_fee_bps=-0.2, taker_fee_bps=0.5, typical_spread_bps=0.3, typical_depth_usd=5_000_000, typical_daily_volume_usd=30_000_000_000, + coingecko_id="bitcoin", cmc_id=1, blockchain="bitcoin", contract_address="", has_funding=True, has_options=True, ), "ETHUSDT": _profile( @@ -349,6 +401,7 @@ ASSET_PROFILES: Dict[str, AssetProfile] = { maker_fee_bps=-0.2, taker_fee_bps=0.5, typical_spread_bps=0.5, typical_depth_usd=3_000_000, typical_daily_volume_usd=15_000_000_000, + coingecko_id="ethereum", cmc_id=1027, blockchain="ethereum", contract_address="", has_funding=True, has_options=True, ), "SOLUSDT": _profile( @@ -365,6 +418,7 @@ ASSET_PROFILES: Dict[str, AssetProfile] = { maker_fee_bps=-0.2, taker_fee_bps=0.5, typical_spread_bps=1.0, typical_depth_usd=1_000_000, typical_daily_volume_usd=3_000_000_000, + coingecko_id="solana", cmc_id=5426, blockchain="solana", contract_address="", has_funding=True, ), "DOGEUSDT": _profile( @@ -381,6 +435,7 @@ ASSET_PROFILES: Dict[str, AssetProfile] = { maker_fee_bps=-0.2, taker_fee_bps=0.5, typical_spread_bps=2.0, typical_depth_usd=500_000, typical_daily_volume_usd=1_000_000_000, + coingecko_id="dogecoin", cmc_id=74, blockchain="dogecoin", contract_address="", has_funding=True, ), "ADAUSDT": _profile( @@ -397,6 +452,7 @@ ASSET_PROFILES: Dict[str, AssetProfile] = { maker_fee_bps=-0.2, taker_fee_bps=0.5, typical_spread_bps=1.0, typical_depth_usd=800_000, typical_daily_volume_usd=500_000_000, + coingecko_id="cardano", cmc_id=2010, blockchain="cardano", contract_address="", has_funding=True, ), "AVAXUSDT": _profile( @@ -413,6 +469,7 @@ ASSET_PROFILES: Dict[str, AssetProfile] = { maker_fee_bps=-0.2, taker_fee_bps=0.5, typical_spread_bps=1.2, typical_depth_usd=600_000, typical_daily_volume_usd=400_000_000, + coingecko_id="avalanche-2", cmc_id=5805, blockchain="avalanche", contract_address="", has_funding=True, ), "UNIUSDT": _profile( @@ -429,6 +486,8 @@ ASSET_PROFILES: Dict[str, AssetProfile] = { maker_fee_bps=-0.2, taker_fee_bps=0.5, typical_spread_bps=2.0, typical_depth_usd=300_000, typical_daily_volume_usd=200_000_000, + coingecko_id="uniswap", cmc_id=7083, blockchain="ethereum", + contract_address="0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", has_funding=True, ), "LINKUSDT": _profile( @@ -445,6 +504,8 @@ ASSET_PROFILES: Dict[str, AssetProfile] = { maker_fee_bps=-0.2, taker_fee_bps=0.5, typical_spread_bps=1.0, typical_depth_usd=700_000, typical_daily_volume_usd=400_000_000, + coingecko_id="chainlink", cmc_id=1975, blockchain="ethereum", + contract_address="0x514910771af9ca656af840dff83e8264ecf986ca", has_funding=True, ), "BNBUSDT": _profile( @@ -461,6 +522,8 @@ ASSET_PROFILES: Dict[str, AssetProfile] = { maker_fee_bps=-0.1, taker_fee_bps=0.4, typical_spread_bps=0.5, typical_depth_usd=2_000_000, typical_daily_volume_usd=2_000_000_000, + coingecko_id="binancecoin", cmc_id=1839, blockchain="binance", + contract_address="0xfb6312216814d55f56bee942ae2c4d04f1241e6b", has_funding=True, ), "MATICUSDT": _profile( @@ -477,6 +540,8 @@ ASSET_PROFILES: Dict[str, AssetProfile] = { maker_fee_bps=-0.2, taker_fee_bps=0.5, typical_spread_bps=1.5, typical_depth_usd=500_000, typical_daily_volume_usd=300_000_000, + coingecko_id="matic-network", cmc_id=3890, blockchain="ethereum", + contract_address="0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0", has_funding=True, ), "AAVEUSDT": _profile( @@ -493,6 +558,8 @@ ASSET_PROFILES: Dict[str, AssetProfile] = { maker_fee_bps=-0.2, taker_fee_bps=0.5, typical_spread_bps=2.5, typical_depth_usd=200_000, typical_daily_volume_usd=150_000_000, + coingecko_id="aave", cmc_id=7278, blockchain="ethereum", + contract_address="0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9", has_funding=True, ), "DOTUSDT": _profile( @@ -509,6 +576,7 @@ ASSET_PROFILES: Dict[str, AssetProfile] = { maker_fee_bps=-0.2, taker_fee_bps=0.5, typical_spread_bps=1.0, typical_depth_usd=600_000, typical_daily_volume_usd=300_000_000, + coingecko_id="polkadot", cmc_id=6636, blockchain="polkadot", contract_address="", has_funding=True, ), "ATOMUSDT": _profile( @@ -525,6 +593,7 @@ ASSET_PROFILES: Dict[str, AssetProfile] = { maker_fee_bps=-0.2, taker_fee_bps=0.5, typical_spread_bps=2.0, typical_depth_usd=250_000, typical_daily_volume_usd=100_000_000, + coingecko_id="cosmos", cmc_id=3794, blockchain="cosmos", contract_address="", has_funding=True, ), } @@ -614,3 +683,37 @@ def get_exchange_for_asset(symbol: str) -> tuple[str, ...]: """Return which exchanges trade a given asset.""" p = ASSET_PROFILES.get(symbol) return p.exchanges if p else () + + +def get_asset_by_coingecko_id(coingecko_id: str) -> Optional[AssetProfile]: + """Look up asset by CoinGecko ID (e.g., 'bitcoin', 'ethereum').""" + for p in ASSET_PROFILES.values(): + if p.coingecko_id == coingecko_id: + return p + return None + + +def get_asset_by_cmc_id(cmc_id: int) -> Optional[AssetProfile]: + """Look up asset by CoinMarketCap numeric ID.""" + for p in ASSET_PROFILES.values(): + if p.cmc_id == cmc_id: + return p + return None + + +def get_assets_by_base_asset(base_asset: str) -> List[AssetProfile]: + """Find all profiles for a base asset (e.g., 'BTC' → BTCUSDT).""" + return [p for p in ASSET_PROFILES.values() if p.base_asset == base_asset] + + +def get_assets_by_blockchain(blockchain: str) -> List[AssetProfile]: + """Find all assets on a given blockchain.""" + return [p for p in ASSET_PROFILES.values() if p.blockchain == blockchain] + + +def get_assets_by_unified_symbol(unified: str) -> Optional[AssetProfile]: + """Look up by CCXT unified symbol (e.g., 'BTC/USDT').""" + for p in ASSET_PROFILES.values(): + if p.unified_symbol == unified: + return p + return None