59a45ed027
Sync order monitors from Gate position history after hub flat close. Store and display initial_stop_loss in trade records instead of post-amend exchange stops. Co-authored-by: Cursor <cursoragent@cursor.com>
242 lines
8.1 KiB
Python
242 lines
8.1 KiB
Python
"""实时持仓展示:开仓快照盈亏比、交易所止损是否已保本。"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Callable, Optional
|
|
|
|
|
|
def _positive_float(value: Any) -> Optional[float]:
|
|
try:
|
|
v = float(value)
|
|
return v if v > 0 else None
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def snapshot_stop_loss(initial_stop_loss: Any, stop_loss: Any) -> Optional[float]:
|
|
"""展示盈亏比 / 交易记录时优先用开仓时止损快照,不用后续改单后的止损。"""
|
|
sl = _positive_float(initial_stop_loss)
|
|
if sl is not None:
|
|
return sl
|
|
return _positive_float(stop_loss)
|
|
|
|
|
|
def monitor_open_stop_loss(row: Any) -> Optional[float]:
|
|
"""从 order_monitors 行取开仓止损快照。"""
|
|
try:
|
|
keys = row.keys() if hasattr(row, "keys") else ()
|
|
except Exception:
|
|
keys = ()
|
|
init = row["initial_stop_loss"] if "initial_stop_loss" in keys else None
|
|
cur = row["stop_loss"] if "stop_loss" in keys else None
|
|
if init is None and isinstance(row, dict):
|
|
init = row.get("initial_stop_loss")
|
|
cur = row.get("stop_loss")
|
|
return snapshot_stop_loss(init, cur)
|
|
|
|
|
|
def snapshot_rr(
|
|
calc_rr_ratio_fn: Callable[..., Optional[float]],
|
|
direction: str,
|
|
trigger_price: Any,
|
|
initial_stop_loss: Any,
|
|
stop_loss: Any,
|
|
take_profit: Any,
|
|
) -> Optional[float]:
|
|
entry = _positive_float(trigger_price)
|
|
sl = snapshot_stop_loss(initial_stop_loss, stop_loss)
|
|
tp = _positive_float(take_profit)
|
|
if entry is None or sl is None or tp is None:
|
|
return None
|
|
return calc_rr_ratio_fn(direction or "long", entry, sl, tp)
|
|
|
|
|
|
def tpsl_slot_trigger_price(slot: Any) -> Optional[float]:
|
|
if not isinstance(slot, dict):
|
|
return None
|
|
for key in ("trigger_price", "trigger_display"):
|
|
v = _positive_float(slot.get(key))
|
|
if v is not None:
|
|
return v
|
|
return None
|
|
|
|
|
|
def stop_is_profit_protecting(direction: str, entry_price: Any, stop_loss: Any) -> bool:
|
|
"""
|
|
止损是否已在盈利侧(保本/锁盈),不再适用「开仓盈亏比」风控。
|
|
做空:止损 < 成交价;做多:止损 > 成交价。
|
|
"""
|
|
entry = _positive_float(entry_price)
|
|
sl = _positive_float(stop_loss)
|
|
if entry is None or sl is None:
|
|
return False
|
|
d = (direction or "long").strip().lower()
|
|
if d == "short":
|
|
return sl < entry
|
|
return sl > entry
|
|
|
|
|
|
def tpsl_update_passes_rr_gate(
|
|
direction: str,
|
|
entry_price: Any,
|
|
stop_loss: Any,
|
|
take_profit: Any,
|
|
min_rr: float,
|
|
calc_rr_ratio_fn: Callable[..., Optional[float]],
|
|
) -> tuple[bool, Optional[str]]:
|
|
"""持仓委托改价:盈利侧止损跳过最低盈亏比;否则按开仓价几何校验。"""
|
|
if stop_is_profit_protecting(direction, entry_price, stop_loss):
|
|
return True, None
|
|
rr = calc_rr_ratio_fn(direction or "long", entry_price, stop_loss, take_profit)
|
|
if rr is not None and rr >= float(min_rr):
|
|
return True, None
|
|
rr_txt = f"{rr:.4f}" if rr is not None else "无法计算"
|
|
return False, f"计划盈亏比 {rr_txt}:1 低于最低要求 {min_rr}:1(盈利侧保本止损不受此限)"
|
|
|
|
|
|
def is_sl_breakeven_secured(direction: str, entry_price: Any, exchange_sl_price: Any) -> bool:
|
|
"""
|
|
交易所当前止损相对开仓成交价是否已保本。
|
|
做多:止损 >= 成交价;做空:止损 <= 成交价。
|
|
"""
|
|
entry = _positive_float(entry_price)
|
|
sl = _positive_float(exchange_sl_price)
|
|
if entry is None or sl is None:
|
|
return False
|
|
d = (direction or "long").strip().lower()
|
|
if d == "short":
|
|
return sl <= entry
|
|
return sl >= entry
|
|
|
|
|
|
def sl_breakeven_from_exchange_tpsl(
|
|
direction: str,
|
|
entry_price: Any,
|
|
exchange_tpsl: Any,
|
|
) -> bool:
|
|
if not isinstance(exchange_tpsl, dict):
|
|
return False
|
|
sl_px = tpsl_slot_trigger_price(exchange_tpsl.get("sl"))
|
|
if sl_px is None:
|
|
return False
|
|
return is_sl_breakeven_secured(direction, entry_price, sl_px)
|
|
|
|
|
|
def enrich_order_display_fields(item: dict[str, Any], calc_rr_ratio_fn: Callable[..., Optional[float]]) -> dict[str, Any]:
|
|
item["rr_ratio"] = snapshot_rr(
|
|
calc_rr_ratio_fn,
|
|
item.get("direction") or "long",
|
|
item.get("trigger_price"),
|
|
item.get("initial_stop_loss"),
|
|
item.get("stop_loss"),
|
|
item.get("take_profit"),
|
|
)
|
|
return item
|
|
|
|
|
|
def apply_order_live_price_display(
|
|
payload: dict[str, Any],
|
|
symbol: Any,
|
|
ticker_price: Any,
|
|
exchange_mark_price: Any,
|
|
format_price_fn: Callable[[Any, Any], str],
|
|
) -> dict[str, Any]:
|
|
"""标记价/现价展示:与交易所 price_to_precision 对齐,避免前端 toFixed(8)。"""
|
|
px_for_fmt = ticker_price
|
|
mark_raw = exchange_mark_price
|
|
if mark_raw is not None:
|
|
try:
|
|
px_for_fmt = float(mark_raw)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
px_disp = format_price_fn(symbol, px_for_fmt)
|
|
payload["price_display"] = px_disp
|
|
if mark_raw is not None:
|
|
try:
|
|
payload["exchange_mark_price_display"] = format_price_fn(symbol, float(mark_raw))
|
|
except (TypeError, ValueError):
|
|
payload["exchange_mark_price_display"] = px_disp
|
|
else:
|
|
payload["exchange_mark_price_display"] = None
|
|
return payload
|
|
|
|
|
|
def resolve_live_tpsl_prices(
|
|
plan_sl: Any,
|
|
plan_tp: Any,
|
|
exchange_tpsl: Any,
|
|
) -> tuple[Optional[float], Optional[float], Optional[float], Optional[float]]:
|
|
"""返回 (展示用止损, 展示用止盈, 交易所止损, 交易所止盈)。"""
|
|
ex_sl = ex_tp = None
|
|
if isinstance(exchange_tpsl, dict):
|
|
ex_sl = tpsl_slot_trigger_price(exchange_tpsl.get("sl"))
|
|
ex_tp = tpsl_slot_trigger_price(exchange_tpsl.get("tp"))
|
|
disp_sl = ex_sl if ex_sl is not None else _positive_float(plan_sl)
|
|
disp_tp = ex_tp if ex_tp is not None else _positive_float(plan_tp)
|
|
return disp_sl, disp_tp, ex_sl, ex_tp
|
|
|
|
|
|
def order_monitor_tpsl_needs_sync(
|
|
plan_sl: Any,
|
|
plan_tp: Any,
|
|
exchange_tpsl: Any,
|
|
*,
|
|
eps: float = 1e-12,
|
|
) -> tuple[Optional[float], Optional[float], bool]:
|
|
"""若交易所 TP/SL 与库中不一致,返回应写回的 (sl, tp) 及是否需更新。"""
|
|
_, _, ex_sl, ex_tp = resolve_live_tpsl_prices(plan_sl, plan_tp, exchange_tpsl)
|
|
try:
|
|
cur_sl = float(plan_sl or 0)
|
|
cur_tp = float(plan_tp or 0)
|
|
except (TypeError, ValueError):
|
|
cur_sl, cur_tp = 0.0, 0.0
|
|
new_sl = ex_sl if ex_sl is not None else cur_sl
|
|
new_tp = ex_tp if ex_tp is not None else cur_tp
|
|
changed = (
|
|
(ex_sl is not None and abs(new_sl - cur_sl) > eps)
|
|
or (ex_tp is not None and abs(new_tp - cur_tp) > eps)
|
|
)
|
|
return new_sl, new_tp, changed
|
|
|
|
|
|
def apply_order_price_display_fields(
|
|
payload: dict[str, Any],
|
|
*,
|
|
direction: str,
|
|
entry_price: Any,
|
|
initial_stop_loss: Any,
|
|
stop_loss: Any,
|
|
take_profit: Any,
|
|
calc_rr_ratio_fn: Callable[..., Optional[float]],
|
|
exchange_tpsl: Any = None,
|
|
format_price_fn: Optional[Callable[[Any, Any], str]] = None,
|
|
symbol: Any = None,
|
|
) -> dict[str, Any]:
|
|
disp_sl, disp_tp, _, _ = resolve_live_tpsl_prices(stop_loss, take_profit, exchange_tpsl)
|
|
payload["rr_ratio"] = snapshot_rr(
|
|
calc_rr_ratio_fn,
|
|
direction,
|
|
entry_price,
|
|
initial_stop_loss,
|
|
stop_loss,
|
|
take_profit,
|
|
)
|
|
payload["sl_breakeven_secured"] = sl_breakeven_from_exchange_tpsl(
|
|
direction, entry_price, exchange_tpsl
|
|
)
|
|
payload["stop_loss"] = disp_sl
|
|
payload["take_profit"] = disp_tp
|
|
if disp_sl is not None and disp_tp is not None:
|
|
payload["display_rr_ratio"] = calc_rr_ratio_fn(
|
|
direction or "long", entry_price, disp_sl, disp_tp
|
|
)
|
|
else:
|
|
payload["display_rr_ratio"] = None
|
|
if format_price_fn is not None and symbol is not None:
|
|
payload["stop_loss_display"] = (
|
|
format_price_fn(symbol, disp_sl) if disp_sl is not None else "—"
|
|
)
|
|
payload["take_profit_display"] = (
|
|
format_price_fn(symbol, disp_tp) if disp_tp is not None else "—"
|
|
)
|
|
return payload
|