from __future__ import annotations from decimal import Decimal from typing import Any from nautilus_trader.model.enums import LiquiditySide from nautilus_trader.model.enums import OrderType from nautilus_trader.model.enums import TimeInForce from nautilus_trader.model.objects import Price from nautilus_trader.model.objects import Quantity from nautilus_trader.model.orders import Order def _decimal(value: Any) -> Decimal | None: if value in (None, "", "null"): return None try: return Decimal(str(value)) except Exception: return None def _decimal_text(value: Decimal | None) -> str | None: if value is None: return None text = format(value.normalize(), "f") if "." in text: text = text.rstrip("0").rstrip(".") return text or "0" def _fill_quality_score( *, slippage_bps: Decimal | None, fee_bps: Decimal | None, liquidity_side: LiquiditySide, ) -> tuple[Decimal, str]: """Return a compact fill-quality score and label. The score is intentionally conservative: 100 is ideal, and lower scores reflect adverse slippage plus fee drag. Maker fills are allowed a small bonus because they are typically less toxic than taker fills. """ score = Decimal("100") if slippage_bps is not None: score -= abs(slippage_bps) if fee_bps is not None: score -= abs(fee_bps) if liquidity_side == LiquiditySide.MAKER: score += Decimal("1") score = max(Decimal("0"), min(Decimal("100"), score)) if score >= Decimal("99"): label = "excellent" elif score >= Decimal("95"): label = "good" elif score >= Decimal("85"): label = "fair" else: label = "poor" return score, label def _price_from_order(order: Order) -> Decimal | None: if getattr(order, "has_price", False): price = getattr(order, "price", None) if price is not None: try: return price.as_decimal() except Exception: return _decimal(price) return None def infer_liquidity_side(order: Order, row: dict[str, Any] | None = None) -> LiquiditySide: row = row or {} explicit = row.get("liquiditySide") or row.get("liquidity_side") if isinstance(explicit, str): upper = explicit.upper() if "MAKER" in upper: return LiquiditySide.MAKER if "TAKER" in upper: return LiquiditySide.TAKER for key in ("isMaker", "maker", "m"): value = row.get(key) if isinstance(value, bool): return LiquiditySide.MAKER if value else LiquiditySide.TAKER if isinstance(value, str): lower = value.strip().lower() if lower in {"true", "1", "yes", "maker"}: return LiquiditySide.MAKER if lower in {"false", "0", "no", "taker"}: return LiquiditySide.TAKER if bool(getattr(order, "is_post_only", False)): return LiquiditySide.MAKER tif = getattr(order, "time_in_force", None) if tif in {TimeInForce.IOC, TimeInForce.FOK}: return LiquiditySide.TAKER if order.order_type in { OrderType.MARKET, OrderType.STOP_MARKET, OrderType.MARKET_IF_TOUCHED, OrderType.TRAILING_STOP_MARKET, }: return LiquiditySide.TAKER if order.order_type in { OrderType.LIMIT, OrderType.STOP_LIMIT, OrderType.LIMIT_IF_TOUCHED, OrderType.TRAILING_STOP_LIMIT, }: return LiquiditySide.MAKER return LiquiditySide.NO_LIQUIDITY_SIDE def reference_price(order: Order, row: dict[str, Any] | None = None) -> tuple[Decimal | None, str]: row = row or {} for key in ("referencePrice", "referencePx", "expectedPrice", "expectedPx", "bestPrice"): value = _decimal(row.get(key)) if value is not None and value > 0: return value, key if getattr(order, "has_price", False): price = _price_from_order(order) if price is not None and price > 0: return price, "order_price" if getattr(order, "has_trigger_price", False) and order.order_type in { OrderType.STOP_MARKET, OrderType.MARKET_IF_TOUCHED, OrderType.TRAILING_STOP_MARKET, }: trigger = getattr(order, "trigger_price", None) if trigger is not None: try: value = trigger.as_decimal() except Exception: value = _decimal(trigger) if value is not None and value > 0: return value, "trigger_price" return None, "unavailable" def _commission_quote( *, commission_amount: Decimal, commission_asset: str, quote_currency: str, base_currency: str, last_px: Decimal | None, estimate_quote: Decimal, ) -> tuple[Decimal, str]: if commission_amount == 0: return estimate_quote, "estimated" if commission_asset == quote_currency: return commission_amount, "quote" if commission_asset == base_currency and last_px is not None: return commission_amount * last_px, "base_converted" return estimate_quote, "estimated" def estimate_friction( order: Order, row: dict[str, Any], *, last_qty: Quantity | None = None, last_px: Price | None = None, quote_currency: str, base_currency: str, maker_fee: Decimal, taker_fee: Decimal, feed_source: str = "unknown", ) -> dict[str, Any]: qty = last_qty.as_decimal() if last_qty is not None else _decimal(row.get("lastFilledQty") or row.get("executedQty")) or Decimal("0") px = last_px.as_decimal() if last_px is not None else _decimal(row.get("lastFillPrice") or row.get("avgPrice") or row.get("price")) notional = qty * px if px is not None else Decimal("0") liquidity_side = infer_liquidity_side(order, row) fee_rate = maker_fee if liquidity_side == LiquiditySide.MAKER else taker_fee if liquidity_side == LiquiditySide.TAKER else taker_fee estimated_fee_quote = notional * fee_rate if notional > 0 else Decimal("0") commission_amount = _decimal(row.get("commission")) or Decimal("0") commission_asset = str(row.get("commissionAsset") or quote_currency) actual_fee_quote, commission_source = _commission_quote( commission_amount=commission_amount, commission_asset=commission_asset, quote_currency=quote_currency, base_currency=base_currency, last_px=px, estimate_quote=estimated_fee_quote, ) reference_px, reference_source = reference_price(order, row) slippage_quote = None slippage_bps = None if reference_px is not None and px is not None and qty > 0: side = getattr(order.side, "name", str(order.side)).upper() delta = px - reference_px if side == "BUY" else reference_px - px slippage_quote = delta * qty if reference_px > 0: slippage_bps = (delta / reference_px) * Decimal("10000") net_friction_quote = actual_fee_quote + (slippage_quote or Decimal("0")) gross_friction_quote = abs(actual_fee_quote) + abs(slippage_quote or Decimal("0")) fee_bps = None if notional > 0: fee_bps = (actual_fee_quote / notional) * Decimal("10000") fill_quality_score, fill_quality_class = _fill_quality_score( slippage_bps=slippage_bps, fee_bps=fee_bps, liquidity_side=liquidity_side, ) return { "liquidity_side": getattr(liquidity_side, "name", str(liquidity_side)), "feed_source": str(feed_source or "unknown"), "commission_asset": commission_asset, "commission_source": commission_source, "commission_quote": _decimal_text(actual_fee_quote), "estimated_fee_quote": _decimal_text(estimated_fee_quote), "fee_rate": _decimal_text(fee_rate), "fee_bps": _decimal_text(fee_bps), "reference_px": _decimal_text(reference_px), "reference_source": reference_source, "slippage_quote": _decimal_text(slippage_quote), "slippage_bps": _decimal_text(slippage_bps), "net_friction_quote": _decimal_text(net_friction_quote), "gross_friction_quote": _decimal_text(gross_friction_quote), "fill_quality_score": _decimal_text(fill_quality_score), "fill_quality_class": fill_quality_class, "notional_quote": _decimal_text(notional), "last_qty": _decimal_text(qty), "last_px": _decimal_text(px), }