254 lines
8.1 KiB
Python
254 lines
8.1 KiB
Python
|
|
"""
|
||
|
|
Tests for DuckDB asset store — schema, sync, queries, persistence.
|
||
|
|
|
||
|
|
Covers:
|
||
|
|
- Schema creation and table structure
|
||
|
|
- Sync from in-memory profiles → DuckDB
|
||
|
|
- Asset CRUD (upsert, query, get)
|
||
|
|
- Exchange-asset mapping
|
||
|
|
- Behavior profiles
|
||
|
|
- Cross-exchange queries
|
||
|
|
- Blockchain filtering
|
||
|
|
- Case-insensitive exchange lookup
|
||
|
|
- Persistence across connections
|
||
|
|
- Performance baseline
|
||
|
|
"""
|
||
|
|
import pytest
|
||
|
|
import os
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from malkhut.storage.asset_store import AssetStore
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def store(tmp_path):
|
||
|
|
"""Create a fresh DuckDB store for each test."""
|
||
|
|
db_path = str(tmp_path / "test_assets.duckdb")
|
||
|
|
s = AssetStore(db_path=db_path)
|
||
|
|
yield s
|
||
|
|
s.close()
|
||
|
|
|
||
|
|
|
||
|
|
class TestSchema:
|
||
|
|
def test_tables_created(self, store):
|
||
|
|
tables = store.conn.execute(
|
||
|
|
"SELECT table_name FROM information_schema.tables WHERE table_schema='main'"
|
||
|
|
).fetchall()
|
||
|
|
names = {t[0] for t in tables}
|
||
|
|
assert "assets" in names
|
||
|
|
assert "exchanges" in names
|
||
|
|
assert "asset_exchanges" in names
|
||
|
|
assert "behavior_profiles" in names
|
||
|
|
|
||
|
|
def test_asset_table_columns(self, store):
|
||
|
|
cols = store.conn.execute(
|
||
|
|
"SELECT column_name FROM information_schema.columns WHERE table_name='assets'"
|
||
|
|
).fetchall()
|
||
|
|
col_names = [c[0] for c in cols]
|
||
|
|
assert "symbol" in col_names
|
||
|
|
assert "base_asset" in col_names
|
||
|
|
assert "coingecko_id" in col_names
|
||
|
|
assert "sectors" in col_names
|
||
|
|
assert "tick_size" in col_names
|
||
|
|
|
||
|
|
def test_indexes_created(self, store):
|
||
|
|
indexes = store.conn.execute(
|
||
|
|
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='assets'"
|
||
|
|
).fetchall()
|
||
|
|
idx_names = {i[0] for i in indexes}
|
||
|
|
assert any("blockchain" in n for n in idx_names)
|
||
|
|
assert any("coingecko" in n for n in idx_names)
|
||
|
|
|
||
|
|
|
||
|
|
class TestSyncFromProfiles:
|
||
|
|
def test_sync_returns_count(self, store):
|
||
|
|
count = store.sync_from_profiles()
|
||
|
|
assert count >= 13
|
||
|
|
|
||
|
|
def test_sync_populates_assets(self, store):
|
||
|
|
store.sync_from_profiles()
|
||
|
|
assert store.asset_count() >= 13
|
||
|
|
|
||
|
|
def test_sync_populates_exchanges(self, store):
|
||
|
|
store.sync_from_profiles()
|
||
|
|
assert store.exchange_count() >= 3
|
||
|
|
|
||
|
|
def test_sync_populates_asset_exchanges(self, store):
|
||
|
|
store.sync_from_profiles()
|
||
|
|
exs = store.get_asset_exchanges("BTCUSDT")
|
||
|
|
assert len(exs) >= 1
|
||
|
|
|
||
|
|
def test_sync_populates_behaviors(self, store):
|
||
|
|
store.sync_from_profiles()
|
||
|
|
row = store.conn.execute(
|
||
|
|
"SELECT * FROM behavior_profiles WHERE symbol = 'BTCUSDT'"
|
||
|
|
).fetchone()
|
||
|
|
assert row is not None
|
||
|
|
|
||
|
|
def test_sync_is_idempotent(self, store):
|
||
|
|
store.sync_from_profiles()
|
||
|
|
count1 = store.asset_count()
|
||
|
|
store.sync_from_profiles()
|
||
|
|
count2 = store.asset_count()
|
||
|
|
assert count1 == count2
|
||
|
|
|
||
|
|
|
||
|
|
class TestAssetCRUD:
|
||
|
|
def test_get_asset_hit(self, store):
|
||
|
|
store.sync_from_profiles()
|
||
|
|
btc = store.get_asset("BTCUSDT")
|
||
|
|
assert btc is not None
|
||
|
|
assert btc["base_asset"] == "BTC"
|
||
|
|
assert btc["name"] == "BTC"
|
||
|
|
|
||
|
|
def test_get_asset_miss(self, store):
|
||
|
|
store.sync_from_profiles()
|
||
|
|
assert store.get_asset("NONEXISTENT") is None
|
||
|
|
|
||
|
|
def test_get_asset_all_fields(self, store):
|
||
|
|
store.sync_from_profiles()
|
||
|
|
btc = store.get_asset("BTCUSDT")
|
||
|
|
assert btc["coingecko_id"] == "bitcoin"
|
||
|
|
assert btc["cmc_id"] == 1
|
||
|
|
assert btc["blockchain"] == "bitcoin"
|
||
|
|
assert btc["unified_symbol"] == "BTC/USDT"
|
||
|
|
assert btc["quote_currency"] == "USDT"
|
||
|
|
assert btc["tick_size"] == 0.1
|
||
|
|
assert btc["lot_size"] == 0.001
|
||
|
|
|
||
|
|
def test_query_assets_by_blockchain(self, store):
|
||
|
|
store.sync_from_profiles()
|
||
|
|
eth_assets = store.query_assets(blockchain="ethereum")
|
||
|
|
assert len(eth_assets) >= 5
|
||
|
|
symbols = {a["symbol"] for a in eth_assets}
|
||
|
|
assert "ETHUSDT" in symbols
|
||
|
|
assert "UNIUSDT" in symbols
|
||
|
|
|
||
|
|
def test_query_assets_by_coingecko(self, store):
|
||
|
|
store.sync_from_profiles()
|
||
|
|
results = store.query_assets(coingecko_id="bitcoin")
|
||
|
|
assert len(results) == 1
|
||
|
|
assert results[0]["symbol"] == "BTCUSDT"
|
||
|
|
|
||
|
|
def test_query_assets_by_sector(self, store):
|
||
|
|
store.sync_from_profiles()
|
||
|
|
defi = store.query_assets(sectors=["defi"])
|
||
|
|
assert len(defi) >= 2
|
||
|
|
symbols = {a["symbol"] for a in defi}
|
||
|
|
assert "UNIUSDT" in symbols
|
||
|
|
|
||
|
|
def test_asset_count(self, store):
|
||
|
|
store.sync_from_profiles()
|
||
|
|
assert store.asset_count() == 13
|
||
|
|
|
||
|
|
|
||
|
|
class TestExchangeMapping:
|
||
|
|
def test_symbols_for_exchange(self, store):
|
||
|
|
store.sync_from_profiles()
|
||
|
|
binance_assets = store.symbols_for_exchange("binance")
|
||
|
|
assert len(binance_assets) >= 13
|
||
|
|
|
||
|
|
def test_symbols_for_exchange_case_insensitive(self, store):
|
||
|
|
store.sync_from_profiles()
|
||
|
|
upper = store.symbols_for_exchange("BINANCE")
|
||
|
|
lower = store.symbols_for_exchange("binance")
|
||
|
|
assert upper == lower
|
||
|
|
|
||
|
|
def test_symbols_for_unknown_exchange(self, store):
|
||
|
|
store.sync_from_profiles()
|
||
|
|
assert store.symbols_for_exchange("KRAKEN") == []
|
||
|
|
|
||
|
|
def test_assets_on_blockchain(self, store):
|
||
|
|
store.sync_from_profiles()
|
||
|
|
eth = store.assets_on_blockchain("ethereum")
|
||
|
|
assert len(eth) >= 5
|
||
|
|
|
||
|
|
def test_assets_on_unknown_blockchain(self, store):
|
||
|
|
store.sync_from_profiles()
|
||
|
|
assert store.assets_on_blockchain("nonexistent") == []
|
||
|
|
|
||
|
|
|
||
|
|
class TestBehaviorProfiles:
|
||
|
|
def test_behavior_stored(self, store):
|
||
|
|
store.sync_from_profiles()
|
||
|
|
row = store.conn.execute(
|
||
|
|
"SELECT depth_amplitude_usd, vol_annualized_normal FROM behavior_profiles WHERE symbol='BTCUSDT'"
|
||
|
|
).fetchone()
|
||
|
|
assert row[0] == 750_000.0
|
||
|
|
assert row[1] == 35.0
|
||
|
|
|
||
|
|
def test_behavior_template(self, store):
|
||
|
|
store.sync_from_profiles()
|
||
|
|
row = store.conn.execute(
|
||
|
|
"SELECT template_name FROM behavior_profiles WHERE symbol='BTCUSDT'"
|
||
|
|
).fetchone()
|
||
|
|
assert row[0] == "institutional_blue_chip"
|
||
|
|
|
||
|
|
def test_behavior_reference_price(self, store):
|
||
|
|
store.sync_from_profiles()
|
||
|
|
row = store.conn.execute(
|
||
|
|
"SELECT reference_price FROM behavior_profiles WHERE symbol='BTCUSDT'"
|
||
|
|
).fetchone()
|
||
|
|
assert row[0] == 64000.0
|
||
|
|
|
||
|
|
def test_behavior_liquidation(self, store):
|
||
|
|
store.sync_from_profiles()
|
||
|
|
row = store.conn.execute(
|
||
|
|
"SELECT liq_speed, liq_recovery FROM behavior_profiles WHERE symbol='BTCUSDT'"
|
||
|
|
).fetchone()
|
||
|
|
assert row[0] == "slow"
|
||
|
|
assert row[1] == "fast"
|
||
|
|
|
||
|
|
|
||
|
|
class TestPersistence:
|
||
|
|
def test_data_survives_reopen(self, tmp_path):
|
||
|
|
db_path = str(tmp_path / "persist.duckdb")
|
||
|
|
s1 = AssetStore(db_path=db_path)
|
||
|
|
s1.sync_from_profiles()
|
||
|
|
s1.close()
|
||
|
|
|
||
|
|
s2 = AssetStore(db_path=db_path)
|
||
|
|
assert s2.asset_count() >= 13
|
||
|
|
btc = s2.get_asset("BTCUSDT")
|
||
|
|
assert btc["base_asset"] == "BTC"
|
||
|
|
s2.close()
|
||
|
|
|
||
|
|
def test_exchange_mapping_survives(self, tmp_path):
|
||
|
|
db_path = str(tmp_path / "persist.duckdb")
|
||
|
|
s1 = AssetStore(db_path=db_path)
|
||
|
|
s1.sync_from_profiles()
|
||
|
|
exs = s1.get_asset_exchanges("BTCUSDT")
|
||
|
|
s1.close()
|
||
|
|
|
||
|
|
s2 = AssetStore(db_path=db_path)
|
||
|
|
exs2 = s2.get_asset_exchanges("BTCUSDT")
|
||
|
|
assert exs == exs2
|
||
|
|
s2.close()
|
||
|
|
|
||
|
|
|
||
|
|
class TestPerformanceBaseline:
|
||
|
|
def test_sync_speed(self, store):
|
||
|
|
import time
|
||
|
|
t0 = time.time()
|
||
|
|
store.sync_from_profiles()
|
||
|
|
elapsed = time.time() - t0
|
||
|
|
assert elapsed < 1.0, f"Sync took {elapsed:.2f}s"
|
||
|
|
|
||
|
|
def test_query_speed(self, store):
|
||
|
|
import time
|
||
|
|
store.sync_from_profiles()
|
||
|
|
t0 = time.time()
|
||
|
|
for _ in range(100):
|
||
|
|
store.query_assets(blockchain="ethereum")
|
||
|
|
elapsed = time.time() - t0
|
||
|
|
assert elapsed < 1.0, f"100 queries took {elapsed:.2f}s"
|
||
|
|
|
||
|
|
def test_get_speed(self, store):
|
||
|
|
import time
|
||
|
|
store.sync_from_profiles()
|
||
|
|
t0 = time.time()
|
||
|
|
for _ in range(1000):
|
||
|
|
store.get_asset("BTCUSDT")
|
||
|
|
elapsed = time.time() - t0
|
||
|
|
assert elapsed < 1.0, f"1000 gets took {elapsed:.2f}s"
|