diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 249e597..6025f61 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -51,6 +51,18 @@ from fib_key_monitor_lib import ( key_signal_type_for_trade_record, stored_key_signal_type, ) +from false_breakout_key_monitor_lib import ( + FALSE_BREAKOUT_MONITOR_TYPE, + FALSE_BREAKOUT_VALIDITY_HOURS, + calc_false_breakout_plan, + expires_at_text, + is_false_breakout_expired, + is_false_breakout_key_monitor_type, + is_limit_key_monitor_type, + key_price_from_row, + normalize_false_breakout_symbol, + storage_bounds_from_key_price, +) from strategy_trade_labels import ( STRATEGY_ENTRY_REASON_OPTIONS, apply_order_monitor_source_labels, @@ -992,6 +1004,7 @@ ENTRY_REASON_OPTIONS = ( "关键位收敛突破", "关键位斐波0.618", "关键位斐波0.786", + "关键位假突破", ) + STRATEGY_ENTRY_REASON_OPTIONS STATS_SEGMENT_DEFS = ( @@ -1001,6 +1014,7 @@ STATS_SEGMENT_DEFS = ( ("key_conv", "关键位收敛结构", {"segment": "key_conv"}), ("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}), ("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}), + ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}), ) # 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom) ENTRY_REASON_OTHER = "__OTHER__" @@ -1587,6 +1601,8 @@ def _pnl_row_matches_segment(row, segment_key): return kst == "斐波回调0.618" if segment_key == "key_fib786": return kst == "斐波回调0.786" + if segment_key == "key_false_breakout": + return kst == FALSE_BREAKOUT_MONITOR_TYPE return False @@ -1603,6 +1619,7 @@ def _count_opens_for_segment(conn, start_td, end_td, segment_key): "key_conv": "收敛突破", "key_fib618": "斐波回调0.618", "key_fib786": "斐波回调0.786", + "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE, } kst = kst_map.get(segment_key) if kst: @@ -2610,7 +2627,7 @@ def order_row_key_signal_type(row): if "key_signal_type" not in keys: return None kst = (row["key_signal_type"] or "").strip() - if kst in KEY_MONITOR_AUTO_TYPES or is_fib_key_monitor_type(kst): + if kst in KEY_MONITOR_AUTO_TYPES or is_fib_key_monitor_type(kst) or is_false_breakout_key_monitor_type(kst): return kst return None @@ -4787,6 +4804,19 @@ def _fib_plan_for_row(row): return calc_fib_plan(row["direction"], row["upper"], row["lower"], ratio) +def _limit_key_plan_for_row(row): + typ = (row["monitor_type"] or "").strip() + if is_fib_key_monitor_type(typ): + return _fib_plan_for_row(row) + if is_false_breakout_key_monitor_type(typ): + direction = (row["direction"] or "long").lower() + key_px = key_price_from_row(direction, row["upper"], row["lower"]) + if key_px is None: + return None + return calc_false_breakout_plan(direction, key_px) + return None + + def _cancel_fib_monitor_limit(row): ex_sym = normalize_exchange_symbol(row["symbol"]) oid = _sqlite_row_val(row, "fib_limit_order_id") @@ -4873,10 +4903,11 @@ def _finalize_fib_key_fill(conn, row): symbol = row["symbol"] direction = (row["direction"] or "long").lower() typ = (row["monitor_type"] or "").strip() + kind = "假突破" if is_false_breakout_key_monitor_type(typ) else "斐波" ex_sym = normalize_exchange_symbol(symbol) - plan = _fib_plan_for_row(row) + plan = _limit_key_plan_for_row(row) if not plan: - _finalize_key_monitor_one_shot(conn, row, "斐波计划无效", "fib_plan_invalid") + _finalize_key_monitor_one_shot(conn, row, f"{kind}计划无效", "fib_plan_invalid") return entry_plan, sl_plan, tp_plan = plan sl = float(_sqlite_row_val(row, "fib_stop_loss", sl_plan) or sl_plan) @@ -4907,7 +4938,7 @@ def _finalize_fib_key_fill(conn, row): amount = float(live_amt or 0) if amount <= 0: send_wechat_msg( - f"# ❌ {symbol} 斐波成交后处理失败\n" + f"# ❌ {symbol} {kind}成交后处理失败\n" f"**账户:{_wechat_account_label()}**\n" f"- 无法取得持仓/下单数量,未挂 TP/SL\n" ) @@ -4915,7 +4946,7 @@ def _finalize_fib_key_fill(conn, row): ok, reason = precheck_risk(conn, symbol, direction) if not ok: send_wechat_msg( - f"# ❌ {symbol} 斐波成交后风控拒绝\n" + f"# ❌ {symbol} {kind}成交后风控拒绝\n" f"**账户:{_wechat_account_label()}**\n" f"- 类型:{typ}\n" f"- 原因:{reason}\n" @@ -4928,7 +4959,7 @@ def _finalize_fib_key_fill(conn, row): tpsl_attached = True except Exception as e: send_wechat_msg( - f"# ❌ {symbol} 斐波成交后挂 TP/SL 失败\n" + f"# ❌ {symbol} {kind}成交后挂 TP/SL 失败\n" f"**账户:{_wechat_account_label()}**\n" f"- 错误:{friendly_exchange_error(e)}\n" f"- 请手动补挂止盈止损\n" @@ -4946,8 +4977,9 @@ def _finalize_fib_key_fill(conn, row): notional_value, position_ratio, base_amount, oid, tpsl_attached, ) rr_txt = format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" + close_reason = "false_breakout_filled" if is_false_breakout_key_monitor_type(typ) else "fib_filled" succ = ( - f"# ✅ {symbol} 斐波限价成交\n" + f"# ✅ {symbol} {kind}限价成交\n" f"**账户:{_wechat_account_label()}**\n" f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(限价 @ E)\n" f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" @@ -4958,7 +4990,7 @@ def _finalize_fib_key_fill(conn, row): f"- {'已挂交易所 TP/SL' if tpsl_attached else 'TP/SL 未挂上'}\n" ) send_wechat_msg(succ) - _finalize_key_monitor_one_shot(conn, row, succ, "fib_filled") + _finalize_key_monitor_one_shot(conn, row, succ, close_reason) def check_fib_key_monitors(): @@ -4966,13 +4998,28 @@ def check_fib_key_monitors(): rows = conn.execute("SELECT * FROM key_monitors").fetchall() for r in rows: typ = (r["monitor_type"] or "").strip() - if not is_fib_key_monitor_type(typ): + if not is_limit_key_monitor_type(typ): continue symbol = r["symbol"] direction = (r["direction"] or "long").lower() ex_sym = normalize_exchange_symbol(symbol) up, low = float(r["upper"]), float(r["lower"]) oid = _sqlite_row_val(r, "fib_limit_order_id") + if is_false_breakout_key_monitor_type(typ): + now_dt = app_now() + if is_false_breakout_expired(r["created_at"], now_dt): + _cancel_fib_monitor_limit(r) + exp_txt = expires_at_text(r["created_at"]) + msg = ( + f"# ⚠️ {symbol} 假突破监控已过期\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h(应于 {exp_txt} 前成交)\n" + f"- 已撤销限价单\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "false_breakout_expired") + continue mark = get_symbol_mark_price(symbol) if mark is None: continue @@ -4980,7 +5027,7 @@ def check_fib_key_monitors(): if status == "filled" or (status != "open" and _fib_has_live_position(ex_sym, direction)): _finalize_fib_key_fill(conn, r) continue - if status == "open": + if is_fib_key_monitor_type(typ) and status == "open": if fib_invalidate_by_mark(direction, mark, up, low): _cancel_fib_monitor_limit(r) msg = ( @@ -4992,7 +5039,7 @@ def check_fib_key_monitors(): send_wechat_msg(msg) _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") continue - if status in ("canceled", "missing", "unknown") and fib_invalidate_by_mark(direction, mark, up, low): + if is_fib_key_monitor_type(typ) and status in ("canceled", "missing", "unknown") and fib_invalidate_by_mark(direction, mark, up, low): msg = ( f"# ⚠️ {symbol} 斐波监控失效(限价已不在挂单)\n" f"**账户:{_wechat_account_label()}**\n" @@ -5079,6 +5126,86 @@ def _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px, br return True, None +def _false_breakout_exists_for_symbol(conn, symbol): + row = conn.execute( + "SELECT id FROM key_monitors WHERE symbol=? AND monitor_type=?", + (symbol, FALSE_BREAKOUT_MONITOR_TYPE), + ).fetchone() + return row is not None + + +def _add_false_breakout_key_monitor( + conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=0, +): + if _false_breakout_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有假突破监控(同币仅允许一条)" + plan = calc_false_breakout_plan(direction_sel, key_px) + if not plan: + return False, "假突破价位无效,请核对方向与关键价位" + entry, sl, tp = plan + ex_sym = normalize_exchange_symbol(symbol) + entry = round_price_to_exchange(ex_sym, entry) + sl = round_price_to_exchange(ex_sym, sl) + tp = round_price_to_exchange(ex_sym, tp) + if entry is None or sl is None or tp is None: + return False, "假突破价位经交易所精度舍入后无效" + entry, sl, tp = float(entry), float(sl), float(tp) + ok, reason = precheck_risk(conn, symbol, direction_sel) + if not ok: + return False, reason + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + return False, reason_live + now = app_now() + trading_day = get_trading_day(now) + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) + default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + available_usdt = get_available_trading_usdt() + risk_fraction = calc_risk_fraction(direction_sel, entry, sl) + if risk_fraction is None: + return False, "止损方向不合法(相对挂单价);请核对方向与关键价位" + 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, "以损定仓后保证金超过当前交易资金" + 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", + ) + try: + amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) + order_resp = place_fib_limit_order(ex_sym, direction_sel, amount, leverage, entry) + oid = str(order_resp.get("id") or "") + if not oid: + return False, "交易所未返回限价单 ID" + except Exception as e: + return False, friendly_exchange_error(e, available_usdt=available_usdt) + be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, FALSE_BREAKOUT_MONITOR_TYPE, direction_sel, upper_px, lower_px, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, + ), + ) + return True, None + + # 关键位监控(箱体/收敛可自动开仓;阻力/支撑为双向 5m 收盘突破 + 三次提醒) def check_key_monitors(): conn = get_db() @@ -5086,7 +5213,7 @@ def check_key_monitors(): for r in rows: sym, typ_raw, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] typ = (typ_raw or "").strip() - if is_fib_key_monitor_type(typ): + if is_limit_key_monitor_type(typ): continue if typ in KEY_MONITOR_RS_TYPES: try: @@ -5969,6 +6096,7 @@ def render_main_page(page="trade"): f"【箱体/收敛】{KLINE_TIMEFRAME} 两根闭合K|突破越过关键位 > {KEY_BREAKOUT_AMP_MIN_PCT}%|" f"确认K收于箱外|量能>前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}|" f"RR>{KEY_AUTO_MIN_PLANNED_RR}|日成交前{KEY_DAILY_VOLUME_RANK_MAX}|" + f"【假突破·BTC/ETH】做空填高点/做多填低点,外侧 0.1% 挂限价,止损 0.5%,RR 1.5,有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h|" f"【阻力/支撑】填上/下沿,5m 收盘突破任一侧即提醒 {KEY_ALERT_MAX_TIMES} 次(间隔 {KEY_ALERT_INTERVAL_MINUTES} 分),不选方向、不自动开仓" ) strategy_extra = {} @@ -6715,6 +6843,7 @@ def add_key(): tuple(KEY_MONITOR_AUTO_TYPES) + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + tuple(FIB_KEY_MONITOR_TYPES) + + (FALSE_BREAKOUT_MONITOR_TYPE,) ) if mt not in allowed_types: flash("监控类型无效") @@ -6725,13 +6854,16 @@ def add_key(): "请改用阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。" ) return redirect("/key_monitor") - rank, total = _daily_volume_rank(symbol) - if rank is None: - flash("日成交量排名读取失败,请稍后重试") - return redirect("/key_monitor") - if rank > KEY_DAILY_VOLUME_RANK_MAX: - flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位") - return redirect("/key_monitor") + skip_volume_rank = is_false_breakout_key_monitor_type(mt) + rank, total = None, None + if not skip_volume_rank: + rank, total = _daily_volume_rank(symbol) + if rank is None: + flash("日成交量排名读取失败,请稍后重试") + return redirect("/key_monitor") + if rank > KEY_DAILY_VOLUME_RANK_MAX: + flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位") + return redirect("/key_monitor") conn = get_db() if mt in KEY_MONITOR_AUTO_TYPES: occupied = get_active_position_count(conn) @@ -6747,6 +6879,48 @@ def add_key(): ensure_markets_loaded() except Exception: pass + be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled")) + if is_false_breakout_key_monitor_type(mt): + fb_sym = normalize_false_breakout_symbol(symbol) + if not fb_sym: + conn.close() + flash("假突破仅支持 BTC / ETH") + return redirect("/key_monitor") + symbol = fb_sym + if direction_sel not in ("long", "short"): + conn.close() + flash("假突破请选择做多或做空") + return redirect("/key_monitor") + try: + key_px = float(d.get("key_price") or 0) + except (TypeError, ValueError): + key_px = 0 + if key_px <= 0: + conn.close() + flash("请填写关键价位(做空填高点,做多填低点)") + return redirect("/key_monitor") + ex_sym_key = normalize_exchange_symbol(symbol) + key_adj = round_price_to_exchange(ex_sym_key, key_px) + key_px = float(key_adj) if key_adj is not None else float(key_px) + try: + upper_px, lower_px = storage_bounds_from_key_price(direction_sel, key_px) + except ValueError as e: + conn.close() + flash(str(e)) + return redirect("/key_monitor") + ok_fb, err_fb = _add_false_breakout_key_monitor( + conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=be_flag, + ) + conn.commit() + conn.close() + if not ok_fb: + flash(err_fb or "假突破监控添加失败") + return redirect("/key_monitor") + flash( + f"假突破监控已添加,限价单已挂出({symbol})" + f"|有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h|移动保本:{'开' if be_flag else '关'}" + ) + return redirect("/key_monitor") 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"]) @@ -6755,7 +6929,6 @@ def add_key(): conn.close() flash("上沿必须大于下沿") return redirect("/key_monitor") - be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled")) if is_fib_key_monitor_type(mt): ok_fib, err_fib = _add_fib_key_monitor( conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=be_flag, @@ -7187,7 +7360,7 @@ def delete_key_monitor(kid): if not row: conn.close() return jsonify({"ok": False, "error": "not_found"}) - if is_fib_key_monitor_type(row["monitor_type"]): + if is_limit_key_monitor_type(row["monitor_type"]): _cancel_fib_monitor_limit(row) insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") cur = conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) @@ -7212,7 +7385,7 @@ def del_key(id): conn = get_db() row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (id,)).fetchone() if row: - if is_fib_key_monitor_type(row["monitor_type"]): + if is_limit_key_monitor_type(row["monitor_type"]): _cancel_fib_monitor_limit(row) insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") conn.execute("DELETE FROM key_monitors WHERE id=?", (id,)) diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html index 102ee98..411b8ec 100644 --- a/crypto_monitor_binance/templates/index.html +++ b/crypto_monitor_binance/templates/index.html @@ -338,14 +338,16 @@ + - - + + + - - + + + - - + + +