From 39878de7fcc0a0026d6248e34d21a5d47f3b342a Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 13 Aug 2026 00:29:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=A1=E8=AE=A1P1=EF=BC=9A?= =?UTF-8?q?=E5=9F=BA=E6=95=B0=E7=BA=A0=E5=81=8F=E7=94=A8=E5=BC=80=E4=BB=93?= =?UTF-8?q?=E6=AD=A2=E6=8D=9F=E5=B9=B6=E6=B8=85=E7=A9=BA=E5=B8=81=E6=95=B0?= =?UTF-8?q?=E9=87=8F=E3=80=81Gate=E4=BF=9D=E8=AF=81=E9=87=91/=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E5=B9=B3=E4=BB=93=E6=88=90=E4=BA=A4=E4=BB=B7/?= =?UTF-8?q?=E5=85=A8=E4=BB=93=E5=AE=9E=E7=9B=98=E7=A9=BA=E4=BB=93=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E3=80=81OKX=C2=B7Binance=E5=8D=A0=E4=BD=8D=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E3=80=81=E5=BF=AB=E7=85=A7=E8=B6=85=E6=97=B6=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- crypto_monitor_binance/app.py | 5 ++ crypto_monitor_gate/app.py | 87 +++++++++++++++---- crypto_monitor_okx/app.py | 30 ++++++- lib/instance/records_list_lib.py | 8 +- .../templates/embed_boot_scripts.html | 23 ++++- lib/instance/templates/index.html | 23 ++++- lib/trade/trade_margin_record_lib.py | 60 ++++++++++--- tests/test_trade_margin_record_lib.py | 39 ++++++++- 8 files changed, 232 insertions(+), 43 deletions(-) diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index fb1da1f..712ccfc 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -4277,6 +4277,11 @@ def get_live_position_exchange_metrics(exchange_symbol, direction): return parse_ccxt_position_metrics(p) +def try_persist_exchange_margin_for_order(conn, order_id, exchange_symbol, direction, order_leverage=None, max_attempts=6, sleep_s=0.45): + """Binance 暂无 order_monitors.exchange_margin_usdt 列;占位避免开仓后 NameError.""" + return False + + def opened_at_str_to_ms(opened_at_str): if not opened_at_str: return None diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index be5a27e..8f21393 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -2922,6 +2922,39 @@ def get_active_position_count(conn): return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]) +def count_nonzero_live_exchange_positions(): + """交易所非零持仓条数;API 不可用时返回 None(不阻断,仅 DB 判定).""" + if not exchange_private_api_configured(): + return None + try: + ensure_markets_loaded() + rows = exchange.fetch_positions(None, {"settle": "usdt"}) or [] + except Exception: + try: + rows = exchange.fetch_positions() or [] + except Exception: + return None + n = 0 + for p in rows or []: + try: + if _position_row_effective_contracts(p) > 0: + n += 1 + except Exception: + continue + return n + + +def full_margin_flat_check(conn): + """全仓复利开仓前:本地监控仓 + 交易所实盘仓均须为空.""" + ok, msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok: + return ok, msg + ex_n = count_nonzero_live_exchange_positions() + if ex_n is not None and ex_n > 0: + return False, "交易所仍有持仓,全仓杠杆模式请先平仓后再开" + return True, "" + + def clear_key_sizing_snapshot_if_flat(conn, session_date): if get_active_position_count(conn) > 0: return @@ -3770,17 +3803,19 @@ def parse_ccxt_position_metrics(position, order_leverage=None): return None p = position info = p.get("info", {}) or {} - # Gate 全仓:ccxt 的 initialMargin 常为空;collateral 来自 API 的 margin,与 App「保证金」一致 - initial = _coerce_float(p.get("collateral"), p.get("initialMargin"), p.get("margin")) + # 与 Binance 对齐:优先 initialMargin;collateral 常含未实现盈亏,仅作末位兜底 + initial = _coerce_float(p.get("initialMargin"), p.get("margin")) if initial is None or initial <= 0: initial = _coerce_float( - info.get("margin"), + info.get("initial_margin"), info.get("cross_margin"), info.get("iso_margin"), - info.get("initial_margin"), info.get("position_margin"), info.get("initialMargin"), + info.get("margin"), ) + if initial is None or initial <= 0: + initial = _coerce_float(p.get("collateral")) notional = _coerce_float(p.get("notional"), p.get("notionalValue")) if notional is None or notional <= 0: notional = _coerce_float(info.get("value")) @@ -5354,7 +5389,7 @@ def _add_trigger_entry_key_monitor( if not ok_intent: return False, intent_msg if is_full_margin_mode(POSITION_SIZING_MODE): - ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + ok_flat, flat_msg = full_margin_flat_check(conn) if not ok_flat: return False, flat_msg if count_pending_trigger_entries(conn, trading_day) > 0: @@ -5503,7 +5538,7 @@ def _market_open_for_trigger_entry( risk_percent = max(0.01, float(RISK_PERCENT)) if is_full_margin_mode(POSITION_SIZING_MODE): - ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + ok_flat, flat_msg = full_margin_flat_check(conn) if not ok_flat: return False, flat_msg, None leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) @@ -8662,7 +8697,7 @@ def add_order(): risk_percent = max(0.01, float(RISK_PERCENT)) risk_amount = round(capital_base * risk_percent / 100.0, 2) if is_full_margin_mode(POSITION_SIZING_MODE): - ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + ok_flat, flat_msg = full_margin_flat_check(conn) if not ok_flat: conn.close() flash(flat_msg) @@ -9152,21 +9187,43 @@ def del_order(id): return redirect("/") if row["status"] == "active": try: - p = get_price(row["symbol"]) or float(row["trigger_price"]) opened_at = get_opened_at_value(row) + opened_at_ms = _to_ms_with_fallback( + row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at + ) + margin_capital = row["margin_capital"] or DAILY_START_CAPITAL + leverage = row["leverage"] or infer_leverage(row["symbol"]) + close_resp = close_exchange_order(row) + close_order_id = close_resp.get("id", "") + cancel_gate_swap_trigger_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) + exit_p = extract_trade_price_from_order(close_resp) closed_at = app_now_str() - hold_seconds = calc_hold_seconds(opened_at, app_now()) + if not exit_p or float(exit_p) <= 0: + tr_fill = fetch_latest_closing_fill( + row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]), + row["direction"], + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr_fill and tr_fill.get("price"): + try: + exit_p = float(tr_fill["price"]) + except (TypeError, ValueError): + exit_p = None + ts = tr_fill.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + p = exit_p or get_price(row["symbol"]) or float(row["trigger_price"]) + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) pnl_amount = calc_pnl( row["direction"], row["trigger_price"], p, - row["margin_capital"] or DAILY_START_CAPITAL, - row["leverage"] or infer_leverage(row["symbol"]) + margin_capital, + leverage, ) - close_resp = close_exchange_order(row) - close_order_id = close_resp.get("id", "") - cancel_gate_swap_trigger_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) - session_date = row["session_date"] or get_trading_day() + session_date = row["session_date"] or get_trading_day(closed_at_dt) session_capital = update_session_capital(conn, session_date, pnl_amount) row_snap = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() or row insert_trade_record( diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index d1b33a7..8b646f8 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -3106,13 +3106,16 @@ def parse_ccxt_position_metrics(position, order_leverage=None): return None p = position info = p.get("info", {}) or {} - initial = _coerce_float(p.get("collateral"), p.get("initialMargin"), p.get("margin")) + # 优先 initialMargin;collateral 常含未实现,仅作兜底 + initial = _coerce_float(p.get("initialMargin"), p.get("margin")) if initial is None or initial <= 0: initial = _coerce_float( info.get("margin"), info.get("imr"), info.get("initial_margin"), ) + if initial is None or initial <= 0: + initial = _coerce_float(p.get("collateral")) notional = _coerce_float(p.get("notional"), p.get("notionalValue")) if notional is None or notional <= 0: notional = _coerce_float(info.get("notionalUsd"), info.get("notional")) @@ -3136,6 +3139,26 @@ def parse_ccxt_position_metrics(position, order_leverage=None): ) mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("markPx")) out = {} + try: + contracts = abs(float(p.get("contracts") or info.get("pos") or 0)) + except (TypeError, ValueError): + contracts = 0.0 + coin_amt = None + if contracts > 0: + try: + sym0 = (p.get("symbol") or "").strip() + cs0 = float(get_contract_size(sym0)) if sym0 else 1.0 + coin_amt = contracts * cs0 if cs0 > 0 else None + except Exception: + coin_amt = None + from lib.trade.trade_margin_record_lib import sanitize_exchange_initial_margin + + initial = sanitize_exchange_initial_margin( + initial, + notional=notional, + order_leverage=order_leverage, + coin_amount=coin_amt, + ) if initial is not None and initial > 0: out["initial_margin"] = round(initial, FUNDS_DECIMALS) if notional is not None and notional > 0: @@ -3410,6 +3433,11 @@ def get_live_position_exchange_metrics(exchange_symbol, direction, order_leverag return parse_ccxt_position_metrics(prow, order_leverage=order_leverage) +def try_persist_exchange_margin_for_order(conn, order_id, exchange_symbol, direction, order_leverage=None, max_attempts=6, sleep_s=0.45): + """OKX 暂无 order_monitors.exchange_margin_usdt 列;占位避免开仓后 NameError.""" + return False + + def opened_at_str_to_ms(opened_at_str): if not opened_at_str: return None diff --git a/lib/instance/records_list_lib.py b/lib/instance/records_list_lib.py index c36e752..f8796e6 100644 --- a/lib/instance/records_list_lib.py +++ b/lib/instance/records_list_lib.py @@ -13,10 +13,10 @@ def enrich_trade_price_displays( if not isinstance(item, dict): return item try: - from lib.trade.trade_margin_record_lib import repair_stored_margin_capital + from lib.trade.trade_margin_record_lib import apply_repaired_margin_capital - fixed = repair_stored_margin_capital( - item.get("margin_capital"), + apply_repaired_margin_capital( + item, trigger_price=item.get("trigger_price"), leverage=item.get("leverage"), symbol=item.get("symbol"), @@ -24,8 +24,6 @@ def enrich_trade_price_displays( initial_stop_loss=item.get("initial_stop_loss"), risk_amount=item.get("risk_amount"), ) - if fixed is not None: - item["margin_capital"] = fixed except Exception: pass if not format_price_fn: diff --git a/lib/instance/templates/embed_boot_scripts.html b/lib/instance/templates/embed_boot_scripts.html index 8241233..47581fb 100644 --- a/lib/instance/templates/embed_boot_scripts.html +++ b/lib/instance/templates/embed_boot_scripts.html @@ -1173,6 +1173,17 @@ function accountSnapshotFundingMissing(data){ return !hasFunding && !hasTotal && !hasTrading; } let accountSnapshotRetryCount = 0; +let accountSnapshotInflight = false; +let priceSnapshotInflight = false; + +function fetchJsonWithTimeout(url, timeoutMs) { + const ms = timeoutMs != null ? timeoutMs : 25000; + const ac = new AbortController(); + const timer = setTimeout(function () { ac.abort(); }, ms); + return fetch(url, { signal: ac.signal, credentials: "same-origin" }) + .then(function (r) { return r.json(); }) + .finally(function () { clearTimeout(timer); }); +} function applyAccountSnapshot(data){ if(!data || typeof data !== "object") return; if(typeof data.show_perp_funds !== "undefined"){ @@ -1263,8 +1274,10 @@ function applyAccountSnapshot(data){ } function refreshAccountSnapshot(opts){ const options = opts || {}; + if(accountSnapshotInflight && !options.force) return; + accountSnapshotInflight = true; const qs = options.force ? "?force=1" : ""; - fetch("/api/account_snapshot" + qs).then(r=>r.json()).then(data=>{ + fetchJsonWithTimeout("/api/account_snapshot" + qs, 25000).then(data=>{ applyAccountSnapshot(data); if(accountSnapshotFundingMissing(data) && !options.force && accountSnapshotRetryCount < 3){ accountSnapshotRetryCount += 1; @@ -1277,7 +1290,7 @@ function refreshAccountSnapshot(opts){ accountSnapshotRetryCount += 1; setTimeout(() => refreshAccountSnapshot({ silent: true }), 1200 * accountSnapshotRetryCount); } - }); + }).finally(()=>{ accountSnapshotInflight = false; }); } const orderSymbolEl = document.getElementById("order-symbol"); @@ -1419,8 +1432,10 @@ refreshOrderDefaults(); if(typeof initOrderEntryModelSelect === "function") initOrderEntryModelSelect(); refreshPriceSnapshotConditional(); function refreshPriceSnapshotConditional(){ + if(priceSnapshotInflight) return; + priceSnapshotInflight = true; const page = document.body.getAttribute("data-page") || ""; - fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{ + fetchJsonWithTimeout("/api/price_snapshot", 25000).then(data=>{ const updatedEl = document.getElementById("price-last-updated"); if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at; if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){ @@ -1474,7 +1489,7 @@ function refreshPriceSnapshotConditional(){ } else if (typeof data.options_unrealized_pnl !== "undefined") { paintRealtimePnlFromSnapshot(data); } - }).catch(()=>{}); + }).catch(()=>{}).finally(()=>{ priceSnapshotInflight = false; }); } function formatLiveHoldDurationFromMs(openedMs, nowMs){ if(openedMs == null || openedMs === "" || !Number.isFinite(Number(openedMs))) return "—"; diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index 0153cf4..478d8bd 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -1654,6 +1654,17 @@ function accountSnapshotFundingMissing(data){ return !hasFunding && !hasTotal && !hasTrading; } let accountSnapshotRetryCount = 0; +let accountSnapshotInflight = false; +let priceSnapshotInflight = false; + +function fetchJsonWithTimeout(url, timeoutMs) { + const ms = timeoutMs != null ? timeoutMs : 25000; + const ac = new AbortController(); + const timer = setTimeout(function () { ac.abort(); }, ms); + return fetch(url, { signal: ac.signal, credentials: "same-origin" }) + .then(function (r) { return r.json(); }) + .finally(function () { clearTimeout(timer); }); +} function applyAccountSnapshot(data){ if(!data || typeof data !== "object") return; if(typeof data.show_perp_funds !== "undefined"){ @@ -1753,8 +1764,10 @@ function applyAccountSnapshot(data){ } function refreshAccountSnapshot(opts){ const options = opts || {}; + if(accountSnapshotInflight && !options.force) return; + accountSnapshotInflight = true; const qs = options.force ? "?force=1" : ""; - fetch("/api/account_snapshot" + qs).then(r=>r.json()).then(data=>{ + fetchJsonWithTimeout("/api/account_snapshot" + qs, 25000).then(data=>{ applyAccountSnapshot(data); if(accountSnapshotFundingMissing(data) && !options.force && accountSnapshotRetryCount < 3){ accountSnapshotRetryCount += 1; @@ -1767,7 +1780,7 @@ function refreshAccountSnapshot(opts){ accountSnapshotRetryCount += 1; setTimeout(() => refreshAccountSnapshot({ silent: true }), 1200 * accountSnapshotRetryCount); } - }); + }).finally(()=>{ accountSnapshotInflight = false; }); } {% if ui_open_guard_enabled %} @@ -1925,8 +1938,10 @@ refreshOrderDefaults(); refreshPriceSnapshotConditional(); setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }}); function refreshPriceSnapshotConditional(){ + if(priceSnapshotInflight) return; + priceSnapshotInflight = true; const page = document.body.getAttribute("data-page") || ""; - fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{ + fetchJsonWithTimeout("/api/price_snapshot", 25000).then(data=>{ const updatedEl = document.getElementById("price-last-updated"); if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at; if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){ @@ -2000,7 +2015,7 @@ function refreshPriceSnapshotConditional(){ } else if (typeof data.options_unrealized_pnl !== "undefined") { paintRealtimePnlFromSnapshot(data); } - }).catch(()=>{}); + }).catch(()=>{}).finally(()=>{ priceSnapshotInflight = false; }); } function formatLiveHoldDurationFromMs(openedMs, nowMs){ if(openedMs == null || openedMs === "" || !Number.isFinite(Number(openedMs))) return "—"; diff --git a/lib/trade/trade_margin_record_lib.py b/lib/trade/trade_margin_record_lib.py index d32612d..8cc94af 100644 --- a/lib/trade/trade_margin_record_lib.py +++ b/lib/trade/trade_margin_record_lib.py @@ -134,6 +134,12 @@ def resolve_trade_record_margin_usdt( """写入 trade_records.基数:计划保证金优先于异常交易所快照;禁止币×价÷杠杆虚增.""" plan = _pos_float(plan_margin_capital) ex = _pos_float(exchange_margin_usdt) + from_risk = margin_from_risk_amount( + risk_amount, + trigger_price=trigger_price, + stop_loss=stop_loss, + leverage=leverage, + ) if plan is not None and looks_like_coin_amount_as_margin( plan, @@ -143,6 +149,8 @@ def resolve_trade_record_margin_usdt( notional_value=notional_value, ): plan = None + if plan is not None and from_risk is not None and plan > from_risk * 2.5: + plan = None if ex is not None and looks_like_coin_amount_as_margin( ex, @@ -155,18 +163,13 @@ def resolve_trade_record_margin_usdt( ex = None if ex is not None and plan is not None and ex > plan * 2.5: ex = None + if ex is not None and from_risk is not None and ex > from_risk * 2.5: + ex = None if plan is not None: return round(plan, 2) if ex is not None: return round(ex, 2) - - from_risk = margin_from_risk_amount( - risk_amount, - trigger_price=trigger_price, - stop_loss=stop_loss, - leverage=leverage, - ) if from_risk is not None: return from_risk @@ -187,10 +190,16 @@ def repair_stored_margin_capital( risk_amount: Any = None, initial_stop_loss: Any = None, ) -> Optional[float]: - """展示/列表:修复已入库的异常基数;禁止把币数量换算成虚高保证金.""" + """展示/列表:修复已入库的异常基数. + + 返回值语义: + - 正数:应用该保证金 + - None:若原值像币数量,调用方应清空显示;否则保留原值(见 apply_repaired_margin_capital) + """ del symbol # 预留按币种阈值 m = _pos_float(margin_capital) - sl = stop_loss if stop_loss not in (None, "") else initial_stop_loss + # 与写入路径一致:优先开仓止损,避免保本后距离变小导致反推虚高 + sl = initial_stop_loss if initial_stop_loss not in (None, "") else stop_loss from_risk = margin_from_risk_amount( risk_amount, trigger_price=trigger_price, @@ -207,7 +216,38 @@ def repair_stored_margin_capital( return from_risk if from_risk is not None and m > from_risk * 2.5: - # 已入库虚高(如曾用币×价÷杠杆「纠偏」成 384) return from_risk return round(m, 2) + + +def apply_repaired_margin_capital( + item: dict[str, Any], + *, + trigger_price: Any = None, + leverage: Any = None, + stop_loss: Any = None, + initial_stop_loss: Any = None, + risk_amount: Any = None, + symbol: Any = None, +) -> None: + """就地更新 item['margin_capital'];币数量无法修复时清空为 None.""" + if not isinstance(item, dict): + return + raw = item.get("margin_capital") + fixed = repair_stored_margin_capital( + raw, + trigger_price=trigger_price, + leverage=leverage, + symbol=symbol, + stop_loss=stop_loss, + initial_stop_loss=initial_stop_loss, + risk_amount=risk_amount, + ) + if fixed is not None: + item["margin_capital"] = fixed + return + if looks_like_coin_amount_as_margin( + raw, trigger_price=trigger_price, leverage=leverage + ): + item["margin_capital"] = None diff --git a/tests/test_trade_margin_record_lib.py b/tests/test_trade_margin_record_lib.py index bfa5d5a..c61f00c 100644 --- a/tests/test_trade_margin_record_lib.py +++ b/tests/test_trade_margin_record_lib.py @@ -4,6 +4,7 @@ from __future__ import annotations import unittest from lib.trade.trade_margin_record_lib import ( + apply_repaired_margin_capital, coin_amount_to_margin_usdt, looks_like_coin_amount_as_margin, margin_from_risk_amount, @@ -34,7 +35,6 @@ class TestTradeMarginRecord(unittest.TestCase): ) def test_coin_to_margin(self): - # 仅作数学兜底函数;展示层不得优先用它虚增高额 self.assertAlmostEqual( coin_amount_to_margin_usdt(0.12, trigger_price=64054.1, leverage=20), 384.32, @@ -42,7 +42,6 @@ class TestTradeMarginRecord(unittest.TestCase): ) def test_margin_from_risk(self): - # risk = 110 * 20 * 445.9 / 64054.1 ≈ 15.32 risk = 110.0 * 20.0 * 445.9 / 64054.1 out = margin_from_risk_amount( risk, trigger_price=64054.1, stop_loss=64500.0, leverage=20 @@ -68,6 +67,18 @@ class TestTradeMarginRecord(unittest.TestCase): ) self.assertEqual(out, 117.71) + def test_resolve_rejects_inflated_plan_via_risk(self): + risk = 110.0 * 20.0 * 445.9 / 64054.1 + out = resolve_trade_record_margin_usdt( + exchange_margin_usdt=None, + plan_margin_capital=384.32, + leverage=20, + trigger_price=64054.1, + stop_loss=64500.0, + risk_amount=risk, + ) + self.assertAlmostEqual(out, 110.0, places=1) + def test_resolve_uses_risk_when_no_plan(self): risk = 110.0 * 20.0 * 445.9 / 64054.1 out = resolve_trade_record_margin_usdt( @@ -89,6 +100,19 @@ class TestTradeMarginRecord(unittest.TestCase): ) self.assertIsNone(out) + def test_repair_prefers_initial_stop_loss(self): + # 当前止损已移近(保本),若误用会把反推基数抬高;应使用开仓止损 + risk = 110.0 * 20.0 * 445.9 / 64054.1 + out = repair_stored_margin_capital( + 384.32, + trigger_price=64054.1, + leverage=20, + stop_loss=64080.0, + initial_stop_loss=64500.0, + risk_amount=risk, + ) + self.assertAlmostEqual(out, 110.0, places=1) + def test_repair_stored_uses_risk_not_coin_times_price(self): risk = 110.0 * 20.0 * 445.9 / 64054.1 self.assertAlmostEqual( @@ -102,7 +126,6 @@ class TestTradeMarginRecord(unittest.TestCase): 110.0, places=1, ) - # 曾被错误「纠偏」成 384 的入库值,用风险金额压回 self.assertAlmostEqual( repair_stored_margin_capital( 384.32, @@ -120,13 +143,21 @@ class TestTradeMarginRecord(unittest.TestCase): ), 108.97, ) - # 无风险金额时:绝不把 0.12 换成 384 self.assertIsNone( repair_stored_margin_capital( 0.12, trigger_price=64054.1, leverage=20 ) ) + def test_apply_clears_coin_like_when_unrepairable(self): + item = {"margin_capital": 0.12, "trigger_price": 64054.1, "leverage": 20} + apply_repaired_margin_capital( + item, + trigger_price=64054.1, + leverage=20, + ) + self.assertIsNone(item["margin_capital"]) + def test_sanitize_rejects_oversized_collateral(self): out = sanitize_exchange_initial_margin( 384.32, notional=2200.0, order_leverage=20, coin_amount=0.034