前端面板修改

This commit is contained in:
dekun
2026-05-17 08:42:50 +08:00
parent eb32ec70b5
commit ff62666c4d
11 changed files with 1229 additions and 183 deletions
+137 -38
View File
@@ -123,9 +123,18 @@ BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60"))
PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5"))
KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3"))
KEY_ALERT_INTERVAL_MINUTES = int(os.getenv("KEY_ALERT_INTERVAL_MINUTES", "5"))
KEY_BREAKOUT_LIMIT_PCT = float(os.getenv("KEY_BREAKOUT_LIMIT_PCT", "1.5"))
KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5"))
KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5"))
MANUAL_MIN_PLANNED_RR = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4"))
MAX_ACTIVE_POSITIONS = max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1")))
KEY_VOLUME_MA_BARS = max(1, int(os.getenv("KEY_VOLUME_MA_BARS", "20")))
KEY_VOLUME_RATIO_MIN = float(os.getenv("KEY_VOLUME_RATIO_MIN", "1.3"))
KEY_BREAKOUT_AMP_MIN_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MIN_PCT", "0.03"))
KEY_BREAKOUT_AMP_MAX_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MAX_PCT", "0.5"))
KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30")))
KEY_CONFIRM_BREAKOUT_BAR = int(os.getenv("KEY_CONFIRM_BREAKOUT_BAR", "-2"))
KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR", "-1"))
KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() == "true"
ORDER_MONITOR_TYPE_MANUAL = "下单监控"
ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控"
KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"})
@@ -1223,6 +1232,10 @@ def init_db():
try:
c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5")
except: pass
try:
c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL")
except Exception:
pass
c.execute(
"""CREATE TABLE IF NOT EXISTS key_monitor_history
@@ -2302,13 +2315,64 @@ def trading_day_reset_allows_new_open(now):
return now.hour >= TRADING_DAY_RESET_HOUR
def get_active_position_count(conn):
return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0])
def clear_key_sizing_snapshot_if_flat(conn, session_date):
if get_active_position_count(conn) > 0:
return
conn.execute(
"UPDATE trading_sessions SET key_sizing_capital_snapshot = NULL, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?",
(session_date,),
)
conn.commit()
def get_key_sizing_capital_snapshot(conn, session_date):
row = ensure_session(conn, session_date)
try:
val = row["key_sizing_capital_snapshot"]
except (KeyError, IndexError):
return None
if val is None:
return None
try:
return float(val)
except (TypeError, ValueError):
return None
def set_key_sizing_capital_snapshot(conn, session_date, capital):
ensure_session(conn, session_date)
conn.execute(
"UPDATE trading_sessions SET key_sizing_capital_snapshot = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?",
(round(float(capital), FUNDS_DECIMALS), session_date),
)
conn.commit()
def resolve_capital_base_for_key_open(conn, trading_day, live_capital):
"""关键位自动开仓:有仓时用无仓时资金快照计仓(可配置)。"""
live = float(live_capital)
active = get_active_position_count(conn)
if active <= 0:
set_key_sizing_capital_snapshot(conn, trading_day, live)
return live
if KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT:
snap = get_key_sizing_capital_snapshot(conn, trading_day)
if snap is not None and snap > 0:
return snap
return live
def precheck_risk(conn, symbol, direction):
now = app_now()
if not trading_day_reset_allows_new_open(now):
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]
if active_count > 0:
return False, "一次只能持有一个仓位"
active_count = get_active_position_count(conn)
if active_count >= MAX_ACTIVE_POSITIONS:
return False, f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS}"
if direction not in ("long", "short"):
return False, "方向必须为 long 或 short"
if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"):
@@ -3121,6 +3185,7 @@ def reconcile_external_closes(conn, days=None):
closed_at=closed_at,
)
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],))
clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day())
if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"):
send_wechat_msg(
build_wechat_close_message(
@@ -3287,21 +3352,26 @@ def _key_hard_checks(symbol, direction, upper, lower, monitor_type):
out["reason"] = "5m K线数量不足"
return out
closed = bars[:-1] if len(bars) >= 3 else bars
if len(closed) < 23:
out["reason"] = "闭合K线不足"
min_closed = KEY_VOLUME_MA_BARS + 3
if len(closed) < min_closed:
out["reason"] = f"{KLINE_TIMEFRAME} 闭合K线不足"
return out
breakout = closed[-2]
confirm = closed[-1]
prev20 = closed[-22:-2]
avg20 = sum(float(x[5]) for x in prev20) / max(len(prev20), 1)
try:
breakout = closed[KEY_CONFIRM_BREAKOUT_BAR]
confirm = closed[KEY_CONFIRM_BAR]
except IndexError:
out["reason"] = "确认K索引超出范围,请检查 KEY_CONFIRM_* 配置"
return out
prev_vol = closed[KEY_CONFIRM_BREAKOUT_BAR - KEY_VOLUME_MA_BARS : KEY_CONFIRM_BREAKOUT_BAR]
avg20 = sum(float(x[5]) for x in prev_vol) / max(len(prev_vol), 1)
vol_break = float(breakout[5])
vol_ok = vol_break > avg20 * 1.3 if avg20 > 0 else False
vol_ok = vol_break > avg20 * KEY_VOLUME_RATIO_MIN if avg20 > 0 else False
open_b = float(breakout[1])
close_b = float(breakout[4])
high_b = float(breakout[2])
low_b = float(breakout[3])
amp_pct = abs(close_b - open_b) / open_b * 100 if open_b > 0 else 0
amp_ok = (amp_pct > 0.03) and (amp_pct < 0.5)
amp_ok = (amp_pct > KEY_BREAKOUT_AMP_MIN_PCT) and (amp_pct < KEY_BREAKOUT_AMP_MAX_PCT)
cfm_close = float(confirm[4])
# 区间极值点严格以前端录入 upper/lower 为准:做多看上沿,做空看下沿
edge = float(upper) if direction == "long" else float(lower)
@@ -3311,7 +3381,7 @@ def _key_hard_checks(symbol, direction, upper, lower, monitor_type):
amp_ok = amp_ok and breakout_ok
confirm_ok = confirm_ok_raw and breakout_ok
rank, total = _daily_volume_rank(symbol)
rank_ok = (rank is not None) and (rank <= 30)
rank_ok = (rank is not None) and (rank <= KEY_DAILY_VOLUME_RANK_MAX)
swing4h_pct = 0.0
try:
seg48 = closed[-48:] if len(closed) >= 48 else closed
@@ -3424,7 +3494,8 @@ def _market_open_for_key_monitor(conn, symbol, direction, exchange_symbol, stop_
).fetchone()[0]
session_row = ensure_session(conn, trading_day)
_, trading_capital_live = get_exchange_capitals(force=True)
capital_base = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"])
live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"])
capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital)
trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower()
if trade_style not in ("trend", "swing"):
@@ -3990,6 +4061,7 @@ def check_order_monitors():
closed_at=closed_at,
)
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, pid))
clear_key_sizing_snapshot_if_flat(conn, get_trading_day())
conn.commit()
conn.close()
@@ -4198,7 +4270,12 @@ def render_main_page(page="trade"):
)
rate = round(win/total*100,2) if total else 0
active_count = len(order_list)
can_trade = trading_day_reset_allows_new_open(now) and active_count == 0
can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS
key_gate_rule_text = (
f"周期 {KLINE_TIMEFRAME}|确认K:突破棒偏移 {KEY_CONFIRM_BREAKOUT_BAR}、确认棒偏移 {KEY_CONFIRM_BAR}"
f"量能:突破量 > 前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}"
f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}"
)
conn.close()
return render_template(
"index.html",
@@ -4242,6 +4319,11 @@ def render_main_page(page="trade"):
entry_reason_options=list(ENTRY_REASON_OPTIONS),
entry_reason_other_value=ENTRY_REASON_OTHER,
exchange_display=EXCHANGE_DISPLAY_NAME,
max_active_positions=MAX_ACTIVE_POSITIONS,
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR,
key_gate_rule_text=key_gate_rule_text,
kline_timeframe=KLINE_TIMEFRAME,
)
@@ -4251,6 +4333,12 @@ def index():
return redirect("/trade")
@app.route("/key_monitor")
@login_required
def key_monitor_page():
return render_main_page("key_monitor")
@app.route("/trade")
@login_required
def trade_page():
@@ -4281,9 +4369,9 @@ def api_account_snapshot():
funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None
current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS)
recommended_capital = get_recommended_capital(current_capital)
active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]
active_count = get_active_position_count(conn)
conn.close()
can_trade = trading_day_reset_allows_new_open(now) and active_count == 0
can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS
available_trading_usdt = get_available_trading_usdt()
return jsonify({
"funding_usdt": funding_usdt,
@@ -4291,7 +4379,9 @@ def api_account_snapshot():
"available_trading_usdt": round(available_trading_usdt, FUNDS_DECIMALS) if available_trading_usdt is not None else None,
"recommended_capital": recommended_capital,
"active_count": active_count,
"max_active_positions": MAX_ACTIVE_POSITIONS,
"can_trade": can_trade,
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
"trading_day": trading_day
})
@@ -4451,7 +4541,8 @@ def api_symbol_liquidity_rank():
"symbol": symbol,
"rank": int(rank),
"total": int(total),
"in_top30": bool(rank <= 30),
"in_top30": bool(rank <= KEY_DAILY_VOLUME_RANK_MAX),
"rank_max": KEY_DAILY_VOLUME_RANK_MAX,
}
)
@@ -4468,13 +4559,15 @@ def api_order_defaults():
exchange_symbol = normalize_exchange_symbol(symbol)
leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol)
available = get_available_trading_usdt()
last_price = get_price(symbol)
return jsonify({
"ok": True,
"symbol": symbol,
"exchange_symbol": exchange_symbol,
"direction": direction,
"leverage": leverage,
"available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None
"available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None,
"last_price": round(float(last_price), 8) if last_price is not None else None,
})
@@ -4709,34 +4802,33 @@ def add_key():
symbol = normalize_symbol_input(d.get("symbol"))
if not symbol:
flash("symbol 不能为空")
return redirect("/")
return redirect("/key_monitor")
direction_sel = (d.get("direction") or "").strip().lower()
if direction_sel not in ("long", "short"):
flash("请选择做多或做空")
return redirect("/")
return redirect("/key_monitor")
mt = (d.get("type") or "").strip()
allowed_types = tuple(KEY_MONITOR_AUTO_TYPES) + tuple(KEY_MONITOR_ALERT_ONLY_TYPES)
if mt not in allowed_types:
flash("监控类型无效,请选择:箱体突破、收敛突破、关键阻力位、关键支撑位")
return redirect("/")
return redirect("/key_monitor")
rank, total = _daily_volume_rank(symbol)
if rank is None:
flash("日成交量排名读取失败,请稍后重试")
return redirect("/")
if rank > 30:
flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前30,已拒绝添加关键位")
return redirect("/")
return redirect("/key_monitor")
if rank > KEY_DAILY_VOLUME_RANK_MAX:
flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位")
return redirect("/key_monitor")
conn = get_db()
if mt in KEY_MONITOR_AUTO_TYPES:
occupied = conn.execute(
"SELECT COUNT(*) FROM order_monitors WHERE status='active'",
).fetchone()[0]
if occupied and int(occupied) > 0:
occupied = get_active_position_count(conn)
if occupied >= MAX_ACTIVE_POSITIONS:
conn.close()
flash(
"当前已有实盘持仓:无法添加「箱体突破 / 收敛突破」(会自动开仓)。请平仓后再试,或使用「关键阻力位/关键支撑位」(仅单次提醒)"
f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」"
"请平仓后再试,或使用「关键阻力位/关键支撑位」(仅单次提醒)。"
)
return redirect("/")
return redirect("/key_monitor")
ex_sym_key = normalize_exchange_symbol(symbol)
try:
ensure_markets_loaded()
@@ -4765,7 +4857,7 @@ def add_key():
flash(
"⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。"
)
return redirect("/")
return redirect("/key_monitor")
@app.route("/add_order", methods=["POST"])
@login_required
@@ -4781,7 +4873,7 @@ def add_order():
return redirect("/")
ok, reason = precheck_risk(conn, symbol, direction)
if not ok:
if "一次只能持有一个仓位" in reason:
if "已达最大持仓数" in reason:
try:
tp_raw = parse_positive_float(d.get("tp"))
sl_raw = parse_positive_float(d.get("sl"))
@@ -4797,14 +4889,14 @@ def add_order():
stop_loss=sl_raw or 0,
take_profit=tgt_raw or 0,
result="错过",
miss_reason="持仓占用:一次只能持有一个仓位",
miss_reason=f"持仓占用:{reason}",
opened_at=app_now_str(),
closed_at=app_now_str(),
)
conn.commit()
conn.close()
flash(f"风控拒绝下单:{reason}")
return redirect("/")
return redirect("/trade")
ok_live, reason_live = ensure_exchange_live_ready()
if not ok_live:
conn.close()
@@ -4873,7 +4965,13 @@ def add_order():
if stop_loss <= 0 or take_profit <= 0:
conn.close()
flash("价格参数必须大于0")
return redirect("/")
return redirect("/trade")
planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit)
if planned_rr_manual is None or planned_rr_manual < MANUAL_MIN_PLANNED_RR:
conn.close()
rr_txt = f"{planned_rr_manual:.4f}" if planned_rr_manual is not None else "无法计算"
flash(f"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1")
return redirect("/trade")
risk_fraction = calc_risk_fraction(direction, live_price, stop_loss)
if risk_fraction is None:
conn.close()
@@ -5329,6 +5427,7 @@ def del_order(id):
closed_at=closed_at,
)
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id))
clear_key_sizing_snapshot_if_flat(conn, session_date)
conn.commit()
conn.close()
send_wechat_msg(
@@ -5348,7 +5447,7 @@ def del_order(id):
)
)
flash("已按实盘流程手动平仓")
return redirect("/")
return redirect("/trade")
except Exception as e:
if is_no_position_error(str(e)):
cancel_binance_futures_open_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]))