From ff62666c4d953afd54bd1e2069dc34889078f459 Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 17 May 2026 08:42:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E9=9D=A2=E6=9D=BF=E4=BF=AE?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crypto_monitor_binance/.env.example | 31 +- crypto_monitor_binance/app.py | 175 +++++++-- .../scripts/_layout_snippet.html | 2 + .../scripts/patch_index_layout.py | 358 ++++++++++++++++++ .../scripts/sync_gate_app.py | 116 ++++++ crypto_monitor_binance/templates/index.html | 235 +++++++++--- crypto_monitor_binance/更新文档.md | 37 ++ crypto_monitor_gate/.env.example | 24 +- crypto_monitor_gate/app.py | 176 +++++++-- crypto_monitor_gate/templates/index.html | 225 ++++++++--- crypto_monitor_gate/更新文档.md | 33 ++ 11 files changed, 1229 insertions(+), 183 deletions(-) create mode 100644 crypto_monitor_binance/scripts/_layout_snippet.html create mode 100644 crypto_monitor_binance/scripts/patch_index_layout.py create mode 100644 crypto_monitor_binance/scripts/sync_gate_app.py create mode 100644 crypto_monitor_binance/更新文档.md create mode 100644 crypto_monitor_gate/更新文档.md diff --git a/crypto_monitor_binance/.env.example b/crypto_monitor_binance/.env.example index bb71c57..110d17f 100644 --- a/crypto_monitor_binance/.env.example +++ b/crypto_monitor_binance/.env.example @@ -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%) diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index cb2c483..084c4a1 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -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"])) diff --git a/crypto_monitor_binance/scripts/_layout_snippet.html b/crypto_monitor_binance/scripts/_layout_snippet.html new file mode 100644 index 0000000..d278fe2 --- /dev/null +++ b/crypto_monitor_binance/scripts/_layout_snippet.html @@ -0,0 +1,2 @@ + {% if page == 'key_monitor' %} + diff --git a/crypto_monitor_binance/scripts/patch_index_layout.py b/crypto_monitor_binance/scripts/patch_index_layout.py new file mode 100644 index 0000000..8b68b31 --- /dev/null +++ b/crypto_monitor_binance/scripts/patch_index_layout.py @@ -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"> +

关键位监控

