diff --git a/crypto_monitor_binance/.env b/crypto_monitor_binance/.env index 5b080ae..dc645c4 100644 --- a/crypto_monitor_binance/.env +++ b/crypto_monitor_binance/.env @@ -60,6 +60,10 @@ BINANCE_TRIGGER_WORKING_TYPE=CONTRACT_PRICE # 关键位监控:5m收线突破过滤参数 KLINE_TIMEFRAME=5m KEY_BREAKOUT_LIMIT_PCT=1.5 +# 关键位自动单:计划 RR 阈值(严格大于该值才开仓,按确认K收盘 E 计算) +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 diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 313e4b7..cb2c483 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -124,6 +124,12 @@ 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")) +ORDER_MONITOR_TYPE_MANUAL = "下单监控" +ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控" +KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"}) +KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({"关键阻力位", "关键支撑位"}) AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true" AUTO_TRANSFER_AMOUNT = float(os.getenv("AUTO_TRANSFER_AMOUNT", "30")) AUTO_TRANSFER_FROM = os.getenv("AUTO_TRANSFER_FROM", "funding") @@ -1089,6 +1095,17 @@ def init_db(): c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 1") except Exception: pass + try: + c.execute(f"ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT DEFAULT '{ORDER_MONITOR_TYPE_MANUAL}'") + except Exception: + pass + try: + c.execute( + "UPDATE order_monitors SET monitor_type=? WHERE monitor_type IS NULL OR TRIM(monitor_type)=''", + (ORDER_MONITOR_TYPE_MANUAL,), + ) + except Exception: + pass try: c.execute("UPDATE order_monitors SET opened_at = datetime('now') WHERE opened_at IS NULL OR opened_at = ''") except: pass @@ -1760,6 +1777,18 @@ def format_price_for_symbol(symbol, value): return text.rstrip("0").rstrip(".") if "." in text else text +def round_price_to_exchange(exchange_symbol, price): + """将价格按 U 本位永续 tick 取整;失败返回 None。""" + if price is None: + return None + try: + ensure_markets_loaded() + sym = normalize_exchange_symbol(exchange_symbol) + return float(exchange.price_to_precision(sym, float(price))) + except Exception: + return None + + def format_hold_minutes(minutes): if not minutes: return "0分钟" @@ -1975,6 +2004,8 @@ def enrich_order_item(raw_item, current_capital): item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 except Exception: item["breakeven_enabled"] = 1 + if not (item.get("monitor_type") or "").strip(): + item["monitor_type"] = ORDER_MONITOR_TYPE_MANUAL return item @@ -3332,113 +3363,383 @@ def calc_price_diff_pct(current_price, target_price): return None, None -def can_notify_key_monitor(row, now_dt): - max_notify = int(row["max_notify"] or KEY_ALERT_MAX_TIMES) - if int(row["notification_count"] or 0) >= max_notify: - return False - last_at = row["last_notified_at"] - if not last_at: - return True +def _finalize_key_monitor_one_shot(conn, row, last_msg, close_reason): + """本条关键位一次性结案:写历史并从当前表删除。""" + n = int(row["notification_count"] or 0) + 1 + insert_key_monitor_history(conn, row, n, last_msg, close_reason) + conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) + + +def _key_hard_lines_from_checks(checks): + return [ + f"量能:{'通过' if checks['vol_ok'] else '不通过'}(突破K量 {round(checks['vol_break'], 4)} / 前20均量 {round(checks['avg20'], 4)},阈值1.3x)", + f"突破价位:{'通过' if checks['breakout_ok'] else '不通过'}(突破K收盘 {round(float(checks['breakout_close']), 8)},关键位 {checks['edge_price']})", + f"突破K幅度:{'通过' if checks['amp_ok'] else '不通过'}({round(checks['amp_pct'], 4)}%,要求0.03%~0.5%)", + f"第二根确认:{'通过' if checks['confirm_ok'] else '不通过'}(确认收盘 {checks['confirm_close']},关键位 {checks['edge_price']})", + f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前30)", + ] + + +def _key_plan_auto_sl_tp(direction, upper, lower, checks, outside_pct): + """ + 计划 SL/TP:止损在突破 K 极值外侧 outside_pct%,止盈为确认收盘 ± 箱体高。 + 返回 (E, raw_sl, raw_tp, box_h)。 + """ + E = float(checks["confirm_close"]) + H = abs(float(upper) - float(lower)) + br_hi = float(checks["breakout_high"]) + br_lo = float(checks["breakout_low"]) + m = float(outside_pct) / 100.0 + if direction == "long": + sl_raw = br_lo * (1.0 - m) if br_lo > 0 else 0.0 + tp_raw = E + H + else: + sl_raw = br_hi * (1.0 + m) if br_hi > 0 else 0.0 + tp_raw = E - H + return E, sl_raw, tp_raw, H + + +def _market_open_for_key_monitor(conn, symbol, direction, exchange_symbol, stop_loss, take_profit): + """ + 与手动「实盘下单」对齐的市价开仓与 order_monitors 写入(Binance U 本位)。 + 返回 (ok: bool, err_msg: Optional[str], detail: Optional[dict]) + """ + now = app_now() + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + return False, f"风控拒绝下单:{reason}", None + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + return False, reason_live, None + + default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + + trading_day = get_trading_day(now) + opens_today_before = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).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"]) + + trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() + if trade_style not in ("trend", "swing"): + trade_style = "trend" + + available_usdt = get_available_trading_usdt() + live_price = get_price(symbol) + if live_price is None: + return False, "获取交易所实时价格失败(以损定仓需要当前价)", None try: - last_dt = datetime.strptime(last_at, "%Y-%m-%d %H:%M:%S") + ensure_markets_loaded() except Exception: - return True - interval_min = int(row["notify_interval_min"] or KEY_ALERT_INTERVAL_MINUTES) - return (now_dt - last_dt).total_seconds() >= interval_min * 60 + pass + lp_adj = round_price_to_exchange(exchange_symbol, live_price) + if lp_adj is not None: + live_price = float(lp_adj) + sl_adj = round_price_to_exchange(exchange_symbol, float(stop_loss)) + tp_adj = round_price_to_exchange(exchange_symbol, float(take_profit)) + if sl_adj is not None: + stop_loss = float(sl_adj) + if tp_adj is not None: + take_profit = float(tp_adj) + + risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) + if risk_fraction is None: + return False, "止损方向不合法(相对当前市价);请核对上下沿与方向", None + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, FUNDS_DECIMALS) + notional_value = round(risk_amount / risk_fraction, FUNDS_DECIMALS) + margin_capital = round(notional_value / leverage, FUNDS_DECIMALS) + + if capital_base and margin_capital > capital_base: + return False, "以损定仓后保证金超过当前交易资金", None + + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), FUNDS_DECIMALS) + if margin_capital > max_margin: + return ( + False, + f"保证金不足:交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}U,当前最多建议 {max_margin}U", + None, + ) + + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 -def breakout_too_far(p, edge_price, limit_pct): try: - if edge_price is None or float(edge_price) <= 0: - return False - diff_pct = abs(float(p) - float(edge_price)) / float(edge_price) * 100 - return diff_pct > float(limit_pct) - except Exception: - return False + amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) + contract_size = get_contract_size(exchange_symbol) + base_amount = round(float(amount) * contract_size, 8) + order_resp = place_exchange_order( + exchange_symbol, direction, amount, leverage, + stop_loss=stop_loss, take_profit=take_profit, + ) + open_order_id = order_resp.get("id", "") + tpsl_attached = bool(order_resp.get("tpsl_attached")) + trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price) + except Exception as e: + return False, friendly_exchange_error(e, available_usdt=available_usdt), None + + tr_adj = round_price_to_exchange(exchange_symbol, trigger_price) + if tr_adj is not None: + trigger_price = float(tr_adj) + sl_f = round_price_to_exchange(exchange_symbol, stop_loss) + if sl_f is not None: + stop_loss = float(sl_f) + tp_f = round_price_to_exchange(exchange_symbol, take_profit) + if tp_f is not None: + take_profit = float(tp_f) + + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + + planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) or risk_amount + + if direction == "short": + breakeven_price = round(float(trigger_price) * (1 - breakeven_offset_pct / 100.0), 8) + else: + breakeven_price = round(float(trigger_price) * (1 + breakeven_offset_pct / 100.0), 8) + breakeven_enabled = 1 + + conn.execute( + "INSERT INTO order_monitors " + "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " + "margin_capital, leverage, trade_style, risk_percent, risk_amount, " + "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " + "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent, + risk_amount_final, + breakeven_rr_trigger, + breakeven_offset_pct, + breakeven_step_r, + 0, + breakeven_price, + breakeven_enabled, + notional_value, + position_ratio, + base_amount, + amount, + open_order_id, + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + ), + ) + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + opens_today_after = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + + return True, None, { + "new_order_id": new_order_id, + "open_order_id": open_order_id, + "trigger_price": trigger_price, + "planned_rr_fill": planned_rr, + "risk_amount_final": risk_amount_final, + "margin_capital": margin_capital, + "leverage": leverage, + "amount": amount, + "base_amount": base_amount, + "notional_value": notional_value, + "position_ratio": position_ratio, + "tpsl_attached": tpsl_attached, + "opens_today_before": opens_today_before, + "opens_today_after": opens_today_after, + "trading_day": trading_day, + "risk_percent": risk_percent, + "breakeven_rr_trigger": breakeven_rr_trigger, + "breakeven_price": breakeven_price, + "capital_base_at_open": capital_base, + } -# 关键位监控 +# 关键位监控(箱体/收敛可自动开仓;阻力/支撑位仅单次提醒结案) def check_key_monitors(): conn = get_db() rows = conn.execute("SELECT * FROM key_monitors").fetchall() for r in rows: - sym, typ, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] + sym, typ_raw, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] + typ = (typ_raw or "").strip() direction = (r["direction"] or "long").lower() - now_dt = app_now() - if not can_notify_key_monitor(r, now_dt): - continue try: checks = _key_hard_checks(sym, direction, up, low, typ) except Exception: checks = {"ok": False} if not checks.get("ok"): continue + btc8h_status, _, _ = _status_by_ema55("BTC/USDT", "8h") coin4h_status, _, _ = _status_by_ema55(sym, "4h") risk_tip = None if (direction == "long" and coin4h_status == "空头") or (direction == "short" and coin4h_status == "多头"): risk_tip = "当前信号与本币4h(EMA55)主趋势逆势,建议降低仓位并严格执行止损。" - box_h = abs(float(up) - float(low)) if up is not None and low is not None else 0.0 - c_close = float(checks.get("confirm_close") or 0) - b_high = float(checks.get("breakout_high") or 0) - b_low = float(checks.get("breakout_low") or 0) + key_price = float(low) if direction == "long" else float(up) - if direction == "long": - tp1 = c_close + box_h - tp2 = c_close + box_h * 1.5 - sl1 = b_low * (1 - 0.002) if b_low > 0 else None - sl2 = key_price * (1 - 0.002) if key_price > 0 else None - else: - tp1 = c_close - box_h - tp2 = c_close - box_h * 1.5 - sl1 = b_high * (1 + 0.002) if b_high > 0 else None - sl2 = key_price * (1 + 0.002) if key_price > 0 else None - hard_lines = [ - f"量能:{'通过' if checks['vol_ok'] else '不通过'}(突破K量 {round(checks['vol_break'], 4)} / 前20均量 {round(checks['avg20'], 4)},阈值1.3x)", - f"突破价位:{'通过' if checks['breakout_ok'] else '不通过'}(突破K收盘 {round(float(checks['breakout_close']), 8)},关键位 {checks['edge_price']})", - f"突破K幅度:{'通过' if checks['amp_ok'] else '不通过'}({round(checks['amp_pct'], 4)}%,要求0.03%~0.5%)", - f"第二根确认:{'通过' if checks['confirm_ok'] else '不通过'}(确认收盘 {checks['confirm_close']},关键位 {checks['edge_price']})", - f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前30)", - ] - op_lines = [ - f"方案A:止盈=箱体1.0倍({round(tp1, 8) if tp1 else '-' }),止损=突破K极值外0.2%({round(sl1, 8) if sl1 else '-' })", - f"方案B:止盈=箱体1.5倍({round(tp2, 8) if tp2 else '-' }),止损=箱体关键位外0.2%({round(sl2, 8) if sl2 else '-' })", - ] + hard_lines = _key_hard_lines_from_checks(checks) trigger_time = ms_to_app_local_str(int(checks["confirm_ts"])) if checks.get("confirm_ts") else app_now_str() - msg = build_wechat_key_monitor_message( - symbol=sym, - direction=direction, - monitor_type=typ, - trigger_time=trigger_time, - key_price=key_price, - confirm_close=checks["confirm_close"], - hard_lines=hard_lines, - btc8h_status=btc8h_status, - coin4h_status=coin4h_status, - swing4h_pct=checks.get("swing4h_pct") or 0.0, - op_lines=op_lines, - risk_tip=risk_tip, + + alert_only = typ in KEY_MONITOR_ALERT_ONLY_TYPES or ( + typ not in KEY_MONITOR_AUTO_TYPES and typ not in KEY_MONITOR_ALERT_ONLY_TYPES ) - send_wechat_msg(msg) - new_count = int(r["notification_count"] or 0) + 1 - max_n = int(r["max_notify"] or KEY_ALERT_MAX_TIMES) - conn.execute( - "UPDATE key_monitors SET notification_count = ?, last_notified_at = ? WHERE id = ?", - (new_count, app_now_str(), r["id"]), - ) - if new_count >= max_n: - insert_key_monitor_history(conn, r, new_count, msg, "alerts_complete") - conn.execute("DELETE FROM key_monitors WHERE id = ?", (r["id"],)) - send_wechat_msg( - "\n".join( - [ - f"# 🧾 {r['symbol']} 关键位监控结束", - "", - f"- 原因:已满 {max_n} 次提醒", - "- 状态:已自动结束并记入历史", - ] - ) + + if alert_only: + op_lines = [ + "- 本条为关键阻力/支撑或非标类型:**仅单次推送**,不进行自动开仓。", + "- 本条关键位将在推送后记入历史并从监控列表移除。", + ] + msg = build_wechat_key_monitor_message( + symbol=sym, + direction=direction, + monitor_type=typ, + trigger_time=trigger_time, + key_price=key_price, + confirm_close=checks["confirm_close"], + hard_lines=hard_lines, + btc8h_status=btc8h_status, + coin4h_status=coin4h_status, + swing4h_pct=checks.get("swing4h_pct") or 0.0, + op_lines=op_lines, + risk_tip=risk_tip, ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "key_level_alert_only") + continue + + E, sl_raw, tp_raw, box_h = _key_plan_auto_sl_tp( + direction, up, low, checks, KEY_STOP_OUTSIDE_BREAKOUT_PCT, + ) + exchange_symbol = normalize_exchange_symbol(sym) + try: + ensure_markets_loaded() + except Exception: + pass + sl_px = round_price_to_exchange(exchange_symbol, sl_raw) + tp_px = round_price_to_exchange(exchange_symbol, tp_raw) + if sl_px is not None: + sl_raw = float(sl_px) + if tp_px is not None: + tp_raw = float(tp_px) + + planned_rr = calc_rr_ratio(direction, E, sl_raw, tp_raw) + rr_ok = planned_rr is not None and planned_rr > KEY_AUTO_MIN_PLANNED_RR + + if not rr_ok: + fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算(止损/止盈与确认价几何关系无效)" + rr_msg = ( + f"# ⚠️ {sym} 关键位自动单:计划 RR 未达标\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" + f"- 箱体高 H:`{format_price_for_symbol(sym, box_h)}`\n" + f"- 计划止损(突破K外侧 {KEY_STOP_OUTSIDE_BREAKOUT_PCT}%):`{format_wechat_scalar_2dp(sl_raw)}`\n" + f"- 计划止盈(E±1×H):`{format_price_for_symbol(sym, tp_raw)}`\n" + f"- **计划 RR(按确认收盘 E):{fmt_rr} : 1**(要求 **>{KEY_AUTO_MIN_PLANNED_RR}:1**,未开仓)\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(rr_msg) + _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient") + continue + + ok_trade, trade_err, det = _market_open_for_key_monitor( + conn, sym, direction, exchange_symbol, sl_raw, tp_raw, + ) + planned_rr_txt = ( + format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" + ) + if not ok_trade: + fail_msg = ( + f"# ❌ {sym} 关键位自动单失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" + f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n" + f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n" + f"- **计划 RR(按 E):{planned_rr_txt} : 1**(已通过 RR 阈值)\n" + f"- **失败原因:{trade_err}**\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + fail_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(fail_msg) + _finalize_key_monitor_one_shot(conn, r, fail_msg, "exchange_failed") + continue + + tpsl_txt = ( + "已在交易所挂止盈/止损触发单(Binance U 本位条件单)" + if det.get("tpsl_attached") + else "⚠️ 条件单挂接状态异常或未挂上" + ) + rr_fill = det.get("planned_rr_fill") + rr_fill_txt = format_wechat_scalar_2dp(rr_fill) if rr_fill is not None else "-" + + succ_msg_lines = [ + f"# ✅ {sym} 关键位自动开仓成功", + f"**账户:{_wechat_account_label()}**", + f"- **来源:**{ORDER_MONITOR_TYPE_KEY_AUTO}(市价)", + f"- 页面订单 ID:**{det['new_order_id']}**", + f"- 交易所订单 ID:`{det.get('open_order_id') or '-'}`", + f"- 类型:{typ}|{_wechat_direction_text(direction)}", + f"- 触发时间:`{trigger_time}`", + f"- 确认K收盘(E):{format_price_for_symbol(sym, E)}(RR 阈值按此计价)", + f"- **计划 RR(E):{planned_rr_txt}:1**", + f"- 开仓成交价:**{format_price_for_symbol(sym, det['trigger_price'])}**", + f"- **成交价侧计划 RR:**{rr_fill_txt}:1", + f"- 止损:{format_wechat_scalar_2dp(sl_raw)}", + f"- 止盈:{format_price_for_symbol(sym, tp_raw)}", + f"- 风险:{det.get('risk_percent')}%≈{format_wechat_scalar_2dp(det.get('risk_amount_final'))}U|基数 {format_wechat_scalar_2dp(det.get('margin_capital'))}U|杠杆 {det.get('leverage')}x", + f"- 名义 {format_wechat_scalar_2dp(det.get('notional_value'))}U|张数 {format_wechat_scalar_2dp(det.get('amount'))}|折算标的 {det.get('base_amount')}", + f"- **{tpsl_txt}**", + f"- 保本触发:{det.get('breakeven_rr_trigger')}R→{format_price_for_symbol(sym, det.get('breakeven_price'))}", + f"- 当日开仓次数:**{det.get('opens_today_after')}** / {DAILY_OPEN_ALERT_THRESHOLD}(提醒阈值)", + ] + succ_msg_lines.extend(["---", "### 硬条件"] + [f"- {x}" for x in hard_lines]) + if risk_tip: + succ_msg_lines.extend(["---", "### 逆势风险提示", f"- {risk_tip}"]) + succ_msg = "\n".join(succ_msg_lines) + send_wechat_msg(succ_msg) + _finalize_key_monitor_one_shot(conn, r, succ_msg, "auto_opened") + + if det.get("opens_today_before", 0) < DAILY_OPEN_ALERT_THRESHOLD <= det.get("opens_today_after", 0): + advice = ai_short_advice( + f"用户在北京时间交易日 {det['trading_day']} 已累计开仓 {det['opens_today_after']} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。" + f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x。" + f"用户自述“上头了”。请给克制提醒。" + ) + if advice: + send_wechat_msg(f"【AI提醒】今日开仓次数已达 {det['opens_today_after']}\n{advice[:800]}") conn.commit() conn.close() @@ -4409,6 +4710,15 @@ def add_key(): if not symbol: flash("symbol 不能为空") return redirect("/") + direction_sel = (d.get("direction") or "").strip().lower() + if direction_sel not in ("long", "short"): + flash("请选择做多或做空") + return redirect("/") + 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("/") rank, total = _daily_volume_rank(symbol) if rank is None: flash("日成交量排名读取失败,请稍后重试") @@ -4417,11 +4727,44 @@ def add_key(): flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前30,已拒绝添加关键位") return redirect("/") conn = get_db() - conn.execute("INSERT INTO key_monitors (symbol,monitor_type,direction,upper,lower) VALUES (?,?,?,?,?)", - (symbol, d["type"], d.get("direction", "long"), d["upper"], d["lower"])) + 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: + conn.close() + flash( + "当前已有实盘持仓:无法添加「箱体突破 / 收敛突破」(会自动开仓)。请平仓后再试,或使用「关键阻力位/关键支撑位」(仅单次提醒)。" + ) + return redirect("/") + ex_sym_key = normalize_exchange_symbol(symbol) + try: + ensure_markets_loaded() + except Exception: + pass + uh = round_price_to_exchange(ex_sym_key, float(d["upper"])) + lw = round_price_to_exchange(ex_sym_key, float(d["lower"])) + upper_px = float(uh) if uh is not None else float(d["upper"]) + lower_px = float(lw) if lw is not None else float(d["lower"]) + conn.execute( + "INSERT INTO key_monitors (symbol,monitor_type,direction,upper,lower) VALUES (?,?,?,?,?)", + (symbol, mt, direction_sel, upper_px, lower_px), + ) conn.commit() conn.close() + ctr = False + try: + coin4h_status, _, _ = _status_by_ema55(symbol, "4h") + ctr = (direction_sel == "long" and coin4h_status == "空头") or ( + direction_sel == "short" and coin4h_status == "多头" + ) + except Exception: + pass flash(f"添加成功({symbol} 日成交量排名 {rank}/{total})") + if ctr: + flash( + "⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。" + ) return redirect("/") @app.route("/add_order", methods=["POST"]) @@ -4578,12 +4921,13 @@ def add_order(): breakeven_price = round(float(trigger_price) * (1 + breakeven_offset_pct / 100.0), 8) breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0 conn.execute( - "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price, breakeven_enabled, - notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day + notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day, + ORDER_MONITOR_TYPE_MANUAL, ) ) conn.commit() diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html index 2d8f9a1..29882fb 100644 --- a/crypto_monitor_binance/templates/index.html +++ b/crypto_monitor_binance/templates/index.html @@ -87,7 +87,7 @@ .review-card{grid-column:1/-1} @media (min-width: 1900px){ .container{max-width:2100px} - .monitor-card .list,.order-card .list{max-height:420px} + .monitor-card .list,.order-card .pos-list{max-height:420px} .records-card .table-wrap{max-height:620px;overflow:auto} } @media (max-width: 1400px){ @@ -112,6 +112,34 @@ .key-history h3{font-size:.88rem;color:#b8c4ff;margin-bottom:6px} .key-history .sub{font-size:.72rem;color:#8892b0;margin-bottom:6px} .key-history .list{max-height:200px} + .pos-section{margin-top:12px} + .pos-section-title{font-size:.82rem;color:#8892b0;margin-bottom:8px;font-weight:500} + .pos-list{display:flex;flex-direction:column;gap:10px;max-height:280px;overflow:auto} + .pos-card{background:#141923;border:1px solid #2a3348;border-radius:10px;padding:12px 14px} + .pos-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px} + .pos-meta{font-size:.74rem;color:#8b95a8;line-height:1.45;margin-bottom:12px;display:flex;flex-wrap:wrap;align-items:center;gap:4px 0} + .pos-meta-item{display:inline-flex;align-items:center} + .pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659} + .pos-meta-on{color:#6eb5ff} + .pos-meta-off{color:#7d8799} + .pos-card-symbol{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0} + .pos-card-symbol strong{font-size:.95rem;color:#fff;font-weight:600} + .pos-side-badge{padding:3px 8px;border-radius:6px;font-size:.72rem;font-weight:500;line-height:1.2} + .pos-side-long{background:#253a6e;color:#6eb5ff} + .pos-side-short{background:#4a2230;color:#ff8a8a} + .pos-close-btn{padding:6px 14px;background:#c45454;color:#fff;border-radius:8px;text-decoration:none;font-size:.82rem;font-weight:500;flex-shrink:0;white-space:nowrap} + .pos-close-btn:hover{background:#d66565;color:#fff} + .pos-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px 14px;margin-bottom:12px} + .pos-cell{display:flex;flex-direction:column;gap:4px;min-width:0} + .pos-label{font-size:.72rem;color:#7d8799} + .pos-value{font-size:.88rem;color:#e8ecf4;font-weight:500;line-height:1.25} + .pos-val-dash{opacity:.75;color:#8b95a8} + .pos-value.price-up{color:#4cd97f} + .pos-value.price-down{color:#ff6666} + .pos-value.price-flat{color:#e8ecf4} + .pos-footer{display:flex;flex-wrap:wrap;gap:14px 18px;font-size:.75rem;color:#6d7689} + .pos-empty{padding:18px;text-align:center;color:#8892b0;font-size:.85rem;background:#141923;border:1px dashed #2a3348;border-radius:10px} + @media (max-width:520px){.pos-grid{grid-template-columns:repeat(2,1fr)}} .stats-card{grid-column:1/-1;margin-top:14px} .stats-card .stats-toggle{background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;padding:6px 10px;cursor:pointer} .stats-card.collapsed .stats-content{display:none} @@ -216,7 +244,7 @@

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

