feat(key-monitor): add program trigger entry across four exchanges
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -115,8 +115,27 @@ from manual_sltp_lib import (
|
||||
resolve_entrust_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 (
|
||||
OPEN_SOURCE_KEY_AUTO,
|
||||
OPEN_SOURCE_KEY_TRIGGER,
|
||||
OPEN_SOURCE_MANUAL,
|
||||
OPEN_SOURCE_ROLL,
|
||||
OPEN_SOURCE_TREND,
|
||||
@@ -1032,6 +1051,7 @@ ENTRY_REASON_OPTIONS = (
|
||||
"关键位斐波0.618",
|
||||
"关键位斐波0.786",
|
||||
"关键位假突破",
|
||||
"关键位触价开仓",
|
||||
) + STRATEGY_ENTRY_REASON_OPTIONS
|
||||
|
||||
STATS_SEGMENT_DEFS = (
|
||||
@@ -1042,6 +1062,7 @@ STATS_SEGMENT_DEFS = (
|
||||
("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}),
|
||||
("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}),
|
||||
("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),
|
||||
("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}),
|
||||
)
|
||||
# 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom)
|
||||
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 breakeven_enabled INTEGER DEFAULT 0",
|
||||
"ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER",
|
||||
"ALTER TABLE key_monitors ADD COLUMN session_date TEXT",
|
||||
):
|
||||
try:
|
||||
c.execute(ddl)
|
||||
@@ -1633,6 +1655,8 @@ def _pnl_row_matches_segment(row, segment_key):
|
||||
return kst == "斐波回调0.786"
|
||||
if segment_key == "key_false_breakout":
|
||||
return kst == FALSE_BREAKOUT_MONITOR_TYPE
|
||||
if segment_key == "key_trigger":
|
||||
return kst == TRIGGER_ENTRY_MONITOR_TYPE
|
||||
return False
|
||||
|
||||
|
||||
@@ -1650,6 +1674,7 @@ def _count_opens_for_segment(conn, start_td, end_td, segment_key):
|
||||
"key_fib618": "斐波回调0.618",
|
||||
"key_fib786": "斐波回调0.786",
|
||||
"key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE,
|
||||
"key_trigger": TRIGGER_ENTRY_MONITOR_TYPE,
|
||||
}
|
||||
kst = kst_map.get(segment_key)
|
||||
if kst:
|
||||
@@ -5031,7 +5056,464 @@ def _finalize_fib_key_fill(conn, row):
|
||||
_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()
|
||||
rows = conn.execute("SELECT * FROM key_monitors").fetchall()
|
||||
for r in rows:
|
||||
@@ -5915,6 +6397,7 @@ def background_task():
|
||||
conn.close()
|
||||
force_close_before_reset()
|
||||
check_fib_key_monitors()
|
||||
check_trigger_entry_key_monitors()
|
||||
_roll_cfg = app.extensions.get("strategy_roll_cfg")
|
||||
if _roll_cfg:
|
||||
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_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT,
|
||||
false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS,
|
||||
trigger_entry_validity_hours=TRIGGER_ENTRY_VALIDITY_HOURS,
|
||||
)
|
||||
strategy_extra = {}
|
||||
if page in ("strategy", "strategy_trend", "strategy_roll", "strategy_records"):
|
||||
@@ -6328,7 +6812,7 @@ def api_account_snapshot():
|
||||
def api_price_snapshot():
|
||||
conn = get_db()
|
||||
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()
|
||||
order_rows = conn.execute(
|
||||
"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:
|
||||
is_fib = is_fib_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"])
|
||||
else:
|
||||
price = prices.get(r["symbol"])
|
||||
@@ -6393,6 +6878,24 @@ def api_price_snapshot():
|
||||
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:
|
||||
try:
|
||||
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(FIB_KEY_MONITOR_TYPES)
|
||||
+ (FALSE_BREAKOUT_MONITOR_TYPE,)
|
||||
+ (TRIGGER_ENTRY_MONITOR_TYPE,)
|
||||
)
|
||||
if mt not in allowed_types:
|
||||
flash("监控类型无效")
|
||||
return redirect("/key_monitor")
|
||||
if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt):
|
||||
flash(
|
||||
"全仓杠杆模式下不可添加箱体/收敛突破或斐波监控;"
|
||||
"请改用阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。"
|
||||
"全仓杠杆模式下不可添加箱体/收敛突破、斐波或假突破监控;"
|
||||
"可使用「触价开仓」或阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。"
|
||||
)
|
||||
return redirect("/key_monitor")
|
||||
skip_volume_rank = is_false_breakout_key_monitor_type(mt)
|
||||
@@ -7003,6 +7507,45 @@ def add_key():
|
||||
except Exception:
|
||||
pass
|
||||
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):
|
||||
fb_sym = normalize_false_breakout_symbol(symbol)
|
||||
if not fb_sym:
|
||||
|
||||
@@ -1540,14 +1540,19 @@ function syncKeyMonitorFormFields(){
|
||||
const autoTypes = new Set(["箱体突破","收敛突破"]);
|
||||
const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]);
|
||||
const fbTypes = new Set(["假突破"]);
|
||||
const teTypes = new Set(["触价开仓"]);
|
||||
const rsTypes = new Set(["关键阻力位","关键支撑位"]);
|
||||
const showAuto = autoTypes.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 upperEl = document.getElementById("key-upper");
|
||||
const lowerEl = document.getElementById("key-lower");
|
||||
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){
|
||||
dirEl.style.display = showDir ? "" : "none";
|
||||
dirEl.required = showDir;
|
||||
@@ -1561,15 +1566,16 @@ function syncKeyMonitorFormFields(){
|
||||
}
|
||||
if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
|
||||
if(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
|
||||
const hideBounds = showFb || showTe;
|
||||
if(upperEl){
|
||||
upperEl.style.display = showFb ? "none" : "";
|
||||
upperEl.required = !showFb;
|
||||
if(showFb) upperEl.value = "";
|
||||
upperEl.style.display = hideBounds ? "none" : "";
|
||||
upperEl.required = !hideBounds;
|
||||
if(hideBounds) upperEl.value = "";
|
||||
}
|
||||
if(lowerEl){
|
||||
lowerEl.style.display = showFb ? "none" : "";
|
||||
lowerEl.required = !showFb;
|
||||
if(showFb) lowerEl.value = "";
|
||||
lowerEl.style.display = hideBounds ? "none" : "";
|
||||
lowerEl.required = !hideBounds;
|
||||
if(hideBounds) lowerEl.value = "";
|
||||
}
|
||||
if(fbPriceEl){
|
||||
fbPriceEl.style.display = showFb ? "" : "none";
|
||||
@@ -1577,6 +1583,12 @@ function syncKeyMonitorFormFields(){
|
||||
if(!showFb) fbPriceEl.value = "";
|
||||
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 keyModeSel = document.getElementById("key-sl-tp-mode");
|
||||
|
||||
@@ -66,9 +66,10 @@
|
||||
| **收敛突破** | 同上(自动开仓类)。 |
|
||||
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
|
||||
| **关键支撑位** | 同上(仅提醒)。 |
|
||||
| **触价开仓** | **不挂交易所限价**;标记价触达计划入场价后 **下一轮询市价开仓**(RR 门槛同关键位 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h**;全仓杠杆模式可用。 |
|
||||
|
||||
3. **方向**:做多 / 做空(必选)。
|
||||
4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整。
|
||||
3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
|
||||
4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**。
|
||||
|
||||
**限制:**
|
||||
活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 关键位监控说明(自动开仓 + 人工盯盘)
|
||||
|
||||
**适用:`crypto_monitor_binance`(Binance U 本位永续)**
|
||||
Gate / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_monitor_lib.py`。
|
||||
**适用:`crypto_monitor_gate`(Gate U 本位永续)**
|
||||
Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_monitor_lib.py`。
|
||||
|
||||
本文档与 `.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` |
|
||||
| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) |
|
||||
| 斐波回调 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_
|
||||
|
||||
---
|
||||
|
||||
## 五、相关代码
|
||||
## 六、相关代码
|
||||
|
||||
| 说明 | 位置 |
|
||||
|------|------|
|
||||
|
||||
+542
-3
@@ -116,8 +116,27 @@ from manual_sltp_lib import (
|
||||
resolve_entrust_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 (
|
||||
OPEN_SOURCE_KEY_AUTO,
|
||||
OPEN_SOURCE_KEY_TRIGGER,
|
||||
OPEN_SOURCE_MANUAL,
|
||||
assert_open_source_allowed,
|
||||
compute_full_margin_sizing,
|
||||
@@ -1022,6 +1041,7 @@ ENTRY_REASON_OPTIONS = (
|
||||
"关键位斐波0.618",
|
||||
"关键位斐波0.786",
|
||||
"关键位假突破",
|
||||
"关键位触价开仓",
|
||||
) + STRATEGY_ENTRY_REASON_OPTIONS
|
||||
|
||||
STATS_SEGMENT_DEFS = (
|
||||
@@ -1032,6 +1052,7 @@ STATS_SEGMENT_DEFS = (
|
||||
("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}),
|
||||
("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}),
|
||||
("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),
|
||||
("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}),
|
||||
)
|
||||
# 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom)
|
||||
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 breakeven_enabled INTEGER DEFAULT 0",
|
||||
"ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER",
|
||||
"ALTER TABLE key_monitors ADD COLUMN session_date TEXT",
|
||||
):
|
||||
try:
|
||||
c.execute(ddl)
|
||||
@@ -1630,6 +1652,8 @@ def _pnl_row_matches_segment(row, segment_key):
|
||||
return kst == "斐波回调0.786"
|
||||
if segment_key == "key_false_breakout":
|
||||
return kst == FALSE_BREAKOUT_MONITOR_TYPE
|
||||
if segment_key == "key_trigger":
|
||||
return kst == TRIGGER_ENTRY_MONITOR_TYPE
|
||||
return False
|
||||
|
||||
|
||||
@@ -1647,6 +1671,7 @@ def _count_opens_for_segment(conn, start_td, end_td, segment_key):
|
||||
"key_fib618": "斐波回调0.618",
|
||||
"key_fib786": "斐波回调0.786",
|
||||
"key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE,
|
||||
"key_trigger": TRIGGER_ENTRY_MONITOR_TYPE,
|
||||
}
|
||||
kst = kst_map.get(segment_key)
|
||||
if kst:
|
||||
@@ -5001,6 +5026,463 @@ def _finalize_fib_key_fill(conn, row):
|
||||
_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():
|
||||
conn = get_db()
|
||||
rows = conn.execute("SELECT * FROM key_monitors").fetchall()
|
||||
@@ -5879,6 +6361,7 @@ def background_task():
|
||||
conn.close()
|
||||
force_close_before_reset()
|
||||
check_fib_key_monitors()
|
||||
check_trigger_entry_key_monitors()
|
||||
_roll_cfg = app.extensions.get("strategy_roll_cfg")
|
||||
if _roll_cfg:
|
||||
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_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT,
|
||||
false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS,
|
||||
trigger_entry_validity_hours=TRIGGER_ENTRY_VALIDITY_HOURS,
|
||||
)
|
||||
strategy_extra = {}
|
||||
if page in ("strategy", "strategy_trend", "strategy_roll", "strategy_records"):
|
||||
@@ -6451,7 +6935,7 @@ def api_account_snapshot():
|
||||
def api_price_snapshot():
|
||||
conn = get_db()
|
||||
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()
|
||||
order_rows = conn.execute(
|
||||
"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:
|
||||
is_fib = is_fib_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"])
|
||||
else:
|
||||
price = prices.get(r["symbol"])
|
||||
@@ -6525,6 +7010,24 @@ def api_price_snapshot():
|
||||
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:
|
||||
try:
|
||||
prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"])
|
||||
@@ -7123,6 +7626,7 @@ def add_key():
|
||||
+ tuple(KEY_MONITOR_ALERT_ONLY_TYPES)
|
||||
+ tuple(FIB_KEY_MONITOR_TYPES)
|
||||
+ (FALSE_BREAKOUT_MONITOR_TYPE,)
|
||||
+ (TRIGGER_ENTRY_MONITOR_TYPE,)
|
||||
)
|
||||
if mt not in allowed_types:
|
||||
flash("监控类型无效")
|
||||
@@ -7130,7 +7634,7 @@ def add_key():
|
||||
if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt):
|
||||
flash(
|
||||
"全仓杠杆模式下不可添加箱体/收敛突破、斐波或假突破监控;"
|
||||
"请改用阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。"
|
||||
"可使用「触价开仓」或阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。"
|
||||
)
|
||||
return redirect("/key_monitor")
|
||||
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
|
||||
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):
|
||||
fb_sym = normalize_false_breakout_symbol(symbol)
|
||||
if not fb_sym:
|
||||
|
||||
@@ -1520,14 +1520,19 @@ function syncKeyMonitorFormFields(){
|
||||
const autoTypes = new Set(["箱体突破","收敛突破"]);
|
||||
const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]);
|
||||
const fbTypes = new Set(["假突破"]);
|
||||
const teTypes = new Set(["触价开仓"]);
|
||||
const rsTypes = new Set(["关键阻力位","关键支撑位"]);
|
||||
const showAuto = autoTypes.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 upperEl = document.getElementById("key-upper");
|
||||
const lowerEl = document.getElementById("key-lower");
|
||||
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){
|
||||
dirEl.style.display = showDir ? "" : "none";
|
||||
dirEl.required = showDir;
|
||||
@@ -1541,15 +1546,16 @@ function syncKeyMonitorFormFields(){
|
||||
}
|
||||
if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
|
||||
if(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
|
||||
const hideBounds = showFb || showTe;
|
||||
if(upperEl){
|
||||
upperEl.style.display = showFb ? "none" : "";
|
||||
upperEl.required = !showFb;
|
||||
if(showFb) upperEl.value = "";
|
||||
upperEl.style.display = hideBounds ? "none" : "";
|
||||
upperEl.required = !hideBounds;
|
||||
if(hideBounds) upperEl.value = "";
|
||||
}
|
||||
if(lowerEl){
|
||||
lowerEl.style.display = showFb ? "none" : "";
|
||||
lowerEl.required = !showFb;
|
||||
if(showFb) lowerEl.value = "";
|
||||
lowerEl.style.display = hideBounds ? "none" : "";
|
||||
lowerEl.required = !hideBounds;
|
||||
if(hideBounds) lowerEl.value = "";
|
||||
}
|
||||
if(fbPriceEl){
|
||||
fbPriceEl.style.display = showFb ? "" : "none";
|
||||
@@ -1557,6 +1563,12 @@ function syncKeyMonitorFormFields(){
|
||||
if(!showFb) fbPriceEl.value = "";
|
||||
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 keyModeSel = document.getElementById("key-sl-tp-mode");
|
||||
|
||||
@@ -65,9 +65,10 @@
|
||||
| **收敛突破** | 同上(自动开仓类)。 |
|
||||
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
|
||||
| **关键支撑位** | 同上(仅提醒)。 |
|
||||
| **触价开仓** | **不挂交易所限价**;标记价触达计划入场价后 **下一轮询市价开仓**(RR 门槛同关键位 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h**;全仓杠杆模式可用。 |
|
||||
|
||||
3. **方向**:做多 / 做空(必选)。
|
||||
4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整。
|
||||
3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
|
||||
4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**。
|
||||
|
||||
**限制:**
|
||||
活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。
|
||||
|
||||
@@ -16,8 +16,9 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k
|
||||
| **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` |
|
||||
| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) |
|
||||
| 斐波回调 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
|
||||
|
||||
---
|
||||
|
||||
## 五、相关代码
|
||||
## 六、相关代码
|
||||
|
||||
| 说明 | 位置 |
|
||||
|------|------|
|
||||
|
||||
@@ -116,8 +116,27 @@ from manual_sltp_lib import (
|
||||
resolve_entrust_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 (
|
||||
OPEN_SOURCE_KEY_AUTO,
|
||||
OPEN_SOURCE_KEY_TRIGGER,
|
||||
OPEN_SOURCE_MANUAL,
|
||||
assert_open_source_allowed,
|
||||
compute_full_margin_sizing,
|
||||
@@ -1022,6 +1041,7 @@ ENTRY_REASON_OPTIONS = (
|
||||
"关键位斐波0.618",
|
||||
"关键位斐波0.786",
|
||||
"关键位假突破",
|
||||
"关键位触价开仓",
|
||||
) + STRATEGY_ENTRY_REASON_OPTIONS
|
||||
|
||||
STATS_SEGMENT_DEFS = (
|
||||
@@ -1032,6 +1052,7 @@ STATS_SEGMENT_DEFS = (
|
||||
("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}),
|
||||
("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}),
|
||||
("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),
|
||||
("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}),
|
||||
)
|
||||
# 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom)
|
||||
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 breakeven_enabled INTEGER DEFAULT 0",
|
||||
"ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER",
|
||||
"ALTER TABLE key_monitors ADD COLUMN session_date TEXT",
|
||||
):
|
||||
try:
|
||||
c.execute(ddl)
|
||||
@@ -1630,6 +1652,8 @@ def _pnl_row_matches_segment(row, segment_key):
|
||||
return kst == "斐波回调0.786"
|
||||
if segment_key == "key_false_breakout":
|
||||
return kst == FALSE_BREAKOUT_MONITOR_TYPE
|
||||
if segment_key == "key_trigger":
|
||||
return kst == TRIGGER_ENTRY_MONITOR_TYPE
|
||||
return False
|
||||
|
||||
|
||||
@@ -1647,6 +1671,7 @@ def _count_opens_for_segment(conn, start_td, end_td, segment_key):
|
||||
"key_fib618": "斐波回调0.618",
|
||||
"key_fib786": "斐波回调0.786",
|
||||
"key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE,
|
||||
"key_trigger": TRIGGER_ENTRY_MONITOR_TYPE,
|
||||
}
|
||||
kst = kst_map.get(segment_key)
|
||||
if kst:
|
||||
@@ -5001,7 +5026,464 @@ def _finalize_fib_key_fill(conn, row):
|
||||
_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()
|
||||
rows = conn.execute("SELECT * FROM key_monitors").fetchall()
|
||||
for r in rows:
|
||||
@@ -5879,6 +6361,7 @@ def background_task():
|
||||
conn.close()
|
||||
force_close_before_reset()
|
||||
check_fib_key_monitors()
|
||||
check_trigger_entry_key_monitors()
|
||||
_roll_cfg = app.extensions.get("strategy_roll_cfg")
|
||||
if _roll_cfg:
|
||||
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_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT,
|
||||
false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS,
|
||||
trigger_entry_validity_hours=TRIGGER_ENTRY_VALIDITY_HOURS,
|
||||
)
|
||||
strategy_extra = {}
|
||||
if page in ("strategy", "strategy_trend", "strategy_roll", "strategy_records"):
|
||||
@@ -6451,7 +6935,7 @@ def api_account_snapshot():
|
||||
def api_price_snapshot():
|
||||
conn = get_db()
|
||||
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()
|
||||
order_rows = conn.execute(
|
||||
"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:
|
||||
is_fib = is_fib_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"])
|
||||
else:
|
||||
price = prices.get(r["symbol"])
|
||||
@@ -6525,6 +7010,24 @@ def api_price_snapshot():
|
||||
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:
|
||||
try:
|
||||
prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"])
|
||||
@@ -7123,6 +7626,7 @@ def add_key():
|
||||
+ tuple(KEY_MONITOR_ALERT_ONLY_TYPES)
|
||||
+ tuple(FIB_KEY_MONITOR_TYPES)
|
||||
+ (FALSE_BREAKOUT_MONITOR_TYPE,)
|
||||
+ (TRIGGER_ENTRY_MONITOR_TYPE,)
|
||||
)
|
||||
if mt not in allowed_types:
|
||||
flash("监控类型无效")
|
||||
@@ -7130,7 +7634,7 @@ def add_key():
|
||||
if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt):
|
||||
flash(
|
||||
"全仓杠杆模式下不可添加箱体/收敛突破、斐波或假突破监控;"
|
||||
"请改用阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。"
|
||||
"可使用「触价开仓」或阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。"
|
||||
)
|
||||
return redirect("/key_monitor")
|
||||
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
|
||||
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):
|
||||
fb_sym = normalize_false_breakout_symbol(symbol)
|
||||
if not fb_sym:
|
||||
|
||||
@@ -1520,14 +1520,19 @@ function syncKeyMonitorFormFields(){
|
||||
const autoTypes = new Set(["箱体突破","收敛突破"]);
|
||||
const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]);
|
||||
const fbTypes = new Set(["假突破"]);
|
||||
const teTypes = new Set(["触价开仓"]);
|
||||
const rsTypes = new Set(["关键阻力位","关键支撑位"]);
|
||||
const showAuto = autoTypes.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 upperEl = document.getElementById("key-upper");
|
||||
const lowerEl = document.getElementById("key-lower");
|
||||
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){
|
||||
dirEl.style.display = showDir ? "" : "none";
|
||||
dirEl.required = showDir;
|
||||
@@ -1541,15 +1546,16 @@ function syncKeyMonitorFormFields(){
|
||||
}
|
||||
if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
|
||||
if(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
|
||||
const hideBounds = showFb || showTe;
|
||||
if(upperEl){
|
||||
upperEl.style.display = showFb ? "none" : "";
|
||||
upperEl.required = !showFb;
|
||||
if(showFb) upperEl.value = "";
|
||||
upperEl.style.display = hideBounds ? "none" : "";
|
||||
upperEl.required = !hideBounds;
|
||||
if(hideBounds) upperEl.value = "";
|
||||
}
|
||||
if(lowerEl){
|
||||
lowerEl.style.display = showFb ? "none" : "";
|
||||
lowerEl.required = !showFb;
|
||||
if(showFb) lowerEl.value = "";
|
||||
lowerEl.style.display = hideBounds ? "none" : "";
|
||||
lowerEl.required = !hideBounds;
|
||||
if(hideBounds) lowerEl.value = "";
|
||||
}
|
||||
if(fbPriceEl){
|
||||
fbPriceEl.style.display = showFb ? "" : "none";
|
||||
@@ -1557,6 +1563,12 @@ function syncKeyMonitorFormFields(){
|
||||
if(!showFb) fbPriceEl.value = "";
|
||||
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 keyModeSel = document.getElementById("key-sl-tp-mode");
|
||||
|
||||
@@ -65,9 +65,10 @@
|
||||
| **收敛突破** | 同上(自动开仓类)。 |
|
||||
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
|
||||
| **关键支撑位** | 同上(仅提醒)。 |
|
||||
| **触价开仓** | **不挂交易所限价**;标记价触达计划入场价后 **下一轮询市价开仓**(RR 门槛同关键位 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h**;全仓杠杆模式可用。 |
|
||||
|
||||
3. **方向**:做多 / 做空(必选)。
|
||||
4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整。
|
||||
3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
|
||||
4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**。
|
||||
|
||||
**限制:**
|
||||
活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。
|
||||
|
||||
+506
-4
@@ -116,6 +116,24 @@ from manual_sltp_lib import (
|
||||
resolve_entrust_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 (
|
||||
OPEN_SOURCE_KEY_AUTO,
|
||||
OPEN_SOURCE_MANUAL,
|
||||
@@ -1029,6 +1047,7 @@ ENTRY_REASON_OPTIONS = (
|
||||
"关键位斐波0.618",
|
||||
"关键位斐波0.786",
|
||||
"关键位假突破",
|
||||
"关键位触价开仓",
|
||||
) + STRATEGY_ENTRY_REASON_OPTIONS
|
||||
|
||||
STATS_SEGMENT_DEFS = (
|
||||
@@ -1039,6 +1058,7 @@ STATS_SEGMENT_DEFS = (
|
||||
("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}),
|
||||
("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}),
|
||||
("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),
|
||||
("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}),
|
||||
)
|
||||
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 breakeven_enabled INTEGER DEFAULT 0",
|
||||
"ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER",
|
||||
"ALTER TABLE key_monitors ADD COLUMN session_date TEXT",
|
||||
):
|
||||
try:
|
||||
c.execute(ddl)
|
||||
@@ -1613,6 +1634,8 @@ def _pnl_row_matches_segment(row, segment_key):
|
||||
return kst == "斐波回调0.786"
|
||||
if segment_key == "key_false_breakout":
|
||||
return kst == FALSE_BREAKOUT_MONITOR_TYPE
|
||||
if segment_key == "key_trigger":
|
||||
return kst == TRIGGER_ENTRY_MONITOR_TYPE
|
||||
return False
|
||||
|
||||
|
||||
@@ -4560,7 +4583,464 @@ def _finalize_fib_key_fill(conn, row):
|
||||
_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()
|
||||
rows = conn.execute("SELECT * FROM key_monitors").fetchall()
|
||||
for r in rows:
|
||||
@@ -5662,6 +6142,7 @@ def background_task():
|
||||
conn.close()
|
||||
force_close_before_reset()
|
||||
check_fib_key_monitors()
|
||||
check_trigger_entry_key_monitors()
|
||||
_roll_cfg = app.extensions.get("strategy_roll_cfg")
|
||||
if _roll_cfg:
|
||||
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_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT,
|
||||
false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS,
|
||||
trigger_entry_validity_hours=TRIGGER_ENTRY_VALIDITY_HOURS,
|
||||
)
|
||||
strategy_extra = {}
|
||||
if page in ("strategy", "strategy_trend", "strategy_roll", "strategy_records"):
|
||||
@@ -6037,7 +6519,7 @@ def api_settings_open_guard():
|
||||
def api_price_snapshot():
|
||||
conn = get_db()
|
||||
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()
|
||||
order_rows = conn.execute(
|
||||
"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:
|
||||
is_fib = is_fib_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"])
|
||||
else:
|
||||
price = prices.get(r["symbol"])
|
||||
@@ -6111,6 +6594,24 @@ def api_price_snapshot():
|
||||
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:
|
||||
try:
|
||||
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(FIB_KEY_MONITOR_TYPES)
|
||||
+ (FALSE_BREAKOUT_MONITOR_TYPE,)
|
||||
+ (TRIGGER_ENTRY_MONITOR_TYPE,)
|
||||
)
|
||||
if mt not in allowed_types:
|
||||
flash("监控类型无效")
|
||||
@@ -6721,7 +7223,7 @@ def add_key():
|
||||
if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt):
|
||||
flash(
|
||||
"全仓杠杆模式下不可添加箱体/收敛突破、斐波或假突破监控;"
|
||||
"请改用阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。"
|
||||
"可使用「触价开仓」或阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。"
|
||||
)
|
||||
return redirect("/key_monitor")
|
||||
skip_volume_rank = is_false_breakout_key_monitor_type(mt)
|
||||
|
||||
@@ -1549,14 +1549,19 @@ function syncKeyMonitorFormFields(){
|
||||
const autoTypes = new Set(["箱体突破","收敛突破"]);
|
||||
const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]);
|
||||
const fbTypes = new Set(["假突破"]);
|
||||
const teTypes = new Set(["触价开仓"]);
|
||||
const rsTypes = new Set(["关键阻力位","关键支撑位"]);
|
||||
const showAuto = autoTypes.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 upperEl = document.getElementById("key-upper");
|
||||
const lowerEl = document.getElementById("key-lower");
|
||||
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){
|
||||
dirEl.style.display = showDir ? "" : "none";
|
||||
dirEl.required = showDir;
|
||||
@@ -1570,15 +1575,16 @@ function syncKeyMonitorFormFields(){
|
||||
}
|
||||
if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
|
||||
if(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
|
||||
const hideBounds = showFb || showTe;
|
||||
if(upperEl){
|
||||
upperEl.style.display = showFb ? "none" : "";
|
||||
upperEl.required = !showFb;
|
||||
if(showFb) upperEl.value = "";
|
||||
upperEl.style.display = hideBounds ? "none" : "";
|
||||
upperEl.required = !hideBounds;
|
||||
if(hideBounds) upperEl.value = "";
|
||||
}
|
||||
if(lowerEl){
|
||||
lowerEl.style.display = showFb ? "none" : "";
|
||||
lowerEl.required = !showFb;
|
||||
if(showFb) lowerEl.value = "";
|
||||
lowerEl.style.display = hideBounds ? "none" : "";
|
||||
lowerEl.required = !hideBounds;
|
||||
if(hideBounds) lowerEl.value = "";
|
||||
}
|
||||
if(fbPriceEl){
|
||||
fbPriceEl.style.display = showFb ? "" : "none";
|
||||
@@ -1586,6 +1592,12 @@ function syncKeyMonitorFormFields(){
|
||||
if(!showFb) fbPriceEl.value = "";
|
||||
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 keyModeSel = document.getElementById("key-sl-tp-mode");
|
||||
|
||||
@@ -65,9 +65,10 @@
|
||||
| **收敛突破** | 同上(自动开仓类)。 |
|
||||
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
|
||||
| **关键支撑位** | 同上(仅提醒)。 |
|
||||
| **触价开仓** | **不挂交易所限价**;标记价触达计划入场价后 **下一轮询市价开仓**(RR 门槛同关键位 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h**;全仓杠杆模式可用。 |
|
||||
|
||||
3. **方向**:做多 / 做空(必选)。
|
||||
4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整。
|
||||
3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
|
||||
4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**。
|
||||
|
||||
**限制:**
|
||||
活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 关键位监控说明(自动开仓 + 人工盯盘)
|
||||
|
||||
**适用:`crypto_monitor_okx`(OKX 永续)**
|
||||
箱体/收敛与 Binance、Gate 相同:**门控通过后自动市价开仓**(须 `LIVE_TRADING_ENABLED=true`)。阻力/支撑仍为微信提醒。共享逻辑见 `key_monitor_lib.py`。
|
||||
**适用:`crypto_monitor_gate`(Gate U 本位永续)**
|
||||
Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_monitor_lib.py`。
|
||||
|
||||
本文档与 `.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` |
|
||||
| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) |
|
||||
| 斐波回调 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 @@
|
||||
|
||||
---
|
||||
|
||||
## 五、相关代码
|
||||
## 六、相关代码
|
||||
|
||||
| 说明 | 位置 |
|
||||
|------|------|
|
||||
|
||||
@@ -18,20 +18,22 @@ FULL_MARGIN_BUFFER_RATIO=0.98
|
||||
| 模式 | 保证金计算 | 杠杆 | 允许入口 |
|
||||
|------|------------|------|----------|
|
||||
| `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` 决定。
|
||||
- `order_monitors.initial_stop_loss` 仍记录**开仓时**止损快照;交易记录复盘以该快照为准。
|
||||
- 已存在的 **箱体突破 / 收敛突破 / 斐波** 监控:进程启动时**自动撤销**并企业微信通知。
|
||||
- 已存在的 **箱体突破 / 收敛突破 / 斐波 / 假突破** 监控:进程启动时**自动撤销**并企业微信通知。
|
||||
|
||||
## 不允许(全仓模式)
|
||||
|
||||
- 关键位:箱体突破、收敛突破、斐波自动单(添加时拒绝;已存在则启动时撤销)。
|
||||
- 关键位:箱体突破、收敛突破、斐波、假突破(添加时拒绝;已存在则启动时撤销)。
|
||||
- 趋势回调、顺势加仓(策略入口返回明确错误)。
|
||||
|
||||
**允许:** 关键位 **触价开仓**(程序盯价、触达计划入场后市价成交,无交易所挂单;全仓下仅允许一条待触发)。
|
||||
|
||||
## 用脚本更新四所 `.env`
|
||||
|
||||
详见 **[env-sync-scripts.md](./env-sync-scripts.md)**。常用命令:
|
||||
|
||||
@@ -44,11 +44,11 @@ def calc_fib_plan(direction, upper, lower, ratio):
|
||||
|
||||
|
||||
def stored_key_signal_type(monitor_type):
|
||||
"""写入 order_monitors / trade_records 的 key_signal_type(箱体/收敛/斐波/假突破)。"""
|
||||
"""写入 order_monitors / trade_records 的 key_signal_type(箱体/收敛/斐波/假突破/触价开仓)。"""
|
||||
mt = (monitor_type or "").strip()
|
||||
if mt in FIB_KEY_MONITOR_TYPES:
|
||||
return mt
|
||||
if mt == "假突破":
|
||||
if mt in ("假突破", "触价开仓"):
|
||||
return mt
|
||||
if mt in KEY_MONITOR_AUTO_TYPES:
|
||||
return mt
|
||||
@@ -61,6 +61,7 @@ KEY_ENTRY_REASON_BY_SIGNAL = {
|
||||
"斐波回调0.618": "关键位斐波0.618",
|
||||
"斐波回调0.786": "关键位斐波0.786",
|
||||
"假突破": "关键位假突破",
|
||||
"触价开仓": "关键位触价开仓",
|
||||
"趋势回调": "趋势回调",
|
||||
}
|
||||
|
||||
@@ -76,6 +77,8 @@ def key_signal_type_for_trade_record(key_signal_type, box_auto_types):
|
||||
return kst
|
||||
if kst == "假突破":
|
||||
return kst
|
||||
if kst == "触价开仓":
|
||||
return kst
|
||||
if box_auto_types and kst in box_auto_types:
|
||||
return kst
|
||||
return None
|
||||
|
||||
@@ -310,6 +310,7 @@ def key_monitor_rule_template_context(
|
||||
key_stop_outside_breakout_pct: float,
|
||||
key_trend_stop_outside_pct: float,
|
||||
false_breakout_validity_hours: int,
|
||||
trigger_entry_validity_hours: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""关键位监控页规则说明表格(Jinja key_rule_ctx)。"""
|
||||
from false_breakout_key_monitor_lib import (
|
||||
@@ -317,6 +318,13 @@ def key_monitor_rule_template_context(
|
||||
FALSE_BREAKOUT_RR,
|
||||
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 {
|
||||
"tf": (kline_timeframe or "5m").strip(),
|
||||
@@ -335,4 +343,5 @@ def key_monitor_rule_template_context(
|
||||
"fb_sl_pct": FALSE_BREAKOUT_SL_PCT,
|
||||
"fb_rr": FALSE_BREAKOUT_RR,
|
||||
"fb_valid_hours": false_breakout_validity_hours,
|
||||
"trigger_entry_validity_hours": te_hours,
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ VALID_MODES = frozenset({MODE_RISK, MODE_FULL_MARGIN})
|
||||
OPEN_SOURCE_MANUAL = "manual"
|
||||
OPEN_SOURCE_KEY_AUTO = "key_auto"
|
||||
OPEN_SOURCE_KEY_FIB = "key_fib"
|
||||
OPEN_SOURCE_KEY_TRIGGER = "key_trigger"
|
||||
OPEN_SOURCE_TREND = "trend"
|
||||
OPEN_SOURCE_ROLL = "roll"
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -93,7 +93,7 @@
|
||||
|
||||
{% macro key_history_outcome_kind(h) -%}
|
||||
{%- 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 -%}failed
|
||||
{%- else -%}neutral
|
||||
@@ -104,11 +104,15 @@
|
||||
{%- set r = (h.close_reason or '')|trim -%}
|
||||
{%- if r == 'fib_filled' -%}斐波成交
|
||||
{%- elif r == 'false_breakout_filled' -%}假突破成交
|
||||
{%- elif r == 'trigger_entry_filled' -%}触价成交
|
||||
{%- elif r == 'key_level_alert_done' -%}提醒完成
|
||||
{%- elif r == 'alerts_complete' -%}提醒已满
|
||||
{%- elif r == 'auto_opened' -%}自动开仓
|
||||
{%- elif r == 'manual' -%}手动删除
|
||||
{%- 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 == 'fib_plan_invalid' -%}计划无效
|
||||
{%- elif r == 'rr_insufficient' -%}盈亏比不足
|
||||
@@ -133,12 +137,15 @@
|
||||
</div>
|
||||
<form id="key-form" action="/add_key" method="post" class="form-row">
|
||||
<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="斐波回调0.618">斐波回调0.618</option>
|
||||
<option value="斐波回调0.786">斐波回调0.786</option>
|
||||
<option value="假突破">假突破(BTC/ETH)</option>
|
||||
{% endif %}
|
||||
<option value="触价开仓">触价开仓</option>
|
||||
<option value="关键阻力位">关键阻力位</option>
|
||||
<option value="关键支撑位">关键支撑位</option>
|
||||
</select>
|
||||
@@ -146,6 +153,9 @@
|
||||
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||
</select>
|
||||
<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="lower" id="key-lower" step="0.0001" placeholder="下沿/支撑" required>
|
||||
<select name="sl_tp_mode" id="key-sl-tp-mode" title="止盈止损方案">
|
||||
@@ -203,7 +213,7 @@
|
||||
<div class="pos-meta">
|
||||
<span class="pos-meta-item">上沿: {{ k.upper }}</span>
|
||||
<span class="pos-meta-item">下沿: {{ k.lower }}</span>
|
||||
{% if k.fib_entry_price %}<span class="pos-meta-item">挂E: {{ k.fib_entry_price }}</span>{% endif %}
|
||||
{% if k.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 %}
|
||||
<span class="pos-meta-item">已提醒: {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }}</span>
|
||||
{% if k.monitor_type in ['箱体突破','收敛突破'] %}
|
||||
@@ -275,6 +285,7 @@ function keySummaryIsPending(snap){
|
||||
const gm = String(snap.gate_metrics || "");
|
||||
if(gm.includes("限价单") || gm.includes("挂单")) return true;
|
||||
if(/等待成交/.test(gs)) return true;
|
||||
if(/触价待触发/.test(gs)) return true;
|
||||
if(/挂E=/.test(gs) && !gs.includes("将失效")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,13 @@
|
||||
<td class="key-rule-cell">即挂限价<br>成交/过期→历史</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="key-rule-type">触价开仓</td>
|
||||
<td class="key-rule-cell">方向 + 入场 E / 止损 SL / 止盈 TP<br>可勾移动保本、时间平仓</td>
|
||||
<td class="key-rule-cell">RR >{{ r.min_rr }};做多 SL<E<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-cell">双向;填上/下沿</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
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user