Files
sentiment-engine/MALKHUT/malkhut/assets/directory.py
Codex a062507656 malkhut(assets)+uv: system-wide asset directory + UV universe init
MALKHUT asset directory (malkhut/assets/): normalized canonical symbols,
KNOWN_EXCHANGES aux table (BINANCE/BINGX/BINGX_VST), per-exchange listing
status (TRADING/OFFLINE/UNKNOWN), JSON-backed, built for full-Binance-500
scale. Seeded with BLUE's NG7 feed universe (50 symbols) + live VST
contracts probe: 35 TRADING / 15 OFFLINE on VST (BAND, CELR, COS, CVC,
DENT, FUN, HOT, ICX, TFUEL, TUSD, USDC, WAN, WIN, XTZ, ZIL).
uv_asset_universe.init_asset_universe() = runtime tradable set for the
execution exchange; --onboard runs MALKHUT AssetCompiler for full taxonomy.
12 new tests, mutation-RED verified (status-filter + tradability guard).
2026-07-11 21:48:05 +02:00

207 lines
8.1 KiB
Python

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