bot增加保本

This commit is contained in:
dekun
2026-05-19 15:20:14 +08:00
parent 738cf0eccd
commit 44432a0688
4 changed files with 268 additions and 9 deletions
+177 -6
View File
@@ -29,6 +29,19 @@ except ImportError:
ImageFont = None # type: ignore
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
_REPO_ROOT = os.path.dirname(BASE_DIR)
import sys
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from history_window_lib import (
PRESET_CUSTOM,
PRESET_UTC_LAST24H,
PRESET_UTC_LAST7D,
PRESET_UTC_TODAY,
resolve_window,
utc_window_to_bj_sql_strings,
)
def load_env_file(path):
@@ -157,6 +170,10 @@ TREND_PULLBACK_DCA_LEGS = max(1, int(os.getenv("TREND_PULLBACK_DCA_LEGS", "5")))
TREND_PULLBACK_PREVIEW_TTL_SECONDS = max(10, int(os.getenv("TREND_PULLBACK_PREVIEW_TTL_SECONDS", "120")))
# 确认执行时:当前可用余额与预览快照相对偏差超过该百分比则拒绝(避免余额被划走后仍按旧计划满仓)
TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT = float(os.getenv("TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT", "5"))
# 趋势回调:手动保本默认相对均价偏移(%);多=均价×(1+pct/100),空=均价×(1-pct/100)
TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT = float(
os.getenv("TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT", "0.3")
)
MONITOR_TYPE_TREND = "趋势回调"
KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m")
FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98"))
@@ -1294,6 +1311,15 @@ def init_db():
c.execute("ALTER TABLE trend_pullback_plans ADD COLUMN leg_amounts_json TEXT")
except Exception:
pass
for ddl in (
"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",
):
try:
c.execute(ddl)
except Exception:
pass
c.execute(
"""CREATE TABLE IF NOT EXISTS trend_pullback_previews (
@@ -3514,8 +3540,35 @@ def trend_plan_history_status_label(status):
}.get(s, status or "-")
def _list_window_from_request():
return resolve_window(request.args, default_preset=PRESET_UTC_TODAY)
def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None):
"""趋势回调手动保本:默认开仓均价 + offset_pct%(多上移、空下移)。"""
try:
e = float(entry_price)
pct = float(
offset_pct
if offset_pct is not None
else TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT
)
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 enrich_active_trend_plan_row(row):
d = row_to_dict(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 normalize_exchange_symbol(d.get("symbol") or "")
direction = (d.get("direction") or "long").lower()
m = get_live_position_exchange_metrics(ex_sym, direction)
@@ -4225,6 +4278,66 @@ def _trend_refresh_stop_only(exchange_symbol, direction, stop_loss):
_gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss)
def apply_trend_pullback_manual_breakeven(conn, row, offset_pct=None):
"""运行中趋势计划:将交易所止损移至均价+偏移(默认 0.3%),仅当新止损更优时生效。"""
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 normalize_exchange_symbol(row["symbol"])
pos = get_live_position_contracts(ex_sym, direction)
if pos is None or float(pos) <= 0:
return False, "交易所当前无该方向持仓"
new_sl_raw = calc_trend_manual_breakeven_stop(direction, avg_e, offset_pct)
if new_sl_raw is None:
return False, "保本价计算失败"
new_sl = 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(ex_sym, direction, new_sl)
except Exception as e:
return False, friendly_exchange_error(e)
now_s = app_now_str()
conn.execute(
"UPDATE trend_pullback_plans SET stop_loss=?, breakeven_applied=1, breakeven_applied_at=? WHERE id=?",
(new_sl, now_s, row["id"]),
)
pct_used = float(
offset_pct
if offset_pct is not None
else TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT
)
sym = row["symbol"]
send_wechat_msg(
"\n".join(
[
f"# ✅ {sym} 趋势回调手动保本",
f"**账户:{_wechat_account_label()}**",
f"- 计划 ID**{row['id']}**",
f"- 方向:{_wechat_direction_text(direction)}",
f"- 持仓均价:{format_price_for_symbol(sym, avg_e)}",
f"- 偏移:{pct_used}%(相对均价)",
f"- 新止损:{format_price_for_symbol(sym, new_sl)}",
f"- 交易所:已更新仓位止损触发单",
]
)
)
return True, None
def _trend_weighted_avg(old_avg, old_amt, fill_px, add_amt):
try:
oa, aa = float(old_amt), float(add_amt)
@@ -4937,6 +5050,8 @@ def api_sync_positions():
def render_main_page(page="trade"):
now = app_now()
trading_day = get_trading_day(now)
list_window = _list_window_from_request()
start_bj, end_bj = utc_window_to_bj_sql_strings(list_window["start_utc"], list_window["end_utc"], APP_TZ)
conn = get_db()
session_row = ensure_session(conn, trading_day)
local_current_capital = float(session_row["current_capital"])
@@ -4957,7 +5072,14 @@ def render_main_page(page="trade"):
sync_trend_trade_records_from_exchange(conn)
except Exception:
pass
raw_records = conn.execute("SELECT * FROM trade_records ORDER BY id DESC").fetchall()
if page in ("records", "plan_history"):
raw_records = conn.execute(
"SELECT * FROM trade_records WHERE COALESCE(closed_at, created_at, opened_at) >= ? "
"AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id DESC LIMIT 2000",
(start_bj, end_bj),
).fetchall()
else:
raw_records = conn.execute("SELECT * FROM trade_records ORDER BY id DESC LIMIT 500").fetchall()
records = [to_effective_trade_dict(r) for r in raw_records]
total = len(records)
miss_count = sum(1 for r in records if (r.get("effective_result") or "") == "错过")
@@ -4981,14 +5103,18 @@ def render_main_page(page="trade"):
preview_snapshots = []
if page == "plan_history":
plan_history_raw = conn.execute(
"SELECT * FROM trend_pullback_plans WHERE status != 'active' ORDER BY id DESC LIMIT 100"
"SELECT * FROM trend_pullback_plans WHERE status != 'active' "
"AND COALESCE(opened_at, '') >= ? AND COALESCE(opened_at, '') <= ? ORDER BY id DESC LIMIT 500",
(start_bj, end_bj),
).fetchall()
for pr in plan_history_raw:
pd = row_to_dict(pr)
pd["status_label"] = trend_plan_history_status_label(pd.get("status"))
plan_history.append(pd)
snap_rows = conn.execute(
"SELECT * FROM trend_pullback_preview_snapshots ORDER BY id DESC LIMIT 150"
"SELECT * FROM trend_pullback_preview_snapshots WHERE COALESCE(preview_created_at, '') >= ? "
"AND COALESCE(preview_created_at, '') <= ? ORDER BY id DESC LIMIT 500",
(start_bj, end_bj),
).fetchall()
for sr in snap_rows:
sd = row_to_dict(sr)
@@ -5067,6 +5193,14 @@ def render_main_page(page="trade"):
trend_preview_expired=trend_preview_expired,
trend_preview_id_arg=trend_preview_id_arg,
trend_preview_max_drift_pct=TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT,
trend_manual_breakeven_offset_pct=TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT,
list_window=list_window,
list_window_presets={
"utc_today": PRESET_UTC_TODAY,
"utc_last24h": PRESET_UTC_LAST24H,
"utc_last7d": PRESET_UTC_LAST7D,
"custom": PRESET_CUSTOM,
},
focus_key_id=(key_list[0]["id"] if key_list else None),
focus_order_id=(order_list[0]["id"] if order_list else None),
data_export_version=3,
@@ -6019,10 +6153,10 @@ def execute_trend_pullback():
opened_ms = _to_ms_with_fallback(None, opened_at)
cur = conn.execute(
"""INSERT INTO trend_pullback_plans (
status,symbol,exchange_symbol,direction,leverage,stop_loss,add_upper,take_profit,risk_percent,
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
"active",
symbol,
@@ -6030,6 +6164,7 @@ def execute_trend_pullback():
direction,
leverage,
stop_loss,
stop_loss,
add_upper,
take_profit,
risk_percent,
@@ -6086,6 +6221,37 @@ def cancel_trend_pullback_preview():
return redirect(url_for("trade_page"))
@app.route("/trend_pullback_breakeven/<int:pid>", methods=["POST"])
@login_required
def trend_pullback_breakeven(pid):
offset_raw = (request.form.get("breakeven_offset_pct") or "").strip()
offset_pct = None
if offset_raw:
try:
offset_pct = float(offset_raw)
if offset_pct < 0:
raise ValueError
except ValueError:
flash("保本偏移% 格式无效")
return redirect(url_for("trade_page"))
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(url_for("trade_page"))
ok, err = apply_trend_pullback_manual_breakeven(conn, row, offset_pct=offset_pct)
conn.commit()
conn.close()
if ok:
flash("已手动保本:交易所止损已按均价+偏移更新")
else:
flash(err or "手动保本失败")
return redirect(url_for("trade_page"))
@app.route("/stop_trend_pullback/<int:pid>")
@login_required
def stop_trend_pullback(pid):
@@ -6222,12 +6388,17 @@ def _md_response(filename, content):
@app.route("/export/trade_records")
@login_required
def export_trade_records():
win = _list_window_from_request()
start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ)
conn = get_db()
rows = conn.execute(
"SELECT id,symbol,monitor_type,direction,trigger_price,stop_loss,take_profit,margin_capital,leverage,"
"pnl_amount,hold_seconds,hold_minutes,opened_at,closed_at,result,miss_reason,"
"entry_reason,reviewed_entry_reason,created_at,trend_plan_id,exchange_realized_pnl,"
"exchange_opened_at,exchange_closed_at,exchange_sync_key FROM trade_records ORDER BY id ASC"
"exchange_opened_at,exchange_closed_at,exchange_sync_key FROM trade_records "
"WHERE COALESCE(closed_at, created_at, opened_at) >= ? "
"AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id ASC",
(start_bj, end_bj),
).fetchall()
conn.close()
head_base = [