feat: show force-close badge and countdown when FORCE_CLOSE is enabled
Add shared lib/UI for midnight force-close indicator on instance and hub pages, and document Gate intraday 0-point exit alignment. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -120,6 +120,11 @@ from lib.trade.time_close_lib import (
|
||||
time_close_label,
|
||||
time_close_settings_from_row,
|
||||
)
|
||||
from lib.trade.force_close_lib import (
|
||||
apply_force_close_to_payload,
|
||||
enrich_orders_force_close,
|
||||
force_close_template_context,
|
||||
)
|
||||
from lib.trade.manual_sltp_lib import (
|
||||
normalize_open_sltp_mode,
|
||||
resolve_entrust_sltp_prices,
|
||||
@@ -7135,6 +7140,12 @@ def render_main_page(page="trade", embed_mode=None):
|
||||
raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall()
|
||||
for o in raw_order_list:
|
||||
order_list.append(enrich_order_item(row_to_dict(o), current_capital))
|
||||
enrich_orders_force_close(
|
||||
order_list,
|
||||
FORCE_CLOSE_ENABLED,
|
||||
FORCE_CLOSE_BJ_HOUR,
|
||||
now_ms=int(app_now().timestamp() * 1000),
|
||||
)
|
||||
tr_ts = sql_list_time_field("closed_at", "created_at", "opened_at")
|
||||
if plan.records_rows:
|
||||
raw_records = conn.execute(
|
||||
@@ -7279,6 +7290,11 @@ def render_main_page(page="trade", embed_mode=None):
|
||||
kline_timeframe=KLINE_TIMEFRAME,
|
||||
**strategy_extra,
|
||||
**embed_context_extras("binance"),
|
||||
**force_close_template_context(
|
||||
FORCE_CLOSE_ENABLED,
|
||||
FORCE_CLOSE_BJ_HOUR,
|
||||
now_ms=int(app_now().timestamp() * 1000),
|
||||
),
|
||||
)
|
||||
if embed_mode == "fragment":
|
||||
return render_template("embed_page_fragment.html", **template_ctx)
|
||||
@@ -7358,6 +7374,11 @@ def api_account_snapshot():
|
||||
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
|
||||
"trading_day": trading_day,
|
||||
"risk_status": risk_status,
|
||||
**force_close_template_context(
|
||||
FORCE_CLOSE_ENABLED,
|
||||
FORCE_CLOSE_BJ_HOUR,
|
||||
now_ms=int(now.timestamp() * 1000),
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@@ -7599,6 +7620,11 @@ def api_price_snapshot():
|
||||
funds_decimals=FUNDS_DECIMALS,
|
||||
)
|
||||
apply_time_close_to_payload(payload, r)
|
||||
apply_force_close_to_payload(
|
||||
payload,
|
||||
enabled=FORCE_CLOSE_ENABLED,
|
||||
bj_hour=FORCE_CLOSE_BJ_HOUR,
|
||||
)
|
||||
payload["opened_at"] = r["opened_at"] if "opened_at" in r.keys() else None
|
||||
open_ms = r["opened_at_ms"] if "opened_at_ms" in r.keys() else None
|
||||
payload["opened_at_ms"] = int(open_ms) if open_ms not in (None, "") else None
|
||||
@@ -7637,6 +7663,10 @@ def api_price_snapshot():
|
||||
"position_marks": position_marks,
|
||||
"positions_raw_count": len(all_swap_positions),
|
||||
"orphan_live_positions": orphan_live_positions,
|
||||
**force_close_template_context(
|
||||
FORCE_CLOSE_ENABLED,
|
||||
FORCE_CLOSE_BJ_HOUR,
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -278,6 +278,7 @@
|
||||
{% if trade_policy.badge_text %}
|
||||
<span class="trade-policy-badge" title="账户交易限制(.env)">{{ trade_policy.badge_text }}</span>
|
||||
{% endif %}
|
||||
{% include 'force_close_header_badge.html' %}
|
||||
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}" data-status-label="{{ risk_status.status_label|default('正常') }}"{% if risk_status.freeze_until_ms %} data-freeze-until-ms="{{ risk_status.freeze_until_ms }}"{% endif %}>{{ risk_status.status_label|default('正常') }}</span>
|
||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
||||
@@ -447,6 +448,7 @@
|
||||
· <span class="pos-time-close-cd" id="order-time-close-cd-{{ o.id }}">--:--:--</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% include 'force_close_order_badge.html' %}
|
||||
<span class="pos-side-badge {{ 'pos-side-long' if o.direction == 'long' else 'pos-side-short' }}">{{ '做多' if o.direction == 'long' else '做空' }}</span>
|
||||
</div>
|
||||
<div class="pos-head-actions">
|
||||
@@ -789,7 +791,7 @@
|
||||
<script src="/static/instance_ui.js?v=6"></script>
|
||||
<script src="/static/journal_upload_slots.js?v=3"></script>
|
||||
<script src="/static/instance_records_mobile.js?v=2"></script>
|
||||
<script src="/static/time_close_ui.js?v=2"></script>
|
||||
<script src="/static/time_close_ui.js?v=3"></script>
|
||||
<script src="/static/ai_review_render.js?v=2"></script>
|
||||
<script src="/static/form_submit_guard.js?v=2"></script>
|
||||
<script src="/static/order_entry_model.js?v=3"></script>
|
||||
@@ -1711,6 +1713,9 @@ function refreshPriceSnapshot(){
|
||||
if(data.updated_at && updatedEl){
|
||||
updatedEl.innerText = data.updated_at;
|
||||
}
|
||||
if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){
|
||||
TimeCloseUI.paintForceCloseHeader(data.force_close);
|
||||
}
|
||||
(data.key_prices || []).forEach(k=>{
|
||||
const pEl = document.getElementById(`key-price-${k.id}`);
|
||||
if(pEl){
|
||||
@@ -1782,6 +1787,7 @@ 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);
|
||||
});
|
||||
renderOrphanRecoverBanner(data.orphan_live_positions);
|
||||
}).catch(()=>{});
|
||||
@@ -1847,6 +1853,9 @@ function refreshAccountSnapshot(){
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader) {
|
||||
TimeCloseUI.paintForceCloseHeader(data.force_close);
|
||||
}
|
||||
let canTradeText = "可开仓";
|
||||
if (!data.can_trade) {
|
||||
const parts = [];
|
||||
@@ -2019,6 +2028,9 @@ function refreshPriceSnapshotConditional(){
|
||||
fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{
|
||||
const updatedEl = document.getElementById("price-last-updated");
|
||||
if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at;
|
||||
if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){
|
||||
TimeCloseUI.paintForceCloseHeader(data.force_close);
|
||||
}
|
||||
if(page === "key_monitor"){
|
||||
(data.key_prices || []).forEach(k=>{
|
||||
const pEl = document.getElementById(`key-price-${k.id}`);
|
||||
@@ -2068,6 +2080,8 @@ function refreshPriceSnapshotConditional(){
|
||||
paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
|
||||
paintPlanTpslDisplay(o.id, o);
|
||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderForceClose(o);
|
||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderForceClose(o);
|
||||
const holdEl = document.getElementById(`order-hold-duration-${o.id}`);
|
||||
if(holdEl && o.opened_at_ms != null && o.opened_at_ms !== ""){
|
||||
holdEl.setAttribute("data-order-opened-ms", String(o.opened_at_ms));
|
||||
|
||||
@@ -121,6 +121,11 @@ from lib.trade.time_close_lib import (
|
||||
time_close_label,
|
||||
time_close_settings_from_row,
|
||||
)
|
||||
from lib.trade.force_close_lib import (
|
||||
apply_force_close_to_payload,
|
||||
enrich_orders_force_close,
|
||||
force_close_template_context,
|
||||
)
|
||||
from lib.trade.manual_sltp_lib import (
|
||||
normalize_open_sltp_mode,
|
||||
resolve_entrust_sltp_prices,
|
||||
@@ -6910,6 +6915,12 @@ def render_main_page(page="trade", embed_mode=None):
|
||||
raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall()
|
||||
for o in raw_order_list:
|
||||
order_list.append(enrich_order_item(row_to_dict(o), current_capital))
|
||||
enrich_orders_force_close(
|
||||
order_list,
|
||||
FORCE_CLOSE_ENABLED,
|
||||
FORCE_CLOSE_BJ_HOUR,
|
||||
now_ms=int(app_now().timestamp() * 1000),
|
||||
)
|
||||
exchange_pnl_sync = {}
|
||||
if exchange_private_api_configured() and not request_is_hub_soft_nav() and embed_mode not in (
|
||||
"fragment",
|
||||
@@ -7062,6 +7073,11 @@ def render_main_page(page="trade", embed_mode=None):
|
||||
kline_timeframe=KLINE_TIMEFRAME,
|
||||
exchange_pnl_sync=exchange_pnl_sync,
|
||||
**strategy_extra,
|
||||
**force_close_template_context(
|
||||
FORCE_CLOSE_ENABLED,
|
||||
FORCE_CLOSE_BJ_HOUR,
|
||||
now_ms=int(app_now().timestamp() * 1000),
|
||||
),
|
||||
**embed_context_extras("gate"),
|
||||
)
|
||||
if embed_mode == "fragment":
|
||||
@@ -7159,6 +7175,11 @@ def api_account_snapshot():
|
||||
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
|
||||
"trading_day": trading_day,
|
||||
"risk_status": risk_status,
|
||||
**force_close_template_context(
|
||||
FORCE_CLOSE_ENABLED,
|
||||
FORCE_CLOSE_BJ_HOUR,
|
||||
now_ms=int(now.timestamp() * 1000),
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@@ -7425,6 +7446,11 @@ def api_price_snapshot():
|
||||
funds_decimals=FUNDS_DECIMALS,
|
||||
)
|
||||
apply_time_close_to_payload(payload, r)
|
||||
apply_force_close_to_payload(
|
||||
payload,
|
||||
enabled=FORCE_CLOSE_ENABLED,
|
||||
bj_hour=FORCE_CLOSE_BJ_HOUR,
|
||||
)
|
||||
payload["opened_at"] = r["opened_at"] if "opened_at" in r.keys() else None
|
||||
open_ms = r["opened_at_ms"] if "opened_at_ms" in r.keys() else None
|
||||
payload["opened_at_ms"] = int(open_ms) if open_ms not in (None, "") else None
|
||||
@@ -7460,6 +7486,10 @@ def api_price_snapshot():
|
||||
"order_prices": order_prices,
|
||||
"position_marks": position_marks,
|
||||
"positions_raw_count": len(all_swap_positions),
|
||||
**force_close_template_context(
|
||||
FORCE_CLOSE_ENABLED,
|
||||
FORCE_CLOSE_BJ_HOUR,
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -277,6 +277,7 @@
|
||||
{% if trade_policy.badge_text %}
|
||||
<span class="trade-policy-badge" title="账户交易限制(.env)">{{ trade_policy.badge_text }}</span>
|
||||
{% endif %}
|
||||
{% include 'force_close_header_badge.html' %}
|
||||
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}" data-status-label="{{ risk_status.status_label|default('正常') }}"{% if risk_status.freeze_until_ms %} data-freeze-until-ms="{{ risk_status.freeze_until_ms }}"{% endif %}>{{ risk_status.status_label|default('正常') }}</span>
|
||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
||||
@@ -413,6 +414,7 @@
|
||||
· <span class="pos-time-close-cd" id="order-time-close-cd-{{ o.id }}">--:--:--</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% include 'force_close_order_badge.html' %}
|
||||
<span class="pos-side-badge {{ 'pos-side-long' if o.direction == 'long' else 'pos-side-short' }}">{{ '做多' if o.direction == 'long' else '做空' }}</span>
|
||||
</div>
|
||||
<div class="pos-head-actions">
|
||||
@@ -755,7 +757,7 @@
|
||||
<script src="/static/instance_ui.js?v=6"></script>
|
||||
<script src="/static/journal_upload_slots.js?v=3"></script>
|
||||
<script src="/static/instance_records_mobile.js?v=2"></script>
|
||||
<script src="/static/time_close_ui.js?v=2"></script>
|
||||
<script src="/static/time_close_ui.js?v=3"></script>
|
||||
<script src="/static/ai_review_render.js?v=2"></script>
|
||||
<script src="/static/form_submit_guard.js?v=2"></script>
|
||||
<script src="/static/order_entry_model.js?v=3"></script>
|
||||
@@ -1637,6 +1639,9 @@ function refreshPriceSnapshot(){
|
||||
if(data.updated_at && updatedEl){
|
||||
updatedEl.innerText = data.updated_at;
|
||||
}
|
||||
if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){
|
||||
TimeCloseUI.paintForceCloseHeader(data.force_close);
|
||||
}
|
||||
(data.key_prices || []).forEach(k=>{
|
||||
const pEl = document.getElementById(`key-price-${k.id}`);
|
||||
if(pEl){
|
||||
@@ -1772,6 +1777,9 @@ function refreshAccountSnapshot(){
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader) {
|
||||
TimeCloseUI.paintForceCloseHeader(data.force_close);
|
||||
}
|
||||
let canTradeText = "可开仓";
|
||||
if (!data.can_trade) {
|
||||
const parts = [];
|
||||
@@ -1944,6 +1952,9 @@ function refreshPriceSnapshotConditional(){
|
||||
fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{
|
||||
const updatedEl = document.getElementById("price-last-updated");
|
||||
if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at;
|
||||
if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){
|
||||
TimeCloseUI.paintForceCloseHeader(data.force_close);
|
||||
}
|
||||
if(page === "key_monitor"){
|
||||
(data.key_prices || []).forEach(k=>{
|
||||
const pEl = document.getElementById(`key-price-${k.id}`);
|
||||
@@ -1993,6 +2004,7 @@ function refreshPriceSnapshotConditional(){
|
||||
paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
|
||||
paintPlanTpslDisplay(o.id, o);
|
||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderForceClose(o);
|
||||
const holdEl = document.getElementById(`order-hold-duration-${o.id}`);
|
||||
if(holdEl && o.opened_at_ms != null && o.opened_at_ms !== ""){
|
||||
holdEl.setAttribute("data-order-opened-ms", String(o.opened_at_ms));
|
||||
|
||||
@@ -121,6 +121,11 @@ from lib.trade.time_close_lib import (
|
||||
time_close_label,
|
||||
time_close_settings_from_row,
|
||||
)
|
||||
from lib.trade.force_close_lib import (
|
||||
apply_force_close_to_payload,
|
||||
enrich_orders_force_close,
|
||||
force_close_template_context,
|
||||
)
|
||||
from lib.trade.manual_sltp_lib import (
|
||||
normalize_open_sltp_mode,
|
||||
resolve_entrust_sltp_prices,
|
||||
@@ -6471,6 +6476,12 @@ def render_main_page(page="trade", embed_mode=None):
|
||||
raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall()
|
||||
for o in raw_order_list:
|
||||
order_list.append(enrich_order_item(row_to_dict(o), current_capital))
|
||||
enrich_orders_force_close(
|
||||
order_list,
|
||||
FORCE_CLOSE_ENABLED,
|
||||
FORCE_CLOSE_BJ_HOUR,
|
||||
now_ms=int(app_now().timestamp() * 1000),
|
||||
)
|
||||
exchange_pnl_sync = {}
|
||||
if exchange_private_api_configured() and not request_is_hub_soft_nav() and embed_mode not in (
|
||||
"fragment",
|
||||
@@ -6625,6 +6636,11 @@ def render_main_page(page="trade", embed_mode=None):
|
||||
exchange_pnl_sync=exchange_pnl_sync,
|
||||
**strategy_extra,
|
||||
**embed_context_extras("okx"),
|
||||
**force_close_template_context(
|
||||
FORCE_CLOSE_ENABLED,
|
||||
FORCE_CLOSE_BJ_HOUR,
|
||||
now_ms=int(app_now().timestamp() * 1000),
|
||||
),
|
||||
)
|
||||
if embed_mode == "fragment":
|
||||
return render_template("embed_page_fragment.html", **template_ctx)
|
||||
@@ -6722,6 +6738,11 @@ def api_account_snapshot():
|
||||
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
|
||||
"trading_day": trading_day,
|
||||
"risk_status": risk_status,
|
||||
**force_close_template_context(
|
||||
FORCE_CLOSE_ENABLED,
|
||||
FORCE_CLOSE_BJ_HOUR,
|
||||
now_ms=int(now.timestamp() * 1000),
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@@ -7028,6 +7049,11 @@ def api_price_snapshot():
|
||||
funds_decimals=FUNDS_DECIMALS,
|
||||
)
|
||||
apply_time_close_to_payload(payload, r)
|
||||
apply_force_close_to_payload(
|
||||
payload,
|
||||
enabled=FORCE_CLOSE_ENABLED,
|
||||
bj_hour=FORCE_CLOSE_BJ_HOUR,
|
||||
)
|
||||
payload["opened_at"] = r["opened_at"] if "opened_at" in r.keys() else None
|
||||
open_ms = r["opened_at_ms"] if "opened_at_ms" in r.keys() else None
|
||||
payload["opened_at_ms"] = int(open_ms) if open_ms not in (None, "") else None
|
||||
@@ -7063,6 +7089,10 @@ def api_price_snapshot():
|
||||
"order_prices": order_prices,
|
||||
"position_marks": position_marks,
|
||||
"positions_raw_count": len(all_swap_positions),
|
||||
**force_close_template_context(
|
||||
FORCE_CLOSE_ENABLED,
|
||||
FORCE_CLOSE_BJ_HOUR,
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -277,6 +277,7 @@
|
||||
{% if trade_policy.badge_text %}
|
||||
<span class="trade-policy-badge" title="账户交易限制(.env)">{{ trade_policy.badge_text }}</span>
|
||||
{% endif %}
|
||||
{% include 'force_close_header_badge.html' %}
|
||||
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}" data-status-label="{{ risk_status.status_label|default('正常') }}"{% if risk_status.freeze_until_ms %} data-freeze-until-ms="{{ risk_status.freeze_until_ms }}"{% endif %}>{{ risk_status.status_label|default('正常') }}</span>
|
||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
||||
@@ -442,6 +443,7 @@
|
||||
· <span class="pos-time-close-cd" id="order-time-close-cd-{{ o.id }}">--:--:--</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% include 'force_close_order_badge.html' %}
|
||||
<span class="pos-side-badge {{ 'pos-side-long' if o.direction == 'long' else 'pos-side-short' }}">{{ '做多' if o.direction == 'long' else '做空' }}</span>
|
||||
</div>
|
||||
<div class="pos-head-actions">
|
||||
@@ -784,7 +786,7 @@
|
||||
<script src="/static/instance_ui.js?v=6"></script>
|
||||
<script src="/static/journal_upload_slots.js?v=3"></script>
|
||||
<script src="/static/instance_records_mobile.js?v=2"></script>
|
||||
<script src="/static/time_close_ui.js?v=2"></script>
|
||||
<script src="/static/time_close_ui.js?v=3"></script>
|
||||
<script src="/static/ai_review_render.js?v=2"></script>
|
||||
<script src="/static/form_submit_guard.js?v=2"></script>
|
||||
<script src="/static/order_entry_model.js?v=3"></script>
|
||||
@@ -1666,6 +1668,9 @@ function refreshPriceSnapshot(){
|
||||
if(data.updated_at && updatedEl){
|
||||
updatedEl.innerText = data.updated_at;
|
||||
}
|
||||
if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){
|
||||
TimeCloseUI.paintForceCloseHeader(data.force_close);
|
||||
}
|
||||
(data.key_prices || []).forEach(k=>{
|
||||
const pEl = document.getElementById(`key-price-${k.id}`);
|
||||
if(pEl){
|
||||
@@ -1737,6 +1742,7 @@ 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);
|
||||
});
|
||||
}).catch(()=>{});
|
||||
}
|
||||
@@ -1801,6 +1807,9 @@ function refreshAccountSnapshot(){
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader) {
|
||||
TimeCloseUI.paintForceCloseHeader(data.force_close);
|
||||
}
|
||||
let canTradeText = "可开仓";
|
||||
if(!data.can_trade){
|
||||
const parts = [];
|
||||
@@ -1996,6 +2005,9 @@ function refreshPriceSnapshotConditional(){
|
||||
fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{
|
||||
const updatedEl = document.getElementById("price-last-updated");
|
||||
if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at;
|
||||
if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){
|
||||
TimeCloseUI.paintForceCloseHeader(data.force_close);
|
||||
}
|
||||
if(page === "key_monitor"){
|
||||
(data.key_prices || []).forEach(k=>{
|
||||
const pEl = document.getElementById(`key-price-${k.id}`);
|
||||
@@ -2045,6 +2057,8 @@ function refreshPriceSnapshotConditional(){
|
||||
paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
|
||||
paintPlanTpslDisplay(o.id, o);
|
||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderForceClose(o);
|
||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderForceClose(o);
|
||||
const holdEl = document.getElementById(`order-hold-duration-${o.id}`);
|
||||
if(holdEl && o.opened_at_ms != null && o.opened_at_ms !== ""){
|
||||
holdEl.setAttribute("data-order-opened-ms", String(o.opened_at_ms));
|
||||
|
||||
@@ -6,16 +6,19 @@
|
||||
|------|------|------|
|
||||
| [binance-alt-trend-long.md](./binance-alt-trend-long.md) | 币安山寨·多头趋势 | v0.2 |
|
||||
| [okx-trend-both.md](./okx-trend-both.md) | OKX·多空趋势 | v0.2 |
|
||||
| gate-intraday.md | Gate·BTC/ETH 日内 | **待定** |
|
||||
| [gate-intraday.md](./gate-intraday.md) | Gate·BTC 日内 | v0.2 |
|
||||
|
||||
## 约定
|
||||
|
||||
- **不写盈亏比**:止盈/止损随行情人工设定,不写入策略 MD。
|
||||
- **界面短标签**:`大分歧A` / `大分歧B` / `小分歧`;全称在 option `title` / 策略 MD 说明。
|
||||
- **不写盈亏比(趋势户)**:币安/OKX 止盈/止损随行情人工设定,趋势 MD 不量化 RR。
|
||||
- **日内例外**:Gate 日内 **最低 1:1** 才开仓,持仓目标可动态调整(见 [gate-intraday.md](./gate-intraday.md))。
|
||||
- **界面短标签(趋势户)**:`大分歧A` / `大分歧B` / `小分歧`;全称在 option `title` / 策略 MD。
|
||||
- **界面短标签(日内户)**:`假破` / `结构突破`。
|
||||
- **多空共用三字**:方向由「做多/做空」表达,不复用为「大分歧A多」等。
|
||||
- **自动联动**:大分歧 A/B → 趋势单;**小分歧 → 波段单**;平仓写入交易记录 `entry_reason`;复盘「填入」自动带入。
|
||||
- **杠杆默认**:BTC/ETH **10x**,其它 **5x**;与开仓类型无关(env `BTC_LEVERAGE` / `ALT_LEVERAGE`)。
|
||||
- **日内 profile 独立**:env 启用 `TRADE_SYMBOL_WHITELIST=BTC,ETH` 且限制开启时,**不显示**上述三项(Gate 当前);日内开仓类型后续单独定义。
|
||||
- **日内 profile 独立**:env 启用 `TRADE_SYMBOL_WHITELIST=BTC,ETH` 且限制开启时,**不显示** 大分歧三项(Gate);开仓类型为 **假破 / 结构突破**(见 gate-intraday.md)。
|
||||
- **日内 0 点出场**:策略称「0 点平仓」;程序为 `FORCE_CLOSE_ENABLED` + `FORCE_CLOSE_BJ_HOUR=0`,交易记录 `result=强制清仓`(与表单 1h/2h/4h `time_close` 无关)。
|
||||
- **策略模块独立**:趋势回调、顺势加仓不走上述三项。
|
||||
|
||||
## 系统实现
|
||||
@@ -23,6 +26,7 @@
|
||||
- 库:`lib/trade/entry_model_lib.py`
|
||||
- 趋势户表单:`lib/instance/templates/order_entry_model_fields.html`
|
||||
- 日内判定:`is_intraday_trading_profile()`(白名单仅含 BTC/ETH)
|
||||
- 0 点强平:`force_close_before_reset()`(三所 `app.py`);env `FORCE_CLOSE_ENABLED` / `FORCE_CLOSE_BJ_HOUR`
|
||||
|
||||
## 相关文档
|
||||
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
# Gate·BTC 日内账户
|
||||
|
||||
> **状态**:v0.2(策略定稿;**0 点强平已实现**;日内 UI 隐藏平仓/委托/移动保本 **待实现**)
|
||||
|
||||
---
|
||||
|
||||
## 1. 账户定位
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 交易所 | Gate 合约 |
|
||||
| 品种 | **仅 BTC** |
|
||||
| 方向 | **多空都做**(由过滤条件决定,非手选方向) |
|
||||
| 计仓 | `POSITION_SIZING_MODE=full_margin`(全仓杠杆) |
|
||||
| UI profile | **日内户**(`TRADE_SYMBOL_WHITELIST=BTC,ETH` 且限制开启;本策略只交易 BTC) |
|
||||
| 与趋势户关系 | **不使用** 大分歧 A/B/小分歧;**不使用**「趋势单 / 波段单」手选 |
|
||||
|
||||
### 资金与杠杆(执行约定)
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 账户规模 | 约 300U(测试阶段) |
|
||||
| 日交易基数 | **50U**(早 8:00 重置为 50U,不延续前日阶梯) |
|
||||
| 单笔阶梯 | 上一笔 **+10U / −10U** 调节下一笔基数(赢 60U / 亏 40U 等) |
|
||||
| 杠杆 | **10× 全仓** |
|
||||
| 一次一单 | 同时仅 **1** 个 Gate 仓位 |
|
||||
| 止损带宽 | **0.4%~1.5%**(结构要求更宽则 **不做**) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 周期分层
|
||||
|
||||
自上而下,**先定能不能做、再做哪一类**:
|
||||
|
||||
| 层级 | 周期 | 作用 |
|
||||
|------|------|------|
|
||||
| 方向过滤 | **1H** | 大方向;**不得与 15m 排列反向** |
|
||||
| 均线 + 结构 | **15m** | 21/55/144 排列、顶底分型、结构识别、**B 类收盘突破** |
|
||||
| 触发 | **5m** | **A 类**:N 字形突破(配合 15m 分型) |
|
||||
| 方法 | 裸 K + 三均线 | 形态确认、入场与止损锚点 |
|
||||
|
||||
**不做「趋势单」概念**:持仓以 **小时** 计,当日了结;与币安/OKX 多日趋势户区分。
|
||||
|
||||
---
|
||||
|
||||
## 3. 方向过滤(必过)
|
||||
|
||||
### 3.1 15m 三均线(21 / 55 / 144)
|
||||
|
||||
| 15m 排列 | 只允许 |
|
||||
|----------|--------|
|
||||
| **多头排列**(21 > 55 > 144) | **只做多** |
|
||||
| **空头排列**(21 < 55 < 144) | **只做空** |
|
||||
| 纠缠、粘合、不符合 | **不做** |
|
||||
|
||||
### 3.2 与 1H 同向
|
||||
|
||||
- **做多**:15m 多头排列,且 **1H 不得为空头排列**(1H 均线不能与 15m 方向相反)。
|
||||
- **做空**:15m 空头排列,且 **1H 不得为多头排列**。
|
||||
- 1H/15m 方向冲突 → **当日该方向不做**。
|
||||
|
||||
### 3.3 21 均线关系(才允许开仓)
|
||||
|
||||
入场须与 **21 均线** 发生有效关系,避免 distant 追单:
|
||||
|
||||
| 方向 | 要求(定性) |
|
||||
|------|----------------|
|
||||
| **做多** | 多头排列下,**回踩 21 附近获支撑** 或 **站稳 21 上方** 后再按 playbook 入场 |
|
||||
| **做空** | 空头排列下,**反弹 21 附近承压** 或 **压在 21 下方** 后再按 playbook 入场 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 开仓类型(仅两类)
|
||||
|
||||
界面日后仅两个短标签(全称见下表 hover / 本文):
|
||||
|
||||
| 界面标签 | 存储 code(建议) | 本质 |
|
||||
|----------|-------------------|------|
|
||||
| **假破** | `liquidity_false_break` | 流动性扫单 → 假突破验证 → **5m N 字** → **15m 顶/底分型** |
|
||||
| **结构突破** | `structure_breakout` | **15m 结构有效突破**(**收盘确认**) |
|
||||
|
||||
子结构 **不单独占主下拉**,可在复盘备注或二级标签中记录。
|
||||
|
||||
---
|
||||
|
||||
## 5. A 类:假破(流动性 / 假突破)
|
||||
|
||||
**适用**:关键位附近 **扫止损** 后价格 **回到结构内**,陷阱确认后再反向做。
|
||||
|
||||
### 5.1 流程
|
||||
|
||||
```text
|
||||
1H/15m 方向 + 21 均线过滤通过
|
||||
→ 假突破出现(扫高/扫低)
|
||||
→ 验证为「假」(收回结构内 / 反向裸 K 确认)
|
||||
→ 5m 走出 N 字(二次探底/探顶后,沿允许方向突破)
|
||||
→ 15m 出现底分型(多)或顶分型(空)
|
||||
→ 入场
|
||||
```
|
||||
|
||||
### 5.2 做多 / 做空(对称)
|
||||
|
||||
| 步骤 | 做多 | 做空 |
|
||||
|------|------|------|
|
||||
| 假破 | 向下扫低后快速拉回支撑/箱上 | 向上扫高后跌回阻力/箱下 |
|
||||
| 5m N 字 | 扫低 → 反弹 → 不破前低 → 向上突破 | 扫高 → 回落 → 不过前高 → 向下突破 |
|
||||
| 15m 确认 | **底分型** | **顶分型** |
|
||||
| 止损 | 假破极值或 N 字低点 **外侧**(仍须落在 0.4%~1.5%) | 对称 |
|
||||
| 目标 | **最低 1:1**;之后 **随行情动态** 部分止盈、移动止损或延伸 | 对称 |
|
||||
|
||||
### 5.3 注意
|
||||
|
||||
- **须等假破验证完成**,扫完不追。
|
||||
- **5m N + 15m 分型** 为入场必要条件,缺一不可。
|
||||
- 与大级别 **宽幅震荡(S1)** 叠加时假信号多,优先 **降频或不做**。
|
||||
|
||||
---
|
||||
|
||||
## 6. B 类:结构突破
|
||||
|
||||
**适用**:15m 上结构清晰,方向与均线排列一致,**收盘突破** 后顺势做。
|
||||
|
||||
### 6.1 子结构(均属 B 类)
|
||||
|
||||
双顶、双底、头肩顶/底、收敛(三角/楔形)、箱体等——**统一记为「结构突破」**。
|
||||
|
||||
### 6.2 突破确认
|
||||
|
||||
- **以 15m K 线收盘价为准** 突破关键位(颈线、箱边、收敛边界等)。
|
||||
- **仅刺破、未收盘站稳** → **不算** 有效突破,不做。
|
||||
- 可选:**收盘突破后回踩** 再进(裸 K 确认),仍须满足 21 均线关系与 **≥1:1** 空间。
|
||||
|
||||
### 6.3 止损与目标
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 止损 | 结构另一侧或突破位回退点 **外侧**(0.4%~1.5%,超出则不做) |
|
||||
| 目标 | 下单前 **至少 1:1**;到位后 **随行情动态** 调整,不写死固定 RR |
|
||||
| 空间不足 | 最近阻力/支撑导致 **达不到 1:1** → **不做** |
|
||||
|
||||
---
|
||||
|
||||
## 7. 行情状态(辅助过滤)
|
||||
|
||||
| 状态 | 特征 | Gate 动作 |
|
||||
|------|------|-----------|
|
||||
| **S0 趋势** | 1H/15m 排列清晰,高低点有序 | 正常:A/B 均可 |
|
||||
| **S1 宽幅震荡** | 大箱横盘多日、均线反复穿 | **降频或不做** |
|
||||
| **S2 末期/选边** | 贴边收敛、刚突破或假破频发 | 优先 **A 假破** 或 **B 收敛突破** |
|
||||
|
||||
---
|
||||
|
||||
## 8. 一日节奏与笔数
|
||||
|
||||
| 项 | 规则 |
|
||||
|----|------|
|
||||
| 周末 | **不开新仓** |
|
||||
| 早窗 | 约 **9:00**(8:00~12:00 内),**计划内第 1 笔** |
|
||||
| 下午 | **默认不开新仓**(持仓可保留至晚窗) |
|
||||
| 晚窗 | 约 **21:00**(20:00~23:00 内),**计划内第 2 笔** |
|
||||
| 第 3 笔 | 仅当 **未连错 2 笔**,且 **早/晚有一笔为止损出场**,可 **补 1 笔** |
|
||||
| 日上限 | **最多 3 笔** |
|
||||
| **连错 2 笔** | **当日不再开新仓**(第 3 笔名额作废) |
|
||||
|
||||
**连错计数**:
|
||||
|
||||
| 出场 | 是否算「错 1 笔」 |
|
||||
|------|------------------|
|
||||
| **计划止损**触发 | ✅ 算 |
|
||||
| **0 点强制清仓**(系统结果 `强制清仓`)且亏损 | ✅ 算 |
|
||||
| 止盈 / ≥1:1 按计划平 | ❌ 不算 |
|
||||
| 0 点强制清仓且盈利或平推 | ❌ 不算 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 出场与统计纪律
|
||||
|
||||
### 9.1 盈亏比
|
||||
|
||||
- 开仓前:**第一目标空间 ≥ 止损距离(最低 1:1)**。
|
||||
- 持仓中:目标 **随行情动态** 调整;本文档 **不量化** 固定止盈比例。
|
||||
|
||||
### 9.2 禁止「手动止损」
|
||||
|
||||
- **亏损出场** 必须来自 **开仓时设定的计划止损**(交易所或监控等价执行)。
|
||||
- **禁止** 盘中亏着 **手点平仓** 充当止损(破坏统计与连错规则)。
|
||||
- 若违规手动平亏:**视为当日纪律失败,建议停手**;复盘结果 **不得** 记为「止损」糊弄统计。
|
||||
|
||||
### 9.3 时间出场:仅 0 点(程序已实现)
|
||||
|
||||
- **唯一** 时间类出场:**当日 0:00(北京时间)前必须空仓**(赚赔都平)。
|
||||
- **不使用** 下单表单里的 1h / 2h / 4h「开仓后 N 小时平」(`time_close`);与本策略无关。
|
||||
- **程序兜底**(三所共用,Gate 已启用):
|
||||
|
||||
| env | 说明 |
|
||||
|-----|------|
|
||||
| `FORCE_CLOSE_ENABLED=true` | 开启整点强制清仓 |
|
||||
| `FORCE_CLOSE_BJ_HOUR=0` | 北京时间 **0 点那一小时**(00:00~00:59)执行 |
|
||||
|
||||
- 实现:`force_close_before_reset()`(各实例 `app.py` 后台循环调用)。
|
||||
- 行为:对该小时仍 **active** 的 `order_monitors` **市价全平**,取消交易所触发单,写交易记录。
|
||||
- **系统结果字段**:`result = 强制清仓`;备注含「北京时间 0:00 整点风控清仓」。
|
||||
- **策略口语「0 点平仓」= 系统「强制清仓」**,统计连错时按 §8 盈亏判定,不按字段名区分。
|
||||
|
||||
> **与 `TRADING_DAY_RESET_HOUR=8` 无关**:后者只切 **交易日**(统计、8 点前禁开等),**不会**自动平仓。
|
||||
|
||||
### 9.4 允许的出场类型(统计用)
|
||||
|
||||
| 策略说法 | 系统 `result` | 说明 |
|
||||
|----------|---------------|------|
|
||||
| 止盈 | 止盈 / 移动止盈 / 保本止盈 等 | 计划止盈或 ≥1:1 后按计划/动态平 |
|
||||
| 止损 | 止损 | 仅 **计划止损** 触发 |
|
||||
| 0 点平仓 | **强制清仓** | 整点风控兜底(§9.3) |
|
||||
| ~~手动平仓~~ | 手动平仓 | **策略禁止**(除极端技术故障等,须复盘说明) |
|
||||
|
||||
---
|
||||
|
||||
## 10. A / B 如何选择(当日)
|
||||
|
||||
| 盘面 | 优先 |
|
||||
|------|------|
|
||||
| 刚扫流动性、回到箱内 | **A 假破** |
|
||||
| 结构清晰、排列已顺、收敛末端 | **B 结构突破** |
|
||||
| 大箱乱扫、均线粘合 | **不做** |
|
||||
|
||||
早/晚窗 **有形态才做**,无形态 = **0 笔**,不占额度。
|
||||
|
||||
---
|
||||
|
||||
## 11. 与其它账户边界
|
||||
|
||||
| 账户 | 周期 | 持仓 | 本户勿混 |
|
||||
|------|------|------|----------|
|
||||
| 币安 | 日线/4H 事件 | 数天~数周 | 不要用 Gate 扛隔夜趋势 |
|
||||
| OKX | 4H 波段滚仓 | 数小时~数天 | 勿与 Gate 同向同结构叠隔夜 |
|
||||
| **Gate 日内** | 1H 过滤 + 15m/5m | **当日 0 点前** | 见上文 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 系统对接
|
||||
|
||||
### 12.1 已实现
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 日内 profile 判定 | `is_intraday_trading_profile()`(`lib/trade/entry_model_lib.py`) |
|
||||
| 0 点强制清仓 | `FORCE_CLOSE_ENABLED` + `FORCE_CLOSE_BJ_HOUR`;`force_close_before_reset()`;结果 **`强制清仓`** |
|
||||
| UI 标识 | 顶栏 **强制清仓 已开启** 徽章 + 持仓卡片 **倒计时**(三所 + 中控) |
|
||||
| 交易记录展示 | 三所 UI / 中控:`强制清仓` 与止损同类 badge |
|
||||
| 三所统一 | 币安 / OKX / Gate 同一函数与 env;**将来改日内只需各所 `.env` 打开,无需改代码** |
|
||||
|
||||
Gate 当前建议 env(节选):
|
||||
|
||||
```env
|
||||
FORCE_CLOSE_ENABLED=true
|
||||
FORCE_CLOSE_BJ_HOUR=0
|
||||
TRADING_DAY_RESET_HOUR=8
|
||||
```
|
||||
|
||||
### 12.2 待实现(UI / 纪律)
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 开仓类型 | 界面 **`假破` / `结构突破`**(code:`liquidity_false_break` / `structure_breakout`);**无** trend/swing 手选 |
|
||||
| 写入字段 | `trade_records.entry_model` / 复盘下拉同两项 |
|
||||
| 隐藏操作 | 日内 profile 下 **隐藏** 平仓、委托、移动保本(**实例页 + 中控**,共用 lib 判断) |
|
||||
| 隐藏表单项 | 不展示 1h/2h/4h 时间平仓、移动保本勾选(避免与 §9.3 混用) |
|
||||
| 后端可选 | 严格模式下拒绝 `del_order` / 改委托 API |
|
||||
|
||||
---
|
||||
|
||||
## 13. 修订记录
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|------|------|------|
|
||||
| v0.1 | 2026-07-06 | 定稿:BTC 日内;1H+15m 均线;A 假破(5m N+15m 分型);B 结构突破(15m 收盘);早1晚1/最多3笔/连错2停;禁手动止损;仅 0 点强平 |
|
||||
| v0.2 | 2026-07-06 | §9.3/§12:对齐 `FORCE_CLOSE_*` 与系统结果「强制清仓」;区分 `time_close` / `TRADING_DAY_RESET_HOUR`;Gate 已启用说明 |
|
||||
@@ -590,6 +590,53 @@ html[data-theme="light"] .pos-meta-item::after {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.force-close-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: #ffc870;
|
||||
background: #2a2218;
|
||||
border: 1px solid #6a5020;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.force-close-badge .force-close-header-cd {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.pos-force-close-meta {
|
||||
color: #ffc870;
|
||||
}
|
||||
.pos-symbol-force-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
color: #ffc870;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 200, 112, 0.12);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pos-symbol-force-close .pos-force-close-cd {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
html[data-theme="light"] .force-close-badge {
|
||||
color: #9a6200;
|
||||
background: #fff6e8;
|
||||
border-color: #d4a84a;
|
||||
}
|
||||
html[data-theme="light"] .pos-symbol-force-close,
|
||||
html[data-theme="light"] .pos-force-close-meta {
|
||||
color: #9a6200;
|
||||
background: rgba(212, 168, 74, 0.14);
|
||||
}
|
||||
.key-time-close-wrap.is-disabled > label,
|
||||
.order-time-close-wrap.is-disabled > label {
|
||||
opacity: 0.72;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 时间平仓:表单开关 + 持仓倒计时。
|
||||
* 时间平仓 + 整点强制清仓:表单开关 + 持仓/顶栏倒计时。
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
@@ -16,6 +16,15 @@
|
||||
return pad2(h) + ":" + pad2(m) + ":" + pad2(r);
|
||||
}
|
||||
|
||||
function isForceCloseActive(wrap) {
|
||||
if (!wrap) return false;
|
||||
const raw =
|
||||
wrap.dataset.forceCloseActive ||
|
||||
wrap.getAttribute("data-force-close-active") ||
|
||||
"";
|
||||
return raw === "1" || raw === "true";
|
||||
}
|
||||
|
||||
function bindTimeCloseForm(checkboxId, selectId, wrapId) {
|
||||
const cb = document.getElementById(checkboxId);
|
||||
const sel = document.getElementById(selectId);
|
||||
@@ -37,6 +46,15 @@
|
||||
sync();
|
||||
}
|
||||
|
||||
function paintCountdownEl(cd, rem, active) {
|
||||
if (!cd) return;
|
||||
if (active) {
|
||||
cd.textContent = "执行中";
|
||||
return;
|
||||
}
|
||||
cd.textContent = Number.isFinite(rem) ? formatCountdown(rem) : "--:--:--";
|
||||
}
|
||||
|
||||
function paintOrderTimeClose(order) {
|
||||
if (!order || order.id == null) return;
|
||||
const wrap = document.getElementById("order-time-close-wrap-" + order.id);
|
||||
@@ -59,10 +77,71 @@
|
||||
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) : "--:--:--";
|
||||
paintCountdownEl(cd, rem, false);
|
||||
wrap.dataset.closeAtMs = order.time_close_at_ms ? String(order.time_close_at_ms) : "";
|
||||
}
|
||||
|
||||
function paintOrderForceClose(order) {
|
||||
if (!order || order.id == null) return;
|
||||
const wrap = document.getElementById("order-force-close-wrap-" + order.id);
|
||||
const cd = document.getElementById("order-force-close-cd-" + order.id);
|
||||
if (!wrap || !cd) return;
|
||||
const enabled = !!order.force_close_enabled;
|
||||
if (!enabled) {
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
const label = order.force_close_label || "强制清仓";
|
||||
const labelEl = wrap.querySelector(".pos-force-close-label");
|
||||
if (labelEl) labelEl.textContent = label;
|
||||
let rem =
|
||||
order.force_close_remaining_sec != null
|
||||
? Number(order.force_close_remaining_sec)
|
||||
: null;
|
||||
const atMs = order.force_close_at_ms;
|
||||
if ((rem == null || !Number.isFinite(rem)) && atMs) {
|
||||
rem = Math.max(0, Math.floor((Number(atMs) - Date.now()) / 1000));
|
||||
}
|
||||
const active = !!order.force_close_active;
|
||||
paintCountdownEl(cd, rem, active);
|
||||
wrap.dataset.forceCloseAtMs = atMs ? String(atMs) : "";
|
||||
wrap.dataset.forceCloseActive = active ? "1" : "0";
|
||||
}
|
||||
|
||||
function paintForceCloseHeader(state) {
|
||||
const wrap = document.getElementById("force-close-header-badge");
|
||||
if (!wrap) return;
|
||||
if (!state || !state.enabled) {
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
const label = state.label || "强制清仓";
|
||||
const labelPrefix = label + " 已开启 · ";
|
||||
let prefixNode = wrap.querySelector(".force-close-header-prefix");
|
||||
if (!prefixNode) {
|
||||
wrap.textContent = "";
|
||||
prefixNode = document.createElement("span");
|
||||
prefixNode.className = "force-close-header-prefix";
|
||||
prefixNode.textContent = labelPrefix;
|
||||
wrap.appendChild(prefixNode);
|
||||
const cd = document.createElement("span");
|
||||
cd.className = "force-close-header-cd";
|
||||
wrap.appendChild(cd);
|
||||
} else {
|
||||
prefixNode.textContent = labelPrefix;
|
||||
}
|
||||
const cd = wrap.querySelector(".force-close-header-cd");
|
||||
let rem = state.remaining_sec != null ? Number(state.remaining_sec) : null;
|
||||
if ((rem == null || !Number.isFinite(rem)) && state.next_at_ms) {
|
||||
rem = Math.max(0, Math.floor((Number(state.next_at_ms) - Date.now()) / 1000));
|
||||
}
|
||||
paintCountdownEl(cd, rem, !!state.active);
|
||||
wrap.dataset.forceCloseAtMs = state.next_at_ms ? String(state.next_at_ms) : "";
|
||||
wrap.dataset.forceCloseActive = state.active ? "1" : "0";
|
||||
}
|
||||
|
||||
function tickLocalCountdowns() {
|
||||
document.querySelectorAll("[data-close-at-ms]").forEach(function (wrap) {
|
||||
const closeAtRaw = wrap.dataset.closeAtMs || wrap.getAttribute("data-close-at-ms") || "";
|
||||
@@ -73,10 +152,23 @@
|
||||
const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000));
|
||||
cd.textContent = formatCountdown(rem);
|
||||
});
|
||||
document.querySelectorAll("[data-force-close-at-ms]").forEach(function (wrap) {
|
||||
const closeAtRaw =
|
||||
wrap.dataset.forceCloseAtMs || wrap.getAttribute("data-force-close-at-ms") || "";
|
||||
const cd = wrap.querySelector(".pos-force-close-cd, .force-close-header-cd");
|
||||
if (!cd) return;
|
||||
const closeAt = Number(closeAtRaw);
|
||||
if (!closeAt) return;
|
||||
const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000));
|
||||
paintCountdownEl(cd, rem, isForceCloseActive(wrap));
|
||||
});
|
||||
}
|
||||
|
||||
function paintOrders(orders) {
|
||||
(orders || []).forEach(paintOrderTimeClose);
|
||||
(orders || []).forEach(function (order) {
|
||||
paintOrderTimeClose(order);
|
||||
paintOrderForceClose(order);
|
||||
});
|
||||
}
|
||||
|
||||
function syncKeyTimeCloseVisibility(show) {
|
||||
@@ -88,6 +180,8 @@
|
||||
global.TimeCloseUI = {
|
||||
bindTimeCloseForm: bindTimeCloseForm,
|
||||
paintOrderTimeClose: paintOrderTimeClose,
|
||||
paintOrderForceClose: paintOrderForceClose,
|
||||
paintForceCloseHeader: paintForceCloseHeader,
|
||||
paintOrders: paintOrders,
|
||||
tickLocalCountdowns: tickLocalCountdowns,
|
||||
syncKeyTimeCloseVisibility: syncKeyTimeCloseVisibility,
|
||||
|
||||
@@ -944,6 +944,9 @@ function refreshPriceSnapshot(){
|
||||
if(data.updated_at && updatedEl){
|
||||
updatedEl.innerText = data.updated_at;
|
||||
}
|
||||
if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){
|
||||
TimeCloseUI.paintForceCloseHeader(data.force_close);
|
||||
}
|
||||
(data.key_prices || []).forEach(k=>{
|
||||
const pEl = document.getElementById(`key-price-${k.id}`);
|
||||
if(pEl){
|
||||
@@ -1015,6 +1018,7 @@ 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);
|
||||
});
|
||||
}).catch(()=>{});
|
||||
}
|
||||
@@ -1074,6 +1078,9 @@ function refreshAccountSnapshot(){
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader) {
|
||||
TimeCloseUI.paintForceCloseHeader(data.force_close);
|
||||
}
|
||||
let canTradeText = "可开仓";
|
||||
if (!data.can_trade) {
|
||||
const parts = [];
|
||||
@@ -1247,6 +1254,9 @@ function refreshPriceSnapshotConditional(){
|
||||
fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{
|
||||
const updatedEl = document.getElementById("price-last-updated");
|
||||
if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at;
|
||||
if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){
|
||||
TimeCloseUI.paintForceCloseHeader(data.force_close);
|
||||
}
|
||||
if(page === "key_monitor"){
|
||||
(data.key_prices || []).forEach(k=>{
|
||||
const pEl = document.getElementById(`key-price-${k.id}`);
|
||||
@@ -1296,6 +1306,7 @@ function refreshPriceSnapshotConditional(){
|
||||
paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
|
||||
paintPlanTpslDisplay(o.id, o);
|
||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderForceClose(o);
|
||||
const holdEl = document.getElementById(`order-hold-duration-${o.id}`);
|
||||
if(holdEl && o.opened_at_ms != null && o.opened_at_ms !== ""){
|
||||
holdEl.setAttribute("data-order-opened-ms", String(o.opened_at_ms));
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
· <span class="pos-time-close-cd" id="order-time-close-cd-{{ o.id }}">--:--:--</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% include 'force_close_order_badge.html' %}
|
||||
<span class="pos-side-badge {{ 'pos-side-long' if o.direction == 'long' else 'pos-side-short' }}">{{ '做多' if o.direction == 'long' else '做空' }}</span>
|
||||
</div>
|
||||
<div class="pos-head-actions">
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
{% if trade_policy.badge_text %}
|
||||
<span class="trade-policy-badge" title="账户交易限制(.env)">{{ trade_policy.badge_text }}</span>
|
||||
{% endif %}
|
||||
{% include 'force_close_header_badge.html' %}
|
||||
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}" data-status-label="{{ risk_status.status_label|default('正常') }}"{% if risk_status.freeze_until_ms %} data-freeze-until-ms="{{ risk_status.freeze_until_ms }}"{% endif %}>{{ risk_status.status_label|default('正常') }}</span>
|
||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
||||
@@ -118,7 +119,7 @@
|
||||
<script src="/static/instance_ui.js?v=6"></script>
|
||||
<script src="/static/journal_upload_slots.js?v=3"></script>
|
||||
<script src="/static/instance_records_mobile.js?v=2"></script>
|
||||
<script src="/static/time_close_ui.js?v=2"></script>
|
||||
<script src="/static/time_close_ui.js?v=3"></script>
|
||||
<script src="/static/ai_review_render.js?v=2"></script>
|
||||
<script src="/static/form_submit_guard.js?v=2"></script>
|
||||
<script src="/static/order_entry_model.js?v=3"></script>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{% if force_close.enabled %}
|
||||
<span class="force-close-badge" id="force-close-header-badge" role="status"
|
||||
title="北京时间 {{ force_close.hour_label }} 整点未平仓将市价强制清仓(result=强制清仓)"
|
||||
data-force-close-at-ms="{{ force_close.next_at_ms or '' }}"
|
||||
data-force-close-active="{{ '1' if force_close.active else '0' }}">
|
||||
{{ force_close.label }} 已开启 · <span class="force-close-header-cd">{{ force_close.countdown or '--:--:--' }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% if force_close.enabled %}
|
||||
<span class="pos-symbol-force-close pos-force-close-meta" id="order-force-close-wrap-{{ o.id }}"
|
||||
data-force-close-at-ms="{{ o.force_close_at_ms or force_close.next_at_ms or '' }}"
|
||||
data-force-close-active="{{ '1' if (o.force_close_active or force_close.active) else '0' }}">
|
||||
<span class="pos-force-close-label">{{ o.force_close_label or force_close.label }}</span>
|
||||
· <span class="pos-force-close-cd" id="order-force-close-cd-{{ o.id }}">{{ o.force_close_countdown or force_close.countdown or '--:--:--' }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,179 @@
|
||||
"""整点强制清仓(FORCE_CLOSE_*):UI 标识与持仓倒计时。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
FORCE_CLOSE_RESULT = "强制清仓"
|
||||
|
||||
|
||||
def app_timezone_name() -> str:
|
||||
return (os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai"
|
||||
|
||||
|
||||
def normalize_force_close_bj_hour(value: Any) -> int:
|
||||
try:
|
||||
h = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return max(0, min(23, h))
|
||||
|
||||
|
||||
def _now_dt(*, now_ms: Optional[int] = None, tz_name: Optional[str] = None) -> datetime:
|
||||
tz = ZoneInfo(tz_name or app_timezone_name())
|
||||
if now_ms is None:
|
||||
return datetime.now(tz)
|
||||
return datetime.fromtimestamp(int(now_ms) / 1000, tz=tz)
|
||||
|
||||
|
||||
def force_close_hour_label(bj_hour: Any) -> str:
|
||||
return f"{normalize_force_close_bj_hour(bj_hour):02d}:00"
|
||||
|
||||
|
||||
def force_close_label(bj_hour: Any) -> str:
|
||||
return f"强制清仓 {force_close_hour_label(bj_hour)}"
|
||||
|
||||
|
||||
def is_force_close_active_hour(
|
||||
bj_hour: Any,
|
||||
*,
|
||||
now_ms: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""当前是否处于整点强制清仓执行窗口(该北京时间整点小时内)。"""
|
||||
hour = normalize_force_close_bj_hour(bj_hour)
|
||||
return _now_dt(now_ms=now_ms, tz_name=tz_name).hour == hour
|
||||
|
||||
|
||||
def compute_next_force_close_at_ms(
|
||||
*,
|
||||
bj_hour: Any,
|
||||
now_ms: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
) -> Optional[int]:
|
||||
"""下一次强制清仓时刻(北京时间整点)的 epoch 毫秒。"""
|
||||
hour = normalize_force_close_bj_hour(bj_hour)
|
||||
now = _now_dt(now_ms=now_ms, tz_name=tz_name)
|
||||
target = now.replace(hour=hour, minute=0, second=0, microsecond=0)
|
||||
if now.hour > hour or now.hour == hour:
|
||||
if now.hour > hour:
|
||||
target += timedelta(days=1)
|
||||
return int(target.timestamp() * 1000)
|
||||
|
||||
|
||||
def force_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_force_close_countdown(seconds: Any, *, active: bool = False) -> str:
|
||||
if active:
|
||||
return "执行中"
|
||||
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 build_force_close_state(
|
||||
enabled: bool,
|
||||
bj_hour: Any,
|
||||
*,
|
||||
now_ms: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""实例级强制清仓状态(模板 / API 共用)。"""
|
||||
if not enabled:
|
||||
return {
|
||||
"enabled": False,
|
||||
"bj_hour": normalize_force_close_bj_hour(bj_hour),
|
||||
"hour_label": force_close_hour_label(bj_hour),
|
||||
"label": force_close_label(bj_hour),
|
||||
"next_at_ms": None,
|
||||
"remaining_sec": None,
|
||||
"countdown": "",
|
||||
"active": False,
|
||||
}
|
||||
hour = normalize_force_close_bj_hour(bj_hour)
|
||||
active = is_force_close_active_hour(hour, now_ms=now_ms, tz_name=tz_name)
|
||||
next_at_ms = compute_next_force_close_at_ms(bj_hour=hour, now_ms=now_ms, tz_name=tz_name)
|
||||
rem = force_close_remaining_seconds(next_at_ms, now_ms=now_ms) if next_at_ms else None
|
||||
return {
|
||||
"enabled": True,
|
||||
"bj_hour": hour,
|
||||
"hour_label": force_close_hour_label(hour),
|
||||
"label": force_close_label(hour),
|
||||
"next_at_ms": next_at_ms,
|
||||
"remaining_sec": rem,
|
||||
"countdown": format_force_close_countdown(rem, active=active),
|
||||
"active": active,
|
||||
}
|
||||
|
||||
|
||||
def force_close_template_context(
|
||||
enabled: bool,
|
||||
bj_hour: Any,
|
||||
*,
|
||||
now_ms: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
return {
|
||||
"force_close": build_force_close_state(
|
||||
enabled, bj_hour, now_ms=now_ms, tz_name=tz_name
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def apply_force_close_to_payload(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
enabled: bool,
|
||||
bj_hour: Any,
|
||||
now_ms: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
) -> None:
|
||||
"""为 active 持仓 JSON 附加整点强制清仓倒计时。"""
|
||||
state = build_force_close_state(enabled, bj_hour, now_ms=now_ms, tz_name=tz_name)
|
||||
payload["force_close_enabled"] = bool(state["enabled"])
|
||||
payload["force_close_bj_hour"] = state["bj_hour"]
|
||||
payload["force_close_at_ms"] = state["next_at_ms"]
|
||||
payload["force_close_label"] = state["label"] if state["enabled"] else ""
|
||||
payload["force_close_remaining_sec"] = state["remaining_sec"]
|
||||
payload["force_close_countdown"] = state["countdown"]
|
||||
payload["force_close_active"] = bool(state["active"])
|
||||
|
||||
|
||||
def enrich_orders_force_close(
|
||||
orders: list[dict[str, Any]],
|
||||
enabled: bool,
|
||||
bj_hour: Any,
|
||||
*,
|
||||
now_ms: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
) -> None:
|
||||
if not enabled or not orders:
|
||||
return
|
||||
for item in orders:
|
||||
if isinstance(item, dict):
|
||||
apply_force_close_to_payload(
|
||||
item,
|
||||
enabled=enabled,
|
||||
bj_hour=bj_hour,
|
||||
now_ms=now_ms,
|
||||
tz_name=tz_name,
|
||||
)
|
||||
@@ -1784,6 +1784,13 @@ _ORDER_PRICE_MERGE_KEYS = (
|
||||
"time_close_label",
|
||||
"time_close_countdown",
|
||||
"time_close_remaining_sec",
|
||||
"force_close_enabled",
|
||||
"force_close_bj_hour",
|
||||
"force_close_at_ms",
|
||||
"force_close_label",
|
||||
"force_close_countdown",
|
||||
"force_close_remaining_sec",
|
||||
"force_close_active",
|
||||
)
|
||||
|
||||
|
||||
@@ -2141,6 +2148,7 @@ async def _assemble_board_row(
|
||||
"available_trading_usdt": account.get("available_trading_usdt") if acct_ok else None,
|
||||
"account_ok": acct_ok,
|
||||
"day_stats": _day_stats_from_trades_body(trades_today),
|
||||
"force_close": snap.get("force_close") if isinstance(snap, dict) else None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1605,6 +1605,27 @@ body.market-chart-fs-open {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.hub-pos-card .pos-symbol-force-close,
|
||||
.hub-mini-title .pos-symbol-force-close,
|
||||
.td-symbol .pos-symbol-force-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
color: #ffc870;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 200, 112, 0.12);
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.hub-pos-card .pos-symbol-force-close .pos-force-close-cd,
|
||||
.hub-mini-title .pos-symbol-force-close .pos-force-close-cd,
|
||||
.td-symbol .pos-symbol-force-close .pos-force-close-cd {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.hub-pos-card .pos-card-symbol strong {
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
|
||||
@@ -2792,6 +2792,18 @@
|
||||
);
|
||||
}
|
||||
|
||||
function forceCloseSymbolBadgeHtml(item) {
|
||||
if (!item || !item.force_close_enabled) return "";
|
||||
const fcLabel = item.force_close_label || "强制清仓";
|
||||
const fcCd = item.force_close_countdown || "--:--:--";
|
||||
const fcAt = item.force_close_at_ms != null ? String(item.force_close_at_ms) : "";
|
||||
const fcActive = item.force_close_active ? "1" : "0";
|
||||
return (
|
||||
`<span class="pos-symbol-force-close pos-force-close-meta" data-force-close-at-ms="${esc(fcAt)}" data-force-close-active="${esc(fcActive)}">` +
|
||||
`${esc(fcLabel)} · <span class="pos-force-close-cd">${esc(fcCd)}</span></span>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderTrendDcaTable(t, tickMap) {
|
||||
const levels = resolveTrendDcaLevels(t);
|
||||
if (!levels.length) return "";
|
||||
@@ -3043,11 +3055,12 @@
|
||||
}
|
||||
const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : "";
|
||||
const tcSymBadge = !isTrend && mo.time_close_enabled ? timeCloseSymbolBadgeHtml(mo) : "";
|
||||
const fcSymBadge = !isTrend && mo.force_close_enabled ? forceCloseSymbolBadgeHtml(mo) : "";
|
||||
const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan);
|
||||
return `<div class="pos-card hub-pos-card">
|
||||
<div class="pos-card-head">
|
||||
<div class="pos-card-symbol">
|
||||
<button type="button" class="btn-open-market sym-link pos-symbol-link" ${mktAttrs} title="打开行情区(含入场/止盈止损)"><strong>${esc(symbol)}</strong></button>${tcSymBadge}${symBeBadge}
|
||||
<button type="button" class="btn-open-market sym-link pos-symbol-link" ${mktAttrs} title="打开行情区(含入场/止盈止损)"><strong>${esc(symbol)}</strong></button>${tcSymBadge}${fcSymBadge}${symBeBadge}
|
||||
<span class="pos-side-badge ${sideCls}">${sideCn}</span>
|
||||
</div>
|
||||
<div class="pos-head-actions">
|
||||
@@ -3132,8 +3145,9 @@
|
||||
.map((o) => {
|
||||
const sym = o.exchange_symbol || o.symbol || "";
|
||||
const tcBadge = o.time_close_enabled ? timeCloseSymbolBadgeHtml(o) : "";
|
||||
const fcBadge = o.force_close_enabled ? forceCloseSymbolBadgeHtml(o) : "";
|
||||
return `<div class="hub-mini-card">
|
||||
<div class="hub-mini-title">#${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)} ${tcBadge} · ${renderDirectionHtml(o.direction)}</div>
|
||||
<div class="hub-mini-title">#${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)} ${tcBadge}${fcBadge} · ${renderDirectionHtml(o.direction)}</div>
|
||||
<div class="hub-mini-line">触发 ${fmtSymbolPrice(o.trigger_price, sym, tickMap)} · SL ${fmtSymbolPrice(o.stop_loss, sym, tickMap)} · TP ${fmtSymbolPrice(o.take_profit, sym, tickMap)} · ${esc(o.trade_style || o.monitor_type || "下单监控")}</div>
|
||||
</div>`;
|
||||
})
|
||||
@@ -3202,6 +3216,8 @@
|
||||
const mo = monitorOrder || {};
|
||||
const tcBadge =
|
||||
!isTrendContext(mo, trendPlan) && mo.time_close_enabled ? timeCloseSymbolBadgeHtml(mo) : "";
|
||||
const fcBadge =
|
||||
!isTrendContext(mo, trendPlan) && mo.force_close_enabled ? forceCloseSymbolBadgeHtml(mo) : "";
|
||||
const actionCell = `<div class="pos-action-group">
|
||||
<button type="button" class="btn-place-tpsl btn-sm ghost" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-side="${sideAttr}" data-contracts="${contractsAttr}" data-sl="${slAttr}" data-tp="${tpAttr}">委托</button>
|
||||
<button type="button" class="btn-close-pos btn-sm danger" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-side="${sideAttr}">平仓</button>
|
||||
@@ -3210,7 +3226,7 @@
|
||||
? `<td class="${pnlCls(x.unrealized_pnl)}">${fmt(x.unrealized_pnl, 2)}</td>`
|
||||
: "";
|
||||
return `<tr>
|
||||
<td class="td-symbol"><button type="button" class="btn-open-market sym-link" ${mktAttrs} title="打开行情区(含入场/止盈止损)">${esc(x.symbol)}</button>${tcBadge}${symBeBadge}</td>
|
||||
<td class="td-symbol"><button type="button" class="btn-open-market sym-link" ${mktAttrs} title="打开行情区(含入场/止盈止损)">${esc(x.symbol)}</button>${tcBadge}${fcBadge}${symBeBadge}</td>
|
||||
<td class="${sideDirCls(x.side)}">${renderDirectionHtml(x.side)}</td>
|
||||
<td class="td-entry">${fmtEntryPrice(x, tickMap)}</td>
|
||||
<td>${fmtMarkPrice(x, tickMap)}</td>
|
||||
|
||||
@@ -1114,7 +1114,7 @@
|
||||
<script src="/assets/funds.js?v=20260609-hub-funds-fold"></script>
|
||||
<script src="/assets/dashboard.js?v=20260612-dash-monitor-count"></script>
|
||||
<script src="/assets/ai_review_render.js?v=3"></script>
|
||||
<script src="/assets/time_close_ui.js?v=2"></script>
|
||||
<script src="/assets/time_close_ui.js?v=3"></script>
|
||||
<script src="/assets/backup.js?v=1"></script>
|
||||
<script src="/assets/app.js?v=20260704-monitor-stats-v2"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 时间平仓:表单开关 + 持仓倒计时。
|
||||
* 时间平仓 + 整点强制清仓:表单开关 + 持仓/顶栏倒计时。
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
@@ -16,6 +16,15 @@
|
||||
return pad2(h) + ":" + pad2(m) + ":" + pad2(r);
|
||||
}
|
||||
|
||||
function isForceCloseActive(wrap) {
|
||||
if (!wrap) return false;
|
||||
const raw =
|
||||
wrap.dataset.forceCloseActive ||
|
||||
wrap.getAttribute("data-force-close-active") ||
|
||||
"";
|
||||
return raw === "1" || raw === "true";
|
||||
}
|
||||
|
||||
function bindTimeCloseForm(checkboxId, selectId, wrapId) {
|
||||
const cb = document.getElementById(checkboxId);
|
||||
const sel = document.getElementById(selectId);
|
||||
@@ -37,6 +46,15 @@
|
||||
sync();
|
||||
}
|
||||
|
||||
function paintCountdownEl(cd, rem, active) {
|
||||
if (!cd) return;
|
||||
if (active) {
|
||||
cd.textContent = "执行中";
|
||||
return;
|
||||
}
|
||||
cd.textContent = Number.isFinite(rem) ? formatCountdown(rem) : "--:--:--";
|
||||
}
|
||||
|
||||
function paintOrderTimeClose(order) {
|
||||
if (!order || order.id == null) return;
|
||||
const wrap = document.getElementById("order-time-close-wrap-" + order.id);
|
||||
@@ -59,10 +77,71 @@
|
||||
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) : "--:--:--";
|
||||
paintCountdownEl(cd, rem, false);
|
||||
wrap.dataset.closeAtMs = order.time_close_at_ms ? String(order.time_close_at_ms) : "";
|
||||
}
|
||||
|
||||
function paintOrderForceClose(order) {
|
||||
if (!order || order.id == null) return;
|
||||
const wrap = document.getElementById("order-force-close-wrap-" + order.id);
|
||||
const cd = document.getElementById("order-force-close-cd-" + order.id);
|
||||
if (!wrap || !cd) return;
|
||||
const enabled = !!order.force_close_enabled;
|
||||
if (!enabled) {
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
const label = order.force_close_label || "强制清仓";
|
||||
const labelEl = wrap.querySelector(".pos-force-close-label");
|
||||
if (labelEl) labelEl.textContent = label;
|
||||
let rem =
|
||||
order.force_close_remaining_sec != null
|
||||
? Number(order.force_close_remaining_sec)
|
||||
: null;
|
||||
const atMs = order.force_close_at_ms;
|
||||
if ((rem == null || !Number.isFinite(rem)) && atMs) {
|
||||
rem = Math.max(0, Math.floor((Number(atMs) - Date.now()) / 1000));
|
||||
}
|
||||
const active = !!order.force_close_active;
|
||||
paintCountdownEl(cd, rem, active);
|
||||
wrap.dataset.forceCloseAtMs = atMs ? String(atMs) : "";
|
||||
wrap.dataset.forceCloseActive = active ? "1" : "0";
|
||||
}
|
||||
|
||||
function paintForceCloseHeader(state) {
|
||||
const wrap = document.getElementById("force-close-header-badge");
|
||||
if (!wrap) return;
|
||||
if (!state || !state.enabled) {
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
const label = state.label || "强制清仓";
|
||||
const labelPrefix = label + " 已开启 · ";
|
||||
let prefixNode = wrap.querySelector(".force-close-header-prefix");
|
||||
if (!prefixNode) {
|
||||
wrap.textContent = "";
|
||||
prefixNode = document.createElement("span");
|
||||
prefixNode.className = "force-close-header-prefix";
|
||||
prefixNode.textContent = labelPrefix;
|
||||
wrap.appendChild(prefixNode);
|
||||
const cd = document.createElement("span");
|
||||
cd.className = "force-close-header-cd";
|
||||
wrap.appendChild(cd);
|
||||
} else {
|
||||
prefixNode.textContent = labelPrefix;
|
||||
}
|
||||
const cd = wrap.querySelector(".force-close-header-cd");
|
||||
let rem = state.remaining_sec != null ? Number(state.remaining_sec) : null;
|
||||
if ((rem == null || !Number.isFinite(rem)) && state.next_at_ms) {
|
||||
rem = Math.max(0, Math.floor((Number(state.next_at_ms) - Date.now()) / 1000));
|
||||
}
|
||||
paintCountdownEl(cd, rem, !!state.active);
|
||||
wrap.dataset.forceCloseAtMs = state.next_at_ms ? String(state.next_at_ms) : "";
|
||||
wrap.dataset.forceCloseActive = state.active ? "1" : "0";
|
||||
}
|
||||
|
||||
function tickLocalCountdowns() {
|
||||
document.querySelectorAll("[data-close-at-ms]").forEach(function (wrap) {
|
||||
const closeAtRaw = wrap.dataset.closeAtMs || wrap.getAttribute("data-close-at-ms") || "";
|
||||
@@ -73,10 +152,23 @@
|
||||
const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000));
|
||||
cd.textContent = formatCountdown(rem);
|
||||
});
|
||||
document.querySelectorAll("[data-force-close-at-ms]").forEach(function (wrap) {
|
||||
const closeAtRaw =
|
||||
wrap.dataset.forceCloseAtMs || wrap.getAttribute("data-force-close-at-ms") || "";
|
||||
const cd = wrap.querySelector(".pos-force-close-cd, .force-close-header-cd");
|
||||
if (!cd) return;
|
||||
const closeAt = Number(closeAtRaw);
|
||||
if (!closeAt) return;
|
||||
const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000));
|
||||
paintCountdownEl(cd, rem, isForceCloseActive(wrap));
|
||||
});
|
||||
}
|
||||
|
||||
function paintOrders(orders) {
|
||||
(orders || []).forEach(paintOrderTimeClose);
|
||||
(orders || []).forEach(function (order) {
|
||||
paintOrderTimeClose(order);
|
||||
paintOrderForceClose(order);
|
||||
});
|
||||
}
|
||||
|
||||
function syncKeyTimeCloseVisibility(show) {
|
||||
@@ -88,6 +180,8 @@
|
||||
global.TimeCloseUI = {
|
||||
bindTimeCloseForm: bindTimeCloseForm,
|
||||
paintOrderTimeClose: paintOrderTimeClose,
|
||||
paintOrderForceClose: paintOrderForceClose,
|
||||
paintForceCloseHeader: paintForceCloseHeader,
|
||||
paintOrders: paintOrders,
|
||||
tickLocalCountdowns: tickLocalCountdowns,
|
||||
syncKeyTimeCloseVisibility: syncKeyTimeCloseVisibility,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from lib.trade.force_close_lib import (
|
||||
build_force_close_state,
|
||||
compute_next_force_close_at_ms,
|
||||
force_close_label,
|
||||
format_force_close_countdown,
|
||||
is_force_close_active_hour,
|
||||
)
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def _ms(y, m, d, hh, mm=0):
|
||||
return int(datetime(y, m, d, hh, mm, tzinfo=TZ).timestamp() * 1000)
|
||||
|
||||
|
||||
def test_force_close_label():
|
||||
assert force_close_label(0) == "强制清仓 00:00"
|
||||
assert force_close_label(8) == "强制清仓 08:00"
|
||||
|
||||
|
||||
def test_next_force_close_at_midnight():
|
||||
# 2026-07-05 23:30 -> next 2026-07-06 00:00
|
||||
now = _ms(2026, 7, 5, 23, 30)
|
||||
assert compute_next_force_close_at_ms(bj_hour=0, now_ms=now, tz_name="Asia/Shanghai") == _ms(
|
||||
2026, 7, 6, 0, 0
|
||||
)
|
||||
|
||||
|
||||
def test_next_force_close_same_day_before_hour():
|
||||
now = _ms(2026, 7, 6, 15, 0)
|
||||
assert compute_next_force_close_at_ms(bj_hour=0, now_ms=now, tz_name="Asia/Shanghai") == _ms(
|
||||
2026, 7, 7, 0, 0
|
||||
)
|
||||
|
||||
|
||||
def test_active_hour_and_countdown():
|
||||
now = _ms(2026, 7, 6, 0, 15)
|
||||
assert is_force_close_active_hour(0, now_ms=now, tz_name="Asia/Shanghai")
|
||||
state = build_force_close_state(True, 0, now_ms=now, tz_name="Asia/Shanghai")
|
||||
assert state["enabled"] is True
|
||||
assert state["active"] is True
|
||||
assert state["countdown"] == "执行中"
|
||||
assert state["remaining_sec"] == 0
|
||||
|
||||
|
||||
def test_disabled_state():
|
||||
state = build_force_close_state(False, 0)
|
||||
assert state["enabled"] is False
|
||||
assert state["next_at_ms"] is None
|
||||
|
||||
|
||||
def test_format_countdown():
|
||||
assert format_force_close_countdown(3661) == "01:01:01"
|
||||
assert format_force_close_countdown(0, active=True) == "执行中"
|
||||
Reference in New Issue
Block a user