前端面板修改

This commit is contained in:
dekun
2026-05-17 08:42:50 +08:00
parent eb32ec70b5
commit ff62666c4d
11 changed files with 1229 additions and 183 deletions
+28 -3
View File
@@ -71,18 +71,43 @@ BINANCE_TRIGGER_WORKING_TYPE=CONTRACT_PRICE
# 企业微信推送里展示的账户备注
# BINANCE_ACCOUNT_LABEL=binance实盘账户
# 关键位监控:5m收线突破过滤参数
# =============================================================================
# 关键位门控(页面「关键位监控」规则条与 _key_hard_checks 共用)
# =============================================================================
# 【周期】门控 K 线周期,如 5m、15m;仅影响关键位硬条件,不改变顶栏分区
KLINE_TIMEFRAME=5m
KEY_BREAKOUT_LIMIT_PCT=1.5
# 关键位自动单:计划 RR 阈值(严格大于该值才开仓,按确认K收盘 E 计算)
# 【确认K】闭合 K 序列中的棒偏移:突破棒默认 -2(倒数第2根),确认棒默认 -1(倒数第1根)
KEY_CONFIRM_BREAKOUT_BAR=-2
KEY_CONFIRM_BAR=-1
# 【量能】突破棒成交量 > 前 N 根均量 × 倍数(默认 N=20,倍数=1.3 即放大 30%
KEY_VOLUME_MA_BARS=20
KEY_VOLUME_RATIO_MIN=1.3
# 【突破K实体幅度】占开盘价百分比区间(须同时满足有效突破)
KEY_BREAKOUT_AMP_MIN_PCT=0.03
KEY_BREAKOUT_AMP_MAX_PCT=0.5
# 【日成交量排名】品种须在该排名前 N 名(添加关键位与运行时门控均校验)
KEY_DAILY_VOLUME_RANK_MAX=30
# 【关键位自动开仓盈亏比】按确认K收盘 E 计算,严格大于该值才市价开仓(如 1.5 表示须 >1.5:1
KEY_AUTO_MIN_PLANNED_RR=1.5
# 止损:突破 K 极值向外缓冲的百分比(默认 0.5 即 0.5%)
KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5
KEY_ALERT_MAX_TIMES=3
KEY_ALERT_INTERVAL_MINUTES=5
# =============================================================================
# 交易执行 / 人工风控(页面「实盘下单」)
# =============================================================================
# 【最大同时持仓】active 订单数达到该值后禁止人工与关键位自动再加仓(默认 1=单仓)
MAX_ACTIVE_POSITIONS=1
# 【人工下单最低盈亏比】按当前价与 SL/TP 计算,低于该值前后端均拒绝(默认 1.4,即须 >=1.4:1
MANUAL_MIN_PLANNED_RR=1.4
# 【关键位连开计仓】true=已有持仓时关键位自动单仍按「无仓时」资金快照算保证金基数
KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true
# 资金与仓位刷新周期(秒)
BALANCE_REFRESH_SECONDS=60
# 前端价格快照轮询(秒)
PRICE_REFRESH_SECONDS=5
# 后台监控轮询周期(秒)
MONITOR_POLL_SECONDS=3
# 使用可用资金时的缓冲比例(如0.98代表用98%)
+137 -38
View File
@@ -123,9 +123,18 @@ BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60"))
PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5"))
KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3"))
KEY_ALERT_INTERVAL_MINUTES = int(os.getenv("KEY_ALERT_INTERVAL_MINUTES", "5"))
KEY_BREAKOUT_LIMIT_PCT = float(os.getenv("KEY_BREAKOUT_LIMIT_PCT", "1.5"))
KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5"))
KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5"))
MANUAL_MIN_PLANNED_RR = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4"))
MAX_ACTIVE_POSITIONS = max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1")))
KEY_VOLUME_MA_BARS = max(1, int(os.getenv("KEY_VOLUME_MA_BARS", "20")))
KEY_VOLUME_RATIO_MIN = float(os.getenv("KEY_VOLUME_RATIO_MIN", "1.3"))
KEY_BREAKOUT_AMP_MIN_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MIN_PCT", "0.03"))
KEY_BREAKOUT_AMP_MAX_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MAX_PCT", "0.5"))
KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30")))
KEY_CONFIRM_BREAKOUT_BAR = int(os.getenv("KEY_CONFIRM_BREAKOUT_BAR", "-2"))
KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR", "-1"))
KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() == "true"
ORDER_MONITOR_TYPE_MANUAL = "下单监控"
ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控"
KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"})
@@ -1223,6 +1232,10 @@ def init_db():
try:
c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5")
except: pass
try:
c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL")
except Exception:
pass
c.execute(
"""CREATE TABLE IF NOT EXISTS key_monitor_history
@@ -2302,13 +2315,64 @@ def trading_day_reset_allows_new_open(now):
return now.hour >= TRADING_DAY_RESET_HOUR
def get_active_position_count(conn):
return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0])
def clear_key_sizing_snapshot_if_flat(conn, session_date):
if get_active_position_count(conn) > 0:
return
conn.execute(
"UPDATE trading_sessions SET key_sizing_capital_snapshot = NULL, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?",
(session_date,),
)
conn.commit()
def get_key_sizing_capital_snapshot(conn, session_date):
row = ensure_session(conn, session_date)
try:
val = row["key_sizing_capital_snapshot"]
except (KeyError, IndexError):
return None
if val is None:
return None
try:
return float(val)
except (TypeError, ValueError):
return None
def set_key_sizing_capital_snapshot(conn, session_date, capital):
ensure_session(conn, session_date)
conn.execute(
"UPDATE trading_sessions SET key_sizing_capital_snapshot = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?",
(round(float(capital), FUNDS_DECIMALS), session_date),
)
conn.commit()
def resolve_capital_base_for_key_open(conn, trading_day, live_capital):
"""关键位自动开仓:有仓时用无仓时资金快照计仓(可配置)。"""
live = float(live_capital)
active = get_active_position_count(conn)
if active <= 0:
set_key_sizing_capital_snapshot(conn, trading_day, live)
return live
if KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT:
snap = get_key_sizing_capital_snapshot(conn, trading_day)
if snap is not None and snap > 0:
return snap
return live
def precheck_risk(conn, symbol, direction):
now = app_now()
if not trading_day_reset_allows_new_open(now):
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]
if active_count > 0:
return False, "一次只能持有一个仓位"
active_count = get_active_position_count(conn)
if active_count >= MAX_ACTIVE_POSITIONS:
return False, f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS}"
if direction not in ("long", "short"):
return False, "方向必须为 long 或 short"
if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"):
@@ -3121,6 +3185,7 @@ def reconcile_external_closes(conn, days=None):
closed_at=closed_at,
)
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],))
clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day())
if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"):
send_wechat_msg(
build_wechat_close_message(
@@ -3287,21 +3352,26 @@ def _key_hard_checks(symbol, direction, upper, lower, monitor_type):
out["reason"] = "5m K线数量不足"
return out
closed = bars[:-1] if len(bars) >= 3 else bars
if len(closed) < 23:
out["reason"] = "闭合K线不足"
min_closed = KEY_VOLUME_MA_BARS + 3
if len(closed) < min_closed:
out["reason"] = f"{KLINE_TIMEFRAME} 闭合K线不足"
return out
breakout = closed[-2]
confirm = closed[-1]
prev20 = closed[-22:-2]
avg20 = sum(float(x[5]) for x in prev20) / max(len(prev20), 1)
try:
breakout = closed[KEY_CONFIRM_BREAKOUT_BAR]
confirm = closed[KEY_CONFIRM_BAR]
except IndexError:
out["reason"] = "确认K索引超出范围,请检查 KEY_CONFIRM_* 配置"
return out
prev_vol = closed[KEY_CONFIRM_BREAKOUT_BAR - KEY_VOLUME_MA_BARS : KEY_CONFIRM_BREAKOUT_BAR]
avg20 = sum(float(x[5]) for x in prev_vol) / max(len(prev_vol), 1)
vol_break = float(breakout[5])
vol_ok = vol_break > avg20 * 1.3 if avg20 > 0 else False
vol_ok = vol_break > avg20 * KEY_VOLUME_RATIO_MIN if avg20 > 0 else False
open_b = float(breakout[1])
close_b = float(breakout[4])
high_b = float(breakout[2])
low_b = float(breakout[3])
amp_pct = abs(close_b - open_b) / open_b * 100 if open_b > 0 else 0
amp_ok = (amp_pct > 0.03) and (amp_pct < 0.5)
amp_ok = (amp_pct > KEY_BREAKOUT_AMP_MIN_PCT) and (amp_pct < KEY_BREAKOUT_AMP_MAX_PCT)
cfm_close = float(confirm[4])
# 区间极值点严格以前端录入 upper/lower 为准:做多看上沿,做空看下沿
edge = float(upper) if direction == "long" else float(lower)
@@ -3311,7 +3381,7 @@ def _key_hard_checks(symbol, direction, upper, lower, monitor_type):
amp_ok = amp_ok and breakout_ok
confirm_ok = confirm_ok_raw and breakout_ok
rank, total = _daily_volume_rank(symbol)
rank_ok = (rank is not None) and (rank <= 30)
rank_ok = (rank is not None) and (rank <= KEY_DAILY_VOLUME_RANK_MAX)
swing4h_pct = 0.0
try:
seg48 = closed[-48:] if len(closed) >= 48 else closed
@@ -3424,7 +3494,8 @@ def _market_open_for_key_monitor(conn, symbol, direction, exchange_symbol, stop_
).fetchone()[0]
session_row = ensure_session(conn, trading_day)
_, trading_capital_live = get_exchange_capitals(force=True)
capital_base = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"])
live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"])
capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital)
trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower()
if trade_style not in ("trend", "swing"):
@@ -3990,6 +4061,7 @@ def check_order_monitors():
closed_at=closed_at,
)
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, pid))
clear_key_sizing_snapshot_if_flat(conn, get_trading_day())
conn.commit()
conn.close()
@@ -4198,7 +4270,12 @@ def render_main_page(page="trade"):
)
rate = round(win/total*100,2) if total else 0
active_count = len(order_list)
can_trade = trading_day_reset_allows_new_open(now) and active_count == 0
can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS
key_gate_rule_text = (
f"周期 {KLINE_TIMEFRAME}|确认K:突破棒偏移 {KEY_CONFIRM_BREAKOUT_BAR}、确认棒偏移 {KEY_CONFIRM_BAR}"
f"量能:突破量 > 前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}"
f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}"
)
conn.close()
return render_template(
"index.html",
@@ -4242,6 +4319,11 @@ def render_main_page(page="trade"):
entry_reason_options=list(ENTRY_REASON_OPTIONS),
entry_reason_other_value=ENTRY_REASON_OTHER,
exchange_display=EXCHANGE_DISPLAY_NAME,
max_active_positions=MAX_ACTIVE_POSITIONS,
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR,
key_gate_rule_text=key_gate_rule_text,
kline_timeframe=KLINE_TIMEFRAME,
)
@@ -4251,6 +4333,12 @@ def index():
return redirect("/trade")
@app.route("/key_monitor")
@login_required
def key_monitor_page():
return render_main_page("key_monitor")
@app.route("/trade")
@login_required
def trade_page():
@@ -4281,9 +4369,9 @@ def api_account_snapshot():
funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None
current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS)
recommended_capital = get_recommended_capital(current_capital)
active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]
active_count = get_active_position_count(conn)
conn.close()
can_trade = trading_day_reset_allows_new_open(now) and active_count == 0
can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS
available_trading_usdt = get_available_trading_usdt()
return jsonify({
"funding_usdt": funding_usdt,
@@ -4291,7 +4379,9 @@ def api_account_snapshot():
"available_trading_usdt": round(available_trading_usdt, FUNDS_DECIMALS) if available_trading_usdt is not None else None,
"recommended_capital": recommended_capital,
"active_count": active_count,
"max_active_positions": MAX_ACTIVE_POSITIONS,
"can_trade": can_trade,
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
"trading_day": trading_day
})
@@ -4451,7 +4541,8 @@ def api_symbol_liquidity_rank():
"symbol": symbol,
"rank": int(rank),
"total": int(total),
"in_top30": bool(rank <= 30),
"in_top30": bool(rank <= KEY_DAILY_VOLUME_RANK_MAX),
"rank_max": KEY_DAILY_VOLUME_RANK_MAX,
}
)
@@ -4468,13 +4559,15 @@ def api_order_defaults():
exchange_symbol = normalize_exchange_symbol(symbol)
leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol)
available = get_available_trading_usdt()
last_price = get_price(symbol)
return jsonify({
"ok": True,
"symbol": symbol,
"exchange_symbol": exchange_symbol,
"direction": direction,
"leverage": leverage,
"available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None
"available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None,
"last_price": round(float(last_price), 8) if last_price is not None else None,
})
@@ -4709,34 +4802,33 @@ def add_key():
symbol = normalize_symbol_input(d.get("symbol"))
if not symbol:
flash("symbol 不能为空")
return redirect("/")
return redirect("/key_monitor")
direction_sel = (d.get("direction") or "").strip().lower()
if direction_sel not in ("long", "short"):
flash("请选择做多或做空")
return redirect("/")
return redirect("/key_monitor")
mt = (d.get("type") or "").strip()
allowed_types = tuple(KEY_MONITOR_AUTO_TYPES) + tuple(KEY_MONITOR_ALERT_ONLY_TYPES)
if mt not in allowed_types:
flash("监控类型无效,请选择:箱体突破、收敛突破、关键阻力位、关键支撑位")
return redirect("/")
return redirect("/key_monitor")
rank, total = _daily_volume_rank(symbol)
if rank is None:
flash("日成交量排名读取失败,请稍后重试")
return redirect("/")
if rank > 30:
flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前30,已拒绝添加关键位")
return redirect("/")
return redirect("/key_monitor")
if rank > KEY_DAILY_VOLUME_RANK_MAX:
flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位")
return redirect("/key_monitor")
conn = get_db()
if mt in KEY_MONITOR_AUTO_TYPES:
occupied = conn.execute(
"SELECT COUNT(*) FROM order_monitors WHERE status='active'",
).fetchone()[0]
if occupied and int(occupied) > 0:
occupied = get_active_position_count(conn)
if occupied >= MAX_ACTIVE_POSITIONS:
conn.close()
flash(
"当前已有实盘持仓:无法添加「箱体突破 / 收敛突破」(会自动开仓)。请平仓后再试,或使用「关键阻力位/关键支撑位」(仅单次提醒)"
f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」"
"请平仓后再试,或使用「关键阻力位/关键支撑位」(仅单次提醒)。"
)
return redirect("/")
return redirect("/key_monitor")
ex_sym_key = normalize_exchange_symbol(symbol)
try:
ensure_markets_loaded()
@@ -4765,7 +4857,7 @@ def add_key():
flash(
"⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。"
)
return redirect("/")
return redirect("/key_monitor")
@app.route("/add_order", methods=["POST"])
@login_required
@@ -4781,7 +4873,7 @@ def add_order():
return redirect("/")
ok, reason = precheck_risk(conn, symbol, direction)
if not ok:
if "一次只能持有一个仓位" in reason:
if "已达最大持仓数" in reason:
try:
tp_raw = parse_positive_float(d.get("tp"))
sl_raw = parse_positive_float(d.get("sl"))
@@ -4797,14 +4889,14 @@ def add_order():
stop_loss=sl_raw or 0,
take_profit=tgt_raw or 0,
result="错过",
miss_reason="持仓占用:一次只能持有一个仓位",
miss_reason=f"持仓占用:{reason}",
opened_at=app_now_str(),
closed_at=app_now_str(),
)
conn.commit()
conn.close()
flash(f"风控拒绝下单:{reason}")
return redirect("/")
return redirect("/trade")
ok_live, reason_live = ensure_exchange_live_ready()
if not ok_live:
conn.close()
@@ -4873,7 +4965,13 @@ def add_order():
if stop_loss <= 0 or take_profit <= 0:
conn.close()
flash("价格参数必须大于0")
return redirect("/")
return redirect("/trade")
planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit)
if planned_rr_manual is None or planned_rr_manual < MANUAL_MIN_PLANNED_RR:
conn.close()
rr_txt = f"{planned_rr_manual:.4f}" if planned_rr_manual is not None else "无法计算"
flash(f"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1")
return redirect("/trade")
risk_fraction = calc_risk_fraction(direction, live_price, stop_loss)
if risk_fraction is None:
conn.close()
@@ -5329,6 +5427,7 @@ def del_order(id):
closed_at=closed_at,
)
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id))
clear_key_sizing_snapshot_if_flat(conn, session_date)
conn.commit()
conn.close()
send_wechat_msg(
@@ -5348,7 +5447,7 @@ def del_order(id):
)
)
flash("已按实盘流程手动平仓")
return redirect("/")
return redirect("/trade")
except Exception as e:
if is_no_position_error(str(e)):
cancel_binance_futures_open_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]))
@@ -0,0 +1,2 @@
{% if page == 'key_monitor' %}
<motion class="dual-panel-grid" style="grid-column:1/-1">
@@ -0,0 +1,358 @@
# -*- coding: utf-8 -*-
"""Patch index.html layout for key_monitor / trade split."""
from pathlib import Path
import re
TAG = "div"
PATHS = [
Path(__file__).resolve().parent.parent / "templates" / "index.html",
Path(r"c:\Users\dekun\Desktop\crypto_monitor\crypto_monitor_gate\templates\index.html"),
]
KEY_START = " {% if page == 'key_monitor' %}"
KEY_START_ALT = " {% if page == 'trade' %}"
RECORDS_START = " {% if page == 'records' %}"
def build_section(order_loop: str) -> str:
t = TAG
return f""" {{% if page == 'key_monitor' %}}
<{t} class="dual-panel-grid" style="grid-column:1/-1">
<{t} class="card">
<{t} style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<h2 style="margin-bottom:0">关键位监控</h2>
{{% if focus_key_id %}}
<a href="/key_focus?key_id={{{{ focus_key_id }}}}" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">放大查看K线(默认200根)</a>
{{% else %}}
<a href="/key_focus" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">输入币种查看K线</a>
{{% endif %}}
</{t}>
<form id="key-form" action="/add_key" method="post" class="form-row">
<input name="symbol" placeholder="BTC 或 BTC/USDT" required>
<select name="type" required>
<option value="箱体突破">箱体突破</option>
<option value="收敛突破">收敛突破</option>
<option value="关键阻力位">关键阻力位</option>
<option value="关键支撑位">关键支撑位</option>
</select>
<select name="direction" required>
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
</select>
<input name="upper" step="0.0001" placeholder="上沿/阻力" required>
<input name="lower" step="0.0001" placeholder="下沿/支撑" required>
<button type="submit">添加</button>
</form>
<{t} class="rule-tip">{{{{ key_gate_rule_text }}}}</{t}>
<{t} class="panel-scroll pos-list">
{{% for k in key %}}
<{t} class="pos-card" id="key-row-{{{{ k.id }}}}">
<{t} class="pos-card-head">
<{t} class="pos-card-symbol">
<strong>{{{{ k.symbol }}}}</strong>
<span class="pos-side-badge {{{{ 'pos-side-long' if k.direction == 'long' else 'pos-side-short' }}}}">{{{{ '做多' if k.direction == 'long' else '做空' }}}}</span>
<span class="badge direction" style="margin-left:4px">{{{{ k.monitor_type }}}}</span>
</{t}>
<button type="button" class="pos-close-btn" style="border:none;cursor:pointer" onclick="deleteKeyMonitor({{{{ k.id }}}})">删</button>
</{t}>
<{t} class="pos-meta">
<span class="pos-meta-item">上沿: {{{{ k.upper }}}}</span>
<span class="pos-meta-item">下沿: {{{{ k.lower }}}}</span>
<span class="pos-meta-item">已提醒: {{{{ k.notification_count or 0 }}}}/{{{{ k.max_notify or 3 }}}}</span>
</{t}>
<{t} class="pos-grid">
<{t} class="pos-cell"><span class="pos-label">现价</span><span class="pos-value" id="key-price-{{{{ k.id }}}}">-</span></{t}>
<{t} class="pos-cell"><span class="pos-label">距上沿</span><span class="pos-value" id="key-up-diff-{{{{ k.id }}}}">-</span></{t}>
<{t} class="pos-cell"><span class="pos-label">距下沿</span><span class="pos-value" id="key-low-diff-{{{{ k.id }}}}">-</span></{t}>
<{t} class="pos-cell"><span class="pos-label">门控</span><span class="pos-value" id="key-gate-{{{{ k.id }}}}" style="color:#9aa">-</span></{t}>
</{t}>
<{t} class="pos-meta" style="margin-top:8px"><span class="pos-meta-item" id="key-gate-metrics-{{{{ k.id }}}}" style="color:#8fc8ff"></span></{t}>
</{t}>
{{% else %}}
<{t} class="pos-empty">暂无监控中的关键位</{t}>
{{% endfor %}}
</{t}>
</{t}>
<{t} class="card">
<h2 style="margin-bottom:8px">关键位历史</h2>
<{t} class="sub" style="font-size:.72rem;color:#8892b0;margin-bottom:8px">失效或已结案的关键位</{t}>
<{t} class="panel-scroll pos-list">
{{% for h in key_history %}}
<{t} class="pos-card">
<{t} class="pos-card-head">
<{t} class="pos-card-symbol">
<strong>{{{{ h.symbol }}}}</strong>
<span class="pos-side-badge {{{{ 'pos-side-long' if h.direction == 'long' else 'pos-side-short' }}}}">{{{{ '做多' if h.direction == 'long' else '做空' }}}}</span>
</{t}>
<button type="button" class="table-del" onclick="deleteKeyHistory({{{{ h.id }}}})">删除</button>
</{t}>
<{t} class="pos-meta">
<span class="pos-meta-item">{{{{ h.monitor_type }}}}</span>
<span class="pos-meta-item">{{{{ h.close_reason }}}}</span>
<span class="pos-meta-item">{{{{ (h.closed_at or '-')[:16] }}}}</span>
</{t}>
<{t} class="pos-meta">
<span class="pos-meta-item">上: {{{{ h.upper }}}} 下: {{{{ h.lower }}}}</span>
<span class="pos-meta-item">提醒: {{{{ h.notification_count }}}}</span>
</{t}>
{{% if h.last_alert_message %}}<{t} style="font-size:.75rem;color:#aab;margin-top:6px;white-space:pre-wrap">{{{{ h.last_alert_message[:180] }}}}{{% if h.last_alert_message|length > 180 %}}{{% endif %}}</{t}>{{% endif %}}
</{t}>
{{% else %}}
<{t} class="pos-empty">暂无历史</{t}>
{{% endfor %}}
</{t}>
</{t}>
</{t}>
{{% elif page == 'trade' %}}
<{t} class="dual-panel-grid" style="grid-column:1/-1">
<{t} class="card">
<{t} style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<h2 style="margin-bottom:0">实盘下单监控</h2>
{{% if focus_order_id %}}
<a href="/order_focus?order_id={{{{ focus_order_id }}}}" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">放大查看K线(100根)</a>
{{% else %}}
<span class="btn-del" style="background:#2f2f44;color:#9aa;cursor:not-allowed">暂无持仓可放大</span>
{{% endif %}}
</{t}>
<{t} class="rule-tip" id="order-rule-tip">
规则:最多 {{{{ max_active_positions }}}} 仓;BTC {{{{ btc_leverage }}}}x / 山寨 {{{{ alt_leverage }}}}x
{{% if can_trade %}}可开仓{{% else %}}不可开仓(持仓已满或未到北京时间 {{{{ reset_hour }}}}:00{{% endif %}}
人工开仓盈亏比不得低于 {{{{ manual_min_planned_rr }}}}:1
</{t}>
<{t} class="rule-tip">
以损定仓:风险 {{{{ risk_percent }}}}% |移动保本:下单可勾选关闭;开启时 {{{{ breakeven_rr_trigger }}}}R 触发(每 1R 阶梯上移),偏移 {{{{ breakeven_offset_pct }}}}%
</{t}>
<{t} class="rule-tip">
划转:自动划转 {{{{ '开启' if auto_transfer_enabled else '关闭' }}}}(每天<strong>北京时间 {{{{ auto_transfer_bj_hour }}}}:00</strong>起该整点小时内尝试;账簿按 <strong>UTC 自然日</strong>去重;界面时间为北京;将 {{{{ auto_transfer_to }}}} 补足到 {{{{ auto_transfer_amount }}}}U,来自 {{{{ auto_transfer_from }}}}
</{t}>
<form action="/manual_transfer" method="post" class="form-row">
<input name="amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额U" required>
<select name="from_account">
<option value="funding" {{% if auto_transfer_from == 'funding' %}}selected{{% endif %}}>from: funding</option>
<option value="swap" {{% if auto_transfer_from == 'swap' %}}selected{{% endif %}}>from: swap</option>
<option value="spot" {{% if auto_transfer_from == 'spot' %}}selected{{% endif %}}>from: spot</option>
</select>
<select name="to_account">
<option value="swap" {{% if auto_transfer_to == 'swap' %}}selected{{% endif %}}>to: swap</option>
<option value="funding" {{% if auto_transfer_to == 'funding' %}}selected{{% endif %}}>to: funding</option>
<option value="spot" {{% if auto_transfer_to == 'spot' %}}selected{{% endif %}}>to: spot</option>
</select>
<button type="submit">手动划转</button>
</form>
<form id="add-order-form" action="/add_order" method="post" class="form-row">
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
<select id="order-direction" name="direction" required>
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
</select>
<select id="sltp-mode" name="sltp_mode">
<option value="price">止盈止损:价格模式</option>
<option value="pct">止盈止损:百分比模式</option>
</select>
<select name="trade_style" required>
<option value="trend">趋势单</option>
<option value="swing">波段单</option>
</select>
<input id="order-leverage" name="leverage" type="number" min="1" step="1" placeholder="杠杆(可选)">
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="breakeven_enabled" value="1" checked> 启用移动保本(关闭则仅保留初始止损与交易所挂单)
</label>
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="order_chart" value="true"> 开仓后生成多周期K线图(各周期100根,含开平仓标记)
</label>
<span style="display:flex;align-items:center;padding:0 10px;font-size:.8rem;color:#8fc8ff">成交价自动取交易所实时+成交回报</span>
<input id="order-sl" name="sl" step="any" placeholder="止损价格" required>
<input id="order-tp" name="tgt" step="any" placeholder="止盈价格" required>
<input id="order-sl-pct" name="sl_pct" type="number" min="0.01" step="0.01" placeholder="止损%" style="display:none">
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
<button type="submit">开仓(以损定仓)</button>
</form>
</{t}>
<{t} class="card">
<h2 style="margin-bottom:8px">实时持仓</h2>
<{t} class="panel-scroll pos-list">
{order_loop}
</{t}>
</{t}>
</{t}>
{{% endif %}}
"""
def patch_nav(text: str) -> str:
old = '<a href="/trade" class="{% if page == \'trade\' %}active{% endif %}">交易执行</a>'
new = (
'<a href="/key_monitor" class="{% if page == \'key_monitor\' %}active{% endif %}">关键位监控</a>\n'
' <a href="/trade" class="{% if page == \'trade\' %}active{% endif %}">实盘下单</a>'
)
if "关键位监控" not in text:
text = text.replace(old, new)
return text
def patch_js(text: str) -> str:
# page id on body
if 'id="page-trade"' not in text:
text = text.replace("<body>", '<body data-page="{{ page }}">', 1)
if "MANUAL_MIN_PLANNED_RR" not in text:
insert = """
const MANUAL_MIN_PLANNED_RR = {{ manual_min_planned_rr }};
function calcClientRr(direction, entry, sl, tp){
const e = Number(entry), s = Number(sl), t = Number(tp);
if(!Number.isFinite(e) || !Number.isFinite(s) || !Number.isFinite(t)) return null;
if(direction === 'short'){
if(s <= e || t >= e) return null;
return (e - t) / (s - e);
}
if(s >= e || t <= e) return null;
return (t - e) / (e - s);
}
"""
text = text.replace("let latestAvailableUsdt = null;", insert + "\nlet latestAvailableUsdt = null;")
if "add-order-form" not in text or "calcClientRr" in text and "addOrderForm" not in text:
hook = """
const addOrderForm = document.getElementById("add-order-form");
if(addOrderForm){
addOrderForm.addEventListener("submit", function(ev){
const direction = (document.getElementById("order-direction")||{}).value || "long";
const mode = (document.getElementById("sltp-mode")||{}).value || "price";
let sl, tp, entry;
if(mode === "pct"){
alert("百分比模式请确认盈亏比后再提交;建议使用价格模式以便校验。");
return;
}
sl = Number((document.getElementById("order-sl")||{}).value);
tp = Number((document.getElementById("order-tp")||{}).value);
entry = sl;
fetch(`/api/order_defaults?symbol=${encodeURIComponent((document.getElementById("order-symbol")||{}).value||"")}&direction=${encodeURIComponent(direction)}`)
.then(r=>r.json())
.then(data=>{
const px = data.last_price || data.price;
if(px) entry = Number(px);
const rr = calcClientRr(direction, entry, sl, tp);
if(rr === null || rr < MANUAL_MIN_PLANNED_RR){
alert(`计划盈亏比 ${rr === null ? '无效' : rr.toFixed(2)}:1 低于最低要求 ${MANUAL_MIN_PLANNED_RR}:1,已阻止人工下单。`);
return;
}
addOrderForm.submit();
})
.catch(()=>{ ev.preventDefault(); alert("无法校验盈亏比,请稍后重试"); });
ev.preventDefault();
});
}
"""
text = text.replace("refreshOrderDefaults();", hook + "\nrefreshOrderDefaults();")
if "max_active_positions" not in text and "order-rule-tip" in text:
text = text.replace(
"规则:单仓;",
"规则:最多 {{ max_active_positions }} 仓;",
)
# account snapshot tip
old_tip = '`规则:单仓;BTC {{ btc_leverage }}x'
if old_tip in text:
text = text.replace(
old_tip,
"`规则:最多 ${data.max_active_positions || {{ max_active_positions }}} 仓;BTC {{ btc_leverage }}x",
)
text = text.replace(
'const canTradeText = data.can_trade ? "可开仓" : "不可开仓(有持仓或未到北京时间 {{ reset_hour }}:00";',
'const canTradeText = data.can_trade ? "可开仓" : `不可开仓(持仓 ${data.active_count||0}/${data.max_active_positions||{{ max_active_positions }}} 或未到北京时间 {{ reset_hour }}:00`;',
)
text = text.replace(
"if(!data.in_top30){",
"const rankMax = data.rank_max || 30;\n if(!data.in_top30){",
)
text = text.replace(
"不在前30,已拦截",
"不在前${rankMax},已拦截",
)
# conditional price refresh
if "data-page" in text and "refreshPriceSnapshotConditional" not in text:
text = text.replace(
"setInterval(refreshPriceSnapshot, {{ price_refresh_seconds * 1000 }});",
"""function refreshPriceSnapshotConditional(){
const page = document.body.getAttribute("data-page") || "";
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(page === "key_monitor"){
(data.key_prices || []).forEach(k=>{
const pEl = document.getElementById(`key-price-${k.id}`);
if(pEl){ pEl.innerText = k.price_display || (Number.isFinite(Number(k.price)) ? Number(k.price).toFixed(6) : "-"); paintPriceTrend(pEl, `k-${k.id}`, Number(k.price)); }
const upEl = document.getElementById(`key-up-diff-${k.id}`);
if(upEl) upEl.innerText = `${formatSigned(k.upper_diff, 4)} (${formatSigned(k.upper_pct, 2)}%)`;
const lowEl = document.getElementById(`key-low-diff-${k.id}`);
if(lowEl) lowEl.innerText = `${formatSigned(k.lower_diff, 4)} (${formatSigned(k.lower_pct, 2)}%)`;
const gateEl = document.getElementById(`key-gate-${k.id}`);
if(gateEl){ gateEl.innerText = k.gate_summary || "-"; gateEl.style.color = k.gate_ok ? "#4cd97f" : "#ff8f8f"; }
const gateMetricEl = document.getElementById(`key-gate-metrics-${k.id}`);
if(gateMetricEl) gateMetricEl.innerText = k.gate_metrics || "";
});
}
if(page === "trade"){
(data.order_prices || []).forEach(o=>{
const pEl = document.getElementById(`order-price-${o.id}`);
if(pEl){
const hasMark = (()=>{ const x = o.exchange_mark_price; if(x===null||x===undefined||x==="")return false; const n=Number(x); return !Number.isNaN(n); })();
let disp = "";
if(hasMark && o.exchange_mark_price_display) disp = o.exchange_mark_price_display;
else if(o.price_display) disp = o.price_display;
else { const px = hasMark ? Number(o.exchange_mark_price) : Number(o.price); disp = Number.isFinite(px) ? px.toFixed(6) : "-"; }
pEl.innerText = disp;
const pxNum = hasMark ? Number(o.exchange_mark_price) : Number(o.price);
paintPriceTrend(pEl, `o-${o.id}`, Number.isFinite(pxNum) ? pxNum : px);
}
const exM = document.getElementById(`order-ex-margin-${o.id}`);
if(exM){
const mv = o.exchange_initial_margin;
const mn = (mv === null || mv === undefined || mv === "") ? NaN : Number(mv);
if(!Number.isNaN(mn)) exM.innerText = `${mn.toFixed(2)}U`;
else { const prc = (typeof data.positions_raw_count === "number") ? data.positions_raw_count : null; exM.innerText = (prc === 0) ? "无仓数据" : "-"; }
}
const pnlEl = document.getElementById(`order-pnl-${o.id}`);
if(pnlEl){
pnlEl.innerText = `${formatSigned(o.float_pnl, 2)}U (${formatSigned(o.float_pct, 2)}%)`;
pnlEl.classList.remove("price-up","price-down","price-flat");
if(Number(o.float_pnl) > 0) pnlEl.classList.add("price-up");
else if(Number(o.float_pnl) < 0) pnlEl.classList.add("price-down");
else pnlEl.classList.add("price-flat");
}
const rrEl = document.getElementById(`order-rr-${o.id}`);
if(rrEl) rrEl.innerText = formatRrRatio(o.rr_ratio);
});
}
}).catch(()=>{});
}
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});""",
)
return text
def main():
for path in PATHS:
if not path.exists():
print("skip", path)
continue
text = path.read_text(encoding="utf-8")
start = text.find(KEY_START)
if start < 0:
start = text.find(KEY_START_ALT)
end = text.find(RECORDS_START)
if start < 0 or end < 0:
raise SystemExit(f"markers not found: {path}")
old = text[start:end]
m = re.search(r"(\{% for o in order %\}.*?\{% endfor %\})", old, re.S)
if not m:
raise SystemExit(f"order loop not found: {path}")
order_loop = m.group(1)
section = build_section(order_loop)
section = section.replace("{{%", "{%").replace("%}}", "%}").replace("{{{{", "{{").replace("}}}}", "}}")
out = text[:start] + section + "\n\n" + text[end:]
out = patch_nav(out)
out = patch_js(out)
path.write_text(out, encoding="utf-8")
print("patched", path)
if __name__ == "__main__":
main()
@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
"""Apply binance app.py risk/layout changes to gate app.py (pattern replace)."""
from pathlib import Path
binance = Path(__file__).resolve().parent.parent / "app.py"
gate = Path(r"c:\Users\dekun\Desktop\crypto_monitor\crypto_monitor_gate\app.py")
b = binance.read_text(encoding="utf-8")
g = gate.read_text(encoding="utf-8")
# 1) env block
old_env = """KEY_BREAKOUT_LIMIT_PCT = float(os.getenv("KEY_BREAKOUT_LIMIT_PCT", "1.5"))
KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5"))
KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5"))"""
new_env = """KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5"))
KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5"))
MANUAL_MIN_PLANNED_RR = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4"))
MAX_ACTIVE_POSITIONS = max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1")))
KEY_VOLUME_MA_BARS = max(1, int(os.getenv("KEY_VOLUME_MA_BARS", "20")))
KEY_VOLUME_RATIO_MIN = float(os.getenv("KEY_VOLUME_RATIO_MIN", "1.3"))
KEY_BREAKOUT_AMP_MIN_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MIN_PCT", "0.03"))
KEY_BREAKOUT_AMP_MAX_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MAX_PCT", "0.5"))
KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30")))
KEY_CONFIRM_BREAKOUT_BAR = int(os.getenv("KEY_CONFIRM_BREAKOUT_BAR", "-2"))
KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR", "-1"))
KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() == "true")"""
if old_env in g:
g = g.replace(old_env, new_env)
# 2) DB migration snippet
snip = """ try:
c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL")
except Exception:
pass
c.execute("""
if snip not in g and 'key_sizing_capital_snapshot' not in g:
g = g.replace(
' c.execute(\n """CREATE TABLE IF NOT EXISTS key_monitor_history',
""" try:
c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL")
except Exception:
pass
c.execute(
\"\"\"CREATE TABLE IF NOT EXISTS key_monitor_history""",
1,
)
# 3) precheck block - extract from binance
import re
m = re.search(
r"def get_active_position_count\(conn\):.*?return True, \"\"\n\n\ndef prepare_order_amount",
b,
re.S,
)
if m and "get_active_position_count" not in g:
g = g.replace(
"def precheck_risk(conn, symbol, direction):\n now = app_now()\n if not trading_day_reset_allows_new_open(now):\n return False, f\"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓\"\n active_count = conn.execute(\"SELECT COUNT(*) FROM order_monitors WHERE status='active'\").fetchone()[0]\n if active_count > 0:\n return False, \"一次只能持有一个仓位\"\n if direction not in (\"long\", \"short\"):\n return False, \"方向必须为 long 或 short\"\n if symbol.upper().startswith(\"BTC\") or symbol.upper().startswith(\"ETH\"):\n expected = BTC_LEVERAGE\n else:\n expected = ALT_LEVERAGE\n if expected <= 0:\n return False, \"杠杆配置异常\"\n return True, \"\"\n\n\ndef prepare_order_amount",
m.group(0),
)
# 4) render_main_page can_trade + template vars + route
if "key_monitor_page" not in g:
g = g.replace(
" can_trade = trading_day_reset_allows_new_open(now) and active_count == 0\n conn.close()\n return render_template(",
""" can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS
key_gate_rule_text = (
f"周期 {KLINE_TIMEFRAME}|确认K:突破棒偏移 {KEY_CONFIRM_BREAKOUT_BAR}、确认棒偏移 {KEY_CONFIRM_BAR}"
f"量能:突破量 > 前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}"
f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}"
)
conn.close()
return render_template(""",
)
g = g.replace(
" exchange_display=EXCHANGE_DISPLAY_NAME,\n )\n\n\n@app.route(\"/\")\n@login_required\ndef index():\n return redirect(\"/trade\")\n\n\n@app.route(\"/trade\")",
""" exchange_display=EXCHANGE_DISPLAY_NAME,
max_active_positions=MAX_ACTIVE_POSITIONS,
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR,
key_gate_rule_text=key_gate_rule_text,
kline_timeframe=KLINE_TIMEFRAME,
)
@app.route("/")
@login_required
def index():
return redirect("/trade")
@app.route("/key_monitor")
@login_required
def key_monitor_page():
return render_main_page("key_monitor")
@app.route("/trade")""",
)
# api account
g = g.replace(
" active_count = conn.execute(\"SELECT COUNT(*) FROM order_monitors WHERE status='active'\").fetchone()[0]\n conn.close()\n can_trade = trading_day_reset_allows_new_open(now) and active_count == 0",
" active_count = get_active_position_count(conn)\n conn.close()\n can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS",
)
if '"max_active_positions"' not in g:
g = g.replace(
'"can_trade": can_trade,\n "trading_day": trading_day\n })',
'"can_trade": can_trade,\n "max_active_positions": MAX_ACTIVE_POSITIONS,\n "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,\n "trading_day": trading_day\n })',
)
gate.write_text(g, encoding="utf-8")
print("gate app partially synced; manual review _key_hard_checks add_order still needed")
+185 -50
View File
@@ -81,19 +81,21 @@
.detail-modal .panel-body{white-space:pre-wrap;line-height:1.5;font-size:.86rem;color:#e5e9ff}
.detail-modal .panel-image{margin-top:10px;max-width:min(100%,680px);border-radius:8px;cursor:pointer;border:1px solid #2a3150}
.table-wrap{overflow-x:auto}
.monitor-card{grid-column:1}
.order-card{grid-column:2}
.dual-panel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:stretch}
.dual-panel-grid .card{height:100%;display:flex;flex-direction:column}
.panel-scroll{flex:1;min-height:280px;max-height:420px;overflow:auto}
.records-card{grid-column:1/-1}
.review-card{grid-column:1/-1}
@media (min-width: 1900px){
.container{max-width:2100px}
.monitor-card .list,.order-card .pos-list{max-height:420px}
.panel-scroll,.pos-list{max-height:420px}
.records-card .table-wrap{max-height:620px;overflow:auto}
}
@media (max-width: 1400px){
.container{width:min(99vw,1600px)}
.grid{grid-template-columns:1fr}
.monitor-card,.order-card,.records-card,.review-card{grid-column:auto}
.dual-panel-grid{grid-template-columns:1fr}
.records-card,.review-card{grid-column:auto}
.panel-list{grid-template-columns:1fr}
}
@media (max-width: 960px){
@@ -149,7 +151,7 @@
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
</style>
</head>
<body>
<body data-page="{{ page }}">
{% macro period_stats(title, s) %}
<div class="stats-period-block">
<h3>{{ title }}</h3>
@@ -175,7 +177,8 @@
<div class="exchange-tag">{{ exchange_display }}</div>
</div>
<div class="top-nav">
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}">交易执行</a>
<a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}">关键位监控</a>
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}">实盘下单</a>
<a href="/records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
<a href="/stats" class="{% if page == 'stats' %}active{% endif %}">统计分析</a>
</div>
@@ -200,10 +203,11 @@
<div class="rule-tip">实时价格更新时间:<span id="price-last-updated">--</span>(北京时间 UTC+8</div>
<div class="grid">
{% if page == 'trade' %}
<div class="card monitor-card">
{% if page == 'key_monitor' %}
<div class="dual-panel-grid" style="grid-column:1/-1">
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<h2 style="margin-bottom:0">关键位监控5m</h2>
<h2 style="margin-bottom:0">关键位监控</h2>
{% if focus_key_id %}
<a href="/key_focus?key_id={{ focus_key_id }}" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">放大查看K线(默认200根)</a>
{% else %}
@@ -225,44 +229,69 @@
<input name="lower" step="0.0001" placeholder="下沿/支撑" required>
<button type="submit">添加</button>
</form>
<div class="list">
<div class="rule-tip">{{ key_gate_rule_text }}</div>
<div class="panel-scroll pos-list">
{% for k in key %}
<div class="list-item" id="key-row-{{ k.id }}">
<div><strong>{{ k.symbol }}</strong> | {{ k.monitor_type }} | <span class="badge direction">{{ '做多' if k.direction == 'long' else '做空' }}</span></div>
<div>
上:{{ k.upper }} 下:{{ k.lower }}
| 已提醒:{{ k.notification_count or 0 }}/{{ k.max_notify or 3 }}
| 现价:<span id="key-price-{{ k.id }}">-</span>
| 距上沿:<span id="key-up-diff-{{ k.id }}">-</span>
| 距下沿:<span id="key-low-diff-{{ k.id }}">-</span>
| 门控:<span id="key-gate-{{ k.id }}" style="color:#9aa">-</span>
<span id="key-gate-metrics-{{ k.id }}" style="margin-left:8px;color:#8fc8ff;font-size:.78rem"></span>
<div class="pos-card" id="key-row-{{ k.id }}">
<div class="pos-card-head">
<div class="pos-card-symbol">
<strong>{{ k.symbol }}</strong>
<span class="pos-side-badge {{ 'pos-side-long' if k.direction == 'long' else 'pos-side-short' }}">{{ '做多' if k.direction == 'long' else '做空' }}</span>
<span class="badge direction" style="margin-left:4px">{{ k.monitor_type }}</span>
</div>
<button type="button" class="pos-close-btn" style="border:none;cursor:pointer" onclick="deleteKeyMonitor({{ k.id }})"></button>
</div>
<button type="button" class="btn-del" style="border:none;cursor:pointer" onclick="deleteKeyMonitor({{ k.id }})"></button>
<div class="pos-meta">
<span class="pos-meta-item">上沿: {{ k.upper }}</span>
<span class="pos-meta-item">下沿: {{ k.lower }}</span>
<span class="pos-meta-item">已提醒: {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }}</span>
</div>
<div class="pos-grid">
<div class="pos-cell"><span class="pos-label">现价</span><span class="pos-value" id="key-price-{{ k.id }}">-</span></div>
<div class="pos-cell"><span class="pos-label">距上沿</span><span class="pos-value" id="key-up-diff-{{ k.id }}">-</span></div>
<div class="pos-cell"><span class="pos-label">距下沿</span><span class="pos-value" id="key-low-diff-{{ k.id }}">-</span></div>
<div class="pos-cell"><span class="pos-label">门控</span><span class="pos-value" id="key-gate-{{ k.id }}" style="color:#9aa">-</span></div>
</div>
<div class="pos-meta" style="margin-top:8px"><span class="pos-meta-item" id="key-gate-metrics-{{ k.id }}" style="color:#8fc8ff"></span></div>
</div>
{% else %}
<div class="pos-empty">暂无监控中的关键位</div>
{% endfor %}
</div>
<div class="key-history">
<h3>关键位历史(满次提醒或手动删除)</h3>
<div class="sub">每种关键位触发后<strong>一次性结案</strong>并写入下方历史:箱体/收敛在计划 RR 达标时<strong>自动市价开仓</strong>;阻力/支撑仅<strong>单次企业微信提醒</strong>。详见项目根目录「关键位自动下单说明.md」。满多次提醒的旧逻辑已不适用。</div>
<div class="list">
{% for h in key_history %}
<div class="list-item">
<div>
<strong>{{ h.symbol }}</strong> | {{ h.monitor_type }} | {{ '做多' if h.direction == 'long' else '做空' }} | {{ h.close_reason }}
<button type="button" class="table-del" style="margin-left:8px" onclick="deleteKeyHistory({{ h.id }})">删除</button>
</div>
<div class="card">
<h2 style="margin-bottom:8px">关键位历史</h2>
<div class="sub" style="font-size:.72rem;color:#8892b0;margin-bottom:8px">失效或已结案的关键位</div>
<div class="panel-scroll pos-list">
{% for h in key_history %}
<div class="pos-card">
<div class="pos-card-head">
<div class="pos-card-symbol">
<strong>{{ h.symbol }}</strong>
<span class="pos-side-badge {{ 'pos-side-long' if h.direction == 'long' else 'pos-side-short' }}">{{ '做多' if h.direction == 'long' else '做空' }}</span>
</div>
<div>上:{{ h.upper }} 下:{{ h.lower }} | 提醒次数:{{ h.notification_count }} | {{ (h.closed_at or '-')[:16] }}</div>
{% if h.last_alert_message %}<div style="font-size:.78rem;color:#aab;margin-top:4px;white-space:pre-wrap">{{ h.last_alert_message[:200] }}{% if h.last_alert_message|length > 200 %}…{% endif %}</div>{% endif %}
<button type="button" class="table-del" onclick="deleteKeyHistory({{ h.id }})">删除</button>
</div>
{% else %}
<div class="list-item" style="color:#8892b0">暂无历史</div>
{% endfor %}
<div class="pos-meta">
<span class="pos-meta-item">{{ h.monitor_type }}</span>
<span class="pos-meta-item">{{ h.close_reason }}</span>
<span class="pos-meta-item">{{ (h.closed_at or '-')[:16] }}</span>
</div>
<div class="pos-meta">
<span class="pos-meta-item">上: {{ h.upper }} 下: {{ h.lower }}</span>
<span class="pos-meta-item">提醒: {{ h.notification_count }}</span>
</div>
{% if h.last_alert_message %}<div style="font-size:.75rem;color:#aab;margin-top:6px;white-space:pre-wrap">{{ h.last_alert_message[:180] }}{% if h.last_alert_message|length > 180 %}…{% endif %}</div>{% endif %}
</div>
{% else %}
<div class="pos-empty">暂无历史</div>
{% endfor %}
</div>
</div>
<div class="card order-card">
</div>
{% elif page == 'trade' %}
<div class="dual-panel-grid" style="grid-column:1/-1">
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<h2 style="margin-bottom:0">实盘下单监控</h2>
{% if focus_order_id %}
@@ -272,9 +301,9 @@
{% endif %}
</div>
<div class="rule-tip" id="order-rule-tip">
规则:仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x
{% if can_trade %}可开仓{% else %}不可开仓(持仓或未到北京时间 {{ reset_hour }}:00{% endif %}
按风险比例自动计算仓位
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x
{% if can_trade %}可开仓{% else %}不可开仓(持仓已满或未到北京时间 {{ reset_hour }}:00{% endif %}
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
</div>
<div class="rule-tip">
以损定仓:风险 {{ risk_percent }}% |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
@@ -296,7 +325,7 @@
</select>
<button type="submit">手动划转</button>
</form>
<form action="/add_order" method="post" class="form-row">
<form id="add-order-form" action="/add_order" method="post" class="form-row">
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
<select id="order-direction" name="direction" required>
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
@@ -323,9 +352,10 @@
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
<button type="submit">开仓(以损定仓)</button>
</form>
<div class="pos-section">
<div class="pos-section-title">实时持仓</div>
<div class="pos-list">
</div>
<div class="card">
<h2 style="margin-bottom:8px">实时持仓</h2>
<div class="panel-scroll pos-list">
{% for o in order %}
<div class="pos-card" id="order-row-{{ o.id }}">
<div class="pos-card-head">
@@ -387,11 +417,13 @@
{% else %}
<div class="pos-empty">暂无持仓</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endif %}
{% if page == 'records' %}
<div class="card full records-card">
<h2>交易记录 & 错过机会</h2>
@@ -1148,8 +1180,9 @@ if(keyForm){
alert((data && data.msg) || "日成交量排名读取失败");
return;
}
const rankMax = data.rank_max || 30;
if(!data.in_top30){
alert(`${data.symbol} 当前日成交量排名 ${data.rank}/${data.total},不在前30,已拦截。`);
alert(`${data.symbol} 当前日成交量排名 ${data.rank}/${data.total},不在前${rankMax},已拦截。`);
return;
}
keyForm.submit();
@@ -1163,6 +1196,19 @@ setTimeout(() => {
if(document.getElementById("review-list")) loadReviews();
}, 300);
const MANUAL_MIN_PLANNED_RR = {{ manual_min_planned_rr }};
function calcClientRr(direction, entry, sl, tp){
const e = Number(entry), s = Number(sl), t = Number(tp);
if(!Number.isFinite(e) || !Number.isFinite(s) || !Number.isFinite(t)) return null;
if(direction === 'short'){
if(s <= e || t >= e) return null;
return (e - t) / (s - e);
}
if(s >= e || t <= e) return null;
return (t - e) / (e - s);
}
let latestAvailableUsdt = null;
const lastPriceMap = {};
@@ -1309,11 +1355,11 @@ function refreshAccountSnapshot(){
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
latestAvailableUsdt = Number(data.available_trading_usdt);
}
const canTradeText = data.can_trade ? "可开仓" : "不可开仓(持仓或未到北京时间 {{ reset_hour }}:00";
const canTradeText = data.can_trade ? "可开仓" : `不可开仓(持仓 ${data.active_count||0}/${data.max_active_positions||{{ max_active_positions }}} 或未到北京时间 {{ reset_hour }}:00`;
const tip = document.getElementById("order-rule-tip");
const avail = (latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)) ? `;交易账户可用约${latestAvailableUsdt.toFixed(2)}U` : "";
if(tip){
tip.innerText = `规则:仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x${canTradeText}${avail}`;
tip.innerText = `规则:最多 ${data.max_active_positions || {{ max_active_positions }}} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x${canTradeText}${avail}`;
}
}).catch(()=>{});
}
@@ -1365,10 +1411,99 @@ if(_journalFormEl){
if(_jErSel) _jErSel.addEventListener("change", syncJournalEntryReasonOtherUi);
syncJournalEntryReasonOtherUi();
}
const addOrderForm = document.getElementById("add-order-form");
if(addOrderForm){
addOrderForm.addEventListener("submit", function(ev){
if(addOrderForm.dataset.rrOk === "1"){
addOrderForm.dataset.rrOk = "0";
return;
}
ev.preventDefault();
const direction = (document.getElementById("order-direction")||{}).value || "long";
const mode = (document.getElementById("sltp-mode")||{}).value || "price";
let sl, tp, entry;
if(mode === "pct"){
alert("百分比模式请确认盈亏比后再提交;建议使用价格模式以便校验。");
return;
}
sl = Number((document.getElementById("order-sl")||{}).value);
tp = Number((document.getElementById("order-tp")||{}).value);
entry = sl;
fetch(`/api/order_defaults?symbol=${encodeURIComponent((document.getElementById("order-symbol")||{}).value||"")}&direction=${encodeURIComponent(direction)}`)
.then(r=>r.json())
.then(data=>{
const px = data.last_price || data.price;
if(px) entry = Number(px);
const rr = calcClientRr(direction, entry, sl, tp);
if(rr === null || rr < MANUAL_MIN_PLANNED_RR){
alert(`计划盈亏比 ${rr === null ? '无效' : rr.toFixed(2)}:1 低于最低要求 ${MANUAL_MIN_PLANNED_RR}:1,已阻止人工下单。`);
return;
}
addOrderForm.dataset.rrOk = "1";
addOrderForm.submit();
})
.catch(()=>{ alert("无法校验盈亏比,请稍后重试"); });
});
}
refreshOrderDefaults();
refreshPriceSnapshot();
refreshPriceSnapshotConditional();
setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }});
setInterval(refreshPriceSnapshot, {{ price_refresh_seconds * 1000 }});
function refreshPriceSnapshotConditional(){
const page = document.body.getAttribute("data-page") || "";
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(page === "key_monitor"){
(data.key_prices || []).forEach(k=>{
const pEl = document.getElementById(`key-price-${k.id}`);
if(pEl){ pEl.innerText = k.price_display || (Number.isFinite(Number(k.price)) ? Number(k.price).toFixed(6) : "-"); paintPriceTrend(pEl, `k-${k.id}`, Number(k.price)); }
const upEl = document.getElementById(`key-up-diff-${k.id}`);
if(upEl) upEl.innerText = `${formatSigned(k.upper_diff, 4)} (${formatSigned(k.upper_pct, 2)}%)`;
const lowEl = document.getElementById(`key-low-diff-${k.id}`);
if(lowEl) lowEl.innerText = `${formatSigned(k.lower_diff, 4)} (${formatSigned(k.lower_pct, 2)}%)`;
const gateEl = document.getElementById(`key-gate-${k.id}`);
if(gateEl){ gateEl.innerText = k.gate_summary || "-"; gateEl.style.color = k.gate_ok ? "#4cd97f" : "#ff8f8f"; }
const gateMetricEl = document.getElementById(`key-gate-metrics-${k.id}`);
if(gateMetricEl) gateMetricEl.innerText = k.gate_metrics || "";
});
}
if(page === "trade"){
(data.order_prices || []).forEach(o=>{
const pEl = document.getElementById(`order-price-${o.id}`);
if(pEl){
const hasMark = (()=>{ const x = o.exchange_mark_price; if(x===null||x===undefined||x==="")return false; const n=Number(x); return !Number.isNaN(n); })();
let disp = "";
if(hasMark && o.exchange_mark_price_display) disp = o.exchange_mark_price_display;
else if(o.price_display) disp = o.price_display;
else { const px = hasMark ? Number(o.exchange_mark_price) : Number(o.price); disp = Number.isFinite(px) ? px.toFixed(6) : "-"; }
pEl.innerText = disp;
const pxNum = hasMark ? Number(o.exchange_mark_price) : Number(o.price);
paintPriceTrend(pEl, `o-${o.id}`, Number.isFinite(pxNum) ? pxNum : px);
}
const exM = document.getElementById(`order-ex-margin-${o.id}`);
if(exM){
const mv = o.exchange_initial_margin;
const mn = (mv === null || mv === undefined || mv === "") ? NaN : Number(mv);
if(!Number.isNaN(mn)) exM.innerText = `${mn.toFixed(2)}U`;
else { const prc = (typeof data.positions_raw_count === "number") ? data.positions_raw_count : null; exM.innerText = (prc === 0) ? "无仓数据" : "-"; }
}
const pnlEl = document.getElementById(`order-pnl-${o.id}`);
if(pnlEl){
pnlEl.innerText = `${formatSigned(o.float_pnl, 2)}U (${formatSigned(o.float_pct, 2)}%)`;
pnlEl.classList.remove("price-up","price-down","price-flat");
if(Number(o.float_pnl) > 0) pnlEl.classList.add("price-up");
else if(Number(o.float_pnl) < 0) pnlEl.classList.add("price-down");
else pnlEl.classList.add("price-flat");
}
const rrEl = document.getElementById(`order-rr-${o.id}`);
if(rrEl) rrEl.innerText = formatRrRatio(o.rr_ratio);
});
}
}).catch(()=>{});
}
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
</script>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
# 界面与风控更新说明(Binance 实例)
## 顶栏导航(4 项)
| 顺序 | 名称 | 路由 | 说明 |
|------|------|------|------|
| 1 | 关键位监控 | `/key_monitor` | 关键位添加、实时门控、历史 |
| 2 | 实盘下单 | `/trade` | 人工开仓、划转、实时持仓(**默认首页** `/``/trade` |
| 3 | 交易记录与复盘 | `/records` | 未改动 |
| 4 | 统计分析 | `/stats` | 未改动 |
## 关键位监控页
- 标题去掉「5m」;规则条从 `.env` 读取(周期、确认K、量能、自动开仓盈亏比、日成交量排名)。
- 左列:活跃关键位,**pos-card** 样式展示现价/距上沿/距下沿/门控。
- 右列:关键位历史(失效/结案),与左列等高滚动。
## 实盘下单页
- 左列:实盘下单监控(表单、划转、规则)。
- 右列:实时持仓(独立模块)。
- **人工开仓门控**:计划盈亏比 &lt; `MANUAL_MIN_PLANNED_RR`(默认 **1.4**)时前端弹窗 + 后端拒绝。
## 持仓与计仓
- `MAX_ACTIVE_POSITIONS` 默认 **1**(可在 `.env` 调大)。
- 关键位自动开仓:在已有持仓时,若 `KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true`,按**首笔开仓前**交易账户资金快照计仓(字段 `trading_sessions.key_sizing_capital_snapshot`)。
## 配置
详见 `.env.example` 中「关键位门控」「交易执行 / 人工风控」注释段。
## 升级步骤
1. `git pull` 后对比 `.env.example`,把新增变量合并进本地 `.env`
2. 重启服务(如 `pm2 restart`);SQLite 会在启动时自动 `ALTER` 新列。
3. 浏览器强刷(Ctrl+F5)避免旧版 `index.html` 缓存。
+18 -6
View File
@@ -73,21 +73,33 @@ GATE_TPSL_PRICE_TYPE=0
# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 Gate·模拟)
# EXCHANGE_DISPLAY_NAME=Gate.io
# 关键位监控:5m收线突破过滤参数
# =============================================================================
# 关键位门控(页面「关键位监控」)
# =============================================================================
KLINE_TIMEFRAME=5m
KEY_BREAKOUT_LIMIT_PCT=1.5
# 关键位自动单:计划 RR 阈值(严格大于该值才开仓,按确认K收盘 E 计算)
KEY_CONFIRM_BREAKOUT_BAR=-2
KEY_CONFIRM_BAR=-1
KEY_VOLUME_MA_BARS=20
KEY_VOLUME_RATIO_MIN=1.3
KEY_BREAKOUT_AMP_MIN_PCT=0.03
KEY_BREAKOUT_AMP_MAX_PCT=0.5
KEY_DAILY_VOLUME_RANK_MAX=30
KEY_AUTO_MIN_PLANNED_RR=1.5
# 止损:突破 K 极值向外缓冲的百分比(默认 0.5 即 0.5%)
KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5
KEY_ALERT_MAX_TIMES=3
KEY_ALERT_INTERVAL_MINUTES=5
# =============================================================================
# 交易执行 / 人工风控(页面「实盘下单」)
# =============================================================================
MAX_ACTIVE_POSITIONS=1
MANUAL_MIN_PLANNED_RR=1.4
KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true
# 资金与仓位刷新周期(秒)
BALANCE_REFRESH_SECONDS=60
# 后台监控轮询周期(秒)
PRICE_REFRESH_SECONDS=5
MONITOR_POLL_SECONDS=3
# 使用可用资金时的缓冲比例(如0.98代表用98%)
FULL_MARGIN_BUFFER_RATIO=0.98
# 自动划转:将目标账户补足到 AUTO_TRANSFER_AMOUNT
+137 -39
View File
@@ -123,9 +123,18 @@ BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60"))
PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5"))
KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3"))
KEY_ALERT_INTERVAL_MINUTES = int(os.getenv("KEY_ALERT_INTERVAL_MINUTES", "5"))
KEY_BREAKOUT_LIMIT_PCT = float(os.getenv("KEY_BREAKOUT_LIMIT_PCT", "1.5"))
KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5"))
KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5"))
MANUAL_MIN_PLANNED_RR = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4"))
MAX_ACTIVE_POSITIONS = max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1")))
KEY_VOLUME_MA_BARS = max(1, int(os.getenv("KEY_VOLUME_MA_BARS", "20")))
KEY_VOLUME_RATIO_MIN = float(os.getenv("KEY_VOLUME_RATIO_MIN", "1.3"))
KEY_BREAKOUT_AMP_MIN_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MIN_PCT", "0.03"))
KEY_BREAKOUT_AMP_MAX_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MAX_PCT", "0.5"))
KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30")))
KEY_CONFIRM_BREAKOUT_BAR = int(os.getenv("KEY_CONFIRM_BREAKOUT_BAR", "-2"))
KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR", "-1"))
KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() == "true"
ORDER_MONITOR_TYPE_MANUAL = "下单监控"
ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控"
@@ -1228,6 +1237,10 @@ def init_db():
try:
c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5")
except: pass
try:
c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL")
except Exception:
pass
c.execute(
"""CREATE TABLE IF NOT EXISTS key_monitor_history
@@ -2386,13 +2399,63 @@ def trading_day_reset_allows_new_open(now):
return now.hour >= TRADING_DAY_RESET_HOUR
def get_active_position_count(conn):
return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0])
def clear_key_sizing_snapshot_if_flat(conn, session_date):
if get_active_position_count(conn) > 0:
return
conn.execute(
"UPDATE trading_sessions SET key_sizing_capital_snapshot = NULL, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?",
(session_date,),
)
conn.commit()
def get_key_sizing_capital_snapshot(conn, session_date):
row = ensure_session(conn, session_date)
try:
val = row["key_sizing_capital_snapshot"]
except (KeyError, IndexError):
return None
if val is None:
return None
try:
return float(val)
except (TypeError, ValueError):
return None
def set_key_sizing_capital_snapshot(conn, session_date, capital):
ensure_session(conn, session_date)
conn.execute(
"UPDATE trading_sessions SET key_sizing_capital_snapshot = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?",
(round(float(capital), 2), session_date),
)
conn.commit()
def resolve_capital_base_for_key_open(conn, trading_day, live_capital):
live = float(live_capital)
active = get_active_position_count(conn)
if active <= 0:
set_key_sizing_capital_snapshot(conn, trading_day, live)
return live
if KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT:
snap = get_key_sizing_capital_snapshot(conn, trading_day)
if snap is not None and snap > 0:
return snap
return live
def precheck_risk(conn, symbol, direction):
now = app_now()
if not trading_day_reset_allows_new_open(now):
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]
if active_count > 0:
return False, "一次只能持有一个仓位"
active_count = get_active_position_count(conn)
if active_count >= MAX_ACTIVE_POSITIONS:
return False, f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS}"
if direction not in ("long", "short"):
return False, "方向必须为 long 或 short"
if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"):
@@ -3239,6 +3302,7 @@ def reconcile_external_closes(conn, days=None):
closed_at=closed_at,
)
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],))
clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day())
if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"):
send_wechat_msg(
build_wechat_close_message(
@@ -3405,21 +3469,26 @@ def _key_hard_checks(symbol, direction, upper, lower, monitor_type):
out["reason"] = "5m K线数量不足"
return out
closed = bars[:-1] if len(bars) >= 3 else bars
if len(closed) < 23:
out["reason"] = "闭合K线不足"
min_closed = KEY_VOLUME_MA_BARS + 3
if len(closed) < min_closed:
out["reason"] = f"{KLINE_TIMEFRAME} 闭合K线不足"
return out
breakout = closed[-2]
confirm = closed[-1]
prev20 = closed[-22:-2]
avg20 = sum(float(x[5]) for x in prev20) / max(len(prev20), 1)
try:
breakout = closed[KEY_CONFIRM_BREAKOUT_BAR]
confirm = closed[KEY_CONFIRM_BAR]
except IndexError:
out["reason"] = "确认K索引超出范围,请检查 KEY_CONFIRM_* 配置"
return out
prev_vol = closed[KEY_CONFIRM_BREAKOUT_BAR - KEY_VOLUME_MA_BARS : KEY_CONFIRM_BREAKOUT_BAR]
avg20 = sum(float(x[5]) for x in prev_vol) / max(len(prev_vol), 1)
vol_break = float(breakout[5])
vol_ok = vol_break > avg20 * 1.3 if avg20 > 0 else False
vol_ok = vol_break > avg20 * KEY_VOLUME_RATIO_MIN if avg20 > 0 else False
open_b = float(breakout[1])
close_b = float(breakout[4])
high_b = float(breakout[2])
low_b = float(breakout[3])
amp_pct = abs(close_b - open_b) / open_b * 100 if open_b > 0 else 0
amp_ok = (amp_pct > 0.03) and (amp_pct < 0.5)
amp_ok = (amp_pct > KEY_BREAKOUT_AMP_MIN_PCT) and (amp_pct < KEY_BREAKOUT_AMP_MAX_PCT)
cfm_close = float(confirm[4])
# 区间极值点严格以前端录入 upper/lower 为准:做多看上沿,做空看下沿
edge = float(upper) if direction == "long" else float(lower)
@@ -3429,7 +3498,7 @@ def _key_hard_checks(symbol, direction, upper, lower, monitor_type):
amp_ok = amp_ok and breakout_ok
confirm_ok = confirm_ok_raw and breakout_ok
rank, total = _daily_volume_rank(symbol)
rank_ok = (rank is not None) and (rank <= 30)
rank_ok = (rank is not None) and (rank <= KEY_DAILY_VOLUME_RANK_MAX)
swing4h_pct = 0.0
try:
seg48 = closed[-48:] if len(closed) >= 48 else closed
@@ -3542,7 +3611,8 @@ def _market_open_for_key_monitor(conn, symbol, direction, exchange_symbol, stop_
).fetchone()[0]
session_row = ensure_session(conn, trading_day)
_, trading_capital_live = get_exchange_capitals(force=True)
capital_base = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"])
live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"])
capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital)
trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower()
if trade_style not in ("trend", "swing"):
@@ -4127,6 +4197,7 @@ def check_order_monitors():
closed_at=closed_at,
)
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, pid))
clear_key_sizing_snapshot_if_flat(conn, get_trading_day())
conn.commit()
conn.close()
@@ -4335,7 +4406,12 @@ def render_main_page(page="trade"):
)
rate = round(win/total*100,2) if total else 0
active_count = len(order_list)
can_trade = trading_day_reset_allows_new_open(now) and active_count == 0
can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS
key_gate_rule_text = (
f"周期 {KLINE_TIMEFRAME}|确认K:突破棒偏移 {KEY_CONFIRM_BREAKOUT_BAR}、确认棒偏移 {KEY_CONFIRM_BAR}"
f"量能:突破量 > 前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}"
f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}"
)
conn.close()
return render_template(
"index.html",
@@ -4380,6 +4456,11 @@ def render_main_page(page="trade"):
entry_reason_options=list(ENTRY_REASON_OPTIONS),
entry_reason_other_value=ENTRY_REASON_OTHER,
exchange_display=EXCHANGE_DISPLAY_NAME,
max_active_positions=MAX_ACTIVE_POSITIONS,
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR,
key_gate_rule_text=key_gate_rule_text,
kline_timeframe=KLINE_TIMEFRAME,
)
@@ -4389,6 +4470,12 @@ def index():
return redirect("/trade")
@app.route("/key_monitor")
@login_required
def key_monitor_page():
return render_main_page("key_monitor")
@app.route("/trade")
@login_required
def trade_page():
@@ -4419,9 +4506,9 @@ def api_account_snapshot():
funding_usdt = round(funding_capital, 2) if funding_capital is not None else None
current_capital = round(trading_capital, 2) if trading_capital is not None else round(local_current_capital, 2)
recommended_capital = round(float(get_recommended_capital(current_capital)), 2)
active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]
active_count = get_active_position_count(conn)
conn.close()
can_trade = trading_day_reset_allows_new_open(now) and active_count == 0
can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS
available_trading_usdt = get_available_trading_usdt()
return jsonify({
"funding_usdt": funding_usdt,
@@ -4429,7 +4516,9 @@ def api_account_snapshot():
"available_trading_usdt": round(available_trading_usdt, 2) if available_trading_usdt is not None else None,
"recommended_capital": recommended_capital,
"active_count": active_count,
"max_active_positions": MAX_ACTIVE_POSITIONS,
"can_trade": can_trade,
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
"trading_day": trading_day
})
@@ -4609,7 +4698,8 @@ def api_symbol_liquidity_rank():
"symbol": symbol,
"rank": int(rank),
"total": int(total),
"in_top30": bool(rank <= 30),
"in_top30": bool(rank <= KEY_DAILY_VOLUME_RANK_MAX),
"rank_max": KEY_DAILY_VOLUME_RANK_MAX,
}
)
@@ -4626,13 +4716,15 @@ def api_order_defaults():
exchange_symbol = normalize_exchange_symbol(symbol)
leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol)
available = get_available_trading_usdt()
last_price = get_price(symbol)
return jsonify({
"ok": True,
"symbol": symbol,
"exchange_symbol": exchange_symbol,
"direction": direction,
"leverage": leverage,
"available_trading_usdt": round(available, 2) if available is not None else None
"available_trading_usdt": round(available, 2) if available is not None else None,
"last_price": round(float(last_price), 8) if last_price is not None else None,
})
@@ -4865,34 +4957,33 @@ def add_key():
symbol = normalize_symbol_input(d.get("symbol"))
if not symbol:
flash("symbol 不能为空")
return redirect("/")
return redirect("/key_monitor")
direction_sel = (d.get("direction") or "").strip().lower()
if direction_sel not in ("long", "short"):
flash("请选择做多或做空")
return redirect("/")
return redirect("/key_monitor")
mt = (d.get("type") or "").strip()
allowed_types = tuple(KEY_MONITOR_AUTO_TYPES) + tuple(KEY_MONITOR_ALERT_ONLY_TYPES)
if mt not in allowed_types:
flash("监控类型无效,请选择:箱体突破、收敛突破、关键阻力位、关键支撑位")
return redirect("/")
return redirect("/key_monitor")
rank, total = _daily_volume_rank(symbol)
if rank is None:
flash("日成交量排名读取失败,请稍后重试")
return redirect("/")
if rank > 30:
flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前30,已拒绝添加关键位")
return redirect("/")
return redirect("/key_monitor")
if rank > KEY_DAILY_VOLUME_RANK_MAX:
flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位")
return redirect("/key_monitor")
conn = get_db()
if mt in KEY_MONITOR_AUTO_TYPES:
occupied = conn.execute(
"SELECT COUNT(*) FROM order_monitors WHERE status='active'",
).fetchone()[0]
if occupied and int(occupied) > 0:
occupied = get_active_position_count(conn)
if occupied >= MAX_ACTIVE_POSITIONS:
conn.close()
flash(
"当前已有实盘持仓:无法添加「箱体突破 / 收敛突破」(会自动开仓)。请平仓后再试,或使用「关键阻力位/关键支撑位」(仅单次提醒)"
f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」"
"请平仓后再试,或使用「关键阻力位/关键支撑位」(仅单次提醒)。"
)
return redirect("/")
return redirect("/key_monitor")
ex_sym_key = normalize_exchange_symbol(symbol)
try:
ensure_markets_loaded()
@@ -4919,7 +5010,7 @@ def add_key():
flash(
"⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。"
)
return redirect("/")
return redirect("/key_monitor")
@app.route("/add_order", methods=["POST"])
@login_required
@@ -4935,7 +5026,7 @@ def add_order():
return redirect("/")
ok, reason = precheck_risk(conn, symbol, direction)
if not ok:
if "一次只能持有一个仓位" in reason:
if "已达最大持仓数" in reason:
try:
tp_raw = parse_positive_float(d.get("tp"))
sl_raw = parse_positive_float(d.get("sl"))
@@ -4956,19 +5047,19 @@ def add_order():
stop_loss=round_price_to_exchange(ex_miss, sl_raw) if sl_raw else 0,
take_profit=round_price_to_exchange(ex_miss, tgt_raw) if tgt_raw else 0,
result="错过",
miss_reason="持仓占用:一次只能持有一个仓位",
miss_reason=f"持仓占用:{reason}",
opened_at=app_now_str(),
closed_at=app_now_str(),
)
conn.commit()
conn.close()
flash(f"风控拒绝下单:{reason}")
return redirect("/")
return redirect("/trade")
ok_live, reason_live = ensure_exchange_live_ready()
if not ok_live:
conn.close()
flash(f"风控拒绝下单:{reason_live}")
return redirect("/")
return redirect("/trade")
exchange_symbol = normalize_exchange_symbol(symbol)
default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol)
try:
@@ -5039,7 +5130,13 @@ def add_order():
if stop_loss <= 0 or take_profit <= 0:
conn.close()
flash("价格参数必须大于0")
return redirect("/")
return redirect("/trade")
planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit)
if planned_rr_manual is None or planned_rr_manual < MANUAL_MIN_PLANNED_RR:
conn.close()
rr_txt = f"{planned_rr_manual:.4f}" if planned_rr_manual is not None else "无法计算"
flash(f"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1")
return redirect("/trade")
sl_adj = round_price_to_exchange(exchange_symbol, stop_loss)
tp_adj = round_price_to_exchange(exchange_symbol, take_profit)
if sl_adj is not None:
@@ -5509,6 +5606,7 @@ def del_order(id):
closed_at=closed_at,
)
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id))
clear_key_sizing_snapshot_if_flat(conn, session_date)
conn.commit()
conn.close()
send_wechat_msg(
@@ -5528,7 +5626,7 @@ def del_order(id):
)
)
flash("已按实盘流程手动平仓")
return redirect("/")
return redirect("/trade")
except Exception as e:
if is_no_position_error(str(e)):
cancel_gate_swap_trigger_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]))
+178 -47
View File
@@ -149,7 +149,7 @@
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
</style>
</head>
<body>
<body data-page="{{ page }}">
{% macro period_stats(title, s) %}
<div class="stats-period-block">
<h3>{{ title }}</h3>
@@ -200,10 +200,11 @@
<div class="rule-tip">实时价格更新时间:<span id="price-last-updated">--</span>(北京时间 UTC+8</div>
<div class="grid">
{% if page == 'trade' %}
<div class="card monitor-card">
{% if page == 'key_monitor' %}
<div class="dual-panel-grid" style="grid-column:1/-1">
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<h2 style="margin-bottom:0">关键位监控5m</h2>
<h2 style="margin-bottom:0">关键位监控</h2>
{% if focus_key_id %}
<a href="/key_focus?key_id={{ focus_key_id }}" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">放大查看K线(默认200根)</a>
{% else %}
@@ -225,44 +226,69 @@
<input name="lower" step="0.0001" placeholder="下沿/支撑" required>
<button type="submit">添加</button>
</form>
<div class="list">
<div class="rule-tip">{{ key_gate_rule_text }}</div>
<div class="panel-scroll pos-list">
{% for k in key %}
<div class="list-item" id="key-row-{{ k.id }}">
<div><strong>{{ k.symbol }}</strong> | {{ k.monitor_type }} | <span class="badge direction">{{ '做多' if k.direction == 'long' else '做空' }}</span></div>
<div>
上:{{ price_fmt(k.symbol, k.upper) }} 下:{{ price_fmt(k.symbol, k.lower) }}
| 已提醒:{{ k.notification_count or 0 }}/{{ k.max_notify or 3 }}
| 现价:<span id="key-price-{{ k.id }}">-</span>
| 距上沿:<span id="key-up-diff-{{ k.id }}">-</span>
| 距下沿:<span id="key-low-diff-{{ k.id }}">-</span>
| 门控:<span id="key-gate-{{ k.id }}" style="color:#9aa">-</span>
<span id="key-gate-metrics-{{ k.id }}" style="margin-left:8px;color:#8fc8ff;font-size:.78rem"></span>
<div class="pos-card" id="key-row-{{ k.id }}">
<div class="pos-card-head">
<div class="pos-card-symbol">
<strong>{{ k.symbol }}</strong>
<span class="pos-side-badge {{ 'pos-side-long' if k.direction == 'long' else 'pos-side-short' }}">{{ '做多' if k.direction == 'long' else '做空' }}</span>
<span class="badge direction" style="margin-left:4px">{{ k.monitor_type }}</span>
</div>
<button type="button" class="pos-close-btn" style="border:none;cursor:pointer" onclick="deleteKeyMonitor({{ k.id }})"></button>
</div>
<button type="button" class="btn-del" style="border:none;cursor:pointer" onclick="deleteKeyMonitor({{ k.id }})"></button>
<div class="pos-meta">
<span class="pos-meta-item">上沿: {{ k.upper }}</span>
<span class="pos-meta-item">下沿: {{ k.lower }}</span>
<span class="pos-meta-item">已提醒: {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }}</span>
</div>
<div class="pos-grid">
<div class="pos-cell"><span class="pos-label">现价</span><span class="pos-value" id="key-price-{{ k.id }}">-</span></div>
<div class="pos-cell"><span class="pos-label">距上沿</span><span class="pos-value" id="key-up-diff-{{ k.id }}">-</span></div>
<div class="pos-cell"><span class="pos-label">距下沿</span><span class="pos-value" id="key-low-diff-{{ k.id }}">-</span></div>
<div class="pos-cell"><span class="pos-label">门控</span><span class="pos-value" id="key-gate-{{ k.id }}" style="color:#9aa">-</span></div>
</div>
<div class="pos-meta" style="margin-top:8px"><span class="pos-meta-item" id="key-gate-metrics-{{ k.id }}" style="color:#8fc8ff"></span></div>
</div>
{% else %}
<div class="pos-empty">暂无监控中的关键位</div>
{% endfor %}
</div>
<div class="key-history">
<h3>关键位历史(满次提醒或手动删除)</h3>
<div class="sub">每种关键位触发后<strong>一次性结案</strong>并写入下方历史:箱体/收敛在计划 RR 达标时<strong>自动市价开仓</strong>;阻力/支撑仅<strong>单次企业微信提醒</strong>。详见项目根目录「关键位自动下单说明.md」。满多次提醒的旧逻辑已不适用。</div>
<div class="list">
{% for h in key_history %}
<div class="list-item">
<div>
<strong>{{ h.symbol }}</strong> | {{ h.monitor_type }} | {{ '做多' if h.direction == 'long' else '做空' }} | {{ h.close_reason }}
<button type="button" class="table-del" style="margin-left:8px" onclick="deleteKeyHistory({{ h.id }})">删除</button>
</div>
<div class="card">
<h2 style="margin-bottom:8px">关键位历史</h2>
<div class="sub" style="font-size:.72rem;color:#8892b0;margin-bottom:8px">失效或已结案的关键位</div>
<div class="panel-scroll pos-list">
{% for h in key_history %}
<div class="pos-card">
<div class="pos-card-head">
<div class="pos-card-symbol">
<strong>{{ h.symbol }}</strong>
<span class="pos-side-badge {{ 'pos-side-long' if h.direction == 'long' else 'pos-side-short' }}">{{ '做多' if h.direction == 'long' else '做空' }}</span>
</div>
<div>上:{{ price_fmt(h.symbol, h.upper) }} 下:{{ price_fmt(h.symbol, h.lower) }} | 提醒次数:{{ h.notification_count }} | {{ (h.closed_at or '-')[:16] }}</div>
{% if h.last_alert_message %}<div style="font-size:.78rem;color:#aab;margin-top:4px;white-space:pre-wrap">{{ h.last_alert_message[:200] }}{% if h.last_alert_message|length > 200 %}…{% endif %}</div>{% endif %}
<button type="button" class="table-del" onclick="deleteKeyHistory({{ h.id }})">删除</button>
</div>
{% else %}
<div class="list-item" style="color:#8892b0">暂无历史</div>
{% endfor %}
<div class="pos-meta">
<span class="pos-meta-item">{{ h.monitor_type }}</span>
<span class="pos-meta-item">{{ h.close_reason }}</span>
<span class="pos-meta-item">{{ (h.closed_at or '-')[:16] }}</span>
</div>
<div class="pos-meta">
<span class="pos-meta-item">上: {{ h.upper }} 下: {{ h.lower }}</span>
<span class="pos-meta-item">提醒: {{ h.notification_count }}</span>
</div>
{% if h.last_alert_message %}<div style="font-size:.75rem;color:#aab;margin-top:6px;white-space:pre-wrap">{{ h.last_alert_message[:180] }}{% if h.last_alert_message|length > 180 %}…{% endif %}</div>{% endif %}
</div>
{% else %}
<div class="pos-empty">暂无历史</div>
{% endfor %}
</div>
</div>
<div class="card order-card">
</div>
{% elif page == 'trade' %}
<div class="dual-panel-grid" style="grid-column:1/-1">
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<h2 style="margin-bottom:0">实盘下单监控</h2>
{% if focus_order_id %}
@@ -272,15 +298,15 @@
{% endif %}
</div>
<div class="rule-tip" id="order-rule-tip">
规则:仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x
{% if can_trade %}可开仓{% else %}不可开仓(持仓或未到北京时间 {{ reset_hour }}:00{% endif %}
按风险比例自动计算仓位
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x
{% if can_trade %}可开仓{% else %}不可开仓(持仓已满或未到北京时间 {{ reset_hour }}:00{% endif %}
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
</div>
<div class="rule-tip">
以损定仓:风险 {{ risk_percent }}% |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
</div>
<div class="rule-tip">
划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天<strong>北京时间 {{ auto_transfer_bj_hour }}:00</strong>起该整点小时内尝试;账簿按 <strong>UTC 自然日</strong>去重;界面时间为北京;将 {{ auto_transfer_to }} 补足到 {{ usdt_fmt(auto_transfer_amount) }}U,来自 {{ auto_transfer_from }}
划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天<strong>北京时间 {{ auto_transfer_bj_hour }}:00</strong>起该整点小时内尝试;账簿按 <strong>UTC 自然日</strong>去重;界面时间为北京;将 {{ auto_transfer_to }} 补足到 {{ auto_transfer_amount }}U,来自 {{ auto_transfer_from }}
</div>
<form action="/manual_transfer" method="post" class="form-row">
<input name="amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额U" required>
@@ -296,7 +322,7 @@
</select>
<button type="submit">手动划转</button>
</form>
<form action="/add_order" method="post" class="form-row">
<form id="add-order-form" action="/add_order" method="post" class="form-row">
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
<select id="order-direction" name="direction" required>
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
@@ -323,9 +349,10 @@
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
<button type="submit">开仓(以损定仓)</button>
</form>
<div class="pos-section">
<div class="pos-section-title">实时持仓</div>
<div class="pos-list">
</div>
<div class="card">
<h2 style="margin-bottom:8px">实时持仓</h2>
<div class="panel-scroll pos-list">
{% for o in order %}
<div class="pos-card" id="order-row-{{ o.id }}">
<div class="pos-card-head">
@@ -387,12 +414,13 @@
{% else %}
<div class="pos-empty">暂无持仓</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if page == 'records' %}
<div class="card full records-card">
<h2>交易记录 & 错过机会</h2>
@@ -1149,8 +1177,9 @@ if(keyForm){
alert((data && data.msg) || "日成交量排名读取失败");
return;
}
const rankMax = data.rank_max || 30;
if(!data.in_top30){
alert(`${data.symbol} 当前日成交量排名 ${data.rank}/${data.total},不在前30,已拦截。`);
alert(`${data.symbol} 当前日成交量排名 ${data.rank}/${data.total},不在前${rankMax},已拦截。`);
return;
}
keyForm.submit();
@@ -1164,6 +1193,19 @@ setTimeout(() => {
if(document.getElementById("review-list")) loadReviews();
}, 300);
const MANUAL_MIN_PLANNED_RR = {{ manual_min_planned_rr }};
function calcClientRr(direction, entry, sl, tp){
const e = Number(entry), s = Number(sl), t = Number(tp);
if(!Number.isFinite(e) || !Number.isFinite(s) || !Number.isFinite(t)) return null;
if(direction === 'short'){
if(s <= e || t >= e) return null;
return (e - t) / (s - e);
}
if(s >= e || t <= e) return null;
return (t - e) / (e - s);
}
let latestAvailableUsdt = null;
const lastPriceMap = {};
@@ -1327,11 +1369,11 @@ function refreshAccountSnapshot(){
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
latestAvailableUsdt = Number(data.available_trading_usdt);
}
const canTradeText = data.can_trade ? "可开仓" : "不可开仓(持仓或未到北京时间 {{ reset_hour }}:00";
const canTradeText = data.can_trade ? "可开仓" : `不可开仓(持仓 ${data.active_count||0}/${data.max_active_positions||{{ max_active_positions }}} 或未到北京时间 {{ reset_hour }}:00`;
const tip = document.getElementById("order-rule-tip");
const avail = (latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)) ? `;交易账户可用约 ${formatUsdt2(latestAvailableUsdt)}U` : "";
if(tip){
tip.innerText = `规则:仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x${canTradeText}${avail}`;
tip.innerText = `规则:最多 ${data.max_active_positions || {{ max_active_positions }}} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x${canTradeText}${avail}`;
}
}).catch(()=>{});
}
@@ -1383,10 +1425,99 @@ if(_journalFormEl){
if(_jErSel) _jErSel.addEventListener("change", syncJournalEntryReasonOtherUi);
syncJournalEntryReasonOtherUi();
}
const addOrderForm = document.getElementById("add-order-form");
if(addOrderForm){
addOrderForm.addEventListener("submit", function(ev){
if(addOrderForm.dataset.rrOk === "1"){
addOrderForm.dataset.rrOk = "0";
return;
}
ev.preventDefault();
const direction = (document.getElementById("order-direction")||{}).value || "long";
const mode = (document.getElementById("sltp-mode")||{}).value || "price";
let sl, tp, entry;
if(mode === "pct"){
alert("百分比模式请确认盈亏比后再提交;建议使用价格模式以便校验。");
return;
}
sl = Number((document.getElementById("order-sl")||{}).value);
tp = Number((document.getElementById("order-tp")||{}).value);
entry = sl;
fetch(`/api/order_defaults?symbol=${encodeURIComponent((document.getElementById("order-symbol")||{}).value||"")}&direction=${encodeURIComponent(direction)}`)
.then(r=>r.json())
.then(data=>{
const px = data.last_price || data.price;
if(px) entry = Number(px);
const rr = calcClientRr(direction, entry, sl, tp);
if(rr === null || rr < MANUAL_MIN_PLANNED_RR){
alert(`计划盈亏比 ${rr === null ? '无效' : rr.toFixed(2)}:1 低于最低要求 ${MANUAL_MIN_PLANNED_RR}:1,已阻止人工下单。`);
return;
}
addOrderForm.dataset.rrOk = "1";
addOrderForm.submit();
})
.catch(()=>{ alert("无法校验盈亏比,请稍后重试"); });
});
}
refreshOrderDefaults();
refreshPriceSnapshot();
refreshPriceSnapshotConditional();
setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }});
setInterval(refreshPriceSnapshot, {{ price_refresh_seconds * 1000 }});
function refreshPriceSnapshotConditional(){
const page = document.body.getAttribute("data-page") || "";
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(page === "key_monitor"){
(data.key_prices || []).forEach(k=>{
const pEl = document.getElementById(`key-price-${k.id}`);
if(pEl){ pEl.innerText = k.price_display || (Number.isFinite(Number(k.price)) ? Number(k.price).toFixed(6) : "-"); paintPriceTrend(pEl, `k-${k.id}`, Number(k.price)); }
const upEl = document.getElementById(`key-up-diff-${k.id}`);
if(upEl) upEl.innerText = `${formatSigned(k.upper_diff, 4)} (${formatSigned(k.upper_pct, 2)}%)`;
const lowEl = document.getElementById(`key-low-diff-${k.id}`);
if(lowEl) lowEl.innerText = `${formatSigned(k.lower_diff, 4)} (${formatSigned(k.lower_pct, 2)}%)`;
const gateEl = document.getElementById(`key-gate-${k.id}`);
if(gateEl){ gateEl.innerText = k.gate_summary || "-"; gateEl.style.color = k.gate_ok ? "#4cd97f" : "#ff8f8f"; }
const gateMetricEl = document.getElementById(`key-gate-metrics-${k.id}`);
if(gateMetricEl) gateMetricEl.innerText = k.gate_metrics || "";
});
}
if(page === "trade"){
(data.order_prices || []).forEach(o=>{
const pEl = document.getElementById(`order-price-${o.id}`);
if(pEl){
const hasMark = (()=>{ const x = o.exchange_mark_price; if(x===null||x===undefined||x==="")return false; const n=Number(x); return !Number.isNaN(n); })();
let disp = "";
if(hasMark && o.exchange_mark_price_display) disp = o.exchange_mark_price_display;
else if(o.price_display) disp = o.price_display;
else { const px = hasMark ? Number(o.exchange_mark_price) : Number(o.price); disp = Number.isFinite(px) ? px.toFixed(6) : "-"; }
pEl.innerText = disp;
const pxNum = hasMark ? Number(o.exchange_mark_price) : Number(o.price);
paintPriceTrend(pEl, `o-${o.id}`, Number.isFinite(pxNum) ? pxNum : px);
}
const exM = document.getElementById(`order-ex-margin-${o.id}`);
if(exM){
const mv = o.exchange_initial_margin;
const mn = (mv === null || mv === undefined || mv === "") ? NaN : Number(mv);
if(!Number.isNaN(mn)) exM.innerText = `${mn.toFixed(2)}U`;
else { const prc = (typeof data.positions_raw_count === "number") ? data.positions_raw_count : null; exM.innerText = (prc === 0) ? "无仓数据" : "-"; }
}
const pnlEl = document.getElementById(`order-pnl-${o.id}`);
if(pnlEl){
pnlEl.innerText = `${formatSigned(o.float_pnl, 2)}U (${formatSigned(o.float_pct, 2)}%)`;
pnlEl.classList.remove("price-up","price-down","price-flat");
if(Number(o.float_pnl) > 0) pnlEl.classList.add("price-up");
else if(Number(o.float_pnl) < 0) pnlEl.classList.add("price-down");
else pnlEl.classList.add("price-flat");
}
const rrEl = document.getElementById(`order-rr-${o.id}`);
if(rrEl) rrEl.innerText = formatRrRatio(o.rr_ratio);
});
}
}).catch(()=>{});
}
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
</script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
# 界面与风控更新说明(Gate 实例)
`crypto_monitor_binance` 同版界面结构,交易所对接为 Gate.io。
## 顶栏导航(4 项)
| 顺序 | 名称 | 路由 | 说明 |
|------|------|------|------|
| 1 | 关键位监控 | `/key_monitor` | 关键位添加、实时门控、历史 |
| 2 | 实盘下单 | `/trade` | 人工开仓、划转、实时持仓(**默认首页** `/``/trade` |
| 3 | 交易记录与复盘 | `/records` | 未改动 |
| 4 | 统计分析 | `/stats` | 未改动 |
## 关键位监控页
- 标题去掉「5m」;规则条从 `.env` 读取。
- 左列活跃关键位、右列历史,均为 pos-card 风格,双列对齐。
## 实盘下单页
- 左列下单表单,右列实时持仓。
- 人工开仓盈亏比不得低于 `MANUAL_MIN_PLANNED_RR`(默认 1.4)。
## 持仓与计仓
- `MAX_ACTIVE_POSITIONS` 默认 **1**
- 关键位连开时使用无仓资金快照(见 `KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT`)。
## 配置与升级
1. 合并 `.env.example` 新增项到本地 `.env`
2. 重启 Gate 实例服务。
3. 强刷浏览器缓存。