+ {{% if focus_key_id %}} + 放大查看K线(默认200根) + {{% else %}} + 输入币种查看K线 + {{% endif %}} + +
+ + + + + + +
+ <{t} class="rule-tip">{{{{ key_gate_rule_text }}}} + <{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"> + {{{{ k.symbol }}}} + {{{{ '做多' if k.direction == 'long' else '做空' }}}} + {{{{ k.monitor_type }}}} + + + + <{t} class="pos-meta"> + 上沿: {{{{ k.upper }}}} + 下沿: {{{{ k.lower }}}} + 已提醒: {{{{ k.notification_count or 0 }}}}/{{{{ k.max_notify or 3 }}}} + + <{t} class="pos-grid"> + <{t} class="pos-cell">现价- + <{t} class="pos-cell">距上沿- + <{t} class="pos-cell">距下沿- + <{t} class="pos-cell">门控- + + <{t} class="pos-meta" style="margin-top:8px"> + + {{% else %}} + <{t} class="pos-empty">暂无监控中的关键位 + {{% endfor %}} + + + <{t} class="card"> +

关键位历史

+ <{t} class="sub" style="font-size:.72rem;color:#8892b0;margin-bottom:8px">失效或已结案的关键位 + <{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"> + {{{{ h.symbol }}}} + {{{{ '做多' if h.direction == 'long' else '做空' }}}} + + + + <{t} class="pos-meta"> + {{{{ h.monitor_type }}}} + {{{{ h.close_reason }}}} + {{{{ (h.closed_at or '-')[:16] }}}} + + <{t} class="pos-meta"> + 上: {{{{ h.upper }}}} 下: {{{{ h.lower }}}} + 提醒: {{{{ h.notification_count }}}} + + {{% 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 %}}{{% endif %}} + + {{% else %}} + <{t} class="pos-empty">暂无历史 + {{% endfor %}} + + + + {{% 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"> +

实盘下单监控

+ {{% if focus_order_id %}} + 放大查看K线(100根) + {{% else %}} + 暂无持仓可放大 + {{% endif %}} + + <{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} class="rule-tip"> + 以损定仓:风险 {{{{ risk_percent }}}}% |移动保本:下单可勾选关闭;开启时 {{{{ breakeven_rr_trigger }}}}R 触发(每 1R 阶梯上移),偏移 {{{{ breakeven_offset_pct }}}}% + + <{t} class="rule-tip"> + 划转:自动划转 {{{{ '开启' if auto_transfer_enabled else '关闭' }}}}(每天北京时间 {{{{ auto_transfer_bj_hour }}}}:00起该整点小时内尝试;账簿按 UTC 自然日去重;界面时间为北京;将 {{{{ auto_transfer_to }}}} 补足到 {{{{ auto_transfer_amount }}}}U,来自 {{{{ auto_transfer_from }}}}) + +
+ + + + +
+
+ + + + + + + + 成交价自动取交易所实时+成交回报 + + + + + +
+ + <{t} class="card"> +

实时持仓

+ <{t} class="panel-scroll pos-list"> + {order_loop} + + + + {{% endif %}} + +""" + + +def patch_nav(text: str) -> str: + old = '交易执行' + new = ( + '关键位监控\n' + ' 实盘下单' + ) + 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("", '', 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() diff --git a/crypto_monitor_binance/scripts/sync_gate_app.py b/crypto_monitor_binance/scripts/sync_gate_app.py new file mode 100644 index 0000000..81e467d --- /dev/null +++ b/crypto_monitor_binance/scripts/sync_gate_app.py @@ -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") diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html index 29882fb..0398408 100644 --- a/crypto_monitor_binance/templates/index.html +++ b/crypto_monitor_binance/templates/index.html @@ -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} - + {% macro period_stats(title, s) %}

{{ title }}

@@ -175,7 +177,8 @@
{{ exchange_display }}
@@ -200,10 +203,11 @@
实时价格更新时间:--(北京时间 UTC+8)
- {% if page == 'trade' %} -
+ {% if page == 'key_monitor' %} +
+
-

关键位监控(5m)

+

关键位监控

{% if focus_key_id %} 放大查看K线(默认200根) {% else %} @@ -225,44 +229,69 @@ -
+
{{ key_gate_rule_text }}
+
{% for k in key %} -
-
{{ k.symbol }} | {{ k.monitor_type }} | {{ '做多' if k.direction == 'long' else '做空' }}
-
- 上:{{ k.upper }} 下:{{ k.lower }} - | 已提醒:{{ k.notification_count or 0 }}/{{ k.max_notify or 3 }} - | 现价:- - | 距上沿:- - | 距下沿:- - | 门控:- - +
+
+
+ {{ k.symbol }} + {{ '做多' if k.direction == 'long' else '做空' }} + {{ k.monitor_type }} +
+
- +
+ 上沿: {{ k.upper }} + 下沿: {{ k.lower }} + 已提醒: {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }} +
+
+
现价-
+
距上沿-
+
距下沿-
+
门控-
+
+
+ {% else %} +
暂无监控中的关键位
{% endfor %}
-
-

关键位历史(满次提醒或手动删除)

-
每种关键位触发后一次性结案并写入下方历史:箱体/收敛在计划 RR 达标时自动市价开仓;阻力/支撑仅单次企业微信提醒。详见项目根目录「关键位自动下单说明.md」。满多次提醒的旧逻辑已不适用。
-
- {% for h in key_history %} -
-
- {{ h.symbol }} | {{ h.monitor_type }} | {{ '做多' if h.direction == 'long' else '做空' }} | {{ h.close_reason }} - +
+
+

关键位历史

+
失效或已结案的关键位
+
+ {% for h in key_history %} +
+
+
+ {{ h.symbol }} + {{ '做多' if h.direction == 'long' else '做空' }}
-
上:{{ h.upper }} 下:{{ h.lower }} | 提醒次数:{{ h.notification_count }} | {{ (h.closed_at or '-')[:16] }}
- {% if h.last_alert_message %}
{{ h.last_alert_message[:200] }}{% if h.last_alert_message|length > 200 %}…{% endif %}
{% endif %} +
- {% else %} -
暂无历史
- {% endfor %} +
+ {{ h.monitor_type }} + {{ h.close_reason }} + {{ (h.closed_at or '-')[:16] }} +
+
+ 上: {{ h.upper }} 下: {{ h.lower }} + 提醒: {{ h.notification_count }} +
+ {% if h.last_alert_message %}
{{ h.last_alert_message[:180] }}{% if h.last_alert_message|length > 180 %}…{% endif %}
{% endif %}
+ {% else %} +
暂无历史
+ {% endfor %}
- -
+
+ {% elif page == 'trade' %} +
+

实盘下单监控

{% if focus_order_id %} @@ -272,9 +301,9 @@ {% endif %}
- 规则:单仓;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
以损定仓:风险 {{ risk_percent }}% |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% @@ -296,7 +325,7 @@ -
+
-
-
实时持仓
-
+
+
+

实时持仓

+
{% for o in order %}
@@ -387,11 +417,13 @@ {% else %}
暂无持仓
{% endfor %} -
+
{% endif %} + + {% if page == 'records' %}

交易记录 & 错过机会

@@ -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 }}); \ No newline at end of file diff --git a/crypto_monitor_binance/更新文档.md b/crypto_monitor_binance/更新文档.md new file mode 100644 index 0000000..386526b --- /dev/null +++ b/crypto_monitor_binance/更新文档.md @@ -0,0 +1,37 @@ +# 界面与风控更新说明(Binance 实例) + +## 顶栏导航(4 项) + +| 顺序 | 名称 | 路由 | 说明 | +|------|------|------|------| +| 1 | 关键位监控 | `/key_monitor` | 关键位添加、实时门控、历史 | +| 2 | 实盘下单 | `/trade` | 人工开仓、划转、实时持仓(**默认首页** `/` → `/trade`) | +| 3 | 交易记录与复盘 | `/records` | 未改动 | +| 4 | 统计分析 | `/stats` | 未改动 | + +## 关键位监控页 + +- 标题去掉「5m」;规则条从 `.env` 读取(周期、确认K、量能、自动开仓盈亏比、日成交量排名)。 +- 左列:活跃关键位,**pos-card** 样式展示现价/距上沿/距下沿/门控。 +- 右列:关键位历史(失效/结案),与左列等高滚动。 + +## 实盘下单页 + +- 左列:实盘下单监控(表单、划转、规则)。 +- 右列:实时持仓(独立模块)。 +- **人工开仓门控**:计划盈亏比 < `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` 缓存。 diff --git a/crypto_monitor_gate/.env.example b/crypto_monitor_gate/.env.example index cf99c7c..3b64d82 100644 --- a/crypto_monitor_gate/.env.example +++ b/crypto_monitor_gate/.env.example @@ -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 diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 229c746..4832600 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -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"])) diff --git a/crypto_monitor_gate/templates/index.html b/crypto_monitor_gate/templates/index.html index e484f64..14942e2 100644 --- a/crypto_monitor_gate/templates/index.html +++ b/crypto_monitor_gate/templates/index.html @@ -149,7 +149,7 @@ .stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4} - + {% macro period_stats(title, s) %}

{{ title }}

@@ -200,10 +200,11 @@
实时价格更新时间:--(北京时间 UTC+8)
- {% if page == 'trade' %} -
+ {% if page == 'key_monitor' %} +
+
-

关键位监控(5m)

+

关键位监控

{% if focus_key_id %} 放大查看K线(默认200根) {% else %} @@ -225,44 +226,69 @@ -
+
{{ key_gate_rule_text }}
+
{% for k in key %} -
-
{{ k.symbol }} | {{ k.monitor_type }} | {{ '做多' if k.direction == 'long' else '做空' }}
-
- 上:{{ price_fmt(k.symbol, k.upper) }} 下:{{ price_fmt(k.symbol, k.lower) }} - | 已提醒:{{ k.notification_count or 0 }}/{{ k.max_notify or 3 }} - | 现价:- - | 距上沿:- - | 距下沿:- - | 门控:- - +
+
+
+ {{ k.symbol }} + {{ '做多' if k.direction == 'long' else '做空' }} + {{ k.monitor_type }} +
+
- +
+ 上沿: {{ k.upper }} + 下沿: {{ k.lower }} + 已提醒: {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }} +
+
+
现价-
+
距上沿-
+
距下沿-
+
门控-
+
+
+ {% else %} +
暂无监控中的关键位
{% endfor %}
-
-

关键位历史(满次提醒或手动删除)

-
每种关键位触发后一次性结案并写入下方历史:箱体/收敛在计划 RR 达标时自动市价开仓;阻力/支撑仅单次企业微信提醒。详见项目根目录「关键位自动下单说明.md」。满多次提醒的旧逻辑已不适用。
-
- {% for h in key_history %} -
-
- {{ h.symbol }} | {{ h.monitor_type }} | {{ '做多' if h.direction == 'long' else '做空' }} | {{ h.close_reason }} - +
+
+

关键位历史

+
失效或已结案的关键位
+
+ {% for h in key_history %} +
+
+
+ {{ h.symbol }} + {{ '做多' if h.direction == 'long' else '做空' }}
-
上:{{ price_fmt(h.symbol, h.upper) }} 下:{{ price_fmt(h.symbol, h.lower) }} | 提醒次数:{{ h.notification_count }} | {{ (h.closed_at or '-')[:16] }}
- {% if h.last_alert_message %}
{{ h.last_alert_message[:200] }}{% if h.last_alert_message|length > 200 %}…{% endif %}
{% endif %} +
- {% else %} -
暂无历史
- {% endfor %} +
+ {{ h.monitor_type }} + {{ h.close_reason }} + {{ (h.closed_at or '-')[:16] }} +
+
+ 上: {{ h.upper }} 下: {{ h.lower }} + 提醒: {{ h.notification_count }} +
+ {% if h.last_alert_message %}
{{ h.last_alert_message[:180] }}{% if h.last_alert_message|length > 180 %}…{% endif %}
{% endif %}
+ {% else %} +
暂无历史
+ {% endfor %}
- -
+
+ {% elif page == 'trade' %} +
+

实盘下单监控

{% if focus_order_id %} @@ -272,15 +298,15 @@ {% endif %}
- 规则:单仓;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
以损定仓:风险 {{ risk_percent }}% |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
- 划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00起该整点小时内尝试;账簿按 UTC 自然日去重;界面时间为北京;将 {{ auto_transfer_to }} 补足到 {{ usdt_fmt(auto_transfer_amount) }}U,来自 {{ auto_transfer_from }}) + 划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00起该整点小时内尝试;账簿按 UTC 自然日去重;界面时间为北京;将 {{ auto_transfer_to }} 补足到 {{ auto_transfer_amount }}U,来自 {{ auto_transfer_from }})
@@ -296,7 +322,7 @@
-
+
-
-
实时持仓
-
+
+
+

实时持仓

+
{% for o in order %}
@@ -387,12 +414,13 @@ {% else %}
暂无持仓
{% endfor %} -
-
+
{% endif %} + + {% if page == 'records' %}

交易记录 & 错过机会

@@ -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 }}); \ No newline at end of file diff --git a/crypto_monitor_gate/更新文档.md b/crypto_monitor_gate/更新文档.md new file mode 100644 index 0000000..99d0977 --- /dev/null +++ b/crypto_monitor_gate/更新文档.md @@ -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. 强刷浏览器缓存。