修复审计P1:基数纠偏用开仓止损并清空币数量、Gate保证金/手动平仓成交价/全仓实盘空仓校验、OKX·Binance占位函数、快照超时。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4277,6 +4277,11 @@ def get_live_position_exchange_metrics(exchange_symbol, direction):
|
|||||||
return parse_ccxt_position_metrics(p)
|
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):
|
def opened_at_str_to_ms(opened_at_str):
|
||||||
if not opened_at_str:
|
if not opened_at_str:
|
||||||
return None
|
return None
|
||||||
|
|||||||
+72
-15
@@ -2922,6 +2922,39 @@ def get_active_position_count(conn):
|
|||||||
return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0])
|
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):
|
def clear_key_sizing_snapshot_if_flat(conn, session_date):
|
||||||
if get_active_position_count(conn) > 0:
|
if get_active_position_count(conn) > 0:
|
||||||
return
|
return
|
||||||
@@ -3770,17 +3803,19 @@ def parse_ccxt_position_metrics(position, order_leverage=None):
|
|||||||
return None
|
return None
|
||||||
p = position
|
p = position
|
||||||
info = p.get("info", {}) or {}
|
info = p.get("info", {}) or {}
|
||||||
# Gate 全仓:ccxt 的 initialMargin 常为空;collateral 来自 API 的 margin,与 App「保证金」一致
|
# 与 Binance 对齐:优先 initialMargin;collateral 常含未实现盈亏,仅作末位兜底
|
||||||
initial = _coerce_float(p.get("collateral"), p.get("initialMargin"), p.get("margin"))
|
initial = _coerce_float(p.get("initialMargin"), p.get("margin"))
|
||||||
if initial is None or initial <= 0:
|
if initial is None or initial <= 0:
|
||||||
initial = _coerce_float(
|
initial = _coerce_float(
|
||||||
info.get("margin"),
|
info.get("initial_margin"),
|
||||||
info.get("cross_margin"),
|
info.get("cross_margin"),
|
||||||
info.get("iso_margin"),
|
info.get("iso_margin"),
|
||||||
info.get("initial_margin"),
|
|
||||||
info.get("position_margin"),
|
info.get("position_margin"),
|
||||||
info.get("initialMargin"),
|
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"))
|
notional = _coerce_float(p.get("notional"), p.get("notionalValue"))
|
||||||
if notional is None or notional <= 0:
|
if notional is None or notional <= 0:
|
||||||
notional = _coerce_float(info.get("value"))
|
notional = _coerce_float(info.get("value"))
|
||||||
@@ -5354,7 +5389,7 @@ def _add_trigger_entry_key_monitor(
|
|||||||
if not ok_intent:
|
if not ok_intent:
|
||||||
return False, intent_msg
|
return False, intent_msg
|
||||||
if is_full_margin_mode(POSITION_SIZING_MODE):
|
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:
|
if not ok_flat:
|
||||||
return False, flat_msg
|
return False, flat_msg
|
||||||
if count_pending_trigger_entries(conn, trading_day) > 0:
|
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))
|
risk_percent = max(0.01, float(RISK_PERCENT))
|
||||||
if is_full_margin_mode(POSITION_SIZING_MODE):
|
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:
|
if not ok_flat:
|
||||||
return False, flat_msg, None
|
return False, flat_msg, None
|
||||||
leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE)
|
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_percent = max(0.01, float(RISK_PERCENT))
|
||||||
risk_amount = round(capital_base * risk_percent / 100.0, 2)
|
risk_amount = round(capital_base * risk_percent / 100.0, 2)
|
||||||
if is_full_margin_mode(POSITION_SIZING_MODE):
|
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:
|
if not ok_flat:
|
||||||
conn.close()
|
conn.close()
|
||||||
flash(flat_msg)
|
flash(flat_msg)
|
||||||
@@ -9152,21 +9187,43 @@ def del_order(id):
|
|||||||
return redirect("/")
|
return redirect("/")
|
||||||
if row["status"] == "active":
|
if row["status"] == "active":
|
||||||
try:
|
try:
|
||||||
p = get_price(row["symbol"]) or float(row["trigger_price"])
|
|
||||||
opened_at = get_opened_at_value(row)
|
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()
|
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(
|
pnl_amount = calc_pnl(
|
||||||
row["direction"],
|
row["direction"],
|
||||||
row["trigger_price"],
|
row["trigger_price"],
|
||||||
p,
|
p,
|
||||||
row["margin_capital"] or DAILY_START_CAPITAL,
|
margin_capital,
|
||||||
row["leverage"] or infer_leverage(row["symbol"])
|
leverage,
|
||||||
)
|
)
|
||||||
close_resp = close_exchange_order(row)
|
session_date = row["session_date"] or get_trading_day(closed_at_dt)
|
||||||
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_capital = update_session_capital(conn, session_date, pnl_amount)
|
session_capital = update_session_capital(conn, session_date, pnl_amount)
|
||||||
row_snap = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() or row
|
row_snap = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() or row
|
||||||
insert_trade_record(
|
insert_trade_record(
|
||||||
|
|||||||
@@ -3106,13 +3106,16 @@ def parse_ccxt_position_metrics(position, order_leverage=None):
|
|||||||
return None
|
return None
|
||||||
p = position
|
p = position
|
||||||
info = p.get("info", {}) or {}
|
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:
|
if initial is None or initial <= 0:
|
||||||
initial = _coerce_float(
|
initial = _coerce_float(
|
||||||
info.get("margin"),
|
info.get("margin"),
|
||||||
info.get("imr"),
|
info.get("imr"),
|
||||||
info.get("initial_margin"),
|
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"))
|
notional = _coerce_float(p.get("notional"), p.get("notionalValue"))
|
||||||
if notional is None or notional <= 0:
|
if notional is None or notional <= 0:
|
||||||
notional = _coerce_float(info.get("notionalUsd"), info.get("notional"))
|
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"))
|
mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("markPx"))
|
||||||
out = {}
|
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:
|
if initial is not None and initial > 0:
|
||||||
out["initial_margin"] = round(initial, FUNDS_DECIMALS)
|
out["initial_margin"] = round(initial, FUNDS_DECIMALS)
|
||||||
if notional is not None and notional > 0:
|
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)
|
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):
|
def opened_at_str_to_ms(opened_at_str):
|
||||||
if not opened_at_str:
|
if not opened_at_str:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ def enrich_trade_price_displays(
|
|||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
return item
|
return item
|
||||||
try:
|
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(
|
apply_repaired_margin_capital(
|
||||||
item.get("margin_capital"),
|
item,
|
||||||
trigger_price=item.get("trigger_price"),
|
trigger_price=item.get("trigger_price"),
|
||||||
leverage=item.get("leverage"),
|
leverage=item.get("leverage"),
|
||||||
symbol=item.get("symbol"),
|
symbol=item.get("symbol"),
|
||||||
@@ -24,8 +24,6 @@ def enrich_trade_price_displays(
|
|||||||
initial_stop_loss=item.get("initial_stop_loss"),
|
initial_stop_loss=item.get("initial_stop_loss"),
|
||||||
risk_amount=item.get("risk_amount"),
|
risk_amount=item.get("risk_amount"),
|
||||||
)
|
)
|
||||||
if fixed is not None:
|
|
||||||
item["margin_capital"] = fixed
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if not format_price_fn:
|
if not format_price_fn:
|
||||||
|
|||||||
@@ -1173,6 +1173,17 @@ function accountSnapshotFundingMissing(data){
|
|||||||
return !hasFunding && !hasTotal && !hasTrading;
|
return !hasFunding && !hasTotal && !hasTrading;
|
||||||
}
|
}
|
||||||
let accountSnapshotRetryCount = 0;
|
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){
|
function applyAccountSnapshot(data){
|
||||||
if(!data || typeof data !== "object") return;
|
if(!data || typeof data !== "object") return;
|
||||||
if(typeof data.show_perp_funds !== "undefined"){
|
if(typeof data.show_perp_funds !== "undefined"){
|
||||||
@@ -1263,8 +1274,10 @@ function applyAccountSnapshot(data){
|
|||||||
}
|
}
|
||||||
function refreshAccountSnapshot(opts){
|
function refreshAccountSnapshot(opts){
|
||||||
const options = opts || {};
|
const options = opts || {};
|
||||||
|
if(accountSnapshotInflight && !options.force) return;
|
||||||
|
accountSnapshotInflight = true;
|
||||||
const qs = options.force ? "?force=1" : "";
|
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);
|
applyAccountSnapshot(data);
|
||||||
if(accountSnapshotFundingMissing(data) && !options.force && accountSnapshotRetryCount < 3){
|
if(accountSnapshotFundingMissing(data) && !options.force && accountSnapshotRetryCount < 3){
|
||||||
accountSnapshotRetryCount += 1;
|
accountSnapshotRetryCount += 1;
|
||||||
@@ -1277,7 +1290,7 @@ function refreshAccountSnapshot(opts){
|
|||||||
accountSnapshotRetryCount += 1;
|
accountSnapshotRetryCount += 1;
|
||||||
setTimeout(() => refreshAccountSnapshot({ silent: true }), 1200 * accountSnapshotRetryCount);
|
setTimeout(() => refreshAccountSnapshot({ silent: true }), 1200 * accountSnapshotRetryCount);
|
||||||
}
|
}
|
||||||
});
|
}).finally(()=>{ accountSnapshotInflight = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderSymbolEl = document.getElementById("order-symbol");
|
const orderSymbolEl = document.getElementById("order-symbol");
|
||||||
@@ -1419,8 +1432,10 @@ refreshOrderDefaults();
|
|||||||
if(typeof initOrderEntryModelSelect === "function") initOrderEntryModelSelect();
|
if(typeof initOrderEntryModelSelect === "function") initOrderEntryModelSelect();
|
||||||
refreshPriceSnapshotConditional();
|
refreshPriceSnapshotConditional();
|
||||||
function refreshPriceSnapshotConditional(){
|
function refreshPriceSnapshotConditional(){
|
||||||
|
if(priceSnapshotInflight) return;
|
||||||
|
priceSnapshotInflight = true;
|
||||||
const page = document.body.getAttribute("data-page") || "";
|
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");
|
const updatedEl = document.getElementById("price-last-updated");
|
||||||
if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at;
|
if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at;
|
||||||
if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){
|
if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){
|
||||||
@@ -1474,7 +1489,7 @@ function refreshPriceSnapshotConditional(){
|
|||||||
} else if (typeof data.options_unrealized_pnl !== "undefined") {
|
} else if (typeof data.options_unrealized_pnl !== "undefined") {
|
||||||
paintRealtimePnlFromSnapshot(data);
|
paintRealtimePnlFromSnapshot(data);
|
||||||
}
|
}
|
||||||
}).catch(()=>{});
|
}).catch(()=>{}).finally(()=>{ priceSnapshotInflight = false; });
|
||||||
}
|
}
|
||||||
function formatLiveHoldDurationFromMs(openedMs, nowMs){
|
function formatLiveHoldDurationFromMs(openedMs, nowMs){
|
||||||
if(openedMs == null || openedMs === "" || !Number.isFinite(Number(openedMs))) return "—";
|
if(openedMs == null || openedMs === "" || !Number.isFinite(Number(openedMs))) return "—";
|
||||||
|
|||||||
@@ -1654,6 +1654,17 @@ function accountSnapshotFundingMissing(data){
|
|||||||
return !hasFunding && !hasTotal && !hasTrading;
|
return !hasFunding && !hasTotal && !hasTrading;
|
||||||
}
|
}
|
||||||
let accountSnapshotRetryCount = 0;
|
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){
|
function applyAccountSnapshot(data){
|
||||||
if(!data || typeof data !== "object") return;
|
if(!data || typeof data !== "object") return;
|
||||||
if(typeof data.show_perp_funds !== "undefined"){
|
if(typeof data.show_perp_funds !== "undefined"){
|
||||||
@@ -1753,8 +1764,10 @@ function applyAccountSnapshot(data){
|
|||||||
}
|
}
|
||||||
function refreshAccountSnapshot(opts){
|
function refreshAccountSnapshot(opts){
|
||||||
const options = opts || {};
|
const options = opts || {};
|
||||||
|
if(accountSnapshotInflight && !options.force) return;
|
||||||
|
accountSnapshotInflight = true;
|
||||||
const qs = options.force ? "?force=1" : "";
|
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);
|
applyAccountSnapshot(data);
|
||||||
if(accountSnapshotFundingMissing(data) && !options.force && accountSnapshotRetryCount < 3){
|
if(accountSnapshotFundingMissing(data) && !options.force && accountSnapshotRetryCount < 3){
|
||||||
accountSnapshotRetryCount += 1;
|
accountSnapshotRetryCount += 1;
|
||||||
@@ -1767,7 +1780,7 @@ function refreshAccountSnapshot(opts){
|
|||||||
accountSnapshotRetryCount += 1;
|
accountSnapshotRetryCount += 1;
|
||||||
setTimeout(() => refreshAccountSnapshot({ silent: true }), 1200 * accountSnapshotRetryCount);
|
setTimeout(() => refreshAccountSnapshot({ silent: true }), 1200 * accountSnapshotRetryCount);
|
||||||
}
|
}
|
||||||
});
|
}).finally(()=>{ accountSnapshotInflight = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
{% if ui_open_guard_enabled %}
|
{% if ui_open_guard_enabled %}
|
||||||
@@ -1925,8 +1938,10 @@ refreshOrderDefaults();
|
|||||||
refreshPriceSnapshotConditional();
|
refreshPriceSnapshotConditional();
|
||||||
setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }});
|
setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }});
|
||||||
function refreshPriceSnapshotConditional(){
|
function refreshPriceSnapshotConditional(){
|
||||||
|
if(priceSnapshotInflight) return;
|
||||||
|
priceSnapshotInflight = true;
|
||||||
const page = document.body.getAttribute("data-page") || "";
|
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");
|
const updatedEl = document.getElementById("price-last-updated");
|
||||||
if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at;
|
if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at;
|
||||||
if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){
|
if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){
|
||||||
@@ -2000,7 +2015,7 @@ function refreshPriceSnapshotConditional(){
|
|||||||
} else if (typeof data.options_unrealized_pnl !== "undefined") {
|
} else if (typeof data.options_unrealized_pnl !== "undefined") {
|
||||||
paintRealtimePnlFromSnapshot(data);
|
paintRealtimePnlFromSnapshot(data);
|
||||||
}
|
}
|
||||||
}).catch(()=>{});
|
}).catch(()=>{}).finally(()=>{ priceSnapshotInflight = false; });
|
||||||
}
|
}
|
||||||
function formatLiveHoldDurationFromMs(openedMs, nowMs){
|
function formatLiveHoldDurationFromMs(openedMs, nowMs){
|
||||||
if(openedMs == null || openedMs === "" || !Number.isFinite(Number(openedMs))) return "—";
|
if(openedMs == null || openedMs === "" || !Number.isFinite(Number(openedMs))) return "—";
|
||||||
|
|||||||
@@ -134,6 +134,12 @@ def resolve_trade_record_margin_usdt(
|
|||||||
"""写入 trade_records.基数:计划保证金优先于异常交易所快照;禁止币×价÷杠杆虚增."""
|
"""写入 trade_records.基数:计划保证金优先于异常交易所快照;禁止币×价÷杠杆虚增."""
|
||||||
plan = _pos_float(plan_margin_capital)
|
plan = _pos_float(plan_margin_capital)
|
||||||
ex = _pos_float(exchange_margin_usdt)
|
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(
|
if plan is not None and looks_like_coin_amount_as_margin(
|
||||||
plan,
|
plan,
|
||||||
@@ -143,6 +149,8 @@ def resolve_trade_record_margin_usdt(
|
|||||||
notional_value=notional_value,
|
notional_value=notional_value,
|
||||||
):
|
):
|
||||||
plan = None
|
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(
|
if ex is not None and looks_like_coin_amount_as_margin(
|
||||||
ex,
|
ex,
|
||||||
@@ -155,18 +163,13 @@ def resolve_trade_record_margin_usdt(
|
|||||||
ex = None
|
ex = None
|
||||||
if ex is not None and plan is not None and ex > plan * 2.5:
|
if ex is not None and plan is not None and ex > plan * 2.5:
|
||||||
ex = None
|
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:
|
if plan is not None:
|
||||||
return round(plan, 2)
|
return round(plan, 2)
|
||||||
if ex is not None:
|
if ex is not None:
|
||||||
return round(ex, 2)
|
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:
|
if from_risk is not None:
|
||||||
return from_risk
|
return from_risk
|
||||||
|
|
||||||
@@ -187,10 +190,16 @@ def repair_stored_margin_capital(
|
|||||||
risk_amount: Any = None,
|
risk_amount: Any = None,
|
||||||
initial_stop_loss: Any = None,
|
initial_stop_loss: Any = None,
|
||||||
) -> Optional[float]:
|
) -> Optional[float]:
|
||||||
"""展示/列表:修复已入库的异常基数;禁止把币数量换算成虚高保证金."""
|
"""展示/列表:修复已入库的异常基数.
|
||||||
|
|
||||||
|
返回值语义:
|
||||||
|
- 正数:应用该保证金
|
||||||
|
- None:若原值像币数量,调用方应清空显示;否则保留原值(见 apply_repaired_margin_capital)
|
||||||
|
"""
|
||||||
del symbol # 预留按币种阈值
|
del symbol # 预留按币种阈值
|
||||||
m = _pos_float(margin_capital)
|
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(
|
from_risk = margin_from_risk_amount(
|
||||||
risk_amount,
|
risk_amount,
|
||||||
trigger_price=trigger_price,
|
trigger_price=trigger_price,
|
||||||
@@ -207,7 +216,38 @@ def repair_stored_margin_capital(
|
|||||||
return from_risk
|
return from_risk
|
||||||
|
|
||||||
if from_risk is not None and m > from_risk * 2.5:
|
if from_risk is not None and m > from_risk * 2.5:
|
||||||
# 已入库虚高(如曾用币×价÷杠杆「纠偏」成 384)
|
|
||||||
return from_risk
|
return from_risk
|
||||||
|
|
||||||
return round(m, 2)
|
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
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from lib.trade.trade_margin_record_lib import (
|
from lib.trade.trade_margin_record_lib import (
|
||||||
|
apply_repaired_margin_capital,
|
||||||
coin_amount_to_margin_usdt,
|
coin_amount_to_margin_usdt,
|
||||||
looks_like_coin_amount_as_margin,
|
looks_like_coin_amount_as_margin,
|
||||||
margin_from_risk_amount,
|
margin_from_risk_amount,
|
||||||
@@ -34,7 +35,6 @@ class TestTradeMarginRecord(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_coin_to_margin(self):
|
def test_coin_to_margin(self):
|
||||||
# 仅作数学兜底函数;展示层不得优先用它虚增高额
|
|
||||||
self.assertAlmostEqual(
|
self.assertAlmostEqual(
|
||||||
coin_amount_to_margin_usdt(0.12, trigger_price=64054.1, leverage=20),
|
coin_amount_to_margin_usdt(0.12, trigger_price=64054.1, leverage=20),
|
||||||
384.32,
|
384.32,
|
||||||
@@ -42,7 +42,6 @@ class TestTradeMarginRecord(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_margin_from_risk(self):
|
def test_margin_from_risk(self):
|
||||||
# risk = 110 * 20 * 445.9 / 64054.1 ≈ 15.32
|
|
||||||
risk = 110.0 * 20.0 * 445.9 / 64054.1
|
risk = 110.0 * 20.0 * 445.9 / 64054.1
|
||||||
out = margin_from_risk_amount(
|
out = margin_from_risk_amount(
|
||||||
risk, trigger_price=64054.1, stop_loss=64500.0, leverage=20
|
risk, trigger_price=64054.1, stop_loss=64500.0, leverage=20
|
||||||
@@ -68,6 +67,18 @@ class TestTradeMarginRecord(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(out, 117.71)
|
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):
|
def test_resolve_uses_risk_when_no_plan(self):
|
||||||
risk = 110.0 * 20.0 * 445.9 / 64054.1
|
risk = 110.0 * 20.0 * 445.9 / 64054.1
|
||||||
out = resolve_trade_record_margin_usdt(
|
out = resolve_trade_record_margin_usdt(
|
||||||
@@ -89,6 +100,19 @@ class TestTradeMarginRecord(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIsNone(out)
|
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):
|
def test_repair_stored_uses_risk_not_coin_times_price(self):
|
||||||
risk = 110.0 * 20.0 * 445.9 / 64054.1
|
risk = 110.0 * 20.0 * 445.9 / 64054.1
|
||||||
self.assertAlmostEqual(
|
self.assertAlmostEqual(
|
||||||
@@ -102,7 +126,6 @@ class TestTradeMarginRecord(unittest.TestCase):
|
|||||||
110.0,
|
110.0,
|
||||||
places=1,
|
places=1,
|
||||||
)
|
)
|
||||||
# 曾被错误「纠偏」成 384 的入库值,用风险金额压回
|
|
||||||
self.assertAlmostEqual(
|
self.assertAlmostEqual(
|
||||||
repair_stored_margin_capital(
|
repair_stored_margin_capital(
|
||||||
384.32,
|
384.32,
|
||||||
@@ -120,13 +143,21 @@ class TestTradeMarginRecord(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
108.97,
|
108.97,
|
||||||
)
|
)
|
||||||
# 无风险金额时:绝不把 0.12 换成 384
|
|
||||||
self.assertIsNone(
|
self.assertIsNone(
|
||||||
repair_stored_margin_capital(
|
repair_stored_margin_capital(
|
||||||
0.12, trigger_price=64054.1, leverage=20
|
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):
|
def test_sanitize_rejects_oversized_collateral(self):
|
||||||
out = sanitize_exchange_initial_margin(
|
out = sanitize_exchange_initial_margin(
|
||||||
384.32, notional=2200.0, order_leverage=20, coin_amount=0.034
|
384.32, notional=2200.0, order_leverage=20, coin_amount=0.034
|
||||||
|
|||||||
Reference in New Issue
Block a user