diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index bdbe065..00c2a34 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -2507,6 +2507,7 @@ def insert_trade_record( exchange_trade_id=None, key_signal_type=None, entry_reason=None, + trend_plan_id=None, ): hold_minutes = calc_hold_minutes(hold_seconds) open_ts = opened_at or app_now_str() @@ -2517,12 +2518,13 @@ def insert_trade_record( snap_sl = initial_stop_loss if initial_stop_loss not in (None, "") else stop_loss er = (entry_reason or "").strip() or entry_reason_from_key_signal(kst) or "" cur = conn.execute( - "INSERT INTO trade_records (symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,entry_reason) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO trade_records (symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,entry_reason,trend_plan_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, take_profit, margin_capital, leverage, pnl_amount, hold_seconds, trade_style, risk_amount, planned_rr, actual_rr, hold_minutes, - open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id, er or None + open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id, er or None, + trend_plan_id, ) ) return int(cur.lastrowid or 0) @@ -3166,6 +3168,50 @@ def _binance_place_tp_sl_orders(exchange_symbol, direction, position_amount, sto raise RuntimeError(f"Binance 未接受止盈/止损触发单:{last_err}") +def _binance_place_stop_loss_only(exchange_symbol, direction, stop_loss): + """趋势回调:仅挂止损触发单,止盈由程序监控。""" + ensure_markets_loaded() + pos_amt = get_live_position_contracts(exchange_symbol, direction) + if pos_amt is None or float(pos_amt) <= 0: + raise RuntimeError("交易所当前无持仓,无法挂止损") + cancel_binance_futures_open_orders(exchange_symbol) + market = exchange.market(exchange_symbol) + if not market.get("swap"): + raise RuntimeError("仅支持永续合约 symbol") + close_side = "sell" if direction == "long" else "buy" + amt = float(exchange.amount_to_precision(exchange_symbol, float(pos_amt))) + sl_px = exchange.price_to_precision(exchange_symbol, float(stop_loss)) + common = dict(_binance_trigger_order_params()) + if BINANCE_POSITION_MODE == "hedge": + common["positionSide"] = "LONG" if direction == "long" else "SHORT" + exchange.create_order( + exchange_symbol, + "STOP_MARKET", + close_side, + amt, + None, + dict(common, stopPrice=sl_px), + ) + + +def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None): + try: + e = float(entry_price) + pct = float( + offset_pct + if offset_pct is not None + else float(os.getenv("TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT", "0.3")) + ) + except (TypeError, ValueError): + return None + if e <= 0: + return None + direction = (direction or "long").strip().lower() + if direction == "short": + return e * (1.0 - pct / 100.0) + return e * (1.0 + pct / 100.0) + + def ensure_markets_loaded(force=False): global MARKETS_LOADED if force or not MARKETS_LOADED: @@ -5453,6 +5499,11 @@ def background_task(): check_fib_key_monitors() check_key_monitors() check_order_monitors() + cfg = app.extensions.get("strategy_trend_cfg") + if cfg: + from strategy_trend_register import check_trend_pullback_plans + + check_trend_pullback_plans(cfg) except: pass time.sleep(MONITOR_POLL_SECONDS) @@ -5689,6 +5740,12 @@ def render_main_page(page="trade"): strategy_extra = strategy_page_template_vars( conn, page, default_risk_percent=float(RISK_PERCENT) ) + if page == "strategy_trend": + cfg = app.extensions.get("strategy_trend_cfg") + if cfg: + from strategy_trend_register import load_trend_page_context + + strategy_extra.update(load_trend_page_context(conn, request, cfg)) conn.close() return render_template( "index.html", @@ -7695,8 +7752,10 @@ def strategy_roll_page(): from strategy_register import install_strategy_trading +from strategy_trend_register import install_strategy_trend install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__]) +install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__]) # 启动 diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html index bfbe886..83f0037 100644 --- a/crypto_monitor_binance/templates/index.html +++ b/crypto_monitor_binance/templates/index.html @@ -540,7 +540,7 @@ {% elif page == 'strategy_trend' %} - {% include 'strategy_trend_disabled_panel.html' %} + {% include 'strategy_trend_panel.html' %} {% elif page == 'strategy_roll' %} {% include 'strategy_roll_panel.html' %} {% endif %} diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index aa173e5..3140f4d 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -2228,6 +2228,7 @@ def insert_trade_record( exchange_trade_id=None, key_signal_type=None, entry_reason=None, + trend_plan_id=None, ): hold_minutes = calc_hold_minutes(hold_seconds) open_ts = opened_at or app_now_str() @@ -2238,12 +2239,13 @@ def insert_trade_record( snap_sl = initial_stop_loss if initial_stop_loss not in (None, "") else stop_loss er = (entry_reason or "").strip() or entry_reason_from_key_signal(kst) or "" conn.execute( - "INSERT INTO trade_records (symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,entry_reason) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO trade_records (symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,entry_reason,trend_plan_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, take_profit, margin_capital, leverage, pnl_amount, hold_seconds, trade_style, risk_amount, planned_rr, actual_rr, hold_minutes, - open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id, er or None + open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id, er or None, + trend_plan_id, ) ) @@ -3000,6 +3002,76 @@ def _gate_place_tp_sl_orders(exchange_symbol, direction, contracts_amount, stop_ ) +def _gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss): + """Gate 永续:仅挂仓位类止损触发单(趋势回调用)。""" + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + if not market.get("swap"): + raise RuntimeError("仅支持永续合约 symbol") + settle = market["settleId"] + contract = market["id"] + order_type = "close-long-position" if direction == "long" else "close-short-position" + close_side = "sell" if direction == "long" else "buy" + sl_rule = 2 if close_side == "sell" else 1 + initial = { + "contract": contract, + "size": 0, + "price": "0", + "close": True, + "reduce_only": True, + "tif": "ioc", + "text": "api", + } + if GATE_POS_MODE == "hedge": + initial["auto_size"] = "close_long" if direction == "long" else "close_short" + initial["close"] = False + sl_s = exchange.price_to_precision(exchange_symbol, float(stop_loss)) + + def _payload(trigger_price, rule): + trig = { + "strategy_type": 0, + "price_type": GATE_TPSL_PRICE_TYPE, + "price": trigger_price, + "rule": rule, + } + if GATE_TPSL_TRIGGER_EXPIRATION > 0: + trig["expiration"] = GATE_TPSL_TRIGGER_EXPIRATION + return { + "settle": settle, + "initial": dict(initial), + "trigger": trig, + "order_type": order_type, + } + + last_err = None + for attempt in range(8): + try: + exchange.privateFuturesPostSettlePriceOrders(_payload(sl_s, sl_rule)) + return + except Exception as e: + last_err = e + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"交易所未接受仅止损仓位触发单:{last_err}") + + +def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None): + try: + e = float(entry_price) + pct = float( + offset_pct + if offset_pct is not None + else float(os.getenv("TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT", "0.3")) + ) + except (TypeError, ValueError): + return None + if e <= 0: + return None + direction = (direction or "long").strip().lower() + if direction == "short": + return e * (1.0 - pct / 100.0) + return e * (1.0 + pct / 100.0) + + def ensure_markets_loaded(force=False): global MARKETS_LOADED if force or not MARKETS_LOADED: @@ -5290,6 +5362,11 @@ def background_task(): check_fib_key_monitors() check_key_monitors() check_order_monitors() + cfg = app.extensions.get("strategy_trend_cfg") + if cfg: + from strategy_trend_register import check_trend_pullback_plans + + check_trend_pullback_plans(cfg) except: pass time.sleep(MONITOR_POLL_SECONDS) @@ -5668,6 +5745,12 @@ def render_main_page(page="trade"): strategy_extra = strategy_page_template_vars( conn, page, default_risk_percent=float(RISK_PERCENT) ) + if page == "strategy_trend": + cfg = app.extensions.get("strategy_trend_cfg") + if cfg: + from strategy_trend_register import load_trend_page_context + + strategy_extra.update(load_trend_page_context(conn, request, cfg)) conn.close() return render_template( "index.html", @@ -7756,8 +7839,10 @@ def strategy_roll_page(): from strategy_register import install_strategy_trading +from strategy_trend_register import install_strategy_trend install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__]) +install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__]) # 启动 diff --git a/crypto_monitor_gate/templates/index.html b/crypto_monitor_gate/templates/index.html index 156d5b0..6d0cd2d 100644 --- a/crypto_monitor_gate/templates/index.html +++ b/crypto_monitor_gate/templates/index.html @@ -540,7 +540,7 @@ {% elif page == 'strategy_trend' %} - {% include 'strategy_trend_disabled_panel.html' %} + {% include 'strategy_trend_panel.html' %} {% elif page == 'strategy_roll' %} {% include 'strategy_roll_panel.html' %} {% endif %} diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html index f031a06..142ff9d 100644 --- a/crypto_monitor_gate_bot/templates/index.html +++ b/crypto_monitor_gate_bot/templates/index.html @@ -371,188 +371,8 @@ {% elif page == 'strategy_trend' %} - {% include 'strategy_subnav.html' %} -
-

趋势回调策略

-
- ① 生成预览:读取合约 USDT 可用余额快照并计算计划(不下单)。预览有效期 {{ trend_pullback_preview_ttl }} 秒
- ② 确认执行:市价首仓 50% + 挂交易所止损;首仓后可手动保本(默认均价+{{ trend_manual_breakeven_offset_pct }}%);剩余 50% 在止损与补仓区间之间共 {{ trend_pullback_dca_legs }} 档(做多为上沿、做空为下沿;程序可能因最小张数自动减档)市价补仓;止盈由程序监控
- 确认执行时若当前可用余额与预览快照相对偏差 > {{ trend_preview_max_drift_pct }}% 会拒绝并要求重新预览。 -
-
- - - - - - - - -
- - - {% if trend_preview %} -
-
- 当前预览(剩余 {{ trend_pullback_preview_ttl }}s) - 倒计时加载中… -
-
- {{ trend_preview.symbol }} {{ '做多' if trend_preview.direction == 'long' else '做空' }} {{ trend_preview.leverage }}x | - 预览可用快照 {{ money_fmt(trend_preview.snapshot_available_usdt) }} U | 参考价 {{ price_fmt(trend_preview.symbol, trend_preview.live_price_ref) }} | - 计划保证金≈{{ money_fmt(trend_preview.plan_margin_capital) }} U | 总张≈{{ amt_fmt(trend_preview.symbol, trend_preview.target_order_amount) }}(首仓 {{ amt_fmt(trend_preview.symbol, trend_preview.first_order_amount) }} + 补仓 {{ amt_fmt(trend_preview.symbol, trend_preview.remainder_total) }})
- 止损 {{ price_fmt(trend_preview.symbol, trend_preview.stop_loss) }} | {{ trend_add_zone_label(trend_preview.direction) }} {{ price_fmt(trend_preview.symbol, trend_preview.add_upper) }} | 止盈 {{ price_fmt(trend_preview.symbol, trend_preview.take_profit) }} | 风险比例 {{ trend_preview.risk_percent }}% -
-
- - - {% for row in trend_preview_levels %} - - {% endfor %} -
#补仓触发价该档张数
{{ row.i }}{{ price_fmt(trend_preview.symbol, row.price) }}{{ amt_fmt(trend_preview.symbol, row.contracts) }}
-
-
-
- - -
-
- - -
-
-
- - {% elif trend_preview_expired %} -
该预览已过期(超过 {{ trend_pullback_preview_ttl }} 秒),请重新点击「生成预览」。
- {% endif %} - -
-

运行中的计划

-
- {% for t in trend_plans %} - {% set sym = t.exchange_symbol or t.symbol %} - {% set calc = namespace(rr=None, pnlpct=None) %} - {% if t.avg_entry_price is not none and t.stop_loss is not none and t.take_profit is not none %} - {% set e = t.avg_entry_price|float %} - {% set sl = t.stop_loss|float %} - {% set tp = t.take_profit|float %} - {% if t.direction == 'long' %} - {% set risk = e - sl %} - {% set reward = tp - e %} - {% else %} - {% set risk = sl - e %} - {% set reward = e - tp %} - {% endif %} - {% if risk > 0 %} - {% set calc.rr = reward / risk %} - {% endif %} - {% endif %} - {% if t.floating_pnl is not none and t.plan_margin_capital is not none and t.plan_margin_capital|float > 0 %} - {% set calc.pnlpct = (t.floating_pnl|float) / (t.plan_margin_capital|float) * 100 %} - {% endif %} -
-
-
- #{{ t.id }} {{ sym }} - {{ '做多' if t.direction == 'long' else '做空' }} -
- 结束计划 -
-
- 来源: 趋势回调计划 | 风险: {% if t.risk_percent is not none %}{{ t.risk_percent }}%{% else %}—{% endif %} - | {{ trend_add_zone_label(t.direction) }} {{ price_fmt(sym, t.add_upper) }} - | 已补仓 {{ t.legs_done }}/{{ t.dca_legs }} -
-
-
- 均价 - {% if t.avg_entry_price is not none %}{{ price_fmt(sym, t.avg_entry_price) }}{% else %}—{% endif %} -
-
- 止损 - {{ price_fmt(sym, t.stop_loss) }} -
-
- 止盈 - {{ price_fmt(sym, t.take_profit) }} -
-
- 盈亏比 - {% if calc.rr is not none %}{{ '%.2f'|format(calc.rr) }}:1{% else %}—{% endif %} -
-
- 标记价 - {% if t.floating_mark is not none %}{{ price_fmt(sym, t.floating_mark) }}{% else %}—{% endif %} -
-
- 浮盈亏 - - {% if t.floating_pnl is not none %} - {{ money_fmt(t.floating_pnl) }}U{% if calc.pnlpct is not none %} ({{ '%+.2f'|format(calc.pnlpct) }}%){% endif %} - {% else %}—{% endif %} - -
-
- -
-
- - - {% if t.breakeven_applied %}已保本 {{ (t.breakeven_applied_at or '')[:16] }}{% endif %} - {% if t.initial_stop_loss is not none and t.initial_stop_loss != t.stop_loss %}原止损 {{ price_fmt(sym, t.initial_stop_loss) }}{% endif %} -
-
-
- 快照可用: {% if t.snapshot_available_usdt is not none %}{{ money_fmt(t.snapshot_available_usdt) }}U{% else %}—{% endif %} - | 计划保证金≈{% if t.plan_margin_capital is not none %}{{ money_fmt(t.plan_margin_capital) }}U{% else %}—{% endif %} - | 总张≈{{ amt_fmt(sym, t.target_order_amount) }}(首{{ amt_fmt(sym, t.first_order_amount) }} + 补{{ amt_fmt(sym, t.remainder_total) }}) - | 杠杆: {{ t.leverage }}x -
-
- {% else %} -
暂无运行中的趋势回调计划
- {% endfor %} -
-
-
+ {% set can_trade_trend = can_trade %} + {% include 'strategy_trend_panel.html' %} {% elif page == 'strategy_roll' %} {% include 'strategy_roll_panel.html' %} {% endif %} diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index dbcf3d4..f03ac8c 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -1954,6 +1954,7 @@ def insert_trade_record( exchange_trade_id=None, key_signal_type=None, entry_reason=None, + trend_plan_id=None, ): hold_minutes = calc_hold_minutes(hold_seconds) open_ts = opened_at or app_now_str() @@ -1964,12 +1965,13 @@ def insert_trade_record( snap_sl = initial_stop_loss if initial_stop_loss not in (None, "") else stop_loss er = (entry_reason or "").strip() or entry_reason_from_key_signal(kst) or "" conn.execute( - "INSERT INTO trade_records (symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,entry_reason) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO trade_records (symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,entry_reason,trend_plan_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, take_profit, margin_capital, leverage, pnl_amount, hold_seconds, trade_style, risk_amount, planned_rr, actual_rr, hold_minutes, - open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id, er or None + open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id, er or None, + trend_plan_id, ) ) @@ -2462,6 +2464,41 @@ def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): _okx_place_tp_sl_orders(ex_sym, direction, float(pos_amt), float(stop_loss), float(take_profit)) +def _okx_place_stop_loss_only(exchange_symbol, direction, stop_loss): + """OKX 永续:仅挂止损(趋势回调),止盈由程序监控。""" + ensure_markets_loaded() + pos_amt = get_live_position_contracts(exchange_symbol, direction) + if pos_amt is None or float(pos_amt) <= 0: + raise RuntimeError("交易所当前无持仓,无法挂止损") + cancel_okx_swap_open_orders(exchange_symbol) + close_side = "sell" if direction == "long" else "buy" + amt = float(exchange.amount_to_precision(exchange_symbol, float(pos_amt))) + params = build_okx_order_params(direction, reduce_only=True) + params["stopLoss"] = { + "triggerPrice": _okx_algo_trigger_price_str(exchange_symbol, stop_loss), + "type": "market", + } + exchange.create_order(exchange_symbol, "market", close_side, amt, None, params) + + +def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None): + try: + e = float(entry_price) + pct = float( + offset_pct + if offset_pct is not None + else float(os.getenv("TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT", "0.3")) + ) + except (TypeError, ValueError): + return None + if e <= 0: + return None + direction = (direction or "long").strip().lower() + if direction == "short": + return e * (1.0 - pct / 100.0) + return e * (1.0 + pct / 100.0) + + def extract_trade_price_from_order(order): if not order: return None @@ -4069,6 +4106,11 @@ def background_task(): check_fib_key_monitors() check_key_monitors() check_order_monitors() + cfg = app.extensions.get("strategy_trend_cfg") + if cfg: + from strategy_trend_register import check_trend_pullback_plans + + check_trend_pullback_plans(cfg) except: pass time.sleep(MONITOR_POLL_SECONDS) @@ -4199,6 +4241,12 @@ def render_main_page(page="trade"): strategy_extra = strategy_page_template_vars( conn, page, default_risk_percent=float(RISK_PERCENT) ) + if page == "strategy_trend": + cfg = app.extensions.get("strategy_trend_cfg") + if cfg: + from strategy_trend_register import load_trend_page_context + + strategy_extra.update(load_trend_page_context(conn, request, cfg)) conn.close() return render_template( "index.html", @@ -5971,8 +6019,10 @@ def strategy_roll_page(): from strategy_register import install_strategy_trading +from strategy_trend_register import install_strategy_trend install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__]) +install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__]) # 启动 diff --git a/crypto_monitor_okx/templates/index.html b/crypto_monitor_okx/templates/index.html index 343eeaf..bd7f0ee 100644 --- a/crypto_monitor_okx/templates/index.html +++ b/crypto_monitor_okx/templates/index.html @@ -353,7 +353,7 @@ {% elif page == 'strategy_trend' %} - {% include 'strategy_trend_disabled_panel.html' %} + {% include 'strategy_trend_panel.html' %} {% elif page == 'strategy_roll' %} {% include 'strategy_roll_panel.html' %} {% endif %} diff --git a/strategy_db.py b/strategy_db.py index 2880302..eabde2f 100644 --- a/strategy_db.py +++ b/strategy_db.py @@ -37,7 +37,118 @@ CREATE TABLE IF NOT EXISTS roll_legs ( ) """ +TREND_PLANS_SQL = """ +CREATE TABLE IF NOT EXISTS trend_pullback_plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + status TEXT DEFAULT 'active', + symbol TEXT NOT NULL, + exchange_symbol TEXT, + direction TEXT NOT NULL DEFAULT 'long', + leverage INTEGER NOT NULL, + stop_loss REAL NOT NULL, + add_upper REAL NOT NULL, + take_profit REAL NOT NULL, + risk_percent REAL DEFAULT 5, + snapshot_available_usdt REAL, + snapshot_at TEXT, + plan_margin_capital REAL, + target_order_amount REAL, + first_order_amount REAL, + remainder_total REAL, + dca_legs INTEGER DEFAULT 5, + per_leg_amount REAL, + grid_prices_json TEXT, + leg_amounts_json TEXT, + legs_done INTEGER DEFAULT 0, + first_order_done INTEGER DEFAULT 0, + last_mark_price REAL, + avg_entry_price REAL, + order_amount_open REAL, + opened_at TEXT, + opened_at_ms INTEGER, + session_date TEXT, + message TEXT, + initial_stop_loss REAL, + breakeven_applied INTEGER DEFAULT 0, + breakeven_applied_at TEXT +) +""" + +TREND_PREVIEWS_SQL = """ +CREATE TABLE IF NOT EXISTS trend_pullback_previews ( + id TEXT PRIMARY KEY, + symbol TEXT NOT NULL, + exchange_symbol TEXT NOT NULL, + direction TEXT NOT NULL, + leverage INTEGER NOT NULL, + stop_loss REAL NOT NULL, + add_upper REAL NOT NULL, + take_profit REAL NOT NULL, + risk_percent REAL NOT NULL, + snapshot_available_usdt REAL NOT NULL, + snapshot_at TEXT, + live_price_ref REAL, + plan_margin_capital REAL, + target_order_amount REAL, + first_order_amount REAL, + remainder_total REAL, + dca_legs INTEGER, + per_leg_amount REAL, + grid_prices_json TEXT, + leg_amounts_json TEXT, + expires_at_ms INTEGER NOT NULL, + created_at TEXT +) +""" + +TREND_PREVIEW_SNAPSHOTS_SQL = """ +CREATE TABLE IF NOT EXISTS trend_pullback_preview_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + preview_id TEXT NOT NULL UNIQUE, + symbol TEXT NOT NULL, + exchange_symbol TEXT NOT NULL, + direction TEXT NOT NULL, + leverage INTEGER NOT NULL, + stop_loss REAL NOT NULL, + add_upper REAL NOT NULL, + take_profit REAL NOT NULL, + risk_percent REAL NOT NULL, + snapshot_available_usdt REAL NOT NULL, + snapshot_at TEXT, + live_price_ref REAL, + plan_margin_capital REAL, + target_order_amount REAL, + first_order_amount REAL, + remainder_total REAL, + dca_legs INTEGER, + per_leg_amount REAL, + grid_prices_json TEXT, + leg_amounts_json TEXT, + expires_at_ms INTEGER NOT NULL, + preview_created_at TEXT, + outcome TEXT DEFAULT 'open', + executed_plan_id INTEGER +) +""" + def init_strategy_tables(conn) -> None: conn.execute(ROLL_GROUPS_SQL) conn.execute(ROLL_LEGS_SQL) + conn.execute(TREND_PLANS_SQL) + conn.execute(TREND_PREVIEWS_SQL) + conn.execute(TREND_PREVIEW_SNAPSHOTS_SQL) + for ddl in ( + "ALTER TABLE trend_pullback_plans ADD COLUMN leg_amounts_json TEXT", + "ALTER TABLE trend_pullback_plans ADD COLUMN initial_stop_loss REAL", + "ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied INTEGER DEFAULT 0", + "ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied_at TEXT", + "ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN preview_created_at TEXT", + "ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN outcome TEXT DEFAULT 'open'", + "ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN executed_plan_id INTEGER", + "ALTER TABLE trade_records ADD COLUMN trend_plan_id INTEGER", + ): + try: + conn.execute(ddl) + except Exception: + pass diff --git a/strategy_templates/strategy_trend_panel.html b/strategy_templates/strategy_trend_panel.html new file mode 100644 index 0000000..c721b00 --- /dev/null +++ b/strategy_templates/strategy_trend_panel.html @@ -0,0 +1,180 @@ +{% set mf = money_fmt|default(funds_fmt) %} +{% macro amt_disp(sym, val) %}{% if amt_fmt is defined %}{{ amt_fmt(sym, val) }}{% else %}{{ val }}{% endif %}{% endmacro %} +{% include 'strategy_subnav.html' %} +
+

趋势回调策略

+
+ ① 生成预览:读取合约 USDT 可用余额快照并计算计划(不下单)。预览有效期 {{ trend_pullback_preview_ttl }} 秒
+ ② 确认执行:市价首仓 50% + 挂交易所止损;首仓后可手动保本(默认均价+{{ trend_manual_breakeven_offset_pct }}%);剩余 50% 在止损与补仓区间之间共 {{ trend_pullback_dca_legs }} 档(做多为上沿、做空为下沿;程序可能因最小张数自动减档)市价补仓;止盈由程序监控
+ 确认执行时若当前可用余额与预览快照相对偏差 > {{ trend_preview_max_drift_pct }}% 会拒绝并要求重新预览。 +
+
+ + + + + + + + +
+ + + {% if trend_preview %} +
+
+ 当前预览(剩余 {{ trend_pullback_preview_ttl }}s) + 倒计时加载中… +
+
+ {{ trend_preview.symbol }} {{ '做多' if trend_preview.direction == 'long' else '做空' }} {{ trend_preview.leverage }}x | + 预览可用快照 {{ mf(trend_preview.snapshot_available_usdt) }} U | 参考价 {{ price_fmt(trend_preview.symbol, trend_preview.live_price_ref) }} | + 计划保证金≈{{ mf(trend_preview.plan_margin_capital) }} U | 总张≈{{ amt_disp(trend_preview.symbol, trend_preview.target_order_amount) }}(首仓 {{ amt_disp(trend_preview.symbol, trend_preview.first_order_amount) }} + 补仓 {{ amt_disp(trend_preview.symbol, trend_preview.remainder_total) }})
+ 止损 {{ price_fmt(trend_preview.symbol, trend_preview.stop_loss) }} | {{ trend_add_zone_label(trend_preview.direction) }} {{ price_fmt(trend_preview.symbol, trend_preview.add_upper) }} | 止盈 {{ price_fmt(trend_preview.symbol, trend_preview.take_profit) }} | 风险比例 {{ trend_preview.risk_percent }}% +
+
+ + + {% for row in trend_preview_levels %} + + {% endfor %} +
#补仓触发价该档张数
{{ row.i }}{{ price_fmt(trend_preview.symbol, row.price) }}{{ amt_disp(trend_preview.symbol, row.contracts) }}
+
+
+
+ + +
+
+ + +
+
+
+ + {% elif trend_preview_expired %} +
该预览已过期(超过 {{ trend_pullback_preview_ttl }} 秒),请重新点击「生成预览」。
+ {% endif %} + +
+

运行中的计划

+
+ {% for t in trend_plans %} + {% set sym = t.exchange_symbol or t.symbol %} + {% set calc = namespace(rr=None, pnlpct=None) %} + {% if t.avg_entry_price is not none and t.stop_loss is not none and t.take_profit is not none %} + {% set e = t.avg_entry_price|float %} + {% set sl = t.stop_loss|float %} + {% set tp = t.take_profit|float %} + {% if t.direction == 'long' %} + {% set risk = e - sl %} + {% set reward = tp - e %} + {% else %} + {% set risk = sl - e %} + {% set reward = e - tp %} + {% endif %} + {% if risk > 0 %} + {% set calc.rr = reward / risk %} + {% endif %} + {% endif %} + {% if t.floating_pnl is not none and t.plan_margin_capital is not none and t.plan_margin_capital|float > 0 %} + {% set calc.pnlpct = (t.floating_pnl|float) / (t.plan_margin_capital|float) * 100 %} + {% endif %} +
+
+
+ #{{ t.id }} {{ sym }} + {{ '做多' if t.direction == 'long' else '做空' }} +
+ 结束计划 +
+
+ 来源: 趋势回调计划 | 风险: {% if t.risk_percent is not none %}{{ t.risk_percent }}%{% else %}—{% endif %} + | {{ trend_add_zone_label(t.direction) }} {{ price_fmt(sym, t.add_upper) }} + | 已补仓 {{ t.legs_done }}/{{ t.dca_legs }} +
+
+
+ 均价 + {% if t.avg_entry_price is not none %}{{ price_fmt(sym, t.avg_entry_price) }}{% else %}—{% endif %} +
+
+ 止损 + {{ price_fmt(sym, t.stop_loss) }} +
+
+ 止盈 + {{ price_fmt(sym, t.take_profit) }} +
+
+ 盈亏比 + {% if calc.rr is not none %}{{ '%.2f'|format(calc.rr) }}:1{% else %}—{% endif %} +
+
+ 标记价 + {% if t.floating_mark is not none %}{{ price_fmt(sym, t.floating_mark) }}{% else %}—{% endif %} +
+
+ 浮盈亏 + + {% if t.floating_pnl is not none %} + {{ mf(t.floating_pnl) }}U{% if calc.pnlpct is not none %} ({{ '%+.2f'|format(calc.pnlpct) }}%){% endif %} + {% else %}—{% endif %} + +
+
+
+
+ + + {% if t.breakeven_applied %}已保本 {{ (t.breakeven_applied_at or '')[:16] }}{% endif %} +
+
+
+ 快照可用: {% if t.snapshot_available_usdt is not none %}{{ mf(t.snapshot_available_usdt) }}U{% else %}—{% endif %} + | 计划保证金≈{% if t.plan_margin_capital is not none %}{{ mf(t.plan_margin_capital) }}U{% else %}—{% endif %} + | 杠杆: {{ t.leverage }}x +
+
+ {% else %} +
暂无运行中的趋势回调计划
+ {% endfor %} +
+
+
diff --git a/strategy_trend_exchange.py b/strategy_trend_exchange.py new file mode 100644 index 0000000..4eac0f4 --- /dev/null +++ b/strategy_trend_exchange.py @@ -0,0 +1,87 @@ +"""趋势回调:各交易所止损刷新、市价加/平仓(通过 app 模块能力探测)。""" +from __future__ import annotations + +import time +from typing import Any + + +def _m(cfg: dict) -> Any: + return cfg["app_module"] + + +def trend_refresh_stop_only(cfg: dict, exchange_symbol: str, direction: str, stop_loss: float) -> None: + m = _m(cfg) + if hasattr(m, "_gate_place_stop_loss_only_position"): + if hasattr(m, "cancel_gate_swap_trigger_orders"): + m.cancel_gate_swap_trigger_orders(exchange_symbol) + m._gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss) + return + if hasattr(m, "_binance_place_stop_loss_only"): + m._binance_place_stop_loss_only(exchange_symbol, direction, stop_loss) + return + if hasattr(m, "_okx_place_stop_loss_only"): + m._okx_place_stop_loss_only(exchange_symbol, direction, stop_loss) + return + raise RuntimeError("当前实例未配置趋势回调止损挂单能力") + + +def trend_market_add(cfg: dict, exchange_symbol: str, direction: str, contracts: float, leverage: int): + m = _m(cfg) + ex = m.exchange + m.ensure_markets_loaded() + ex.set_leverage(int(leverage), exchange_symbol) + side = "buy" if direction == "long" else "sell" + if hasattr(m, "build_gate_order_params"): + params = m.build_gate_order_params(direction, reduce_only=False) + elif hasattr(m, "build_binance_order_params"): + params = m.build_binance_order_params(direction, reduce_only=False) + elif hasattr(m, "build_okx_order_params"): + params = m.build_okx_order_params(direction, reduce_only=False) + else: + params = {} + return ex.create_order(exchange_symbol, "market", side, float(contracts), None, params or None) + + +def trend_market_close(cfg: dict, exchange_symbol: str, direction: str, pos_qty: float, leverage: int): + m = _m(cfg) + ex = m.exchange + m.ensure_markets_loaded() + ex.set_leverage(int(leverage), exchange_symbol) + side = "sell" if direction == "long" else "buy" + amt = float(ex.amount_to_precision(exchange_symbol, float(pos_qty))) + if hasattr(m, "close_exchange_order"): + row = { + "exchange_symbol": exchange_symbol, + "symbol": exchange_symbol, + "direction": direction, + "order_amount": amt, + } + return m.close_exchange_order(row) + if hasattr(m, "build_gate_order_params"): + params = m.build_gate_order_params(direction, reduce_only=True) + return ex.create_order(exchange_symbol, "market", side, amt, None, params) + if hasattr(m, "build_binance_order_params"): + for params in m._binance_market_close_param_candidates(direction): + try: + return ex.create_order(exchange_symbol, "market", side, amt, None, params) + except Exception as e: + if not m._is_binance_close_param_retryable(str(e)): + raise + raise RuntimeError("平仓失败") + if hasattr(m, "build_okx_order_params"): + params = m.build_okx_order_params(direction, reduce_only=True) + return ex.create_order(exchange_symbol, "market", side, amt, None, params) + return ex.create_order(exchange_symbol, "market", side, amt, None, {"reduceOnly": True}) + + +def cancel_symbol_orders(cfg: dict, exchange_symbol: str) -> None: + m = _m(cfg) + if hasattr(m, "cancel_all_open_orders_for_symbol"): + m.cancel_all_open_orders_for_symbol(exchange_symbol) + return + if hasattr(m, "cancel_gate_swap_trigger_orders"): + m.cancel_gate_swap_trigger_orders(exchange_symbol) + if hasattr(m, "cancel_binance_futures_open_orders"): + m.cancel_binance_futures_open_orders(exchange_symbol) + if hasattr(m, "cancel_okx_swap_open_orders"): + m.cancel_okx_swap_open_orders(exchange_symbol) diff --git a/strategy_trend_register.py b/strategy_trend_register.py new file mode 100644 index 0000000..f26af65 --- /dev/null +++ b/strategy_trend_register.py @@ -0,0 +1,833 @@ +"""趋势回调:路由、轮询、页面数据(四所共用,依赖各 app 模块交易所能力)。""" +from __future__ import annotations + +import inspect +import json +import os +import time +import uuid +from typing import Any, Optional + +from flask import Flask, flash, redirect, request, url_for +from jinja2 import ChoiceLoader, FileSystemLoader + +from strategy_config import resolve_trading_app_module +from strategy_db import init_strategy_tables +from strategy_trend_exchange import ( + cancel_symbol_orders, + trend_market_add, + trend_market_close, + trend_refresh_stop_only, +) +from strategy_trend_lib import ( + build_grid_prices, + build_leg_amounts_json, + calc_risk_fraction, + validate_trend_bounds, +) + +MONITOR_TYPE_TREND = "趋势回调" + + +def trend_add_zone_label(direction: str) -> str: + return "补仓下沿" if (direction or "long").strip().lower() == "short" else "补仓上沿" + + +def install_strategy_trend(app: Flask, repo_root: str, app_module: Any = None, **build_kw) -> dict: + from strategy_register import attach_strategy_templates + + attach_strategy_templates(app, repo_root) + cfg = build_trend_config(app_module, **build_kw) + app.extensions["strategy_trend_cfg"] = cfg + register_trend_routes(app, cfg) + + @app.context_processor + def _trend_ctx(): + return {"trend_add_zone_label": trend_add_zone_label} + + return cfg + + +def build_trend_config(app_module: Any = None, **kw) -> dict[str, Any]: + m = resolve_trading_app_module(app_module) + dca = max(1, int(os.getenv("TREND_PULLBACK_DCA_LEGS", kw.get("dca_legs", "5")))) + preview_ttl = max(10, int(os.getenv("TREND_PULLBACK_PREVIEW_TTL_SECONDS", "120"))) + drift = float(os.getenv("TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT", "5")) + be_pct = float(os.getenv("TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT", "0.3")) + buf = float(getattr(m, "FULL_MARGIN_BUFFER_RATIO", 0.95)) + + def amount_precise(ex_sym, amt): + fn = getattr(m, "_safe_amount_to_precision", None) + if callable(fn): + return fn(ex_sym, amt) + try: + m.ensure_markets_loaded() + return float(m.exchange.amount_to_precision(ex_sym, float(amt))) + except Exception: + return None + + return { + "app_module": m, + "login_required": m.login_required, + "get_db": m.get_db, + "row_to_dict": m.row_to_dict, + "dca_legs": dca, + "preview_ttl": preview_ttl, + "drift_pct": drift, + "breakeven_offset_pct": be_pct, + "margin_buffer": buf, + "amount_precise": amount_precise, + "max_active_positions": int(getattr(m, "MAX_ACTIVE_POSITIONS", 1)), + "reset_hour": int(getattr(m, "TRADING_DAY_RESET_HOUR", 8)), + "monitor_type_trend": MONITOR_TYPE_TREND, + } + + +def _m(cfg: dict): + return cfg["app_module"] + + +def _row(cfg, row) -> dict: + return cfg["row_to_dict"](row) + + +def precheck_trend_start(cfg: dict, conn) -> tuple[bool, str]: + m = _m(cfg) + now = m.app_now() + if not m.trading_day_reset_allows_new_open(now): + return False, f"北京时间 {cfg['reset_hour']}:00 前不允许持仓" + active = m.get_active_position_count(conn) + if active >= cfg["max_active_positions"]: + return ( + False, + f"已达最大持仓数({active}/{cfg['max_active_positions']})," + "请先结束「实盘下单」中的持仓,再启动趋势回调", + ) + trend_n = conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" + ).fetchone()[0] + if int(trend_n or 0) > 0: + return False, "已存在运行中的趋势回调计划" + return True, "" + + +def _cleanup_stale_previews(conn) -> None: + ms = int(time.time() * 1000) + stale = conn.execute( + "SELECT id FROM trend_pullback_previews WHERE expires_at_ms < ?", (ms,) + ).fetchall() + for row in stale: + try: + conn.execute( + "UPDATE trend_pullback_preview_snapshots SET outcome='expired' " + "WHERE preview_id=? AND outcome='open'", + (row["id"],), + ) + except Exception: + pass + conn.execute("DELETE FROM trend_pullback_previews WHERE expires_at_ms < ?", (ms,)) + + +def parse_trend_plan(cfg: dict, form_dict) -> tuple[Optional[dict], Optional[str]]: + m = _m(cfg) + d = form_dict or {} + symbol = m.normalize_symbol_input(d.get("symbol")) + if not symbol: + return None, "symbol 不能为空" + direction = (d.get("direction") or "long").strip().lower() + if direction not in ("long", "short"): + return None, "方向错误" + try: + stop_loss = float(d.get("sl")) + add_upper = float(d.get("add_upper")) + take_profit = float(d.get("take_profit")) + risk_percent = float(d.get("risk_percent") or "5") + except Exception: + return None, "价格或风险比例格式错误" + try: + lev_raw = m.parse_positive_float(d.get("leverage")) + leverage = int(lev_raw) if lev_raw is not None else m.infer_leverage(symbol) + except Exception: + return None, "杠杆格式错误" + if leverage <= 0 or risk_percent <= 0: + return None, "杠杆与风险比例必须大于0" + bound_err = validate_trend_bounds(direction, stop_loss, add_upper) + if bound_err: + return None, bound_err + snap = m.get_available_trading_usdt() + if snap is None or snap <= 0: + return None, "无法读取合约账户 USDT 可用余额,请检查 API 与账户类型" + live_price = m.get_price(symbol) + if live_price is None: + return None, "获取实时价格失败" + exchange_symbol = m.normalize_exchange_symbol(symbol) + rf = calc_risk_fraction(direction, add_upper, stop_loss) + if rf is None or rf <= 0: + return None, "止损与补仓区间边界组合无法计算风险比例" + risk_budget = float(snap) * (risk_percent / 100.0) + notional = risk_budget / rf + margin_plan = notional / float(leverage) + margin_plan = min(margin_plan, float(snap) * cfg["margin_buffer"]) + if margin_plan <= 0: + return None, "计划保证金过小" + try: + target_amt, _ = m.prepare_order_amount(exchange_symbol, margin_plan, leverage, live_price) + except Exception as e: + return None, str(e) + ap = cfg["amount_precise"] + first_amt = ap(exchange_symbol, float(target_amt) * 0.5) + if first_amt is None or first_amt <= 0: + return None, "首仓张数过小(低于交易所最小张数),请提高风险比例或杠杆" + remainder_total = ap(exchange_symbol, max(0.0, float(target_amt) - float(first_amt))) + if remainder_total is None: + remainder_total = 0.0 + m.ensure_markets_loaded() + market = m.exchange.market(exchange_symbol) + min_amt = float((market.get("limits", {}).get("amount", {}) or {}).get("min") or 0) + n_legs, leg_json, per_ref = build_leg_amounts_json( + exchange_symbol, remainder_total, cfg["dca_legs"], ap, min_amt + ) + if n_legs <= 0: + return None, "剩余计划张数不足以拆出补仓档,请提高风险比例或放宽止损与补仓区间间距" + grid = build_grid_prices(direction, stop_loss, add_upper, n_legs) + if len(grid) != n_legs: + return None, "补仓网格生成失败" + opened_at = m.app_now_str() + try: + leg_list = json.loads(leg_json) + except Exception: + leg_list = [] + return { + "symbol": symbol, + "exchange_symbol": exchange_symbol, + "direction": direction, + "leverage": leverage, + "stop_loss": stop_loss, + "add_upper": add_upper, + "take_profit": take_profit, + "risk_percent": risk_percent, + "snapshot_available_usdt": float(snap), + "snapshot_at": opened_at, + "live_price_ref": float(live_price), + "plan_margin_capital": float(margin_plan), + "target_order_amount": float(target_amt), + "first_order_amount": float(first_amt), + "remainder_total": float(remainder_total), + "dca_legs": int(n_legs), + "per_leg_amount": float(per_ref), + "grid_prices_json": json.dumps(grid), + "leg_amounts_json": leg_json, + "grid": grid, + "leg_amounts": leg_list, + }, None + + +def _insert_preview_snapshot(conn, preview_id: str, created: str, exp_ms: int, pl: dict) -> None: + conn.execute( + """INSERT INTO trend_pullback_preview_snapshots ( + preview_id,symbol,exchange_symbol,direction,leverage,stop_loss,add_upper,take_profit,risk_percent, + snapshot_available_usdt,snapshot_at,live_price_ref,plan_margin_capital,target_order_amount,first_order_amount,remainder_total, + dca_legs,per_leg_amount,grid_prices_json,leg_amounts_json,expires_at_ms,preview_created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + preview_id, + pl["symbol"], + pl["exchange_symbol"], + pl["direction"], + pl["leverage"], + pl["stop_loss"], + pl["add_upper"], + pl["take_profit"], + pl["risk_percent"], + pl["snapshot_available_usdt"], + pl["snapshot_at"], + pl["live_price_ref"], + pl["plan_margin_capital"], + pl["target_order_amount"], + pl["first_order_amount"], + pl["remainder_total"], + pl["dca_legs"], + pl["per_leg_amount"], + pl["grid_prices_json"], + pl["leg_amounts_json"], + exp_ms, + created, + ), + ) + + +def enrich_trend_plan(cfg: dict, row) -> dict: + m = _m(cfg) + d = _row(cfg, row) + try: + d["breakeven_applied"] = int(d.get("breakeven_applied") or 0) != 0 + except Exception: + d["breakeven_applied"] = False + ex_sym = d.get("exchange_symbol") or m.normalize_exchange_symbol(d.get("symbol") or "") + direction = (d.get("direction") or "long").lower() + metrics_fn = getattr(m, "get_live_position_exchange_metrics", None) + if callable(metrics_fn): + met = metrics_fn(ex_sym, direction) + if met and met.get("unrealized_pnl") is not None: + d["floating_pnl"] = float(met["unrealized_pnl"]) + else: + d["floating_pnl"] = None + if met and met.get("mark_price") is not None: + d["floating_mark"] = float(met["mark_price"]) + else: + d["floating_mark"] = None + else: + d["floating_pnl"] = d["floating_mark"] = None + return d + + +def _weighted_avg(old_avg, old_amt, fill_px, add_amt): + try: + oa, aa = float(old_amt), float(add_amt) + if oa <= 0: + return float(fill_px) + return (float(old_avg) * oa + float(fill_px) * aa) / (oa + aa) + except Exception: + return float(fill_px or 0) + + +def _finalize_plan(cfg: dict, conn, row, result_label: str, exit_price: float) -> None: + m = _m(cfg) + sym = row["symbol"] + direction = row["direction"] or "long" + ex_sym = row["exchange_symbol"] or m.normalize_exchange_symbol(sym) + closed_at = m.app_now_str() + opened_at = row["opened_at"] or closed_at + hold_seconds = m.calc_hold_seconds(opened_at, m.parse_dt_for_trading_day(closed_at) or m.app_now()) + margin_cap = float(row["plan_margin_capital"] or 0) + lev = int(row["leverage"] or 1) + avg_e = float(row["avg_entry_price"] or 0) + pnl_amount = m.calc_pnl(direction, avg_e, float(exit_price), margin_cap, lev) + res = m.normalize_result_with_pnl(result_label, pnl_amount) + risk_amt = m.calc_risk_amount_from_plan( + direction, float(row["add_upper"]), float(row["stop_loss"]), margin_cap, lev + ) + planned_rr = m.calc_rr_ratio(direction, avg_e, float(row["stop_loss"]), float(row["take_profit"])) + session_date = row["session_date"] or m.get_trading_day() + session_capital = m.update_session_capital(conn, session_date, pnl_amount) + try: + cancel_symbol_orders(cfg, ex_sym) + except Exception: + pass + extra = getattr(m, "build_wechat_close_message", None) + send = getattr(m, "send_wechat_msg", None) + if callable(extra) and callable(send): + send( + extra( + symbol=sym, + direction=direction, + result=f"{res}({MONITOR_TYPE_TREND})", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=avg_e, + current_price=float(exit_price), + stop_loss=float(row["stop_loss"]), + take_profit=float(row["take_profit"]), + close_order_id="-", + extra_note="计划本金口径:启动时合约可用余额快照;止盈由程序监控", + session_capital_fallback=session_capital, + ) + ) + kwargs = dict( + conn=conn, + symbol=sym, + monitor_type=MONITOR_TYPE_TREND, + direction=direction, + trigger_price=avg_e, + stop_loss=float(row["stop_loss"]), + initial_stop_loss=float(row.get("initial_stop_loss") or row["stop_loss"]), + take_profit=float(row["take_profit"]), + margin_capital=margin_cap, + leverage=lev, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style="trend_pullback", + risk_amount=risk_amt, + planned_rr=planned_rr, + actual_rr=m.calc_actual_rr(pnl_amount, risk_amt), + result=res, + opened_at=opened_at, + closed_at=closed_at, + ) + if "trend_plan_id" in inspect.signature(m.insert_trade_record).parameters: + m.insert_trade_record(**kwargs, trend_plan_id=int(row["id"])) + else: + m.insert_trade_record(**kwargs) + st = ( + "stopped_tp" + if result_label == "止盈" + else ("stopped_sl" if result_label == "止损" else "stopped_manual") + ) + conn.execute( + "UPDATE trend_pullback_plans SET status=?, message=? WHERE id=?", + (st, res, row["id"]), + ) + + +def check_trend_pullback_plans(cfg: dict) -> None: + m = _m(cfg) + ok_live, _ = m.ensure_exchange_live_ready() + if not ok_live: + return + conn = cfg["get_db"]() + rows = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active'" + ).fetchall() + for row in rows: + try: + sym = row["symbol"] + direction = (row["direction"] or "long").lower() + ex_sym = row["exchange_symbol"] or m.normalize_exchange_symbol(sym) + sl = float(row["stop_loss"]) + tp = float(row["take_profit"]) + lev = int(row["leverage"] or 1) + p = m.get_price(sym) + if not p: + continue + pf = float(p) + last_p = row["last_mark_price"] + last_pf = float(last_p) if last_p is not None else pf + pos = m.get_live_position_contracts(ex_sym, direction) + if pos is None: + continue + legs_done = int(row["legs_done"] or 0) + try: + leg_amounts = [float(x) for x in json.loads(row["leg_amounts_json"] or "[]")] + except Exception: + leg_amounts = [] + try: + grid = json.loads(row["grid_prices_json"] or "[]") + except Exception: + grid = [] + hit_tp = (direction == "long" and pf >= tp) or (direction == "short" and pf <= tp) + if hit_tp and pos > 0: + try: + close_resp = trend_market_close(cfg, ex_sym, direction, float(pos), lev) + exit_p = m.extract_trade_price_from_order(close_resp) or pf + except Exception as e: + if not m.is_no_position_error(str(e)): + continue + exit_p = pf + _finalize_plan(cfg, conn, row, "止盈", exit_p) + continue + if pos <= 0 and int(row["first_order_done"] or 0): + _finalize_plan(cfg, conn, row, "止损", pf) + continue + if int(row["first_order_done"] or 0) and legs_done < len(grid) and legs_done < len(leg_amounts): + level = float(grid[legs_done]) + fired = False + if direction == "long": + fired = last_pf > level and pf <= level + else: + fired = last_pf < level and pf >= level + if fired: + amt = float(m.exchange.amount_to_precision(ex_sym, leg_amounts[legs_done])) + if amt > 0: + add_resp = trend_market_add(cfg, ex_sym, direction, amt, lev) + fill_px = m.extract_trade_price_from_order(add_resp) or pf + old_avg = float(row["avg_entry_price"] or fill_px) + old_open = float(row["order_amount_open"] or 0) + new_avg = _weighted_avg(old_avg, old_open, fill_px, amt) + conn.execute( + "UPDATE trend_pullback_plans SET legs_done=?, avg_entry_price=?, " + "order_amount_open=?, last_mark_price=? WHERE id=?", + (legs_done + 1, new_avg, old_open + amt, pf, row["id"]), + ) + row = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE id=?", (row["id"],) + ).fetchone() + try: + trend_refresh_stop_only(cfg, ex_sym, direction, sl) + except Exception: + pass + conn.execute( + "UPDATE trend_pullback_plans SET last_mark_price=? WHERE id=?", + (pf, row["id"]), + ) + except Exception: + continue + conn.commit() + conn.close() + + +def apply_manual_breakeven(cfg: dict, conn, row, offset_pct=None) -> tuple[bool, Optional[str]]: + m = _m(cfg) + if (row["status"] or "").strip() != "active": + return False, "计划已结束" + if not int(row["first_order_done"] or 0): + return False, "尚未完成首仓,无法保本" + avg_e = float(row["avg_entry_price"] or 0) + if avg_e <= 0: + return False, "缺少有效持仓均价" + direction = (row["direction"] or "long").lower() + ex_sym = row["exchange_symbol"] or m.normalize_exchange_symbol(row["symbol"]) + pos = m.get_live_position_contracts(ex_sym, direction) + if pos is None or float(pos) <= 0: + return False, "交易所当前无该方向持仓" + be_fn = getattr(m, "calc_trend_manual_breakeven_stop", None) + if not callable(be_fn): + pct = float(offset_pct if offset_pct is not None else cfg["breakeven_offset_pct"]) + if direction == "short": + new_sl_raw = avg_e * (1.0 - pct / 100.0) + else: + new_sl_raw = avg_e * (1.0 + pct / 100.0) + else: + new_sl_raw = be_fn(direction, avg_e, offset_pct) + if new_sl_raw is None: + return False, "保本价计算失败" + new_sl = m.round_price_to_exchange(ex_sym, new_sl_raw) + if new_sl is None: + return False, "保本价经交易所精度舍入后无效" + new_sl = float(new_sl) + cur_sl = float(row["stop_loss"] or 0) + if direction == "long": + if new_sl <= cur_sl: + return False, f"新止损 {new_sl} 未高于当前止损 {cur_sl}(多仓需上移)" + else: + if new_sl >= cur_sl: + return False, f"新止损 {new_sl} 未低于当前止损 {cur_sl}(空仓需下移)" + try: + trend_refresh_stop_only(cfg, ex_sym, direction, new_sl) + except Exception as e: + fe = getattr(m, "friendly_exchange_error", None) + return False, fe(e) if callable(fe) else str(e) + conn.execute( + "UPDATE trend_pullback_plans SET stop_loss=?, breakeven_applied=1, breakeven_applied_at=? WHERE id=?", + (new_sl, m.app_now_str(), row["id"]), + ) + return True, None + + +def load_trend_page_context(conn, request_obj, cfg: dict) -> dict[str, Any]: + m = _m(cfg) + _cleanup_stale_previews(conn) + trend_active = int( + conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" + ).fetchone()[0] + or 0 + ) + trend_plans = [] + for r in conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC" + ).fetchall(): + try: + trend_plans.append(enrich_trend_plan(cfg, r)) + except Exception: + trend_plans.append(_row(cfg, r)) + now = m.app_now() + active_count = m.get_active_position_count(conn) + can_trade_trend = ( + m.trading_day_reset_allows_new_open(now) + and active_count < cfg["max_active_positions"] + and trend_active == 0 + ) + trend_preview = None + trend_preview_levels = [] + preview_expires_ms = None + trend_preview_expired = False + pid_arg = (request_obj.args.get("preview_id") or "").strip() + if pid_arg: + pr = conn.execute( + "SELECT * FROM trend_pullback_previews WHERE id=?", (pid_arg,) + ).fetchone() + now_ms = int(time.time() * 1000) + if pr and int(pr["expires_at_ms"] or 0) >= now_ms: + trend_preview = _row(cfg, pr) + preview_expires_ms = int(pr["expires_at_ms"]) + try: + grid = json.loads(trend_preview.get("grid_prices_json") or "[]") + legs = json.loads(trend_preview.get("leg_amounts_json") or "[]") + except Exception: + grid, legs = [], [] + for i, pair in enumerate(zip(grid, legs), 1): + trend_preview_levels.append({"i": i, "price": pair[0], "contracts": pair[1]}) + elif pr: + trend_preview_expired = True + return { + "trend_plans": trend_plans, + "trend_active": trend_active, + "can_trade_trend": can_trade_trend, + "trend_preview": trend_preview, + "trend_preview_levels": trend_preview_levels, + "preview_expires_ms": preview_expires_ms, + "trend_preview_expired": trend_preview_expired, + "trend_pullback_dca_legs": cfg["dca_legs"], + "trend_pullback_preview_ttl": cfg["preview_ttl"], + "trend_preview_max_drift_pct": cfg["drift_pct"], + "trend_manual_breakeven_offset_pct": cfg["breakeven_offset_pct"], + } + + +def register_trend_routes(app: Flask, cfg: dict) -> None: + lr = cfg["login_required"] + get_db = cfg["get_db"] + + def _redirect_trend(**kw): + return redirect(url_for("strategy_trend_page", **kw)) + + @app.route("/preview_trend_pullback", methods=["POST"]) + @lr + def preview_trend_pullback(): + conn = get_db() + init_strategy_tables(conn) + okp, msg = precheck_trend_start(cfg, conn) + if not okp: + conn.close() + flash(msg) + return _redirect_trend() + m = _m(cfg) + ok_live, reason = m.ensure_exchange_live_ready() + if not ok_live: + conn.close() + flash(reason) + return _redirect_trend() + payload, err = parse_trend_plan(cfg, request.form) + if err: + conn.close() + flash(err) + return _redirect_trend() + pid = str(uuid.uuid4()) + exp_ms = int(time.time() * 1000) + cfg["preview_ttl"] * 1000 + created = m.app_now_str() + conn.execute( + """INSERT INTO trend_pullback_previews ( + id,symbol,exchange_symbol,direction,leverage,stop_loss,add_upper,take_profit,risk_percent, + snapshot_available_usdt,snapshot_at,live_price_ref,plan_margin_capital,target_order_amount,first_order_amount,remainder_total, + dca_legs,per_leg_amount,grid_prices_json,leg_amounts_json,expires_at_ms,created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + pid, + payload["symbol"], + payload["exchange_symbol"], + payload["direction"], + payload["leverage"], + payload["stop_loss"], + payload["add_upper"], + payload["take_profit"], + payload["risk_percent"], + payload["snapshot_available_usdt"], + payload["snapshot_at"], + payload["live_price_ref"], + payload["plan_margin_capital"], + payload["target_order_amount"], + payload["first_order_amount"], + payload["remainder_total"], + payload["dca_legs"], + payload["per_leg_amount"], + payload["grid_prices_json"], + payload["leg_amounts_json"], + exp_ms, + created, + ), + ) + _insert_preview_snapshot(conn, pid, created, exp_ms, payload) + conn.commit() + conn.close() + flash(f"预览已生成,有效期 {cfg['preview_ttl']} 秒,请核对后点击「确认执行」。") + return _redirect_trend(preview_id=pid) + + @app.route("/execute_trend_pullback", methods=["POST"]) + @lr + def execute_trend_pullback(): + pid = (request.form.get("preview_id") or "").strip() + if not pid: + flash("缺少预览 ID") + return _redirect_trend() + conn = get_db() + init_strategy_tables(conn) + _cleanup_stale_previews(conn) + pr = conn.execute( + "SELECT * FROM trend_pullback_previews WHERE id=?", (pid,) + ).fetchone() + now_ms = int(time.time() * 1000) + if not pr or int(pr["expires_at_ms"] or 0) < now_ms: + conn.close() + flash("预览已过期或不存在,请重新生成预览") + return _redirect_trend() + okp, msg = precheck_trend_start(cfg, conn) + if not okp: + conn.close() + flash(msg) + return _redirect_trend(preview_id=pid) + m = _m(cfg) + ok_live, reason = m.ensure_exchange_live_ready() + if not ok_live: + conn.close() + flash(reason) + return _redirect_trend(preview_id=pid) + snap_prev = float(pr["snapshot_available_usdt"] or 0) + snap_now = m.get_available_trading_usdt() + if snap_now is None or snap_now <= 0: + conn.close() + flash("无法读取当前合约可用余额,请稍后重试") + return _redirect_trend(preview_id=pid) + drift = abs(float(snap_now) - snap_prev) / max(snap_prev, 1e-9) * 100.0 + if drift > cfg["drift_pct"]: + conn.close() + flash( + f"当前可用余额与预览快照偏差 {drift:.2f}%,超过允许 {cfg['drift_pct']}%,请重新生成预览" + ) + return _redirect_trend(preview_id=pid) + symbol = pr["symbol"] + exchange_symbol = pr["exchange_symbol"] + direction = pr["direction"] or "long" + leverage = int(pr["leverage"] or 1) + stop_loss = float(pr["stop_loss"]) + first_amt = float(pr["first_order_amount"] or 0) + live_price = m.get_price(symbol) + if live_price is None: + conn.close() + flash("获取实时价格失败") + return _redirect_trend(preview_id=pid) + try: + o1 = m.place_exchange_order( + exchange_symbol, direction, first_amt, leverage, stop_loss=None, take_profit=None + ) + fill1 = m.resolve_order_entry_price(o1, exchange_symbol, live_price) + trend_refresh_stop_only(cfg, exchange_symbol, direction, stop_loss) + except Exception as e: + conn.close() + fe = getattr(m, "friendly_exchange_error", lambda x, **k: str(x)) + flash(fe(e, available_usdt=snap_now)) + return _redirect_trend(preview_id=pid) + trading_day = m.get_trading_day(m.app_now()) + opened_at = m.app_now_str() + opened_ms = getattr(m, "_to_ms_with_fallback", lambda a, b: None)(None, opened_at) + cur = conn.execute( + """INSERT INTO trend_pullback_plans ( + status,symbol,exchange_symbol,direction,leverage,stop_loss,initial_stop_loss,add_upper,take_profit,risk_percent, + snapshot_available_usdt,snapshot_at,plan_margin_capital,target_order_amount,first_order_amount,remainder_total, + dca_legs,per_leg_amount,grid_prices_json,leg_amounts_json,legs_done,first_order_done,last_mark_price,avg_entry_price,order_amount_open,opened_at,opened_at_ms,session_date,message + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + "active", + symbol, + exchange_symbol, + direction, + leverage, + stop_loss, + stop_loss, + float(pr["add_upper"]), + float(pr["take_profit"]), + float(pr["risk_percent"] or 5), + float(snap_now), + opened_at, + float(pr["plan_margin_capital"] or 0), + float(pr["target_order_amount"] or 0), + first_amt, + float(pr["remainder_total"] or 0), + int(pr["dca_legs"] or 0), + float(pr["per_leg_amount"] or 0), + pr["grid_prices_json"] or "[]", + pr["leg_amounts_json"] or "[]", + 0, + 1, + float(live_price), + fill1, + first_amt, + opened_at, + opened_ms, + trading_day, + f"预览ID:{pid[:8]}…", + ), + ) + new_id = int(cur.lastrowid) + conn.execute( + "UPDATE trend_pullback_preview_snapshots SET outcome='executed', executed_plan_id=? WHERE preview_id=?", + (new_id, pid), + ) + conn.execute("DELETE FROM trend_pullback_previews WHERE id=?", (pid,)) + conn.commit() + conn.close() + flash("趋势回调已执行:首仓已成交并挂交易所止损,止盈由程序监控。") + return _redirect_trend() + + @app.route("/cancel_trend_pullback_preview", methods=["POST"]) + @lr + def cancel_trend_pullback_preview(): + pid = (request.form.get("preview_id") or "").strip() + conn = get_db() + if pid: + conn.execute( + "UPDATE trend_pullback_preview_snapshots SET outcome='cancelled' WHERE preview_id=? AND outcome='open'", + (pid,), + ) + conn.execute("DELETE FROM trend_pullback_previews WHERE id=?", (pid,)) + conn.commit() + conn.close() + flash("已取消预览") + return _redirect_trend() + + @app.route("/trend_pullback_breakeven/", methods=["POST"]) + @lr + def trend_pullback_breakeven(pid: int): + offset_pct = None + raw = (request.form.get("breakeven_offset_pct") or "").strip() + if raw: + try: + offset_pct = float(raw) + if offset_pct < 0: + raise ValueError + except ValueError: + flash("保本偏移% 格式无效") + return _redirect_trend() + conn = get_db() + row = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", (pid,) + ).fetchone() + if not row: + conn.close() + flash("未找到运行中的趋势回调计划") + return _redirect_trend() + ok, err = apply_manual_breakeven(cfg, conn, row, offset_pct=offset_pct) + conn.commit() + conn.close() + flash("已手动保本" if ok else (err or "手动保本失败")) + return _redirect_trend() + + @app.route("/stop_trend_pullback/") + @lr + def stop_trend_pullback(pid: int): + conn = get_db() + row = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", (pid,) + ).fetchone() + if not row: + conn.close() + flash("未找到运行中的趋势回调计划") + return redirect("/trade") + m = _m(cfg) + ex_sym = row["exchange_symbol"] or m.normalize_exchange_symbol(row["symbol"]) + direction = row["direction"] or "long" + lev = int(row["leverage"] or 1) + px = m.get_price(row["symbol"]) + exit_p = float(px) if px is not None else 0.0 + ok_live, _ = m.ensure_exchange_live_ready() + if ok_live: + pos = m.get_live_position_contracts(ex_sym, direction) + if pos is not None and pos > 0: + try: + close_resp = trend_market_close(cfg, ex_sym, direction, float(pos), lev) + ep = m.extract_trade_price_from_order(close_resp) + if ep: + exit_p = float(ep) + except Exception as e: + if not m.is_no_position_error(str(e)): + conn.close() + flash(f"平仓失败:{e}") + return redirect("/trade") + try: + cancel_symbol_orders(cfg, ex_sym) + except Exception: + pass + _finalize_plan(cfg, conn, row, "手动平仓", exit_p) + conn.commit() + conn.close() + flash("已结束趋势回调计划") + return redirect("/trade") diff --git a/策略交易说明.md b/策略交易说明.md index 57faa7a..f602171 100644 --- a/策略交易说明.md +++ b/策略交易说明.md @@ -33,7 +33,7 @@ strategy_templates/ # 主站内嵌 panel(subnav、roll、trend 禁用 | 路由 | 子 Tab | 说明 | |------|--------|------| -| `/strategy/trend` | 趋势回调 | **完整功能仅在 `crypto_monitor_gate_bot`**;其它所在主站 `index.html` 内嵌说明(不再跳转独立 HTML) | +| `/strategy/trend` | 趋势回调 | **币安 / Gate / OKX / gate_bot 四所均可**(预览、执行、自动补仓、程序止盈) | | `/strategy/roll` | 顺势加仓 | **四所均可用**(须已有同向持仓),与实盘页同一布局 | | `/trade` | 实盘下单 | 首仓、以损定仓、移动保本(不变) | @@ -43,12 +43,12 @@ strategy_templates/ # 主站内嵌 panel(subnav、roll、trend 禁用 ## 三、趋势回调(延续 Gate 趋势机器人逻辑) -- **位置**:`crypto_monitor_gate_bot` → **策略交易 → 趋势回调**(与 Gate 主站同一顶栏风格,非独立站点)。 +- **位置**:各所顶栏 **策略交易 → 趋势回调**(共用 `strategy_trend_register.py` + 各所交易所 API)。 - **行为**:与《[crypto_monitor_gate_bot/趋势回调策略说明.md](./crypto_monitor_gate_bot/趋势回调策略说明.md)》一致——预览 → 确认执行 → 首仓 50% + 交易所止损 + 多档 **自动** 市价补仓 + 程序监控止盈。 - **共用代码**:`parse_and_compute_trend_pullback_plan` 中网格/拆档已改为调用 `strategy_trend_lib`。 - **互斥**:与「机器人下单监控」持仓上限、运行中趋势计划互斥(逻辑未改)。 -其它三所打开 **策略交易 → 趋势回调** 会在主站内嵌说明:完整功能请使用 Gate 趋势机器人实例(常见 `:5002`)。 +逻辑与 gate_bot 一致;各所使用自己的 API 密钥与 `crypto.db`,互不影响。 ---