alphax/app/core/order_lifecycle.py
2026-05-31 14:10:44 +08:00

134 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
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 = str(order.get("side") or "long").lower()
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 = str(order.get("side") or "long").lower()
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 str(side or "long").lower() == "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 str(side or "long").lower() == "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",
]