+ {% if ui_orphan_recovery_enabled %}
+ {% if not order and orphan_live_positions %}
+ {% set o = orphan_live_positions[0] %}
+
{% for o in order %}
0 || !orphans || !orphans.length){
+ el.style.display = "none";
+ el.innerHTML = "";
+ return;
+ }
+ const o = orphans[0];
+ const dir = o.direction === "short" ? "空" : "多";
+ const mid = o.recoverable_monitor_id;
+ let html = `检测到交易所仍有
${o.symbol} ${dir}仓,但本地监控已中断(误同步时可能无交易记录)。`;
+ if(mid){
+ const tpslHint = (o.plan_stop_loss && o.plan_take_profit) ? "并挂止盈止损" : "";
+ html += `
恢复监控${tpslHint} `;
+ } else {
+ html += " 未找到可恢复的监控记录,需在服务器数据库处理。";
+ }
+ el.innerHTML = html;
+ el.style.display = "block";
+}
+
+async function recoverLivePosition(monitorId){
+ const withTpsl = confirm("确认恢复本地实时监控?若原计划有止盈止损,将尝试重新挂到交易所。");
+ if(!withTpsl) return;
+ try{
+ const res = await fetch("/api/recover_live_position", {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify({monitor_id: monitorId, place_tpsl: true})
+ });
+ const data = await res.json();
+ alert(data.msg || (data.ok ? "已恢复" : "失败"));
+ if(data.ok) location.reload();
+ }catch(e){
+ alert("恢复失败:" + e);
+ }
+}
+
function refreshPriceSnapshot(){
fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{
const updatedEl = document.getElementById("price-last-updated");
@@ -1713,7 +1547,11 @@ function refreshPriceSnapshot(){
if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl);
paintPlanTpslDisplay(o.id, o);
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
+ if(window.TimeCloseUI) TimeCloseUI.paintOrderForceClose(o);
});
+{% if ui_orphan_recovery_enabled %}
+ renderOrphanRecoverBanner(data.orphan_live_positions);
+ {% endif %}
}).catch(()=>{});
}
@@ -1781,20 +1619,17 @@ function refreshAccountSnapshot(){
TimeCloseUI.paintForceCloseHeader(data.force_close);
}
let canTradeText = "可开仓";
- if (!data.can_trade) {
+ if(!data.can_trade){
const parts = [];
if (data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason) {
parts.push(data.risk_status.reason);
}
- const ac = Number(data.active_count || 0);
- const max = Number(data.max_active_positions || {{ max_active_positions }});
- if (ac >= max) parts.push(`持仓 ${ac}/${max}`);
+ if((data.active_count||0) >= (data.max_active_positions||{{ max_active_positions }})) parts.push(`持仓 ${data.active_count}/${data.max_active_positions}`);
const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }});
const opens = Number(data.opens_today);
if (hard > 0 && !Number.isNaN(opens) && opens >= hard) parts.push(`本交易日开仓 ${opens}/${hard} 已达上限`);
- if (!parts.length) parts.push(`未到北京时间 {{ reset_hour }}:00`);
- else parts.push(`或未到北京时间 {{ reset_hour }}:00`);
- canTradeText = `不可开仓(${parts.join(";")})`;
+ if(data.open_guard_blocks_now) parts.push(`未到北京时间 ${data.reset_hour||{{ reset_hour }}}:00`);
+ canTradeText = parts.length ? `不可开仓(${parts.join(";")})` : "不可开仓";
}
const opensToday = Number(data.opens_today);
const hardLim = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }});
@@ -1807,9 +1642,36 @@ function refreshAccountSnapshot(){
if(tip){
tip.innerText = `规则:最多 ${data.max_active_positions || {{ max_active_positions }}} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;${openCntTxt ? openCntTxt + ";" : ""}${canTradeText}${avail};人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1`;
}
+ const allowEl = document.getElementById("allow-open-before-reset");
+ const guardStatus = document.getElementById("open-guard-status");
+ const resetH = data.reset_hour != null ? data.reset_hour : {{ reset_hour }};
+ if(allowEl && typeof data.open_guard_enabled !== "undefined"){
+ allowEl.checked = !data.open_guard_enabled;
+ }
+ if(guardStatus && typeof data.open_guard_enabled !== "undefined"){
+ guardStatus.innerText = data.open_guard_enabled
+ ? `已限制:${resetH}:00 前不可开仓`
+ : `已放开:${resetH}:00 前允许开仓`;
+ }
}).catch(()=>{});
}
+{% if ui_open_guard_enabled %}
+const allowOpenBeforeResetEl = document.getElementById("allow-open-before-reset");
+if(allowOpenBeforeResetEl){
+ allowOpenBeforeResetEl.addEventListener("change", function(){
+ const allow = !!this.checked;
+ fetch("/api/settings/open_guard", {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify({enabled: !allow}),
+ }).then(r=>r.json()).then(data=>{
+ if(!data.ok){ alert(data.msg || "保存失败"); return; }
+ refreshAccountSnapshot();
+ }).catch(()=>alert("保存失败"));
+ });
+}
+{% endif %}
const orderSymbolEl = document.getElementById("order-symbol");
const orderDirectionEl = document.getElementById("order-direction");
const fullMarginEl = document.getElementById("use-full-margin");
@@ -2011,6 +1873,9 @@ function refreshPriceSnapshotConditional(){
}
});
tickOrderHoldDurations();
+{% if ui_orphan_recovery_enabled %}
+ renderOrphanRecoverBanner(data.orphan_live_positions);
+{% endif %}
}
}).catch(()=>{});
}
diff --git a/lib/instance/templates/instance_top_bar.html b/lib/instance/templates/instance_top_bar.html
new file mode 100644
index 0000000..15a221d
--- /dev/null
+++ b/lib/instance/templates/instance_top_bar.html
@@ -0,0 +1,13 @@
+{# 三所统一顶栏:划转 + 手动划转 + 可选整点前开仓开关 #}
+{% include 'gate_transfer_block.html' %}
+{% if ui_open_guard_enabled %}
+
+
+
+ 允许北京时间 {{ reset_hour }}:00 前开仓(斐波成交登记、人工下单)
+
+
+ {% if open_guard_enabled %}已限制:{{ reset_hour }}:00 前不可开仓{% else %}已放开:{{ reset_hour }}:00 前允许开仓{% endif %}
+
+
+{% endif %}
diff --git a/crypto_monitor_binance/templates/login.html b/lib/instance/templates/login.html
similarity index 96%
rename from crypto_monitor_binance/templates/login.html
rename to lib/instance/templates/login.html
index 8b6ea41..8e1c5b7 100644
--- a/crypto_monitor_binance/templates/login.html
+++ b/lib/instance/templates/login.html
@@ -1,136 +1,136 @@
-
-
-
-
-
-
-
登录 · {{ exchange_display }}
-
-
-
-
-
-
-
-
交易监控系统登录
-
交易所:{{ exchange_display }}
- {% with messages = get_flashed_messages() %}
- {% if messages %}
-
{{ messages[0] }}
- {% endif %}
- {% endwith %}
-
-
-
-
+
+
+
+
+
+
+
登录 · {{ exchange_display }}
+
+
+
+
+
+
+
+
交易监控系统登录
+
交易所:{{ exchange_display }}
+ {% with messages = get_flashed_messages() %}
+ {% if messages %}
+
{{ messages[0] }}
+ {% endif %}
+ {% endwith %}
+
+
+
+
diff --git a/lib/strategy/templates/gate_transfer_block.html b/lib/strategy/templates/gate_transfer_block.html
index 2c19daf..1a460b5 100644
--- a/lib/strategy/templates/gate_transfer_block.html
+++ b/lib/strategy/templates/gate_transfer_block.html
@@ -4,7 +4,7 @@
· 划转规则
- 划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00 起该整点小时内尝试;账簿按 UTC 自然日 去重;将 {{ auto_transfer_to }} 调整至 {{ transfer_amount_fmt }}U:不足从 {{ auto_transfer_from }} 划入、超出划回 {{ auto_transfer_from }};持仓中不划转 并微信通知)
+ 划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00 起该整点小时内尝试;账簿按 UTC 自然日 去重;将 {{ auto_transfer_to }} 调整至 {{ transfer_amount_fmt|default(funds_fmt(auto_transfer_amount)) }}U:不足从 {{ auto_transfer_from }} 划入、超出划回 {{ auto_transfer_from }};持仓中不划转 并微信通知)
")
+
+ out_lines = [
+ "{# Hub iframe tab fragment — shared via embed_templates #}",
+ *macro_body,
+ '
',
+ *grid_block,
+ "
",
+ *stats_block,
+ ]
+ text = "\n".join(out_lines).rstrip() + "\n"
+ if "order_rule_tips_tpl" not in text:
+ text = text.replace(
+ "{% include 'order_monitor_rule_tips_binance.html' %}",
+ "{% include order_rule_tips_tpl %}",
+ )
+ OUT.write_text(text, encoding="utf-8")
+ print("wrote", OUT, "lines", len(out_lines))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/build_unified_index.py b/scripts/build_unified_index.py
new file mode 100644
index 0000000..593bea4
--- /dev/null
+++ b/scripts/build_unified_index.py
@@ -0,0 +1,171 @@
+#!/usr/bin/env python3
+"""从 binance index.html 生成三所共用的 lib/instance/templates/index.html。"""
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+SRC = ROOT / "lib" / "instance" / "templates" / "index.html"
+OUT = ROOT / "lib" / "instance" / "templates" / "index.html"
+
+TRANSFER_BLOCK = """
+ 划转规则说明
+
+ 划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00 起该整点小时内尝试;账簿按 UTC 自然日 去重;将 {{ auto_transfer_to }} 调整至 {{ auto_transfer_amount }}U:不足从 {{ auto_transfer_from }} 划入、超出划回 {{ auto_transfer_from }};持仓中不划转 并微信通知)
+
+
+
+"""
+
+
+def main() -> None:
+ text = SRC.read_text(encoding="utf-8")
+
+ # 外链 CSS 替代内联 style
+ text = re.sub(
+ r" \n",
+ '
\n',
+ text,
+ count=1,
+ flags=re.DOTALL,
+ )
+
+ # 顶栏:划转 + 可选 open guard
+ text = text.replace(
+ '
实时价格更新:-- (北京时间 UTC+8)
\n',
+ " {% include 'instance_top_bar.html' %}\n",
+ )
+
+ # 规则条动态 include
+ text = text.replace(
+ "{% include 'order_monitor_rule_tips_binance.html' %}",
+ "{% include order_rule_tips_tpl %}",
+ )
+
+ # 下单面板内划转块移除(已上移到顶栏)
+ if TRANSFER_BLOCK in text:
+ text = text.replace(TRANSFER_BLOCK, "", 1)
+
+ # 孤儿仓恢复 banner
+ orphan_block = """ {% if not order and orphan_live_positions %}
+ {% set o = orphan_live_positions[0] %}
+
+ 检测到交易所仍有持仓,本地无对应监控单
+ {{ o.exchange_symbol or o.symbol }} · {{ '多' if o.direction == 'long' else '空' }}
+
+
+ {% else %}
+
+ {% endif %}"""
+ wrapped = "{% if ui_orphan_recovery_enabled %}\n" + orphan_block + "\n {% endif %}"
+ text = text.replace(orphan_block, wrapped, 1)
+
+ # refreshAccountSnapshot:采用 OKX 版 open_guard 逻辑
+ old_can_trade = """ let canTradeText = "可开仓";
+ if (!data.can_trade) {
+ const parts = [];
+ if (data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason) {
+ parts.push(data.risk_status.reason);
+ }
+ const ac = Number(data.active_count || 0);
+ const max = Number(data.max_active_positions || {{ max_active_positions }});
+ if (ac >= max) parts.push(`持仓 ${ac}/${max}`);
+ const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }});
+ const opens = Number(data.opens_today);
+ if (hard > 0 && !Number.isNaN(opens) && opens >= hard) parts.push(`本交易日开仓 ${opens}/${hard} 已达上限`);
+ if (!parts.length) parts.push(`未到北京时间 {{ reset_hour }}:00`);
+ else parts.push(`或未到北京时间 {{ reset_hour }}:00`);
+ canTradeText = `不可开仓(${parts.join(";")})`;
+ }"""
+ new_can_trade = """ let canTradeText = "可开仓";
+ if(!data.can_trade){
+ const parts = [];
+ if (data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason) {
+ parts.push(data.risk_status.reason);
+ }
+ if((data.active_count||0) >= (data.max_active_positions||{{ max_active_positions }})) parts.push(`持仓 ${data.active_count}/${data.max_active_positions}`);
+ const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }});
+ const opens = Number(data.opens_today);
+ if (hard > 0 && !Number.isNaN(opens) && opens >= hard) parts.push(`本交易日开仓 ${opens}/${hard} 已达上限`);
+ if(data.open_guard_blocks_now) parts.push(`未到北京时间 ${data.reset_hour||{{ reset_hour }}}:00`);
+ canTradeText = parts.length ? `不可开仓(${parts.join(";")})` : "不可开仓";
+ }"""
+ text = text.replace(old_can_trade, new_can_trade, 1)
+
+ guard_sync = """ const allowEl = document.getElementById("allow-open-before-reset");
+ const guardStatus = document.getElementById("open-guard-status");
+ const resetH = data.reset_hour != null ? data.reset_hour : {{ reset_hour }};
+ if(allowEl && typeof data.open_guard_enabled !== "undefined"){
+ allowEl.checked = !data.open_guard_enabled;
+ }
+ if(guardStatus && typeof data.open_guard_enabled !== "undefined"){
+ guardStatus.innerText = data.open_guard_enabled
+ ? `已限制:${resetH}:00 前不可开仓`
+ : `已放开:${resetH}:00 前允许开仓`;
+ }"""
+ insert_after = """ if(tip){
+ tip.innerText = `规则:最多 ${data.max_active_positions || {{ max_active_positions }}} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;${openCntTxt ? openCntTxt + ";" : ""}${canTradeText}${avail};人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1`;
+ }
+ }).catch(()=>{});"""
+ if guard_sync not in text:
+ text = text.replace(
+ insert_after,
+ insert_after.replace(" }).catch(()=>{});", guard_sync + "\n }).catch(()=>{});"),
+ 1,
+ )
+
+ open_guard_js = """
+const allowOpenBeforeResetEl = document.getElementById("allow-open-before-reset");
+if(allowOpenBeforeResetEl){
+ allowOpenBeforeResetEl.addEventListener("change", function(){
+ const allow = !!this.checked;
+ fetch("/api/settings/open_guard", {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify({enabled: !allow}),
+ }).then(r=>r.json()).then(data=>{
+ if(!data.ok){ alert(data.msg || "保存失败"); return; }
+ refreshAccountSnapshot();
+ }).catch(()=>alert("保存失败"));
+ });
+}
+"""
+ marker = "const orderSymbolEl = document.getElementById(\"order-symbol\");"
+ if "allowOpenBeforeResetEl" not in text:
+ text = text.replace(marker, "{% if ui_open_guard_enabled %}" + open_guard_js + "{% endif %}\n" + marker, 1)
+
+ orphan_fn_guard = "{% if ui_orphan_recovery_enabled %}\n renderOrphanRecoverBanner(data.orphan_live_positions);\n {% endif %}"
+ text = text.replace(
+ " renderOrphanRecoverBanner(data.orphan_live_positions);",
+ orphan_fn_guard,
+ )
+
+ header = "{# 三所共用 standalone 主页 — 由 scripts/build_unified_index.py 生成,勿手改三所副本 #}\n"
+ if not text.startswith("{# 三所共用"):
+ text = header + text
+
+ OUT.parent.mkdir(parents=True, exist_ok=True)
+ OUT.write_text(text, encoding="utf-8")
+ print("wrote", OUT, "lines", len(text.splitlines()))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_instance_embed_lib.py b/tests/test_instance_embed_lib.py
index fc20121..7bc4bcf 100644
--- a/tests/test_instance_embed_lib.py
+++ b/tests/test_instance_embed_lib.py
@@ -1,7 +1,11 @@
from lib.instance.instance_embed_lib import (
EMBED_TABS,
+ embed_context_extras,
+ include_transfer_block,
path_to_embed_tab,
rewrite_embed_dest,
+ ui_open_guard_enabled,
+ ui_orphan_recovery_enabled,
)
@@ -24,3 +28,15 @@ def test_embed_tabs_cover_main_nav():
assert "trade" in EMBED_TABS
assert "key_monitor" in EMBED_TABS
assert "records" in EMBED_TABS
+
+
+def test_embed_context_extras_unified_ui_flags():
+ for ex in ("binance", "okx", "gate"):
+ assert include_transfer_block(ex) is True
+ assert ui_open_guard_enabled("okx") is True
+ assert ui_open_guard_enabled("binance") is False
+ assert ui_orphan_recovery_enabled("binance") is True
+ assert ui_orphan_recovery_enabled("gate") is False
+ ctx = embed_context_extras("gate")
+ assert ctx["order_rule_tips_tpl"] == "order_monitor_rule_tips_gate.html"
+ assert ctx["include_transfer_block"] is True