-
满 {{ key_alert_max_times }} 次企业微信提醒后自动移入此处;手动删除也会归档。
+
每种关键位触发后一次性结案并写入下方历史:箱体/收敛在计划 RR 达标时自动市价开仓;阻力/支撑仅单次企业微信提醒。详见项目根目录「关键位自动下单说明.md」。满多次提醒的旧逻辑已不适用。
{% for h in key_history %}
@@ -295,24 +323,71 @@ -
+
+
实时持仓
+
{% for o in order %} -
-
{{ o.symbol }} | {{ '做多' if o.direction == 'long' else '做空' }}
-
- 风格:{{ o.trade_style or 'trend' }} | 风险:{{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U - | {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %} -
- 成交:{{ price_fmt(o.symbol, o.trigger_price) }} 止损:{{ price_fmt(o.symbol, o.stop_loss) }} 止盈:{{ price_fmt(o.symbol, o.take_profit) }} - | 盈亏比:{% if o.rr_ratio is not none %}1:{{ '%.2f'|format(o.rr_ratio) }}{% else %}-{% endif %} - | 现价:- - | 浮盈亏:- - | 计划基数:{{ funds_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U | 所保证金:- - | 杠杆:{{ o.leverage }}x | 仓位占比:{{ o.position_ratio }}% +
+
+
+ {{ o.exchange_symbol or o.symbol }} + {{ '做多' if o.direction == 'long' else '做空' }} +
+ 平仓 +
+
+ 来源: {{ o.monitor_type|default('下单监控', true) }} + 风格: {{ o.trade_style or 'trend' }} + 风险: {{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U + + {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %} + +
+
+
+ 成交价 + {{ price_fmt(o.symbol, o.trigger_price) }} +
+
+ 止损 + {% if o.stop_loss %} + {{ price_fmt(o.symbol, o.stop_loss) }} + {% else %} + + {% endif %} +
+
+ 止盈 + {% if o.take_profit %} + {{ price_fmt(o.symbol, o.take_profit) }} + {% else %} + + {% endif %} +
+
+ 盈亏比 + {% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %} +
+
+ 标记价 + - +
+
+ 浮盈亏 + - +
+
+ - 平仓
+ {% else %} +
暂无持仓
{% endfor %} +
{% endif %} @@ -1098,6 +1173,14 @@ function formatSigned(v, digits=2){ return `${sign}${n.toFixed(digits)}`; } +function formatRrRatio(rr){ + if(rr === null || typeof rr === "undefined") return "-:1"; + const n = Number(rr); + if(Number.isNaN(n)) return "-:1"; + const body = Number.isInteger(n) ? String(n) : String(parseFloat(n.toFixed(2))); + return `${body}:1`; +} + function paintPriceTrend(el, key, value){ if(!el) return; const prev = lastPriceMap[key]; @@ -1180,7 +1263,7 @@ function refreshPriceSnapshot(){ } const rrEl = document.getElementById(`order-rr-${o.id}`); if(rrEl){ - rrEl.innerText = (typeof o.rr_ratio !== "undefined" && o.rr_ratio !== null) ? `1:${Number(o.rr_ratio).toFixed(2)}` : "-"; + rrEl.innerText = formatRrRatio(o.rr_ratio); } }); }).catch(()=>{}); diff --git a/crypto_monitor_binance/使用说明.md b/crypto_monitor_binance/使用说明.md new file mode 100644 index 0000000..bf94b63 --- /dev/null +++ b/crypto_monitor_binance/使用说明.md @@ -0,0 +1,129 @@ +# 使用说明 + +**本文件对应仓库:`crypto_monitor_binance`(Binance U 本位永续)。** +功能、界面与 **Gate.io USDT 永续版**(目录 `crypto_monitor_gate`)基本一致,差异主要在 **`.env` 里交易所密钥与部分参数名**(`BINANCE_*` / `GATE_*`),文末有对照。 + +**部署、代理、PM2 等**请参考本仓库说明或 **`crypto_monitor_gate`** 下的 **`部署文档.md`**(该文以 Gate + SSH SOCKS 为例;Binance 侧将 API 与密钥改为 `BINANCE_*` 即可类比)。 +**关键位自动开仓的规则、RR、结案原因**见本目录 **`关键位自动下单说明.md`**。 + +--- + +## 1. 它能做什么 + +面向个人盘面的 **Web 控制台**,主要能力包括: + +| 模块 | 说明 | +|------|------| +| **关键位监控** | 录入上/下沿与类型,按 **5m 收线** 做硬条件过滤;符合条件后 **企业微信** 提醒,部分类型可 **自动市价开仓**(见第 4 节与专门文档)。 | +| **实盘下单监控** | 手工填止损/止盈,**以损定仓** 市价开单,挂上条件止盈止损,并在页面跟踪浮盈亏、保本逻辑等。 | +| **交易记录 / 复盘** | 平仓结果、盈亏、错过的单等归档与导出。 | + +后台按 **`MONITOR_POLL_SECONDS`**(默认几秒)轮询行情与监控逻辑。**切勿**在未理解规则时同时运行两套程序共用一个实盘账户。 + +--- + +## 2. 运行前必须配置(`.env`) + +至少检查以下项(具体键名以你仓库 `.env` 示例为准): + +| 类别 | 说明 | +|------|------| +| **登录网页** | `APP_PASSWORD`:打开站点后的登录口令。`FLASK_SECRET_KEY`:Session 密钥,请勿使用默认值。 | +| **企业微信** | `WECHAT_WEBHOOK`:告警与关键位推送机器人的 Webhook。 | +| **是否真下单** | `LIVE_TRADING_ENABLED=false`:**不会**向交易所发送开仓指令(适合测试流程)。改为 `true` 且密钥正确才会实盘。 | +| **交易所 API** | **本仓库:** `BINANCE_API_KEY`、`BINANCE_API_SECRET`;永续相关见 `BINANCE_MARGIN_MODE`、`BINANCE_POSITION_MODE`、`BINANCE_TRIGGER_WORKING_TYPE` 等。**勿**把 `.env` 提交到 Git。 | +| **关键位 RR / 止损外扩** | `KEY_AUTO_MIN_PLANNED_RR`、`KEY_STOP_OUTSIDE_BREAKOUT_PCT`(详见 `关键位自动下单说明.md`)。 | + +网络需要代理时可配置 **`BINANCE_SOCKS_PROXY` / `BINANCE_HTTP_PROXY`**(与 Gate 版 `GATE_*_PROXY` 用法类似)。 + +--- + +## 3. 如何启动与登录 + +1. 准备 Python 虚拟环境并安装依赖(如 `flask`、`requests`、`ccxt`、按需 `Pillow`、`PySocks` 等),配置好 `.env`。 +2. 启动 Flask 应用(可用 **`ecosystem.config.cjs`** 交给 PM2,或本地 `python app.py` / `flask run`,以你当前脚本为准)。 +3. 浏览器访问站点,打开 **`/login`**,使用 **`.env` 里的 `APP_PASSWORD`** 登录。 + +--- + +## 4. 关键位监控(页面「关键位监控」卡片) + +### 4.1 添加一条关键位 + +1. **币种**:如 `BTC` 或 `BTC/USDT`(会规范成内部符号)。 +2. **类型**(必选其一): + + | 类型 | 行为摘要 | + |------|----------| + | **箱体突破** | 通过门控且计划 RR 达标 → **自动市价开仓**(需 `LIVE_TRADING_ENABLED=true` 且无其他持仓占位)。结案后本条从列表消失并记入历史。 | + | **收敛突破** | 同上(自动开仓类)。 | + | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 | + | **关键支撑位** | 同上(仅提醒)。 | + +3. **方向**:做多 / 做空(必选)。 +4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整。 + +**限制:** +当前已有 **`order_monitors` 活跃持仓** 时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。 +若 **4h EMA55** 与你的方向逆势,页面会 **额外 Flash 提示**,**不阻挡**提交。 + +### 4.2 触发后会发生什么(简版) + +- **箱体 / 收敛**:门控通过后算计划 SL/TP 与 RR;不达标 → 微信说明 + **`rr_insufficient`** 结案;达标 → **市价开仓**,成功 **`auto_opened`** / 失败 **`exchange_failed`**,均不重试同一关键位。 +- **阻力 / 支撑**:仅 **单次推送** → **`key_level_alert_only`** 结案。 + +详细公式与字段见 **`关键位自动下单说明.md`**。 + +### 4.3 列表与历史 + +当前条目与历史记录的用法与 Gate 版相同;结案后可在历史区查阅 **`close_reason`**。 + +--- + +## 5. 实盘下单监控(手工开仓) + +- **同一时间系统只允许一笔活跃持仓监视**(与关键位自动单共用该限制)。 +- 填写币种、方向、杠杆(可选)、止损/止盈(价格或百分比按表单)。 +- 移动保本等选项按页面与 `.env` 默认。 + +开仓成功后卡片 **「来源」**:手工一般为 **下单监控**;关键位自动为 **关键位监控**。 + +--- + +## 6. 企业微信 + +推送逻辑与 Gate 版一致;未配置 **`WECHAT_WEBHOOK`** 时可能没有消息,请以 **交易所端** 核对持仓与挂单。 + +--- + +## 7. 强烈建议的风险与运维习惯 + +1. **先用 `LIVE_TRADING_ENABLED=false`** 熟悉流程再实盘。 +2. **API 权限**最小化,密钥勿泄露。 +3. **同一账户避免多程序重复开仓**。 +4. **备份数据库**后再升级迁移。 +5. 升级代码后留意 **首轮启动**有无数据库迁移报错。 + +--- + +## 8. 常见问题(简要) + +| 现象 | 可自查 | +|------|--------| +| 关键位永远不触发 | 门控五项、日成交量排名、`KLINE_TIMEFRAME`。 | +| 有信号但不自动开仓 | `LIVE_TRADING_ENABLED`、RR 阈值、是否已有持仓、API/保证金错误信息。 | +| 加不了箱体/收敛 | 是否已有持仓。 | +| 推送收不到 | Webhook、网络。 | + +--- + +## 9. Gate 版(`crypto_monitor_gate`)差异速查 + +| 项目 | Binance 本仓库 | Gate 版 | +|------|----------------|--------| +| API 变量 | `BINANCE_API_KEY`、`BINANCE_API_SECRET`、`BINANCE_*` | `GATE_API_KEY`、`GATE_API_SECRET`、`GATE_*` | +| 代理示例 | `BINANCE_SOCKS_PROXY` | `GATE_SOCKS_PROXY` | +| TP/SL 实现 | `_binance_place_tp_sl_orders` | `_gate_place_tp_sl_orders`、`GATE_TPSL_*` | +| 资金舍入口径 | **`FUNDS_DECIMALS`**(与记账一致) | 以 Gate 仓库实现为准 | + +业务流程(登录、四种关键位、手工单、单仓)两份程序对齐;仅需更换目录与 `.env`。 diff --git a/crypto_monitor_binance/关键位自动下单说明.md b/crypto_monitor_binance/关键位自动下单说明.md new file mode 100644 index 0000000..3ff52c2 --- /dev/null +++ b/crypto_monitor_binance/关键位自动下单说明.md @@ -0,0 +1,98 @@ +# 关键位自动下单说明 + +**适用仓库:`crypto_monitor_binance`|交易所:Binance U 本位永续**(Gate 版见同名的 `crypto_monitor_gate` 目录。) + +本文档与 `.env`、`app.check_key_monitors`、`app.add_key`、`_market_open_for_key_monitor` 的实现一致。 + +--- + +## 结构与是否自动开仓 + +| `key_monitors.monitor_type`(录入类型) | 自动下单 | 触发后处置 | +|---------------------------------------|----------|------------| +| **箱体突破** | 是(满足全部条件) | **一次性结案**:写 `key_monitor_history` → 从 `key_monitors` **删除** | +| **收敛突破** | 是(同上) | 同上 | +| **关键阻力位** | 否 | 企业微信 **1 次** → `close_reason=key_level_alert_only` → **失效** | +| **关键支撑位** | 否 | 同上 | + +触发条件:**5m 收线硬门控** `_key_hard_checks`(量能、突破幅度、第二根收盘确认、日成交量前 30 等)。 + +--- + +## 录入限制(`/add_key`) + +- 存在 **`order_monitors.status='active'`** 时:**禁止添加** 「箱体突破」「收敛突破」。 +- **关键阻力位 / 关键支撑位**:不受上条限制;触发后 **仅单次微信提醒**,然后结案。 +- **4h EMA55 与所选方向逆势**:**不拦截**;添加成功后 **Flash** 提示。 +- 上下沿入库前经 **`round_price_to_exchange`** 按合约 **价格精度** 取整。 + +--- + +## 环境与参数(`.env`) + +| 变量 | 含义 | 默认 | +|------|------|------| +| `KEY_AUTO_MIN_PLANNED_RR` | 计划 RR 阈值:**仅当严格大于该值** 才自动开仓(按下方 `E` 计算) | `1.5` | +| `KEY_STOP_OUTSIDE_BREAKOUT_PCT` | 止损:突破 K 极值向外 **百分比**(多:`低×(1−p/100)`;空:`高×(1+p/100)`) | `0.5` | + +**其余与本仓库手动实盘一致:** `KLINE_TIMEFRAME`、`RISK_PERCENT`、`LIVE_TRADING_ENABLED`、`BREAKEVEN_*`、`DAILY_OPEN_ALERT_THRESHOLD`,以及 **`BINANCE_*`**(密钥、`BINANCE_MARGIN_MODE`、`BINANCE_POSITION_MODE`、`BINANCE_TRIGGER_WORKING_TYPE` 等)。资金字段舍入端口径与 **`FUNDS_DECIMALS`** 一致。 + +--- + +## 计价与下单口径 + +| 用途 | 价格 | +|------|------| +| 企业微信展示、**与 RR 门槛比较的计划 RR** | 确认 K(第二根闭合 5m)收盘 **`E`** | +| **实际开仓** | **市价**(`place_exchange_order`,与 `/add_order` 一致);成交价可能与 `E` **滑点** | +| **以损定仓** | `calc_risk_fraction(direction, 当前市价, 止损)` + `RISK_PERCENT`(保证金等 **`FUNDS_DECIMALS`** 舍入,与 `/add_order` 一致) | + +- 开仓成功后:`order_monitors.monitor_type` 为 **关键位监控**;持仓卡片「来源」显示之。手动开仓为 **下单监控**。 +- 持仓列表中的 **盈亏比**:按 **实际成交价** 相对 SL/TP 重算,可与「按 `E` 算的计划 RR」略有偏差。 +- **本仓库止盈止损挂单**:开仓后由 **`_binance_place_tp_sl_orders`** 挂载(与手动一致:U 本位条件/Algo 类触发单;具体类型以 ccxt / 交易所为准)。 + +--- + +## 自动单止盈 / 止损(仅箱体突破、收敛突破) + +设箱体高度 **`H = |upper − lower|`**(录入上下沿)。 + +| 方向 | 止损 SL | 止盈 TP | +|------|---------|---------| +| 多 | 突破 K **最低价** × (1 − `KEY_STOP_OUTSIDE_BREAKOUT_PCT`/100) | **`E + 1×H`** | +| 空 | 突破 K **最高价** × (1 + `KEY_STOP_OUTSIDE_BREAKOUT_PCT`/100) | **`E − 1×H`** | + +计划 **`RR = calc_rr_ratio(direction, E, SL, TP)`**。若为 `None` 或 **RR ≤ `KEY_AUTO_MIN_PLANNED_RR`** → **不下单**,走 `rr_insufficient` 结案。 + +--- + +## 一次性结案(`close_reason`) + +以下任一发生:**按需发微信** → **`key_monitor_history`** → **从 `key_monitors` 删除**;**不会对同一条关键位重复轮询重试开仓**。 + +| `close_reason` | 含义 | +|----------------|------| +| `rr_insufficient` | 门控通过,但计划 RR 未达标或 SL/TP / RR **几何无效** | +| `exchange_failed` | 计划 RR 达标,但未开实盘、`LIVE_TRADING_ENABLED=false`、风控、保证金或 **交易所报错** 等导致 **开仓失败** | +| `auto_opened` | 计划 RR 达标且 **市价开仓成功**(已写 `order_monitors`,并已挂止盈止损) | +| `key_level_alert_only` | 阻力/支撑位 **仅推送**结案 | + +--- + +## 与企业微信推送 + +每种结案路径 **至多一条**主业务推送(RR 不足 / 下单失败 / 开仓成功 / 阻力支撑仅提醒)。 + +旧版「满 `KEY_ALERT_MAX_TIMES` 次再归档」对已触发结案的路径 **不再适用**;表中 `notification_count`、`max_notify` 等字段仍可能存在,以 **导出、兼容** 为主。 + +--- + +## 相关代码位置(通用) + +| 说明 | 符号 | +|------|------| +| 门控与主循环 | `check_key_monitors` | +| 录入、有仓拦截、4h Flash | `add_key` | +| 市价开仓 + 写 `order_monitors` | `_market_open_for_key_monitor` | +| 计划 RR | `calc_rr_ratio(direction, E, SL, TP)` | +| 价格精度 | `round_price_to_exchange` | diff --git a/crypto_monitor_gate/.env b/crypto_monitor_gate/.env index df1c2c5..42643b0 100644 --- a/crypto_monitor_gate/.env +++ b/crypto_monitor_gate/.env @@ -62,6 +62,10 @@ GATE_TPSL_PRICE_TYPE=0 # 关键位监控:5m收线突破过滤参数 KLINE_TIMEFRAME=5m KEY_BREAKOUT_LIMIT_PCT=1.5 +# 关键位自动单:计划 RR 阈值(严格大于该值才开仓,按确认K收盘 E 计算) +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 diff --git a/crypto_monitor_gate/README.md b/crypto_monitor_gate/README.md new file mode 100644 index 0000000..f400451 --- /dev/null +++ b/crypto_monitor_gate/README.md @@ -0,0 +1,91 @@ +# crypto_monitor_gate + +基于 **Flask** 的加密货币 **下单监控 / 关键位监控 / 交易复盘** 小系统,行情与实盘接口统一走 **Gate.io USDT 永续**,通过 **ccxt** 访问。 + +## 文档导航 + +| 文档 | 说明 | +|------|------| +| **[使用说明.md](./使用说明.md)** | 日常怎么用:登录、关键位四类、手工开仓、单仓与微信等 | +| **[关键位自动下单说明.md](./关键位自动下单说明.md)** | 关键位自动开仓的 RR、止盈止损、结案原因与 `.env` | +| **[部署文档.md](./部署文档.md)** | Ubuntu、PM2、**SSH SOCKS** 访问 Gate API 等 | + +另:**Binance U 本位** 对等实现见同级的 **`crypto_monitor_binance`** 仓库。 + +--- + +## 功能概要 + +- **关键位监控**:5m 收线硬条件、企业微信推送;**箱体 / 收敛** 在 RR 达标时可 **自动市价开仓**(见专门文档);**阻力 / 支撑** 仅单次提醒结案 +- **下单监控**:本地风控(含移动保本)、止盈/止损触达后轮询尝试平仓并记账 +- **实盘(可选)**:`LIVE_TRADING_ENABLED=true` 且配置 **`GATE_API_KEY` / `GATE_API_SECRET`** 时,支持开仓、挂单 TP/SL、余额与划转(权限依账户而定) +- **止盈止损(Gate)**:市价成交后经 **`_gate_place_tp_sl_orders`** 挂单;优先 **仓位类 `price_orders`**(受 `GATE_TPSL_USE_POSITION_ORDER`、`GATE_TPSL_PRICE_TYPE`、`GATE_POS_MODE` 等影响) + +--- + +## 环境要求 + +- Python 3.10+(建议) +- 依赖:`flask`、`requests`、`ccxt`、`werkzeug`、`PySocks`(经 SOCKS 代理时);`Pillow`(K 线导出等可选用) + +安装示例: + +```bash +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install flask requests ccxt werkzeug PySocks Pillow +``` + +## 配置(`.env`) + +项目启动时加载**仓库根目录**下的 `.env`。常用项: + +| 变量 | 说明 | +|------|------| +| `GATE_API_KEY` / `GATE_API_SECRET` | Gate API(需合约与对应权限) | +| `LIVE_TRADING_ENABLED` | `true` 允许真实下单;`false` 仅本地与推送逻辑 | +| `GATE_MARGIN_MODE` / `GATE_POS_MODE` | 保证金与持仓模式 | +| `GATE_TPSL_USE_POSITION_ORDER` / `GATE_TPSL_PRICE_TYPE` 等 | 条件止盈止损行为 | +| `GATE_SOCKS_PROXY` | 可选;直连不稳时 SSH 动态转发(详见部署文档) | +| `APP_PASSWORD` / `FLASK_SECRET_KEY` | Web 登录与 Session | +| `WECHAT_WEBHOOK` | 企业微信机器人 | +| `EXCHANGE_DISPLAY_NAME` / `GATE_ACCOUNT_LABEL` | 页面与推送展示的账户文案 | + +其余见 **`.env` 内注释** 或 **`app.py` 顶部默认值**。 + +## 本地运行 + +**Windows** 推荐使用 UTF-8 控制台脚本: + +```powershell +.\start_utf8.ps1 +``` + +或直接: + +```powershell +python .\app.py +``` + +端口由 **`APP_PORT`** 控制(未设置默认 **5000**)。浏览器登录 **`/login`**,口令为 **`APP_PASSWORD`**。 + +## 部署(Linux / PM2 / SSH SOCKS) + +见 **[部署文档.md](./部署文档.md)**。 + +## 自检脚本 + +```bash +python scripts/verify_gate_funding.py +``` + +用于核对密钥前缀(不落 Secret)、资金/合约可读性等(需网络与权限)。 + +## 数据与脚本 + +- 默认 SQLite:由 **`DB_PATH`** 指定(常见为项目下 `crypto.db`) +- `scripts/fix_breakeven_labels.py`:修正「止损」但盈亏为正的记录标签(参见部署文档说明) + +## 风险与合规 + +实盘有亏损风险。请确认 API 权限、IP 白名单、杠杆与保证金模式与 **Gate.io** 后台一致,并遵守当地法律法规与交易所用户协议。 diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index c562f58..229c746 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -124,6 +124,13 @@ 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")) +ORDER_MONITOR_TYPE_MANUAL = "下单监控" +ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控" + +KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"}) +KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({"关键阻力位", "关键支撑位"}) AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true" AUTO_TRANSFER_AMOUNT = float(os.getenv("AUTO_TRANSFER_AMOUNT", "30")) AUTO_TRANSFER_FROM = os.getenv("AUTO_TRANSFER_FROM", "funding") @@ -1093,6 +1100,17 @@ def init_db(): c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_margin_usdt REAL") except Exception: pass + try: + c.execute(f"ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT DEFAULT '{ORDER_MONITOR_TYPE_MANUAL}'") + except Exception: + pass + try: + c.execute( + "UPDATE order_monitors SET monitor_type=? WHERE monitor_type IS NULL OR TRIM(monitor_type)=''", + (ORDER_MONITOR_TYPE_MANUAL,), + ) + except Exception: + pass try: c.execute("UPDATE order_monitors SET opened_at = datetime('now') WHERE opened_at IS NULL OR opened_at = ''") except: pass @@ -1967,6 +1985,8 @@ def enrich_order_item(raw_item, current_capital): item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 except Exception: item["breakeven_enabled"] = 1 + if not (item.get("monitor_type") or "").strip(): + item["monitor_type"] = ORDER_MONITOR_TYPE_MANUAL return item @@ -3461,113 +3481,386 @@ def calc_price_diff_pct(current_price, target_price): return None, None -def can_notify_key_monitor(row, now_dt): - max_notify = int(row["max_notify"] or KEY_ALERT_MAX_TIMES) - if int(row["notification_count"] or 0) >= max_notify: - return False - last_at = row["last_notified_at"] - if not last_at: - return True +def _finalize_key_monitor_one_shot(conn, row, last_msg, close_reason): + """本条关键位一次性结案:写历史并从当前表删除。""" + n = int(row["notification_count"] or 0) + 1 + insert_key_monitor_history(conn, row, n, last_msg, close_reason) + conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) + + +def _key_hard_lines_from_checks(checks): + return [ + f"量能:{'通过' if checks['vol_ok'] else '不通过'}(突破K量 {round(checks['vol_break'], 4)} / 前20均量 {round(checks['avg20'], 4)},阈值1.3x)", + f"突破价位:{'通过' if checks['breakout_ok'] else '不通过'}(突破K收盘 {round(float(checks['breakout_close']), 8)},关键位 {checks['edge_price']})", + f"突破K幅度:{'通过' if checks['amp_ok'] else '不通过'}({round(checks['amp_pct'], 4)}%,要求0.03%~0.5%)", + f"第二根确认:{'通过' if checks['confirm_ok'] else '不通过'}(确认收盘 {checks['confirm_close']},关键位 {checks['edge_price']})", + f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前30)", + ] + + +def _key_plan_auto_sl_tp(direction, upper, lower, checks, outside_pct): + """ + 计划 SL/TP:止损在突破 K 极值外侧 outside_pct%,止盈为确认收盘 ± 箱体高。 + 返回 (E, raw_sl, raw_tp, box_h)。 + """ + E = float(checks["confirm_close"]) + H = abs(float(upper) - float(lower)) + br_hi = float(checks["breakout_high"]) + br_lo = float(checks["breakout_low"]) + m = float(outside_pct) / 100.0 + if direction == "long": + sl_raw = br_lo * (1.0 - m) if br_lo > 0 else 0.0 + tp_raw = E + H + else: + sl_raw = br_hi * (1.0 + m) if br_hi > 0 else 0.0 + tp_raw = E - H + return E, sl_raw, tp_raw, H + + +def _market_open_for_key_monitor(conn, symbol, direction, exchange_symbol, stop_loss, take_profit): + """ + 与手动「实盘下单」对齐的市价开仓与 order_monitors 写入。 + 返回 (ok: bool, err_msg: Optional[str], detail: Optional[dict]) + """ + now = app_now() + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + return False, f"风控拒绝下单:{reason}", None + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + return False, reason_live, None + + default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + + trading_day = get_trading_day(now) + opens_today_before = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).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"]) + + trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() + if trade_style not in ("trend", "swing"): + trade_style = "trend" + + available_usdt = get_available_trading_usdt() + live_price = get_price(symbol) + if live_price is None: + return False, "获取交易所实时价格失败(以损定仓需要当前价)", None try: - last_dt = datetime.strptime(last_at, "%Y-%m-%d %H:%M:%S") + ensure_markets_loaded() except Exception: - return True - interval_min = int(row["notify_interval_min"] or KEY_ALERT_INTERVAL_MINUTES) - return (now_dt - last_dt).total_seconds() >= interval_min * 60 + pass + lp_r = round_price_to_exchange(exchange_symbol, live_price) + if lp_r is not None: + live_price = lp_r + sl_adj = round_price_to_exchange(exchange_symbol, float(stop_loss)) + tp_adj = round_price_to_exchange(exchange_symbol, float(take_profit)) + if sl_adj is not None: + stop_loss = float(sl_adj) + if tp_adj is not None: + take_profit = float(tp_adj) + + risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) + if risk_fraction is None: + return False, "止损方向不合法(相对当前市价);请核对上下沿与方向", None + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, 4) + notional_value = round(risk_amount / risk_fraction, 4) + margin_capital = round(notional_value / leverage, 4) + + if capital_base and margin_capital > capital_base: + return False, "以损定仓后保证金超过当前交易资金", None + + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + if margin_capital > max_margin: + return ( + False, + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + None, + ) + + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 -def breakout_too_far(p, edge_price, limit_pct): try: - if edge_price is None or float(edge_price) <= 0: - return False - diff_pct = abs(float(p) - float(edge_price)) / float(edge_price) * 100 - return diff_pct > float(limit_pct) - except Exception: - return False + amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) + contract_size = get_contract_size(exchange_symbol) + base_amount = round(float(amount) * contract_size, 8) + order_resp = place_exchange_order( + exchange_symbol, direction, amount, leverage, + stop_loss=stop_loss, take_profit=take_profit, + ) + open_order_id = order_resp.get("id", "") + tpsl_attached = bool(order_resp.get("tpsl_attached")) + trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price) + except Exception as e: + return False, friendly_exchange_error(e, available_usdt=available_usdt), None + + trigger_price = round_price_to_exchange(exchange_symbol, trigger_price) + stop_loss = round_price_to_exchange(exchange_symbol, stop_loss) + take_profit = round_price_to_exchange(exchange_symbol, take_profit) + + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + + planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) + if risk_amount_final is None: + risk_amount_final = risk_amount + else: + try: + risk_amount_final = round(float(risk_amount_final), 4) + except (TypeError, ValueError): + risk_amount_final = risk_amount + + if direction == "short": + breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) + else: + breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) + breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) + breakeven_enabled = 1 + + conn.execute( + "INSERT INTO order_monitors " + "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " + "margin_capital, leverage, trade_style, risk_percent, risk_amount, " + "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " + "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent, + risk_amount_final, + breakeven_rr_trigger, + breakeven_offset_pct, + breakeven_step_r, + 0, + breakeven_price, + breakeven_enabled, + notional_value, + position_ratio, + base_amount, + amount, + open_order_id, + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + ), + ) + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage) + opens_today_after = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + + return True, None, { + "new_order_id": new_order_id, + "open_order_id": open_order_id, + "trigger_price": trigger_price, + "planned_rr_fill": planned_rr, + "risk_amount_final": risk_amount_final, + "margin_capital": margin_capital, + "leverage": leverage, + "amount": amount, + "base_amount": base_amount, + "notional_value": notional_value, + "position_ratio": position_ratio, + "tpsl_attached": tpsl_attached, + "opens_today_before": opens_today_before, + "opens_today_after": opens_today_after, + "trading_day": trading_day, + "risk_percent": risk_percent, + "breakeven_rr_trigger": breakeven_rr_trigger, + "breakeven_price": breakeven_price, + "capital_base_at_open": capital_base, + } -# 关键位监控 +# 关键位监控(箱体/收敛可自动开仓;阻力/支撑位仅单次提醒结案) def check_key_monitors(): conn = get_db() rows = conn.execute("SELECT * FROM key_monitors").fetchall() for r in rows: - sym, typ, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] + sym, typ_raw, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] + typ = (typ_raw or "").strip() direction = (r["direction"] or "long").lower() - now_dt = app_now() - if not can_notify_key_monitor(r, now_dt): - continue try: checks = _key_hard_checks(sym, direction, up, low, typ) except Exception: checks = {"ok": False} if not checks.get("ok"): continue + btc8h_status, _, _ = _status_by_ema55("BTC/USDT", "8h") coin4h_status, _, _ = _status_by_ema55(sym, "4h") risk_tip = None if (direction == "long" and coin4h_status == "空头") or (direction == "short" and coin4h_status == "多头"): risk_tip = "当前信号与本币4h(EMA55)主趋势逆势,建议降低仓位并严格执行止损。" - box_h = abs(float(up) - float(low)) if up is not None and low is not None else 0.0 - c_close = float(checks.get("confirm_close") or 0) - b_high = float(checks.get("breakout_high") or 0) - b_low = float(checks.get("breakout_low") or 0) + key_price = float(low) if direction == "long" else float(up) - if direction == "long": - tp1 = c_close + box_h - tp2 = c_close + box_h * 1.5 - sl1 = b_low * (1 - 0.002) if b_low > 0 else None - sl2 = key_price * (1 - 0.002) if key_price > 0 else None - else: - tp1 = c_close - box_h - tp2 = c_close - box_h * 1.5 - sl1 = b_high * (1 + 0.002) if b_high > 0 else None - sl2 = key_price * (1 + 0.002) if key_price > 0 else None - hard_lines = [ - f"量能:{'通过' if checks['vol_ok'] else '不通过'}(突破K量 {round(checks['vol_break'], 4)} / 前20均量 {round(checks['avg20'], 4)},阈值1.3x)", - f"突破价位:{'通过' if checks['breakout_ok'] else '不通过'}(突破K收盘 {round(float(checks['breakout_close']), 8)},关键位 {checks['edge_price']})", - f"突破K幅度:{'通过' if checks['amp_ok'] else '不通过'}({round(checks['amp_pct'], 4)}%,要求0.03%~0.5%)", - f"第二根确认:{'通过' if checks['confirm_ok'] else '不通过'}(确认收盘 {checks['confirm_close']},关键位 {checks['edge_price']})", - f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前30)", - ] - op_lines = [ - f"方案A:止盈=箱体1.0倍({round(tp1, 8) if tp1 else '-' }),止损=突破K极值外0.2%({round(sl1, 8) if sl1 else '-' })", - f"方案B:止盈=箱体1.5倍({round(tp2, 8) if tp2 else '-' }),止损=箱体关键位外0.2%({round(sl2, 8) if sl2 else '-' })", - ] + hard_lines = _key_hard_lines_from_checks(checks) trigger_time = ms_to_app_local_str(int(checks["confirm_ts"])) if checks.get("confirm_ts") else app_now_str() - msg = build_wechat_key_monitor_message( - symbol=sym, - direction=direction, - monitor_type=typ, - trigger_time=trigger_time, - key_price=key_price, - confirm_close=checks["confirm_close"], - hard_lines=hard_lines, - btc8h_status=btc8h_status, - coin4h_status=coin4h_status, - swing4h_pct=checks.get("swing4h_pct") or 0.0, - op_lines=op_lines, - risk_tip=risk_tip, + + alert_only = typ in KEY_MONITOR_ALERT_ONLY_TYPES or ( + typ not in KEY_MONITOR_AUTO_TYPES and typ not in KEY_MONITOR_ALERT_ONLY_TYPES ) - send_wechat_msg(msg) - new_count = int(r["notification_count"] or 0) + 1 - max_n = int(r["max_notify"] or KEY_ALERT_MAX_TIMES) - conn.execute( - "UPDATE key_monitors SET notification_count = ?, last_notified_at = ? WHERE id = ?", - (new_count, app_now_str(), r["id"]), - ) - if new_count >= max_n: - insert_key_monitor_history(conn, r, new_count, msg, "alerts_complete") - conn.execute("DELETE FROM key_monitors WHERE id = ?", (r["id"],)) - send_wechat_msg( - "\n".join( - [ - f"# 🧾 {r['symbol']} 关键位监控结束", - "", - f"- 原因:已满 {max_n} 次提醒", - "- 状态:已自动结束并记入历史", - ] - ) + + if alert_only: + op_lines = [ + "- 本条为关键阻力/支撑或非标类型:**仅单次推送**,不进行自动开仓。", + "- 本条关键位将在推送后记入历史并从监控列表移除。", + ] + msg = build_wechat_key_monitor_message( + symbol=sym, + direction=direction, + monitor_type=typ, + trigger_time=trigger_time, + key_price=key_price, + confirm_close=checks["confirm_close"], + hard_lines=hard_lines, + btc8h_status=btc8h_status, + coin4h_status=coin4h_status, + swing4h_pct=checks.get("swing4h_pct") or 0.0, + op_lines=op_lines, + risk_tip=risk_tip, ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "key_level_alert_only") + continue + + E, sl_raw, tp_raw, box_h = _key_plan_auto_sl_tp( + direction, up, low, checks, KEY_STOP_OUTSIDE_BREAKOUT_PCT, + ) + exchange_symbol = normalize_exchange_symbol(sym) + try: + ensure_markets_loaded() + except Exception: + pass + sl_px = round_price_to_exchange(exchange_symbol, sl_raw) + tp_px = round_price_to_exchange(exchange_symbol, tp_raw) + if sl_px is not None: + sl_raw = float(sl_px) + if tp_px is not None: + tp_raw = float(tp_px) + + planned_rr = calc_rr_ratio(direction, E, sl_raw, tp_raw) + rr_ok = planned_rr is not None and planned_rr > KEY_AUTO_MIN_PLANNED_RR + + if not rr_ok: + fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算(止损/止盈与确认价几何关系无效)" + rr_msg = ( + f"# ⚠️ {sym} 关键位自动单:计划 RR 未达标\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" + f"- 箱体高 H:`{format_price_for_symbol(sym, box_h)}`\n" + f"- 计划止损(突破K外侧 {KEY_STOP_OUTSIDE_BREAKOUT_PCT}%):`{format_wechat_scalar_2dp(sl_raw)}`\n" + f"- 计划止盈(E±1×H):`{format_price_for_symbol(sym, tp_raw)}`\n" + f"- **计划 RR(按确认收盘 E):{fmt_rr} : 1**(要求 **>{KEY_AUTO_MIN_PLANNED_RR}:1**,未开仓)\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(rr_msg) + _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient") + continue + + ok_trade, trade_err, det = _market_open_for_key_monitor( + conn, sym, direction, exchange_symbol, sl_raw, tp_raw, + ) + planned_rr_txt = ( + format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" + ) + if not ok_trade: + fail_msg = ( + f"# ❌ {sym} 关键位自动单失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" + f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n" + f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n" + f"- **计划 RR(按 E):{planned_rr_txt} : 1**(已通过 RR 阈值)\n" + f"- **失败原因:{trade_err}**\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + fail_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(fail_msg) + _finalize_key_monitor_one_shot(conn, r, fail_msg, "exchange_failed") + continue + + tpsl_txt = ( + "已在交易所挂条件委托(止盈、止损触发单)" + if det.get("tpsl_attached") + else "⚠️ 条件委托挂接状态异常或未挂上" + ) + rr_fill = det.get("planned_rr_fill") + rr_fill_txt = format_wechat_scalar_2dp(rr_fill) if rr_fill is not None else "-" + + succ_msg_lines = [ + f"# ✅ {sym} 关键位自动开仓成功", + f"**账户:{_wechat_account_label()}**", + f"- **来源:**{ORDER_MONITOR_TYPE_KEY_AUTO}(市价)", + f"- 页面订单 ID:**{det['new_order_id']}**", + f"- 交易所订单 ID:`{det.get('open_order_id') or '-'}`", + f"- 类型:{typ}|{_wechat_direction_text(direction)}", + f"- 触发时间:`{trigger_time}`", + f"- 确认K收盘(E):{format_price_for_symbol(sym, E)}(RR 阈值按此计价)", + f"- **计划 RR(E):{planned_rr_txt}:1**", + f"- 开仓成交价:**{format_price_for_symbol(sym, det['trigger_price'])}**", + f"- **成交价侧计划 RR:**{rr_fill_txt}:1", + f"- 止损:{format_wechat_scalar_2dp(sl_raw)}", + f"- 止盈:{format_price_for_symbol(sym, tp_raw)}", + f"- 风险:{det.get('risk_percent')}%≈{format_wechat_scalar_2dp(det.get('risk_amount_final'))}U|基数 {format_wechat_scalar_2dp(det.get('margin_capital'))}U|杠杆 {det.get('leverage')}x", + f"- 名义 {format_wechat_scalar_2dp(det.get('notional_value'))}U|张数 {format_wechat_scalar_2dp(det.get('amount'))}|折算标的 {det.get('base_amount')}", + f"- **{tpsl_txt}**", + f"- 保本触发:{det.get('breakeven_rr_trigger')}R→{format_price_for_symbol(sym, det.get('breakeven_price'))}", + f"- 当日开仓次数:**{det.get('opens_today_after')}** / {DAILY_OPEN_ALERT_THRESHOLD}(提醒阈值)", + ] + succ_msg_lines.extend(["---", "### 硬条件"] + [f"- {x}" for x in hard_lines]) + if risk_tip: + succ_msg_lines.extend(["---", "### 逆势风险提示", f"- {risk_tip}"]) + succ_msg = "\n".join(succ_msg_lines) + send_wechat_msg(succ_msg) + _finalize_key_monitor_one_shot(conn, r, succ_msg, "auto_opened") + + if det.get("opens_today_before", 0) < DAILY_OPEN_ALERT_THRESHOLD <= det.get("opens_today_after", 0): + advice = ai_short_advice( + f"用户在北京时间交易日 {det['trading_day']} 已累计开仓 {det['opens_today_after']} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。" + f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x。" + f"用户自述“上头了”。请给克制提醒。" + ) + if advice: + send_wechat_msg(f"【AI提醒】今日开仓次数已达 {det['opens_today_after']}\n{advice[:800]}") conn.commit() conn.close() @@ -4573,6 +4866,15 @@ def add_key(): if not symbol: flash("symbol 不能为空") return redirect("/") + direction_sel = (d.get("direction") or "").strip().lower() + if direction_sel not in ("long", "short"): + flash("请选择做多或做空") + return redirect("/") + 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("/") rank, total = _daily_volume_rank(symbol) if rank is None: flash("日成交量排名读取失败,请稍后重试") @@ -4581,6 +4883,16 @@ def add_key(): flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前30,已拒绝添加关键位") return redirect("/") 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: + conn.close() + flash( + "当前已有实盘持仓:无法添加「箱体突破 / 收敛突破」(会自动开仓)。请平仓后再试,或使用「关键阻力位/关键支撑位」(仅单次提醒)。" + ) + return redirect("/") ex_sym_key = normalize_exchange_symbol(symbol) try: ensure_markets_loaded() @@ -4588,11 +4900,25 @@ def add_key(): pass upper_px = round_price_to_exchange(ex_sym_key, float(d["upper"])) lower_px = round_price_to_exchange(ex_sym_key, float(d["lower"])) - conn.execute("INSERT INTO key_monitors (symbol,monitor_type,direction,upper,lower) VALUES (?,?,?,?,?)", - (symbol, d["type"], d.get("direction", "long"), upper_px, lower_px)) + conn.execute( + "INSERT INTO key_monitors (symbol,monitor_type,direction,upper,lower) VALUES (?,?,?,?,?)", + (symbol, mt, direction_sel, upper_px, lower_px), + ) conn.commit() conn.close() + ctr = False + try: + coin4h_status, _, _ = _status_by_ema55(symbol, "4h") + ctr = (direction_sel == "long" and coin4h_status == "空头") or ( + direction_sel == "short" and coin4h_status == "多头" + ) + except Exception: + pass flash(f"添加成功({symbol} 日成交量排名 {rank}/{total})") + if ctr: + flash( + "⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。" + ) return redirect("/") @app.route("/add_order", methods=["POST"]) @@ -4772,12 +5098,13 @@ def add_order(): breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0 conn.execute( - "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price, breakeven_enabled, - notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day + notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day, + ORDER_MONITOR_TYPE_MANUAL, ) ) conn.commit() diff --git a/crypto_monitor_gate/templates/index.html b/crypto_monitor_gate/templates/index.html index 41e968c..e484f64 100644 --- a/crypto_monitor_gate/templates/index.html +++ b/crypto_monitor_gate/templates/index.html @@ -87,7 +87,7 @@ .review-card{grid-column:1/-1} @media (min-width: 1900px){ .container{max-width:2100px} - .monitor-card .list,.order-card .list{max-height:420px} + .monitor-card .list,.order-card .pos-list{max-height:420px} .records-card .table-wrap{max-height:620px;overflow:auto} } @media (max-width: 1400px){ @@ -112,6 +112,34 @@ .key-history h3{font-size:.88rem;color:#b8c4ff;margin-bottom:6px} .key-history .sub{font-size:.72rem;color:#8892b0;margin-bottom:6px} .key-history .list{max-height:200px} + .pos-section{margin-top:12px} + .pos-section-title{font-size:.82rem;color:#8892b0;margin-bottom:8px;font-weight:500} + .pos-list{display:flex;flex-direction:column;gap:10px;max-height:280px;overflow:auto} + .pos-card{background:#141923;border:1px solid #2a3348;border-radius:10px;padding:12px 14px} + .pos-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px} + .pos-meta{font-size:.74rem;color:#8b95a8;line-height:1.45;margin-bottom:12px;display:flex;flex-wrap:wrap;align-items:center;gap:4px 0} + .pos-meta-item{display:inline-flex;align-items:center} + .pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659} + .pos-meta-on{color:#6eb5ff} + .pos-meta-off{color:#7d8799} + .pos-card-symbol{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0} + .pos-card-symbol strong{font-size:.95rem;color:#fff;font-weight:600} + .pos-side-badge{padding:3px 8px;border-radius:6px;font-size:.72rem;font-weight:500;line-height:1.2} + .pos-side-long{background:#253a6e;color:#6eb5ff} + .pos-side-short{background:#4a2230;color:#ff8a8a} + .pos-close-btn{padding:6px 14px;background:#c45454;color:#fff;border-radius:8px;text-decoration:none;font-size:.82rem;font-weight:500;flex-shrink:0;white-space:nowrap} + .pos-close-btn:hover{background:#d66565;color:#fff} + .pos-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px 14px;margin-bottom:12px} + .pos-cell{display:flex;flex-direction:column;gap:4px;min-width:0} + .pos-label{font-size:.72rem;color:#7d8799} + .pos-value{font-size:.88rem;color:#e8ecf4;font-weight:500;line-height:1.25} + .pos-val-dash{opacity:.75;color:#8b95a8} + .pos-value.price-up{color:#4cd97f} + .pos-value.price-down{color:#ff6666} + .pos-value.price-flat{color:#e8ecf4} + .pos-footer{display:flex;flex-wrap:wrap;gap:14px 18px;font-size:.75rem;color:#6d7689} + .pos-empty{padding:18px;text-align:center;color:#8892b0;font-size:.85rem;background:#141923;border:1px dashed #2a3348;border-radius:10px} + @media (max-width:520px){.pos-grid{grid-template-columns:repeat(2,1fr)}} .stats-card{grid-column:1/-1;margin-top:14px} .stats-card .stats-toggle{background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;padding:6px 10px;cursor:pointer} .stats-card.collapsed .stats-content{display:none} @@ -216,7 +244,7 @@

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

-
满 {{ key_alert_max_times }} 次企业微信提醒后自动移入此处;手动删除也会归档。
+
每种关键位触发后一次性结案并写入下方历史:箱体/收敛在计划 RR 达标时自动市价开仓;阻力/支撑仅单次企业微信提醒。详见项目根目录「关键位自动下单说明.md」。满多次提醒的旧逻辑已不适用。
{% for h in key_history %}
@@ -295,24 +323,72 @@ -
+
+
实时持仓
+
{% for o in order %} -
-
{{ o.symbol }} | {{ '做多' if o.direction == 'long' else '做空' }}
-
- 风格:{{ o.trade_style or 'trend' }} | 风险:{{ o.risk_percent or '-' }}%≈{% if o.risk_amount is not none %}{{ usdt_fmt(o.risk_amount) }}{% else %}-{% endif %}U - | {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %} -
- 成交:{{ price_fmt(o.symbol, o.trigger_price) }} 止损:{{ price_fmt(o.symbol, o.stop_loss) }} 止盈:{{ price_fmt(o.symbol, o.take_profit) }} - | 盈亏比:{% if o.rr_ratio is not none %}1:{{ '%.2f'|format(o.rr_ratio) }}{% else %}-{% endif %} - | 现价:- - | 浮盈亏:- - | 计划基数:{% if o.margin_capital is not none %}{{ usdt_fmt(o.margin_capital) }}{% else %}-{% endif %}U | 所保证金:- - | 杠杆:{{ o.leverage }}x | 仓位占比:{{ o.position_ratio }}% +
+
+
+ {{ o.exchange_symbol or o.symbol }} + {{ '做多' if o.direction == 'long' else '做空' }} +
+ 平仓 +
+
+ 来源: {{ o.monitor_type|default('下单监控', true) }} + 风格: {{ o.trade_style or 'trend' }} + 风险: {{ o.risk_percent or '-' }}%≈{% if o.risk_amount is not none %}{{ usdt_fmt(o.risk_amount) }}{% else %}-{% endif %}U + + {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %} + +
+
+
+ 成交价 + {{ price_fmt(o.symbol, o.trigger_price) }} +
+
+ 止损 + {% if o.stop_loss %} + {{ price_fmt(o.symbol, o.stop_loss) }} + {% else %} + + {% endif %} +
+
+ 止盈 + {% if o.take_profit %} + {{ price_fmt(o.symbol, o.take_profit) }} + {% else %} + + {% endif %} +
+
+ 盈亏比 + {% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %} +
+
+ 标记价 + - +
+
+ 浮盈亏 + - +
+
+ - 平仓
+ {% else %} +
暂无持仓
{% endfor %} +
+
{% endif %} @@ -1113,6 +1189,14 @@ function formatSignedUsdt2(v){ return `${sign}${n.toFixed(2)}`; } +function formatRrRatio(rr){ + if(rr === null || typeof rr === "undefined") return "-:1"; + const n = Number(rr); + if(Number.isNaN(n)) return "-:1"; + const body = Number.isInteger(n) ? String(n) : String(parseFloat(n.toFixed(2))); + return `${body}:1`; +} + function formatJournalPnlUi(v){ if(v === null || typeof v === "undefined" || v === "") return "-"; const raw = String(v).trim(); @@ -1197,7 +1281,7 @@ function refreshPriceSnapshot(){ } const rrEl = document.getElementById(`order-rr-${o.id}`); if(rrEl){ - rrEl.innerText = (typeof o.rr_ratio !== "undefined" && o.rr_ratio !== null) ? `1:${Number(o.rr_ratio).toFixed(2)}` : "-"; + rrEl.innerText = formatRrRatio(o.rr_ratio); } }); }).catch(()=>{}); diff --git a/crypto_monitor_gate/使用说明.md b/crypto_monitor_gate/使用说明.md new file mode 100644 index 0000000..efa2164 --- /dev/null +++ b/crypto_monitor_gate/使用说明.md @@ -0,0 +1,138 @@ +# 使用说明 + +**本文件对应仓库:`crypto_monitor_gate`(Gate.io USDT 永续)。** +功能、界面与 **Binance U 本位版**(目录 `crypto_monitor_binance`)基本一致,差异主要在 **`.env` 里交易所密钥与部分参数名**(`GATE_*` / `BINANCE_*`),文末有对照。 + +**更细的部署(SSH 代理、PM2、依赖安装)** 见同目录 **`部署文档.md`**。 +**关键位自动开仓的规则、RR、结案原因** 见 **`关键位自动下单说明.md`**。 + +--- + +## 1. 它能做什么 + +面向个人盘面的 **Web 控制台**,主要能力包括: + +| 模块 | 说明 | +|------|------| +| **关键位监控** | 录入上/下沿与类型,按 **5m 收线** 做硬条件过滤;符合条件后 **企业微信** 提醒,部分类型可 **自动市价开仓**(见第 4 节与专门文档)。 | +| **实盘下单监控** | 手工填止损/止盈,**以损定仓** 市价开单,挂上条件止盈止损,并在页面跟踪浮盈亏、保本逻辑等。 | +| **交易记录 / 复盘** | 平仓结果、盈亏、错过的单等归档与导出。 | + +后台按 **`MONITOR_POLL_SECONDS`**(默认几秒)轮询行情与监控逻辑。**切勿**在未理解规则时同时运行两套程序共用一个实盘账户。 + +--- + +## 2. 运行前必须配置(`.env`) + +至少检查以下项(具体键名以你仓库 `.env` 示例为准): + +| 类别 | 说明 | +|------|------| +| **登录网页** | `APP_PASSWORD`:打开站点后的登录口令。`FLASK_SECRET_KEY`:Session 密钥,请勿使用默认值。 | +| **企业微信** | `WECHAT_WEBHOOK`:告警与关键位推送机器人的 Webhook。 | +| **是否真下单** | `LIVE_TRADING_ENABLED=false`:**不会**向交易所发送开仓指令(适合测试流程)。改为 `true` 且密钥正确才会实盘。 | +| **交易所 API** | **本仓库:** `GATE_API_KEY`、`GATE_API_SECRET`;合约相关见 `GATE_MARGIN_MODE`、`GATE_POS_MODE`、`GATE_TPSL_*` 等。**勿**把 `.env` 提交到 Git。 | +| **关键位 RR / 止损外扩** | `KEY_AUTO_MIN_PLANNED_RR`、`KEY_STOP_OUTSIDE_BREAKOUT_PCT`(详见 `关键位自动下单说明.md`)。 | + +网络不稳定时可为 Gate 配置 **`GATE_SOCKS_PROXY`** 等(见 **`部署文档.md`**)。 + +--- + +## 3. 如何启动与登录 + +1. 按 **`部署文档.md`** 建好虚拟环境、安装依赖(如 `flask`、`requests`、`ccxt`、按需 `Pillow`、`PySocks` 等),配置好 `.env`。 +2. 启动 Flask 应用(本仓库可用 **`ecosystem.config.cjs`** 交给 PM2,或本地 `python app.py` / `flask run`,以你当前脚本为准)。 +3. 浏览器访问站点,打开 **`/login`**,使用 **`.env` 里的 `APP_PASSWORD`** 登录。 + +--- + +## 4. 关键位监控(页面「关键位监控」卡片) + +### 4.1 添加一条关键位 + +1. **币种**:如 `BTC` 或 `BTC/USDT`(会规范成内部符号)。 +2. **类型**(必选其一): + + | 类型 | 行为摘要 | + |------|----------| + | **箱体突破** | 通过门控且计划 RR 达标 → **自动市价开仓**(需 `LIVE_TRADING_ENABLED=true` 且无其他持仓占位)。结案后本条从列表消失并记入历史。 | + | **收敛突破** | 同上(自动开仓类)。 | + | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 | + | **关键支撑位** | 同上(仅提醒)。 | + +3. **方向**:做多 / 做空(必选)。 +4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整。 + +**限制:** +当前已有 **`order_monitors` 活跃持仓** 时,**不允许**再添加「**箱体突破** / **收敛突破**」(否则会与「单仓 + 自动开仓」冲突);仍可添加「**关键阻力位 / 支撑位**」。 +若 **4h EMA55** 与你的方向逆势,页面会 **额外 Flash 提示**,**不阻挡**提交。 + +### 4.2 触发后会发生什么(简版) + +- **箱体 / 收敛**:门控通过后计算计划 SL/TP 与 RR;不达标则 **微信说明 + `rr_insufficient` 结案**;达标则尝试 **市价开仓**,成功 **`auto_opened`**,失败 **`exchange_failed`**——均 **不重试同一关键位**。 +- **阻力 / 支撑**:仅 **单次推送** → **`key_level_alert_only`** 结案。 + +详细公式、结案字段、与企业微信文案口径见 **`关键位自动下单说明.md`**。 + +### 4.3 列表与历史 + +- 当前条目可 **删除**(会按规则记入历史的情形见页面说明)。 +- **关键位历史**:已结案记录;可配合导出链接(若有)做备份。 + +--- + +## 5. 实盘下单监控(手工开仓) + +用于 **自己点按钮** 开单: + +- **同一时间系统只允许一笔「活跃持仓」监视**(与关键位自动单共用该限制)。 +- 填写币种、方向、杠杆(可选)、止损/止盈(价格或百分比按表单说明)。 +- 勾选是否启用 **移动保本** 等行为以 `.env`/页面默认值为准。 + +平仓通过页面 **平仓**(或等价入口),会从交易所市价处理并更新记录。**删除/误操作可能造成真实盈亏**,请先确认环境与方向。 + +开仓成功后持仓卡片上会显示 **「来源」**:手工单一般为 **下单监控**;来自关键位自动单的为 **关键位监控**。 + +--- + +## 6. 企业微信会看到什么 + +- 关键位:按类型与结案结果推送(RR 不足、下单失败、自动开仓成功、仅阻力支撑提醒等),**每条关键位结案路径原则上一条主推送**(详见 `关键位自动下单说明.md`)。 +- 手工开仓、平仓、部分异常也会在规则满足时推送(以代码与配置为准)。 + +若未配置 **`WECHAT_WEBHOOK`** 或网络失败,可能只是看不到推送,不代表逻辑未执行;要紧操作请以 **交易所端持仓与挂单** 为准核对。 + +--- + +## 7. 强烈建议的风险与运维习惯 + +1. **先用 `LIVE_TRADING_ENABLED=false`** 验证页面、录入、推送,再开小资金开实盘。 +2. **API 权限**:仅开所需合约权限;勿泄露密钥;定期轮换。 +3. **单进程控盘**:同一账户避免本程序与其他机器人 **重复开仓**。 +4. **数据库**:`*.sqlite`(或你在 `.env` 里指向的库文件)包含监控与订单,**备份后再升级/迁移**。 +5. **升级代码后**:启动时会跑 **数据库迁移**(如新列 `order_monitors.monitor_type`);首次启动关注一下日志或无报错页面。 + +--- + +## 8. 常见问题(简要) + +| 现象 | 可自查 | +|------|--------| +| 关键位永远不触发 | 5m 门控是否全通过(页面门控摘要)、币种日成交量是否在规则内、`KLINE_TIMEFRAME`。 | +| 有信号但不自动开仓 | `LIVE_TRADING_ENABLED`、`KEY_AUTO_MIN_PLANNED_RR`、计划 RR、是否已有持仓、API/余额报错(微信或日志)。 | +| 加不了箱体/收敛 | 是否已有活跃持仓;先平仓或改用「阻力/支撑位」仅提醒。 | +| 推送收不到 | `WECHAT_WEBHOOK`、企业微信机器人配额与网络。 | + +--- + +## 9. Binance 版(`crypto_monitor_binance`)差异速查 + +| 项目 | Gate 本仓库 | Binance 版 | +|------|-------------|------------| +| API 变量 | `GATE_API_KEY`、`GATE_API_SECRET`、`GATE_*` | `BINANCE_API_KEY`、`BINANCE_API_SECRET`、`BINANCE_*` | +| 实盘开关 | `LIVE_TRADING_ENABLED`(通用) | 同上 | +| 止盈止损挂载路径 | `_gate_place_tp_sl_orders` 与 `GATE_TPSL_*` | `_binance_place_tp_sl_orders`(U 本位条件单) | +| 资金显示舍入 | 以本仓库为准 | 与 **`FUNDS_DECIMALS`** 等一致 | +| 专门文档 | **`关键位自动下单说明.md`**(各仓库有一份,开头标明交易所) | 同左 | + +操作流程(登录、关键位四类、手工单、单仓)**两份程序一致**:换目录、换 `.env` 即可对照使用。 diff --git a/crypto_monitor_gate/关键位自动下单说明.md b/crypto_monitor_gate/关键位自动下单说明.md new file mode 100644 index 0000000..5ae3f80 --- /dev/null +++ b/crypto_monitor_gate/关键位自动下单说明.md @@ -0,0 +1,98 @@ +# 关键位自动下单说明 + +**适用仓库:`crypto_monitor_gate`|交易所:Gate.io USDT 永续**(Binance 版见同名的 `crypto_monitor_binance` 目录。) + +本文档与 `.env`、`app.check_key_monitors`、`app.add_key`、`_market_open_for_key_monitor` 的实现一致。 + +--- + +## 结构与是否自动开仓 + +| `key_monitors.monitor_type`(录入类型) | 自动下单 | 触发后处置 | +|---------------------------------------|----------|------------| +| **箱体突破** | 是(满足全部条件) | **一次性结案**:写 `key_monitor_history` → 从 `key_monitors` **删除** | +| **收敛突破** | 是(同上) | 同上 | +| **关键阻力位** | 否 | 企业微信 **1 次** → `close_reason=key_level_alert_only` → **失效** | +| **关键支撑位** | 否 | 同上 | + +触发条件:**5m 收线硬门控** `_key_hard_checks`(量能、突破幅度、第二根收盘确认、日成交量前 30 等)。 + +--- + +## 录入限制(`/add_key`) + +- 存在 **`order_monitors.status='active'`** 时:**禁止添加** 「箱体突破」「收敛突破」。 +- **关键阻力位 / 关键支撑位**:不受上条限制;触发后 **仅单次微信提醒**,然后结案。 +- **4h EMA55 与所选方向逆势**:**不拦截**;添加成功后 **Flash** 提示。 +- 上下沿入库前经 **`round_price_to_exchange`** 按合约 **价格精度** 取整。 + +--- + +## 环境与参数(`.env`) + +| 变量 | 含义 | 默认 | +|------|------|------| +| `KEY_AUTO_MIN_PLANNED_RR` | 计划 RR 阈值:**仅当严格大于该值** 才自动开仓(按下方 `E` 计算) | `1.5` | +| `KEY_STOP_OUTSIDE_BREAKOUT_PCT` | 止损:突破 K 极值向外 **百分比**(多:`低×(1−p/100)`;空:`高×(1+p/100)`) | `0.5` | + +**其余与本仓库手动实盘一致:** `KLINE_TIMEFRAME`、`RISK_PERCENT`、`LIVE_TRADING_ENABLED`、`BREAKEVEN_*`、`DAILY_OPEN_ALERT_THRESHOLD`,以及 **`GATE_*`**(密钥、止盈止损触发、`GATE_TPSL_*` 等)。 + +--- + +## 计价与下单口径 + +| 用途 | 价格 | +|------|------| +| 企业微信展示、**与 RR 门槛比较的计划 RR** | 确认 K(第二根闭合 5m)收盘 **`E`** | +| **实际开仓** | **市价**(`place_exchange_order`,与 `/add_order` 一致);成交价可能与 `E` **滑点** | +| **以损定仓** | `calc_risk_fraction(direction, 当前市价, 止损)` + `RISK_PERCENT`(与 `/add_order` 一致) | + +- 开仓成功后:`order_monitors.monitor_type` 为 **关键位监控**;持仓卡片「来源」显示之。手动开仓为 **下单监控**。 +- 持仓列表中的 **盈亏比**:按 **实际成交价** 相对 SL/TP 重算,可与「按 `E` 算的计划 RR」略有偏差。 +- **本仓库止盈止损挂单**:开仓后由 **`_gate_place_tp_sl_orders`** 挂载(仓位类 `price_orders` 或备选条件路径,逻辑与手动一致);细节受 `GATE_TPSL_USE_POSITION_ORDER`、`GATE_TPSL_PRICE_TYPE` 等影响。 + +--- + +## 自动单止盈 / 止损(仅箱体突破、收敛突破) + +设箱体高度 **`H = |upper − lower|`**(录入上下沿)。 + +| 方向 | 止损 SL | 止盈 TP | +|------|---------|---------| +| 多 | 突破 K **最低价** × (1 − `KEY_STOP_OUTSIDE_BREAKOUT_PCT`/100) | **`E + 1×H`** | +| 空 | 突破 K **最高价** × (1 + `KEY_STOP_OUTSIDE_BREAKOUT_PCT`/100) | **`E − 1×H`** | + +计划 **`RR = calc_rr_ratio(direction, E, SL, TP)`**。若为 `None` 或 **RR ≤ `KEY_AUTO_MIN_PLANNED_RR`** → **不下单**,走 `rr_insufficient` 结案。 + +--- + +## 一次性结案(`close_reason`) + +以下任一发生:**按需发微信** → **`key_monitor_history`** → **从 `key_monitors` 删除**;**不会对同一条关键位重复轮询重试开仓**。 + +| `close_reason` | 含义 | +|----------------|------| +| `rr_insufficient` | 门控通过,但计划 RR 未达标或 SL/TP / RR **几何无效** | +| `exchange_failed` | 计划 RR 达标,但未开实盘、`LIVE_TRADING_ENABLED=false`、风控、保证金或 **交易所报错** 等导致 **开仓失败** | +| `auto_opened` | 计划 RR 达标且 **市价开仓成功**(已写 `order_monitors`,并已挂止盈止损) | +| `key_level_alert_only` | 阻力/支撑位 **仅推送**结案 | + +--- + +## 与企业微信推送 + +每种结案路径 **至多一条**主业务推送(RR 不足 / 下单失败 / 开仓成功 / 阻力支撑仅提醒)。 + +旧版「满 `KEY_ALERT_MAX_TIMES` 次再归档」对已触发结案的路径 **不再适用**;表中 `notification_count`、`max_notify` 等字段仍可能存在,以 **导出、兼容** 为主。 + +--- + +## 相关代码位置(通用) + +| 说明 | 符号 | +|------|------| +| 门控与主循环 | `check_key_monitors` | +| 录入、有仓拦截、4h Flash | `add_key` | +| 市价开仓 + 写 `order_monitors` | `_market_open_for_key_monitor` | +| 计划 RR | `calc_rr_ratio(direction, E, SL, TP)` | +| 价格精度 | `round_price_to_exchange` |