136 lines
4.3 KiB
Python
136 lines
4.3 KiB
Python
"""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",
|
|
]
|