146 lines
4.6 KiB
Python
146 lines
4.6 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from crypto_spot_bot.models import Candle, Ticker, utc_now
|
|
|
|
|
|
def market_quality_snapshot(
|
|
*,
|
|
symbols: list[str],
|
|
candles_by_symbol: dict[str, list[Candle]],
|
|
tickers: dict[str, Ticker],
|
|
interval: str,
|
|
) -> dict[str, Any]:
|
|
rows = [
|
|
analyze_symbol_quality(
|
|
symbol=symbol,
|
|
candles=candles_by_symbol.get(symbol, []),
|
|
ticker=tickers.get(symbol),
|
|
interval=interval,
|
|
)
|
|
for symbol in symbols
|
|
]
|
|
worst_score = min((row["score"] for row in rows), default=0.0)
|
|
status = "ok"
|
|
if any(row["status"] == "error" for row in rows):
|
|
status = "error"
|
|
elif any(row["status"] == "warn" for row in rows):
|
|
status = "warn"
|
|
return {
|
|
"status": status,
|
|
"score": round(worst_score, 4),
|
|
"symbols": rows,
|
|
}
|
|
|
|
|
|
def analyze_symbol_quality(
|
|
*,
|
|
symbol: str,
|
|
candles: list[Candle],
|
|
ticker: Ticker | None,
|
|
interval: str,
|
|
) -> dict[str, Any]:
|
|
issues: list[dict[str, Any]] = []
|
|
score = 1.0
|
|
interval_ms = _interval_ms(interval)
|
|
if not candles:
|
|
return _row(symbol, "error", 0.0, [{"code": "no_candles", "severity": "error"}], 0, None, None)
|
|
|
|
timestamps = [candle.timestamp for candle in candles]
|
|
duplicates = len(timestamps) - len(set(timestamps))
|
|
if duplicates:
|
|
issues.append({"code": "duplicate_candles", "severity": "warn", "count": duplicates})
|
|
score -= min(0.25, duplicates * 0.03)
|
|
|
|
invalid_ohlc = 0
|
|
zero_volume = 0
|
|
for candle in candles:
|
|
prices = [candle.open, candle.high, candle.low, candle.close]
|
|
if any(price <= 0 for price in prices) or candle.high < max(candle.open, candle.close) or candle.low > min(candle.open, candle.close):
|
|
invalid_ohlc += 1
|
|
if candle.volume <= 0:
|
|
zero_volume += 1
|
|
if invalid_ohlc:
|
|
issues.append({"code": "invalid_ohlc", "severity": "error", "count": invalid_ohlc})
|
|
score -= min(0.45, invalid_ohlc * 0.08)
|
|
if zero_volume:
|
|
issues.append({"code": "zero_volume", "severity": "warn", "count": zero_volume})
|
|
score -= min(0.20, zero_volume * 0.02)
|
|
|
|
missing_gaps = 0
|
|
largest_gap_ms = 0
|
|
if interval_ms > 0:
|
|
ordered = sorted(set(timestamps))
|
|
for left, right in zip(ordered, ordered[1:]):
|
|
gap = right - left
|
|
largest_gap_ms = max(largest_gap_ms, gap)
|
|
if gap > interval_ms * 1.5:
|
|
missing_gaps += max(1, round(gap / interval_ms) - 1)
|
|
if missing_gaps:
|
|
issues.append({"code": "missing_candles", "severity": "warn", "count": missing_gaps})
|
|
score -= min(0.35, missing_gaps * 0.04)
|
|
|
|
age_seconds = max(0.0, (utc_now().timestamp() * 1000 - max(timestamps)) / 1000)
|
|
stale_after = interval_ms / 1000 * 2.5
|
|
if age_seconds > stale_after:
|
|
issues.append({"code": "stale_candles", "severity": "warn", "age_seconds": round(age_seconds, 1)})
|
|
score -= 0.20
|
|
else:
|
|
age_seconds = None
|
|
|
|
if ticker is None:
|
|
issues.append({"code": "no_ticker", "severity": "error"})
|
|
score -= 0.45
|
|
else:
|
|
if ticker.last_price <= 0:
|
|
issues.append({"code": "invalid_ticker_price", "severity": "error"})
|
|
score -= 0.35
|
|
if ticker.spread_percent > 0.35:
|
|
issues.append({"code": "wide_spread", "severity": "warn", "spread_percent": round(ticker.spread_percent, 4)})
|
|
score -= 0.15
|
|
|
|
score = max(0.0, min(1.0, score))
|
|
severity = {str(issue.get("severity")) for issue in issues}
|
|
status = "error" if "error" in severity else "warn" if "warn" in severity else "ok"
|
|
return _row(
|
|
symbol,
|
|
status,
|
|
score,
|
|
issues,
|
|
len(candles),
|
|
max(timestamps),
|
|
largest_gap_ms if interval_ms > 0 else None,
|
|
)
|
|
|
|
|
|
def _row(
|
|
symbol: str,
|
|
status: str,
|
|
score: float,
|
|
issues: list[dict[str, Any]],
|
|
candle_count: int,
|
|
last_candle_timestamp: int | None,
|
|
largest_gap_ms: int | None,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"symbol": symbol,
|
|
"status": status,
|
|
"score": round(score, 4),
|
|
"candle_count": candle_count,
|
|
"last_candle_timestamp": last_candle_timestamp,
|
|
"largest_gap_ms": largest_gap_ms,
|
|
"issues": issues,
|
|
}
|
|
|
|
|
|
def _interval_ms(interval: str) -> int:
|
|
normalized = str(interval).strip().upper()
|
|
if normalized == "D":
|
|
return 24 * 60 * 60 * 1000
|
|
if normalized == "W":
|
|
return 7 * 24 * 60 * 60 * 1000
|
|
if normalized.isdigit():
|
|
return int(normalized) * 60 * 1000
|
|
return 0
|