feat: add false breakout key monitor for BTC/ETH on three exchanges

Place limit orders outside key levels with fixed SL and 1.5 RR, 24h expiry, separate stats, and full-margin mode guard.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-09 10:13:53 +08:00
parent 2786acf884
commit 7cb55f6557
10 changed files with 903 additions and 88 deletions
+195 -22
View File
@@ -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,))
+37 -4
View File
@@ -338,14 +338,16 @@
<option value="收敛突破">收敛突破</option>
<option value="斐波回调0.618">斐波回调0.618</option>
<option value="斐波回调0.786">斐波回调0.786</option>
<option value="假突破">假突破(BTC/ETH</option>
<option value="关键阻力位">关键阻力位</option>
<option value="关键支撑位">关键支撑位</option>
</select>
<select name="direction" id="key-direction" required>
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
</select>
<input name="upper" step="0.0001" placeholder="上沿/阻力" required>
<input name="lower" step="0.0001" placeholder="沿/支撑" required>
<input name="key_price" id="key-fb-price" step="0.0001" placeholder="做空填高点/做多填低点" style="display:none">
<input name="upper" id="key-upper" step="0.0001" placeholder="沿/阻力" required>
<input name="lower" id="key-lower" step="0.0001" placeholder="下沿/支撑" required>
<select name="sl_tp_mode" id="key-sl-tp-mode" title="止盈止损方案">
<option value="standard">标准突破</option>
<option value="box_1p5">箱体1R·止盈1.5H</option>
@@ -377,6 +379,7 @@
<span class="pos-meta-item">上沿: {{ k.upper }}</span>
<span class="pos-meta-item">下沿: {{ k.lower }}</span>
{% if k.fib_entry_price %}<span class="pos-meta-item">挂E: {{ k.fib_entry_price }}</span>{% endif %}
{% if k.monitor_type == '假突破' and k.fib_stop_loss %}<span class="pos-meta-item">SL: {{ k.fib_stop_loss }} / TP: {{ k.fib_take_profit }}</span>{% endif %}
<span class="pos-meta-item">已提醒: {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }}</span>
{% if k.monitor_type in ['箱体突破','收敛突破'] %}
<span class="pos-meta-item">方案: {{ '标准突破' if (k.sl_tp_mode or 'standard') == 'standard' else ('箱体1R·止盈1.5H' if k.sl_tp_mode == 'box_1p5' else '趋势单') }}</span>
@@ -1349,7 +1352,8 @@ const KEY_ENTRY_REASON_BY_SIGNAL = {
"箱体突破": "关键位箱体突破",
"收敛突破": "关键位收敛突破",
"斐波回调0.618": "关键位斐波0.618",
"斐波回调0.786": "关键位斐波0.786"
"斐波回调0.786": "关键位斐波0.786",
"假突破": "关键位假突破"
};
function splitLegacyEarlyExitReason(raw){
@@ -1638,10 +1642,15 @@ function syncKeyMonitorFormFields(){
const t = (typeEl.value || "").trim();
const autoTypes = new Set(["箱体突破","收敛突破"]);
const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]);
const fbTypes = new Set(["假突破"]);
const rsTypes = new Set(["关键阻力位","关键支撑位"]);
const showAuto = autoTypes.has(t);
const showBe = showAuto || fibTypes.has(t);
const showFb = fbTypes.has(t);
const showBe = showAuto || fibTypes.has(t) || showFb;
const showDir = !rsTypes.has(t);
const upperEl = document.getElementById("key-upper");
const lowerEl = document.getElementById("key-lower");
const fbPriceEl = document.getElementById("key-fb-price");
if(dirEl){
dirEl.style.display = showDir ? "" : "none";
dirEl.required = showDir;
@@ -1654,11 +1663,29 @@ function syncKeyMonitorFormFields(){
manualTp.required = !!trend;
}
if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
if(upperEl){
upperEl.style.display = showFb ? "none" : "";
upperEl.required = !showFb;
if(showFb) upperEl.value = "";
}
if(lowerEl){
lowerEl.style.display = showFb ? "none" : "";
lowerEl.required = !showFb;
if(showFb) lowerEl.value = "";
}
if(fbPriceEl){
fbPriceEl.style.display = showFb ? "" : "none";
fbPriceEl.required = showFb;
if(!showFb) fbPriceEl.value = "";
fbPriceEl.placeholder = (dirEl && dirEl.value === "short") ? "高点(阻力)" : ((dirEl && dirEl.value === "long") ? "低点(支撑)" : "做空填高点/做多填低点");
}
}
const keyTypeSel = document.querySelector('#key-form [name="type"]');
const keyModeSel = document.getElementById("key-sl-tp-mode");
const keyDirSel = document.getElementById("key-direction");
if(keyTypeSel) keyTypeSel.addEventListener("change", syncKeyMonitorFormFields);
if(keyModeSel) keyModeSel.addEventListener("change", syncKeyMonitorFormFields);
if(keyDirSel) keyDirSel.addEventListener("change", syncKeyMonitorFormFields);
syncKeyMonitorFormFields();
const keyForm = document.getElementById("key-form");
@@ -1672,6 +1699,12 @@ if(keyForm){
alert("请先输入交易对");
return;
}
const typeVal = (keyForm.querySelector('[name="type"]') || {}).value || "";
if(typeVal === "假突破"){
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…");
else keyForm.submit();
return;
}
if(window.FormSubmitGuard) FormSubmitGuard.lock(keyForm, "校验排名中…");
fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`)
.then(r=>r.json().then(d=>({status:r.status, data:d})))
+203 -25
View File
@@ -52,6 +52,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,
@@ -982,6 +994,7 @@ ENTRY_REASON_OPTIONS = (
"关键位收敛突破",
"关键位斐波0.618",
"关键位斐波0.786",
"关键位假突破",
) + STRATEGY_ENTRY_REASON_OPTIONS
STATS_SEGMENT_DEFS = (
@@ -991,6 +1004,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__"
@@ -1584,6 +1598,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
@@ -1600,6 +1616,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:
@@ -2326,7 +2343,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
@@ -4623,6 +4640,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")
@@ -4709,10 +4739,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)
@@ -4743,7 +4774,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"
)
@@ -4751,7 +4782,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"
@@ -4764,7 +4795,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"
@@ -4782,8 +4813,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"
@@ -4794,7 +4826,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():
@@ -4802,13 +4834,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
@@ -4816,7 +4863,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 = (
@@ -4828,7 +4875,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"
@@ -4840,6 +4887,86 @@ def check_fib_key_monitors():
conn.close()
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
def _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0):
if _fib_key_exists_for_symbol(conn, symbol):
return False, f"{symbol} 已有斐波监控(同币仅允许一条 0.618/0.786"
@@ -4922,7 +5049,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:
@@ -5941,6 +6068,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 = {}
@@ -6737,25 +6865,29 @@ 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("监控类型无效")
return redirect("/key_monitor")
if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt):
flash(
"全仓杠杆模式下不可添加箱体/收敛突破斐波监控;"
"全仓杠杆模式下不可添加箱体/收敛突破斐波或假突破监控;"
"请改用阻力/支撑(仅提醒),或切换 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)
@@ -6772,6 +6904,53 @@ 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()
conn = None
flash("假突破仅支持 BTC / ETH")
return redirect("/key_monitor")
symbol = fb_sym
if direction_sel not in ("long", "short"):
conn.close()
conn = None
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()
conn = None
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()
conn = None
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()
conn = None
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")
try:
upper_raw = float(d.get("upper") or 0)
lower_raw = float(d.get("lower") or 0)
@@ -6787,7 +6966,6 @@ def add_key():
conn = None
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,
@@ -7257,7 +7435,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,))
@@ -7282,7 +7460,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,))
+37 -4
View File
@@ -339,14 +339,16 @@
<option value="收敛突破">收敛突破</option>
<option value="斐波回调0.618">斐波回调0.618</option>
<option value="斐波回调0.786">斐波回调0.786</option>
<option value="假突破">假突破(BTC/ETH</option>
<option value="关键阻力位">关键阻力位</option>
<option value="关键支撑位">关键支撑位</option>
</select>
<select name="direction" id="key-direction" required>
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
</select>
<input name="upper" step="0.0001" placeholder="上沿/阻力" required>
<input name="lower" step="0.0001" placeholder="沿/支撑" required>
<input name="key_price" id="key-fb-price" step="0.0001" placeholder="做空填高点/做多填低点" style="display:none">
<input name="upper" id="key-upper" step="0.0001" placeholder="沿/阻力" required>
<input name="lower" id="key-lower" step="0.0001" placeholder="下沿/支撑" required>
<select name="sl_tp_mode" id="key-sl-tp-mode" title="止盈止损方案">
<option value="standard">标准突破</option>
<option value="box_1p5">箱体1R·止盈1.5H</option>
@@ -378,6 +380,7 @@
<span class="pos-meta-item">上沿: {{ k.upper }}</span>
<span class="pos-meta-item">下沿: {{ k.lower }}</span>
{% if k.fib_entry_price %}<span class="pos-meta-item">挂E: {{ k.fib_entry_price }}</span>{% endif %}
{% if k.monitor_type == '假突破' and k.fib_stop_loss %}<span class="pos-meta-item">SL: {{ k.fib_stop_loss }} / TP: {{ k.fib_take_profit }}</span>{% endif %}
<span class="pos-meta-item">已提醒: {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }}</span>
{% if k.monitor_type in ['箱体突破','收敛突破'] %}
<span class="pos-meta-item">方案: {{ '标准突破' if (k.sl_tp_mode or 'standard') == 'standard' else ('箱体1R·止盈1.5H' if k.sl_tp_mode == 'box_1p5' else '趋势单') }}</span>
@@ -1333,7 +1336,8 @@ const KEY_ENTRY_REASON_BY_SIGNAL = {
"箱体突破": "关键位箱体突破",
"收敛突破": "关键位收敛突破",
"斐波回调0.618": "关键位斐波0.618",
"斐波回调0.786": "关键位斐波0.786"
"斐波回调0.786": "关键位斐波0.786",
"假突破": "关键位假突破"
};
function splitLegacyEarlyExitReason(raw){
@@ -1622,10 +1626,15 @@ function syncKeyMonitorFormFields(){
const t = (typeEl.value || "").trim();
const autoTypes = new Set(["箱体突破","收敛突破"]);
const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]);
const fbTypes = new Set(["假突破"]);
const rsTypes = new Set(["关键阻力位","关键支撑位"]);
const showAuto = autoTypes.has(t);
const showBe = showAuto || fibTypes.has(t);
const showFb = fbTypes.has(t);
const showBe = showAuto || fibTypes.has(t) || showFb;
const showDir = !rsTypes.has(t);
const upperEl = document.getElementById("key-upper");
const lowerEl = document.getElementById("key-lower");
const fbPriceEl = document.getElementById("key-fb-price");
if(dirEl){
dirEl.style.display = showDir ? "" : "none";
dirEl.required = showDir;
@@ -1638,11 +1647,29 @@ function syncKeyMonitorFormFields(){
manualTp.required = !!trend;
}
if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
if(upperEl){
upperEl.style.display = showFb ? "none" : "";
upperEl.required = !showFb;
if(showFb) upperEl.value = "";
}
if(lowerEl){
lowerEl.style.display = showFb ? "none" : "";
lowerEl.required = !showFb;
if(showFb) lowerEl.value = "";
}
if(fbPriceEl){
fbPriceEl.style.display = showFb ? "" : "none";
fbPriceEl.required = showFb;
if(!showFb) fbPriceEl.value = "";
fbPriceEl.placeholder = (dirEl && dirEl.value === "short") ? "高点(阻力)" : ((dirEl && dirEl.value === "long") ? "低点(支撑)" : "做空填高点/做多填低点");
}
}
const keyTypeSel = document.querySelector('#key-form [name="type"]');
const keyModeSel = document.getElementById("key-sl-tp-mode");
const keyDirSel = document.getElementById("key-direction");
if(keyTypeSel) keyTypeSel.addEventListener("change", syncKeyMonitorFormFields);
if(keyModeSel) keyModeSel.addEventListener("change", syncKeyMonitorFormFields);
if(keyDirSel) keyDirSel.addEventListener("change", syncKeyMonitorFormFields);
syncKeyMonitorFormFields();
const keyForm = document.getElementById("key-form");
@@ -1656,6 +1683,12 @@ if(keyForm){
alert("请先输入交易对");
return;
}
const typeVal = (keyForm.querySelector('[name="type"]') || {}).value || "";
if(typeVal === "假突破"){
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…");
else keyForm.submit();
return;
}
if(window.FormSubmitGuard) FormSubmitGuard.lock(keyForm, "校验排名中…");
fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`)
.then(r=>r.json().then(d=>({status:r.status, data:d})))
+201 -24
View File
@@ -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,
@@ -989,6 +1001,7 @@ ENTRY_REASON_OPTIONS = (
"关键位收敛突破",
"关键位斐波0.618",
"关键位斐波0.786",
"关键位假突破",
) + STRATEGY_ENTRY_REASON_OPTIONS
STATS_SEGMENT_DEFS = (
@@ -998,6 +1011,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"}),
)
ENTRY_REASON_OTHER = "__OTHER__"
@@ -1567,6 +1581,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
@@ -1583,6 +1599,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:
@@ -2221,7 +2238,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
@@ -4283,6 +4300,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_okx_symbol(row["symbol"])
oid = _sqlite_row_val(row, "fib_limit_order_id")
@@ -4377,10 +4407,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_okx_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)
@@ -4411,7 +4442,7 @@ def _finalize_fib_key_fill(conn, row):
amount = float(live_amt or 0)
if amount <= 0:
msg = (
f"# ❌ {symbol} 斐波成交后处理失败\n"
f"# ❌ {symbol} {kind}成交后处理失败\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 无法取得持仓/下单数量,未挂 TP/SL\n"
)
@@ -4421,7 +4452,7 @@ def _finalize_fib_key_fill(conn, row):
ok, reason = precheck_risk(conn, symbol, direction)
if not ok:
msg = (
f"# ❌ {symbol} 斐波成交后风控拒绝\n"
f"# ❌ {symbol} {kind}成交后风控拒绝\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 类型:{typ}\n"
f"- 原因:{reason}\n"
@@ -4441,7 +4472,7 @@ def _finalize_fib_key_fill(conn, row):
tpsl_attached = bool(slots2.get("sl") and slots2.get("tp"))
except Exception as e:
msg = (
f"# ❌ {symbol} 斐波成交后挂 TP/SL 失败\n"
f"# ❌ {symbol} {kind}成交后挂 TP/SL 失败\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 错误:{friendly_okx_error(e)}\n"
f"- 请手动补挂止盈止损\n"
@@ -4471,8 +4502,9 @@ def _finalize_fib_key_fill(conn, row):
oid,
)
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"
@@ -4483,7 +4515,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():
@@ -4491,13 +4523,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_okx_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
@@ -4505,7 +4552,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 = (
@@ -4517,7 +4564,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"
@@ -4529,6 +4576,86 @@ def check_fib_key_monitors():
conn.close()
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_okx_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_okx_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
def _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0):
if _fib_key_exists_for_symbol(conn, symbol):
return False, f"{symbol} 已有斐波监控(同币仅允许一条 0.618/0.786"
@@ -4842,7 +4969,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:
@@ -5608,6 +5735,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 = {}
@@ -6436,23 +6564,31 @@ def add_key():
elif direction_sel not in ("long", "short"):
flash("箱体/收敛突破请选择做多或做空")
return redirect("/key_monitor")
allowed_types = tuple(KEY_MONITOR_AUTO_TYPES) + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + tuple(FIB_KEY_MONITOR_TYPES)
allowed_types = (
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("监控类型无效")
return redirect("/key_monitor")
if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt):
flash(
"全仓杠杆模式下不可添加箱体/收敛突破斐波监控;"
"全仓杠杆模式下不可添加箱体/收敛突破斐波或假突破监控;"
"请改用阻力/支撑(仅提醒),或切换 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)
@@ -6468,6 +6604,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_okx_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"])
@@ -6476,7 +6654,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,
@@ -6906,7 +7083,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"] or "").strip()):
if is_limit_key_monitor_type((row["monitor_type"] or "").strip()):
_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,))
@@ -6931,7 +7108,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"] or "").strip()):
if is_limit_key_monitor_type((row["monitor_type"] or "").strip()):
_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,))
+37 -4
View File
@@ -347,14 +347,16 @@
<option value="收敛突破">收敛突破</option>
<option value="斐波回调0.618">斐波回调0.618</option>
<option value="斐波回调0.786">斐波回调0.786</option>
<option value="假突破">假突破(BTC/ETH</option>
<option value="关键阻力位">关键阻力位</option>
<option value="关键支撑位">关键支撑位</option>
</select>
<select name="direction" id="key-direction" required>
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
</select>
<input name="upper" step="0.0001" placeholder="上沿/阻力" required>
<input name="lower" step="0.0001" placeholder="沿/支撑" required>
<input name="key_price" id="key-fb-price" step="0.0001" placeholder="做空填高点/做多填低点" style="display:none">
<input name="upper" id="key-upper" step="0.0001" placeholder="沿/阻力" required>
<input name="lower" id="key-lower" step="0.0001" placeholder="下沿/支撑" required>
<select name="sl_tp_mode" id="key-sl-tp-mode" title="止盈止损方案">
<option value="standard">标准突破</option>
<option value="box_1p5">箱体1R·止盈1.5H</option>
@@ -386,6 +388,7 @@
<span class="pos-meta-item">上沿: {{ k.upper }}</span>
<span class="pos-meta-item">下沿: {{ k.lower }}</span>
{% if k.fib_entry_price %}<span class="pos-meta-item">挂E: {{ k.fib_entry_price }}</span>{% endif %}
{% if k.monitor_type == '假突破' and k.fib_stop_loss %}<span class="pos-meta-item">SL: {{ k.fib_stop_loss }} / TP: {{ k.fib_take_profit }}</span>{% endif %}
<span class="pos-meta-item">已提醒: {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }}</span>
{% if k.monitor_type in ['箱体突破','收敛突破'] %}
<span class="pos-meta-item">方案: {{ '标准突破' if (k.sl_tp_mode or 'standard') == 'standard' else ('箱体1R·止盈1.5H' if k.sl_tp_mode == 'box_1p5' else '趋势单') }}</span>
@@ -1358,7 +1361,8 @@ const KEY_ENTRY_REASON_BY_SIGNAL = {
"箱体突破": "关键位箱体突破",
"收敛突破": "关键位收敛突破",
"斐波回调0.618": "关键位斐波0.618",
"斐波回调0.786": "关键位斐波0.786"
"斐波回调0.786": "关键位斐波0.786",
"假突破": "关键位假突破"
};
function splitLegacyEarlyExitReason(raw){
@@ -1647,10 +1651,15 @@ function syncKeyMonitorFormFields(){
const t = (typeEl.value || "").trim();
const autoTypes = new Set(["箱体突破","收敛突破"]);
const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]);
const fbTypes = new Set(["假突破"]);
const rsTypes = new Set(["关键阻力位","关键支撑位"]);
const showAuto = autoTypes.has(t);
const showBe = showAuto || fibTypes.has(t);
const showFb = fbTypes.has(t);
const showBe = showAuto || fibTypes.has(t) || showFb;
const showDir = !rsTypes.has(t);
const upperEl = document.getElementById("key-upper");
const lowerEl = document.getElementById("key-lower");
const fbPriceEl = document.getElementById("key-fb-price");
if(dirEl){
dirEl.style.display = showDir ? "" : "none";
dirEl.required = showDir;
@@ -1663,11 +1672,29 @@ function syncKeyMonitorFormFields(){
manualTp.required = !!trend;
}
if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
if(upperEl){
upperEl.style.display = showFb ? "none" : "";
upperEl.required = !showFb;
if(showFb) upperEl.value = "";
}
if(lowerEl){
lowerEl.style.display = showFb ? "none" : "";
lowerEl.required = !showFb;
if(showFb) lowerEl.value = "";
}
if(fbPriceEl){
fbPriceEl.style.display = showFb ? "" : "none";
fbPriceEl.required = showFb;
if(!showFb) fbPriceEl.value = "";
fbPriceEl.placeholder = (dirEl && dirEl.value === "short") ? "高点(阻力)" : ((dirEl && dirEl.value === "long") ? "低点(支撑)" : "做空填高点/做多填低点");
}
}
const keyTypeSel = document.querySelector('#key-form [name="type"]');
const keyModeSel = document.getElementById("key-sl-tp-mode");
const keyDirSel = document.getElementById("key-direction");
if(keyTypeSel) keyTypeSel.addEventListener("change", syncKeyMonitorFormFields);
if(keyModeSel) keyModeSel.addEventListener("change", syncKeyMonitorFormFields);
if(keyDirSel) keyDirSel.addEventListener("change", syncKeyMonitorFormFields);
syncKeyMonitorFormFields();
const keyForm = document.getElementById("key-form");
@@ -1681,6 +1708,12 @@ if(keyForm){
alert("请先输入交易对");
return;
}
const typeVal = (keyForm.querySelector('[name="type"]') || {}).value || "";
if(typeVal === "假突破"){
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…");
else keyForm.submit();
return;
}
if(window.FormSubmitGuard) FormSubmitGuard.lock(keyForm, "校验排名中…");
fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`)
.then(r=>r.json().then(d=>({status:r.status, data:d})))
+119
View File
@@ -0,0 +1,119 @@
"""假突破关键位监控:BTC/ETH 限价挂单(共享计算与校验)。"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Optional
FALSE_BREAKOUT_MONITOR_TYPE = "假突破"
FALSE_BREAKOUT_SYMBOLS = frozenset({"BTC/USDT", "ETH/USDT"})
FALSE_BREAKOUT_OFFSET_PCT = 0.1
FALSE_BREAKOUT_SL_PCT = 0.5
FALSE_BREAKOUT_RR = 1.5
FALSE_BREAKOUT_VALIDITY_HOURS = 24
def is_false_breakout_key_monitor_type(monitor_type: Optional[str]) -> bool:
return (monitor_type or "").strip() == FALSE_BREAKOUT_MONITOR_TYPE
def is_limit_key_monitor_type(monitor_type: Optional[str]) -> bool:
from fib_key_monitor_lib import is_fib_key_monitor_type
return is_fib_key_monitor_type(monitor_type) or is_false_breakout_key_monitor_type(monitor_type)
def normalize_false_breakout_symbol(symbol: Optional[str]) -> Optional[str]:
s = (symbol or "").strip().upper()
if not s:
return None
if "/" not in s:
s = f"{s}/USDT"
return s if s in FALSE_BREAKOUT_SYMBOLS else None
def storage_bounds_from_key_price(direction: str, key_price: float) -> tuple[float, float]:
k = float(key_price)
if k <= 0:
raise ValueError("关键价位须为正数")
d = (direction or "long").strip().lower()
if d == "short":
return k, k * 0.9999
if d == "long":
return k * 1.0001, k
raise ValueError("方向须为 long 或 short")
def key_price_from_row(direction: str, upper: Any, lower: Any) -> Optional[float]:
d = (direction or "long").strip().lower()
try:
if d == "short":
v = float(upper)
else:
v = float(lower)
except (TypeError, ValueError):
return None
return v if v > 0 else None
def calc_false_breakout_plan(direction: str, key_price: float) -> Optional[tuple[float, float, float]]:
try:
k = float(key_price)
except (TypeError, ValueError):
return None
if k <= 0:
return None
d = (direction or "long").strip().lower()
off = FALSE_BREAKOUT_OFFSET_PCT / 100.0
sl_pct = FALSE_BREAKOUT_SL_PCT / 100.0
rr = float(FALSE_BREAKOUT_RR)
if d == "short":
entry = k * (1 + off)
sl = entry * (1 + sl_pct)
risk = sl - entry
if risk <= 0:
return None
tp = entry - risk * rr
return entry, sl, tp
if d == "long":
entry = k * (1 - off)
sl = entry * (1 - sl_pct)
risk = entry - sl
if risk <= 0:
return None
tp = entry + risk * rr
return entry, sl, tp
return None
def _parse_created_at(raw: Any) -> Optional[datetime]:
s = str(raw or "").strip()
if not s:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(s[:26], fmt)
except ValueError:
continue
try:
return datetime.fromisoformat(s.replace("Z", "+00:00")[:32])
except ValueError:
return None
def is_false_breakout_expired(
created_at: Any,
now: datetime,
*,
hours: int = FALSE_BREAKOUT_VALIDITY_HOURS,
) -> bool:
dt = _parse_created_at(created_at)
if dt is None:
return False
return now >= dt + timedelta(hours=hours)
def expires_at_text(created_at: Any, *, hours: int = FALSE_BREAKOUT_VALIDITY_HOURS) -> str:
dt = _parse_created_at(created_at)
if dt is None:
return ""
return (dt + timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S")
+7 -2
View File
@@ -41,10 +41,12 @@ def calc_fib_plan(direction, upper, lower, ratio):
def stored_key_signal_type(monitor_type):
"""写入 order_monitors / trade_records 的 key_signal_type(箱体/收敛/斐波)。"""
"""写入 order_monitors / trade_records 的 key_signal_type(箱体/收敛/斐波/假突破)。"""
mt = (monitor_type or "").strip()
if mt in FIB_KEY_MONITOR_TYPES:
return mt
if mt == "假突破":
return mt
return None
@@ -53,6 +55,7 @@ KEY_ENTRY_REASON_BY_SIGNAL = {
"收敛突破": "关键位收敛突破",
"斐波回调0.618": "关键位斐波0.618",
"斐波回调0.786": "关键位斐波0.786",
"假突破": "关键位假突破",
"趋势回调": "趋势回调",
}
@@ -62,10 +65,12 @@ def entry_reason_from_key_signal(key_signal_type):
def key_signal_type_for_trade_record(key_signal_type, box_auto_types):
"""平仓写入 trade_records 时保留箱体/收敛/斐波来源。"""
"""平仓写入 trade_records 时保留箱体/收敛/斐波/假突破来源。"""
kst = (key_signal_type or "").strip()
if kst in FIB_KEY_MONITOR_TYPES:
return kst
if kst == "假突破":
return kst
if box_auto_types and kst in box_auto_types:
return kst
return None
+6 -3
View File
@@ -6,6 +6,7 @@ from __future__ import annotations
from typing import Any, Callable, Iterable, Optional
from fib_key_monitor_lib import FIB_KEY_MONITOR_TYPES, is_fib_key_monitor_type
from false_breakout_key_monitor_lib import is_false_breakout_key_monitor_type
from key_monitor_lib import KEY_MONITOR_AUTO_TYPES
from position_sizing_lib import is_full_margin_mode, mode_label_zh
@@ -14,7 +15,9 @@ def monitor_type_disallowed_in_full_margin(monitor_type: str) -> bool:
mt = (monitor_type or "").strip()
if mt in KEY_MONITOR_AUTO_TYPES:
return True
return is_fib_key_monitor_type(mt)
if is_fib_key_monitor_type(mt):
return True
return is_false_breakout_key_monitor_type(mt)
def purge_disallowed_key_monitors(
@@ -38,7 +41,7 @@ def purge_disallowed_key_monitors(
continue
sym = row_symbol(row)
kid = row_id(row)
if is_fib_key_monitor_type(mt):
if is_fib_key_monitor_type(mt) or is_false_breakout_key_monitor_type(mt):
try:
cancel_fib_limit(row)
except Exception:
@@ -52,7 +55,7 @@ def purge_disallowed_key_monitors(
send_wechat(
"# ⚠️ 全仓杠杆模式:已自动撤销关键位监控\n"
f"计仓模式:{mode_label_zh(sizing_mode)}(仅 env 可切换,须无仓)\n"
"已撤销:箱体突破 / 收敛突破 / 斐波回调监控(不可与全仓杠杆并存)\n"
"已撤销:箱体突破 / 收敛突破 / 斐波回调 / 假突破监控(不可与全仓杠杆并存)\n"
+ "\n".join(lines)
)
return len(removed)
@@ -0,0 +1,61 @@
import unittest
from datetime import datetime, timedelta
from false_breakout_key_monitor_lib import (
FALSE_BREAKOUT_MONITOR_TYPE,
calc_false_breakout_plan,
is_false_breakout_expired,
key_price_from_row,
normalize_false_breakout_symbol,
storage_bounds_from_key_price,
)
class FalseBreakoutKeyMonitorLibTests(unittest.TestCase):
def test_normalize_symbol(self):
self.assertEqual(normalize_false_breakout_symbol("btc"), "BTC/USDT")
self.assertEqual(normalize_false_breakout_symbol("ETH/USDT"), "ETH/USDT")
self.assertIsNone(normalize_false_breakout_symbol("SOL"))
def test_short_plan(self):
plan = calc_false_breakout_plan("short", 100000)
self.assertIsNotNone(plan)
entry, sl, tp = plan
self.assertAlmostEqual(entry, 100100.0)
self.assertAlmostEqual(sl, 100600.5)
self.assertAlmostEqual(tp, 99349.25)
def test_long_plan(self):
plan = calc_false_breakout_plan("long", 100000)
self.assertIsNotNone(plan)
entry, sl, tp = plan
self.assertAlmostEqual(entry, 99900.0)
self.assertAlmostEqual(sl, 99400.5)
self.assertAlmostEqual(tp, 100649.25)
def test_storage_bounds(self):
up, low = storage_bounds_from_key_price("short", 100000)
self.assertGreater(up, low)
self.assertAlmostEqual(up, 100000.0)
self.assertAlmostEqual(low, 99990.0)
up, low = storage_bounds_from_key_price("long", 100000)
self.assertGreater(up, low)
self.assertAlmostEqual(low, 100000.0)
self.assertAlmostEqual(up, 100010.0)
def test_key_price_from_row(self):
self.assertEqual(key_price_from_row("short", 100100, 100000), 100100)
self.assertEqual(key_price_from_row("long", 100100, 100000), 100000)
def test_expiry(self):
now = datetime(2026, 6, 9, 12, 0, 0)
created = "2026-06-08 12:00:00"
self.assertTrue(is_false_breakout_expired(created, now))
self.assertFalse(is_false_breakout_expired(created, now - timedelta(hours=1)))
def test_monitor_type_constant(self):
self.assertEqual(FALSE_BREAKOUT_MONITOR_TYPE, "假突破")
if __name__ == "__main__":
unittest.main()