"""Reusable order lifecycle decisions for strategy execution. The functions here are pure decision helpers. They do not know whether an order is simulated or live; adapters decide how to persist, cancel, fill, or send exchange API calls. """ from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime, timedelta from app.core.trade_math import normalize_side def _safe_float(value, default: float = 0.0) -> float: try: if value is None or value == "": return default return float(value) except Exception: return default def _parse_time(value: str) -> datetime | None: if not value: return None try: return datetime.fromisoformat(str(value).replace("Z", "+00:00")) except Exception: return None @dataclass(frozen=True) class OrderLifecycleDecision: action: str = "hold" reason: str = "" detail: dict = field(default_factory=dict) def as_dict(self) -> dict: return {"action": self.action, "reason": self.reason, "detail": self.detail} def order_expires_at(event_time: str, expire_hours: float = 24.0) -> str: hours = max(0.25, _safe_float(expire_hours, 24.0)) base = _parse_time(event_time) or datetime.now() return (base + timedelta(hours=hours)).isoformat() def order_touched(order: dict, current_price: float) -> bool: side = normalize_side(order.get("side")) target = _safe_float(order.get("target_price")) price = _safe_float(current_price) if target <= 0 or price <= 0: return False if side == "short": return price >= target return price <= target def order_too_far(order: dict, current_price: float, threshold_pct: float = 12.0) -> bool: threshold = max(0.0, _safe_float(threshold_pct, 12.0)) if threshold <= 0: return False side = normalize_side(order.get("side")) target = _safe_float(order.get("target_price")) price = _safe_float(current_price) if target <= 0 or price <= 0: return False if side == "short": return price < target * (1 - threshold / 100) return price > target * (1 + threshold / 100) def order_rr(side: str, target: float, stop_loss: float, tp1: float) -> float: if normalize_side(side) == "short": risk = _safe_float(stop_loss) - _safe_float(target) reward = _safe_float(target) - _safe_float(tp1) else: risk = _safe_float(target) - _safe_float(stop_loss) reward = _safe_float(tp1) - _safe_float(target) if risk <= 0 or reward <= 0: return 0.0 return reward / risk def order_distance_pct(side: str, current_price: float, target: float) -> float: price = _safe_float(current_price) target_price = _safe_float(target) if target_price <= 0 or price <= 0: return 999.0 if normalize_side(side) == "short": return max(0.0, (target_price / price - 1) * 100) return max(0.0, (price / target_price - 1) * 100) def evaluate_limit_order( *, order: dict, current_price: float, event_time: str, config: dict | None = None, ) -> OrderLifecycleDecision: cfg = config if isinstance(config, dict) else {} detail = { "order_id": order.get("id"), "side": order.get("side") or "long", "target_price": _safe_float(order.get("target_price")), "current_price": _safe_float(current_price), "expires_at": order.get("expires_at") or "", } if order_touched(order, current_price): return OrderLifecycleDecision(action="fill", reason="touched", detail=detail) expires_at = _parse_time(str(order.get("expires_at") or "")) now = _parse_time(event_time) or datetime.now() if expires_at and now > expires_at: return OrderLifecycleDecision(action="cancel", reason="expired", detail=detail) threshold = max(0.0, _safe_float(cfg.get("order_cancel_far_from_entry_pct"), 12.0)) if order_too_far(order, current_price, threshold): return OrderLifecycleDecision(action="cancel", reason="too_far_from_entry", detail={**detail, "threshold_pct": threshold}) return OrderLifecycleDecision(action="hold", reason="pending", detail=detail) __all__ = [ "OrderLifecycleDecision", "evaluate_limit_order", "order_distance_pct", "order_expires_at", "order_rr", "order_too_far", "order_touched", ]