diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 799ce39..24d8af9 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -1993,13 +1993,12 @@ def get_effective_trade_field(row, reviewed_key, base_key, default=None): def to_effective_trade_dict(row): item = row_to_dict(row) - base_stop = item.get("initial_stop_loss") if item.get("initial_stop_loss") not in (None, "") else item.get("stop_loss") + from order_monitor_display_lib import snapshot_stop_loss + + open_stop = snapshot_stop_loss(item.get("initial_stop_loss"), item.get("stop_loss")) + item["display_open_stop_loss"] = open_stop item["effective_opened_at"] = get_effective_trade_field(row, "reviewed_opened_at", "opened_at", item.get("opened_at")) item["effective_closed_at"] = get_effective_trade_field(row, "reviewed_closed_at", "closed_at", item.get("closed_at")) - open_stop = item.get("initial_stop_loss") - if open_stop in (None, ""): - open_stop = base_stop - item["display_open_stop_loss"] = open_stop item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", open_stop) item["effective_take_profit"] = get_effective_trade_field(row, "reviewed_take_profit", "take_profit", item.get("take_profit")) item["effective_result"] = get_effective_trade_field(row, "reviewed_result", "result", item.get("result")) @@ -2542,7 +2541,9 @@ def insert_trade_record( open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts) close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts) kst = key_signal_type_for_trade_record(key_signal_type, KEY_MONITOR_AUTO_TYPES) - snap_sl = initial_stop_loss if initial_stop_loss not in (None, "") else stop_loss + from order_monitor_display_lib import snapshot_stop_loss + + snap_sl = snapshot_stop_loss(initial_stop_loss, stop_loss) er = ( (entry_reason or "").strip() or entry_reason_from_key_signal(kst) diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 71d094f..d428245 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -1953,13 +1953,12 @@ def get_effective_trade_field(row, reviewed_key, base_key, default=None): def to_effective_trade_dict(row): item = row_to_dict(row) - base_stop = item.get("initial_stop_loss") if item.get("initial_stop_loss") not in (None, "") else item.get("stop_loss") + from order_monitor_display_lib import snapshot_stop_loss + + open_stop = snapshot_stop_loss(item.get("initial_stop_loss"), item.get("stop_loss")) + item["display_open_stop_loss"] = open_stop item["effective_opened_at"] = get_effective_trade_field(row, "reviewed_opened_at", "opened_at", item.get("opened_at")) item["effective_closed_at"] = get_effective_trade_field(row, "reviewed_closed_at", "closed_at", item.get("closed_at")) - open_stop = item.get("initial_stop_loss") - if open_stop in (None, ""): - open_stop = base_stop - item["display_open_stop_loss"] = open_stop item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", open_stop) item["effective_take_profit"] = get_effective_trade_field(row, "reviewed_take_profit", "take_profit", item.get("take_profit")) item["effective_result"] = get_effective_trade_field(row, "reviewed_result", "result", item.get("result")) @@ -2259,7 +2258,9 @@ def insert_trade_record( open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts) close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts) kst = key_signal_type_for_trade_record(key_signal_type, KEY_MONITOR_AUTO_TYPES) - snap_sl = initial_stop_loss if initial_stop_loss not in (None, "") else stop_loss + from order_monitor_display_lib import snapshot_stop_loss + + snap_sl = snapshot_stop_loss(initial_stop_loss, stop_loss) er = ( (entry_reason or "").strip() or entry_reason_from_key_signal(kst) @@ -3710,9 +3711,32 @@ def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_ if hit is None and since_ms: trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=100) hit = pick_from_trades(trades) - return hit + if hit is not None: + return hit except Exception: - return None + pass + try: + from gate_position_history_lib import pick_gate_position_close + + pos = pick_gate_position_close( + fetch_gate_positions_close_history(), + exchange_symbol, + direction, + opened_at_ms=since_ms, + ) + if pos: + return { + "price": None, + "timestamp": pos["close_ms"], + "side": close_side, + "_from_position_history": True, + "_realized_pnl": pos.get("pnl"), + "_sync_key": pos.get("sync_key"), + "_open_ms": pos.get("open_ms"), + } + except Exception: + pass + return None def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None): @@ -3819,7 +3843,7 @@ def calc_weighted_exit_price(trades): return weighted_sum / total_amount -def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): +def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None, *, prefer_manual=False): """ 交易所已无仓、本地仍为 active 时,推断平仓类型/时间/盈亏。 返回 (result, pnl_amount, closed_at_str, miss_reason)。 @@ -3844,6 +3868,12 @@ def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): ts = trade.get("timestamp") if ts: closed_at_str = ms_to_app_local_str(int(ts)) + if trade.get("_from_position_history"): + pnl_hist = trade.get("_realized_pnl") + if pnl_hist is not None: + note = "中控平仓后按 Gate 平仓历史同步盈亏" if prefer_manual else "按 Gate 平仓历史同步盈亏" + res = "手动平仓" if prefer_manual else "外部平仓" + return (res, float(pnl_hist), closed_at_str, note) if exit_px is None or exit_px <= 0: p = get_price(sym) @@ -3866,6 +3896,13 @@ def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): result = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_px) pnl = calc_pnl(direction, trigger_price, exit_px, margin_capital, leverage) + if prefer_manual: + return ( + "手动平仓", + pnl, + closed_at_str, + "中控平仓后按交易所成交记录同步", + ) if result: return ( result, @@ -3881,6 +3918,91 @@ def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): ) +def reconcile_hub_external_close(conn, symbol, direction): + """中控市价全平后:立即同步匹配 order_monitor,并读 Gate 平仓历史。""" + if not exchange_private_api_configured(): + return {"ok": False, "msg": "未配置 GATE_API_KEY / GATE_API_SECRET", "synced": 0} + from gate_position_history_lib import unified_symbol_for_match + + sym_u = unified_symbol_for_match(symbol) + dir_l = (direction or "").strip().lower() + if dir_l not in ("long", "short"): + return {"ok": False, "msg": "side 须为 long 或 short", "synced": 0} + synced = 0 + rows = conn.execute( + "SELECT * FROM order_monitors WHERE status IN ('active', 'error')" + ).fetchall() + for r in rows: + if unified_symbol_for_match(r["symbol"]) != sym_u: + continue + if (r["direction"] or "").strip().lower() != dir_l: + continue + oid = int(r["id"]) + if r["status"] == "error": + opened_at_chk = get_opened_at_value(r) + existing = conn.execute( + "SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type=? LIMIT 1", + (r["symbol"], opened_at_chk, order_row_monitor_type(r)), + ).fetchone() + if existing: + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,)) + synced += 1 + continue + exchange_symbol = resolve_monitor_exchange_symbol(r) + live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) + if live_contracts is None: + continue + if live_contracts > 0: + time.sleep(0.6) + live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) + if live_contracts is None or live_contracts > 0: + continue + global _RECONCILE_FLAT_STREAK + _RECONCILE_FLAT_STREAK.pop(oid, None) + cancel_gate_swap_trigger_orders(exchange_symbol) + opened_at = get_opened_at_value(r) + opened_at_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close( + r, opened_at, opened_at_ms=opened_at_ms, prefer_manual=True + ) + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = r["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(r), + key_signal_type=order_row_key_signal_type(r), + direction=r["direction"], + trigger_price=r["trigger_price"], + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=margin_capital_for_trade_record(r), + leverage=r["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(r["direction"], r["trigger_price"], r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=result, + miss_reason=handoff_trade_miss_reason(miss_reason, r), + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],)) + clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day()) + synced += 1 + try: + sync_trade_records_from_exchange(conn, force=True) + except Exception: + pass + return {"ok": True, "synced": synced} + + def reconcile_external_closes(conn, days=None): global _RECONCILE_FLAT_STREAK if not exchange_private_api_configured(): @@ -8482,6 +8604,7 @@ try: views={"add_order": add_order, "add_key": add_key}, ohlcv_fn=_hub_fetch_ohlcv, volume_rank_fn=_hub_fetch_volume_rank, + reconcile_hub_flat_fn=reconcile_hub_external_close, ) except Exception as _hub_err: print(f"[hub_bridge] gate: {_hub_err}") diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index 74e6e48..8252784 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -1967,10 +1967,13 @@ def get_effective_trade_field(row, reviewed_key, base_key, default=None): def to_effective_trade_dict(row): item = row_to_dict(row) - base_stop = item.get("initial_stop_loss") if item.get("initial_stop_loss") not in (None, "") else item.get("stop_loss") + from order_monitor_display_lib import snapshot_stop_loss + + open_stop = snapshot_stop_loss(item.get("initial_stop_loss"), item.get("stop_loss")) + item["display_open_stop_loss"] = open_stop item["effective_opened_at"] = get_effective_trade_field(row, "reviewed_opened_at", "opened_at", item.get("opened_at")) item["effective_closed_at"] = get_effective_trade_field(row, "reviewed_closed_at", "closed_at", item.get("closed_at")) - item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", base_stop) + item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", open_stop) item["effective_take_profit"] = get_effective_trade_field(row, "reviewed_take_profit", "take_profit", item.get("take_profit")) item["effective_result"] = get_effective_trade_field(row, "reviewed_result", "result", item.get("result")) item["effective_miss_reason"] = get_effective_trade_field(row, "reviewed_miss_reason", "miss_reason", item.get("miss_reason")) @@ -2283,10 +2286,13 @@ def insert_trade_record( open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts) close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts) er = (entry_reason or "").strip() or None + from order_monitor_display_lib import snapshot_stop_loss + + snap_sl = snapshot_stop_loss(initial_stop_loss, stop_loss) conn.execute( "INSERT INTO trade_records (symbol,monitor_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,entry_reason,trend_plan_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( - symbol, monitor_type, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, + symbol, monitor_type, direction, trigger_price, snap_sl, snap_sl, take_profit, margin_capital, leverage, pnl_amount, hold_seconds, trade_style, risk_amount, planned_rr, actual_rr, hold_minutes, open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id, er, @@ -4064,9 +4070,32 @@ def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_ if hit is None and since_ms: trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=100) hit = pick_from_trades(trades) - return hit + if hit is not None: + return hit except Exception: - return None + pass + try: + from gate_position_history_lib import pick_gate_position_close + + pos = pick_gate_position_close( + fetch_gate_positions_close_history(), + exchange_symbol, + direction, + opened_at_ms=since_ms, + ) + if pos: + return { + "price": None, + "timestamp": pos["close_ms"], + "side": close_side, + "_from_position_history": True, + "_realized_pnl": pos.get("pnl"), + "_sync_key": pos.get("sync_key"), + "_open_ms": pos.get("open_ms"), + } + except Exception: + pass + return None def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None): @@ -4173,7 +4202,7 @@ def calc_weighted_exit_price(trades): return weighted_sum / total_amount -def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): +def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None, *, prefer_manual=False): """ 交易所已无仓、本地仍为 active 时,推断平仓类型/时间/盈亏。 返回 (result, pnl_amount, closed_at_str, miss_reason)。 @@ -4198,6 +4227,12 @@ def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): ts = trade.get("timestamp") if ts: closed_at_str = ms_to_app_local_str(int(ts)) + if trade.get("_from_position_history"): + pnl_hist = trade.get("_realized_pnl") + if pnl_hist is not None: + note = "中控平仓后按 Gate 平仓历史同步盈亏" if prefer_manual else "按 Gate 平仓历史同步盈亏" + res = "手动平仓" if prefer_manual else "外部平仓" + return (res, float(pnl_hist), closed_at_str, note) if exit_px is None or exit_px <= 0: p = get_price(sym) @@ -4220,6 +4255,13 @@ def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): result = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_px) pnl = calc_pnl(direction, trigger_price, exit_px, margin_capital, leverage) + if prefer_manual: + return ( + "手动平仓", + pnl, + closed_at_str, + "中控平仓后按交易所成交记录同步", + ) if result: return ( result, @@ -4235,6 +4277,87 @@ def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): ) +def reconcile_hub_external_close(conn, symbol, direction): + """中控市价全平后:立即同步匹配 order_monitor,并读 Gate 平仓历史。""" + if not exchange_private_api_configured(): + return {"ok": False, "msg": "未配置 GATE_API_KEY / GATE_API_SECRET", "synced": 0} + from gate_position_history_lib import unified_symbol_for_match + + sym_u = unified_symbol_for_match(symbol) + dir_l = (direction or "").strip().lower() + if dir_l not in ("long", "short"): + return {"ok": False, "msg": "side 须为 long 或 short", "synced": 0} + synced = 0 + rows = conn.execute( + "SELECT * FROM order_monitors WHERE status IN ('active', 'error')" + ).fetchall() + for r in rows: + if unified_symbol_for_match(r["symbol"]) != sym_u: + continue + if (r["direction"] or "").strip().lower() != dir_l: + continue + oid = int(r["id"]) + if r["status"] == "error": + opened_at_chk = get_opened_at_value(r) + existing = conn.execute( + "SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type='下单监控' LIMIT 1", + (r["symbol"], opened_at_chk), + ).fetchone() + if existing: + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,)) + synced += 1 + continue + exchange_symbol = r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"]) + live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) + if live_contracts is None: + continue + if live_contracts > 0: + time.sleep(0.6) + live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) + if live_contracts is None or live_contracts > 0: + continue + cancel_gate_swap_trigger_orders(exchange_symbol) + opened_at = get_opened_at_value(r) + opened_at_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close( + r, opened_at, opened_at_ms=opened_at_ms, prefer_manual=True + ) + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = r["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(r), + direction=r["direction"], + trigger_price=r["trigger_price"], + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=r["margin_capital"], + leverage=r["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(r["direction"], r["trigger_price"], r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=result, + miss_reason=handoff_trade_miss_reason(miss_reason, r), + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],)) + synced += 1 + try: + sync_trend_trade_records_from_exchange(conn) + except Exception: + pass + return {"ok": True, "synced": synced} + + def reconcile_external_closes(conn, days=None): synced_count = 0 cutoff_ms = None @@ -7979,6 +8102,7 @@ try: }, ohlcv_fn=_hub_fetch_ohlcv, volume_rank_fn=_hub_fetch_volume_rank, + reconcile_hub_flat_fn=reconcile_hub_external_close, ) from strategy_trend_register import build_trend_config, patch_trend_hub_enrich diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html index 907cc9a..29e3f9d 100644 --- a/crypto_monitor_gate_bot/templates/index.html +++ b/crypto_monitor_gate_bot/templates/index.html @@ -568,14 +568,14 @@
| 品种 | 类型 | 方向 | 成交 | 止损 | 止盈 | 基数 | 杠杆 | 持仓分钟 | 开仓(展示) | 平仓(展示) | 盈亏U(展示) | 结果 | 操作 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 品种 | 类型 | 方向 | 成交 | 止损(开仓) | 止盈 | 基数 | 杠杆 | 持仓分钟 | 开仓(展示) | 平仓(展示) | 盈亏U(展示) | 结果 | 操作 |
| {{ r.symbol }} | {{ r.monitor_type }} | {{ '做多' if r.direction == 'long' else '做空' }} | {{ price_fmt(r.symbol, r.trigger_price) }} | - {% set stop_show = r.effective_stop_loss or r.initial_stop_loss or r.stop_loss %} + {% set stop_show = r.display_open_stop_loss or r.initial_stop_loss or r.stop_loss %} {% set tp_show = r.effective_take_profit or r.take_profit %}{{ price_fmt(r.symbol, stop_show) }} | {{ price_fmt(r.symbol, tp_show) }} | diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 921bf5d..ce05be5 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -1901,13 +1901,12 @@ def get_effective_trade_field(row, reviewed_key, base_key, default=None): def to_effective_trade_dict(row): item = row_to_dict(row) - base_stop = item.get("initial_stop_loss") if item.get("initial_stop_loss") not in (None, "") else item.get("stop_loss") + from order_monitor_display_lib import snapshot_stop_loss + + open_stop = snapshot_stop_loss(item.get("initial_stop_loss"), item.get("stop_loss")) + item["display_open_stop_loss"] = open_stop item["effective_opened_at"] = get_effective_trade_field(row, "reviewed_opened_at", "opened_at", item.get("opened_at")) item["effective_closed_at"] = get_effective_trade_field(row, "reviewed_closed_at", "closed_at", item.get("closed_at")) - open_stop = item.get("initial_stop_loss") - if open_stop in (None, ""): - open_stop = base_stop - item["display_open_stop_loss"] = open_stop item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", open_stop) item["effective_take_profit"] = get_effective_trade_field(row, "reviewed_take_profit", "take_profit", item.get("take_profit")) item["effective_result"] = get_effective_trade_field(row, "reviewed_result", "result", item.get("result")) @@ -2154,7 +2153,9 @@ def insert_trade_record( open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts) close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts) kst = key_signal_type_for_trade_record(key_signal_type, KEY_MONITOR_AUTO_TYPES) - snap_sl = initial_stop_loss if initial_stop_loss not in (None, "") else stop_loss + from order_monitor_display_lib import snapshot_stop_loss + + snap_sl = snapshot_stop_loss(initial_stop_loss, stop_loss) er = ( (entry_reason or "").strip() or entry_reason_from_key_signal(kst) diff --git a/gate_position_history_lib.py b/gate_position_history_lib.py new file mode 100644 index 0000000..5ed0438 --- /dev/null +++ b/gate_position_history_lib.py @@ -0,0 +1,66 @@ +"""Gate 平仓历史匹配(fetch_positions_history),供 reconcile / 中控全平同步共用。""" + +from __future__ import annotations + + +def unified_symbol_for_match(symbol_str: str) -> str: + x = (symbol_str or "").strip().upper() + if ":" in x: + x = x.split(":")[0] + return x + + +def pick_gate_position_close( + hist: list[dict], + symbol: str, + direction: str, + *, + opened_at_ms: int | None = None, + closed_at_ms: int | None = None, + used_keys: set[str] | None = None, + max_close_delta_ms: int = 25 * 60 * 1000, +) -> dict | None: + """ + 从 Gate 平仓历史列表中选取与 symbol/direction/开仓时间最匹配的一条。 + 返回 normalize 后的 dict(含 close_ms、pnl、sync_key 等),无匹配则 None。 + """ + if not hist: + return None + sym_u = unified_symbol_for_match(symbol) + dir_l = (direction or "long").strip().lower() + if dir_l not in ("long", "short"): + return None + used = used_keys or set() + ref_ms = closed_at_ms or opened_at_ms + best = None + best_d = None + for h in hist: + if not isinstance(h, dict): + continue + sk = h.get("sync_key") + if not sk or sk in used: + continue + if h.get("symbol_u") != sym_u: + continue + if (h.get("side") or "").strip().lower() != dir_l: + continue + cm = h.get("close_ms") + if cm is None: + continue + if opened_at_ms is not None: + if cm < opened_at_ms - 15 * 60 * 1000: + continue + if cm > opened_at_ms + 15 * 86400 * 1000: + continue + if ref_ms is not None: + d = abs(int(cm) - int(ref_ms)) + else: + d = 0 + if best_d is None or d < best_d: + best_d = d + best = h + if best is None or best_d is None: + return None + if ref_ms is not None and best_d > max_close_delta_ms: + return None + return best diff --git a/hub_bridge.py b/hub_bridge.py index 9c336fd..820233f 100644 --- a/hub_bridge.py +++ b/hub_bridge.py @@ -207,6 +207,7 @@ def install_on_app( ohlcv_fn=None, account_fn=None, volume_rank_fn=None, + reconcile_hub_flat_fn=None, ): app.config["HUB_CTX"] = { "exchange": exchange, @@ -219,6 +220,7 @@ def install_on_app( "views": views, "ohlcv_fn": ohlcv_fn, "volume_rank_fn": volume_rank_fn, + "reconcile_hub_flat_fn": reconcile_hub_flat_fn, } install_hub_embed_headers(app) configure_hub_embed_session(app) @@ -607,6 +609,40 @@ def register_hub_routes(app): return jsonify({"ok": False, "msg": "该实例无趋势回调"}), 400 return jsonify(_invoke_view_get("stop_trend_pullback", f"/stop_trend_pullback/{pid}")) + @app.route("/api/hub/order/sync-flat", methods=["POST"]) + @_hub_auth_required + def api_hub_order_sync_flat(): + """中控市价全平后:同步 order_monitors 并读 Gate 平仓历史写交易记录。""" + fn = _ctx().get("reconcile_hub_flat_fn") + if not callable(fn): + return jsonify({"ok": False, "msg": "该实例未配置 order sync-flat"}), 400 + body = request.get_json(silent=True) or {} + symbol = (body.get("symbol") or request.form.get("symbol") or "").strip() + side = ( + body.get("side") + or body.get("direction") + or request.form.get("side") + or "" + ).strip().lower() + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + if side not in ("long", "short"): + return jsonify({"ok": False, "msg": "side 须为 long 或 short"}), 400 + get_db = _ctx().get("get_db") + if not callable(get_db): + return jsonify({"ok": False, "msg": "HUB_CTX 缺少 get_db"}), 500 + conn = get_db() + try: + out = fn(conn, symbol, side) + if not isinstance(out, dict): + out = {"ok": True, "synced": int(out or 0)} + conn.commit() + return jsonify(out) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + finally: + conn.close() + @app.route("/api/hub/trend/sync-flat", methods=["POST"]) @_hub_auth_required def api_hub_trend_sync_flat(): diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 8381708..1f97acc 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -1802,17 +1802,29 @@ async def api_close_position(exchange_id: str, body: ClosePositionBody): "payload": payload, "ok": bool(isinstance(payload, dict) and payload.get("ok")), } - if out.get("ok") and "trend" in (ex.get("capabilities") or []): + if out.get("ok"): + ex_key = (ex.get("key") or "").strip().lower() async with httpx.AsyncClient() as flask_client: - sync_parsed = await _fetch_flask_json( - flask_client, - ex, - "/api/hub/trend/sync-flat", - method="POST", - json_body={"symbol": sym, "side": side}, - ) - if isinstance(sync_parsed, dict): - out["trend_sync"] = sync_parsed + if ex_key in ("gate", "gate_bot"): + order_sync = await _fetch_flask_json( + flask_client, + ex, + "/api/hub/order/sync-flat", + method="POST", + json_body={"symbol": sym, "side": side}, + ) + if isinstance(order_sync, dict): + out["order_sync"] = order_sync + if "trend" in (ex.get("capabilities") or []): + sync_parsed = await _fetch_flask_json( + flask_client, + ex, + "/api/hub/trend/sync-flat", + method="POST", + json_body={"symbol": sym, "side": side}, + ) + if isinstance(sync_parsed, dict): + out["trend_sync"] = sync_parsed _schedule_board_refresh() return out diff --git a/order_monitor_display_lib.py b/order_monitor_display_lib.py index 3d02ef7..13643e8 100644 --- a/order_monitor_display_lib.py +++ b/order_monitor_display_lib.py @@ -13,13 +13,27 @@ def _positive_float(value: Any) -> Optional[float]: 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, diff --git a/tests/test_gate_position_history_lib.py b/tests/test_gate_position_history_lib.py new file mode 100644 index 0000000..cecbf5e --- /dev/null +++ b/tests/test_gate_position_history_lib.py @@ -0,0 +1,26 @@ +from gate_position_history_lib import pick_gate_position_close, unified_symbol_for_match + + +def test_unified_symbol_strips_settle_suffix(): + assert unified_symbol_for_match("BTC/USDT:USDT") == "BTC/USDT" + + +def test_pick_gate_position_close_matches_symbol_side_and_time(): + hist = [ + { + "symbol_u": "SOL/USDT", + "side": "short", + "close_ms": 1_700_000_000_000, + "open_ms": 1_699_999_000_000, + "pnl": -1.25, + "sync_key": "SOL_USDT|1|short", + } + ] + hit = pick_gate_position_close( + hist, + "SOL/USDT:USDT", + "short", + opened_at_ms=1_699_999_500_000, + ) + assert hit is not None + assert hit["pnl"] == -1.25 diff --git a/tests/test_order_monitor_display_lib.py b/tests/test_order_monitor_display_lib.py index d7084ef..cceb081 100644 --- a/tests/test_order_monitor_display_lib.py +++ b/tests/test_order_monitor_display_lib.py @@ -1,6 +1,7 @@ from order_monitor_display_lib import ( apply_order_price_display_fields, is_sl_breakeven_secured, + monitor_open_stop_loss, order_monitor_tpsl_needs_sync, resolve_live_tpsl_prices, sl_breakeven_from_exchange_tpsl, @@ -26,6 +27,11 @@ def test_snapshot_stop_loss_prefers_initial(): assert snapshot_stop_loss(None, 2.6) == 2.6 +def test_monitor_open_stop_loss_prefers_initial_snapshot(): + row = {"initial_stop_loss": 64000, "stop_loss": 63200} + assert monitor_open_stop_loss(row) == 64000 + + def test_snapshot_rr_ignores_current_stop_after_manual_move(): rr = snapshot_rr(_calc_rr, "long", 2.726, 2.45, 2.65, 3.3) assert rr is not None