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 @@