feat(key-monitor): add program trigger entry across four exchanges

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-14 00:42:21 +08:00
parent c95ca6ac35
commit edf4bb835d
26 changed files with 3002 additions and 76 deletions
+548 -5
View File
@@ -115,8 +115,27 @@ from manual_sltp_lib import (
resolve_entrust_sltp_prices, resolve_entrust_sltp_prices,
resolve_open_sltp_prices, resolve_open_sltp_prices,
) )
from trigger_entry_key_monitor_lib import (
TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED,
TRIGGER_ENTRY_CLOSE_EXPIRED,
TRIGGER_ENTRY_CLOSE_FILLED,
TRIGGER_ENTRY_CLOSE_TP_INVALIDATE,
TRIGGER_ENTRY_MONITOR_TYPE,
TRIGGER_ENTRY_VALIDITY_HOURS,
check_trigger_entry_intent_limit,
count_pending_trigger_entries,
is_trigger_entry_expired,
is_trigger_entry_key_monitor_type,
trigger_entry_expires_at_text,
trigger_entry_gate_preview,
trigger_entry_invalidate_by_tp,
trigger_entry_reached,
validate_trigger_entry_geometry,
validate_trigger_entry_rr,
)
from position_sizing_lib import ( from position_sizing_lib import (
OPEN_SOURCE_KEY_AUTO, OPEN_SOURCE_KEY_AUTO,
OPEN_SOURCE_KEY_TRIGGER,
OPEN_SOURCE_MANUAL, OPEN_SOURCE_MANUAL,
OPEN_SOURCE_ROLL, OPEN_SOURCE_ROLL,
OPEN_SOURCE_TREND, OPEN_SOURCE_TREND,
@@ -1032,6 +1051,7 @@ ENTRY_REASON_OPTIONS = (
"关键位斐波0.618", "关键位斐波0.618",
"关键位斐波0.786", "关键位斐波0.786",
"关键位假突破", "关键位假突破",
"关键位触价开仓",
) + STRATEGY_ENTRY_REASON_OPTIONS ) + STRATEGY_ENTRY_REASON_OPTIONS
STATS_SEGMENT_DEFS = ( STATS_SEGMENT_DEFS = (
@@ -1042,6 +1062,7 @@ STATS_SEGMENT_DEFS = (
("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}), ("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}),
("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}), ("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}),
("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}), ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),
("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}),
) )
# 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom # 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom
ENTRY_REASON_OTHER = "__OTHER__" ENTRY_REASON_OTHER = "__OTHER__"
@@ -1438,6 +1459,7 @@ def init_db():
"ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL", "ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL",
"ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0", "ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0",
"ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER", "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER",
"ALTER TABLE key_monitors ADD COLUMN session_date TEXT",
): ):
try: try:
c.execute(ddl) c.execute(ddl)
@@ -1633,6 +1655,8 @@ def _pnl_row_matches_segment(row, segment_key):
return kst == "斐波回调0.786" return kst == "斐波回调0.786"
if segment_key == "key_false_breakout": if segment_key == "key_false_breakout":
return kst == FALSE_BREAKOUT_MONITOR_TYPE return kst == FALSE_BREAKOUT_MONITOR_TYPE
if segment_key == "key_trigger":
return kst == TRIGGER_ENTRY_MONITOR_TYPE
return False return False
@@ -1650,6 +1674,7 @@ def _count_opens_for_segment(conn, start_td, end_td, segment_key):
"key_fib618": "斐波回调0.618", "key_fib618": "斐波回调0.618",
"key_fib786": "斐波回调0.786", "key_fib786": "斐波回调0.786",
"key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE, "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE,
"key_trigger": TRIGGER_ENTRY_MONITOR_TYPE,
} }
kst = kst_map.get(segment_key) kst = kst_map.get(segment_key)
if kst: if kst:
@@ -5031,7 +5056,464 @@ def _finalize_fib_key_fill(conn, row):
_finalize_key_monitor_one_shot(conn, row, succ, close_reason) _finalize_key_monitor_one_shot(conn, row, succ, close_reason)
def check_fib_key_monitors(): def _trigger_entry_exists_for_symbol(conn, symbol):
row = conn.execute(
"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type=?",
(symbol, TRIGGER_ENTRY_MONITOR_TYPE),
).fetchone()
return row is not None
def _add_trigger_entry_key_monitor(
conn,
symbol,
direction_sel,
entry,
sl,
tp,
breakeven_enabled=0,
time_close_enabled=0,
time_close_hours=None,
):
if _trigger_entry_exists_for_symbol(conn, symbol):
return False, f"{symbol} 已有触价开仓监控(同币仅允许一条)"
ex_sym = normalize_exchange_symbol(symbol)
mark = get_symbol_mark_price(symbol)
geom_err = validate_trigger_entry_geometry(direction_sel, entry, sl, tp, mark_at_add=mark)
if geom_err:
return False, geom_err
rr_err = validate_trigger_entry_rr(
direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio
)
if rr_err:
return False, rr_err
entry = float(round_price_to_exchange(ex_sym, entry) or entry)
sl = float(round_price_to_exchange(ex_sym, sl) or sl)
tp = float(round_price_to_exchange(ex_sym, tp) or tp)
geom_err = validate_trigger_entry_geometry(direction_sel, entry, sl, tp, mark_at_add=mark)
if geom_err:
return False, geom_err
rr_err = validate_trigger_entry_rr(
direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio
)
if rr_err:
return False, rr_err
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)
opens_today = count_opens_for_trading_day(conn, trading_day)
ok_intent, intent_msg = check_trigger_entry_intent_limit(
conn, trading_day, opens_today, DAILY_OPEN_HARD_LIMIT
)
if not ok_intent:
return False, intent_msg
if is_full_margin_mode(POSITION_SIZING_MODE):
ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn))
if not ok_flat:
return False, flat_msg
if count_pending_trigger_entries(conn, trading_day) > 0:
return False, "全仓杠杆模式下仅允许一条待触发触价监控"
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)
available_usdt = get_available_trading_usdt()
if is_full_margin_mode(POSITION_SIZING_MODE):
leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE)
sizing, sizing_err = compute_full_margin_sizing(
symbol=symbol,
available_usdt=available_usdt if available_usdt is not None else 0.0,
capital_base=capital_base,
buffer_ratio=FULL_MARGIN_BUFFER_RATIO,
btc_leverage=BTC_LEVERAGE,
alt_leverage=ALT_LEVERAGE,
funds_decimals=2,
)
if sizing_err:
return False, sizing_err
margin_capital = float(sizing["margin_capital"])
amount_plan = None
else:
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
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_plan, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry)
except Exception as e:
return False, friendly_exchange_error(e, available_usdt=available_usdt)
upper_px = round_price_to_exchange(ex_sym, max(entry, tp))
lower_px = round_price_to_exchange(ex_sym, min(entry, sl))
if upper_px is None or lower_px is None or float(upper_px) <= float(lower_px):
upper_px, lower_px = float(max(entry, tp, sl)), float(min(entry, tp, sl))
if upper_px <= lower_px:
lower_px = upper_px * 0.9999
be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0
tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None)
conn.execute(
"INSERT INTO key_monitors "
"(symbol, monitor_type, direction, upper, lower, "
"fib_entry_price, fib_stop_loss, fib_take_profit, "
"fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, "
"time_close_enabled, time_close_hours, session_date) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
symbol,
TRIGGER_ENTRY_MONITOR_TYPE,
direction_sel,
float(upper_px),
float(lower_px),
entry,
sl,
tp,
float(amount_plan) if amount_plan is not None else None,
margin_capital,
leverage,
be_flag,
tc_en,
tc_h,
trading_day,
),
)
return True, None
def _market_open_for_trigger_entry(
conn,
symbol,
direction,
exchange_symbol,
entry_price,
stop_loss,
take_profit,
breakeven_enabled=0,
time_close_enabled=0,
time_close_hours=None,
):
"""触价触发后市价开仓,计仓规则与实盘下单/关键位 RR 门槛一致。"""
ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_TRIGGER)
if not ok_src:
return False, src_msg, None
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
trading_day = get_trading_day(now)
opens_today_before = count_opens_for_trading_day(conn, trading_day)
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)
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_symbol_mark_price(symbol) or get_price(symbol)
if live_price is None:
return False, "获取标记价/实时价失败", None
try:
ensure_markets_loaded()
except Exception:
pass
lp_r = round_price_to_exchange(exchange_symbol, live_price)
if lp_r is not None:
live_price = float(lp_r)
entry_price = float(entry_price)
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)
planned_rr = calc_rr_ratio(direction, entry_price, stop_loss, take_profit)
if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR:
rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算"
return False, f"计划盈亏比 {rr_txt}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1", None
risk_percent = max(0.01, float(RISK_PERCENT))
if is_full_margin_mode(POSITION_SIZING_MODE):
ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn))
if not ok_flat:
return False, flat_msg, None
leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE)
sizing, sizing_err = compute_full_margin_sizing(
symbol=symbol,
available_usdt=available_usdt if available_usdt is not None else 0.0,
capital_base=capital_base,
buffer_ratio=FULL_MARGIN_BUFFER_RATIO,
btc_leverage=BTC_LEVERAGE,
alt_leverage=ALT_LEVERAGE,
funds_decimals=2,
)
if sizing_err:
return False, sizing_err, None
margin_capital = float(sizing["margin_capital"])
notional_value = float(sizing["notional_value"])
position_ratio = float(sizing["position_ratio"])
risk_amount = margin_capital
else:
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
risk_fraction = calc_risk_fraction(direction, entry_price, stop_loss)
if risk_fraction is None:
return False, "止损方向不合法(相对计划入场价)", None
risk_amount = round(capital_base * risk_percent / 100.0, 4)
notional_value = round(risk_amount / risk_fraction, 4)
margin_capital = round(notional_value / leverage, 4)
if capital_base and margin_capital > capital_base:
return False, "以损定仓后保证金超过当前交易资金", None
if available_usdt is not None:
max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4)
if margin_capital > max_margin:
return (
False,
f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U",
None,
)
position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0
try:
amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price)
contract_size = get_contract_size(exchange_symbol)
base_amount = round(float(amount) * contract_size, 8)
order_resp = place_exchange_order(
exchange_symbol, direction, amount, leverage,
stop_loss=stop_loss, take_profit=take_profit,
)
open_order_id = order_resp.get("id", "")
tpsl_attached = bool(order_resp.get("tpsl_attached"))
trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price)
except Exception as e:
return False, friendly_exchange_error(e, available_usdt=available_usdt), None
trigger_price = round_price_to_exchange(exchange_symbol, trigger_price)
stop_loss = round_price_to_exchange(exchange_symbol, stop_loss)
take_profit = round_price_to_exchange(exchange_symbol, take_profit)
opened_at_bj = app_now_str()
opened_at_ms = _to_ms_with_fallback(None, opened_at_bj)
planned_rr_fill = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit)
breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER)
breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT)
breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0
risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage)
if risk_amount_final is None:
risk_amount_final = risk_amount
else:
try:
risk_amount_final = round(float(risk_amount_final), 4)
except (TypeError, ValueError):
risk_amount_final = risk_amount
if direction == "short":
breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0)
else:
breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0)
breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw)
be_enabled = 1 if int(breakeven_enabled or 0) != 0 else 0
tc_en, tc_h, tc_at = time_close_insert_values(time_close_enabled, time_close_hours, opened_at_ms)
risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent)
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, key_signal_type, "
"time_close_enabled, time_close_hours, time_close_at_ms) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
symbol,
exchange_symbol,
direction,
trigger_price,
stop_loss,
stop_loss,
take_profit,
margin_capital,
leverage,
trade_style,
risk_percent_db,
risk_amount_final,
breakeven_rr_trigger,
breakeven_offset_pct,
breakeven_step_r,
0,
breakeven_price,
be_enabled,
notional_value,
position_ratio,
base_amount,
amount,
open_order_id,
opened_at_bj,
opened_at_ms,
trading_day,
ORDER_MONITOR_TYPE_KEY_AUTO,
stored_key_signal_type(TRIGGER_ENTRY_MONITOR_TYPE),
tc_en,
tc_h,
tc_at,
),
)
new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage)
opens_today_after = count_opens_for_trading_day(conn, trading_day)
return True, None, {
"new_order_id": new_order_id,
"open_order_id": open_order_id,
"trigger_price": trigger_price,
"planned_rr_fill": planned_rr_fill,
"risk_amount_final": risk_amount_final,
"margin_capital": margin_capital,
"leverage": leverage,
"amount": amount,
"tpsl_attached": tpsl_attached,
"opens_today_before": opens_today_before,
"opens_today_after": opens_today_after,
"trading_day": trading_day,
"stop_loss": stop_loss,
"take_profit": take_profit,
}
def _execute_trigger_entry_cross(conn, row):
"""标记价触达计划入场:先删监控行防重复触发,再市价开仓。"""
symbol = row["symbol"]
direction = (row["direction"] or "long").lower()
ex_sym = normalize_exchange_symbol(symbol)
entry = float(_sqlite_row_val(row, "fib_entry_price") or 0)
sl = float(_sqlite_row_val(row, "fib_stop_loss") or 0)
tp = float(_sqlite_row_val(row, "fib_take_profit") or 0)
be_en = breakeven_enabled_from_row(row, 0)
tc_en, tc_h, _ = time_close_settings_from_row(row)
kid = int(row["id"])
conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,))
conn.commit()
ok, err, det = _market_open_for_trigger_entry(
conn,
symbol,
direction,
ex_sym,
entry,
sl,
tp,
breakeven_enabled=be_en,
time_close_enabled=tc_en,
time_close_hours=tc_h,
)
if ok and det:
rr_txt = format_wechat_scalar_2dp(det.get("planned_rr_fill")) if det.get("planned_rr_fill") is not None else "-"
msg = (
f"# ✅ {symbol} 触价开仓成交\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(程序触价 @ E\n"
f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}{_wechat_direction_text(direction)}\n"
f"- 订单 ID**{det.get('new_order_id')}**\n"
f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n"
f"- 成交价:{format_price_for_symbol(symbol, det.get('trigger_price'))}\n"
f"- 止损:{format_wechat_scalar_2dp(det.get('stop_loss'))}|止盈:{format_price_for_symbol(symbol, det.get('take_profit'))}\n"
f"- 计划 RR{rr_txt}:1\n"
f"- {'已挂交易所 TP/SL' if det.get('tpsl_attached') else 'TP/SL 未挂上'}\n"
)
send_wechat_msg(msg)
insert_key_monitor_history(conn, row, 0, msg, TRIGGER_ENTRY_CLOSE_FILLED)
return True, None
fail_msg = err or "触价触发后开仓失败"
send_wechat_msg(
f"# ❌ {symbol} 触价开仓失败\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n"
f"- 原因:{fail_msg}\n"
)
insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED)
return False, fail_msg
def check_trigger_entry_key_monitors():
conn = get_db()
rows = conn.execute("SELECT * FROM key_monitors WHERE monitor_type=?", (TRIGGER_ENTRY_MONITOR_TYPE,)).fetchall()
now_dt = app_now()
for r in rows:
symbol = r["symbol"]
direction = (r["direction"] or "long").lower()
entry = float(_sqlite_row_val(r, "fib_entry_price") or 0)
sl = float(_sqlite_row_val(r, "fib_stop_loss") or 0)
tp = float(_sqlite_row_val(r, "fib_take_profit") or 0)
if entry <= 0 or sl <= 0 or tp <= 0:
_finalize_key_monitor_one_shot(conn, r, "触价计划价位无效", "fib_plan_invalid")
continue
mark = get_symbol_mark_price(symbol)
if mark is None:
continue
if is_trigger_entry_expired(r["created_at"], now_dt, hours=TRIGGER_ENTRY_VALIDITY_HOURS):
exp_txt = trigger_entry_expires_at_text(r["created_at"], hours=TRIGGER_ENTRY_VALIDITY_HOURS)
msg = (
f"# ⚠️ {symbol} 触价开仓已过期\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}{_wechat_direction_text(direction)}\n"
f"- 有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h(应于 {exp_txt} 前触发)\n"
)
send_wechat_msg(msg)
_finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_EXPIRED)
continue
if trigger_entry_invalidate_by_tp(direction, mark, tp):
msg = (
f"# ⚠️ {symbol} 触价开仓失效\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交)\n"
)
send_wechat_msg(msg)
_finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_TP_INVALIDATE)
continue
if trigger_entry_reached(direction, mark, entry):
try:
_execute_trigger_entry_cross(conn, r)
except Exception as e:
fail_msg = friendly_exchange_error(e)
try:
insert_key_monitor_history(conn, r, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED)
except Exception:
pass
send_wechat_msg(
f"# ❌ {symbol} 触价开仓异常\n**账户:{_wechat_account_label()}**\n- {fail_msg}\n"
)
conn.commit()
conn.close()
conn = get_db() conn = get_db()
rows = conn.execute("SELECT * FROM key_monitors").fetchall() rows = conn.execute("SELECT * FROM key_monitors").fetchall()
for r in rows: for r in rows:
@@ -5915,6 +6397,7 @@ def background_task():
conn.close() conn.close()
force_close_before_reset() force_close_before_reset()
check_fib_key_monitors() check_fib_key_monitors()
check_trigger_entry_key_monitors()
_roll_cfg = app.extensions.get("strategy_roll_cfg") _roll_cfg = app.extensions.get("strategy_roll_cfg")
if _roll_cfg: if _roll_cfg:
from strategy_roll_monitor_lib import check_roll_monitors from strategy_roll_monitor_lib import check_roll_monitors
@@ -6170,6 +6653,7 @@ def render_main_page(page="trade"):
key_stop_outside_breakout_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, key_stop_outside_breakout_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT,
key_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, key_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT,
false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS, false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS,
trigger_entry_validity_hours=TRIGGER_ENTRY_VALIDITY_HOURS,
) )
strategy_extra = {} strategy_extra = {}
if page in ("strategy", "strategy_trend", "strategy_roll", "strategy_records"): if page in ("strategy", "strategy_trend", "strategy_roll", "strategy_records"):
@@ -6328,7 +6812,7 @@ def api_account_snapshot():
def api_price_snapshot(): def api_price_snapshot():
conn = get_db() conn = get_db()
key_rows = conn.execute( key_rows = conn.execute(
"SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_limit_order_id,created_at FROM key_monitors" "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_stop_loss,fib_take_profit,fib_limit_order_id,created_at FROM key_monitors"
).fetchall() ).fetchall()
order_rows = conn.execute( order_rows = conn.execute(
"SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage," "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,"
@@ -6359,7 +6843,8 @@ def api_price_snapshot():
for r in key_rows: for r in key_rows:
is_fib = is_fib_key_monitor_type(r["monitor_type"]) is_fib = is_fib_key_monitor_type(r["monitor_type"])
is_fb = is_false_breakout_key_monitor_type(r["monitor_type"]) is_fb = is_false_breakout_key_monitor_type(r["monitor_type"])
if is_fib or is_fb: is_te = is_trigger_entry_key_monitor_type(r["monitor_type"])
if is_fib or is_fb or is_te:
price = get_symbol_mark_price(r["symbol"]) price = get_symbol_mark_price(r["symbol"])
else: else:
price = prices.get(r["symbol"]) price = prices.get(r["symbol"])
@@ -6393,6 +6878,24 @@ def api_price_snapshot():
gate_summary = prev.get("summary") or "-" gate_summary = prev.get("summary") or "-"
gate_metrics = prev.get("metrics") or "" gate_metrics = prev.get("metrics") or ""
fb_gate_ok = bool(prev.get("gate_ok")) fb_gate_ok = bool(prev.get("gate_ok"))
elif is_te:
direction = (r["direction"] or "long").lower()
entry = _sqlite_row_val(r, "fib_entry_price")
tp_v = _sqlite_row_val(r, "fib_take_profit")
entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-"
tp_txt = format_price_for_symbol(r["symbol"], tp_v) if tp_v else "-"
tp_inv = trigger_entry_invalidate_by_tp(direction, price, float(tp_v)) if tp_v else False
prev = trigger_entry_gate_preview(
entry_display=entry_txt,
take_profit_display=tp_txt,
created_at=_sqlite_row_val(r, "created_at"),
now=app_now(),
tp_invalidated=tp_inv,
hours=TRIGGER_ENTRY_VALIDITY_HOURS,
)
gate_summary = prev.get("summary") or "-"
gate_metrics = prev.get("metrics") or ""
fib_gate_ok = bool(prev.get("gate_ok"))
elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES: elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES:
try: try:
prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"]) prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"])
@@ -6967,14 +7470,15 @@ def add_key():
+ tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + tuple(KEY_MONITOR_ALERT_ONLY_TYPES)
+ tuple(FIB_KEY_MONITOR_TYPES) + tuple(FIB_KEY_MONITOR_TYPES)
+ (FALSE_BREAKOUT_MONITOR_TYPE,) + (FALSE_BREAKOUT_MONITOR_TYPE,)
+ (TRIGGER_ENTRY_MONITOR_TYPE,)
) )
if mt not in allowed_types: if mt not in allowed_types:
flash("监控类型无效") flash("监控类型无效")
return redirect("/key_monitor") return redirect("/key_monitor")
if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt): if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt):
flash( flash(
"全仓杠杆模式下不可添加箱体/收敛突破斐波监控;" "全仓杠杆模式下不可添加箱体/收敛突破斐波或假突破监控;"
"请改用阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。" "可使用「触价开仓」或阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。"
) )
return redirect("/key_monitor") return redirect("/key_monitor")
skip_volume_rank = is_false_breakout_key_monitor_type(mt) skip_volume_rank = is_false_breakout_key_monitor_type(mt)
@@ -7003,6 +7507,45 @@ def add_key():
except Exception: except Exception:
pass pass
be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled")) be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled"))
tc_en = parse_time_close_enabled_form(d.get("time_close_enabled"))
tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None
if tc_en and not tc_h:
tc_en = 0
if is_trigger_entry_key_monitor_type(mt):
if direction_sel not in ("long", "short"):
conn.close()
conn = None
flash("触价开仓请选择做多或做空")
return redirect("/key_monitor")
try:
entry_px = float(d.get("trigger_entry") or 0)
sl_px = float(d.get("trigger_sl") or 0)
tp_px = float(d.get("trigger_tp") or 0)
except (TypeError, ValueError):
entry_px = sl_px = tp_px = 0
if entry_px <= 0 or sl_px <= 0 or tp_px <= 0:
conn.close()
conn = None
flash("触价开仓须填写有效的入场价、止损价、止盈价")
return redirect("/key_monitor")
ok_te, err_te = _add_trigger_entry_key_monitor(
conn, symbol, direction_sel, entry_px, sl_px, tp_px, breakeven_enabled=be_flag,
time_close_enabled=tc_en, time_close_hours=tc_h,
)
conn.commit()
conn.close()
conn = None
if not ok_te:
flash(err_te or "触价开仓监控添加失败")
return redirect("/key_monitor")
flash(
f"触价开仓已添加({symbol} 日成交量排名 {rank}/{total}"
f"|有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h"
f"|标记价触达入场价后下一轮询市价开仓"
f"|移动保本:{'' if be_flag else ''}"
+ (f"{time_close_label(tc_h)}" if tc_en else "")
)
return redirect("/key_monitor")
if is_false_breakout_key_monitor_type(mt): if is_false_breakout_key_monitor_type(mt):
fb_sym = normalize_false_breakout_symbol(symbol) fb_sym = normalize_false_breakout_symbol(symbol)
if not fb_sym: if not fb_sym:
+19 -7
View File
@@ -1540,14 +1540,19 @@ function syncKeyMonitorFormFields(){
const autoTypes = new Set(["箱体突破","收敛突破"]); const autoTypes = new Set(["箱体突破","收敛突破"]);
const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]); const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]);
const fbTypes = new Set(["假突破"]); const fbTypes = new Set(["假突破"]);
const teTypes = new Set(["触价开仓"]);
const rsTypes = new Set(["关键阻力位","关键支撑位"]); const rsTypes = new Set(["关键阻力位","关键支撑位"]);
const showAuto = autoTypes.has(t); const showAuto = autoTypes.has(t);
const showFb = fbTypes.has(t); const showFb = fbTypes.has(t);
const showBe = showAuto || fibTypes.has(t) || showFb; const showTe = teTypes.has(t);
const showBe = showAuto || fibTypes.has(t) || showFb || showTe;
const showDir = !rsTypes.has(t); const showDir = !rsTypes.has(t);
const upperEl = document.getElementById("key-upper"); const upperEl = document.getElementById("key-upper");
const lowerEl = document.getElementById("key-lower"); const lowerEl = document.getElementById("key-lower");
const fbPriceEl = document.getElementById("key-fb-price"); const fbPriceEl = document.getElementById("key-fb-price");
const teEntryEl = document.getElementById("key-trigger-entry");
const teSlEl = document.getElementById("key-trigger-sl");
const teTpEl = document.getElementById("key-trigger-tp");
if(dirEl){ if(dirEl){
dirEl.style.display = showDir ? "" : "none"; dirEl.style.display = showDir ? "" : "none";
dirEl.required = showDir; dirEl.required = showDir;
@@ -1561,15 +1566,16 @@ function syncKeyMonitorFormFields(){
} }
if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none"; if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
if(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe); if(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
const hideBounds = showFb || showTe;
if(upperEl){ if(upperEl){
upperEl.style.display = showFb ? "none" : ""; upperEl.style.display = hideBounds ? "none" : "";
upperEl.required = !showFb; upperEl.required = !hideBounds;
if(showFb) upperEl.value = ""; if(hideBounds) upperEl.value = "";
} }
if(lowerEl){ if(lowerEl){
lowerEl.style.display = showFb ? "none" : ""; lowerEl.style.display = hideBounds ? "none" : "";
lowerEl.required = !showFb; lowerEl.required = !hideBounds;
if(showFb) lowerEl.value = ""; if(hideBounds) lowerEl.value = "";
} }
if(fbPriceEl){ if(fbPriceEl){
fbPriceEl.style.display = showFb ? "" : "none"; fbPriceEl.style.display = showFb ? "" : "none";
@@ -1577,6 +1583,12 @@ function syncKeyMonitorFormFields(){
if(!showFb) fbPriceEl.value = ""; if(!showFb) fbPriceEl.value = "";
fbPriceEl.placeholder = (dirEl && dirEl.value === "short") ? "高点(阻力)" : ((dirEl && dirEl.value === "long") ? "低点(支撑)" : "做空填高点/做多填低点"); fbPriceEl.placeholder = (dirEl && dirEl.value === "short") ? "高点(阻力)" : ((dirEl && dirEl.value === "long") ? "低点(支撑)" : "做空填高点/做多填低点");
} }
[teEntryEl, teSlEl, teTpEl].forEach((el)=>{
if(!el) return;
el.style.display = showTe ? "" : "none";
el.required = showTe;
if(!showTe) el.value = "";
});
} }
const keyTypeSel = document.querySelector('#key-form [name="type"]'); const keyTypeSel = document.querySelector('#key-form [name="type"]');
const keyModeSel = document.getElementById("key-sl-tp-mode"); const keyModeSel = document.getElementById("key-sl-tp-mode");
+3 -2
View File
@@ -66,9 +66,10 @@
| **收敛突破** | 同上(自动开仓类)。 | | **收敛突破** | 同上(自动开仓类)。 |
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 | | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
| **关键支撑位** | 同上(仅提醒)。 | | **关键支撑位** | 同上(仅提醒)。 |
| **触价开仓** | **不挂交易所限价**;标记价触达计划入场价后 **下一轮询市价开仓**RR 门槛同关键位 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h**;全仓杠杆模式可用。 |
3. **方向**:做多 / 做空(选)。 3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整 4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**
**限制:** **限制:**
活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。 活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。
@@ -1,7 +1,7 @@
# 关键位监控说明(自动开仓 + 人工盯盘) # 关键位监控说明(自动开仓 + 人工盯盘)
**适用:`crypto_monitor_binance`Binance U 本位永续)** **适用:`crypto_monitor_gate`Gate U 本位永续)**
Gate / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_monitor_lib.py` Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_monitor_lib.py`
本文档与 `.env``check_key_monitors``add_key``_key_hard_checks``_process_key_rs_level_alert` 一致。 本文档与 `.env``check_key_monitors``add_key``_key_hard_checks``_process_key_rs_level_alert` 一致。
@@ -16,8 +16,9 @@ Gate / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_
| **关键阻力位** | **不选**`direction=watch` | **否** | 5m 收盘突破上/下沿 → 微信 **3 次**`key_level_alert_done` | | **关键阻力位** | **不选**`direction=watch` | **否** | 5m 收盘突破上/下沿 → 微信 **3 次**`key_level_alert_done` |
| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) | | **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) |
| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) | | 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) |
| **触价开仓** | **必选** 多/空 | **程序盯价 → 触 E 后市价** | 见下文 **§六** |
**添加时(所有类型):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30**;上沿 **>** 下沿。 **添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位)
--- ---
@@ -117,7 +118,31 @@ Gate / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_
--- ---
## 四、环境与参数(`.env` 摘要 ## 四、触价开仓(程序触价,无交易所挂单
### 4.1 录入
- 类型选 **触价开仓**;方向必选多/空。
- 填写 **计划入场价 E**、**止损 SL**、**止盈 TP**(做多须 `SL < E < TP`)。
- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5,与箱体/斐波相同)。
- 可选移动保本、时间平仓;**全仓杠杆模式**下可用(页面隐藏箱体/收敛/斐波/假突破)。
### 4.2 触发与结案
- 轮询标记价:做多 `标记价 ≤ E`、做空 `标记价 ≥ E`**下一轮询市价开仓**,挂交易所 TP/SL,进下单监控。
- 未成交前标记价先触 **TP 侧**`trigger_tp_invalidate`**24h** 未触发 → `trigger_entry_expired`
- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`
### 4.3 计仓与占位
- **以损定仓**:按 E、SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x、其它 5x。
- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条。
共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`
---
## 五、环境与参数(`.env` 摘要)
| 变量 | 箱体/收敛 | 阻力/支撑 | | 变量 | 箱体/收敛 | 阻力/支撑 |
|------|-----------|-----------| |------|-----------|-----------|
@@ -130,7 +155,7 @@ Gate / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_
--- ---
## 、相关代码 ## 、相关代码
| 说明 | 位置 | | 说明 | 位置 |
|------|------| |------|------|
+543 -4
View File
@@ -116,8 +116,27 @@ from manual_sltp_lib import (
resolve_entrust_sltp_prices, resolve_entrust_sltp_prices,
resolve_open_sltp_prices, resolve_open_sltp_prices,
) )
from trigger_entry_key_monitor_lib import (
TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED,
TRIGGER_ENTRY_CLOSE_EXPIRED,
TRIGGER_ENTRY_CLOSE_FILLED,
TRIGGER_ENTRY_CLOSE_TP_INVALIDATE,
TRIGGER_ENTRY_MONITOR_TYPE,
TRIGGER_ENTRY_VALIDITY_HOURS,
check_trigger_entry_intent_limit,
count_pending_trigger_entries,
is_trigger_entry_expired,
is_trigger_entry_key_monitor_type,
trigger_entry_expires_at_text,
trigger_entry_gate_preview,
trigger_entry_invalidate_by_tp,
trigger_entry_reached,
validate_trigger_entry_geometry,
validate_trigger_entry_rr,
)
from position_sizing_lib import ( from position_sizing_lib import (
OPEN_SOURCE_KEY_AUTO, OPEN_SOURCE_KEY_AUTO,
OPEN_SOURCE_KEY_TRIGGER,
OPEN_SOURCE_MANUAL, OPEN_SOURCE_MANUAL,
assert_open_source_allowed, assert_open_source_allowed,
compute_full_margin_sizing, compute_full_margin_sizing,
@@ -1022,6 +1041,7 @@ ENTRY_REASON_OPTIONS = (
"关键位斐波0.618", "关键位斐波0.618",
"关键位斐波0.786", "关键位斐波0.786",
"关键位假突破", "关键位假突破",
"关键位触价开仓",
) + STRATEGY_ENTRY_REASON_OPTIONS ) + STRATEGY_ENTRY_REASON_OPTIONS
STATS_SEGMENT_DEFS = ( STATS_SEGMENT_DEFS = (
@@ -1032,6 +1052,7 @@ STATS_SEGMENT_DEFS = (
("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}), ("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}),
("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}), ("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}),
("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}), ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),
("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}),
) )
# 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom # 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom
ENTRY_REASON_OTHER = "__OTHER__" ENTRY_REASON_OTHER = "__OTHER__"
@@ -1433,6 +1454,7 @@ def init_db():
"ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL", "ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL",
"ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0", "ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0",
"ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER", "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER",
"ALTER TABLE key_monitors ADD COLUMN session_date TEXT",
): ):
try: try:
c.execute(ddl) c.execute(ddl)
@@ -1630,6 +1652,8 @@ def _pnl_row_matches_segment(row, segment_key):
return kst == "斐波回调0.786" return kst == "斐波回调0.786"
if segment_key == "key_false_breakout": if segment_key == "key_false_breakout":
return kst == FALSE_BREAKOUT_MONITOR_TYPE return kst == FALSE_BREAKOUT_MONITOR_TYPE
if segment_key == "key_trigger":
return kst == TRIGGER_ENTRY_MONITOR_TYPE
return False return False
@@ -1647,6 +1671,7 @@ def _count_opens_for_segment(conn, start_td, end_td, segment_key):
"key_fib618": "斐波回调0.618", "key_fib618": "斐波回调0.618",
"key_fib786": "斐波回调0.786", "key_fib786": "斐波回调0.786",
"key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE, "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE,
"key_trigger": TRIGGER_ENTRY_MONITOR_TYPE,
} }
kst = kst_map.get(segment_key) kst = kst_map.get(segment_key)
if kst: if kst:
@@ -5001,6 +5026,463 @@ def _finalize_fib_key_fill(conn, row):
_finalize_key_monitor_one_shot(conn, row, succ, close_reason) _finalize_key_monitor_one_shot(conn, row, succ, close_reason)
def _trigger_entry_exists_for_symbol(conn, symbol):
row = conn.execute(
"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type=?",
(symbol, TRIGGER_ENTRY_MONITOR_TYPE),
).fetchone()
return row is not None
def _add_trigger_entry_key_monitor(
conn,
symbol,
direction_sel,
entry,
sl,
tp,
breakeven_enabled=0,
time_close_enabled=0,
time_close_hours=None,
):
if _trigger_entry_exists_for_symbol(conn, symbol):
return False, f"{symbol} 已有触价开仓监控(同币仅允许一条)"
ex_sym = normalize_exchange_symbol(symbol)
mark = get_symbol_mark_price(symbol)
geom_err = validate_trigger_entry_geometry(direction_sel, entry, sl, tp, mark_at_add=mark)
if geom_err:
return False, geom_err
rr_err = validate_trigger_entry_rr(
direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio
)
if rr_err:
return False, rr_err
entry = float(round_price_to_exchange(ex_sym, entry) or entry)
sl = float(round_price_to_exchange(ex_sym, sl) or sl)
tp = float(round_price_to_exchange(ex_sym, tp) or tp)
geom_err = validate_trigger_entry_geometry(direction_sel, entry, sl, tp, mark_at_add=mark)
if geom_err:
return False, geom_err
rr_err = validate_trigger_entry_rr(
direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio
)
if rr_err:
return False, rr_err
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)
opens_today = count_opens_for_trading_day(conn, trading_day)
ok_intent, intent_msg = check_trigger_entry_intent_limit(
conn, trading_day, opens_today, DAILY_OPEN_HARD_LIMIT
)
if not ok_intent:
return False, intent_msg
if is_full_margin_mode(POSITION_SIZING_MODE):
ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn))
if not ok_flat:
return False, flat_msg
if count_pending_trigger_entries(conn, trading_day) > 0:
return False, "全仓杠杆模式下仅允许一条待触发触价监控"
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)
available_usdt = get_available_trading_usdt()
if is_full_margin_mode(POSITION_SIZING_MODE):
leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE)
sizing, sizing_err = compute_full_margin_sizing(
symbol=symbol,
available_usdt=available_usdt if available_usdt is not None else 0.0,
capital_base=capital_base,
buffer_ratio=FULL_MARGIN_BUFFER_RATIO,
btc_leverage=BTC_LEVERAGE,
alt_leverage=ALT_LEVERAGE,
funds_decimals=2,
)
if sizing_err:
return False, sizing_err
margin_capital = float(sizing["margin_capital"])
amount_plan = None
else:
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
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_plan, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry)
except Exception as e:
return False, friendly_exchange_error(e, available_usdt=available_usdt)
upper_px = round_price_to_exchange(ex_sym, max(entry, tp))
lower_px = round_price_to_exchange(ex_sym, min(entry, sl))
if upper_px is None or lower_px is None or float(upper_px) <= float(lower_px):
upper_px, lower_px = float(max(entry, tp, sl)), float(min(entry, tp, sl))
if upper_px <= lower_px:
lower_px = upper_px * 0.9999
be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0
tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None)
conn.execute(
"INSERT INTO key_monitors "
"(symbol, monitor_type, direction, upper, lower, "
"fib_entry_price, fib_stop_loss, fib_take_profit, "
"fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, "
"time_close_enabled, time_close_hours, session_date) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
symbol,
TRIGGER_ENTRY_MONITOR_TYPE,
direction_sel,
float(upper_px),
float(lower_px),
entry,
sl,
tp,
float(amount_plan) if amount_plan is not None else None,
margin_capital,
leverage,
be_flag,
tc_en,
tc_h,
trading_day,
),
)
return True, None
def _market_open_for_trigger_entry(
conn,
symbol,
direction,
exchange_symbol,
entry_price,
stop_loss,
take_profit,
breakeven_enabled=0,
time_close_enabled=0,
time_close_hours=None,
):
"""触价触发后市价开仓,计仓规则与实盘下单/关键位 RR 门槛一致。"""
ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_TRIGGER)
if not ok_src:
return False, src_msg, None
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
trading_day = get_trading_day(now)
opens_today_before = count_opens_for_trading_day(conn, trading_day)
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)
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_symbol_mark_price(symbol) or get_price(symbol)
if live_price is None:
return False, "获取标记价/实时价失败", None
try:
ensure_markets_loaded()
except Exception:
pass
lp_r = round_price_to_exchange(exchange_symbol, live_price)
if lp_r is not None:
live_price = float(lp_r)
entry_price = float(entry_price)
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)
planned_rr = calc_rr_ratio(direction, entry_price, stop_loss, take_profit)
if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR:
rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算"
return False, f"计划盈亏比 {rr_txt}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1", None
risk_percent = max(0.01, float(RISK_PERCENT))
if is_full_margin_mode(POSITION_SIZING_MODE):
ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn))
if not ok_flat:
return False, flat_msg, None
leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE)
sizing, sizing_err = compute_full_margin_sizing(
symbol=symbol,
available_usdt=available_usdt if available_usdt is not None else 0.0,
capital_base=capital_base,
buffer_ratio=FULL_MARGIN_BUFFER_RATIO,
btc_leverage=BTC_LEVERAGE,
alt_leverage=ALT_LEVERAGE,
funds_decimals=2,
)
if sizing_err:
return False, sizing_err, None
margin_capital = float(sizing["margin_capital"])
notional_value = float(sizing["notional_value"])
position_ratio = float(sizing["position_ratio"])
risk_amount = margin_capital
else:
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
risk_fraction = calc_risk_fraction(direction, entry_price, stop_loss)
if risk_fraction is None:
return False, "止损方向不合法(相对计划入场价)", None
risk_amount = round(capital_base * risk_percent / 100.0, 4)
notional_value = round(risk_amount / risk_fraction, 4)
margin_capital = round(notional_value / leverage, 4)
if capital_base and margin_capital > capital_base:
return False, "以损定仓后保证金超过当前交易资金", None
if available_usdt is not None:
max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4)
if margin_capital > max_margin:
return (
False,
f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U",
None,
)
position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0
try:
amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price)
contract_size = get_contract_size(exchange_symbol)
base_amount = round(float(amount) * contract_size, 8)
order_resp = place_exchange_order(
exchange_symbol, direction, amount, leverage,
stop_loss=stop_loss, take_profit=take_profit,
)
open_order_id = order_resp.get("id", "")
tpsl_attached = bool(order_resp.get("tpsl_attached"))
trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price)
except Exception as e:
return False, friendly_exchange_error(e, available_usdt=available_usdt), None
trigger_price = round_price_to_exchange(exchange_symbol, trigger_price)
stop_loss = round_price_to_exchange(exchange_symbol, stop_loss)
take_profit = round_price_to_exchange(exchange_symbol, take_profit)
opened_at_bj = app_now_str()
opened_at_ms = _to_ms_with_fallback(None, opened_at_bj)
planned_rr_fill = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit)
breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER)
breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT)
breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0
risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage)
if risk_amount_final is None:
risk_amount_final = risk_amount
else:
try:
risk_amount_final = round(float(risk_amount_final), 4)
except (TypeError, ValueError):
risk_amount_final = risk_amount
if direction == "short":
breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0)
else:
breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0)
breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw)
be_enabled = 1 if int(breakeven_enabled or 0) != 0 else 0
tc_en, tc_h, tc_at = time_close_insert_values(time_close_enabled, time_close_hours, opened_at_ms)
risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent)
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, key_signal_type, "
"time_close_enabled, time_close_hours, time_close_at_ms) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
symbol,
exchange_symbol,
direction,
trigger_price,
stop_loss,
stop_loss,
take_profit,
margin_capital,
leverage,
trade_style,
risk_percent_db,
risk_amount_final,
breakeven_rr_trigger,
breakeven_offset_pct,
breakeven_step_r,
0,
breakeven_price,
be_enabled,
notional_value,
position_ratio,
base_amount,
amount,
open_order_id,
opened_at_bj,
opened_at_ms,
trading_day,
ORDER_MONITOR_TYPE_KEY_AUTO,
stored_key_signal_type(TRIGGER_ENTRY_MONITOR_TYPE),
tc_en,
tc_h,
tc_at,
),
)
new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage)
opens_today_after = count_opens_for_trading_day(conn, trading_day)
return True, None, {
"new_order_id": new_order_id,
"open_order_id": open_order_id,
"trigger_price": trigger_price,
"planned_rr_fill": planned_rr_fill,
"risk_amount_final": risk_amount_final,
"margin_capital": margin_capital,
"leverage": leverage,
"amount": amount,
"tpsl_attached": tpsl_attached,
"opens_today_before": opens_today_before,
"opens_today_after": opens_today_after,
"trading_day": trading_day,
"stop_loss": stop_loss,
"take_profit": take_profit,
}
def _execute_trigger_entry_cross(conn, row):
"""标记价触达计划入场:先删监控行防重复触发,再市价开仓。"""
symbol = row["symbol"]
direction = (row["direction"] or "long").lower()
ex_sym = normalize_exchange_symbol(symbol)
entry = float(_sqlite_row_val(row, "fib_entry_price") or 0)
sl = float(_sqlite_row_val(row, "fib_stop_loss") or 0)
tp = float(_sqlite_row_val(row, "fib_take_profit") or 0)
be_en = breakeven_enabled_from_row(row, 0)
tc_en, tc_h, _ = time_close_settings_from_row(row)
kid = int(row["id"])
conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,))
conn.commit()
ok, err, det = _market_open_for_trigger_entry(
conn,
symbol,
direction,
ex_sym,
entry,
sl,
tp,
breakeven_enabled=be_en,
time_close_enabled=tc_en,
time_close_hours=tc_h,
)
if ok and det:
rr_txt = format_wechat_scalar_2dp(det.get("planned_rr_fill")) if det.get("planned_rr_fill") is not None else "-"
msg = (
f"# ✅ {symbol} 触价开仓成交\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(程序触价 @ E\n"
f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}{_wechat_direction_text(direction)}\n"
f"- 订单 ID**{det.get('new_order_id')}**\n"
f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n"
f"- 成交价:{format_price_for_symbol(symbol, det.get('trigger_price'))}\n"
f"- 止损:{format_wechat_scalar_2dp(det.get('stop_loss'))}|止盈:{format_price_for_symbol(symbol, det.get('take_profit'))}\n"
f"- 计划 RR{rr_txt}:1\n"
f"- {'已挂交易所 TP/SL' if det.get('tpsl_attached') else 'TP/SL 未挂上'}\n"
)
send_wechat_msg(msg)
insert_key_monitor_history(conn, row, 0, msg, TRIGGER_ENTRY_CLOSE_FILLED)
return True, None
fail_msg = err or "触价触发后开仓失败"
send_wechat_msg(
f"# ❌ {symbol} 触价开仓失败\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n"
f"- 原因:{fail_msg}\n"
)
insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED)
return False, fail_msg
def check_trigger_entry_key_monitors():
conn = get_db()
rows = conn.execute("SELECT * FROM key_monitors WHERE monitor_type=?", (TRIGGER_ENTRY_MONITOR_TYPE,)).fetchall()
now_dt = app_now()
for r in rows:
symbol = r["symbol"]
direction = (r["direction"] or "long").lower()
entry = float(_sqlite_row_val(r, "fib_entry_price") or 0)
sl = float(_sqlite_row_val(r, "fib_stop_loss") or 0)
tp = float(_sqlite_row_val(r, "fib_take_profit") or 0)
if entry <= 0 or sl <= 0 or tp <= 0:
_finalize_key_monitor_one_shot(conn, r, "触价计划价位无效", "fib_plan_invalid")
continue
mark = get_symbol_mark_price(symbol)
if mark is None:
continue
if is_trigger_entry_expired(r["created_at"], now_dt, hours=TRIGGER_ENTRY_VALIDITY_HOURS):
exp_txt = trigger_entry_expires_at_text(r["created_at"], hours=TRIGGER_ENTRY_VALIDITY_HOURS)
msg = (
f"# ⚠️ {symbol} 触价开仓已过期\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}{_wechat_direction_text(direction)}\n"
f"- 有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h(应于 {exp_txt} 前触发)\n"
)
send_wechat_msg(msg)
_finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_EXPIRED)
continue
if trigger_entry_invalidate_by_tp(direction, mark, tp):
msg = (
f"# ⚠️ {symbol} 触价开仓失效\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交)\n"
)
send_wechat_msg(msg)
_finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_TP_INVALIDATE)
continue
if trigger_entry_reached(direction, mark, entry):
try:
_execute_trigger_entry_cross(conn, r)
except Exception as e:
fail_msg = friendly_exchange_error(e)
try:
insert_key_monitor_history(conn, r, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED)
except Exception:
pass
send_wechat_msg(
f"# ❌ {symbol} 触价开仓异常\n**账户:{_wechat_account_label()}**\n- {fail_msg}\n"
)
conn.commit()
conn.close()
def check_fib_key_monitors(): def check_fib_key_monitors():
conn = get_db() conn = get_db()
rows = conn.execute("SELECT * FROM key_monitors").fetchall() rows = conn.execute("SELECT * FROM key_monitors").fetchall()
@@ -5879,6 +6361,7 @@ def background_task():
conn.close() conn.close()
force_close_before_reset() force_close_before_reset()
check_fib_key_monitors() check_fib_key_monitors()
check_trigger_entry_key_monitors()
_roll_cfg = app.extensions.get("strategy_roll_cfg") _roll_cfg = app.extensions.get("strategy_roll_cfg")
if _roll_cfg: if _roll_cfg:
from strategy_roll_monitor_lib import check_roll_monitors from strategy_roll_monitor_lib import check_roll_monitors
@@ -6276,6 +6759,7 @@ def render_main_page(page="trade"):
key_stop_outside_breakout_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, key_stop_outside_breakout_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT,
key_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, key_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT,
false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS, false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS,
trigger_entry_validity_hours=TRIGGER_ENTRY_VALIDITY_HOURS,
) )
strategy_extra = {} strategy_extra = {}
if page in ("strategy", "strategy_trend", "strategy_roll", "strategy_records"): if page in ("strategy", "strategy_trend", "strategy_roll", "strategy_records"):
@@ -6451,7 +6935,7 @@ def api_account_snapshot():
def api_price_snapshot(): def api_price_snapshot():
conn = get_db() conn = get_db()
key_rows = conn.execute( key_rows = conn.execute(
"SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_limit_order_id,created_at FROM key_monitors" "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_stop_loss,fib_take_profit,fib_limit_order_id,created_at FROM key_monitors"
).fetchall() ).fetchall()
order_rows = conn.execute( order_rows = conn.execute(
"SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage," "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,"
@@ -6491,7 +6975,8 @@ def api_price_snapshot():
for r in key_rows: for r in key_rows:
is_fib = is_fib_key_monitor_type(r["monitor_type"]) is_fib = is_fib_key_monitor_type(r["monitor_type"])
is_fb = is_false_breakout_key_monitor_type(r["monitor_type"]) is_fb = is_false_breakout_key_monitor_type(r["monitor_type"])
if is_fib or is_fb: is_te = is_trigger_entry_key_monitor_type(r["monitor_type"])
if is_fib or is_fb or is_te:
price = get_symbol_mark_price(r["symbol"]) price = get_symbol_mark_price(r["symbol"])
else: else:
price = prices.get(r["symbol"]) price = prices.get(r["symbol"])
@@ -6525,6 +7010,24 @@ def api_price_snapshot():
gate_summary = prev.get("summary") or "-" gate_summary = prev.get("summary") or "-"
gate_metrics = prev.get("metrics") or "" gate_metrics = prev.get("metrics") or ""
fb_gate_ok = bool(prev.get("gate_ok")) fb_gate_ok = bool(prev.get("gate_ok"))
elif is_te:
direction = (r["direction"] or "long").lower()
entry = _sqlite_row_val(r, "fib_entry_price")
tp_v = _sqlite_row_val(r, "fib_take_profit")
entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-"
tp_txt = format_price_for_symbol(r["symbol"], tp_v) if tp_v else "-"
tp_inv = trigger_entry_invalidate_by_tp(direction, price, float(tp_v)) if tp_v else False
prev = trigger_entry_gate_preview(
entry_display=entry_txt,
take_profit_display=tp_txt,
created_at=_sqlite_row_val(r, "created_at"),
now=app_now(),
tp_invalidated=tp_inv,
hours=TRIGGER_ENTRY_VALIDITY_HOURS,
)
gate_summary = prev.get("summary") or "-"
gate_metrics = prev.get("metrics") or ""
fib_gate_ok = bool(prev.get("gate_ok"))
elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES: elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES:
try: try:
prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"]) prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"])
@@ -7123,14 +7626,15 @@ def add_key():
+ tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + tuple(KEY_MONITOR_ALERT_ONLY_TYPES)
+ tuple(FIB_KEY_MONITOR_TYPES) + tuple(FIB_KEY_MONITOR_TYPES)
+ (FALSE_BREAKOUT_MONITOR_TYPE,) + (FALSE_BREAKOUT_MONITOR_TYPE,)
+ (TRIGGER_ENTRY_MONITOR_TYPE,)
) )
if mt not in allowed_types: if mt not in allowed_types:
flash("监控类型无效") flash("监控类型无效")
return redirect("/key_monitor") return redirect("/key_monitor")
if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt): if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt):
flash( flash(
"全仓杠杆模式下不可添加箱体/收敛突破、斐波或假突破监控;" "全仓杠杆模式下不可添加箱体/收敛突破、斐波或假突破监控;"
"请改用阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。" "可使用「触价开仓」或阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。"
) )
return redirect("/key_monitor") return redirect("/key_monitor")
skip_volume_rank = is_false_breakout_key_monitor_type(mt) skip_volume_rank = is_false_breakout_key_monitor_type(mt)
@@ -7166,6 +7670,41 @@ def add_key():
tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None
if tc_en and not tc_h: if tc_en and not tc_h:
tc_en = 0 tc_en = 0
if is_trigger_entry_key_monitor_type(mt):
if direction_sel not in ("long", "short"):
conn.close()
conn = None
flash("触价开仓请选择做多或做空")
return redirect("/key_monitor")
try:
entry_px = float(d.get("trigger_entry") or 0)
sl_px = float(d.get("trigger_sl") or 0)
tp_px = float(d.get("trigger_tp") or 0)
except (TypeError, ValueError):
entry_px = sl_px = tp_px = 0
if entry_px <= 0 or sl_px <= 0 or tp_px <= 0:
conn.close()
conn = None
flash("触价开仓须填写有效的入场价、止损价、止盈价")
return redirect("/key_monitor")
ok_te, err_te = _add_trigger_entry_key_monitor(
conn, symbol, direction_sel, entry_px, sl_px, tp_px, breakeven_enabled=be_flag,
time_close_enabled=tc_en, time_close_hours=tc_h,
)
conn.commit()
conn.close()
conn = None
if not ok_te:
flash(err_te or "触价开仓监控添加失败")
return redirect("/key_monitor")
flash(
f"触价开仓已添加({symbol} 日成交量排名 {rank}/{total}"
f"|有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h"
f"|标记价触达入场价后下一轮询市价开仓"
f"|移动保本:{'' if be_flag else ''}"
+ (f"{time_close_label(tc_h)}" if tc_en else "")
)
return redirect("/key_monitor")
if is_false_breakout_key_monitor_type(mt): if is_false_breakout_key_monitor_type(mt):
fb_sym = normalize_false_breakout_symbol(symbol) fb_sym = normalize_false_breakout_symbol(symbol)
if not fb_sym: if not fb_sym:
+19 -7
View File
@@ -1520,14 +1520,19 @@ function syncKeyMonitorFormFields(){
const autoTypes = new Set(["箱体突破","收敛突破"]); const autoTypes = new Set(["箱体突破","收敛突破"]);
const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]); const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]);
const fbTypes = new Set(["假突破"]); const fbTypes = new Set(["假突破"]);
const teTypes = new Set(["触价开仓"]);
const rsTypes = new Set(["关键阻力位","关键支撑位"]); const rsTypes = new Set(["关键阻力位","关键支撑位"]);
const showAuto = autoTypes.has(t); const showAuto = autoTypes.has(t);
const showFb = fbTypes.has(t); const showFb = fbTypes.has(t);
const showBe = showAuto || fibTypes.has(t) || showFb; const showTe = teTypes.has(t);
const showBe = showAuto || fibTypes.has(t) || showFb || showTe;
const showDir = !rsTypes.has(t); const showDir = !rsTypes.has(t);
const upperEl = document.getElementById("key-upper"); const upperEl = document.getElementById("key-upper");
const lowerEl = document.getElementById("key-lower"); const lowerEl = document.getElementById("key-lower");
const fbPriceEl = document.getElementById("key-fb-price"); const fbPriceEl = document.getElementById("key-fb-price");
const teEntryEl = document.getElementById("key-trigger-entry");
const teSlEl = document.getElementById("key-trigger-sl");
const teTpEl = document.getElementById("key-trigger-tp");
if(dirEl){ if(dirEl){
dirEl.style.display = showDir ? "" : "none"; dirEl.style.display = showDir ? "" : "none";
dirEl.required = showDir; dirEl.required = showDir;
@@ -1541,15 +1546,16 @@ function syncKeyMonitorFormFields(){
} }
if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none"; if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
if(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe); if(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
const hideBounds = showFb || showTe;
if(upperEl){ if(upperEl){
upperEl.style.display = showFb ? "none" : ""; upperEl.style.display = hideBounds ? "none" : "";
upperEl.required = !showFb; upperEl.required = !hideBounds;
if(showFb) upperEl.value = ""; if(hideBounds) upperEl.value = "";
} }
if(lowerEl){ if(lowerEl){
lowerEl.style.display = showFb ? "none" : ""; lowerEl.style.display = hideBounds ? "none" : "";
lowerEl.required = !showFb; lowerEl.required = !hideBounds;
if(showFb) lowerEl.value = ""; if(hideBounds) lowerEl.value = "";
} }
if(fbPriceEl){ if(fbPriceEl){
fbPriceEl.style.display = showFb ? "" : "none"; fbPriceEl.style.display = showFb ? "" : "none";
@@ -1557,6 +1563,12 @@ function syncKeyMonitorFormFields(){
if(!showFb) fbPriceEl.value = ""; if(!showFb) fbPriceEl.value = "";
fbPriceEl.placeholder = (dirEl && dirEl.value === "short") ? "高点(阻力)" : ((dirEl && dirEl.value === "long") ? "低点(支撑)" : "做空填高点/做多填低点"); fbPriceEl.placeholder = (dirEl && dirEl.value === "short") ? "高点(阻力)" : ((dirEl && dirEl.value === "long") ? "低点(支撑)" : "做空填高点/做多填低点");
} }
[teEntryEl, teSlEl, teTpEl].forEach((el)=>{
if(!el) return;
el.style.display = showTe ? "" : "none";
el.required = showTe;
if(!showTe) el.value = "";
});
} }
const keyTypeSel = document.querySelector('#key-form [name="type"]'); const keyTypeSel = document.querySelector('#key-form [name="type"]');
const keyModeSel = document.getElementById("key-sl-tp-mode"); const keyModeSel = document.getElementById("key-sl-tp-mode");
+3 -2
View File
@@ -65,9 +65,10 @@
| **收敛突破** | 同上(自动开仓类)。 | | **收敛突破** | 同上(自动开仓类)。 |
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 | | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
| **关键支撑位** | 同上(仅提醒)。 | | **关键支撑位** | 同上(仅提醒)。 |
| **触价开仓** | **不挂交易所限价**;标记价触达计划入场价后 **下一轮询市价开仓**RR 门槛同关键位 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h**;全仓杠杆模式可用。 |
3. **方向**:做多 / 做空(选)。 3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整 4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**
**限制:** **限制:**
活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。 活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。
@@ -16,8 +16,9 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k
| **关键阻力位** | **不选**`direction=watch` | **否** | 5m 收盘突破上/下沿 → 微信 **3 次**`key_level_alert_done` | | **关键阻力位** | **不选**`direction=watch` | **否** | 5m 收盘突破上/下沿 → 微信 **3 次**`key_level_alert_done` |
| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) | | **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) |
| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) | | 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) |
| **触价开仓** | **必选** 多/空 | **程序盯价 → 触 E 后市价** | 见下文 **§六** |
**添加时(所有类型):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30**;上沿 **>** 下沿。 **添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位)
--- ---
@@ -117,7 +118,31 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k
--- ---
## 四、环境与参数(`.env` 摘要 ## 四、触价开仓(程序触价,无交易所挂单
### 4.1 录入
- 类型选 **触价开仓**;方向必选多/空。
- 填写 **计划入场价 E**、**止损 SL**、**止盈 TP**(做多须 `SL < E < TP`)。
- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5,与箱体/斐波相同)。
- 可选移动保本、时间平仓;**全仓杠杆模式**下可用(页面隐藏箱体/收敛/斐波/假突破)。
### 4.2 触发与结案
- 轮询标记价:做多 `标记价 ≤ E`、做空 `标记价 ≥ E`**下一轮询市价开仓**,挂交易所 TP/SL,进下单监控。
- 未成交前标记价先触 **TP 侧**`trigger_tp_invalidate`**24h** 未触发 → `trigger_entry_expired`
- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`
### 4.3 计仓与占位
- **以损定仓**:按 E、SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x、其它 5x。
- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条。
共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`
---
## 五、环境与参数(`.env` 摘要)
| 变量 | 箱体/收敛 | 阻力/支撑 | | 变量 | 箱体/收敛 | 阻力/支撑 |
|------|-----------|-----------| |------|-----------|-----------|
@@ -130,7 +155,7 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k
--- ---
## 、相关代码 ## 、相关代码
| 说明 | 位置 | | 说明 | 位置 |
|------|------| |------|------|
+544 -5
View File
@@ -116,8 +116,27 @@ from manual_sltp_lib import (
resolve_entrust_sltp_prices, resolve_entrust_sltp_prices,
resolve_open_sltp_prices, resolve_open_sltp_prices,
) )
from trigger_entry_key_monitor_lib import (
TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED,
TRIGGER_ENTRY_CLOSE_EXPIRED,
TRIGGER_ENTRY_CLOSE_FILLED,
TRIGGER_ENTRY_CLOSE_TP_INVALIDATE,
TRIGGER_ENTRY_MONITOR_TYPE,
TRIGGER_ENTRY_VALIDITY_HOURS,
check_trigger_entry_intent_limit,
count_pending_trigger_entries,
is_trigger_entry_expired,
is_trigger_entry_key_monitor_type,
trigger_entry_expires_at_text,
trigger_entry_gate_preview,
trigger_entry_invalidate_by_tp,
trigger_entry_reached,
validate_trigger_entry_geometry,
validate_trigger_entry_rr,
)
from position_sizing_lib import ( from position_sizing_lib import (
OPEN_SOURCE_KEY_AUTO, OPEN_SOURCE_KEY_AUTO,
OPEN_SOURCE_KEY_TRIGGER,
OPEN_SOURCE_MANUAL, OPEN_SOURCE_MANUAL,
assert_open_source_allowed, assert_open_source_allowed,
compute_full_margin_sizing, compute_full_margin_sizing,
@@ -1022,6 +1041,7 @@ ENTRY_REASON_OPTIONS = (
"关键位斐波0.618", "关键位斐波0.618",
"关键位斐波0.786", "关键位斐波0.786",
"关键位假突破", "关键位假突破",
"关键位触价开仓",
) + STRATEGY_ENTRY_REASON_OPTIONS ) + STRATEGY_ENTRY_REASON_OPTIONS
STATS_SEGMENT_DEFS = ( STATS_SEGMENT_DEFS = (
@@ -1032,6 +1052,7 @@ STATS_SEGMENT_DEFS = (
("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}), ("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}),
("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}), ("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}),
("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}), ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),
("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}),
) )
# 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom # 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom
ENTRY_REASON_OTHER = "__OTHER__" ENTRY_REASON_OTHER = "__OTHER__"
@@ -1433,6 +1454,7 @@ def init_db():
"ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL", "ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL",
"ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0", "ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0",
"ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER", "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER",
"ALTER TABLE key_monitors ADD COLUMN session_date TEXT",
): ):
try: try:
c.execute(ddl) c.execute(ddl)
@@ -1630,6 +1652,8 @@ def _pnl_row_matches_segment(row, segment_key):
return kst == "斐波回调0.786" return kst == "斐波回调0.786"
if segment_key == "key_false_breakout": if segment_key == "key_false_breakout":
return kst == FALSE_BREAKOUT_MONITOR_TYPE return kst == FALSE_BREAKOUT_MONITOR_TYPE
if segment_key == "key_trigger":
return kst == TRIGGER_ENTRY_MONITOR_TYPE
return False return False
@@ -1647,6 +1671,7 @@ def _count_opens_for_segment(conn, start_td, end_td, segment_key):
"key_fib618": "斐波回调0.618", "key_fib618": "斐波回调0.618",
"key_fib786": "斐波回调0.786", "key_fib786": "斐波回调0.786",
"key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE, "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE,
"key_trigger": TRIGGER_ENTRY_MONITOR_TYPE,
} }
kst = kst_map.get(segment_key) kst = kst_map.get(segment_key)
if kst: if kst:
@@ -5001,7 +5026,464 @@ def _finalize_fib_key_fill(conn, row):
_finalize_key_monitor_one_shot(conn, row, succ, close_reason) _finalize_key_monitor_one_shot(conn, row, succ, close_reason)
def check_fib_key_monitors(): def _trigger_entry_exists_for_symbol(conn, symbol):
row = conn.execute(
"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type=?",
(symbol, TRIGGER_ENTRY_MONITOR_TYPE),
).fetchone()
return row is not None
def _add_trigger_entry_key_monitor(
conn,
symbol,
direction_sel,
entry,
sl,
tp,
breakeven_enabled=0,
time_close_enabled=0,
time_close_hours=None,
):
if _trigger_entry_exists_for_symbol(conn, symbol):
return False, f"{symbol} 已有触价开仓监控(同币仅允许一条)"
ex_sym = normalize_exchange_symbol(symbol)
mark = get_symbol_mark_price(symbol)
geom_err = validate_trigger_entry_geometry(direction_sel, entry, sl, tp, mark_at_add=mark)
if geom_err:
return False, geom_err
rr_err = validate_trigger_entry_rr(
direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio
)
if rr_err:
return False, rr_err
entry = float(round_price_to_exchange(ex_sym, entry) or entry)
sl = float(round_price_to_exchange(ex_sym, sl) or sl)
tp = float(round_price_to_exchange(ex_sym, tp) or tp)
geom_err = validate_trigger_entry_geometry(direction_sel, entry, sl, tp, mark_at_add=mark)
if geom_err:
return False, geom_err
rr_err = validate_trigger_entry_rr(
direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio
)
if rr_err:
return False, rr_err
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)
opens_today = count_opens_for_trading_day(conn, trading_day)
ok_intent, intent_msg = check_trigger_entry_intent_limit(
conn, trading_day, opens_today, DAILY_OPEN_HARD_LIMIT
)
if not ok_intent:
return False, intent_msg
if is_full_margin_mode(POSITION_SIZING_MODE):
ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn))
if not ok_flat:
return False, flat_msg
if count_pending_trigger_entries(conn, trading_day) > 0:
return False, "全仓杠杆模式下仅允许一条待触发触价监控"
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)
available_usdt = get_available_trading_usdt()
if is_full_margin_mode(POSITION_SIZING_MODE):
leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE)
sizing, sizing_err = compute_full_margin_sizing(
symbol=symbol,
available_usdt=available_usdt if available_usdt is not None else 0.0,
capital_base=capital_base,
buffer_ratio=FULL_MARGIN_BUFFER_RATIO,
btc_leverage=BTC_LEVERAGE,
alt_leverage=ALT_LEVERAGE,
funds_decimals=2,
)
if sizing_err:
return False, sizing_err
margin_capital = float(sizing["margin_capital"])
amount_plan = None
else:
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
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_plan, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry)
except Exception as e:
return False, friendly_exchange_error(e, available_usdt=available_usdt)
upper_px = round_price_to_exchange(ex_sym, max(entry, tp))
lower_px = round_price_to_exchange(ex_sym, min(entry, sl))
if upper_px is None or lower_px is None or float(upper_px) <= float(lower_px):
upper_px, lower_px = float(max(entry, tp, sl)), float(min(entry, tp, sl))
if upper_px <= lower_px:
lower_px = upper_px * 0.9999
be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0
tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None)
conn.execute(
"INSERT INTO key_monitors "
"(symbol, monitor_type, direction, upper, lower, "
"fib_entry_price, fib_stop_loss, fib_take_profit, "
"fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, "
"time_close_enabled, time_close_hours, session_date) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
symbol,
TRIGGER_ENTRY_MONITOR_TYPE,
direction_sel,
float(upper_px),
float(lower_px),
entry,
sl,
tp,
float(amount_plan) if amount_plan is not None else None,
margin_capital,
leverage,
be_flag,
tc_en,
tc_h,
trading_day,
),
)
return True, None
def _market_open_for_trigger_entry(
conn,
symbol,
direction,
exchange_symbol,
entry_price,
stop_loss,
take_profit,
breakeven_enabled=0,
time_close_enabled=0,
time_close_hours=None,
):
"""触价触发后市价开仓,计仓规则与实盘下单/关键位 RR 门槛一致。"""
ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_TRIGGER)
if not ok_src:
return False, src_msg, None
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
trading_day = get_trading_day(now)
opens_today_before = count_opens_for_trading_day(conn, trading_day)
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)
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_symbol_mark_price(symbol) or get_price(symbol)
if live_price is None:
return False, "获取标记价/实时价失败", None
try:
ensure_markets_loaded()
except Exception:
pass
lp_r = round_price_to_exchange(exchange_symbol, live_price)
if lp_r is not None:
live_price = float(lp_r)
entry_price = float(entry_price)
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)
planned_rr = calc_rr_ratio(direction, entry_price, stop_loss, take_profit)
if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR:
rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算"
return False, f"计划盈亏比 {rr_txt}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1", None
risk_percent = max(0.01, float(RISK_PERCENT))
if is_full_margin_mode(POSITION_SIZING_MODE):
ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn))
if not ok_flat:
return False, flat_msg, None
leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE)
sizing, sizing_err = compute_full_margin_sizing(
symbol=symbol,
available_usdt=available_usdt if available_usdt is not None else 0.0,
capital_base=capital_base,
buffer_ratio=FULL_MARGIN_BUFFER_RATIO,
btc_leverage=BTC_LEVERAGE,
alt_leverage=ALT_LEVERAGE,
funds_decimals=2,
)
if sizing_err:
return False, sizing_err, None
margin_capital = float(sizing["margin_capital"])
notional_value = float(sizing["notional_value"])
position_ratio = float(sizing["position_ratio"])
risk_amount = margin_capital
else:
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
risk_fraction = calc_risk_fraction(direction, entry_price, stop_loss)
if risk_fraction is None:
return False, "止损方向不合法(相对计划入场价)", None
risk_amount = round(capital_base * risk_percent / 100.0, 4)
notional_value = round(risk_amount / risk_fraction, 4)
margin_capital = round(notional_value / leverage, 4)
if capital_base and margin_capital > capital_base:
return False, "以损定仓后保证金超过当前交易资金", None
if available_usdt is not None:
max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4)
if margin_capital > max_margin:
return (
False,
f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U",
None,
)
position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0
try:
amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price)
contract_size = get_contract_size(exchange_symbol)
base_amount = round(float(amount) * contract_size, 8)
order_resp = place_exchange_order(
exchange_symbol, direction, amount, leverage,
stop_loss=stop_loss, take_profit=take_profit,
)
open_order_id = order_resp.get("id", "")
tpsl_attached = bool(order_resp.get("tpsl_attached"))
trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price)
except Exception as e:
return False, friendly_exchange_error(e, available_usdt=available_usdt), None
trigger_price = round_price_to_exchange(exchange_symbol, trigger_price)
stop_loss = round_price_to_exchange(exchange_symbol, stop_loss)
take_profit = round_price_to_exchange(exchange_symbol, take_profit)
opened_at_bj = app_now_str()
opened_at_ms = _to_ms_with_fallback(None, opened_at_bj)
planned_rr_fill = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit)
breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER)
breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT)
breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0
risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage)
if risk_amount_final is None:
risk_amount_final = risk_amount
else:
try:
risk_amount_final = round(float(risk_amount_final), 4)
except (TypeError, ValueError):
risk_amount_final = risk_amount
if direction == "short":
breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0)
else:
breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0)
breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw)
be_enabled = 1 if int(breakeven_enabled or 0) != 0 else 0
tc_en, tc_h, tc_at = time_close_insert_values(time_close_enabled, time_close_hours, opened_at_ms)
risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent)
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, key_signal_type, "
"time_close_enabled, time_close_hours, time_close_at_ms) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
symbol,
exchange_symbol,
direction,
trigger_price,
stop_loss,
stop_loss,
take_profit,
margin_capital,
leverage,
trade_style,
risk_percent_db,
risk_amount_final,
breakeven_rr_trigger,
breakeven_offset_pct,
breakeven_step_r,
0,
breakeven_price,
be_enabled,
notional_value,
position_ratio,
base_amount,
amount,
open_order_id,
opened_at_bj,
opened_at_ms,
trading_day,
ORDER_MONITOR_TYPE_KEY_AUTO,
stored_key_signal_type(TRIGGER_ENTRY_MONITOR_TYPE),
tc_en,
tc_h,
tc_at,
),
)
new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage)
opens_today_after = count_opens_for_trading_day(conn, trading_day)
return True, None, {
"new_order_id": new_order_id,
"open_order_id": open_order_id,
"trigger_price": trigger_price,
"planned_rr_fill": planned_rr_fill,
"risk_amount_final": risk_amount_final,
"margin_capital": margin_capital,
"leverage": leverage,
"amount": amount,
"tpsl_attached": tpsl_attached,
"opens_today_before": opens_today_before,
"opens_today_after": opens_today_after,
"trading_day": trading_day,
"stop_loss": stop_loss,
"take_profit": take_profit,
}
def _execute_trigger_entry_cross(conn, row):
"""标记价触达计划入场:先删监控行防重复触发,再市价开仓。"""
symbol = row["symbol"]
direction = (row["direction"] or "long").lower()
ex_sym = normalize_exchange_symbol(symbol)
entry = float(_sqlite_row_val(row, "fib_entry_price") or 0)
sl = float(_sqlite_row_val(row, "fib_stop_loss") or 0)
tp = float(_sqlite_row_val(row, "fib_take_profit") or 0)
be_en = breakeven_enabled_from_row(row, 0)
tc_en, tc_h, _ = time_close_settings_from_row(row)
kid = int(row["id"])
conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,))
conn.commit()
ok, err, det = _market_open_for_trigger_entry(
conn,
symbol,
direction,
ex_sym,
entry,
sl,
tp,
breakeven_enabled=be_en,
time_close_enabled=tc_en,
time_close_hours=tc_h,
)
if ok and det:
rr_txt = format_wechat_scalar_2dp(det.get("planned_rr_fill")) if det.get("planned_rr_fill") is not None else "-"
msg = (
f"# ✅ {symbol} 触价开仓成交\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(程序触价 @ E\n"
f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}{_wechat_direction_text(direction)}\n"
f"- 订单 ID**{det.get('new_order_id')}**\n"
f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n"
f"- 成交价:{format_price_for_symbol(symbol, det.get('trigger_price'))}\n"
f"- 止损:{format_wechat_scalar_2dp(det.get('stop_loss'))}|止盈:{format_price_for_symbol(symbol, det.get('take_profit'))}\n"
f"- 计划 RR{rr_txt}:1\n"
f"- {'已挂交易所 TP/SL' if det.get('tpsl_attached') else 'TP/SL 未挂上'}\n"
)
send_wechat_msg(msg)
insert_key_monitor_history(conn, row, 0, msg, TRIGGER_ENTRY_CLOSE_FILLED)
return True, None
fail_msg = err or "触价触发后开仓失败"
send_wechat_msg(
f"# ❌ {symbol} 触价开仓失败\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n"
f"- 原因:{fail_msg}\n"
)
insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED)
return False, fail_msg
def check_trigger_entry_key_monitors():
conn = get_db()
rows = conn.execute("SELECT * FROM key_monitors WHERE monitor_type=?", (TRIGGER_ENTRY_MONITOR_TYPE,)).fetchall()
now_dt = app_now()
for r in rows:
symbol = r["symbol"]
direction = (r["direction"] or "long").lower()
entry = float(_sqlite_row_val(r, "fib_entry_price") or 0)
sl = float(_sqlite_row_val(r, "fib_stop_loss") or 0)
tp = float(_sqlite_row_val(r, "fib_take_profit") or 0)
if entry <= 0 or sl <= 0 or tp <= 0:
_finalize_key_monitor_one_shot(conn, r, "触价计划价位无效", "fib_plan_invalid")
continue
mark = get_symbol_mark_price(symbol)
if mark is None:
continue
if is_trigger_entry_expired(r["created_at"], now_dt, hours=TRIGGER_ENTRY_VALIDITY_HOURS):
exp_txt = trigger_entry_expires_at_text(r["created_at"], hours=TRIGGER_ENTRY_VALIDITY_HOURS)
msg = (
f"# ⚠️ {symbol} 触价开仓已过期\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}{_wechat_direction_text(direction)}\n"
f"- 有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h(应于 {exp_txt} 前触发)\n"
)
send_wechat_msg(msg)
_finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_EXPIRED)
continue
if trigger_entry_invalidate_by_tp(direction, mark, tp):
msg = (
f"# ⚠️ {symbol} 触价开仓失效\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交)\n"
)
send_wechat_msg(msg)
_finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_TP_INVALIDATE)
continue
if trigger_entry_reached(direction, mark, entry):
try:
_execute_trigger_entry_cross(conn, r)
except Exception as e:
fail_msg = friendly_exchange_error(e)
try:
insert_key_monitor_history(conn, r, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED)
except Exception:
pass
send_wechat_msg(
f"# ❌ {symbol} 触价开仓异常\n**账户:{_wechat_account_label()}**\n- {fail_msg}\n"
)
conn.commit()
conn.close()
conn = get_db() conn = get_db()
rows = conn.execute("SELECT * FROM key_monitors").fetchall() rows = conn.execute("SELECT * FROM key_monitors").fetchall()
for r in rows: for r in rows:
@@ -5879,6 +6361,7 @@ def background_task():
conn.close() conn.close()
force_close_before_reset() force_close_before_reset()
check_fib_key_monitors() check_fib_key_monitors()
check_trigger_entry_key_monitors()
_roll_cfg = app.extensions.get("strategy_roll_cfg") _roll_cfg = app.extensions.get("strategy_roll_cfg")
if _roll_cfg: if _roll_cfg:
from strategy_roll_monitor_lib import check_roll_monitors from strategy_roll_monitor_lib import check_roll_monitors
@@ -6276,6 +6759,7 @@ def render_main_page(page="trade"):
key_stop_outside_breakout_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, key_stop_outside_breakout_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT,
key_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, key_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT,
false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS, false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS,
trigger_entry_validity_hours=TRIGGER_ENTRY_VALIDITY_HOURS,
) )
strategy_extra = {} strategy_extra = {}
if page in ("strategy", "strategy_trend", "strategy_roll", "strategy_records"): if page in ("strategy", "strategy_trend", "strategy_roll", "strategy_records"):
@@ -6451,7 +6935,7 @@ def api_account_snapshot():
def api_price_snapshot(): def api_price_snapshot():
conn = get_db() conn = get_db()
key_rows = conn.execute( key_rows = conn.execute(
"SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_limit_order_id,created_at FROM key_monitors" "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_stop_loss,fib_take_profit,fib_limit_order_id,created_at FROM key_monitors"
).fetchall() ).fetchall()
order_rows = conn.execute( order_rows = conn.execute(
"SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage," "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,"
@@ -6491,7 +6975,8 @@ def api_price_snapshot():
for r in key_rows: for r in key_rows:
is_fib = is_fib_key_monitor_type(r["monitor_type"]) is_fib = is_fib_key_monitor_type(r["monitor_type"])
is_fb = is_false_breakout_key_monitor_type(r["monitor_type"]) is_fb = is_false_breakout_key_monitor_type(r["monitor_type"])
if is_fib or is_fb: is_te = is_trigger_entry_key_monitor_type(r["monitor_type"])
if is_fib or is_fb or is_te:
price = get_symbol_mark_price(r["symbol"]) price = get_symbol_mark_price(r["symbol"])
else: else:
price = prices.get(r["symbol"]) price = prices.get(r["symbol"])
@@ -6525,6 +7010,24 @@ def api_price_snapshot():
gate_summary = prev.get("summary") or "-" gate_summary = prev.get("summary") or "-"
gate_metrics = prev.get("metrics") or "" gate_metrics = prev.get("metrics") or ""
fb_gate_ok = bool(prev.get("gate_ok")) fb_gate_ok = bool(prev.get("gate_ok"))
elif is_te:
direction = (r["direction"] or "long").lower()
entry = _sqlite_row_val(r, "fib_entry_price")
tp_v = _sqlite_row_val(r, "fib_take_profit")
entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-"
tp_txt = format_price_for_symbol(r["symbol"], tp_v) if tp_v else "-"
tp_inv = trigger_entry_invalidate_by_tp(direction, price, float(tp_v)) if tp_v else False
prev = trigger_entry_gate_preview(
entry_display=entry_txt,
take_profit_display=tp_txt,
created_at=_sqlite_row_val(r, "created_at"),
now=app_now(),
tp_invalidated=tp_inv,
hours=TRIGGER_ENTRY_VALIDITY_HOURS,
)
gate_summary = prev.get("summary") or "-"
gate_metrics = prev.get("metrics") or ""
fib_gate_ok = bool(prev.get("gate_ok"))
elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES: elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES:
try: try:
prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"]) prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"])
@@ -7123,14 +7626,15 @@ def add_key():
+ tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + tuple(KEY_MONITOR_ALERT_ONLY_TYPES)
+ tuple(FIB_KEY_MONITOR_TYPES) + tuple(FIB_KEY_MONITOR_TYPES)
+ (FALSE_BREAKOUT_MONITOR_TYPE,) + (FALSE_BREAKOUT_MONITOR_TYPE,)
+ (TRIGGER_ENTRY_MONITOR_TYPE,)
) )
if mt not in allowed_types: if mt not in allowed_types:
flash("监控类型无效") flash("监控类型无效")
return redirect("/key_monitor") return redirect("/key_monitor")
if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt): if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt):
flash( flash(
"全仓杠杆模式下不可添加箱体/收敛突破、斐波或假突破监控;" "全仓杠杆模式下不可添加箱体/收敛突破、斐波或假突破监控;"
"请改用阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。" "可使用「触价开仓」或阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。"
) )
return redirect("/key_monitor") return redirect("/key_monitor")
skip_volume_rank = is_false_breakout_key_monitor_type(mt) skip_volume_rank = is_false_breakout_key_monitor_type(mt)
@@ -7166,6 +7670,41 @@ def add_key():
tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None
if tc_en and not tc_h: if tc_en and not tc_h:
tc_en = 0 tc_en = 0
if is_trigger_entry_key_monitor_type(mt):
if direction_sel not in ("long", "short"):
conn.close()
conn = None
flash("触价开仓请选择做多或做空")
return redirect("/key_monitor")
try:
entry_px = float(d.get("trigger_entry") or 0)
sl_px = float(d.get("trigger_sl") or 0)
tp_px = float(d.get("trigger_tp") or 0)
except (TypeError, ValueError):
entry_px = sl_px = tp_px = 0
if entry_px <= 0 or sl_px <= 0 or tp_px <= 0:
conn.close()
conn = None
flash("触价开仓须填写有效的入场价、止损价、止盈价")
return redirect("/key_monitor")
ok_te, err_te = _add_trigger_entry_key_monitor(
conn, symbol, direction_sel, entry_px, sl_px, tp_px, breakeven_enabled=be_flag,
time_close_enabled=tc_en, time_close_hours=tc_h,
)
conn.commit()
conn.close()
conn = None
if not ok_te:
flash(err_te or "触价开仓监控添加失败")
return redirect("/key_monitor")
flash(
f"触价开仓已添加({symbol} 日成交量排名 {rank}/{total}"
f"|有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h"
f"|标记价触达入场价后下一轮询市价开仓"
f"|移动保本:{'' if be_flag else ''}"
+ (f"{time_close_label(tc_h)}" if tc_en else "")
)
return redirect("/key_monitor")
if is_false_breakout_key_monitor_type(mt): if is_false_breakout_key_monitor_type(mt):
fb_sym = normalize_false_breakout_symbol(symbol) fb_sym = normalize_false_breakout_symbol(symbol)
if not fb_sym: if not fb_sym:
+19 -7
View File
@@ -1520,14 +1520,19 @@ function syncKeyMonitorFormFields(){
const autoTypes = new Set(["箱体突破","收敛突破"]); const autoTypes = new Set(["箱体突破","收敛突破"]);
const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]); const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]);
const fbTypes = new Set(["假突破"]); const fbTypes = new Set(["假突破"]);
const teTypes = new Set(["触价开仓"]);
const rsTypes = new Set(["关键阻力位","关键支撑位"]); const rsTypes = new Set(["关键阻力位","关键支撑位"]);
const showAuto = autoTypes.has(t); const showAuto = autoTypes.has(t);
const showFb = fbTypes.has(t); const showFb = fbTypes.has(t);
const showBe = showAuto || fibTypes.has(t) || showFb; const showTe = teTypes.has(t);
const showBe = showAuto || fibTypes.has(t) || showFb || showTe;
const showDir = !rsTypes.has(t); const showDir = !rsTypes.has(t);
const upperEl = document.getElementById("key-upper"); const upperEl = document.getElementById("key-upper");
const lowerEl = document.getElementById("key-lower"); const lowerEl = document.getElementById("key-lower");
const fbPriceEl = document.getElementById("key-fb-price"); const fbPriceEl = document.getElementById("key-fb-price");
const teEntryEl = document.getElementById("key-trigger-entry");
const teSlEl = document.getElementById("key-trigger-sl");
const teTpEl = document.getElementById("key-trigger-tp");
if(dirEl){ if(dirEl){
dirEl.style.display = showDir ? "" : "none"; dirEl.style.display = showDir ? "" : "none";
dirEl.required = showDir; dirEl.required = showDir;
@@ -1541,15 +1546,16 @@ function syncKeyMonitorFormFields(){
} }
if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none"; if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
if(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe); if(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
const hideBounds = showFb || showTe;
if(upperEl){ if(upperEl){
upperEl.style.display = showFb ? "none" : ""; upperEl.style.display = hideBounds ? "none" : "";
upperEl.required = !showFb; upperEl.required = !hideBounds;
if(showFb) upperEl.value = ""; if(hideBounds) upperEl.value = "";
} }
if(lowerEl){ if(lowerEl){
lowerEl.style.display = showFb ? "none" : ""; lowerEl.style.display = hideBounds ? "none" : "";
lowerEl.required = !showFb; lowerEl.required = !hideBounds;
if(showFb) lowerEl.value = ""; if(hideBounds) lowerEl.value = "";
} }
if(fbPriceEl){ if(fbPriceEl){
fbPriceEl.style.display = showFb ? "" : "none"; fbPriceEl.style.display = showFb ? "" : "none";
@@ -1557,6 +1563,12 @@ function syncKeyMonitorFormFields(){
if(!showFb) fbPriceEl.value = ""; if(!showFb) fbPriceEl.value = "";
fbPriceEl.placeholder = (dirEl && dirEl.value === "short") ? "高点(阻力)" : ((dirEl && dirEl.value === "long") ? "低点(支撑)" : "做空填高点/做多填低点"); fbPriceEl.placeholder = (dirEl && dirEl.value === "short") ? "高点(阻力)" : ((dirEl && dirEl.value === "long") ? "低点(支撑)" : "做空填高点/做多填低点");
} }
[teEntryEl, teSlEl, teTpEl].forEach((el)=>{
if(!el) return;
el.style.display = showTe ? "" : "none";
el.required = showTe;
if(!showTe) el.value = "";
});
} }
const keyTypeSel = document.querySelector('#key-form [name="type"]'); const keyTypeSel = document.querySelector('#key-form [name="type"]');
const keyModeSel = document.getElementById("key-sl-tp-mode"); const keyModeSel = document.getElementById("key-sl-tp-mode");
+3 -2
View File
@@ -65,9 +65,10 @@
| **收敛突破** | 同上(自动开仓类)。 | | **收敛突破** | 同上(自动开仓类)。 |
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 | | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
| **关键支撑位** | 同上(仅提醒)。 | | **关键支撑位** | 同上(仅提醒)。 |
| **触价开仓** | **不挂交易所限价**;标记价触达计划入场价后 **下一轮询市价开仓**RR 门槛同关键位 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h**;全仓杠杆模式可用。 |
3. **方向**:做多 / 做空(选)。 3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整 4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**
**限制:** **限制:**
活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。 活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。
+506 -4
View File
@@ -116,6 +116,24 @@ from manual_sltp_lib import (
resolve_entrust_sltp_prices, resolve_entrust_sltp_prices,
resolve_open_sltp_prices, resolve_open_sltp_prices,
) )
from trigger_entry_key_monitor_lib import (
TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED,
TRIGGER_ENTRY_CLOSE_EXPIRED,
TRIGGER_ENTRY_CLOSE_FILLED,
TRIGGER_ENTRY_CLOSE_TP_INVALIDATE,
TRIGGER_ENTRY_MONITOR_TYPE,
TRIGGER_ENTRY_VALIDITY_HOURS,
check_trigger_entry_intent_limit,
count_pending_trigger_entries,
is_trigger_entry_expired,
is_trigger_entry_key_monitor_type,
trigger_entry_expires_at_text,
trigger_entry_gate_preview,
trigger_entry_invalidate_by_tp,
trigger_entry_reached,
validate_trigger_entry_geometry,
validate_trigger_entry_rr,
)
from position_sizing_lib import ( from position_sizing_lib import (
OPEN_SOURCE_KEY_AUTO, OPEN_SOURCE_KEY_AUTO,
OPEN_SOURCE_MANUAL, OPEN_SOURCE_MANUAL,
@@ -1029,6 +1047,7 @@ ENTRY_REASON_OPTIONS = (
"关键位斐波0.618", "关键位斐波0.618",
"关键位斐波0.786", "关键位斐波0.786",
"关键位假突破", "关键位假突破",
"关键位触价开仓",
) + STRATEGY_ENTRY_REASON_OPTIONS ) + STRATEGY_ENTRY_REASON_OPTIONS
STATS_SEGMENT_DEFS = ( STATS_SEGMENT_DEFS = (
@@ -1039,6 +1058,7 @@ STATS_SEGMENT_DEFS = (
("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}), ("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}),
("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}), ("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}),
("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}), ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),
("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}),
) )
ENTRY_REASON_OTHER = "__OTHER__" ENTRY_REASON_OTHER = "__OTHER__"
@@ -1434,6 +1454,7 @@ def init_db():
"ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL", "ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL",
"ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0", "ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0",
"ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER", "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER",
"ALTER TABLE key_monitors ADD COLUMN session_date TEXT",
): ):
try: try:
c.execute(ddl) c.execute(ddl)
@@ -1613,6 +1634,8 @@ def _pnl_row_matches_segment(row, segment_key):
return kst == "斐波回调0.786" return kst == "斐波回调0.786"
if segment_key == "key_false_breakout": if segment_key == "key_false_breakout":
return kst == FALSE_BREAKOUT_MONITOR_TYPE return kst == FALSE_BREAKOUT_MONITOR_TYPE
if segment_key == "key_trigger":
return kst == TRIGGER_ENTRY_MONITOR_TYPE
return False return False
@@ -4560,7 +4583,464 @@ def _finalize_fib_key_fill(conn, row):
_finalize_key_monitor_one_shot(conn, row, succ, close_reason) _finalize_key_monitor_one_shot(conn, row, succ, close_reason)
def check_fib_key_monitors(): def _trigger_entry_exists_for_symbol(conn, symbol):
row = conn.execute(
"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type=?",
(symbol, TRIGGER_ENTRY_MONITOR_TYPE),
).fetchone()
return row is not None
def _add_trigger_entry_key_monitor(
conn,
symbol,
direction_sel,
entry,
sl,
tp,
breakeven_enabled=0,
time_close_enabled=0,
time_close_hours=None,
):
if _trigger_entry_exists_for_symbol(conn, symbol):
return False, f"{symbol} 已有触价开仓监控(同币仅允许一条)"
ex_sym = normalize_exchange_symbol(symbol)
mark = get_symbol_mark_price(symbol)
geom_err = validate_trigger_entry_geometry(direction_sel, entry, sl, tp, mark_at_add=mark)
if geom_err:
return False, geom_err
rr_err = validate_trigger_entry_rr(
direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio
)
if rr_err:
return False, rr_err
entry = float(round_price_to_exchange(ex_sym, entry) or entry)
sl = float(round_price_to_exchange(ex_sym, sl) or sl)
tp = float(round_price_to_exchange(ex_sym, tp) or tp)
geom_err = validate_trigger_entry_geometry(direction_sel, entry, sl, tp, mark_at_add=mark)
if geom_err:
return False, geom_err
rr_err = validate_trigger_entry_rr(
direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio
)
if rr_err:
return False, rr_err
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)
opens_today = count_opens_for_trading_day(conn, trading_day)
ok_intent, intent_msg = check_trigger_entry_intent_limit(
conn, trading_day, opens_today, DAILY_OPEN_HARD_LIMIT
)
if not ok_intent:
return False, intent_msg
if is_full_margin_mode(POSITION_SIZING_MODE):
ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn))
if not ok_flat:
return False, flat_msg
if count_pending_trigger_entries(conn, trading_day) > 0:
return False, "全仓杠杆模式下仅允许一条待触发触价监控"
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)
available_usdt = get_available_trading_usdt()
if is_full_margin_mode(POSITION_SIZING_MODE):
leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE)
sizing, sizing_err = compute_full_margin_sizing(
symbol=symbol,
available_usdt=available_usdt if available_usdt is not None else 0.0,
capital_base=capital_base,
buffer_ratio=FULL_MARGIN_BUFFER_RATIO,
btc_leverage=BTC_LEVERAGE,
alt_leverage=ALT_LEVERAGE,
funds_decimals=2,
)
if sizing_err:
return False, sizing_err
margin_capital = float(sizing["margin_capital"])
amount_plan = None
else:
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
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_plan, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry)
except Exception as e:
return False, friendly_exchange_error(e, available_usdt=available_usdt)
upper_px = round_price_to_exchange(ex_sym, max(entry, tp))
lower_px = round_price_to_exchange(ex_sym, min(entry, sl))
if upper_px is None or lower_px is None or float(upper_px) <= float(lower_px):
upper_px, lower_px = float(max(entry, tp, sl)), float(min(entry, tp, sl))
if upper_px <= lower_px:
lower_px = upper_px * 0.9999
be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0
tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None)
conn.execute(
"INSERT INTO key_monitors "
"(symbol, monitor_type, direction, upper, lower, "
"fib_entry_price, fib_stop_loss, fib_take_profit, "
"fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, "
"time_close_enabled, time_close_hours, session_date) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
symbol,
TRIGGER_ENTRY_MONITOR_TYPE,
direction_sel,
float(upper_px),
float(lower_px),
entry,
sl,
tp,
float(amount_plan) if amount_plan is not None else None,
margin_capital,
leverage,
be_flag,
tc_en,
tc_h,
trading_day,
),
)
return True, None
def _market_open_for_trigger_entry(
conn,
symbol,
direction,
exchange_symbol,
entry_price,
stop_loss,
take_profit,
breakeven_enabled=0,
time_close_enabled=0,
time_close_hours=None,
):
"""触价触发后市价开仓,计仓规则与实盘下单/关键位 RR 门槛一致。"""
ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_TRIGGER)
if not ok_src:
return False, src_msg, None
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
trading_day = get_trading_day(now)
opens_today_before = count_opens_for_trading_day(conn, trading_day)
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)
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_symbol_mark_price(symbol) or get_price(symbol)
if live_price is None:
return False, "获取标记价/实时价失败", None
try:
ensure_markets_loaded()
except Exception:
pass
lp_r = round_price_to_exchange(exchange_symbol, live_price)
if lp_r is not None:
live_price = float(lp_r)
entry_price = float(entry_price)
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)
planned_rr = calc_rr_ratio(direction, entry_price, stop_loss, take_profit)
if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR:
rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算"
return False, f"计划盈亏比 {rr_txt}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1", None
risk_percent = max(0.01, float(RISK_PERCENT))
if is_full_margin_mode(POSITION_SIZING_MODE):
ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn))
if not ok_flat:
return False, flat_msg, None
leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE)
sizing, sizing_err = compute_full_margin_sizing(
symbol=symbol,
available_usdt=available_usdt if available_usdt is not None else 0.0,
capital_base=capital_base,
buffer_ratio=FULL_MARGIN_BUFFER_RATIO,
btc_leverage=BTC_LEVERAGE,
alt_leverage=ALT_LEVERAGE,
funds_decimals=2,
)
if sizing_err:
return False, sizing_err, None
margin_capital = float(sizing["margin_capital"])
notional_value = float(sizing["notional_value"])
position_ratio = float(sizing["position_ratio"])
risk_amount = margin_capital
else:
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
risk_fraction = calc_risk_fraction(direction, entry_price, stop_loss)
if risk_fraction is None:
return False, "止损方向不合法(相对计划入场价)", None
risk_amount = round(capital_base * risk_percent / 100.0, 4)
notional_value = round(risk_amount / risk_fraction, 4)
margin_capital = round(notional_value / leverage, 4)
if capital_base and margin_capital > capital_base:
return False, "以损定仓后保证金超过当前交易资金", None
if available_usdt is not None:
max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4)
if margin_capital > max_margin:
return (
False,
f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U",
None,
)
position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0
try:
amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price)
contract_size = get_contract_size(exchange_symbol)
base_amount = round(float(amount) * contract_size, 8)
order_resp = place_exchange_order(
exchange_symbol, direction, amount, leverage,
stop_loss=stop_loss, take_profit=take_profit,
)
open_order_id = order_resp.get("id", "")
tpsl_attached = bool(order_resp.get("tpsl_attached"))
trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price)
except Exception as e:
return False, friendly_exchange_error(e, available_usdt=available_usdt), None
trigger_price = round_price_to_exchange(exchange_symbol, trigger_price)
stop_loss = round_price_to_exchange(exchange_symbol, stop_loss)
take_profit = round_price_to_exchange(exchange_symbol, take_profit)
opened_at_bj = app_now_str()
opened_at_ms = _to_ms_with_fallback(None, opened_at_bj)
planned_rr_fill = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit)
breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER)
breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT)
breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0
risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage)
if risk_amount_final is None:
risk_amount_final = risk_amount
else:
try:
risk_amount_final = round(float(risk_amount_final), 4)
except (TypeError, ValueError):
risk_amount_final = risk_amount
if direction == "short":
breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0)
else:
breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0)
breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw)
be_enabled = 1 if int(breakeven_enabled or 0) != 0 else 0
tc_en, tc_h, tc_at = time_close_insert_values(time_close_enabled, time_close_hours, opened_at_ms)
risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent)
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, key_signal_type, "
"time_close_enabled, time_close_hours, time_close_at_ms) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
symbol,
exchange_symbol,
direction,
trigger_price,
stop_loss,
stop_loss,
take_profit,
margin_capital,
leverage,
trade_style,
risk_percent_db,
risk_amount_final,
breakeven_rr_trigger,
breakeven_offset_pct,
breakeven_step_r,
0,
breakeven_price,
be_enabled,
notional_value,
position_ratio,
base_amount,
amount,
open_order_id,
opened_at_bj,
opened_at_ms,
trading_day,
ORDER_MONITOR_TYPE_KEY_AUTO,
stored_key_signal_type(TRIGGER_ENTRY_MONITOR_TYPE),
tc_en,
tc_h,
tc_at,
),
)
new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage)
opens_today_after = count_opens_for_trading_day(conn, trading_day)
return True, None, {
"new_order_id": new_order_id,
"open_order_id": open_order_id,
"trigger_price": trigger_price,
"planned_rr_fill": planned_rr_fill,
"risk_amount_final": risk_amount_final,
"margin_capital": margin_capital,
"leverage": leverage,
"amount": amount,
"tpsl_attached": tpsl_attached,
"opens_today_before": opens_today_before,
"opens_today_after": opens_today_after,
"trading_day": trading_day,
"stop_loss": stop_loss,
"take_profit": take_profit,
}
def _execute_trigger_entry_cross(conn, row):
"""标记价触达计划入场:先删监控行防重复触发,再市价开仓。"""
symbol = row["symbol"]
direction = (row["direction"] or "long").lower()
ex_sym = normalize_exchange_symbol(symbol)
entry = float(_sqlite_row_val(row, "fib_entry_price") or 0)
sl = float(_sqlite_row_val(row, "fib_stop_loss") or 0)
tp = float(_sqlite_row_val(row, "fib_take_profit") or 0)
be_en = breakeven_enabled_from_row(row, 0)
tc_en, tc_h, _ = time_close_settings_from_row(row)
kid = int(row["id"])
conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,))
conn.commit()
ok, err, det = _market_open_for_trigger_entry(
conn,
symbol,
direction,
ex_sym,
entry,
sl,
tp,
breakeven_enabled=be_en,
time_close_enabled=tc_en,
time_close_hours=tc_h,
)
if ok and det:
rr_txt = format_wechat_scalar_2dp(det.get("planned_rr_fill")) if det.get("planned_rr_fill") is not None else "-"
msg = (
f"# ✅ {symbol} 触价开仓成交\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(程序触价 @ E\n"
f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}{_wechat_direction_text(direction)}\n"
f"- 订单 ID**{det.get('new_order_id')}**\n"
f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n"
f"- 成交价:{format_price_for_symbol(symbol, det.get('trigger_price'))}\n"
f"- 止损:{format_wechat_scalar_2dp(det.get('stop_loss'))}|止盈:{format_price_for_symbol(symbol, det.get('take_profit'))}\n"
f"- 计划 RR{rr_txt}:1\n"
f"- {'已挂交易所 TP/SL' if det.get('tpsl_attached') else 'TP/SL 未挂上'}\n"
)
send_wechat_msg(msg)
insert_key_monitor_history(conn, row, 0, msg, TRIGGER_ENTRY_CLOSE_FILLED)
return True, None
fail_msg = err or "触价触发后开仓失败"
send_wechat_msg(
f"# ❌ {symbol} 触价开仓失败\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n"
f"- 原因:{fail_msg}\n"
)
insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED)
return False, fail_msg
def check_trigger_entry_key_monitors():
conn = get_db()
rows = conn.execute("SELECT * FROM key_monitors WHERE monitor_type=?", (TRIGGER_ENTRY_MONITOR_TYPE,)).fetchall()
now_dt = app_now()
for r in rows:
symbol = r["symbol"]
direction = (r["direction"] or "long").lower()
entry = float(_sqlite_row_val(r, "fib_entry_price") or 0)
sl = float(_sqlite_row_val(r, "fib_stop_loss") or 0)
tp = float(_sqlite_row_val(r, "fib_take_profit") or 0)
if entry <= 0 or sl <= 0 or tp <= 0:
_finalize_key_monitor_one_shot(conn, r, "触价计划价位无效", "fib_plan_invalid")
continue
mark = get_symbol_mark_price(symbol)
if mark is None:
continue
if is_trigger_entry_expired(r["created_at"], now_dt, hours=TRIGGER_ENTRY_VALIDITY_HOURS):
exp_txt = trigger_entry_expires_at_text(r["created_at"], hours=TRIGGER_ENTRY_VALIDITY_HOURS)
msg = (
f"# ⚠️ {symbol} 触价开仓已过期\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}{_wechat_direction_text(direction)}\n"
f"- 有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h(应于 {exp_txt} 前触发)\n"
)
send_wechat_msg(msg)
_finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_EXPIRED)
continue
if trigger_entry_invalidate_by_tp(direction, mark, tp):
msg = (
f"# ⚠️ {symbol} 触价开仓失效\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交)\n"
)
send_wechat_msg(msg)
_finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_TP_INVALIDATE)
continue
if trigger_entry_reached(direction, mark, entry):
try:
_execute_trigger_entry_cross(conn, r)
except Exception as e:
fail_msg = friendly_exchange_error(e)
try:
insert_key_monitor_history(conn, r, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED)
except Exception:
pass
send_wechat_msg(
f"# ❌ {symbol} 触价开仓异常\n**账户:{_wechat_account_label()}**\n- {fail_msg}\n"
)
conn.commit()
conn.close()
conn = get_db() conn = get_db()
rows = conn.execute("SELECT * FROM key_monitors").fetchall() rows = conn.execute("SELECT * FROM key_monitors").fetchall()
for r in rows: for r in rows:
@@ -5662,6 +6142,7 @@ def background_task():
conn.close() conn.close()
force_close_before_reset() force_close_before_reset()
check_fib_key_monitors() check_fib_key_monitors()
check_trigger_entry_key_monitors()
_roll_cfg = app.extensions.get("strategy_roll_cfg") _roll_cfg = app.extensions.get("strategy_roll_cfg")
if _roll_cfg: if _roll_cfg:
from strategy_roll_monitor_lib import check_roll_monitors from strategy_roll_monitor_lib import check_roll_monitors
@@ -5821,6 +6302,7 @@ def render_main_page(page="trade"):
key_stop_outside_breakout_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, key_stop_outside_breakout_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT,
key_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, key_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT,
false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS, false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS,
trigger_entry_validity_hours=TRIGGER_ENTRY_VALIDITY_HOURS,
) )
strategy_extra = {} strategy_extra = {}
if page in ("strategy", "strategy_trend", "strategy_roll", "strategy_records"): if page in ("strategy", "strategy_trend", "strategy_roll", "strategy_records"):
@@ -6037,7 +6519,7 @@ def api_settings_open_guard():
def api_price_snapshot(): def api_price_snapshot():
conn = get_db() conn = get_db()
key_rows = conn.execute( key_rows = conn.execute(
"SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_limit_order_id,created_at FROM key_monitors" "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_stop_loss,fib_take_profit,fib_limit_order_id,created_at FROM key_monitors"
).fetchall() ).fetchall()
order_rows = conn.execute( order_rows = conn.execute(
"SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage," "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,"
@@ -6077,7 +6559,8 @@ def api_price_snapshot():
for r in key_rows: for r in key_rows:
is_fib = is_fib_key_monitor_type(r["monitor_type"]) is_fib = is_fib_key_monitor_type(r["monitor_type"])
is_fb = is_false_breakout_key_monitor_type(r["monitor_type"]) is_fb = is_false_breakout_key_monitor_type(r["monitor_type"])
if is_fib or is_fb: is_te = is_trigger_entry_key_monitor_type(r["monitor_type"])
if is_fib or is_fb or is_te:
price = get_symbol_mark_price(r["symbol"]) price = get_symbol_mark_price(r["symbol"])
else: else:
price = prices.get(r["symbol"]) price = prices.get(r["symbol"])
@@ -6111,6 +6594,24 @@ def api_price_snapshot():
gate_summary = prev.get("summary") or "-" gate_summary = prev.get("summary") or "-"
gate_metrics = prev.get("metrics") or "" gate_metrics = prev.get("metrics") or ""
fb_gate_ok = bool(prev.get("gate_ok")) fb_gate_ok = bool(prev.get("gate_ok"))
elif is_te:
direction = (r["direction"] or "long").lower()
entry = _sqlite_row_val(r, "fib_entry_price")
tp_v = _sqlite_row_val(r, "fib_take_profit")
entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-"
tp_txt = format_price_for_symbol(r["symbol"], tp_v) if tp_v else "-"
tp_inv = trigger_entry_invalidate_by_tp(direction, price, float(tp_v)) if tp_v else False
prev = trigger_entry_gate_preview(
entry_display=entry_txt,
take_profit_display=tp_txt,
created_at=_sqlite_row_val(r, "created_at"),
now=app_now(),
tp_invalidated=tp_inv,
hours=TRIGGER_ENTRY_VALIDITY_HOURS,
)
gate_summary = prev.get("summary") or "-"
gate_metrics = prev.get("metrics") or ""
fib_gate_ok = bool(prev.get("gate_ok"))
elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES: elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES:
try: try:
prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"]) prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"])
@@ -6714,6 +7215,7 @@ def add_key():
+ tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + tuple(KEY_MONITOR_ALERT_ONLY_TYPES)
+ tuple(FIB_KEY_MONITOR_TYPES) + tuple(FIB_KEY_MONITOR_TYPES)
+ (FALSE_BREAKOUT_MONITOR_TYPE,) + (FALSE_BREAKOUT_MONITOR_TYPE,)
+ (TRIGGER_ENTRY_MONITOR_TYPE,)
) )
if mt not in allowed_types: if mt not in allowed_types:
flash("监控类型无效") flash("监控类型无效")
@@ -6721,7 +7223,7 @@ def add_key():
if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt): if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt):
flash( flash(
"全仓杠杆模式下不可添加箱体/收敛突破、斐波或假突破监控;" "全仓杠杆模式下不可添加箱体/收敛突破、斐波或假突破监控;"
"请改用阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。" "可使用「触价开仓」或阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。"
) )
return redirect("/key_monitor") return redirect("/key_monitor")
skip_volume_rank = is_false_breakout_key_monitor_type(mt) skip_volume_rank = is_false_breakout_key_monitor_type(mt)
+19 -7
View File
@@ -1549,14 +1549,19 @@ function syncKeyMonitorFormFields(){
const autoTypes = new Set(["箱体突破","收敛突破"]); const autoTypes = new Set(["箱体突破","收敛突破"]);
const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]); const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]);
const fbTypes = new Set(["假突破"]); const fbTypes = new Set(["假突破"]);
const teTypes = new Set(["触价开仓"]);
const rsTypes = new Set(["关键阻力位","关键支撑位"]); const rsTypes = new Set(["关键阻力位","关键支撑位"]);
const showAuto = autoTypes.has(t); const showAuto = autoTypes.has(t);
const showFb = fbTypes.has(t); const showFb = fbTypes.has(t);
const showBe = showAuto || fibTypes.has(t) || showFb; const showTe = teTypes.has(t);
const showBe = showAuto || fibTypes.has(t) || showFb || showTe;
const showDir = !rsTypes.has(t); const showDir = !rsTypes.has(t);
const upperEl = document.getElementById("key-upper"); const upperEl = document.getElementById("key-upper");
const lowerEl = document.getElementById("key-lower"); const lowerEl = document.getElementById("key-lower");
const fbPriceEl = document.getElementById("key-fb-price"); const fbPriceEl = document.getElementById("key-fb-price");
const teEntryEl = document.getElementById("key-trigger-entry");
const teSlEl = document.getElementById("key-trigger-sl");
const teTpEl = document.getElementById("key-trigger-tp");
if(dirEl){ if(dirEl){
dirEl.style.display = showDir ? "" : "none"; dirEl.style.display = showDir ? "" : "none";
dirEl.required = showDir; dirEl.required = showDir;
@@ -1570,15 +1575,16 @@ function syncKeyMonitorFormFields(){
} }
if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none"; if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
if(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe); if(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
const hideBounds = showFb || showTe;
if(upperEl){ if(upperEl){
upperEl.style.display = showFb ? "none" : ""; upperEl.style.display = hideBounds ? "none" : "";
upperEl.required = !showFb; upperEl.required = !hideBounds;
if(showFb) upperEl.value = ""; if(hideBounds) upperEl.value = "";
} }
if(lowerEl){ if(lowerEl){
lowerEl.style.display = showFb ? "none" : ""; lowerEl.style.display = hideBounds ? "none" : "";
lowerEl.required = !showFb; lowerEl.required = !hideBounds;
if(showFb) lowerEl.value = ""; if(hideBounds) lowerEl.value = "";
} }
if(fbPriceEl){ if(fbPriceEl){
fbPriceEl.style.display = showFb ? "" : "none"; fbPriceEl.style.display = showFb ? "" : "none";
@@ -1586,6 +1592,12 @@ function syncKeyMonitorFormFields(){
if(!showFb) fbPriceEl.value = ""; if(!showFb) fbPriceEl.value = "";
fbPriceEl.placeholder = (dirEl && dirEl.value === "short") ? "高点(阻力)" : ((dirEl && dirEl.value === "long") ? "低点(支撑)" : "做空填高点/做多填低点"); fbPriceEl.placeholder = (dirEl && dirEl.value === "short") ? "高点(阻力)" : ((dirEl && dirEl.value === "long") ? "低点(支撑)" : "做空填高点/做多填低点");
} }
[teEntryEl, teSlEl, teTpEl].forEach((el)=>{
if(!el) return;
el.style.display = showTe ? "" : "none";
el.required = showTe;
if(!showTe) el.value = "";
});
} }
const keyTypeSel = document.querySelector('#key-form [name="type"]'); const keyTypeSel = document.querySelector('#key-form [name="type"]');
const keyModeSel = document.getElementById("key-sl-tp-mode"); const keyModeSel = document.getElementById("key-sl-tp-mode");
+3 -2
View File
@@ -65,9 +65,10 @@
| **收敛突破** | 同上(自动开仓类)。 | | **收敛突破** | 同上(自动开仓类)。 |
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 | | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
| **关键支撑位** | 同上(仅提醒)。 | | **关键支撑位** | 同上(仅提醒)。 |
| **触价开仓** | **不挂交易所限价**;标记价触达计划入场价后 **下一轮询市价开仓**RR 门槛同关键位 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h**;全仓杠杆模式可用。 |
3. **方向**:做多 / 做空(选)。 3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整 4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**
**限制:** **限制:**
活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。 活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。
@@ -1,7 +1,7 @@
# 关键位监控说明(自动开仓 + 人工盯盘) # 关键位监控说明(自动开仓 + 人工盯盘)
**适用:`crypto_monitor_okx`OKX 永续)** **适用:`crypto_monitor_gate`Gate U 本位永续)**
箱体/收敛与 Binance、Gate 相同:**门控通过后自动市价开仓**(须 `LIVE_TRADING_ENABLED=true`)。阻力/支撑仍为微信提醒。共享逻辑见 `key_monitor_lib.py` Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_monitor_lib.py`
本文档与 `.env``check_key_monitors``add_key``_key_hard_checks``_process_key_rs_level_alert` 一致。 本文档与 `.env``check_key_monitors``add_key``_key_hard_checks``_process_key_rs_level_alert` 一致。
@@ -16,8 +16,9 @@
| **关键阻力位** | **不选**`direction=watch` | **否** | 5m 收盘突破上/下沿 → 微信 **3 次**`key_level_alert_done` | | **关键阻力位** | **不选**`direction=watch` | **否** | 5m 收盘突破上/下沿 → 微信 **3 次**`key_level_alert_done` |
| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) | | **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) |
| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) | | 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) |
| **触价开仓** | **必选** 多/空 | **程序盯价 → 触 E 后市价** | 见下文 **§六** |
**添加时(所有类型):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30**;上沿 **>** 下沿。 **添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位)
--- ---
@@ -117,7 +118,31 @@
--- ---
## 四、环境与参数(`.env` 摘要 ## 四、触价开仓(程序触价,无交易所挂单
### 4.1 录入
- 类型选 **触价开仓**;方向必选多/空。
- 填写 **计划入场价 E**、**止损 SL**、**止盈 TP**(做多须 `SL < E < TP`)。
- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5,与箱体/斐波相同)。
- 可选移动保本、时间平仓;**全仓杠杆模式**下可用(页面隐藏箱体/收敛/斐波/假突破)。
### 4.2 触发与结案
- 轮询标记价:做多 `标记价 ≤ E`、做空 `标记价 ≥ E`**下一轮询市价开仓**,挂交易所 TP/SL,进下单监控。
- 未成交前标记价先触 **TP 侧**`trigger_tp_invalidate`**24h** 未触发 → `trigger_entry_expired`
- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`
### 4.3 计仓与占位
- **以损定仓**:按 E、SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x、其它 5x。
- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条。
共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`
---
## 五、环境与参数(`.env` 摘要)
| 变量 | 箱体/收敛 | 阻力/支撑 | | 变量 | 箱体/收敛 | 阻力/支撑 |
|------|-----------|-----------| |------|-----------|-----------|
@@ -130,7 +155,7 @@
--- ---
## 、相关代码 ## 、相关代码
| 说明 | 位置 | | 说明 | 位置 |
|------|------| |------|------|
+6 -4
View File
@@ -18,20 +18,22 @@ FULL_MARGIN_BUFFER_RATIO=0.98
| 模式 | 保证金计算 | 杠杆 | 允许入口 | | 模式 | 保证金计算 | 杠杆 | 允许入口 |
|------|------------|------|----------| |------|------------|------|----------|
| `risk` | `RISK_PERCENT` × 交易资金,按止损距离反推 | 表单可选 / 同步交易所 | 实盘人工、关键位自动、趋势回调、顺势加仓 | | `risk` | `RISK_PERCENT` × 交易资金,按止损距离反推 | 表单可选 / 同步交易所 | 实盘人工、关键位自动、趋势回调、顺势加仓 |
| `full_margin` | **合约账户可用 USDT × `FULL_MARGIN_BUFFER_RATIO`**(保留 2 位小数) | BTC/ETH **10x**,其它 **5x**(与 `BTC_LEVERAGE`/`ALT_LEVERAGE` 一致) | **** 实盘人工下单;阻力/支撑仅提醒 | | `full_margin` | **合约账户可用 USDT × `FULL_MARGIN_BUFFER_RATIO`**(保留 2 位小数) | BTC/ETH **10x**,其它 **5x**(与 `BTC_LEVERAGE`/`ALT_LEVERAGE` 一致) | **实盘人工下单**、**关键位触价开仓**;阻力/支撑仅提醒 |
全仓模式下: 全仓模式下:
- 仍校验 **计划盈亏比**`MANUAL_MIN_PLANNED_RR`)。 - 仍校验 **计划盈亏比**实盘用 `MANUAL_MIN_PLANNED_RR`;触价开仓用 `KEY_AUTO_MIN_PLANNED_RR`)。
- 下单张数由 `prepare_order_amount` + 交易所 `amount_to_precision` 决定。 - 下单张数由 `prepare_order_amount` + 交易所 `amount_to_precision` 决定。
- `order_monitors.initial_stop_loss` 仍记录**开仓时**止损快照;交易记录复盘以该快照为准。 - `order_monitors.initial_stop_loss` 仍记录**开仓时**止损快照;交易记录复盘以该快照为准。
- 已存在的 **箱体突破 / 收敛突破 / 斐波** 监控:进程启动时**自动撤销**并企业微信通知。 - 已存在的 **箱体突破 / 收敛突破 / 斐波 / 假突破** 监控:进程启动时**自动撤销**并企业微信通知。
## 不允许(全仓模式) ## 不允许(全仓模式)
- 关键位:箱体突破、收敛突破、斐波自动单(添加时拒绝;已存在则启动时撤销)。 - 关键位:箱体突破、收敛突破、斐波、假突破(添加时拒绝;已存在则启动时撤销)。
- 趋势回调、顺势加仓(策略入口返回明确错误)。 - 趋势回调、顺势加仓(策略入口返回明确错误)。
**允许:** 关键位 **触价开仓**(程序盯价、触达计划入场后市价成交,无交易所挂单;全仓下仅允许一条待触发)。
## 用脚本更新四所 `.env` ## 用脚本更新四所 `.env`
详见 **[env-sync-scripts.md](./env-sync-scripts.md)**。常用命令: 详见 **[env-sync-scripts.md](./env-sync-scripts.md)**。常用命令:
+5 -2
View File
@@ -44,11 +44,11 @@ def calc_fib_plan(direction, upper, lower, ratio):
def stored_key_signal_type(monitor_type): 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() mt = (monitor_type or "").strip()
if mt in FIB_KEY_MONITOR_TYPES: if mt in FIB_KEY_MONITOR_TYPES:
return mt return mt
if mt == "假突破": if mt in ("假突破", "触价开仓"):
return mt return mt
if mt in KEY_MONITOR_AUTO_TYPES: if mt in KEY_MONITOR_AUTO_TYPES:
return mt return mt
@@ -61,6 +61,7 @@ KEY_ENTRY_REASON_BY_SIGNAL = {
"斐波回调0.618": "关键位斐波0.618", "斐波回调0.618": "关键位斐波0.618",
"斐波回调0.786": "关键位斐波0.786", "斐波回调0.786": "关键位斐波0.786",
"假突破": "关键位假突破", "假突破": "关键位假突破",
"触价开仓": "关键位触价开仓",
"趋势回调": "趋势回调", "趋势回调": "趋势回调",
} }
@@ -76,6 +77,8 @@ def key_signal_type_for_trade_record(key_signal_type, box_auto_types):
return kst return kst
if kst == "假突破": if kst == "假突破":
return kst return kst
if kst == "触价开仓":
return kst
if box_auto_types and kst in box_auto_types: if box_auto_types and kst in box_auto_types:
return kst return kst
return None return None
+9
View File
@@ -310,6 +310,7 @@ def key_monitor_rule_template_context(
key_stop_outside_breakout_pct: float, key_stop_outside_breakout_pct: float,
key_trend_stop_outside_pct: float, key_trend_stop_outside_pct: float,
false_breakout_validity_hours: int, false_breakout_validity_hours: int,
trigger_entry_validity_hours: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""关键位监控页规则说明表格(Jinja key_rule_ctx)。""" """关键位监控页规则说明表格(Jinja key_rule_ctx)。"""
from false_breakout_key_monitor_lib import ( from false_breakout_key_monitor_lib import (
@@ -317,6 +318,13 @@ def key_monitor_rule_template_context(
FALSE_BREAKOUT_RR, FALSE_BREAKOUT_RR,
FALSE_BREAKOUT_SL_PCT, FALSE_BREAKOUT_SL_PCT,
) )
from trigger_entry_key_monitor_lib import TRIGGER_ENTRY_VALIDITY_HOURS
te_hours = (
int(trigger_entry_validity_hours)
if trigger_entry_validity_hours is not None
else TRIGGER_ENTRY_VALIDITY_HOURS
)
return { return {
"tf": (kline_timeframe or "5m").strip(), "tf": (kline_timeframe or "5m").strip(),
@@ -335,4 +343,5 @@ def key_monitor_rule_template_context(
"fb_sl_pct": FALSE_BREAKOUT_SL_PCT, "fb_sl_pct": FALSE_BREAKOUT_SL_PCT,
"fb_rr": FALSE_BREAKOUT_RR, "fb_rr": FALSE_BREAKOUT_RR,
"fb_valid_hours": false_breakout_validity_hours, "fb_valid_hours": false_breakout_validity_hours,
"trigger_entry_validity_hours": te_hours,
} }
+1
View File
@@ -14,6 +14,7 @@ VALID_MODES = frozenset({MODE_RISK, MODE_FULL_MARGIN})
OPEN_SOURCE_MANUAL = "manual" OPEN_SOURCE_MANUAL = "manual"
OPEN_SOURCE_KEY_AUTO = "key_auto" OPEN_SOURCE_KEY_AUTO = "key_auto"
OPEN_SOURCE_KEY_FIB = "key_fib" OPEN_SOURCE_KEY_FIB = "key_fib"
OPEN_SOURCE_KEY_TRIGGER = "key_trigger"
OPEN_SOURCE_TREND = "trend" OPEN_SOURCE_TREND = "trend"
OPEN_SOURCE_ROLL = "roll" OPEN_SOURCE_ROLL = "roll"
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""补打 binance / okx 触价开仓补丁。"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
GATE = ROOT / "crypto_monitor_gate" / "app.py"
def extract_block():
text = GATE.read_text(encoding="utf-8")
i = text.index("def _trigger_entry_exists_for_symbol")
j = text.index("def check_fib_key_monitors():")
return text[i:j]
ADD_BRANCH = GATE.read_text(encoding="utf-8").split(
" if tc_en and not tc_h:\n tc_en = 0\n if is_trigger_entry_key_monitor_type(mt):"
)[1].split(" if is_false_breakout_key_monitor_type(mt):")[0]
TE_BRANCH = """ elif is_te:
direction = (r["direction"] or "long").lower()
entry = _sqlite_row_val(r, "fib_entry_price")
tp_v = _sqlite_row_val(r, "fib_take_profit")
entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-"
tp_txt = format_price_for_symbol(r["symbol"], tp_v) if tp_v else "-"
tp_inv = trigger_entry_invalidate_by_tp(direction, price, float(tp_v)) if tp_v else False
prev = trigger_entry_gate_preview(
entry_display=entry_txt,
take_profit_display=tp_txt,
created_at=_sqlite_row_val(r, "created_at"),
now=app_now(),
tp_invalidated=tp_inv,
hours=TRIGGER_ENTRY_VALIDITY_HOURS,
)
gate_summary = prev.get("summary") or "-"
gate_metrics = prev.get("metrics") or ""
fib_gate_ok = bool(prev.get("gate_ok"))
"""
TRIG_IMPORT = """from trigger_entry_key_monitor_lib import (
TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED,
TRIGGER_ENTRY_CLOSE_EXPIRED,
TRIGGER_ENTRY_CLOSE_FILLED,
TRIGGER_ENTRY_CLOSE_TP_INVALIDATE,
TRIGGER_ENTRY_MONITOR_TYPE,
TRIGGER_ENTRY_VALIDITY_HOURS,
check_trigger_entry_intent_limit,
count_pending_trigger_entries,
is_trigger_entry_expired,
is_trigger_entry_key_monitor_type,
trigger_entry_expires_at_text,
trigger_entry_gate_preview,
trigger_entry_invalidate_by_tp,
trigger_entry_reached,
validate_trigger_entry_geometry,
validate_trigger_entry_rr,
)
"""
def patch(path: Path, block: str) -> None:
text = path.read_text(encoding="utf-8")
if "trigger_entry_key_monitor_lib" not in text:
text = text.replace("from position_sizing_lib import (", TRIG_IMPORT + "from position_sizing_lib import (", 1)
if "OPEN_SOURCE_KEY_TRIGGER" not in text:
text = text.replace(
" OPEN_SOURCE_KEY_AUTO,\n OPEN_SOURCE_MANUAL,",
" OPEN_SOURCE_KEY_AUTO,\n OPEN_SOURCE_KEY_TRIGGER,\n OPEN_SOURCE_MANUAL,",
1,
)
reps = [
(' "关键位假突破",\n) + STRATEGY_ENTRY_REASON_OPTIONS', ' "关键位假突破",\n "关键位触价开仓",\n) + STRATEGY_ENTRY_REASON_OPTIONS'),
(' ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),\n)', ' ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),\n ("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}),\n)'),
(' "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER",\n ):', ' "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER",\n "ALTER TABLE key_monitors ADD COLUMN session_date TEXT",\n ):'),
(' if segment_key == "key_false_breakout":\n return kst == FALSE_BREAKOUT_MONITOR_TYPE\n return False', ' if segment_key == "key_false_breakout":\n return kst == FALSE_BREAKOUT_MONITOR_TYPE\n if segment_key == "key_trigger":\n return kst == TRIGGER_ENTRY_MONITOR_TYPE\n return False'),
(' "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE,\n }', ' "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE,\n "key_trigger": TRIGGER_ENTRY_MONITOR_TYPE,\n }'),
(" check_fib_key_monitors()\n _roll_cfg", " check_fib_key_monitors()\n check_trigger_entry_key_monitors()\n _roll_cfg"),
(' "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_limit_order_id,created_at FROM key_monitors"', ' "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_stop_loss,fib_take_profit,fib_limit_order_id,created_at FROM key_monitors"'),
(' is_fb = is_false_breakout_key_monitor_type(r["monitor_type"])\n if is_fib or is_fb:\n price = get_symbol_mark_price(r["symbol"])', ' is_fb = is_false_breakout_key_monitor_type(r["monitor_type"])\n is_te = is_trigger_entry_key_monitor_type(r["monitor_type"])\n if is_fib or is_fb or is_te:\n price = get_symbol_mark_price(r["symbol"])'),
]
for a, b in reps:
if a not in text:
raise SystemExit(f"{path.name}: missing\n{a[:70]}")
text = text.replace(a, b, 1)
if " + (TRIGGER_ENTRY_MONITOR_TYPE,)" not in text:
text = text.replace(
" + (FALSE_BREAKOUT_MONITOR_TYPE,)\n )",
" + (FALSE_BREAKOUT_MONITOR_TYPE,)\n + (TRIGGER_ENTRY_MONITOR_TYPE,)\n )",
1,
)
te_anchor = ' fb_gate_ok = bool(prev.get("gate_ok"))\n elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES:'
if te_anchor not in text:
raise SystemExit(f"{path.name}: te anchor")
text = text.replace(te_anchor, ' fb_gate_ok = bool(prev.get("gate_ok"))\n' + TE_BRANCH + ' elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES:', 1)
add_anchor = " if tc_en and not tc_h:\n tc_en = 0\n if is_false_breakout_key_monitor_type(mt):"
if add_anchor not in text:
raise SystemExit(f"{path.name}: add anchor")
text = text.replace(
add_anchor,
" if tc_en and not tc_h:\n tc_en = 0\n if is_trigger_entry_key_monitor_type(mt):" + ADD_BRANCH + " if is_false_breakout_key_monitor_type(mt):",
1,
)
func_anchor = " send_wechat_msg(succ)\n _finalize_key_monitor_one_shot(conn, row, succ, close_reason)\n\n\ndef check_fib_key_monitors():"
if func_anchor not in text:
raise SystemExit(f"{path.name}: func anchor")
text = text.replace(func_anchor, " send_wechat_msg(succ)\n _finalize_key_monitor_one_shot(conn, row, succ, close_reason)\n\n\n" + block, 1)
path.write_text(text, encoding="utf-8")
print("patched", path.relative_to(ROOT))
def main():
block = extract_block()
patch(ROOT / "crypto_monitor_binance" / "app.py", block)
patch(ROOT / "crypto_monitor_okx" / "app.py", block)
if __name__ == "__main__":
main()
+111
View File
@@ -0,0 +1,111 @@
"""补全 okx / binance 触价开仓:import、stats、background、add_key。"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
GATE = ROOT / "crypto_monitor_gate" / "app.py"
ADD_INSERT = GATE.read_text(encoding="utf-8").split(
" if is_trigger_entry_key_monitor_type(mt):"
)[1].split(" if is_false_breakout_key_monitor_type(mt):")[0]
TRIG_IMPORT = """from trigger_entry_key_monitor_lib import (
TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED,
TRIGGER_ENTRY_CLOSE_EXPIRED,
TRIGGER_ENTRY_CLOSE_FILLED,
TRIGGER_ENTRY_CLOSE_TP_INVALIDATE,
TRIGGER_ENTRY_MONITOR_TYPE,
TRIGGER_ENTRY_VALIDITY_HOURS,
check_trigger_entry_intent_limit,
count_pending_trigger_entries,
is_trigger_entry_expired,
is_trigger_entry_key_monitor_type,
trigger_entry_expires_at_text,
trigger_entry_gate_preview,
trigger_entry_invalidate_by_tp,
trigger_entry_reached,
validate_trigger_entry_geometry,
validate_trigger_entry_rr,
)
"""
def patch_okx():
p = ROOT / "crypto_monitor_okx" / "app.py"
t = p.read_text(encoding="utf-8")
if "trigger_entry_key_monitor_lib" not in t:
t = t.replace("from position_sizing_lib import (", TRIG_IMPORT + "from position_sizing_lib import (", 1)
if "OPEN_SOURCE_KEY_TRIGGER" not in t:
t = t.replace(
" OPEN_SOURCE_KEY_AUTO,\n OPEN_SOURCE_MANUAL,",
" OPEN_SOURCE_KEY_AUTO,\n OPEN_SOURCE_KEY_TRIGGER,\n OPEN_SOURCE_MANUAL,",
1,
)
if '"关键位触价开仓"' not in t:
t = t.replace(
' "关键位假突破",\n) + STRATEGY_ENTRY_REASON_OPTIONS',
' "关键位假突破",\n "关键位触价开仓",\n) + STRATEGY_ENTRY_REASON_OPTIONS',
1,
)
if "key_trigger" not in t:
t = t.replace(
' ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),\n)',
' ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),\n ("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}),\n)',
1,
)
if "key_monitors ADD COLUMN session_date" not in t:
t = t.replace(
' "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER",\n ):',
' "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER",\n "ALTER TABLE key_monitors ADD COLUMN session_date TEXT",\n ):',
1,
)
if 'segment_key == "key_trigger"' not in t:
t = t.replace(
' if segment_key == "key_false_breakout":\n return kst == FALSE_BREAKOUT_MONITOR_TYPE\n return False',
' if segment_key == "key_false_breakout":\n return kst == FALSE_BREAKOUT_MONITOR_TYPE\n if segment_key == "key_trigger":\n return kst == TRIGGER_ENTRY_MONITOR_TYPE\n return False',
1,
)
if '"key_trigger":' not in t:
t = t.replace(
' "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE,\n }',
' "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE,\n "key_trigger": TRIGGER_ENTRY_MONITOR_TYPE,\n }',
1,
)
if "check_trigger_entry_key_monitors()" not in t:
t = t.replace(
" check_fib_key_monitors()\n _roll_cfg",
" check_fib_key_monitors()\n check_trigger_entry_key_monitors()\n _roll_cfg",
1,
)
if " + (TRIGGER_ENTRY_MONITOR_TYPE,)" not in t:
t = t.replace(
" + (FALSE_BREAKOUT_MONITOR_TYPE,)\n )",
" + (FALSE_BREAKOUT_MONITOR_TYPE,)\n + (TRIGGER_ENTRY_MONITOR_TYPE,)\n )",
1,
)
anchor = " be_flag = parse_breakeven_enabled_form(d.get(\"breakeven_enabled\"))\n if is_false_breakout_key_monitor_type(mt):"
if "is_trigger_entry_key_monitor_type(mt)" not in t:
t = t.replace(
anchor,
" be_flag = parse_breakeven_enabled_form(d.get(\"breakeven_enabled\"))\n tc_en = parse_time_close_enabled_form(d.get(\"time_close_enabled\"))\n tc_h = parse_time_close_hours_form(d.get(\"time_close_hours\")) if tc_en else None\n if tc_en and not tc_h:\n tc_en = 0\n if is_trigger_entry_key_monitor_type(mt):" + ADD_INSERT + " if is_false_breakout_key_monitor_type(mt):",
1,
)
p.write_text(t, encoding="utf-8")
print("okx done")
def patch_binance_add_key():
p = ROOT / "crypto_monitor_binance" / "app.py"
t = p.read_text(encoding="utf-8")
anchor = " be_flag = parse_breakeven_enabled_form(d.get(\"breakeven_enabled\"))\n if is_false_breakout_key_monitor_type(mt):"
if "is_trigger_entry_key_monitor_type(mt)" in t:
print("binance add_key skip")
return
t = t.replace(
anchor,
" be_flag = parse_breakeven_enabled_form(d.get(\"breakeven_enabled\"))\n tc_en = parse_time_close_enabled_form(d.get(\"time_close_enabled\"))\n tc_h = parse_time_close_hours_form(d.get(\"time_close_hours\")) if tc_en else None\n if tc_en and not tc_h:\n tc_en = 0\n if is_trigger_entry_key_monitor_type(mt):" + ADD_INSERT + " if is_false_breakout_key_monitor_type(mt):",
1,
)
p.write_text(t, encoding="utf-8")
print("binance add_key done")
if __name__ == "__main__":
patch_okx()
patch_binance_add_key()
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""将触价开仓补丁同步到四所 app.py(与 crypto_monitor_gate 对齐)。"""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
GATE = ROOT / "crypto_monitor_gate" / "app.py"
TARGETS = [
ROOT / "crypto_monitor_gate_bot" / "app.py",
ROOT / "crypto_monitor_binance" / "app.py",
ROOT / "crypto_monitor_okx" / "app.py",
]
# 从 gate 提取触价相关函数块
FUNC_BLOCK_START = "def _trigger_entry_exists_for_symbol(conn, symbol):"
FUNC_BLOCK_END = "def check_fib_key_monitors():"
def extract_gate_block() -> str:
text = GATE.read_text(encoding="utf-8")
i = text.index(FUNC_BLOCK_START)
j = text.index(FUNC_BLOCK_END)
return text[i:j]
def ensure_imports(text: str) -> str:
trigger_import = """from trigger_entry_key_monitor_lib import (
TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED,
TRIGGER_ENTRY_CLOSE_EXPIRED,
TRIGGER_ENTRY_CLOSE_FILLED,
TRIGGER_ENTRY_CLOSE_TP_INVALIDATE,
TRIGGER_ENTRY_MONITOR_TYPE,
TRIGGER_ENTRY_VALIDITY_HOURS,
check_trigger_entry_intent_limit,
count_pending_trigger_entries,
is_trigger_entry_expired,
is_trigger_entry_key_monitor_type,
trigger_entry_expires_at_text,
trigger_entry_gate_preview,
trigger_entry_invalidate_by_tp,
trigger_entry_reached,
validate_trigger_entry_geometry,
validate_trigger_entry_rr,
)
"""
if "from trigger_entry_key_monitor_lib import" not in text:
text = text.replace(
"from position_sizing_lib import (",
trigger_import + "from position_sizing_lib import (",
1,
)
if "OPEN_SOURCE_KEY_TRIGGER" not in text:
text = text.replace(
" OPEN_SOURCE_KEY_AUTO,\n OPEN_SOURCE_MANUAL,",
" OPEN_SOURCE_KEY_AUTO,\n OPEN_SOURCE_KEY_TRIGGER,\n OPEN_SOURCE_MANUAL,",
1,
)
return text
def patch_file(path: Path, func_block: str) -> None:
text = path.read_text(encoding="utf-8")
text = ensure_imports(text)
replacements = [
(
' "关键位假突破",\n) + STRATEGY_ENTRY_REASON_OPTIONS',
' "关键位假突破",\n "关键位触价开仓",\n) + STRATEGY_ENTRY_REASON_OPTIONS',
),
(
' ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),\n)',
' ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),\n ("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}),\n)',
),
(
' "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER",\n ):',
' "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER",\n "ALTER TABLE key_monitors ADD COLUMN session_date TEXT",\n ):',
),
(
' if segment_key == "key_false_breakout":\n return kst == FALSE_BREAKOUT_MONITOR_TYPE\n return False',
' if segment_key == "key_false_breakout":\n return kst == FALSE_BREAKOUT_MONITOR_TYPE\n if segment_key == "key_trigger":\n return kst == TRIGGER_ENTRY_MONITOR_TYPE\n return False',
),
(
' "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE,\n }',
' "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE,\n "key_trigger": TRIGGER_ENTRY_MONITOR_TYPE,\n }',
),
(
" check_fib_key_monitors()\n _roll_cfg",
" check_fib_key_monitors()\n check_trigger_entry_key_monitors()\n _roll_cfg",
),
(
" + (FALSE_BREAKOUT_MONITOR_TYPE,)\n )",
" + (FALSE_BREAKOUT_MONITOR_TYPE,)\n + (TRIGGER_ENTRY_MONITOR_TYPE,)\n )",
),
(
' "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_limit_order_id,created_at FROM key_monitors"',
' "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_stop_loss,fib_take_profit,fib_limit_order_id,created_at FROM key_monitors"',
),
(
" is_fb = is_false_breakout_key_monitor_type(r[\"monitor_type\"])\n if is_fib or is_fb:\n price = get_symbol_mark_price(r[\"symbol\"])",
" is_fb = is_false_breakout_key_monitor_type(r[\"monitor_type\"])\n is_te = is_trigger_entry_key_monitor_type(r[\"monitor_type\"])\n if is_fib or is_fb or is_te:\n price = get_symbol_mark_price(r[\"symbol\"])",
),
]
for old, new in replacements:
if old not in text:
raise SystemExit(f"[{path.name}] missing pattern:\n{old[:80]}...")
text = text.replace(old, new, 1)
# api_price_snapshot te branch
te_branch_old = """ gate_summary = prev.get("summary") or "-"
gate_metrics = prev.get("metrics") or ""
fb_gate_ok = bool(prev.get("gate_ok"))
elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES:"""
te_branch_new = """ gate_summary = prev.get("summary") or "-"
gate_metrics = prev.get("metrics") or ""
fb_gate_ok = bool(prev.get("gate_ok"))
elif is_te:
direction = (r["direction"] or "long").lower()
entry = _sqlite_row_val(r, "fib_entry_price")
tp_v = _sqlite_row_val(r, "fib_take_profit")
entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-"
tp_txt = format_price_for_symbol(r["symbol"], tp_v) if tp_v else "-"
tp_inv = trigger_entry_invalidate_by_tp(direction, price, float(tp_v)) if tp_v else False
prev = trigger_entry_gate_preview(
entry_display=entry_txt,
take_profit_display=tp_txt,
created_at=_sqlite_row_val(r, "created_at"),
now=app_now(),
tp_invalidated=tp_inv,
hours=TRIGGER_ENTRY_VALIDITY_HOURS,
)
gate_summary = prev.get("summary") or "-"
gate_metrics = prev.get("metrics") or ""
fib_gate_ok = bool(prev.get("gate_ok"))
elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES:"""
if te_branch_old not in text:
raise SystemExit(f"[{path.name}] missing api te branch anchor")
text = text.replace(te_branch_old, te_branch_new, 1)
# add_key trigger branch
add_key_old = """ if tc_en and not tc_h:
tc_en = 0
if is_false_breakout_key_monitor_type(mt):"""
add_key_new = """ if tc_en and not tc_h:
tc_en = 0
if is_trigger_entry_key_monitor_type(mt):
if direction_sel not in ("long", "short"):
conn.close()
conn = None
flash("触价开仓请选择做多或做空")
return redirect("/key_monitor")
try:
entry_px = float(d.get("trigger_entry") or 0)
sl_px = float(d.get("trigger_sl") or 0)
tp_px = float(d.get("trigger_tp") or 0)
except (TypeError, ValueError):
entry_px = sl_px = tp_px = 0
if entry_px <= 0 or sl_px <= 0 or tp_px <= 0:
conn.close()
conn = None
flash("触价开仓须填写有效的入场价、止损价、止盈价")
return redirect("/key_monitor")
ok_te, err_te = _add_trigger_entry_key_monitor(
conn, symbol, direction_sel, entry_px, sl_px, tp_px, breakeven_enabled=be_flag,
time_close_enabled=tc_en, time_close_hours=tc_h,
)
conn.commit()
conn.close()
conn = None
if not ok_te:
flash(err_te or "触价开仓监控添加失败")
return redirect("/key_monitor")
flash(
f"触价开仓已添加({symbol} 日成交量排名 {rank}/{total}"
f"|有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h"
f"|标记价触达入场价后下一轮询市价开仓"
f"|移动保本:{'' if be_flag else ''}"
+ (f"{time_close_label(tc_h)}" if tc_en else "")
)
return redirect("/key_monitor")
if is_false_breakout_key_monitor_type(mt):"""
if add_key_old not in text:
raise SystemExit(f"[{path.name}] missing add_key anchor")
text = text.replace(add_key_old, add_key_new, 1)
# function block
anchor = " send_wechat_msg(succ)\n _finalize_key_monitor_one_shot(conn, row, succ, close_reason)\n\n\ndef check_fib_key_monitors():"
if anchor not in text:
raise SystemExit(f"[{path.name}] missing func insert anchor")
text = text.replace(
anchor,
" send_wechat_msg(succ)\n _finalize_key_monitor_one_shot(conn, row, succ, close_reason)\n\n\n" + func_block,
1,
)
path.write_text(text, encoding="utf-8")
print(f"patched {path.relative_to(ROOT)}")
def main() -> None:
block = extract_gate_block()
for t in TARGETS:
patch_file(t, block)
print("done")
if __name__ == "__main__":
main()
+14 -3
View File
@@ -93,7 +93,7 @@
{% macro key_history_outcome_kind(h) -%} {% macro key_history_outcome_kind(h) -%}
{%- set r = (h.close_reason or '')|trim -%} {%- set r = (h.close_reason or '')|trim -%}
{%- if r in ['fib_filled', 'false_breakout_filled', 'key_level_alert_done', 'alerts_complete', 'auto_opened'] -%}success {%- if r in ['fib_filled', 'false_breakout_filled', 'trigger_entry_filled', 'key_level_alert_done', 'alerts_complete', 'auto_opened'] -%}success
{%- elif r == 'manual' -%}manual {%- elif r == 'manual' -%}manual
{%- elif r -%}failed {%- elif r -%}failed
{%- else -%}neutral {%- else -%}neutral
@@ -104,11 +104,15 @@
{%- set r = (h.close_reason or '')|trim -%} {%- set r = (h.close_reason or '')|trim -%}
{%- if r == 'fib_filled' -%}斐波成交 {%- if r == 'fib_filled' -%}斐波成交
{%- elif r == 'false_breakout_filled' -%}假突破成交 {%- elif r == 'false_breakout_filled' -%}假突破成交
{%- elif r == 'trigger_entry_filled' -%}触价成交
{%- elif r == 'key_level_alert_done' -%}提醒完成 {%- elif r == 'key_level_alert_done' -%}提醒完成
{%- elif r == 'alerts_complete' -%}提醒已满 {%- elif r == 'alerts_complete' -%}提醒已满
{%- elif r == 'auto_opened' -%}自动开仓 {%- elif r == 'auto_opened' -%}自动开仓
{%- elif r == 'manual' -%}手动删除 {%- elif r == 'manual' -%}手动删除
{%- elif r == 'fib_invalidate' -%}斐波失效 {%- elif r == 'fib_invalidate' -%}斐波失效
{%- elif r == 'trigger_tp_invalidate' -%}触价止盈失效
{%- elif r == 'trigger_entry_expired' -%}触价过期
{%- elif r == 'trigger_exchange_failed' -%}触价下单失败
{%- elif r == 'false_breakout_expired' -%}假突破过期 {%- elif r == 'false_breakout_expired' -%}假突破过期
{%- elif r == 'fib_plan_invalid' -%}计划无效 {%- elif r == 'fib_plan_invalid' -%}计划无效
{%- elif r == 'rr_insufficient' -%}盈亏比不足 {%- elif r == 'rr_insufficient' -%}盈亏比不足
@@ -133,12 +137,15 @@
</div> </div>
<form id="key-form" action="/add_key" method="post" class="form-row"> <form id="key-form" action="/add_key" method="post" class="form-row">
<input name="symbol" placeholder="BTC 或 BTC/USDT" required> <input name="symbol" placeholder="BTC 或 BTC/USDT" required>
<select name="type" required> <select name="type" id="key-type-select" required>
{% if position_sizing_mode != 'full_margin' %}
<option value="箱体突破">箱体突破</option> <option value="箱体突破">箱体突破</option>
<option value="收敛突破">收敛突破</option> <option value="收敛突破">收敛突破</option>
<option value="斐波回调0.618">斐波回调0.618</option> <option value="斐波回调0.618">斐波回调0.618</option>
<option value="斐波回调0.786">斐波回调0.786</option> <option value="斐波回调0.786">斐波回调0.786</option>
<option value="假突破">假突破(BTC/ETH</option> <option value="假突破">假突破(BTC/ETH</option>
{% endif %}
<option value="触价开仓">触价开仓</option>
<option value="关键阻力位">关键阻力位</option> <option value="关键阻力位">关键阻力位</option>
<option value="关键支撑位">关键支撑位</option> <option value="关键支撑位">关键支撑位</option>
</select> </select>
@@ -146,6 +153,9 @@
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option> <option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
</select> </select>
<input name="key_price" id="key-fb-price" step="0.0001" placeholder="做空填高点/做多填低点" style="display:none"> <input name="key_price" id="key-fb-price" step="0.0001" placeholder="做空填高点/做多填低点" style="display:none">
<input name="trigger_entry" id="key-trigger-entry" step="0.0001" placeholder="计划入场价" style="display:none">
<input name="trigger_sl" id="key-trigger-sl" step="0.0001" placeholder="止损价" style="display:none">
<input name="trigger_tp" id="key-trigger-tp" step="0.0001" placeholder="止盈价" style="display:none">
<input name="upper" id="key-upper" step="0.0001" placeholder="上沿/阻力" required> <input name="upper" id="key-upper" step="0.0001" placeholder="上沿/阻力" required>
<input name="lower" id="key-lower" 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="止盈止损方案"> <select name="sl_tp_mode" id="key-sl-tp-mode" title="止盈止损方案">
@@ -203,7 +213,7 @@
<div class="pos-meta"> <div class="pos-meta">
<span class="pos-meta-item">上沿: {{ k.upper }}</span> <span class="pos-meta-item">上沿: {{ k.upper }}</span>
<span class="pos-meta-item">下沿: {{ k.lower }}</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.fib_entry_price and k.monitor_type == '触价开仓' %}<span class="pos-meta-item">E: {{ k.fib_entry_price }} / SL: {{ k.fib_stop_loss }} / TP: {{ k.fib_take_profit }}</span>{% elif 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 %} {% 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> <span class="pos-meta-item">已提醒: {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }}</span>
{% if k.monitor_type in ['箱体突破','收敛突破'] %} {% if k.monitor_type in ['箱体突破','收敛突破'] %}
@@ -275,6 +285,7 @@ function keySummaryIsPending(snap){
const gm = String(snap.gate_metrics || ""); const gm = String(snap.gate_metrics || "");
if(gm.includes("限价单") || gm.includes("挂单")) return true; if(gm.includes("限价单") || gm.includes("挂单")) return true;
if(/等待成交/.test(gs)) return true; if(/等待成交/.test(gs)) return true;
if(/触价待触发/.test(gs)) return true;
if(/挂E=/.test(gs) && !gs.includes("将失效")) return true; if(/挂E=/.test(gs) && !gs.includes("将失效")) return true;
return false; return false;
} }
@@ -33,6 +33,13 @@
<td class="key-rule-cell">即挂限价<br>成交/过期→历史</td> <td class="key-rule-cell">即挂限价<br>成交/过期→历史</td>
</tr> </tr>
<tr> <tr>
<td class="key-rule-type">触价开仓</td>
<td class="key-rule-cell">方向 + 入场 E / 止损 SL / 止盈 TP<br>可勾移动保本、时间平仓</td>
<td class="key-rule-cell">RR &gt;{{ r.min_rr }};做多 SL&lt;E&lt;TP<br>标记价触 E 后下一轮询市价开<br>先触 TP 侧失效;有效 {{ r.trigger_entry_validity_hours }}h</td>
<td class="key-rule-cell">程序盯价,无交易所挂单<br>成交后挂所 TP/SL → 下单监控</td>
<td class="key-rule-cell">占当日开仓意图<br>全仓模式可用</td>
</tr>
<tr>
<td class="key-rule-type">阻力 / 支撑</td> <td class="key-rule-type">阻力 / 支撑</td>
<td class="key-rule-cell">双向;填上/下沿</td> <td class="key-rule-cell">双向;填上/下沿</td>
<td class="key-rule-cell">{{ r.tf }} 收盘破上沿或下沿<br>上沿优先</td> <td class="key-rule-cell">{{ r.tf }} 收盘破上沿或下沿<br>上沿优先</td>
@@ -0,0 +1,45 @@
"""触价开仓关键位监控单元测试。"""
from trigger_entry_key_monitor_lib import (
TRIGGER_ENTRY_MONITOR_TYPE,
TRIGGER_ENTRY_VALIDITY_HOURS,
check_trigger_entry_intent_limit,
is_trigger_entry_key_monitor_type,
trigger_entry_invalidate_by_tp,
trigger_entry_reached,
validate_trigger_entry_geometry,
)
class _FakeConn:
def execute(self, sql, params=()):
class R:
def fetchone(self_inner):
return (params[1] == "2026-06-07" and 2,) # 2 pending
return R()
def test_trigger_entry_reached_long():
assert trigger_entry_reached("long", 2049.0, 2050.0) is True
assert trigger_entry_reached("long", 2051.0, 2050.0) is False
def test_trigger_entry_invalidate_long():
assert trigger_entry_invalidate_by_tp("long", 2100.0, 2100.0) is True
assert trigger_entry_invalidate_by_tp("long", 2099.0, 2100.0) is False
def test_validate_geometry_long():
assert validate_trigger_entry_geometry("long", 2050, 2000, 2100, 2090) is None
assert "止损" in (validate_trigger_entry_geometry("long", 2050, 2100, 2000) or "")
def test_intent_limit():
ok, msg = check_trigger_entry_intent_limit(_FakeConn(), "2026-06-07", 2, 3)
assert ok is False
assert "意图" in msg
def test_type_name():
assert is_trigger_entry_key_monitor_type(TRIGGER_ENTRY_MONITOR_TYPE)
assert TRIGGER_ENTRY_VALIDITY_HOURS == 24
+165
View File
@@ -0,0 +1,165 @@
"""触价开仓关键位监控:程序盯价、触达计划入场后市价成交(四所共用逻辑)。"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Callable, Optional
from false_breakout_key_monitor_lib import (
_parse_created_at,
expires_at_text,
is_false_breakout_expired,
)
from strategy_trend_lib import trend_dca_level_reached
TRIGGER_ENTRY_MONITOR_TYPE = "触价开仓"
TRIGGER_ENTRY_VALIDITY_HOURS = 24
TRIGGER_ENTRY_CLOSE_FILLED = "trigger_entry_filled"
TRIGGER_ENTRY_CLOSE_TP_INVALIDATE = "trigger_tp_invalidate"
TRIGGER_ENTRY_CLOSE_EXPIRED = "trigger_entry_expired"
TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED = "trigger_exchange_failed"
KEY_ENTRY_REASON_TRIGGER = "关键位触价开仓"
def is_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool:
return (monitor_type or "").strip() == TRIGGER_ENTRY_MONITOR_TYPE
def trigger_entry_reached(direction: str, mark_price: float, entry: float) -> bool:
return trend_dca_level_reached(direction, mark_price, entry)
def trigger_entry_invalidate_by_tp(direction: str, mark_price: float, take_profit: float) -> bool:
"""未开仓前标记价先触达止盈侧则失效。"""
try:
m = float(mark_price)
tp = float(take_profit)
except (TypeError, ValueError):
return False
d = (direction or "long").strip().lower()
if d == "short":
return m <= tp
return m >= tp
def validate_trigger_entry_geometry(
direction: str,
entry: float,
stop_loss: float,
take_profit: float,
mark_at_add: Optional[float] = None,
) -> Optional[str]:
"""返回错误文案;合法则 None。"""
try:
e = float(entry)
sl = float(stop_loss)
tp = float(take_profit)
except (TypeError, ValueError):
return "入场价、止损、止盈须为有效数字"
if e <= 0 or sl <= 0 or tp <= 0:
return "入场价、止损、止盈须大于 0"
d = (direction or "long").strip().lower()
if d == "long":
if not (sl < e < tp):
return "做多:须满足 止损 < 入场价 < 止盈"
if mark_at_add is not None and float(mark_at_add) >= tp:
return "做多:当前价已不低于止盈,无法添加触价开仓"
elif d == "short":
if not (tp < e < sl):
return "做空:须满足 止盈 < 入场价 < 止损"
if mark_at_add is not None and float(mark_at_add) <= tp:
return "做空:当前价已不高于止盈,无法添加触价开仓"
else:
return "方向须为 long 或 short"
return None
def validate_trigger_entry_rr(
direction: str,
entry: float,
stop_loss: float,
take_profit: float,
min_rr: float,
calc_rr_ratio: Callable[..., Optional[float]],
) -> Optional[str]:
rr = calc_rr_ratio(direction, entry, stop_loss, take_profit)
if rr is None or rr <= float(min_rr):
fmt = f"{rr:.4f}" if rr is not None else "无法计算"
return f"计划盈亏比 {fmt}:1 未达要求(>{float(min_rr)}:1"
return None
def is_trigger_entry_expired(
created_at: Any,
now: datetime,
*,
hours: int = TRIGGER_ENTRY_VALIDITY_HOURS,
) -> bool:
return is_false_breakout_expired(created_at, now, hours=hours)
def trigger_entry_expires_at_text(
created_at: Any,
*,
hours: int = TRIGGER_ENTRY_VALIDITY_HOURS,
) -> str:
return expires_at_text(created_at, hours=hours)
def count_pending_trigger_entries(conn: Any, trading_day: str) -> int:
td = (trading_day or "").strip()
if not td:
return 0
row = conn.execute(
"SELECT COUNT(*) FROM key_monitors WHERE monitor_type=? AND session_date=?",
(TRIGGER_ENTRY_MONITOR_TYPE, td),
).fetchone()
return int(row[0] if row else 0)
def check_trigger_entry_intent_limit(
conn: Any,
trading_day: str,
opens_today: int,
hard_limit: int,
) -> tuple[bool, str]:
"""当日开仓意图:已成交次数 + 待触发触价条数。"""
if int(hard_limit) <= 0:
return True, ""
pending = count_pending_trigger_entries(conn, trading_day)
total = int(opens_today) + pending
if total >= int(hard_limit):
return (
False,
f"本交易日开仓意图已达上限(已开 {int(opens_today)} + 待触发 {pending} / 硬上限 {int(hard_limit)}",
)
return True, ""
def trigger_entry_gate_preview(
*,
entry_display: str,
take_profit_display: str,
created_at: Any = None,
now: Optional[datetime] = None,
expired: bool = False,
tp_invalidated: bool = False,
hours: int = TRIGGER_ENTRY_VALIDITY_HOURS,
) -> dict[str, Any]:
now_dt = now or datetime.now()
is_exp = expired or is_trigger_entry_expired(created_at, now_dt, hours=hours)
exp_txt = trigger_entry_expires_at_text(created_at, hours=hours)
if tp_invalidated:
status = "止盈侧失效"
elif is_exp:
status = "已过期"
else:
status = "触价待触发"
metrics_parts: list[str] = [f"TP:{take_profit_display}"]
if exp_txt != "":
metrics_parts.append(f"截至:{exp_txt}")
return {
"summary": f"触价 E={entry_display} {status}",
"metrics": " ".join(metrics_parts),
"gate_ok": not is_exp and not tp_invalidated,
}