增加趋势回调
This commit is contained in:
@@ -2507,6 +2507,7 @@ def insert_trade_record(
|
|||||||
exchange_trade_id=None,
|
exchange_trade_id=None,
|
||||||
key_signal_type=None,
|
key_signal_type=None,
|
||||||
entry_reason=None,
|
entry_reason=None,
|
||||||
|
trend_plan_id=None,
|
||||||
):
|
):
|
||||||
hold_minutes = calc_hold_minutes(hold_seconds)
|
hold_minutes = calc_hold_minutes(hold_seconds)
|
||||||
open_ts = opened_at or app_now_str()
|
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
|
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 ""
|
er = (entry_reason or "").strip() or entry_reason_from_key_signal(kst) or ""
|
||||||
cur = conn.execute(
|
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,
|
symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, take_profit,
|
||||||
margin_capital, leverage, pnl_amount, hold_seconds,
|
margin_capital, leverage, pnl_amount, hold_seconds,
|
||||||
trade_style, risk_amount, planned_rr, actual_rr, hold_minutes,
|
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)
|
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}")
|
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):
|
def ensure_markets_loaded(force=False):
|
||||||
global MARKETS_LOADED
|
global MARKETS_LOADED
|
||||||
if force or not MARKETS_LOADED:
|
if force or not MARKETS_LOADED:
|
||||||
@@ -5453,6 +5499,11 @@ def background_task():
|
|||||||
check_fib_key_monitors()
|
check_fib_key_monitors()
|
||||||
check_key_monitors()
|
check_key_monitors()
|
||||||
check_order_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:
|
except:
|
||||||
pass
|
pass
|
||||||
time.sleep(MONITOR_POLL_SECONDS)
|
time.sleep(MONITOR_POLL_SECONDS)
|
||||||
@@ -5689,6 +5740,12 @@ def render_main_page(page="trade"):
|
|||||||
strategy_extra = strategy_page_template_vars(
|
strategy_extra = strategy_page_template_vars(
|
||||||
conn, page, default_risk_percent=float(RISK_PERCENT)
|
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()
|
conn.close()
|
||||||
return render_template(
|
return render_template(
|
||||||
"index.html",
|
"index.html",
|
||||||
@@ -7695,8 +7752,10 @@ def strategy_roll_page():
|
|||||||
|
|
||||||
|
|
||||||
from strategy_register import install_strategy_trading
|
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_trading(app, _REPO_ROOT, app_module=sys.modules[__name__])
|
||||||
|
install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__])
|
||||||
|
|
||||||
|
|
||||||
# 启动
|
# 启动
|
||||||
|
|||||||
@@ -540,7 +540,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% elif page == 'strategy_trend' %}
|
{% elif page == 'strategy_trend' %}
|
||||||
{% include 'strategy_trend_disabled_panel.html' %}
|
{% include 'strategy_trend_panel.html' %}
|
||||||
{% elif page == 'strategy_roll' %}
|
{% elif page == 'strategy_roll' %}
|
||||||
{% include 'strategy_roll_panel.html' %}
|
{% include 'strategy_roll_panel.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -2228,6 +2228,7 @@ def insert_trade_record(
|
|||||||
exchange_trade_id=None,
|
exchange_trade_id=None,
|
||||||
key_signal_type=None,
|
key_signal_type=None,
|
||||||
entry_reason=None,
|
entry_reason=None,
|
||||||
|
trend_plan_id=None,
|
||||||
):
|
):
|
||||||
hold_minutes = calc_hold_minutes(hold_seconds)
|
hold_minutes = calc_hold_minutes(hold_seconds)
|
||||||
open_ts = opened_at or app_now_str()
|
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
|
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 ""
|
er = (entry_reason or "").strip() or entry_reason_from_key_signal(kst) or ""
|
||||||
conn.execute(
|
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,
|
symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, take_profit,
|
||||||
margin_capital, leverage, pnl_amount, hold_seconds,
|
margin_capital, leverage, pnl_amount, hold_seconds,
|
||||||
trade_style, risk_amount, planned_rr, actual_rr, hold_minutes,
|
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):
|
def ensure_markets_loaded(force=False):
|
||||||
global MARKETS_LOADED
|
global MARKETS_LOADED
|
||||||
if force or not MARKETS_LOADED:
|
if force or not MARKETS_LOADED:
|
||||||
@@ -5290,6 +5362,11 @@ def background_task():
|
|||||||
check_fib_key_monitors()
|
check_fib_key_monitors()
|
||||||
check_key_monitors()
|
check_key_monitors()
|
||||||
check_order_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:
|
except:
|
||||||
pass
|
pass
|
||||||
time.sleep(MONITOR_POLL_SECONDS)
|
time.sleep(MONITOR_POLL_SECONDS)
|
||||||
@@ -5668,6 +5745,12 @@ def render_main_page(page="trade"):
|
|||||||
strategy_extra = strategy_page_template_vars(
|
strategy_extra = strategy_page_template_vars(
|
||||||
conn, page, default_risk_percent=float(RISK_PERCENT)
|
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()
|
conn.close()
|
||||||
return render_template(
|
return render_template(
|
||||||
"index.html",
|
"index.html",
|
||||||
@@ -7756,8 +7839,10 @@ def strategy_roll_page():
|
|||||||
|
|
||||||
|
|
||||||
from strategy_register import install_strategy_trading
|
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_trading(app, _REPO_ROOT, app_module=sys.modules[__name__])
|
||||||
|
install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__])
|
||||||
|
|
||||||
|
|
||||||
# 启动
|
# 启动
|
||||||
|
|||||||
@@ -540,7 +540,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% elif page == 'strategy_trend' %}
|
{% elif page == 'strategy_trend' %}
|
||||||
{% include 'strategy_trend_disabled_panel.html' %}
|
{% include 'strategy_trend_panel.html' %}
|
||||||
{% elif page == 'strategy_roll' %}
|
{% elif page == 'strategy_roll' %}
|
||||||
{% include 'strategy_roll_panel.html' %}
|
{% include 'strategy_roll_panel.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -371,188 +371,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% elif page == 'strategy_trend' %}
|
{% elif page == 'strategy_trend' %}
|
||||||
{% include 'strategy_subnav.html' %}
|
{% set can_trade_trend = can_trade %}
|
||||||
<div class="card trend-card">
|
{% include 'strategy_trend_panel.html' %}
|
||||||
<h2 style="margin-bottom:8px">趋势回调策略</h2>
|
|
||||||
<div class="rule-tip">
|
|
||||||
① <strong>生成预览</strong>:读取合约 USDT <strong>可用余额快照</strong>并计算计划(不下单)。预览有效期 <strong>{{ trend_pullback_preview_ttl }} 秒</strong>。<br>
|
|
||||||
② <strong>确认执行</strong>:市价首仓 50% + 挂交易所止损;首仓后可<strong>手动保本</strong>(默认均价+{{ trend_manual_breakeven_offset_pct }}%);剩余 50% 在止损与补仓区间之间共 {{ trend_pullback_dca_legs }} 档(做多为<strong>上沿</strong>、做空为<strong>下沿</strong>;程序可能因最小张数自动减档)市价补仓;<strong>止盈由程序监控</strong>。<br>
|
|
||||||
确认执行时若当前可用余额与预览快照相对偏差 > <strong>{{ trend_preview_max_drift_pct }}%</strong> 会拒绝并要求重新预览。
|
|
||||||
</div>
|
|
||||||
<form id="trend-pullback-form" action="{{ url_for('preview_trend_pullback') }}" method="post" class="form-row">
|
|
||||||
<input name="symbol" placeholder="BTC 或 ETH/USDT" required>
|
|
||||||
<select name="direction" id="trend-direction" required>
|
|
||||||
<option value="">方向</option>
|
|
||||||
<option value="long">做多</option>
|
|
||||||
<option value="short">做空</option>
|
|
||||||
</select>
|
|
||||||
<input name="leverage" type="number" min="1" step="1" placeholder="杠杆(必填)" required>
|
|
||||||
<input name="risk_percent" type="number" min="0.1" step="0.1" value="5" placeholder="风险%相对可用快照" title="默认5:最坏亏损约≤可用余额×5%">
|
|
||||||
<input name="sl" step="any" placeholder="止损价" required>
|
|
||||||
<input name="add_upper" id="trend-add-upper" step="any" placeholder="补仓上沿价" required>
|
|
||||||
<input name="take_profit" step="any" placeholder="止盈价(固定)" required>
|
|
||||||
<button type="submit" {% if not can_trade %}disabled style="opacity:.5;cursor:not-allowed"{% endif %}>生成预览</button>
|
|
||||||
</form>
|
|
||||||
<script>
|
|
||||||
(function(){
|
|
||||||
const dirSel = document.getElementById("trend-direction");
|
|
||||||
const addInp = document.getElementById("trend-add-upper");
|
|
||||||
function syncAddUpperPlaceholder(){
|
|
||||||
if(!addInp || !dirSel) return;
|
|
||||||
const d = (dirSel.value || "long").toLowerCase();
|
|
||||||
addInp.placeholder = d === "short" ? "补仓下沿价" : "补仓上沿价";
|
|
||||||
}
|
|
||||||
if(dirSel){
|
|
||||||
dirSel.addEventListener("change", syncAddUpperPlaceholder);
|
|
||||||
syncAddUpperPlaceholder();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% if trend_preview %}
|
|
||||||
<div style="margin-top:14px;padding:12px;background:#141a2e;border:1px solid #2a3150;border-radius:8px">
|
|
||||||
<div style="display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px;margin-bottom:8px">
|
|
||||||
<strong style="color:#dbe4ff">当前预览(剩余 <span id="trend-preview-ttl">{{ trend_pullback_preview_ttl }}</span>s)</strong>
|
|
||||||
<span style="font-size:.8rem;color:#9aa" data-expires-ms="{{ preview_expires_ms }}">倒计时加载中…</span>
|
|
||||||
</div>
|
|
||||||
<div style="font-size:.82rem;color:#cfd3ef;line-height:1.55;margin-bottom:10px">
|
|
||||||
{{ trend_preview.symbol }} {{ '做多' if trend_preview.direction == 'long' else '做空' }} {{ trend_preview.leverage }}x |
|
|
||||||
预览可用快照 <strong>{{ money_fmt(trend_preview.snapshot_available_usdt) }}</strong> 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) }})<br>
|
|
||||||
止损 {{ 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 }}%
|
|
||||||
</div>
|
|
||||||
<div class="table-wrap" style="margin-bottom:10px">
|
|
||||||
<table>
|
|
||||||
<tr><th>#</th><th>补仓触发价</th><th>该档张数</th></tr>
|
|
||||||
{% for row in trend_preview_levels %}
|
|
||||||
<tr><td>{{ row.i }}</td><td>{{ price_fmt(trend_preview.symbol, row.price) }}</td><td>{{ amt_fmt(trend_preview.symbol, row.contracts) }}</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="form-row" style="gap:10px;align-items:center">
|
|
||||||
<form action="{{ url_for('execute_trend_pullback') }}" method="post" style="display:inline">
|
|
||||||
<input type="hidden" name="preview_id" value="{{ trend_preview.id }}">
|
|
||||||
<button type="submit" onclick="return confirm('确认按预览参数实盘下单?')">确认执行(实盘)</button>
|
|
||||||
</form>
|
|
||||||
<form action="{{ url_for('cancel_trend_pullback_preview') }}" method="post" style="display:inline">
|
|
||||||
<input type="hidden" name="preview_id" value="{{ trend_preview.id }}">
|
|
||||||
<button type="submit" style="background:#2f2134;color:#ffb2b2">取消预览</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
(function(){
|
|
||||||
const el = document.querySelector("[data-expires-ms]");
|
|
||||||
if(!el) return;
|
|
||||||
const exp = parseInt(el.getAttribute("data-expires-ms")||"0",10);
|
|
||||||
function tick(){
|
|
||||||
const left = Math.max(0, Math.floor((exp - Date.now()) / 1000));
|
|
||||||
el.innerText = left > 0 ? ("剩余 " + left + " 秒") : "已过期,请重新生成预览";
|
|
||||||
const span = document.getElementById("trend-preview-ttl");
|
|
||||||
if(span) span.innerText = String(left);
|
|
||||||
if(left <= 0) return;
|
|
||||||
setTimeout(tick, 1000);
|
|
||||||
}
|
|
||||||
tick();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
{% elif trend_preview_expired %}
|
|
||||||
<div class="rule-tip" style="margin-top:12px;color:#ff8f8f">该预览已过期(超过 {{ trend_pullback_preview_ttl }} 秒),请重新点击「生成预览」。</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="trend-running-plans">
|
|
||||||
<h3 style="margin:0 0 10px;font-size:.95rem;color:#b8c4ff">运行中的计划</h3>
|
|
||||||
<div class="running-plans-stack">
|
|
||||||
{% 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 %}
|
|
||||||
<div class="plan-position-card">
|
|
||||||
<div class="plan-card-head">
|
|
||||||
<div class="plan-card-title">
|
|
||||||
<span>#{{ t.id }} {{ sym }}</span>
|
|
||||||
<span class="badge {{ 'direction-long' if t.direction == 'long' else 'direction-short' }}">{{ '做多' if t.direction == 'long' else '做空' }}</span>
|
|
||||||
</div>
|
|
||||||
<a href="/stop_trend_pullback/{{ t.id }}" class="btn-close-plan" onclick="return confirm('结束计划:市价平仓并撤掉该合约全部挂单,确定?')">结束计划</a>
|
|
||||||
</div>
|
|
||||||
<div class="plan-card-meta">
|
|
||||||
来源: 趋势回调计划 | 风险: {% if t.risk_percent is not none %}{{ t.risk_percent }}%{% else %}—{% endif %}
|
|
||||||
| <span class="accent">{{ trend_add_zone_label(t.direction) }} {{ price_fmt(sym, t.add_upper) }}</span>
|
|
||||||
| 已补仓 <strong>{{ t.legs_done }}/{{ t.dca_legs }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="plan-card-grid">
|
|
||||||
<div class="plan-cell">
|
|
||||||
<span class="lbl">均价</span>
|
|
||||||
<span class="val">{% if t.avg_entry_price is not none %}{{ price_fmt(sym, t.avg_entry_price) }}{% else %}—{% endif %}</span>
|
|
||||||
</div>
|
|
||||||
<div class="plan-cell">
|
|
||||||
<span class="lbl">止损</span>
|
|
||||||
<span class="val">{{ price_fmt(sym, t.stop_loss) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="plan-cell">
|
|
||||||
<span class="lbl">止盈</span>
|
|
||||||
<span class="val">{{ price_fmt(sym, t.take_profit) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="plan-cell">
|
|
||||||
<span class="lbl">盈亏比</span>
|
|
||||||
<span class="val">{% if calc.rr is not none %}{{ '%.2f'|format(calc.rr) }}:1{% else %}—{% endif %}</span>
|
|
||||||
</div>
|
|
||||||
<div class="plan-cell">
|
|
||||||
<span class="lbl">标记价</span>
|
|
||||||
<span class="val">{% if t.floating_mark is not none %}{{ price_fmt(sym, t.floating_mark) }}{% else %}—{% endif %}</span>
|
|
||||||
</div>
|
|
||||||
<div class="plan-cell">
|
|
||||||
<span class="lbl">浮盈亏</span>
|
|
||||||
<span class="val {% if t.floating_pnl is not none %}{% if t.floating_pnl > 0 %}pnl-profit{% elif t.floating_pnl < 0 %}pnl-loss{% else %}pnl-neutral{% endif %}{% 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 %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="plan-card-meta" style="margin-top:8px">
|
|
||||||
<form action="{{ url_for('trend_pullback_breakeven', pid=t.id) }}" method="post" class="form-row" style="margin:0;align-items:center" onsubmit="return confirm('将交易所止损移至持仓均价+偏移?仅当新止损优于当前止损时生效。');">
|
|
||||||
<label style="font-size:.78rem;color:#cfd3ef;display:flex;align-items:center;gap:6px">
|
|
||||||
手动保本 偏移%
|
|
||||||
<input name="breakeven_offset_pct" type="number" min="0" step="0.01" value="{{ trend_manual_breakeven_offset_pct }}" style="width:72px;padding:4px 8px">
|
|
||||||
(默认均价+{{ trend_manual_breakeven_offset_pct }}%)
|
|
||||||
</label>
|
|
||||||
<button type="submit" style="padding:6px 12px;background:#1f4a3a;color:#8fc8ff">应用保本止损</button>
|
|
||||||
{% if t.breakeven_applied %}<span style="color:#6ab88a;font-size:.75rem">已保本 {{ (t.breakeven_applied_at or '')[:16] }}</span>{% endif %}
|
|
||||||
{% if t.initial_stop_loss is not none and t.initial_stop_loss != t.stop_loss %}<span style="color:#8892b0;font-size:.75rem">原止损 {{ price_fmt(sym, t.initial_stop_loss) }}</span>{% endif %}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="plan-card-meta" style="margin-bottom:0">
|
|
||||||
快照可用: {% 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
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="plan-position-card" style="color:#8892b0;text-align:center;padding:16px">暂无运行中的趋势回调计划</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% elif page == 'strategy_roll' %}
|
{% elif page == 'strategy_roll' %}
|
||||||
{% include 'strategy_roll_panel.html' %}
|
{% include 'strategy_roll_panel.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1954,6 +1954,7 @@ def insert_trade_record(
|
|||||||
exchange_trade_id=None,
|
exchange_trade_id=None,
|
||||||
key_signal_type=None,
|
key_signal_type=None,
|
||||||
entry_reason=None,
|
entry_reason=None,
|
||||||
|
trend_plan_id=None,
|
||||||
):
|
):
|
||||||
hold_minutes = calc_hold_minutes(hold_seconds)
|
hold_minutes = calc_hold_minutes(hold_seconds)
|
||||||
open_ts = opened_at or app_now_str()
|
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
|
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 ""
|
er = (entry_reason or "").strip() or entry_reason_from_key_signal(kst) or ""
|
||||||
conn.execute(
|
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,
|
symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, take_profit,
|
||||||
margin_capital, leverage, pnl_amount, hold_seconds,
|
margin_capital, leverage, pnl_amount, hold_seconds,
|
||||||
trade_style, risk_amount, planned_rr, actual_rr, hold_minutes,
|
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))
|
_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):
|
def extract_trade_price_from_order(order):
|
||||||
if not order:
|
if not order:
|
||||||
return None
|
return None
|
||||||
@@ -4069,6 +4106,11 @@ def background_task():
|
|||||||
check_fib_key_monitors()
|
check_fib_key_monitors()
|
||||||
check_key_monitors()
|
check_key_monitors()
|
||||||
check_order_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:
|
except:
|
||||||
pass
|
pass
|
||||||
time.sleep(MONITOR_POLL_SECONDS)
|
time.sleep(MONITOR_POLL_SECONDS)
|
||||||
@@ -4199,6 +4241,12 @@ def render_main_page(page="trade"):
|
|||||||
strategy_extra = strategy_page_template_vars(
|
strategy_extra = strategy_page_template_vars(
|
||||||
conn, page, default_risk_percent=float(RISK_PERCENT)
|
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()
|
conn.close()
|
||||||
return render_template(
|
return render_template(
|
||||||
"index.html",
|
"index.html",
|
||||||
@@ -5971,8 +6019,10 @@ def strategy_roll_page():
|
|||||||
|
|
||||||
|
|
||||||
from strategy_register import install_strategy_trading
|
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_trading(app, _REPO_ROOT, app_module=sys.modules[__name__])
|
||||||
|
install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__])
|
||||||
|
|
||||||
|
|
||||||
# 启动
|
# 启动
|
||||||
|
|||||||
@@ -353,7 +353,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% elif page == 'strategy_trend' %}
|
{% elif page == 'strategy_trend' %}
|
||||||
{% include 'strategy_trend_disabled_panel.html' %}
|
{% include 'strategy_trend_panel.html' %}
|
||||||
{% elif page == 'strategy_roll' %}
|
{% elif page == 'strategy_roll' %}
|
||||||
{% include 'strategy_roll_panel.html' %}
|
{% include 'strategy_roll_panel.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
+111
@@ -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:
|
def init_strategy_tables(conn) -> None:
|
||||||
conn.execute(ROLL_GROUPS_SQL)
|
conn.execute(ROLL_GROUPS_SQL)
|
||||||
conn.execute(ROLL_LEGS_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
|
||||||
|
|||||||
@@ -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' %}
|
||||||
|
<div class="card trend-card" style="grid-column:1/-1">
|
||||||
|
<h2 style="margin-bottom:8px">趋势回调策略</h2>
|
||||||
|
<div class="rule-tip">
|
||||||
|
① <strong>生成预览</strong>:读取合约 USDT <strong>可用余额快照</strong>并计算计划(不下单)。预览有效期 <strong>{{ trend_pullback_preview_ttl }} 秒</strong>。<br>
|
||||||
|
② <strong>确认执行</strong>:市价首仓 50% + 挂交易所止损;首仓后可<strong>手动保本</strong>(默认均价+{{ trend_manual_breakeven_offset_pct }}%);剩余 50% 在止损与补仓区间之间共 {{ trend_pullback_dca_legs }} 档(做多为<strong>上沿</strong>、做空为<strong>下沿</strong>;程序可能因最小张数自动减档)市价补仓;<strong>止盈由程序监控</strong>。<br>
|
||||||
|
确认执行时若当前可用余额与预览快照相对偏差 > <strong>{{ trend_preview_max_drift_pct }}%</strong> 会拒绝并要求重新预览。
|
||||||
|
</div>
|
||||||
|
<form id="trend-pullback-form" action="{{ url_for('preview_trend_pullback') }}" method="post" class="form-row">
|
||||||
|
<input name="symbol" placeholder="BTC 或 ETH/USDT" required>
|
||||||
|
<select name="direction" id="trend-direction" required>
|
||||||
|
<option value="">方向</option>
|
||||||
|
<option value="long">做多</option>
|
||||||
|
<option value="short">做空</option>
|
||||||
|
</select>
|
||||||
|
<input name="leverage" type="number" min="1" step="1" placeholder="杠杆(必填)" required>
|
||||||
|
<input name="risk_percent" type="number" min="0.1" step="0.1" value="5" placeholder="风险%相对可用快照" title="默认5:最坏亏损约≤可用余额×5%">
|
||||||
|
<input name="sl" step="any" placeholder="止损价" required>
|
||||||
|
<input name="add_upper" id="trend-add-upper" step="any" placeholder="补仓上沿价" required>
|
||||||
|
<input name="take_profit" step="any" placeholder="止盈价(固定)" required>
|
||||||
|
<button type="submit" {% if can_trade_trend is defined %}{% if not can_trade_trend %}disabled style="opacity:.5;cursor:not-allowed"{% endif %}{% elif not can_trade %}disabled style="opacity:.5;cursor:not-allowed"{% endif %}>生成预览</button>
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const dirSel = document.getElementById("trend-direction");
|
||||||
|
const addInp = document.getElementById("trend-add-upper");
|
||||||
|
function syncAddUpperPlaceholder(){
|
||||||
|
if(!addInp || !dirSel) return;
|
||||||
|
const d = (dirSel.value || "long").toLowerCase();
|
||||||
|
addInp.placeholder = d === "short" ? "补仓下沿价" : "补仓上沿价";
|
||||||
|
}
|
||||||
|
if(dirSel){
|
||||||
|
dirSel.addEventListener("change", syncAddUpperPlaceholder);
|
||||||
|
syncAddUpperPlaceholder();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% if trend_preview %}
|
||||||
|
<div style="margin-top:14px;padding:12px;background:#141a2e;border:1px solid #2a3150;border-radius:8px">
|
||||||
|
<div style="display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px;margin-bottom:8px">
|
||||||
|
<strong style="color:#dbe4ff">当前预览(剩余 <span id="trend-preview-ttl">{{ trend_pullback_preview_ttl }}</span>s)</strong>
|
||||||
|
<span style="font-size:.8rem;color:#9aa" data-expires-ms="{{ preview_expires_ms }}">倒计时加载中…</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:.82rem;color:#cfd3ef;line-height:1.55;margin-bottom:10px">
|
||||||
|
{{ trend_preview.symbol }} {{ '做多' if trend_preview.direction == 'long' else '做空' }} {{ trend_preview.leverage }}x |
|
||||||
|
预览可用快照 <strong>{{ mf(trend_preview.snapshot_available_usdt) }}</strong> 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) }})<br>
|
||||||
|
止损 {{ 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 }}%
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap" style="margin-bottom:10px">
|
||||||
|
<table>
|
||||||
|
<tr><th>#</th><th>补仓触发价</th><th>该档张数</th></tr>
|
||||||
|
{% for row in trend_preview_levels %}
|
||||||
|
<tr><td>{{ row.i }}</td><td>{{ price_fmt(trend_preview.symbol, row.price) }}</td><td>{{ amt_disp(trend_preview.symbol, row.contracts) }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" style="gap:10px;align-items:center">
|
||||||
|
<form action="{{ url_for('execute_trend_pullback') }}" method="post" style="display:inline">
|
||||||
|
<input type="hidden" name="preview_id" value="{{ trend_preview.id }}">
|
||||||
|
<button type="submit" onclick="return confirm('确认按预览参数实盘下单?')">确认执行(实盘)</button>
|
||||||
|
</form>
|
||||||
|
<form action="{{ url_for('cancel_trend_pullback_preview') }}" method="post" style="display:inline">
|
||||||
|
<input type="hidden" name="preview_id" value="{{ trend_preview.id }}">
|
||||||
|
<button type="submit" style="background:#2f2134;color:#ffb2b2">取消预览</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const el = document.querySelector("[data-expires-ms]");
|
||||||
|
if(!el) return;
|
||||||
|
const exp = parseInt(el.getAttribute("data-expires-ms")||"0",10);
|
||||||
|
function tick(){
|
||||||
|
const left = Math.max(0, Math.floor((exp - Date.now()) / 1000));
|
||||||
|
el.innerText = left > 0 ? ("剩余 " + left + " 秒") : "已过期,请重新生成预览";
|
||||||
|
const span = document.getElementById("trend-preview-ttl");
|
||||||
|
if(span) span.innerText = String(left);
|
||||||
|
if(left <= 0) return;
|
||||||
|
setTimeout(tick, 1000);
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% elif trend_preview_expired %}
|
||||||
|
<div class="rule-tip" style="margin-top:12px;color:#ff8f8f">该预览已过期(超过 {{ trend_pullback_preview_ttl }} 秒),请重新点击「生成预览」。</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="trend-running-plans">
|
||||||
|
<h3 style="margin:0 0 10px;font-size:.95rem;color:#b8c4ff">运行中的计划</h3>
|
||||||
|
<div class="running-plans-stack">
|
||||||
|
{% 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 %}
|
||||||
|
<div class="plan-position-card">
|
||||||
|
<div class="plan-card-head">
|
||||||
|
<div class="plan-card-title">
|
||||||
|
<span>#{{ t.id }} {{ sym }}</span>
|
||||||
|
<span class="badge {{ 'direction-long' if t.direction == 'long' else 'direction-short' }}">{{ '做多' if t.direction == 'long' else '做空' }}</span>
|
||||||
|
</div>
|
||||||
|
<a href="/stop_trend_pullback/{{ t.id }}" class="btn-close-plan" onclick="return confirm('结束计划:市价平仓并撤掉该合约全部挂单,确定?')">结束计划</a>
|
||||||
|
</div>
|
||||||
|
<div class="plan-card-meta">
|
||||||
|
来源: 趋势回调计划 | 风险: {% if t.risk_percent is not none %}{{ t.risk_percent }}%{% else %}—{% endif %}
|
||||||
|
| <span class="accent">{{ trend_add_zone_label(t.direction) }} {{ price_fmt(sym, t.add_upper) }}</span>
|
||||||
|
| 已补仓 <strong>{{ t.legs_done }}/{{ t.dca_legs }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="plan-card-grid">
|
||||||
|
<div class="plan-cell">
|
||||||
|
<span class="lbl">均价</span>
|
||||||
|
<span class="val">{% if t.avg_entry_price is not none %}{{ price_fmt(sym, t.avg_entry_price) }}{% else %}—{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="plan-cell">
|
||||||
|
<span class="lbl">止损</span>
|
||||||
|
<span class="val">{{ price_fmt(sym, t.stop_loss) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="plan-cell">
|
||||||
|
<span class="lbl">止盈</span>
|
||||||
|
<span class="val">{{ price_fmt(sym, t.take_profit) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="plan-cell">
|
||||||
|
<span class="lbl">盈亏比</span>
|
||||||
|
<span class="val">{% if calc.rr is not none %}{{ '%.2f'|format(calc.rr) }}:1{% else %}—{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="plan-cell">
|
||||||
|
<span class="lbl">标记价</span>
|
||||||
|
<span class="val">{% if t.floating_mark is not none %}{{ price_fmt(sym, t.floating_mark) }}{% else %}—{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="plan-cell">
|
||||||
|
<span class="lbl">浮盈亏</span>
|
||||||
|
<span class="val {% if t.floating_pnl is not none %}{% if t.floating_pnl > 0 %}pnl-profit{% elif t.floating_pnl < 0 %}pnl-loss{% else %}pnl-neutral{% endif %}{% 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 %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="plan-card-meta" style="margin-top:8px">
|
||||||
|
<form action="{{ url_for('trend_pullback_breakeven', pid=t.id) }}" method="post" class="form-row" style="margin:0;align-items:center" onsubmit="return confirm('将交易所止损移至持仓均价+偏移?仅当新止损优于当前止损时生效。');">
|
||||||
|
<label style="font-size:.78rem;color:#cfd3ef;display:flex;align-items:center;gap:6px">
|
||||||
|
手动保本 偏移%
|
||||||
|
<input name="breakeven_offset_pct" type="number" min="0" step="0.01" value="{{ trend_manual_breakeven_offset_pct }}" style="width:72px;padding:4px 8px">
|
||||||
|
</label>
|
||||||
|
<button type="submit" style="padding:6px 12px;background:#1f4a3a;color:#8fc8ff">应用保本止损</button>
|
||||||
|
{% if t.breakeven_applied %}<span style="color:#6ab88a;font-size:.75rem">已保本 {{ (t.breakeven_applied_at or '')[:16] }}</span>{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="plan-card-meta" style="margin-bottom:0">
|
||||||
|
快照可用: {% 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
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="plan-position-card" style="color:#8892b0;text-align:center;padding:16px">暂无运行中的趋势回调计划</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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)
|
||||||
@@ -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/<int:pid>", 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/<int:pid>")
|
||||||
|
@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")
|
||||||
@@ -33,7 +33,7 @@ strategy_templates/ # 主站内嵌 panel(subnav、roll、trend 禁用
|
|||||||
|
|
||||||
| 路由 | 子 Tab | 说明 |
|
| 路由 | 子 Tab | 说明 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| `/strategy/trend` | 趋势回调 | **完整功能仅在 `crypto_monitor_gate_bot`**;其它所在主站 `index.html` 内嵌说明(不再跳转独立 HTML) |
|
| `/strategy/trend` | 趋势回调 | **币安 / Gate / OKX / gate_bot 四所均可**(预览、执行、自动补仓、程序止盈) |
|
||||||
| `/strategy/roll` | 顺势加仓 | **四所均可用**(须已有同向持仓),与实盘页同一布局 |
|
| `/strategy/roll` | 顺势加仓 | **四所均可用**(须已有同向持仓),与实盘页同一布局 |
|
||||||
| `/trade` | 实盘下单 | 首仓、以损定仓、移动保本(不变) |
|
| `/trade` | 实盘下单 | 首仓、以损定仓、移动保本(不变) |
|
||||||
|
|
||||||
@@ -43,12 +43,12 @@ strategy_templates/ # 主站内嵌 panel(subnav、roll、trend 禁用
|
|||||||
|
|
||||||
## 三、趋势回调(延续 Gate 趋势机器人逻辑)
|
## 三、趋势回调(延续 Gate 趋势机器人逻辑)
|
||||||
|
|
||||||
- **位置**:`crypto_monitor_gate_bot` → **策略交易 → 趋势回调**(与 Gate 主站同一顶栏风格,非独立站点)。
|
- **位置**:各所顶栏 **策略交易 → 趋势回调**(共用 `strategy_trend_register.py` + 各所交易所 API)。
|
||||||
- **行为**:与《[crypto_monitor_gate_bot/趋势回调策略说明.md](./crypto_monitor_gate_bot/趋势回调策略说明.md)》一致——预览 → 确认执行 → 首仓 50% + 交易所止损 + 多档 **自动** 市价补仓 + 程序监控止盈。
|
- **行为**:与《[crypto_monitor_gate_bot/趋势回调策略说明.md](./crypto_monitor_gate_bot/趋势回调策略说明.md)》一致——预览 → 确认执行 → 首仓 50% + 交易所止损 + 多档 **自动** 市价补仓 + 程序监控止盈。
|
||||||
- **共用代码**:`parse_and_compute_trend_pullback_plan` 中网格/拆档已改为调用 `strategy_trend_lib`。
|
- **共用代码**:`parse_and_compute_trend_pullback_plan` 中网格/拆档已改为调用 `strategy_trend_lib`。
|
||||||
- **互斥**:与「机器人下单监控」持仓上限、运行中趋势计划互斥(逻辑未改)。
|
- **互斥**:与「机器人下单监控」持仓上限、运行中趋势计划互斥(逻辑未改)。
|
||||||
|
|
||||||
其它三所打开 **策略交易 → 趋势回调** 会在主站内嵌说明:完整功能请使用 Gate 趋势机器人实例(常见 `:5002`)。
|
逻辑与 gate_bot 一致;各所使用自己的 API 密钥与 `crypto.db`,互不影响。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user