""" MALKHUT Asset Directory — the system-wide known-asset universe store. This is the normalization layer UNDER the rich MALKHUT taxonomy (`training/asset_classification.AssetProfile`): a record here says an asset EXISTS, what its canonical symbol is, and WHICH exchanges list it — nothing more. Most taxonomy fields stay empty until the AssetCompiler (or a human) fills them; a record may link to a full AssetProfile when one exists. Design (operator directive 2026-07-11): - MALKHUT's asset store is the overall system asset store (BLUE / VIOLET / UV consume it) — multi-exchange operation is the destination. - `KNOWN_EXCHANGES` is the aux "known exchanges" table. - Per-exchange listing carries the venue-local symbol and a TRADING / OFFLINE / UNKNOWN status, so an execution layer can ask `symbols_for_exchange("BINGX_VST")` and never submit a dead symbol. - Storage is a JSON file (versioned, language-agnostic, GraalVM-safe, no DB dependency). Path via $MALKHUT_ASSET_DIR_PATH or package-local `asset_directory.json`. Canonical symbol normalization: venue variants ("BAND-USDT", "band_usdt", "BANDUSDT") all normalize to "BANDUSDT" (uppercase, separators stripped). """ from __future__ import annotations import json import os from dataclasses import dataclass, field, asdict from pathlib import Path from typing import Dict, Iterable, List, Optional SCHEMA_VERSION = 1 # ─── Known exchanges (aux table) ─────────────────────────────────────────── # key → human description. BINGX_VST is deliberately separate from BINGX: # the testnet universe differs from live (observed 2026-07-11: BAND/CELR # offline on VST while live on Binance). KNOWN_EXCHANGES: Dict[str, str] = { "BINANCE": "Binance USDT-M futures/spot (DOLPHIN NG7 scan-feed universe)", "BINGX": "BingX perpetual swap, live", "BINGX_VST": "BingX perpetual swap, VST demo/testnet", } _STATUSES = ("TRADING", "OFFLINE", "UNKNOWN") class ListingStatus: TRADING = "TRADING" OFFLINE = "OFFLINE" UNKNOWN = "UNKNOWN" def normalize_symbol(symbol: str) -> str: """Canonical form: uppercase, separators stripped. 'BAND-USDT' -> 'BANDUSDT'.""" return symbol.replace("-", "").replace("_", "").replace("/", "").strip().upper() @dataclass class ExchangeListing: venue_symbol: str # symbol as the venue spells it status: str = ListingStatus.UNKNOWN # TRADING / OFFLINE / UNKNOWN last_checked: str = "" # ISO-8601 UTC, "" = never verified source: str = "" # who asserted this (feed, API probe, human) def __post_init__(self) -> None: if self.status not in _STATUSES: raise ValueError(f"unknown listing status {self.status!r}; use {_STATUSES}") @dataclass class AssetRecord: symbol: str # canonical (normalized) base: str = "" # e.g. BAND quote: str = "" # e.g. USDT exchanges: Dict[str, ExchangeListing] = field(default_factory=dict) profile_ref: str = "" # key into ASSET_PROFILES when taxonomy exists notes: str = "" def listed_on(self, exchange: str) -> bool: lst = self.exchanges.get(exchange) return lst is not None and lst.status == ListingStatus.TRADING def _default_path() -> Path: env = os.environ.get("MALKHUT_ASSET_DIR_PATH", "") if env: return Path(env) return Path(__file__).resolve().parent / "asset_directory.json" class AssetDirectory: """Load / mutate / persist the asset universe. All symbols canonical.""" def __init__(self, path: Optional[Path] = None) -> None: self.path = Path(path) if path else _default_path() self.records: Dict[str, AssetRecord] = {} if self.path.exists(): self.load() # ── persistence ─────────────────────────────────────────────────── def load(self) -> None: raw = json.loads(self.path.read_text(encoding="utf-8")) self.records = {} for sym, rec in raw.get("assets", {}).items(): exchanges = { ex: ExchangeListing(**lst) for ex, lst in rec.get("exchanges", {}).items() } self.records[sym] = AssetRecord( symbol=sym, base=rec.get("base", ""), quote=rec.get("quote", ""), exchanges=exchanges, profile_ref=rec.get("profile_ref", ""), notes=rec.get("notes", ""), ) def save(self) -> None: payload = { "schema_version": SCHEMA_VERSION, "known_exchanges": KNOWN_EXCHANGES, "assets": { sym: { "base": r.base, "quote": r.quote, "exchanges": {ex: asdict(l) for ex, l in sorted(r.exchanges.items())}, "profile_ref": r.profile_ref, "notes": r.notes, } for sym, r in sorted(self.records.items()) }, } tmp = self.path.with_suffix(".json.tmp") tmp.write_text(json.dumps(payload, indent=1, sort_keys=True), encoding="utf-8") tmp.replace(self.path) # ── mutation ────────────────────────────────────────────────────── def upsert(self, symbol: str, *, base: str = "", quote: str = "") -> AssetRecord: sym = normalize_symbol(symbol) rec = self.records.get(sym) if rec is None: if not base and not quote and sym.endswith("USDT"): base, quote = sym[:-4], "USDT" rec = AssetRecord(symbol=sym, base=base, quote=quote) self.records[sym] = rec return rec def set_listing( self, symbol: str, exchange: str, *, venue_symbol: str = "", status: str = ListingStatus.UNKNOWN, checked_at: str = "", source: str = "", ) -> None: if exchange not in KNOWN_EXCHANGES: raise ValueError( f"unknown exchange {exchange!r}; add it to KNOWN_EXCHANGES first" ) rec = self.upsert(symbol) rec.exchanges[exchange] = ExchangeListing( venue_symbol=venue_symbol or rec.symbol, status=status, last_checked=checked_at, source=source, ) def import_symbols( self, symbols: Iterable[str], exchange: str, *, status: str = ListingStatus.TRADING, checked_at: str = "", source: str = "", ) -> int: """Bulk-import a symbol list as listings on one exchange. Idempotent.""" n = 0 for s in symbols: self.set_listing( s, exchange, venue_symbol=s, status=status, checked_at=checked_at, source=source, ) n += 1 return n # ── queries ─────────────────────────────────────────────────────── def symbols_for_exchange( self, exchange: str, *, status: str = ListingStatus.TRADING ) -> List[str]: if exchange not in KNOWN_EXCHANGES: raise ValueError(f"unknown exchange {exchange!r}") return sorted( sym for sym, r in self.records.items() if (l := r.exchanges.get(exchange)) is not None and l.status == status ) def venue_symbol(self, symbol: str, exchange: str) -> str: """Venue-local spelling for a canonical symbol ('' if unlisted).""" rec = self.records.get(normalize_symbol(symbol)) if rec is None: return "" lst = rec.exchanges.get(exchange) return lst.venue_symbol if lst else "" def get(self, symbol: str) -> Optional[AssetRecord]: return self.records.get(normalize_symbol(symbol)) def __len__(self) -> int: return len(self.records)