From 959593cdab4754faa795d347125f88963ebf38b9 Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 11 Jun 2026 19:30:16 +0800 Subject: [PATCH] feat: add timed position close (1h/2h/4h) for key levels and live orders Program monitors open positions and market-closes at deadline; UI shows label and countdown on instance and hub boards. Co-authored-by: Cursor --- crypto_monitor_binance/app.py | 55 ++- crypto_monitor_binance/templates/index.html | 22 + crypto_monitor_gate/app.py | 99 ++++- crypto_monitor_gate/templates/index.html | 22 + crypto_monitor_gate_bot/app.py | 99 ++++- crypto_monitor_gate_bot/templates/index.html | 22 + crypto_monitor_okx/app.py | 92 ++++- crypto_monitor_okx/templates/index.html | 22 + manual_trading_hub/hub.py | 6 + manual_trading_hub/static/app.js | 9 + manual_trading_hub/static/index.html | 3 +- manual_trading_hub/static/time_close_ui.js | 93 +++++ scripts/apply_time_close_patches.py | 412 +++++++++++++++++++ static/instance_theme.css | 11 + static/time_close_ui.js | 93 +++++ strategy_templates/key_monitor_panel.html | 11 + time_close_lib.py | 150 +++++++ 17 files changed, 1152 insertions(+), 69 deletions(-) create mode 100644 manual_trading_hub/static/time_close_ui.js create mode 100644 scripts/apply_time_close_patches.py create mode 100644 static/time_close_ui.js create mode 100644 time_close_lib.py diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index e7b7f44..31a54bc 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -99,6 +99,17 @@ from key_sl_tp_lib import ( sl_tp_mode_label, sl_tp_plan_summary_text, ) +from time_close_lib import ( + TIME_CLOSE_RESULT, + apply_time_close_to_payload, + ensure_time_close_schema, + parse_time_close_enabled_form, + parse_time_close_hours_form, + should_trigger_time_close, + time_close_insert_values, + time_close_label, + time_close_settings_from_row, +) from manual_sltp_lib import ( normalize_open_sltp_mode, resolve_entrust_sltp_prices, @@ -1431,6 +1442,7 @@ def init_db(): c.execute(ddl) except Exception: pass + ensure_time_close_schema(c) try: c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL") @@ -4534,6 +4546,8 @@ def _market_open_for_key_monitor( take_profit, key_signal_type=None, breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, ): """ 与手动「实盘下单」对齐的市价开仓与 order_monitors 写入(Binance U 本位)。 @@ -5073,7 +5087,10 @@ def check_fib_key_monitors(): conn.close() -def _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0): +def _add_fib_key_monitor( + conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0, + time_close_enabled=0, time_close_hours=None, +): if _fib_key_exists_for_symbol(conn, symbol): return False, f"{symbol} 已有斐波监控(同币仅允许一条 0.618/0.786)" ratio = fib_ratio_from_type(mt) @@ -5134,15 +5151,16 @@ def _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px, br except Exception as e: return False, friendly_exchange_error(e, available_usdt=available_usdt) be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) conn.execute( "INSERT INTO key_monitors " "(symbol, monitor_type, direction, upper, lower, " "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, " - "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled) " - "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, time_close_enabled, time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( symbol, mt, direction_sel, upper_px, lower_px, - oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, tc_en, tc_h, ), ) return True, None @@ -5158,6 +5176,7 @@ def _false_breakout_exists_for_symbol(conn, symbol): def _add_false_breakout_key_monitor( conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=0, + time_close_enabled=0, time_close_hours=None, ): if _false_breakout_exists_for_symbol(conn, symbol): return False, f"{symbol} 已有假突破监控(同币仅允许一条)" @@ -5214,15 +5233,16 @@ def _add_false_breakout_key_monitor( except Exception as e: return False, friendly_exchange_error(e, available_usdt=available_usdt) be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) conn.execute( "INSERT INTO key_monitors " "(symbol, monitor_type, direction, upper, lower, " "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, " - "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled) " - "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, time_close_enabled, time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( symbol, FALSE_BREAKOUT_MONITOR_TYPE, direction_sel, upper_px, lower_px, - oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, tc_en, tc_h, ), ) return True, None @@ -5333,6 +5353,7 @@ def check_key_monitors(): key_sig = typ if typ in KEY_MONITOR_AUTO_TYPES else None be_on = breakeven_enabled_from_row(r, 0) + tc_en, tc_h, _ = time_close_settings_from_row(r) ok_trade, trade_err, det = _market_open_for_key_monitor( conn, sym, @@ -5342,6 +5363,8 @@ def check_key_monitors(): tp_raw, key_signal_type=key_sig, breakeven_enabled=1 if be_on else 0, + time_close_enabled=tc_en, + time_close_hours=tc_h, ) planned_rr_txt = ( format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" @@ -5515,12 +5538,14 @@ def check_order_monitors(): send_wechat_msg(be_msg) res = None + if should_trigger_time_close(r): + res = TIME_CLOSE_RESULT # 做多 - if direction == "long": + if not res and direction == "long": if p >= take_profit: res = "止盈" elif p <= stop_loss: res = "止损" # 做空 - elif direction == "short": + elif not res and direction == "short": if p <= take_profit: res = "止盈" elif p >= stop_loss: res = "止损" @@ -6304,7 +6329,8 @@ def api_price_snapshot(): "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_limit_order_id,created_at FROM key_monitors" ).fetchall() order_rows = conn.execute( - "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage FROM order_monitors WHERE status='active'" + "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage," + "time_close_enabled,time_close_hours,time_close_at_ms,opened_at_ms FROM order_monitors WHERE status='active'" ).fetchall() symbol_set = set() @@ -6488,6 +6514,7 @@ def api_price_snapshot(): format_price_fn=format_price_for_symbol, symbol=r["symbol"], ) + apply_time_close_to_payload(payload, r) new_sl, new_tp, changed = order_monitor_tpsl_needs_sync( r["stop_loss"], r["take_profit"], exchange_tpsl ) @@ -7284,14 +7311,20 @@ def add_order(): else: breakeven_price = round(float(trigger_price) * (1 + breakeven_offset_pct / 100.0), 8) breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0 + tc_en = parse_time_close_enabled_form(d.get("time_close_enabled")) + tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None + if tc_en and not tc_h: + tc_en = 0 + tc_en, tc_h, tc_at = time_close_insert_values(tc_en, tc_h, opened_at_ms) conn.execute( - "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, time_close_enabled, time_close_hours, time_close_at_ms) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent_db, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day, ORDER_MONITOR_TYPE_MANUAL, + tc_en, tc_h, tc_at, ) ) conn.commit() diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html index 6f016f0..7056ac5 100644 --- a/crypto_monitor_binance/templates/index.html +++ b/crypto_monitor_binance/templates/index.html @@ -376,6 +376,14 @@ + @@ -417,6 +425,12 @@ {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %} + + 时间平仓 {{ o.time_close_hours or '' }}h + · 倒计时 --:--:-- +
@@ -785,6 +799,7 @@
+ + + + - + + diff --git a/manual_trading_hub/static/time_close_ui.js b/manual_trading_hub/static/time_close_ui.js new file mode 100644 index 0000000..2e3366a --- /dev/null +++ b/manual_trading_hub/static/time_close_ui.js @@ -0,0 +1,93 @@ +/** + * 时间平仓:表单开关 + 持仓倒计时。 + */ +(function (global) { + "use strict"; + + function pad2(n) { + return n < 10 ? "0" + n : String(n); + } + + function formatCountdown(sec) { + const s = Math.max(0, parseInt(sec, 10) || 0); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const r = s % 60; + return pad2(h) + ":" + pad2(m) + ":" + pad2(r); + } + + function bindTimeCloseForm(checkboxId, selectId, wrapId) { + const cb = document.getElementById(checkboxId); + const sel = document.getElementById(selectId); + const wrap = wrapId ? document.getElementById(wrapId) : null; + if (!cb || !sel) return; + function sync() { + const on = !!cb.checked; + sel.disabled = !on; + if (wrap) wrap.classList.toggle("is-disabled", !on); + } + cb.addEventListener("change", sync); + sync(); + } + + function paintOrderTimeClose(order) { + if (!order || order.id == null) return; + const wrap = document.getElementById("order-time-close-wrap-" + order.id); + const cd = document.getElementById("order-time-close-cd-" + order.id); + if (!wrap || !cd) return; + const enabled = !!(order.time_close_enabled || order.time_close_at_ms); + if (!enabled) { + wrap.style.display = "none"; + return; + } + wrap.style.display = ""; + const hours = order.time_close_hours; + const label = order.time_close_label || (hours ? "时间平仓 " + hours + "h" : "时间平仓"); + const labelEl = wrap.querySelector(".pos-time-close-label"); + if (labelEl) labelEl.textContent = label; + let rem = + order.time_close_remaining_sec != null + ? Number(order.time_close_remaining_sec) + : null; + if ((rem == null || !Number.isFinite(rem)) && order.time_close_at_ms) { + rem = Math.max(0, Math.floor((Number(order.time_close_at_ms) - Date.now()) / 1000)); + } + cd.textContent = Number.isFinite(rem) ? formatCountdown(rem) : "--:--:--"; + wrap.dataset.closeAtMs = order.time_close_at_ms ? String(order.time_close_at_ms) : ""; + } + + function tickLocalCountdowns() { + document.querySelectorAll("[data-close-at-ms]").forEach(function (wrap) { + const closeAtRaw = wrap.dataset.closeAtMs || wrap.getAttribute("data-close-at-ms") || ""; + const cd = wrap.querySelector(".pos-time-close-cd"); + if (!cd) return; + const closeAt = Number(closeAtRaw); + if (!closeAt) return; + const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000)); + cd.textContent = formatCountdown(rem); + }); + } + + function paintOrders(orders) { + (orders || []).forEach(paintOrderTimeClose); + } + + function syncKeyTimeCloseVisibility(show) { + const wrap = document.getElementById("key-time-close-wrap"); + if (!wrap) return; + wrap.style.display = show ? "inline-flex" : "none"; + } + + global.TimeCloseUI = { + bindTimeCloseForm: bindTimeCloseForm, + paintOrderTimeClose: paintOrderTimeClose, + paintOrders: paintOrders, + tickLocalCountdowns: tickLocalCountdowns, + syncKeyTimeCloseVisibility: syncKeyTimeCloseVisibility, + formatCountdown: formatCountdown, + }; + + if (!global.__timeCloseCountdownTimer) { + global.__timeCloseCountdownTimer = setInterval(tickLocalCountdowns, 1000); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/scripts/apply_time_close_patches.py b/scripts/apply_time_close_patches.py new file mode 100644 index 0000000..bc9906c --- /dev/null +++ b/scripts/apply_time_close_patches.py @@ -0,0 +1,412 @@ +#!/usr/bin/env python3 +"""对 binance/okx/gate_bot 应用与 gate 相同的时间平仓代码替换。""" +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +FILES = [ + ROOT / "crypto_monitor_binance" / "app.py", + ROOT / "crypto_monitor_okx" / "app.py", + ROOT / "crypto_monitor_gate_bot" / "app.py", +] + +REPLACEMENTS: list[tuple[str, str]] = [ + ( + "def _market_open_for_key_monitor(\n conn,\n symbol,\n direction,\n exchange_symbol,\n stop_loss,\n take_profit,\n key_signal_type=None,\n breakeven_enabled=0,\n):", + "def _market_open_for_key_monitor(\n conn,\n symbol,\n direction,\n exchange_symbol,\n stop_loss,\n take_profit,\n key_signal_type=None,\n breakeven_enabled=0,\n time_close_enabled=0,\n time_close_hours=None,\n):", + ), + ( + "def _add_false_breakout_key_monitor(\n conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=0,\n):", + "def _add_false_breakout_key_monitor(\n conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=0,\n time_close_enabled=0, time_close_hours=None,\n):", + ), + ( + "def _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0):", + "def _add_fib_key_monitor(\n conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0,\n time_close_enabled=0, time_close_hours=None,\n):", + ), + ( + " key_sig = typ if typ in KEY_MONITOR_AUTO_TYPES else None\n be_on = breakeven_enabled_from_row(r, 0)\n ok_trade, trade_err, det = _market_open_for_key_monitor(\n conn,\n sym,\n direction,\n exchange_symbol,\n sl_raw,\n tp_raw,\n key_signal_type=key_sig,\n breakeven_enabled=1 if be_on else 0,\n )", + " key_sig = typ if typ in KEY_MONITOR_AUTO_TYPES else None\n be_on = breakeven_enabled_from_row(r, 0)\n tc_en, tc_h, _ = time_close_settings_from_row(r)\n ok_trade, trade_err, det = _market_open_for_key_monitor(\n conn,\n sym,\n direction,\n exchange_symbol,\n sl_raw,\n tp_raw,\n key_signal_type=key_sig,\n breakeven_enabled=1 if be_on else 0,\n time_close_enabled=tc_en,\n time_close_hours=tc_h,\n )", + ), + ( + " res = None\n # 做多\n if direction == \"long\":\n if p >= take_profit: res = \"止盈\"\n elif p <= stop_loss: res = \"止损\"\n # 做空\n elif direction == \"short\":\n if p <= take_profit: res = \"止盈\"\n elif p >= stop_loss: res = \"止损\"", + " res = None\n if should_trigger_time_close(r):\n res = TIME_CLOSE_RESULT\n # 做多\n if not res and direction == \"long\":\n if p >= take_profit: res = \"止盈\"\n elif p <= stop_loss: res = \"止损\"\n # 做空\n elif not res and direction == \"short\":\n if p <= take_profit: res = \"止盈\"\n elif p >= stop_loss: res = \"止损\"", + ), + ( + ' "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage FROM order_monitors WHERE status=\'active\'"', + ' "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,"\n "time_close_enabled,time_close_hours,time_close_at_ms,opened_at_ms FROM order_monitors WHERE status=\'active\'"', + ), + ( + " apply_order_price_display_fields(\n payload,\n direction=r[\"direction\"],\n entry_price=entry,\n initial_stop_loss=r[\"initial_stop_loss\"],\n stop_loss=r[\"stop_loss\"],\n take_profit=r[\"take_profit\"],\n calc_rr_ratio_fn=calc_rr_ratio,\n exchange_tpsl=exchange_tpsl,\n format_price_fn=format_price_for_symbol,\n symbol=r[\"symbol\"],\n )\n new_sl, new_tp, changed = order_monitor_tpsl_needs_sync(", + " apply_order_price_display_fields(\n payload,\n direction=r[\"direction\"],\n entry_price=entry,\n initial_stop_loss=r[\"initial_stop_loss\"],\n stop_loss=r[\"stop_loss\"],\n take_profit=r[\"take_profit\"],\n calc_rr_ratio_fn=calc_rr_ratio,\n exchange_tpsl=exchange_tpsl,\n format_price_fn=format_price_for_symbol,\n symbol=r[\"symbol\"],\n )\n apply_time_close_to_payload(payload, r)\n new_sl, new_tp, changed = order_monitor_tpsl_needs_sync(", + ), + ( + " be_flag = parse_breakeven_enabled_form(d.get(\"breakeven_enabled\"))\n if is_false_breakout_key_monitor_type(mt):", + " be_flag = parse_breakeven_enabled_form(d.get(\"breakeven_enabled\"))\n tc_en = parse_time_close_enabled_form(d.get(\"time_close_enabled\"))\n tc_h = parse_time_close_hours_form(d.get(\"time_close_hours\")) if tc_en else None\n if tc_en and not tc_h:\n tc_en = 0\n if is_false_breakout_key_monitor_type(mt):", + ), + ( + " ok_fb, err_fb = _add_false_breakout_key_monitor(\n conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=be_flag,\n )", + " ok_fb, err_fb = _add_false_breakout_key_monitor(\n conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=be_flag,\n time_close_enabled=tc_en, time_close_hours=tc_h,\n )", + ), + ( + " f\"|有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h|移动保本:{'开' if be_flag else '关'}\"\n )", + " f\"|有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h|移动保本:{'开' if be_flag else '关'}\"\n + (f\"|{time_close_label(tc_h)}\" if tc_en else \"\")\n )", + ), + ( + " ok_fib, err_fib = _add_fib_key_monitor(\n conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=be_flag,\n )", + " ok_fib, err_fib = _add_fib_key_monitor(\n conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=be_flag,\n time_close_enabled=tc_en, time_close_hours=tc_h,\n )", + ), + ( + " f\"|移动保本:{'开' if be_flag else '关'}\"\n )\n return redirect(\"/key_monitor\")", + " f\"|移动保本:{'开' if be_flag else '关'}\"\n + (f\"|{time_close_label(tc_h)}\" if tc_en else \"\")\n )\n return redirect(\"/key_monitor\")", + ), + ( + " if mt in KEY_MONITOR_AUTO_TYPES:\n extra = f\"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}\"", + " if mt in KEY_MONITOR_AUTO_TYPES:\n extra = f\"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}\"\n if tc_en:\n extra += f\"|{time_close_label(tc_h)}\"", + ), +] + +MARKET_OPEN_OLD = """ breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) + be_enabled = 1 if int(breakeven_enabled or 0) != 0 else 0 + + conn.execute( + "INSERT INTO order_monitors " + "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " + "margin_capital, leverage, trade_style, risk_percent, risk_amount, " + "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " + "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent, + risk_amount_final, + breakeven_rr_trigger, + breakeven_offset_pct, + breakeven_step_r, + 0, + breakeven_price, + be_enabled, + notional_value, + position_ratio, + base_amount, + amount, + open_order_id, + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + stored_key_signal_type(key_signal_type), + ), + )""" + +MARKET_OPEN_NEW = """ breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) + be_enabled = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, tc_at = time_close_insert_values( + time_close_enabled, time_close_hours, opened_at_ms + ) + + conn.execute( + "INSERT INTO order_monitors " + "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " + "margin_capital, leverage, trade_style, risk_percent, risk_amount, " + "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " + "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type, " + "time_close_enabled, time_close_hours, time_close_at_ms) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent, + risk_amount_final, + breakeven_rr_trigger, + breakeven_offset_pct, + breakeven_step_r, + 0, + breakeven_price, + be_enabled, + notional_value, + position_ratio, + base_amount, + amount, + open_order_id, + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + stored_key_signal_type(key_signal_type), + tc_en, + tc_h, + tc_at, + ), + )""" + +FIB_INSERT_OLD = """ opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + conn.execute( + "INSERT INTO order_monitors " + "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " + "margin_capital, leverage, trade_style, risk_percent, risk_amount, " + "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " + "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent, + risk_amount_final, + breakeven_rr_trigger, + breakeven_offset_pct, + breakeven_step_r, + 0, + breakeven_price, + 1 if breakeven_enabled_from_row(row, 0) else 0, + notional_value, + position_ratio, + base_amount, + amount, + exchange_order_id or "", + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + stored_key_signal_type(typ), + ), + )""" + +FIB_INSERT_NEW = """ opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + tc_en, tc_h, _ = time_close_settings_from_row(row) + tc_en, tc_h, tc_at = time_close_insert_values(tc_en, tc_h, opened_at_ms) + conn.execute( + "INSERT INTO order_monitors " + "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " + "margin_capital, leverage, trade_style, risk_percent, risk_amount, " + "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " + "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type, " + "time_close_enabled, time_close_hours, time_close_at_ms) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent, + risk_amount_final, + breakeven_rr_trigger, + breakeven_offset_pct, + breakeven_step_r, + 0, + breakeven_price, + 1 if breakeven_enabled_from_row(row, 0) else 0, + notional_value, + position_ratio, + base_amount, + amount, + exchange_order_id or "", + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + stored_key_signal_type(typ), + tc_en, + tc_h, + tc_at, + ), + )""" + +KEY_FB_OLD = """ be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, FALSE_BREAKOUT_MONITOR_TYPE, direction_sel, upper_px, lower_px, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, + ), + )""" + +KEY_FB_NEW = """ be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, time_close_enabled, time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, FALSE_BREAKOUT_MONITOR_TYPE, direction_sel, upper_px, lower_px, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, tc_en, tc_h, + ), + )""" + +KEY_FIB_OLD = """ be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, mt, direction_sel, upper_px, lower_px, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, + ), + )""" + +KEY_FIB_NEW = """ be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, time_close_enabled, time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, mt, direction_sel, upper_px, lower_px, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, tc_en, tc_h, + ), + )""" + +ADD_KEY_RS_OLD = """ conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled," + "max_notify,notify_interval_min) " + "VALUES (?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + mt, + direction_sel, + upper_px, + lower_px, + sl_tp_mode, + manual_tp, + be_flag, + KEY_ALERT_MAX_TIMES, + KEY_ALERT_INTERVAL_MINUTES, + ), + ) + else: + conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled) " + "VALUES (?,?,?,?,?,?,?,?)", + (symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag), + )""" + +ADD_KEY_RS_NEW = """ conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled," + "max_notify,notify_interval_min,time_close_enabled,time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + mt, + direction_sel, + upper_px, + lower_px, + sl_tp_mode, + manual_tp, + be_flag, + KEY_ALERT_MAX_TIMES, + KEY_ALERT_INTERVAL_MINUTES, + tc_en, + tc_h, + ), + ) + else: + conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled," + "time_close_enabled,time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?)", + (symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag, tc_en, tc_h), + )""" + +ADD_ORDER_OLD = """ breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0 + conn.execute( + "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, + margin_capital, leverage, trade_style, risk_percent_db, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price, + breakeven_enabled, + notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day, + ORDER_MONITOR_TYPE_MANUAL, + ) + )""" + +ADD_ORDER_NEW = """ breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0 + tc_en = parse_time_close_enabled_form(d.get("time_close_enabled")) + tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None + if tc_en and not tc_h: + tc_en = 0 + tc_en, tc_h, tc_at = time_close_insert_values(tc_en, tc_h, opened_at_ms) + conn.execute( + "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, time_close_enabled, time_close_hours, time_close_at_ms) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, + margin_capital, leverage, trade_style, risk_percent_db, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price, + breakeven_enabled, + notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day, + ORDER_MONITOR_TYPE_MANUAL, + tc_en, tc_h, tc_at, + ) + )""" + +BIG_BLOCKS = [ + (MARKET_OPEN_OLD, MARKET_OPEN_NEW), + (FIB_INSERT_OLD, FIB_INSERT_NEW), + (KEY_FB_OLD, KEY_FB_NEW), + (KEY_FIB_OLD, KEY_FIB_NEW), + (ADD_KEY_RS_OLD, ADD_KEY_RS_NEW), + (ADD_ORDER_OLD, ADD_ORDER_NEW), +] + + +def patch(path: Path) -> None: + text = path.read_text(encoding="utf-8") + for old, new in REPLACEMENTS + BIG_BLOCKS: + if old in text: + text = text.replace(old, new, 1) + path.write_text(text, encoding="utf-8") + print("done", path.name) + + +def main() -> None: + for f in FILES: + patch(f) + + +if __name__ == "__main__": + main() diff --git a/static/instance_theme.css b/static/instance_theme.css index 8d5cbcf..6f5c91f 100644 --- a/static/instance_theme.css +++ b/static/instance_theme.css @@ -341,6 +341,17 @@ html[data-theme="light"] .pos-meta-item::after { color: #b8c8d8 !important; } +.pos-time-close-meta { + color: #8fc8ff; +} +.pos-time-close-meta .pos-time-close-cd { + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; +} +.key-time-close-wrap.is-disabled select, +.order-time-close-wrap.is-disabled select { + opacity: 0.55; +} html[data-theme="light"] .pos-meta-on { color: #006e9a !important; } diff --git a/static/time_close_ui.js b/static/time_close_ui.js new file mode 100644 index 0000000..2e3366a --- /dev/null +++ b/static/time_close_ui.js @@ -0,0 +1,93 @@ +/** + * 时间平仓:表单开关 + 持仓倒计时。 + */ +(function (global) { + "use strict"; + + function pad2(n) { + return n < 10 ? "0" + n : String(n); + } + + function formatCountdown(sec) { + const s = Math.max(0, parseInt(sec, 10) || 0); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const r = s % 60; + return pad2(h) + ":" + pad2(m) + ":" + pad2(r); + } + + function bindTimeCloseForm(checkboxId, selectId, wrapId) { + const cb = document.getElementById(checkboxId); + const sel = document.getElementById(selectId); + const wrap = wrapId ? document.getElementById(wrapId) : null; + if (!cb || !sel) return; + function sync() { + const on = !!cb.checked; + sel.disabled = !on; + if (wrap) wrap.classList.toggle("is-disabled", !on); + } + cb.addEventListener("change", sync); + sync(); + } + + function paintOrderTimeClose(order) { + if (!order || order.id == null) return; + const wrap = document.getElementById("order-time-close-wrap-" + order.id); + const cd = document.getElementById("order-time-close-cd-" + order.id); + if (!wrap || !cd) return; + const enabled = !!(order.time_close_enabled || order.time_close_at_ms); + if (!enabled) { + wrap.style.display = "none"; + return; + } + wrap.style.display = ""; + const hours = order.time_close_hours; + const label = order.time_close_label || (hours ? "时间平仓 " + hours + "h" : "时间平仓"); + const labelEl = wrap.querySelector(".pos-time-close-label"); + if (labelEl) labelEl.textContent = label; + let rem = + order.time_close_remaining_sec != null + ? Number(order.time_close_remaining_sec) + : null; + if ((rem == null || !Number.isFinite(rem)) && order.time_close_at_ms) { + rem = Math.max(0, Math.floor((Number(order.time_close_at_ms) - Date.now()) / 1000)); + } + cd.textContent = Number.isFinite(rem) ? formatCountdown(rem) : "--:--:--"; + wrap.dataset.closeAtMs = order.time_close_at_ms ? String(order.time_close_at_ms) : ""; + } + + function tickLocalCountdowns() { + document.querySelectorAll("[data-close-at-ms]").forEach(function (wrap) { + const closeAtRaw = wrap.dataset.closeAtMs || wrap.getAttribute("data-close-at-ms") || ""; + const cd = wrap.querySelector(".pos-time-close-cd"); + if (!cd) return; + const closeAt = Number(closeAtRaw); + if (!closeAt) return; + const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000)); + cd.textContent = formatCountdown(rem); + }); + } + + function paintOrders(orders) { + (orders || []).forEach(paintOrderTimeClose); + } + + function syncKeyTimeCloseVisibility(show) { + const wrap = document.getElementById("key-time-close-wrap"); + if (!wrap) return; + wrap.style.display = show ? "inline-flex" : "none"; + } + + global.TimeCloseUI = { + bindTimeCloseForm: bindTimeCloseForm, + paintOrderTimeClose: paintOrderTimeClose, + paintOrders: paintOrders, + tickLocalCountdowns: tickLocalCountdowns, + syncKeyTimeCloseVisibility: syncKeyTimeCloseVisibility, + formatCountdown: formatCountdown, + }; + + if (!global.__timeCloseCountdownTimer) { + global.__timeCloseCountdownTimer = setInterval(tickLocalCountdowns, 1000); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/strategy_templates/key_monitor_panel.html b/strategy_templates/key_monitor_panel.html index 8a723e0..2bbd541 100644 --- a/strategy_templates/key_monitor_panel.html +++ b/strategy_templates/key_monitor_panel.html @@ -157,6 +157,14 @@ +
@@ -197,6 +205,9 @@ 方案: {{ key_sl_tp_mode_label(k) }} {% endif %} 保本: {{ '开' if k.breakeven_enabled else '关' }} + {% if k.time_close_enabled and k.time_close_hours %} + 时间平仓: {{ k.time_close_hours }}h + {% endif %}
现价-
diff --git a/time_close_lib.py b/time_close_lib.py new file mode 100644 index 0000000..0d275bb --- /dev/null +++ b/time_close_lib.py @@ -0,0 +1,150 @@ +"""持仓时间平仓:开仓后按 1h/2h/4h 定时市价平仓。""" +from __future__ import annotations + +import time +from typing import Any, Optional + +ALLOWED_TIME_CLOSE_HOURS = (1, 2, 4) +TIME_CLOSE_RESULT = "时间平仓" + + +def parse_time_close_enabled_form(form_value: Any) -> int: + return 1 if str(form_value or "").strip().lower() in ("1", "true", "on", "yes") else 0 + + +def parse_time_close_hours_form(form_value: Any, *, default: int = 4) -> Optional[int]: + raw = str(form_value or "").strip().lower().rstrip("h") + if not raw: + return None + try: + h = int(float(raw)) + except (TypeError, ValueError): + return None + if h in ALLOWED_TIME_CLOSE_HOURS: + return h + return None + + +def normalize_time_close_hours(value: Any) -> Optional[int]: + try: + h = int(value) + except (TypeError, ValueError): + return None + return h if h in ALLOWED_TIME_CLOSE_HOURS else None + + +def _row_val(row: Any, key: str, default=None): + if row is None: + return default + try: + if hasattr(row, "keys") and key in row.keys(): + return row[key] + except Exception: + pass + if isinstance(row, dict): + return row.get(key, default) + return default + + +def time_close_settings_from_row(row: Any) -> tuple[int, Optional[int], Optional[int]]: + """返回 (enabled, hours, close_at_ms)。""" + enabled = int(_row_val(row, "time_close_enabled", 0) or 0) != 0 + hours = normalize_time_close_hours(_row_val(row, "time_close_hours")) + close_at = _row_val(row, "time_close_at_ms") + try: + close_at_ms = int(close_at) if close_at not in (None, "") else None + except (TypeError, ValueError): + close_at_ms = None + if enabled and hours and not close_at_ms: + opened_ms = _row_val(row, "opened_at_ms") + try: + opened_ms = int(opened_ms) if opened_ms not in (None, "") else None + except (TypeError, ValueError): + opened_ms = None + close_at_ms = compute_close_at_ms(opened_ms, hours) + return (1 if enabled and hours else 0, hours, close_at_ms) + + +def compute_close_at_ms(opened_at_ms: Any, hours: Any) -> Optional[int]: + h = normalize_time_close_hours(hours) + try: + opened = int(opened_at_ms) + except (TypeError, ValueError): + return None + if not h or opened <= 0: + return None + return opened + h * 3600 * 1000 + + +def should_trigger_time_close(row: Any, *, now_ms: Optional[int] = None) -> bool: + enabled, hours, close_at_ms = time_close_settings_from_row(row) + if not enabled or not close_at_ms: + return False + now = int(now_ms if now_ms is not None else time.time() * 1000) + return now >= int(close_at_ms) + + +def time_close_remaining_seconds(close_at_ms: Any, *, now_ms: Optional[int] = None) -> Optional[int]: + try: + close_at = int(close_at_ms) + except (TypeError, ValueError): + return None + now = int(now_ms if now_ms is not None else time.time() * 1000) + return max(0, int((close_at - now) / 1000)) + + +def format_time_close_countdown(seconds: Any) -> str: + try: + sec = max(0, int(seconds)) + except (TypeError, ValueError): + return "--:--:--" + h = sec // 3600 + m = (sec % 3600) // 60 + s = sec % 60 + return f"{h:02d}:{m:02d}:{s:02d}" + + +def time_close_label(hours: Any) -> str: + h = normalize_time_close_hours(hours) + return f"时间平仓 {h}h" if h else "时间平仓" + + +def apply_time_close_to_payload(payload: dict[str, Any], row: Any, *, now_ms: Optional[int] = None) -> None: + enabled, hours, close_at_ms = time_close_settings_from_row(row) + payload["time_close_enabled"] = bool(enabled) + payload["time_close_hours"] = hours + payload["time_close_at_ms"] = close_at_ms + payload["time_close_label"] = time_close_label(hours) if enabled else "" + if enabled and close_at_ms: + rem = time_close_remaining_seconds(close_at_ms, now_ms=now_ms) + payload["time_close_remaining_sec"] = rem + payload["time_close_countdown"] = format_time_close_countdown(rem) + else: + payload["time_close_remaining_sec"] = None + payload["time_close_countdown"] = "" + + +def ensure_time_close_schema(cursor) -> None: + ddl_list = ( + "ALTER TABLE order_monitors ADD COLUMN time_close_enabled INTEGER DEFAULT 0", + "ALTER TABLE order_monitors ADD COLUMN time_close_hours INTEGER", + "ALTER TABLE order_monitors ADD COLUMN time_close_at_ms INTEGER", + "ALTER TABLE key_monitors ADD COLUMN time_close_enabled INTEGER DEFAULT 0", + "ALTER TABLE key_monitors ADD COLUMN time_close_hours INTEGER", + ) + for ddl in ddl_list: + try: + cursor.execute(ddl) + except Exception: + pass + + +def time_close_insert_values( + enabled: int, + hours: Optional[int], + opened_at_ms: Optional[int], +) -> tuple[int, Optional[int], Optional[int]]: + en = 1 if int(enabled or 0) != 0 and hours else 0 + h = normalize_time_close_hours(hours) if en else None + close_at = compute_close_at_ms(opened_at_ms, h) if en else None + return en, h, close_at