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).
This commit is contained in:
17
MALKHUT/malkhut/assets/__init__.py
Normal file
17
MALKHUT/malkhut/assets/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from malkhut.assets.directory import (
|
||||
KNOWN_EXCHANGES,
|
||||
AssetDirectory,
|
||||
AssetRecord,
|
||||
ExchangeListing,
|
||||
ListingStatus,
|
||||
normalize_symbol,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"KNOWN_EXCHANGES",
|
||||
"AssetDirectory",
|
||||
"AssetRecord",
|
||||
"ExchangeListing",
|
||||
"ListingStatus",
|
||||
"normalize_symbol",
|
||||
]
|
||||
1010
MALKHUT/malkhut/assets/asset_directory.json
Normal file
1010
MALKHUT/malkhut/assets/asset_directory.json
Normal file
File diff suppressed because it is too large
Load Diff
206
MALKHUT/malkhut/assets/directory.py
Normal file
206
MALKHUT/malkhut/assets/directory.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
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)
|
||||
90
MALKHUT/malkhut/tests/test_asset_directory.py
Normal file
90
MALKHUT/malkhut/tests/test_asset_directory.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Tests for malkhut.assets.directory — the system-wide asset universe store.
|
||||
|
||||
Mutation-sensitivity: breaking normalize_symbol's uppercasing, listed_on's
|
||||
status comparison, or symbols_for_exchange's filter goes RED here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from malkhut.assets.directory import (
|
||||
KNOWN_EXCHANGES,
|
||||
AssetDirectory,
|
||||
ExchangeListing,
|
||||
ListingStatus,
|
||||
normalize_symbol,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_symbol_variants():
|
||||
assert normalize_symbol("BAND-USDT") == "BANDUSDT"
|
||||
assert normalize_symbol("band_usdt") == "BANDUSDT"
|
||||
assert normalize_symbol(" eth/usdt ") == "ETHUSDT"
|
||||
assert normalize_symbol("BANDUSDT") == "BANDUSDT"
|
||||
|
||||
|
||||
def test_known_exchanges_table_has_the_three_venues():
|
||||
assert {"BINANCE", "BINGX", "BINGX_VST"} <= set(KNOWN_EXCHANGES)
|
||||
|
||||
|
||||
def test_listing_status_validated():
|
||||
with pytest.raises(ValueError):
|
||||
ExchangeListing(venue_symbol="X-USDT", status="LISTED") # not a valid status
|
||||
|
||||
|
||||
def test_set_listing_rejects_unknown_exchange(tmp_path):
|
||||
d = AssetDirectory(tmp_path / "d.json")
|
||||
with pytest.raises(ValueError):
|
||||
d.set_listing("BTCUSDT", "KRAKEN", status=ListingStatus.TRADING)
|
||||
|
||||
|
||||
def test_upsert_splits_base_quote_for_usdt():
|
||||
d = AssetDirectory.__new__(AssetDirectory)
|
||||
d.records = {}
|
||||
rec = d.upsert("BANDUSDT")
|
||||
assert rec.base == "BAND" and rec.quote == "USDT"
|
||||
|
||||
|
||||
def test_import_and_query_roundtrip(tmp_path):
|
||||
p = tmp_path / "dir.json"
|
||||
d = AssetDirectory(p)
|
||||
n = d.import_symbols(
|
||||
["ETHUSDT", "BANDUSDT", "CELRUSDT"], "BINANCE",
|
||||
status=ListingStatus.TRADING, checked_at="2026-07-11T20:00:00Z",
|
||||
source="dolphin_ng7_scan_feed",
|
||||
)
|
||||
assert n == 3
|
||||
d.set_listing("BANDUSDT", "BINGX_VST", venue_symbol="BAND-USDT",
|
||||
status=ListingStatus.OFFLINE, source="vst_contracts_probe")
|
||||
d.set_listing("ETHUSDT", "BINGX_VST", venue_symbol="ETH-USDT",
|
||||
status=ListingStatus.TRADING, source="vst_contracts_probe")
|
||||
d.save()
|
||||
|
||||
d2 = AssetDirectory(p) # reload from disk
|
||||
assert len(d2) == 3
|
||||
assert d2.symbols_for_exchange("BINANCE") == ["BANDUSDT", "CELRUSDT", "ETHUSDT"]
|
||||
# BAND is OFFLINE on VST → excluded from the tradable set
|
||||
assert d2.symbols_for_exchange("BINGX_VST") == ["ETHUSDT"]
|
||||
assert d2.symbols_for_exchange("BINGX_VST", status=ListingStatus.OFFLINE) == ["BANDUSDT"]
|
||||
assert d2.venue_symbol("ETHUSDT", "BINGX_VST") == "ETH-USDT"
|
||||
assert d2.venue_symbol("BANDUSDT", "BINGX") == "" # never probed on live BingX
|
||||
assert d2.get("band-usdt").listed_on("BINANCE") is True
|
||||
assert d2.get("band-usdt").listed_on("BINGX_VST") is False
|
||||
|
||||
|
||||
def test_import_is_idempotent(tmp_path):
|
||||
d = AssetDirectory(tmp_path / "d.json")
|
||||
d.import_symbols(["ETHUSDT"], "BINANCE")
|
||||
d.import_symbols(["ETHUSDT"], "BINANCE")
|
||||
assert len(d) == 1
|
||||
assert d.symbols_for_exchange("BINANCE") == ["ETHUSDT"]
|
||||
|
||||
|
||||
def test_listing_on_second_exchange_preserves_first(tmp_path):
|
||||
d = AssetDirectory(tmp_path / "d.json")
|
||||
d.import_symbols(["ETHUSDT"], "BINANCE")
|
||||
d.set_listing("ETHUSDT", "BINGX_VST", venue_symbol="ETH-USDT",
|
||||
status=ListingStatus.TRADING)
|
||||
rec = d.get("ETHUSDT")
|
||||
assert set(rec.exchanges) == {"BINANCE", "BINGX_VST"}
|
||||
Reference in New Issue
Block a user