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.
This commit is contained in:
Codex
2026-07-12 00:40:14 +02:00
parent da61c8f6c2
commit 1f709af6d1
4 changed files with 341 additions and 13 deletions

View File

@@ -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"

View File

@@ -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 |

View File

@@ -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