前端面板修改
This commit is contained in:
@@ -71,18 +71,43 @@ BINANCE_TRIGGER_WORKING_TYPE=CONTRACT_PRICE
|
|||||||
# 企业微信推送里展示的账户备注
|
# 企业微信推送里展示的账户备注
|
||||||
# BINANCE_ACCOUNT_LABEL=binance实盘账户
|
# BINANCE_ACCOUNT_LABEL=binance实盘账户
|
||||||
|
|
||||||
# 关键位监控:5m收线突破过滤参数
|
# =============================================================================
|
||||||
|
# 关键位门控(页面「关键位监控」规则条与 _key_hard_checks 共用)
|
||||||
|
# =============================================================================
|
||||||
|
# 【周期】门控 K 线周期,如 5m、15m;仅影响关键位硬条件,不改变顶栏分区
|
||||||
KLINE_TIMEFRAME=5m
|
KLINE_TIMEFRAME=5m
|
||||||
KEY_BREAKOUT_LIMIT_PCT=1.5
|
# 【确认K】闭合 K 序列中的棒偏移:突破棒默认 -2(倒数第2根),确认棒默认 -1(倒数第1根)
|
||||||
# 关键位自动单:计划 RR 阈值(严格大于该值才开仓,按确认K收盘 E 计算)
|
KEY_CONFIRM_BREAKOUT_BAR=-2
|
||||||
|
KEY_CONFIRM_BAR=-1
|
||||||
|
# 【量能】突破棒成交量 > 前 N 根均量 × 倍数(默认 N=20,倍数=1.3 即放大 30%)
|
||||||
|
KEY_VOLUME_MA_BARS=20
|
||||||
|
KEY_VOLUME_RATIO_MIN=1.3
|
||||||
|
# 【突破K实体幅度】占开盘价百分比区间(须同时满足有效突破)
|
||||||
|
KEY_BREAKOUT_AMP_MIN_PCT=0.03
|
||||||
|
KEY_BREAKOUT_AMP_MAX_PCT=0.5
|
||||||
|
# 【日成交量排名】品种须在该排名前 N 名(添加关键位与运行时门控均校验)
|
||||||
|
KEY_DAILY_VOLUME_RANK_MAX=30
|
||||||
|
# 【关键位自动开仓盈亏比】按确认K收盘 E 计算,严格大于该值才市价开仓(如 1.5 表示须 >1.5:1)
|
||||||
KEY_AUTO_MIN_PLANNED_RR=1.5
|
KEY_AUTO_MIN_PLANNED_RR=1.5
|
||||||
# 止损:突破 K 极值向外缓冲的百分比(默认 0.5 即 0.5%)
|
# 止损:突破 K 极值向外缓冲的百分比(默认 0.5 即 0.5%)
|
||||||
KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5
|
KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5
|
||||||
KEY_ALERT_MAX_TIMES=3
|
KEY_ALERT_MAX_TIMES=3
|
||||||
KEY_ALERT_INTERVAL_MINUTES=5
|
KEY_ALERT_INTERVAL_MINUTES=5
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 交易执行 / 人工风控(页面「实盘下单」)
|
||||||
|
# =============================================================================
|
||||||
|
# 【最大同时持仓】active 订单数达到该值后禁止人工与关键位自动再加仓(默认 1=单仓)
|
||||||
|
MAX_ACTIVE_POSITIONS=1
|
||||||
|
# 【人工下单最低盈亏比】按当前价与 SL/TP 计算,低于该值前后端均拒绝(默认 1.4,即须 >=1.4:1)
|
||||||
|
MANUAL_MIN_PLANNED_RR=1.4
|
||||||
|
# 【关键位连开计仓】true=已有持仓时关键位自动单仍按「无仓时」资金快照算保证金基数
|
||||||
|
KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true
|
||||||
|
|
||||||
# 资金与仓位刷新周期(秒)
|
# 资金与仓位刷新周期(秒)
|
||||||
BALANCE_REFRESH_SECONDS=60
|
BALANCE_REFRESH_SECONDS=60
|
||||||
|
# 前端价格快照轮询(秒)
|
||||||
|
PRICE_REFRESH_SECONDS=5
|
||||||
# 后台监控轮询周期(秒)
|
# 后台监控轮询周期(秒)
|
||||||
MONITOR_POLL_SECONDS=3
|
MONITOR_POLL_SECONDS=3
|
||||||
# 使用可用资金时的缓冲比例(如0.98代表用98%)
|
# 使用可用资金时的缓冲比例(如0.98代表用98%)
|
||||||
|
|||||||
+137
-38
@@ -123,9 +123,18 @@ BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60"))
|
|||||||
PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5"))
|
PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5"))
|
||||||
KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3"))
|
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_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_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"))
|
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_MANUAL = "下单监控"
|
||||||
ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控"
|
ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控"
|
||||||
KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"})
|
KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"})
|
||||||
@@ -1223,6 +1232,10 @@ def init_db():
|
|||||||
try:
|
try:
|
||||||
c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5")
|
c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5")
|
||||||
except: pass
|
except: pass
|
||||||
|
try:
|
||||||
|
c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
c.execute(
|
c.execute(
|
||||||
"""CREATE TABLE IF NOT EXISTS key_monitor_history
|
"""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
|
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):
|
def precheck_risk(conn, symbol, direction):
|
||||||
now = app_now()
|
now = app_now()
|
||||||
if not trading_day_reset_allows_new_open(now):
|
if not trading_day_reset_allows_new_open(now):
|
||||||
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
|
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
|
||||||
active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]
|
active_count = get_active_position_count(conn)
|
||||||
if active_count > 0:
|
if active_count >= MAX_ACTIVE_POSITIONS:
|
||||||
return False, "一次只能持有一个仓位"
|
return False, f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS})"
|
||||||
if direction not in ("long", "short"):
|
if direction not in ("long", "short"):
|
||||||
return False, "方向必须为 long 或 short"
|
return False, "方向必须为 long 或 short"
|
||||||
if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"):
|
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,
|
closed_at=closed_at,
|
||||||
)
|
)
|
||||||
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],))
|
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 ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"):
|
if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"):
|
||||||
send_wechat_msg(
|
send_wechat_msg(
|
||||||
build_wechat_close_message(
|
build_wechat_close_message(
|
||||||
@@ -3287,21 +3352,26 @@ def _key_hard_checks(symbol, direction, upper, lower, monitor_type):
|
|||||||
out["reason"] = "5m K线数量不足"
|
out["reason"] = "5m K线数量不足"
|
||||||
return out
|
return out
|
||||||
closed = bars[:-1] if len(bars) >= 3 else bars
|
closed = bars[:-1] if len(bars) >= 3 else bars
|
||||||
if len(closed) < 23:
|
min_closed = KEY_VOLUME_MA_BARS + 3
|
||||||
out["reason"] = "闭合K线不足"
|
if len(closed) < min_closed:
|
||||||
|
out["reason"] = f"{KLINE_TIMEFRAME} 闭合K线不足"
|
||||||
return out
|
return out
|
||||||
breakout = closed[-2]
|
try:
|
||||||
confirm = closed[-1]
|
breakout = closed[KEY_CONFIRM_BREAKOUT_BAR]
|
||||||
prev20 = closed[-22:-2]
|
confirm = closed[KEY_CONFIRM_BAR]
|
||||||
avg20 = sum(float(x[5]) for x in prev20) / max(len(prev20), 1)
|
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_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])
|
open_b = float(breakout[1])
|
||||||
close_b = float(breakout[4])
|
close_b = float(breakout[4])
|
||||||
high_b = float(breakout[2])
|
high_b = float(breakout[2])
|
||||||
low_b = float(breakout[3])
|
low_b = float(breakout[3])
|
||||||
amp_pct = abs(close_b - open_b) / open_b * 100 if open_b > 0 else 0
|
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])
|
cfm_close = float(confirm[4])
|
||||||
# 区间极值点严格以前端录入 upper/lower 为准:做多看上沿,做空看下沿
|
# 区间极值点严格以前端录入 upper/lower 为准:做多看上沿,做空看下沿
|
||||||
edge = float(upper) if direction == "long" else float(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
|
amp_ok = amp_ok and breakout_ok
|
||||||
confirm_ok = confirm_ok_raw and breakout_ok
|
confirm_ok = confirm_ok_raw and breakout_ok
|
||||||
rank, total = _daily_volume_rank(symbol)
|
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
|
swing4h_pct = 0.0
|
||||||
try:
|
try:
|
||||||
seg48 = closed[-48:] if len(closed) >= 48 else closed
|
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]
|
).fetchone()[0]
|
||||||
session_row = ensure_session(conn, trading_day)
|
session_row = ensure_session(conn, trading_day)
|
||||||
_, trading_capital_live = get_exchange_capitals(force=True)
|
_, 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()
|
trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower()
|
||||||
if trade_style not in ("trend", "swing"):
|
if trade_style not in ("trend", "swing"):
|
||||||
@@ -3990,6 +4061,7 @@ def check_order_monitors():
|
|||||||
closed_at=closed_at,
|
closed_at=closed_at,
|
||||||
)
|
)
|
||||||
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, pid))
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -4198,7 +4270,12 @@ def render_main_page(page="trade"):
|
|||||||
)
|
)
|
||||||
rate = round(win/total*100,2) if total else 0
|
rate = round(win/total*100,2) if total else 0
|
||||||
active_count = len(order_list)
|
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()
|
conn.close()
|
||||||
return render_template(
|
return render_template(
|
||||||
"index.html",
|
"index.html",
|
||||||
@@ -4242,6 +4319,11 @@ def render_main_page(page="trade"):
|
|||||||
entry_reason_options=list(ENTRY_REASON_OPTIONS),
|
entry_reason_options=list(ENTRY_REASON_OPTIONS),
|
||||||
entry_reason_other_value=ENTRY_REASON_OTHER,
|
entry_reason_other_value=ENTRY_REASON_OTHER,
|
||||||
exchange_display=EXCHANGE_DISPLAY_NAME,
|
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")
|
return redirect("/trade")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/key_monitor")
|
||||||
|
@login_required
|
||||||
|
def key_monitor_page():
|
||||||
|
return render_main_page("key_monitor")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/trade")
|
@app.route("/trade")
|
||||||
@login_required
|
@login_required
|
||||||
def trade_page():
|
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
|
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)
|
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)
|
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()
|
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()
|
available_trading_usdt = get_available_trading_usdt()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"funding_usdt": funding_usdt,
|
"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,
|
"available_trading_usdt": round(available_trading_usdt, FUNDS_DECIMALS) if available_trading_usdt is not None else None,
|
||||||
"recommended_capital": recommended_capital,
|
"recommended_capital": recommended_capital,
|
||||||
"active_count": active_count,
|
"active_count": active_count,
|
||||||
|
"max_active_positions": MAX_ACTIVE_POSITIONS,
|
||||||
"can_trade": can_trade,
|
"can_trade": can_trade,
|
||||||
|
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
|
||||||
"trading_day": trading_day
|
"trading_day": trading_day
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -4451,7 +4541,8 @@ def api_symbol_liquidity_rank():
|
|||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"rank": int(rank),
|
"rank": int(rank),
|
||||||
"total": int(total),
|
"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)
|
exchange_symbol = normalize_exchange_symbol(symbol)
|
||||||
leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol)
|
leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol)
|
||||||
available = get_available_trading_usdt()
|
available = get_available_trading_usdt()
|
||||||
|
last_price = get_price(symbol)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"exchange_symbol": exchange_symbol,
|
"exchange_symbol": exchange_symbol,
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
"leverage": leverage,
|
"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"))
|
symbol = normalize_symbol_input(d.get("symbol"))
|
||||||
if not symbol:
|
if not symbol:
|
||||||
flash("symbol 不能为空")
|
flash("symbol 不能为空")
|
||||||
return redirect("/")
|
return redirect("/key_monitor")
|
||||||
direction_sel = (d.get("direction") or "").strip().lower()
|
direction_sel = (d.get("direction") or "").strip().lower()
|
||||||
if direction_sel not in ("long", "short"):
|
if direction_sel not in ("long", "short"):
|
||||||
flash("请选择做多或做空")
|
flash("请选择做多或做空")
|
||||||
return redirect("/")
|
return redirect("/key_monitor")
|
||||||
mt = (d.get("type") or "").strip()
|
mt = (d.get("type") or "").strip()
|
||||||
allowed_types = tuple(KEY_MONITOR_AUTO_TYPES) + tuple(KEY_MONITOR_ALERT_ONLY_TYPES)
|
allowed_types = tuple(KEY_MONITOR_AUTO_TYPES) + tuple(KEY_MONITOR_ALERT_ONLY_TYPES)
|
||||||
if mt not in allowed_types:
|
if mt not in allowed_types:
|
||||||
flash("监控类型无效,请选择:箱体突破、收敛突破、关键阻力位、关键支撑位")
|
flash("监控类型无效,请选择:箱体突破、收敛突破、关键阻力位、关键支撑位")
|
||||||
return redirect("/")
|
return redirect("/key_monitor")
|
||||||
rank, total = _daily_volume_rank(symbol)
|
rank, total = _daily_volume_rank(symbol)
|
||||||
if rank is None:
|
if rank is None:
|
||||||
flash("日成交量排名读取失败,请稍后重试")
|
flash("日成交量排名读取失败,请稍后重试")
|
||||||
return redirect("/")
|
return redirect("/key_monitor")
|
||||||
if rank > 30:
|
if rank > KEY_DAILY_VOLUME_RANK_MAX:
|
||||||
flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前30,已拒绝添加关键位")
|
flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位")
|
||||||
return redirect("/")
|
return redirect("/key_monitor")
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
if mt in KEY_MONITOR_AUTO_TYPES:
|
if mt in KEY_MONITOR_AUTO_TYPES:
|
||||||
occupied = conn.execute(
|
occupied = get_active_position_count(conn)
|
||||||
"SELECT COUNT(*) FROM order_monitors WHERE status='active'",
|
if occupied >= MAX_ACTIVE_POSITIONS:
|
||||||
).fetchone()[0]
|
|
||||||
if occupied and int(occupied) > 0:
|
|
||||||
conn.close()
|
conn.close()
|
||||||
flash(
|
flash(
|
||||||
"当前已有实盘持仓:无法添加「箱体突破 / 收敛突破」(会自动开仓)。请平仓后再试,或使用「关键阻力位/关键支撑位」(仅单次提醒)。"
|
f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」。"
|
||||||
|
"请平仓后再试,或使用「关键阻力位/关键支撑位」(仅单次提醒)。"
|
||||||
)
|
)
|
||||||
return redirect("/")
|
return redirect("/key_monitor")
|
||||||
ex_sym_key = normalize_exchange_symbol(symbol)
|
ex_sym_key = normalize_exchange_symbol(symbol)
|
||||||
try:
|
try:
|
||||||
ensure_markets_loaded()
|
ensure_markets_loaded()
|
||||||
@@ -4765,7 +4857,7 @@ def add_key():
|
|||||||
flash(
|
flash(
|
||||||
"⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。"
|
"⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。"
|
||||||
)
|
)
|
||||||
return redirect("/")
|
return redirect("/key_monitor")
|
||||||
|
|
||||||
@app.route("/add_order", methods=["POST"])
|
@app.route("/add_order", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@@ -4781,7 +4873,7 @@ def add_order():
|
|||||||
return redirect("/")
|
return redirect("/")
|
||||||
ok, reason = precheck_risk(conn, symbol, direction)
|
ok, reason = precheck_risk(conn, symbol, direction)
|
||||||
if not ok:
|
if not ok:
|
||||||
if "一次只能持有一个仓位" in reason:
|
if "已达最大持仓数" in reason:
|
||||||
try:
|
try:
|
||||||
tp_raw = parse_positive_float(d.get("tp"))
|
tp_raw = parse_positive_float(d.get("tp"))
|
||||||
sl_raw = parse_positive_float(d.get("sl"))
|
sl_raw = parse_positive_float(d.get("sl"))
|
||||||
@@ -4797,14 +4889,14 @@ def add_order():
|
|||||||
stop_loss=sl_raw or 0,
|
stop_loss=sl_raw or 0,
|
||||||
take_profit=tgt_raw or 0,
|
take_profit=tgt_raw or 0,
|
||||||
result="错过",
|
result="错过",
|
||||||
miss_reason="持仓占用:一次只能持有一个仓位",
|
miss_reason=f"持仓占用:{reason}",
|
||||||
opened_at=app_now_str(),
|
opened_at=app_now_str(),
|
||||||
closed_at=app_now_str(),
|
closed_at=app_now_str(),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
flash(f"风控拒绝下单:{reason}")
|
flash(f"风控拒绝下单:{reason}")
|
||||||
return redirect("/")
|
return redirect("/trade")
|
||||||
ok_live, reason_live = ensure_exchange_live_ready()
|
ok_live, reason_live = ensure_exchange_live_ready()
|
||||||
if not ok_live:
|
if not ok_live:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -4873,7 +4965,13 @@ def add_order():
|
|||||||
if stop_loss <= 0 or take_profit <= 0:
|
if stop_loss <= 0 or take_profit <= 0:
|
||||||
conn.close()
|
conn.close()
|
||||||
flash("价格参数必须大于0")
|
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)
|
risk_fraction = calc_risk_fraction(direction, live_price, stop_loss)
|
||||||
if risk_fraction is None:
|
if risk_fraction is None:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -5329,6 +5427,7 @@ def del_order(id):
|
|||||||
closed_at=closed_at,
|
closed_at=closed_at,
|
||||||
)
|
)
|
||||||
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id))
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
send_wechat_msg(
|
send_wechat_msg(
|
||||||
@@ -5348,7 +5447,7 @@ def del_order(id):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
flash("已按实盘流程手动平仓")
|
flash("已按实盘流程手动平仓")
|
||||||
return redirect("/")
|
return redirect("/trade")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if is_no_position_error(str(e)):
|
if is_no_position_error(str(e)):
|
||||||
cancel_binance_futures_open_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]))
|
cancel_binance_futures_open_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]))
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{% if page == 'key_monitor' %}
|
||||||
|
<motion class="dual-panel-grid" style="grid-column:1/-1">
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Patch index.html layout for key_monitor / trade split."""
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
TAG = "div"
|
||||||
|
|
||||||
|
PATHS = [
|
||||||
|
Path(__file__).resolve().parent.parent / "templates" / "index.html",
|
||||||
|
Path(r"c:\Users\dekun\Desktop\crypto_monitor\crypto_monitor_gate\templates\index.html"),
|
||||||
|
]
|
||||||
|
|
||||||
|
KEY_START = " {% if page == 'key_monitor' %}"
|
||||||
|
KEY_START_ALT = " {% if page == 'trade' %}"
|
||||||
|
RECORDS_START = " {% if page == 'records' %}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_section(order_loop: str) -> str:
|
||||||
|
t = TAG
|
||||||
|
return f""" {{% if page == 'key_monitor' %}}
|
||||||
|
<{t} class="dual-panel-grid" style="grid-column:1/-1">
|
||||||
|
<{t} class="card">
|
||||||
|
<{t} style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
||||||
|
<h2 style="margin-bottom:0">关键位监控</h2>
|
||||||
|
{{% if focus_key_id %}}
|
||||||
|
<a href="/key_focus?key_id={{{{ focus_key_id }}}}" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">放大查看K线(默认200根)</a>
|
||||||
|
{{% else %}}
|
||||||
|
<a href="/key_focus" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">输入币种查看K线</a>
|
||||||
|
{{% endif %}}
|
||||||
|
</{t}>
|
||||||
|
<form id="key-form" action="/add_key" method="post" class="form-row">
|
||||||
|
<input name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
||||||
|
<select name="type" required>
|
||||||
|
<option value="箱体突破">箱体突破</option>
|
||||||
|
<option value="收敛突破">收敛突破</option>
|
||||||
|
<option value="关键阻力位">关键阻力位</option>
|
||||||
|
<option value="关键支撑位">关键支撑位</option>
|
||||||
|
</select>
|
||||||
|
<select name="direction" required>
|
||||||
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
|
</select>
|
||||||
|
<input name="upper" step="0.0001" placeholder="上沿/阻力" required>
|
||||||
|
<input name="lower" step="0.0001" placeholder="下沿/支撑" required>
|
||||||
|
<button type="submit">添加</button>
|
||||||
|
</form>
|
||||||
|
<{t} class="rule-tip">{{{{ key_gate_rule_text }}}}</{t}>
|
||||||
|
<{t} class="panel-scroll pos-list">
|
||||||
|
{{% for k in key %}}
|
||||||
|
<{t} class="pos-card" id="key-row-{{{{ k.id }}}}">
|
||||||
|
<{t} class="pos-card-head">
|
||||||
|
<{t} class="pos-card-symbol">
|
||||||
|
<strong>{{{{ k.symbol }}}}</strong>
|
||||||
|
<span class="pos-side-badge {{{{ 'pos-side-long' if k.direction == 'long' else 'pos-side-short' }}}}">{{{{ '做多' if k.direction == 'long' else '做空' }}}}</span>
|
||||||
|
<span class="badge direction" style="margin-left:4px">{{{{ k.monitor_type }}}}</span>
|
||||||
|
</{t}>
|
||||||
|
<button type="button" class="pos-close-btn" style="border:none;cursor:pointer" onclick="deleteKeyMonitor({{{{ k.id }}}})">删</button>
|
||||||
|
</{t}>
|
||||||
|
<{t} class="pos-meta">
|
||||||
|
<span class="pos-meta-item">上沿: {{{{ k.upper }}}}</span>
|
||||||
|
<span class="pos-meta-item">下沿: {{{{ k.lower }}}}</span>
|
||||||
|
<span class="pos-meta-item">已提醒: {{{{ k.notification_count or 0 }}}}/{{{{ k.max_notify or 3 }}}}</span>
|
||||||
|
</{t}>
|
||||||
|
<{t} class="pos-grid">
|
||||||
|
<{t} class="pos-cell"><span class="pos-label">现价</span><span class="pos-value" id="key-price-{{{{ k.id }}}}">-</span></{t}>
|
||||||
|
<{t} class="pos-cell"><span class="pos-label">距上沿</span><span class="pos-value" id="key-up-diff-{{{{ k.id }}}}">-</span></{t}>
|
||||||
|
<{t} class="pos-cell"><span class="pos-label">距下沿</span><span class="pos-value" id="key-low-diff-{{{{ k.id }}}}">-</span></{t}>
|
||||||
|
<{t} class="pos-cell"><span class="pos-label">门控</span><span class="pos-value" id="key-gate-{{{{ k.id }}}}" style="color:#9aa">-</span></{t}>
|
||||||
|
</{t}>
|
||||||
|
<{t} class="pos-meta" style="margin-top:8px"><span class="pos-meta-item" id="key-gate-metrics-{{{{ k.id }}}}" style="color:#8fc8ff"></span></{t}>
|
||||||
|
</{t}>
|
||||||
|
{{% else %}}
|
||||||
|
<{t} class="pos-empty">暂无监控中的关键位</{t}>
|
||||||
|
{{% endfor %}}
|
||||||
|
</{t}>
|
||||||
|
</{t}>
|
||||||
|
<{t} class="card">
|
||||||
|
<h2 style="margin-bottom:8px">关键位历史</h2>
|
||||||
|
<{t} class="sub" style="font-size:.72rem;color:#8892b0;margin-bottom:8px">失效或已结案的关键位</{t}>
|
||||||
|
<{t} class="panel-scroll pos-list">
|
||||||
|
{{% for h in key_history %}}
|
||||||
|
<{t} class="pos-card">
|
||||||
|
<{t} class="pos-card-head">
|
||||||
|
<{t} class="pos-card-symbol">
|
||||||
|
<strong>{{{{ h.symbol }}}}</strong>
|
||||||
|
<span class="pos-side-badge {{{{ 'pos-side-long' if h.direction == 'long' else 'pos-side-short' }}}}">{{{{ '做多' if h.direction == 'long' else '做空' }}}}</span>
|
||||||
|
</{t}>
|
||||||
|
<button type="button" class="table-del" onclick="deleteKeyHistory({{{{ h.id }}}})">删除</button>
|
||||||
|
</{t}>
|
||||||
|
<{t} class="pos-meta">
|
||||||
|
<span class="pos-meta-item">{{{{ h.monitor_type }}}}</span>
|
||||||
|
<span class="pos-meta-item">{{{{ h.close_reason }}}}</span>
|
||||||
|
<span class="pos-meta-item">{{{{ (h.closed_at or '-')[:16] }}}}</span>
|
||||||
|
</{t}>
|
||||||
|
<{t} class="pos-meta">
|
||||||
|
<span class="pos-meta-item">上: {{{{ h.upper }}}} 下: {{{{ h.lower }}}}</span>
|
||||||
|
<span class="pos-meta-item">提醒: {{{{ h.notification_count }}}}</span>
|
||||||
|
</{t}>
|
||||||
|
{{% if h.last_alert_message %}}<{t} style="font-size:.75rem;color:#aab;margin-top:6px;white-space:pre-wrap">{{{{ h.last_alert_message[:180] }}}}{{% if h.last_alert_message|length > 180 %}}…{{% endif %}}</{t}>{{% endif %}}
|
||||||
|
</{t}>
|
||||||
|
{{% else %}}
|
||||||
|
<{t} class="pos-empty">暂无历史</{t}>
|
||||||
|
{{% endfor %}}
|
||||||
|
</{t}>
|
||||||
|
</{t}>
|
||||||
|
</{t}>
|
||||||
|
{{% elif page == 'trade' %}}
|
||||||
|
<{t} class="dual-panel-grid" style="grid-column:1/-1">
|
||||||
|
<{t} class="card">
|
||||||
|
<{t} style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
||||||
|
<h2 style="margin-bottom:0">实盘下单监控</h2>
|
||||||
|
{{% if focus_order_id %}}
|
||||||
|
<a href="/order_focus?order_id={{{{ focus_order_id }}}}" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">放大查看K线(100根)</a>
|
||||||
|
{{% else %}}
|
||||||
|
<span class="btn-del" style="background:#2f2f44;color:#9aa;cursor:not-allowed">暂无持仓可放大</span>
|
||||||
|
{{% endif %}}
|
||||||
|
</{t}>
|
||||||
|
<{t} class="rule-tip" id="order-rule-tip">
|
||||||
|
规则:最多 {{{{ max_active_positions }}}} 仓;BTC {{{{ btc_leverage }}}}x / 山寨 {{{{ alt_leverage }}}}x;
|
||||||
|
{{% if can_trade %}}可开仓{{% else %}}不可开仓(持仓已满或未到北京时间 {{{{ reset_hour }}}}:00){{% endif %}};
|
||||||
|
人工开仓盈亏比不得低于 {{{{ manual_min_planned_rr }}}}:1
|
||||||
|
</{t}>
|
||||||
|
<{t} class="rule-tip">
|
||||||
|
以损定仓:风险 {{{{ risk_percent }}}}% |移动保本:下单可勾选关闭;开启时 {{{{ breakeven_rr_trigger }}}}R 触发(每 1R 阶梯上移),偏移 {{{{ breakeven_offset_pct }}}}%
|
||||||
|
</{t}>
|
||||||
|
<{t} class="rule-tip">
|
||||||
|
划转:自动划转 {{{{ '开启' if auto_transfer_enabled else '关闭' }}}}(每天<strong>北京时间 {{{{ auto_transfer_bj_hour }}}}:00</strong>起该整点小时内尝试;账簿按 <strong>UTC 自然日</strong>去重;界面时间为北京;将 {{{{ auto_transfer_to }}}} 补足到 {{{{ auto_transfer_amount }}}}U,来自 {{{{ auto_transfer_from }}}})
|
||||||
|
</{t}>
|
||||||
|
<form action="/manual_transfer" method="post" class="form-row">
|
||||||
|
<input name="amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额U" required>
|
||||||
|
<select name="from_account">
|
||||||
|
<option value="funding" {{% if auto_transfer_from == 'funding' %}}selected{{% endif %}}>from: funding</option>
|
||||||
|
<option value="swap" {{% if auto_transfer_from == 'swap' %}}selected{{% endif %}}>from: swap</option>
|
||||||
|
<option value="spot" {{% if auto_transfer_from == 'spot' %}}selected{{% endif %}}>from: spot</option>
|
||||||
|
</select>
|
||||||
|
<select name="to_account">
|
||||||
|
<option value="swap" {{% if auto_transfer_to == 'swap' %}}selected{{% endif %}}>to: swap</option>
|
||||||
|
<option value="funding" {{% if auto_transfer_to == 'funding' %}}selected{{% endif %}}>to: funding</option>
|
||||||
|
<option value="spot" {{% if auto_transfer_to == 'spot' %}}selected{{% endif %}}>to: spot</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit">手动划转</button>
|
||||||
|
</form>
|
||||||
|
<form id="add-order-form" action="/add_order" method="post" class="form-row">
|
||||||
|
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
||||||
|
<select id="order-direction" name="direction" required>
|
||||||
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
|
</select>
|
||||||
|
<select id="sltp-mode" name="sltp_mode">
|
||||||
|
<option value="price">止盈止损:价格模式</option>
|
||||||
|
<option value="pct">止盈止损:百分比模式</option>
|
||||||
|
</select>
|
||||||
|
<select name="trade_style" required>
|
||||||
|
<option value="trend">趋势单</option>
|
||||||
|
<option value="swing">波段单</option>
|
||||||
|
</select>
|
||||||
|
<input id="order-leverage" name="leverage" type="number" min="1" step="1" placeholder="杠杆(可选)">
|
||||||
|
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
|
||||||
|
<input type="checkbox" name="breakeven_enabled" value="1" checked> 启用移动保本(关闭则仅保留初始止损与交易所挂单)
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
|
||||||
|
<input type="checkbox" name="order_chart" value="true"> 开仓后生成多周期K线图(各周期100根,含开平仓标记)
|
||||||
|
</label>
|
||||||
|
<span style="display:flex;align-items:center;padding:0 10px;font-size:.8rem;color:#8fc8ff">成交价自动取交易所实时+成交回报</span>
|
||||||
|
<input id="order-sl" name="sl" step="any" placeholder="止损价格" required>
|
||||||
|
<input id="order-tp" name="tgt" step="any" placeholder="止盈价格" required>
|
||||||
|
<input id="order-sl-pct" name="sl_pct" type="number" min="0.01" step="0.01" placeholder="止损%" style="display:none">
|
||||||
|
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
|
||||||
|
<button type="submit">开仓(以损定仓)</button>
|
||||||
|
</form>
|
||||||
|
</{t}>
|
||||||
|
<{t} class="card">
|
||||||
|
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||||
|
<{t} class="panel-scroll pos-list">
|
||||||
|
{order_loop}
|
||||||
|
</{t}>
|
||||||
|
</{t}>
|
||||||
|
</{t}>
|
||||||
|
{{% endif %}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def patch_nav(text: str) -> str:
|
||||||
|
old = '<a href="/trade" class="{% if page == \'trade\' %}active{% endif %}">交易执行</a>'
|
||||||
|
new = (
|
||||||
|
'<a href="/key_monitor" class="{% if page == \'key_monitor\' %}active{% endif %}">关键位监控</a>\n'
|
||||||
|
' <a href="/trade" class="{% if page == \'trade\' %}active{% endif %}">实盘下单</a>'
|
||||||
|
)
|
||||||
|
if "关键位监控" not in text:
|
||||||
|
text = text.replace(old, new)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def patch_js(text: str) -> str:
|
||||||
|
# page id on body
|
||||||
|
if 'id="page-trade"' not in text:
|
||||||
|
text = text.replace("<body>", '<body data-page="{{ page }}">', 1)
|
||||||
|
if "MANUAL_MIN_PLANNED_RR" not in text:
|
||||||
|
insert = """
|
||||||
|
const MANUAL_MIN_PLANNED_RR = {{ manual_min_planned_rr }};
|
||||||
|
function calcClientRr(direction, entry, sl, tp){
|
||||||
|
const e = Number(entry), s = Number(sl), t = Number(tp);
|
||||||
|
if(!Number.isFinite(e) || !Number.isFinite(s) || !Number.isFinite(t)) return null;
|
||||||
|
if(direction === 'short'){
|
||||||
|
if(s <= e || t >= e) return null;
|
||||||
|
return (e - t) / (s - e);
|
||||||
|
}
|
||||||
|
if(s >= e || t <= e) return null;
|
||||||
|
return (t - e) / (e - s);
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
text = text.replace("let latestAvailableUsdt = null;", insert + "\nlet latestAvailableUsdt = null;")
|
||||||
|
if "add-order-form" not in text or "calcClientRr" in text and "addOrderForm" not in text:
|
||||||
|
hook = """
|
||||||
|
const addOrderForm = document.getElementById("add-order-form");
|
||||||
|
if(addOrderForm){
|
||||||
|
addOrderForm.addEventListener("submit", function(ev){
|
||||||
|
const direction = (document.getElementById("order-direction")||{}).value || "long";
|
||||||
|
const mode = (document.getElementById("sltp-mode")||{}).value || "price";
|
||||||
|
let sl, tp, entry;
|
||||||
|
if(mode === "pct"){
|
||||||
|
alert("百分比模式请确认盈亏比后再提交;建议使用价格模式以便校验。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sl = Number((document.getElementById("order-sl")||{}).value);
|
||||||
|
tp = Number((document.getElementById("order-tp")||{}).value);
|
||||||
|
entry = sl;
|
||||||
|
fetch(`/api/order_defaults?symbol=${encodeURIComponent((document.getElementById("order-symbol")||{}).value||"")}&direction=${encodeURIComponent(direction)}`)
|
||||||
|
.then(r=>r.json())
|
||||||
|
.then(data=>{
|
||||||
|
const px = data.last_price || data.price;
|
||||||
|
if(px) entry = Number(px);
|
||||||
|
const rr = calcClientRr(direction, entry, sl, tp);
|
||||||
|
if(rr === null || rr < MANUAL_MIN_PLANNED_RR){
|
||||||
|
alert(`计划盈亏比 ${rr === null ? '无效' : rr.toFixed(2)}:1 低于最低要求 ${MANUAL_MIN_PLANNED_RR}:1,已阻止人工下单。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addOrderForm.submit();
|
||||||
|
})
|
||||||
|
.catch(()=>{ ev.preventDefault(); alert("无法校验盈亏比,请稍后重试"); });
|
||||||
|
ev.preventDefault();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
text = text.replace("refreshOrderDefaults();", hook + "\nrefreshOrderDefaults();")
|
||||||
|
if "max_active_positions" not in text and "order-rule-tip" in text:
|
||||||
|
text = text.replace(
|
||||||
|
"规则:单仓;",
|
||||||
|
"规则:最多 {{ max_active_positions }} 仓;",
|
||||||
|
)
|
||||||
|
# account snapshot tip
|
||||||
|
old_tip = '`规则:单仓;BTC {{ btc_leverage }}x'
|
||||||
|
if old_tip in text:
|
||||||
|
text = text.replace(
|
||||||
|
old_tip,
|
||||||
|
"`规则:最多 ${data.max_active_positions || {{ max_active_positions }}} 仓;BTC {{ btc_leverage }}x",
|
||||||
|
)
|
||||||
|
text = text.replace(
|
||||||
|
'const canTradeText = data.can_trade ? "可开仓" : "不可开仓(有持仓或未到北京时间 {{ reset_hour }}:00)";',
|
||||||
|
'const canTradeText = data.can_trade ? "可开仓" : `不可开仓(持仓 ${data.active_count||0}/${data.max_active_positions||{{ max_active_positions }}} 或未到北京时间 {{ reset_hour }}:00)`;',
|
||||||
|
)
|
||||||
|
text = text.replace(
|
||||||
|
"if(!data.in_top30){",
|
||||||
|
"const rankMax = data.rank_max || 30;\n if(!data.in_top30){",
|
||||||
|
)
|
||||||
|
text = text.replace(
|
||||||
|
"不在前30,已拦截",
|
||||||
|
"不在前${rankMax},已拦截",
|
||||||
|
)
|
||||||
|
# conditional price refresh
|
||||||
|
if "data-page" in text and "refreshPriceSnapshotConditional" not in text:
|
||||||
|
text = text.replace(
|
||||||
|
"setInterval(refreshPriceSnapshot, {{ price_refresh_seconds * 1000 }});",
|
||||||
|
"""function refreshPriceSnapshotConditional(){
|
||||||
|
const page = document.body.getAttribute("data-page") || "";
|
||||||
|
fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{
|
||||||
|
const updatedEl = document.getElementById("price-last-updated");
|
||||||
|
if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at;
|
||||||
|
if(page === "key_monitor"){
|
||||||
|
(data.key_prices || []).forEach(k=>{
|
||||||
|
const pEl = document.getElementById(`key-price-${k.id}`);
|
||||||
|
if(pEl){ pEl.innerText = k.price_display || (Number.isFinite(Number(k.price)) ? Number(k.price).toFixed(6) : "-"); paintPriceTrend(pEl, `k-${k.id}`, Number(k.price)); }
|
||||||
|
const upEl = document.getElementById(`key-up-diff-${k.id}`);
|
||||||
|
if(upEl) upEl.innerText = `${formatSigned(k.upper_diff, 4)} (${formatSigned(k.upper_pct, 2)}%)`;
|
||||||
|
const lowEl = document.getElementById(`key-low-diff-${k.id}`);
|
||||||
|
if(lowEl) lowEl.innerText = `${formatSigned(k.lower_diff, 4)} (${formatSigned(k.lower_pct, 2)}%)`;
|
||||||
|
const gateEl = document.getElementById(`key-gate-${k.id}`);
|
||||||
|
if(gateEl){ gateEl.innerText = k.gate_summary || "-"; gateEl.style.color = k.gate_ok ? "#4cd97f" : "#ff8f8f"; }
|
||||||
|
const gateMetricEl = document.getElementById(`key-gate-metrics-${k.id}`);
|
||||||
|
if(gateMetricEl) gateMetricEl.innerText = k.gate_metrics || "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(page === "trade"){
|
||||||
|
(data.order_prices || []).forEach(o=>{
|
||||||
|
const pEl = document.getElementById(`order-price-${o.id}`);
|
||||||
|
if(pEl){
|
||||||
|
const hasMark = (()=>{ const x = o.exchange_mark_price; if(x===null||x===undefined||x==="")return false; const n=Number(x); return !Number.isNaN(n); })();
|
||||||
|
let disp = "";
|
||||||
|
if(hasMark && o.exchange_mark_price_display) disp = o.exchange_mark_price_display;
|
||||||
|
else if(o.price_display) disp = o.price_display;
|
||||||
|
else { const px = hasMark ? Number(o.exchange_mark_price) : Number(o.price); disp = Number.isFinite(px) ? px.toFixed(6) : "-"; }
|
||||||
|
pEl.innerText = disp;
|
||||||
|
const pxNum = hasMark ? Number(o.exchange_mark_price) : Number(o.price);
|
||||||
|
paintPriceTrend(pEl, `o-${o.id}`, Number.isFinite(pxNum) ? pxNum : px);
|
||||||
|
}
|
||||||
|
const exM = document.getElementById(`order-ex-margin-${o.id}`);
|
||||||
|
if(exM){
|
||||||
|
const mv = o.exchange_initial_margin;
|
||||||
|
const mn = (mv === null || mv === undefined || mv === "") ? NaN : Number(mv);
|
||||||
|
if(!Number.isNaN(mn)) exM.innerText = `${mn.toFixed(2)}U`;
|
||||||
|
else { const prc = (typeof data.positions_raw_count === "number") ? data.positions_raw_count : null; exM.innerText = (prc === 0) ? "无仓数据" : "-"; }
|
||||||
|
}
|
||||||
|
const pnlEl = document.getElementById(`order-pnl-${o.id}`);
|
||||||
|
if(pnlEl){
|
||||||
|
pnlEl.innerText = `${formatSigned(o.float_pnl, 2)}U (${formatSigned(o.float_pct, 2)}%)`;
|
||||||
|
pnlEl.classList.remove("price-up","price-down","price-flat");
|
||||||
|
if(Number(o.float_pnl) > 0) pnlEl.classList.add("price-up");
|
||||||
|
else if(Number(o.float_pnl) < 0) pnlEl.classList.add("price-down");
|
||||||
|
else pnlEl.classList.add("price-flat");
|
||||||
|
}
|
||||||
|
const rrEl = document.getElementById(`order-rr-${o.id}`);
|
||||||
|
if(rrEl) rrEl.innerText = formatRrRatio(o.rr_ratio);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch(()=>{});
|
||||||
|
}
|
||||||
|
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});""",
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
for path in PATHS:
|
||||||
|
if not path.exists():
|
||||||
|
print("skip", path)
|
||||||
|
continue
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
start = text.find(KEY_START)
|
||||||
|
if start < 0:
|
||||||
|
start = text.find(KEY_START_ALT)
|
||||||
|
end = text.find(RECORDS_START)
|
||||||
|
if start < 0 or end < 0:
|
||||||
|
raise SystemExit(f"markers not found: {path}")
|
||||||
|
old = text[start:end]
|
||||||
|
m = re.search(r"(\{% for o in order %\}.*?\{% endfor %\})", old, re.S)
|
||||||
|
if not m:
|
||||||
|
raise SystemExit(f"order loop not found: {path}")
|
||||||
|
order_loop = m.group(1)
|
||||||
|
section = build_section(order_loop)
|
||||||
|
section = section.replace("{{%", "{%").replace("%}}", "%}").replace("{{{{", "{{").replace("}}}}", "}}")
|
||||||
|
out = text[:start] + section + "\n\n" + text[end:]
|
||||||
|
out = patch_nav(out)
|
||||||
|
out = patch_js(out)
|
||||||
|
path.write_text(out, encoding="utf-8")
|
||||||
|
print("patched", path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Apply binance app.py risk/layout changes to gate app.py (pattern replace)."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
binance = Path(__file__).resolve().parent.parent / "app.py"
|
||||||
|
gate = Path(r"c:\Users\dekun\Desktop\crypto_monitor\crypto_monitor_gate\app.py")
|
||||||
|
|
||||||
|
b = binance.read_text(encoding="utf-8")
|
||||||
|
g = gate.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# 1) env block
|
||||||
|
old_env = """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"))"""
|
||||||
|
|
||||||
|
new_env = """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")"""
|
||||||
|
|
||||||
|
if old_env in g:
|
||||||
|
g = g.replace(old_env, new_env)
|
||||||
|
|
||||||
|
# 2) DB migration snippet
|
||||||
|
snip = """ try:
|
||||||
|
c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
c.execute("""
|
||||||
|
if snip not in g and 'key_sizing_capital_snapshot' not in g:
|
||||||
|
g = g.replace(
|
||||||
|
' c.execute(\n """CREATE TABLE IF NOT EXISTS key_monitor_history',
|
||||||
|
""" 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""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3) precheck block - extract from binance
|
||||||
|
import re
|
||||||
|
m = re.search(
|
||||||
|
r"def get_active_position_count\(conn\):.*?return True, \"\"\n\n\ndef prepare_order_amount",
|
||||||
|
b,
|
||||||
|
re.S,
|
||||||
|
)
|
||||||
|
if m and "get_active_position_count" not in g:
|
||||||
|
g = g.replace(
|
||||||
|
"def precheck_risk(conn, symbol, direction):\n now = app_now()\n if not trading_day_reset_allows_new_open(now):\n return False, f\"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓\"\n active_count = conn.execute(\"SELECT COUNT(*) FROM order_monitors WHERE status='active'\").fetchone()[0]\n if active_count > 0:\n return False, \"一次只能持有一个仓位\"\n if direction not in (\"long\", \"short\"):\n return False, \"方向必须为 long 或 short\"\n if symbol.upper().startswith(\"BTC\") or symbol.upper().startswith(\"ETH\"):\n expected = BTC_LEVERAGE\n else:\n expected = ALT_LEVERAGE\n if expected <= 0:\n return False, \"杠杆配置异常\"\n return True, \"\"\n\n\ndef prepare_order_amount",
|
||||||
|
m.group(0),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4) render_main_page can_trade + template vars + route
|
||||||
|
if "key_monitor_page" not in g:
|
||||||
|
g = g.replace(
|
||||||
|
" can_trade = trading_day_reset_allows_new_open(now) and active_count == 0\n conn.close()\n return render_template(",
|
||||||
|
""" 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(""",
|
||||||
|
)
|
||||||
|
g = g.replace(
|
||||||
|
" exchange_display=EXCHANGE_DISPLAY_NAME,\n )\n\n\n@app.route(\"/\")\n@login_required\ndef index():\n return redirect(\"/trade\")\n\n\n@app.route(\"/trade\")",
|
||||||
|
""" 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
return redirect("/trade")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/key_monitor")
|
||||||
|
@login_required
|
||||||
|
def key_monitor_page():
|
||||||
|
return render_main_page("key_monitor")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/trade")""",
|
||||||
|
)
|
||||||
|
|
||||||
|
# api account
|
||||||
|
g = g.replace(
|
||||||
|
" active_count = conn.execute(\"SELECT COUNT(*) FROM order_monitors WHERE status='active'\").fetchone()[0]\n conn.close()\n can_trade = trading_day_reset_allows_new_open(now) and active_count == 0",
|
||||||
|
" active_count = get_active_position_count(conn)\n conn.close()\n can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS",
|
||||||
|
)
|
||||||
|
if '"max_active_positions"' not in g:
|
||||||
|
g = g.replace(
|
||||||
|
'"can_trade": can_trade,\n "trading_day": trading_day\n })',
|
||||||
|
'"can_trade": can_trade,\n "max_active_positions": MAX_ACTIVE_POSITIONS,\n "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,\n "trading_day": trading_day\n })',
|
||||||
|
)
|
||||||
|
|
||||||
|
gate.write_text(g, encoding="utf-8")
|
||||||
|
print("gate app partially synced; manual review _key_hard_checks add_order still needed")
|
||||||
@@ -81,19 +81,21 @@
|
|||||||
.detail-modal .panel-body{white-space:pre-wrap;line-height:1.5;font-size:.86rem;color:#e5e9ff}
|
.detail-modal .panel-body{white-space:pre-wrap;line-height:1.5;font-size:.86rem;color:#e5e9ff}
|
||||||
.detail-modal .panel-image{margin-top:10px;max-width:min(100%,680px);border-radius:8px;cursor:pointer;border:1px solid #2a3150}
|
.detail-modal .panel-image{margin-top:10px;max-width:min(100%,680px);border-radius:8px;cursor:pointer;border:1px solid #2a3150}
|
||||||
.table-wrap{overflow-x:auto}
|
.table-wrap{overflow-x:auto}
|
||||||
.monitor-card{grid-column:1}
|
.dual-panel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:stretch}
|
||||||
.order-card{grid-column:2}
|
.dual-panel-grid .card{height:100%;display:flex;flex-direction:column}
|
||||||
|
.panel-scroll{flex:1;min-height:280px;max-height:420px;overflow:auto}
|
||||||
.records-card{grid-column:1/-1}
|
.records-card{grid-column:1/-1}
|
||||||
.review-card{grid-column:1/-1}
|
.review-card{grid-column:1/-1}
|
||||||
@media (min-width: 1900px){
|
@media (min-width: 1900px){
|
||||||
.container{max-width:2100px}
|
.container{max-width:2100px}
|
||||||
.monitor-card .list,.order-card .pos-list{max-height:420px}
|
.panel-scroll,.pos-list{max-height:420px}
|
||||||
.records-card .table-wrap{max-height:620px;overflow:auto}
|
.records-card .table-wrap{max-height:620px;overflow:auto}
|
||||||
}
|
}
|
||||||
@media (max-width: 1400px){
|
@media (max-width: 1400px){
|
||||||
.container{width:min(99vw,1600px)}
|
.container{width:min(99vw,1600px)}
|
||||||
.grid{grid-template-columns:1fr}
|
.grid{grid-template-columns:1fr}
|
||||||
.monitor-card,.order-card,.records-card,.review-card{grid-column:auto}
|
.dual-panel-grid{grid-template-columns:1fr}
|
||||||
|
.records-card,.review-card{grid-column:auto}
|
||||||
.panel-list{grid-template-columns:1fr}
|
.panel-list{grid-template-columns:1fr}
|
||||||
}
|
}
|
||||||
@media (max-width: 960px){
|
@media (max-width: 960px){
|
||||||
@@ -149,7 +151,7 @@
|
|||||||
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
|
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body data-page="{{ page }}">
|
||||||
{% macro period_stats(title, s) %}
|
{% macro period_stats(title, s) %}
|
||||||
<div class="stats-period-block">
|
<div class="stats-period-block">
|
||||||
<h3>{{ title }}</h3>
|
<h3>{{ title }}</h3>
|
||||||
@@ -175,7 +177,8 @@
|
|||||||
<div class="exchange-tag">{{ exchange_display }}</div>
|
<div class="exchange-tag">{{ exchange_display }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-nav">
|
<div class="top-nav">
|
||||||
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}">交易执行</a>
|
<a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}">关键位监控</a>
|
||||||
|
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}">实盘下单</a>
|
||||||
<a href="/records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
|
<a href="/records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
|
||||||
<a href="/stats" class="{% if page == 'stats' %}active{% endif %}">统计分析</a>
|
<a href="/stats" class="{% if page == 'stats' %}active{% endif %}">统计分析</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,10 +203,11 @@
|
|||||||
<div class="rule-tip">实时价格更新时间:<span id="price-last-updated">--</span>(北京时间 UTC+8)</div>
|
<div class="rule-tip">实时价格更新时间:<span id="price-last-updated">--</span>(北京时间 UTC+8)</div>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{% if page == 'trade' %}
|
{% if page == 'key_monitor' %}
|
||||||
<div class="card monitor-card">
|
<div class="dual-panel-grid" style="grid-column:1/-1">
|
||||||
|
<div class="card">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
||||||
<h2 style="margin-bottom:0">关键位监控(5m)</h2>
|
<h2 style="margin-bottom:0">关键位监控</h2>
|
||||||
{% if focus_key_id %}
|
{% if focus_key_id %}
|
||||||
<a href="/key_focus?key_id={{ focus_key_id }}" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">放大查看K线(默认200根)</a>
|
<a href="/key_focus?key_id={{ focus_key_id }}" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">放大查看K线(默认200根)</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -225,44 +229,69 @@
|
|||||||
<input name="lower" step="0.0001" placeholder="下沿/支撑" required>
|
<input name="lower" step="0.0001" placeholder="下沿/支撑" required>
|
||||||
<button type="submit">添加</button>
|
<button type="submit">添加</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="list">
|
<div class="rule-tip">{{ key_gate_rule_text }}</div>
|
||||||
|
<div class="panel-scroll pos-list">
|
||||||
{% for k in key %}
|
{% for k in key %}
|
||||||
<div class="list-item" id="key-row-{{ k.id }}">
|
<div class="pos-card" id="key-row-{{ k.id }}">
|
||||||
<div><strong>{{ k.symbol }}</strong> | {{ k.monitor_type }} | <span class="badge direction">{{ '做多' if k.direction == 'long' else '做空' }}</span></div>
|
<div class="pos-card-head">
|
||||||
<div>
|
<div class="pos-card-symbol">
|
||||||
上:{{ k.upper }} 下:{{ k.lower }}
|
<strong>{{ k.symbol }}</strong>
|
||||||
| 已提醒:{{ k.notification_count or 0 }}/{{ k.max_notify or 3 }}
|
<span class="pos-side-badge {{ 'pos-side-long' if k.direction == 'long' else 'pos-side-short' }}">{{ '做多' if k.direction == 'long' else '做空' }}</span>
|
||||||
| 现价:<span id="key-price-{{ k.id }}">-</span>
|
<span class="badge direction" style="margin-left:4px">{{ k.monitor_type }}</span>
|
||||||
| 距上沿:<span id="key-up-diff-{{ k.id }}">-</span>
|
</div>
|
||||||
| 距下沿:<span id="key-low-diff-{{ k.id }}">-</span>
|
<button type="button" class="pos-close-btn" style="border:none;cursor:pointer" onclick="deleteKeyMonitor({{ k.id }})">删</button>
|
||||||
| 门控:<span id="key-gate-{{ k.id }}" style="color:#9aa">-</span>
|
|
||||||
<span id="key-gate-metrics-{{ k.id }}" style="margin-left:8px;color:#8fc8ff;font-size:.78rem"></span>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn-del" style="border:none;cursor:pointer" onclick="deleteKeyMonitor({{ k.id }})">删</button>
|
<div class="pos-meta">
|
||||||
|
<span class="pos-meta-item">上沿: {{ k.upper }}</span>
|
||||||
|
<span class="pos-meta-item">下沿: {{ k.lower }}</span>
|
||||||
|
<span class="pos-meta-item">已提醒: {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pos-grid">
|
||||||
|
<div class="pos-cell"><span class="pos-label">现价</span><span class="pos-value" id="key-price-{{ k.id }}">-</span></div>
|
||||||
|
<div class="pos-cell"><span class="pos-label">距上沿</span><span class="pos-value" id="key-up-diff-{{ k.id }}">-</span></div>
|
||||||
|
<div class="pos-cell"><span class="pos-label">距下沿</span><span class="pos-value" id="key-low-diff-{{ k.id }}">-</span></div>
|
||||||
|
<div class="pos-cell"><span class="pos-label">门控</span><span class="pos-value" id="key-gate-{{ k.id }}" style="color:#9aa">-</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="pos-meta" style="margin-top:8px"><span class="pos-meta-item" id="key-gate-metrics-{{ k.id }}" style="color:#8fc8ff"></span></div>
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="pos-empty">暂无监控中的关键位</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="key-history">
|
</div>
|
||||||
<h3>关键位历史(满次提醒或手动删除)</h3>
|
<div class="card">
|
||||||
<div class="sub">每种关键位触发后<strong>一次性结案</strong>并写入下方历史:箱体/收敛在计划 RR 达标时<strong>自动市价开仓</strong>;阻力/支撑仅<strong>单次企业微信提醒</strong>。详见项目根目录「关键位自动下单说明.md」。满多次提醒的旧逻辑已不适用。</div>
|
<h2 style="margin-bottom:8px">关键位历史</h2>
|
||||||
<div class="list">
|
<div class="sub" style="font-size:.72rem;color:#8892b0;margin-bottom:8px">失效或已结案的关键位</div>
|
||||||
{% for h in key_history %}
|
<div class="panel-scroll pos-list">
|
||||||
<div class="list-item">
|
{% for h in key_history %}
|
||||||
<div>
|
<div class="pos-card">
|
||||||
<strong>{{ h.symbol }}</strong> | {{ h.monitor_type }} | {{ '做多' if h.direction == 'long' else '做空' }} | {{ h.close_reason }}
|
<div class="pos-card-head">
|
||||||
<button type="button" class="table-del" style="margin-left:8px" onclick="deleteKeyHistory({{ h.id }})">删除</button>
|
<div class="pos-card-symbol">
|
||||||
|
<strong>{{ h.symbol }}</strong>
|
||||||
|
<span class="pos-side-badge {{ 'pos-side-long' if h.direction == 'long' else 'pos-side-short' }}">{{ '做多' if h.direction == 'long' else '做空' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>上:{{ h.upper }} 下:{{ h.lower }} | 提醒次数:{{ h.notification_count }} | {{ (h.closed_at or '-')[:16] }}</div>
|
<button type="button" class="table-del" onclick="deleteKeyHistory({{ h.id }})">删除</button>
|
||||||
{% if h.last_alert_message %}<div style="font-size:.78rem;color:#aab;margin-top:4px;white-space:pre-wrap">{{ h.last_alert_message[:200] }}{% if h.last_alert_message|length > 200 %}…{% endif %}</div>{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
<div class="pos-meta">
|
||||||
<div class="list-item" style="color:#8892b0">暂无历史</div>
|
<span class="pos-meta-item">{{ h.monitor_type }}</span>
|
||||||
{% endfor %}
|
<span class="pos-meta-item">{{ h.close_reason }}</span>
|
||||||
|
<span class="pos-meta-item">{{ (h.closed_at or '-')[:16] }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pos-meta">
|
||||||
|
<span class="pos-meta-item">上: {{ h.upper }} 下: {{ h.lower }}</span>
|
||||||
|
<span class="pos-meta-item">提醒: {{ h.notification_count }}</span>
|
||||||
|
</div>
|
||||||
|
{% if h.last_alert_message %}<div style="font-size:.75rem;color:#aab;margin-top:6px;white-space:pre-wrap">{{ h.last_alert_message[:180] }}{% if h.last_alert_message|length > 180 %}…{% endif %}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="pos-empty">暂无历史</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card order-card">
|
{% elif page == 'trade' %}
|
||||||
|
<div class="dual-panel-grid" style="grid-column:1/-1">
|
||||||
|
<div class="card">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
||||||
<h2 style="margin-bottom:0">实盘下单监控</h2>
|
<h2 style="margin-bottom:0">实盘下单监控</h2>
|
||||||
{% if focus_order_id %}
|
{% if focus_order_id %}
|
||||||
@@ -272,9 +301,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="rule-tip" id="order-rule-tip">
|
<div class="rule-tip" id="order-rule-tip">
|
||||||
规则:单仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;
|
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;
|
||||||
{% if can_trade %}可开仓{% else %}不可开仓(有持仓或未到北京时间 {{ reset_hour }}:00){% endif %};
|
{% if can_trade %}可开仓{% else %}不可开仓(持仓已满或未到北京时间 {{ reset_hour }}:00){% endif %};
|
||||||
按风险比例自动计算仓位
|
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
|
||||||
</div>
|
</div>
|
||||||
<div class="rule-tip">
|
<div class="rule-tip">
|
||||||
以损定仓:风险 {{ risk_percent }}% |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
|
以损定仓:风险 {{ risk_percent }}% |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
|
||||||
@@ -296,7 +325,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<button type="submit">手动划转</button>
|
<button type="submit">手动划转</button>
|
||||||
</form>
|
</form>
|
||||||
<form action="/add_order" method="post" class="form-row">
|
<form id="add-order-form" action="/add_order" method="post" class="form-row">
|
||||||
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
||||||
<select id="order-direction" name="direction" required>
|
<select id="order-direction" name="direction" required>
|
||||||
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
@@ -323,9 +352,10 @@
|
|||||||
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
|
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
|
||||||
<button type="submit">开仓(以损定仓)</button>
|
<button type="submit">开仓(以损定仓)</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="pos-section">
|
</div>
|
||||||
<div class="pos-section-title">实时持仓</div>
|
<div class="card">
|
||||||
<div class="pos-list">
|
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||||
|
<div class="panel-scroll pos-list">
|
||||||
{% for o in order %}
|
{% for o in order %}
|
||||||
<div class="pos-card" id="order-row-{{ o.id }}">
|
<div class="pos-card" id="order-row-{{ o.id }}">
|
||||||
<div class="pos-card-head">
|
<div class="pos-card-head">
|
||||||
@@ -387,11 +417,13 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="pos-empty">暂无持仓</div>
|
<div class="pos-empty">暂无持仓</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% if page == 'records' %}
|
{% if page == 'records' %}
|
||||||
<div class="card full records-card">
|
<div class="card full records-card">
|
||||||
<h2>交易记录 & 错过机会</h2>
|
<h2>交易记录 & 错过机会</h2>
|
||||||
@@ -1148,8 +1180,9 @@ if(keyForm){
|
|||||||
alert((data && data.msg) || "日成交量排名读取失败");
|
alert((data && data.msg) || "日成交量排名读取失败");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const rankMax = data.rank_max || 30;
|
||||||
if(!data.in_top30){
|
if(!data.in_top30){
|
||||||
alert(`${data.symbol} 当前日成交量排名 ${data.rank}/${data.total},不在前30,已拦截。`);
|
alert(`${data.symbol} 当前日成交量排名 ${data.rank}/${data.total},不在前${rankMax},已拦截。`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
keyForm.submit();
|
keyForm.submit();
|
||||||
@@ -1163,6 +1196,19 @@ setTimeout(() => {
|
|||||||
if(document.getElementById("review-list")) loadReviews();
|
if(document.getElementById("review-list")) loadReviews();
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
|
|
||||||
|
const MANUAL_MIN_PLANNED_RR = {{ manual_min_planned_rr }};
|
||||||
|
function calcClientRr(direction, entry, sl, tp){
|
||||||
|
const e = Number(entry), s = Number(sl), t = Number(tp);
|
||||||
|
if(!Number.isFinite(e) || !Number.isFinite(s) || !Number.isFinite(t)) return null;
|
||||||
|
if(direction === 'short'){
|
||||||
|
if(s <= e || t >= e) return null;
|
||||||
|
return (e - t) / (s - e);
|
||||||
|
}
|
||||||
|
if(s >= e || t <= e) return null;
|
||||||
|
return (t - e) / (e - s);
|
||||||
|
}
|
||||||
|
|
||||||
let latestAvailableUsdt = null;
|
let latestAvailableUsdt = null;
|
||||||
const lastPriceMap = {};
|
const lastPriceMap = {};
|
||||||
|
|
||||||
@@ -1309,11 +1355,11 @@ function refreshAccountSnapshot(){
|
|||||||
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
|
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
|
||||||
latestAvailableUsdt = Number(data.available_trading_usdt);
|
latestAvailableUsdt = Number(data.available_trading_usdt);
|
||||||
}
|
}
|
||||||
const canTradeText = data.can_trade ? "可开仓" : "不可开仓(有持仓或未到北京时间 {{ reset_hour }}:00)";
|
const canTradeText = data.can_trade ? "可开仓" : `不可开仓(持仓 ${data.active_count||0}/${data.max_active_positions||{{ max_active_positions }}} 或未到北京时间 {{ reset_hour }}:00)`;
|
||||||
const tip = document.getElementById("order-rule-tip");
|
const tip = document.getElementById("order-rule-tip");
|
||||||
const avail = (latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)) ? `;交易账户可用约${latestAvailableUsdt.toFixed(2)}U` : "";
|
const avail = (latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)) ? `;交易账户可用约${latestAvailableUsdt.toFixed(2)}U` : "";
|
||||||
if(tip){
|
if(tip){
|
||||||
tip.innerText = `规则:单仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;${canTradeText}${avail}`;
|
tip.innerText = `规则:最多 ${data.max_active_positions || {{ max_active_positions }}} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;${canTradeText}${avail}`;
|
||||||
}
|
}
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
@@ -1365,10 +1411,99 @@ if(_journalFormEl){
|
|||||||
if(_jErSel) _jErSel.addEventListener("change", syncJournalEntryReasonOtherUi);
|
if(_jErSel) _jErSel.addEventListener("change", syncJournalEntryReasonOtherUi);
|
||||||
syncJournalEntryReasonOtherUi();
|
syncJournalEntryReasonOtherUi();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addOrderForm = document.getElementById("add-order-form");
|
||||||
|
if(addOrderForm){
|
||||||
|
addOrderForm.addEventListener("submit", function(ev){
|
||||||
|
if(addOrderForm.dataset.rrOk === "1"){
|
||||||
|
addOrderForm.dataset.rrOk = "0";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ev.preventDefault();
|
||||||
|
const direction = (document.getElementById("order-direction")||{}).value || "long";
|
||||||
|
const mode = (document.getElementById("sltp-mode")||{}).value || "price";
|
||||||
|
let sl, tp, entry;
|
||||||
|
if(mode === "pct"){
|
||||||
|
alert("百分比模式请确认盈亏比后再提交;建议使用价格模式以便校验。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sl = Number((document.getElementById("order-sl")||{}).value);
|
||||||
|
tp = Number((document.getElementById("order-tp")||{}).value);
|
||||||
|
entry = sl;
|
||||||
|
fetch(`/api/order_defaults?symbol=${encodeURIComponent((document.getElementById("order-symbol")||{}).value||"")}&direction=${encodeURIComponent(direction)}`)
|
||||||
|
.then(r=>r.json())
|
||||||
|
.then(data=>{
|
||||||
|
const px = data.last_price || data.price;
|
||||||
|
if(px) entry = Number(px);
|
||||||
|
const rr = calcClientRr(direction, entry, sl, tp);
|
||||||
|
if(rr === null || rr < MANUAL_MIN_PLANNED_RR){
|
||||||
|
alert(`计划盈亏比 ${rr === null ? '无效' : rr.toFixed(2)}:1 低于最低要求 ${MANUAL_MIN_PLANNED_RR}:1,已阻止人工下单。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addOrderForm.dataset.rrOk = "1";
|
||||||
|
addOrderForm.submit();
|
||||||
|
})
|
||||||
|
.catch(()=>{ alert("无法校验盈亏比,请稍后重试"); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
refreshOrderDefaults();
|
refreshOrderDefaults();
|
||||||
refreshPriceSnapshot();
|
refreshPriceSnapshotConditional();
|
||||||
setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }});
|
setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }});
|
||||||
setInterval(refreshPriceSnapshot, {{ price_refresh_seconds * 1000 }});
|
function refreshPriceSnapshotConditional(){
|
||||||
|
const page = document.body.getAttribute("data-page") || "";
|
||||||
|
fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{
|
||||||
|
const updatedEl = document.getElementById("price-last-updated");
|
||||||
|
if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at;
|
||||||
|
if(page === "key_monitor"){
|
||||||
|
(data.key_prices || []).forEach(k=>{
|
||||||
|
const pEl = document.getElementById(`key-price-${k.id}`);
|
||||||
|
if(pEl){ pEl.innerText = k.price_display || (Number.isFinite(Number(k.price)) ? Number(k.price).toFixed(6) : "-"); paintPriceTrend(pEl, `k-${k.id}`, Number(k.price)); }
|
||||||
|
const upEl = document.getElementById(`key-up-diff-${k.id}`);
|
||||||
|
if(upEl) upEl.innerText = `${formatSigned(k.upper_diff, 4)} (${formatSigned(k.upper_pct, 2)}%)`;
|
||||||
|
const lowEl = document.getElementById(`key-low-diff-${k.id}`);
|
||||||
|
if(lowEl) lowEl.innerText = `${formatSigned(k.lower_diff, 4)} (${formatSigned(k.lower_pct, 2)}%)`;
|
||||||
|
const gateEl = document.getElementById(`key-gate-${k.id}`);
|
||||||
|
if(gateEl){ gateEl.innerText = k.gate_summary || "-"; gateEl.style.color = k.gate_ok ? "#4cd97f" : "#ff8f8f"; }
|
||||||
|
const gateMetricEl = document.getElementById(`key-gate-metrics-${k.id}`);
|
||||||
|
if(gateMetricEl) gateMetricEl.innerText = k.gate_metrics || "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(page === "trade"){
|
||||||
|
(data.order_prices || []).forEach(o=>{
|
||||||
|
const pEl = document.getElementById(`order-price-${o.id}`);
|
||||||
|
if(pEl){
|
||||||
|
const hasMark = (()=>{ const x = o.exchange_mark_price; if(x===null||x===undefined||x==="")return false; const n=Number(x); return !Number.isNaN(n); })();
|
||||||
|
let disp = "";
|
||||||
|
if(hasMark && o.exchange_mark_price_display) disp = o.exchange_mark_price_display;
|
||||||
|
else if(o.price_display) disp = o.price_display;
|
||||||
|
else { const px = hasMark ? Number(o.exchange_mark_price) : Number(o.price); disp = Number.isFinite(px) ? px.toFixed(6) : "-"; }
|
||||||
|
pEl.innerText = disp;
|
||||||
|
const pxNum = hasMark ? Number(o.exchange_mark_price) : Number(o.price);
|
||||||
|
paintPriceTrend(pEl, `o-${o.id}`, Number.isFinite(pxNum) ? pxNum : px);
|
||||||
|
}
|
||||||
|
const exM = document.getElementById(`order-ex-margin-${o.id}`);
|
||||||
|
if(exM){
|
||||||
|
const mv = o.exchange_initial_margin;
|
||||||
|
const mn = (mv === null || mv === undefined || mv === "") ? NaN : Number(mv);
|
||||||
|
if(!Number.isNaN(mn)) exM.innerText = `${mn.toFixed(2)}U`;
|
||||||
|
else { const prc = (typeof data.positions_raw_count === "number") ? data.positions_raw_count : null; exM.innerText = (prc === 0) ? "无仓数据" : "-"; }
|
||||||
|
}
|
||||||
|
const pnlEl = document.getElementById(`order-pnl-${o.id}`);
|
||||||
|
if(pnlEl){
|
||||||
|
pnlEl.innerText = `${formatSigned(o.float_pnl, 2)}U (${formatSigned(o.float_pct, 2)}%)`;
|
||||||
|
pnlEl.classList.remove("price-up","price-down","price-flat");
|
||||||
|
if(Number(o.float_pnl) > 0) pnlEl.classList.add("price-up");
|
||||||
|
else if(Number(o.float_pnl) < 0) pnlEl.classList.add("price-down");
|
||||||
|
else pnlEl.classList.add("price-flat");
|
||||||
|
}
|
||||||
|
const rrEl = document.getElementById(`order-rr-${o.id}`);
|
||||||
|
if(rrEl) rrEl.innerText = formatRrRatio(o.rr_ratio);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch(()=>{});
|
||||||
|
}
|
||||||
|
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 界面与风控更新说明(Binance 实例)
|
||||||
|
|
||||||
|
## 顶栏导航(4 项)
|
||||||
|
|
||||||
|
| 顺序 | 名称 | 路由 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 1 | 关键位监控 | `/key_monitor` | 关键位添加、实时门控、历史 |
|
||||||
|
| 2 | 实盘下单 | `/trade` | 人工开仓、划转、实时持仓(**默认首页** `/` → `/trade`) |
|
||||||
|
| 3 | 交易记录与复盘 | `/records` | 未改动 |
|
||||||
|
| 4 | 统计分析 | `/stats` | 未改动 |
|
||||||
|
|
||||||
|
## 关键位监控页
|
||||||
|
|
||||||
|
- 标题去掉「5m」;规则条从 `.env` 读取(周期、确认K、量能、自动开仓盈亏比、日成交量排名)。
|
||||||
|
- 左列:活跃关键位,**pos-card** 样式展示现价/距上沿/距下沿/门控。
|
||||||
|
- 右列:关键位历史(失效/结案),与左列等高滚动。
|
||||||
|
|
||||||
|
## 实盘下单页
|
||||||
|
|
||||||
|
- 左列:实盘下单监控(表单、划转、规则)。
|
||||||
|
- 右列:实时持仓(独立模块)。
|
||||||
|
- **人工开仓门控**:计划盈亏比 < `MANUAL_MIN_PLANNED_RR`(默认 **1.4**)时前端弹窗 + 后端拒绝。
|
||||||
|
|
||||||
|
## 持仓与计仓
|
||||||
|
|
||||||
|
- `MAX_ACTIVE_POSITIONS` 默认 **1**(可在 `.env` 调大)。
|
||||||
|
- 关键位自动开仓:在已有持仓时,若 `KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true`,按**首笔开仓前**交易账户资金快照计仓(字段 `trading_sessions.key_sizing_capital_snapshot`)。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
详见 `.env.example` 中「关键位门控」「交易执行 / 人工风控」注释段。
|
||||||
|
|
||||||
|
## 升级步骤
|
||||||
|
|
||||||
|
1. `git pull` 后对比 `.env.example`,把新增变量合并进本地 `.env`。
|
||||||
|
2. 重启服务(如 `pm2 restart`);SQLite 会在启动时自动 `ALTER` 新列。
|
||||||
|
3. 浏览器强刷(Ctrl+F5)避免旧版 `index.html` 缓存。
|
||||||
@@ -73,21 +73,33 @@ GATE_TPSL_PRICE_TYPE=0
|
|||||||
# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 Gate·模拟)
|
# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 Gate·模拟)
|
||||||
# EXCHANGE_DISPLAY_NAME=Gate.io
|
# EXCHANGE_DISPLAY_NAME=Gate.io
|
||||||
|
|
||||||
# 关键位监控:5m收线突破过滤参数
|
# =============================================================================
|
||||||
|
# 关键位门控(页面「关键位监控」)
|
||||||
|
# =============================================================================
|
||||||
KLINE_TIMEFRAME=5m
|
KLINE_TIMEFRAME=5m
|
||||||
KEY_BREAKOUT_LIMIT_PCT=1.5
|
KEY_CONFIRM_BREAKOUT_BAR=-2
|
||||||
# 关键位自动单:计划 RR 阈值(严格大于该值才开仓,按确认K收盘 E 计算)
|
KEY_CONFIRM_BAR=-1
|
||||||
|
KEY_VOLUME_MA_BARS=20
|
||||||
|
KEY_VOLUME_RATIO_MIN=1.3
|
||||||
|
KEY_BREAKOUT_AMP_MIN_PCT=0.03
|
||||||
|
KEY_BREAKOUT_AMP_MAX_PCT=0.5
|
||||||
|
KEY_DAILY_VOLUME_RANK_MAX=30
|
||||||
KEY_AUTO_MIN_PLANNED_RR=1.5
|
KEY_AUTO_MIN_PLANNED_RR=1.5
|
||||||
# 止损:突破 K 极值向外缓冲的百分比(默认 0.5 即 0.5%)
|
|
||||||
KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5
|
KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5
|
||||||
KEY_ALERT_MAX_TIMES=3
|
KEY_ALERT_MAX_TIMES=3
|
||||||
KEY_ALERT_INTERVAL_MINUTES=5
|
KEY_ALERT_INTERVAL_MINUTES=5
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 交易执行 / 人工风控(页面「实盘下单」)
|
||||||
|
# =============================================================================
|
||||||
|
MAX_ACTIVE_POSITIONS=1
|
||||||
|
MANUAL_MIN_PLANNED_RR=1.4
|
||||||
|
KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true
|
||||||
|
|
||||||
# 资金与仓位刷新周期(秒)
|
# 资金与仓位刷新周期(秒)
|
||||||
BALANCE_REFRESH_SECONDS=60
|
BALANCE_REFRESH_SECONDS=60
|
||||||
# 后台监控轮询周期(秒)
|
PRICE_REFRESH_SECONDS=5
|
||||||
MONITOR_POLL_SECONDS=3
|
MONITOR_POLL_SECONDS=3
|
||||||
# 使用可用资金时的缓冲比例(如0.98代表用98%)
|
|
||||||
FULL_MARGIN_BUFFER_RATIO=0.98
|
FULL_MARGIN_BUFFER_RATIO=0.98
|
||||||
|
|
||||||
# 自动划转:将目标账户补足到 AUTO_TRANSFER_AMOUNT
|
# 自动划转:将目标账户补足到 AUTO_TRANSFER_AMOUNT
|
||||||
|
|||||||
+137
-39
@@ -123,9 +123,18 @@ BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60"))
|
|||||||
PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5"))
|
PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5"))
|
||||||
KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3"))
|
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_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_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"))
|
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_MANUAL = "下单监控"
|
||||||
ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控"
|
ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控"
|
||||||
|
|
||||||
@@ -1228,6 +1237,10 @@ def init_db():
|
|||||||
try:
|
try:
|
||||||
c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5")
|
c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5")
|
||||||
except: pass
|
except: pass
|
||||||
|
try:
|
||||||
|
c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
c.execute(
|
c.execute(
|
||||||
"""CREATE TABLE IF NOT EXISTS key_monitor_history
|
"""CREATE TABLE IF NOT EXISTS key_monitor_history
|
||||||
@@ -2386,13 +2399,63 @@ def trading_day_reset_allows_new_open(now):
|
|||||||
return now.hour >= TRADING_DAY_RESET_HOUR
|
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), 2), 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):
|
def precheck_risk(conn, symbol, direction):
|
||||||
now = app_now()
|
now = app_now()
|
||||||
if not trading_day_reset_allows_new_open(now):
|
if not trading_day_reset_allows_new_open(now):
|
||||||
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
|
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
|
||||||
active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]
|
active_count = get_active_position_count(conn)
|
||||||
if active_count > 0:
|
if active_count >= MAX_ACTIVE_POSITIONS:
|
||||||
return False, "一次只能持有一个仓位"
|
return False, f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS})"
|
||||||
if direction not in ("long", "short"):
|
if direction not in ("long", "short"):
|
||||||
return False, "方向必须为 long 或 short"
|
return False, "方向必须为 long 或 short"
|
||||||
if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"):
|
if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"):
|
||||||
@@ -3239,6 +3302,7 @@ def reconcile_external_closes(conn, days=None):
|
|||||||
closed_at=closed_at,
|
closed_at=closed_at,
|
||||||
)
|
)
|
||||||
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],))
|
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 ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"):
|
if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"):
|
||||||
send_wechat_msg(
|
send_wechat_msg(
|
||||||
build_wechat_close_message(
|
build_wechat_close_message(
|
||||||
@@ -3405,21 +3469,26 @@ def _key_hard_checks(symbol, direction, upper, lower, monitor_type):
|
|||||||
out["reason"] = "5m K线数量不足"
|
out["reason"] = "5m K线数量不足"
|
||||||
return out
|
return out
|
||||||
closed = bars[:-1] if len(bars) >= 3 else bars
|
closed = bars[:-1] if len(bars) >= 3 else bars
|
||||||
if len(closed) < 23:
|
min_closed = KEY_VOLUME_MA_BARS + 3
|
||||||
out["reason"] = "闭合K线不足"
|
if len(closed) < min_closed:
|
||||||
|
out["reason"] = f"{KLINE_TIMEFRAME} 闭合K线不足"
|
||||||
return out
|
return out
|
||||||
breakout = closed[-2]
|
try:
|
||||||
confirm = closed[-1]
|
breakout = closed[KEY_CONFIRM_BREAKOUT_BAR]
|
||||||
prev20 = closed[-22:-2]
|
confirm = closed[KEY_CONFIRM_BAR]
|
||||||
avg20 = sum(float(x[5]) for x in prev20) / max(len(prev20), 1)
|
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_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])
|
open_b = float(breakout[1])
|
||||||
close_b = float(breakout[4])
|
close_b = float(breakout[4])
|
||||||
high_b = float(breakout[2])
|
high_b = float(breakout[2])
|
||||||
low_b = float(breakout[3])
|
low_b = float(breakout[3])
|
||||||
amp_pct = abs(close_b - open_b) / open_b * 100 if open_b > 0 else 0
|
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])
|
cfm_close = float(confirm[4])
|
||||||
# 区间极值点严格以前端录入 upper/lower 为准:做多看上沿,做空看下沿
|
# 区间极值点严格以前端录入 upper/lower 为准:做多看上沿,做空看下沿
|
||||||
edge = float(upper) if direction == "long" else float(lower)
|
edge = float(upper) if direction == "long" else float(lower)
|
||||||
@@ -3429,7 +3498,7 @@ def _key_hard_checks(symbol, direction, upper, lower, monitor_type):
|
|||||||
amp_ok = amp_ok and breakout_ok
|
amp_ok = amp_ok and breakout_ok
|
||||||
confirm_ok = confirm_ok_raw and breakout_ok
|
confirm_ok = confirm_ok_raw and breakout_ok
|
||||||
rank, total = _daily_volume_rank(symbol)
|
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
|
swing4h_pct = 0.0
|
||||||
try:
|
try:
|
||||||
seg48 = closed[-48:] if len(closed) >= 48 else closed
|
seg48 = closed[-48:] if len(closed) >= 48 else closed
|
||||||
@@ -3542,7 +3611,8 @@ def _market_open_for_key_monitor(conn, symbol, direction, exchange_symbol, stop_
|
|||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
session_row = ensure_session(conn, trading_day)
|
session_row = ensure_session(conn, trading_day)
|
||||||
_, trading_capital_live = get_exchange_capitals(force=True)
|
_, 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()
|
trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower()
|
||||||
if trade_style not in ("trend", "swing"):
|
if trade_style not in ("trend", "swing"):
|
||||||
@@ -4127,6 +4197,7 @@ def check_order_monitors():
|
|||||||
closed_at=closed_at,
|
closed_at=closed_at,
|
||||||
)
|
)
|
||||||
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, pid))
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -4335,7 +4406,12 @@ def render_main_page(page="trade"):
|
|||||||
)
|
)
|
||||||
rate = round(win/total*100,2) if total else 0
|
rate = round(win/total*100,2) if total else 0
|
||||||
active_count = len(order_list)
|
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()
|
conn.close()
|
||||||
return render_template(
|
return render_template(
|
||||||
"index.html",
|
"index.html",
|
||||||
@@ -4380,6 +4456,11 @@ def render_main_page(page="trade"):
|
|||||||
entry_reason_options=list(ENTRY_REASON_OPTIONS),
|
entry_reason_options=list(ENTRY_REASON_OPTIONS),
|
||||||
entry_reason_other_value=ENTRY_REASON_OTHER,
|
entry_reason_other_value=ENTRY_REASON_OTHER,
|
||||||
exchange_display=EXCHANGE_DISPLAY_NAME,
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -4389,6 +4470,12 @@ def index():
|
|||||||
return redirect("/trade")
|
return redirect("/trade")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/key_monitor")
|
||||||
|
@login_required
|
||||||
|
def key_monitor_page():
|
||||||
|
return render_main_page("key_monitor")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/trade")
|
@app.route("/trade")
|
||||||
@login_required
|
@login_required
|
||||||
def trade_page():
|
def trade_page():
|
||||||
@@ -4419,9 +4506,9 @@ def api_account_snapshot():
|
|||||||
funding_usdt = round(funding_capital, 2) if funding_capital is not None else None
|
funding_usdt = round(funding_capital, 2) if funding_capital is not None else None
|
||||||
current_capital = round(trading_capital, 2) if trading_capital is not None else round(local_current_capital, 2)
|
current_capital = round(trading_capital, 2) if trading_capital is not None else round(local_current_capital, 2)
|
||||||
recommended_capital = round(float(get_recommended_capital(current_capital)), 2)
|
recommended_capital = round(float(get_recommended_capital(current_capital)), 2)
|
||||||
active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]
|
active_count = get_active_position_count(conn)
|
||||||
conn.close()
|
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()
|
available_trading_usdt = get_available_trading_usdt()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"funding_usdt": funding_usdt,
|
"funding_usdt": funding_usdt,
|
||||||
@@ -4429,7 +4516,9 @@ def api_account_snapshot():
|
|||||||
"available_trading_usdt": round(available_trading_usdt, 2) if available_trading_usdt is not None else None,
|
"available_trading_usdt": round(available_trading_usdt, 2) if available_trading_usdt is not None else None,
|
||||||
"recommended_capital": recommended_capital,
|
"recommended_capital": recommended_capital,
|
||||||
"active_count": active_count,
|
"active_count": active_count,
|
||||||
|
"max_active_positions": MAX_ACTIVE_POSITIONS,
|
||||||
"can_trade": can_trade,
|
"can_trade": can_trade,
|
||||||
|
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
|
||||||
"trading_day": trading_day
|
"trading_day": trading_day
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -4609,7 +4698,8 @@ def api_symbol_liquidity_rank():
|
|||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"rank": int(rank),
|
"rank": int(rank),
|
||||||
"total": int(total),
|
"total": int(total),
|
||||||
"in_top30": bool(rank <= 30),
|
"in_top30": bool(rank <= KEY_DAILY_VOLUME_RANK_MAX),
|
||||||
|
"rank_max": KEY_DAILY_VOLUME_RANK_MAX,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -4626,13 +4716,15 @@ def api_order_defaults():
|
|||||||
exchange_symbol = normalize_exchange_symbol(symbol)
|
exchange_symbol = normalize_exchange_symbol(symbol)
|
||||||
leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol)
|
leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol)
|
||||||
available = get_available_trading_usdt()
|
available = get_available_trading_usdt()
|
||||||
|
last_price = get_price(symbol)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"exchange_symbol": exchange_symbol,
|
"exchange_symbol": exchange_symbol,
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
"leverage": leverage,
|
"leverage": leverage,
|
||||||
"available_trading_usdt": round(available, 2) if available is not None else None
|
"available_trading_usdt": round(available, 2) if available is not None else None,
|
||||||
|
"last_price": round(float(last_price), 8) if last_price is not None else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -4865,34 +4957,33 @@ def add_key():
|
|||||||
symbol = normalize_symbol_input(d.get("symbol"))
|
symbol = normalize_symbol_input(d.get("symbol"))
|
||||||
if not symbol:
|
if not symbol:
|
||||||
flash("symbol 不能为空")
|
flash("symbol 不能为空")
|
||||||
return redirect("/")
|
return redirect("/key_monitor")
|
||||||
direction_sel = (d.get("direction") or "").strip().lower()
|
direction_sel = (d.get("direction") or "").strip().lower()
|
||||||
if direction_sel not in ("long", "short"):
|
if direction_sel not in ("long", "short"):
|
||||||
flash("请选择做多或做空")
|
flash("请选择做多或做空")
|
||||||
return redirect("/")
|
return redirect("/key_monitor")
|
||||||
mt = (d.get("type") or "").strip()
|
mt = (d.get("type") or "").strip()
|
||||||
allowed_types = tuple(KEY_MONITOR_AUTO_TYPES) + tuple(KEY_MONITOR_ALERT_ONLY_TYPES)
|
allowed_types = tuple(KEY_MONITOR_AUTO_TYPES) + tuple(KEY_MONITOR_ALERT_ONLY_TYPES)
|
||||||
if mt not in allowed_types:
|
if mt not in allowed_types:
|
||||||
flash("监控类型无效,请选择:箱体突破、收敛突破、关键阻力位、关键支撑位")
|
flash("监控类型无效,请选择:箱体突破、收敛突破、关键阻力位、关键支撑位")
|
||||||
return redirect("/")
|
return redirect("/key_monitor")
|
||||||
rank, total = _daily_volume_rank(symbol)
|
rank, total = _daily_volume_rank(symbol)
|
||||||
if rank is None:
|
if rank is None:
|
||||||
flash("日成交量排名读取失败,请稍后重试")
|
flash("日成交量排名读取失败,请稍后重试")
|
||||||
return redirect("/")
|
return redirect("/key_monitor")
|
||||||
if rank > 30:
|
if rank > KEY_DAILY_VOLUME_RANK_MAX:
|
||||||
flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前30,已拒绝添加关键位")
|
flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位")
|
||||||
return redirect("/")
|
return redirect("/key_monitor")
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
if mt in KEY_MONITOR_AUTO_TYPES:
|
if mt in KEY_MONITOR_AUTO_TYPES:
|
||||||
occupied = conn.execute(
|
occupied = get_active_position_count(conn)
|
||||||
"SELECT COUNT(*) FROM order_monitors WHERE status='active'",
|
if occupied >= MAX_ACTIVE_POSITIONS:
|
||||||
).fetchone()[0]
|
|
||||||
if occupied and int(occupied) > 0:
|
|
||||||
conn.close()
|
conn.close()
|
||||||
flash(
|
flash(
|
||||||
"当前已有实盘持仓:无法添加「箱体突破 / 收敛突破」(会自动开仓)。请平仓后再试,或使用「关键阻力位/关键支撑位」(仅单次提醒)。"
|
f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」。"
|
||||||
|
"请平仓后再试,或使用「关键阻力位/关键支撑位」(仅单次提醒)。"
|
||||||
)
|
)
|
||||||
return redirect("/")
|
return redirect("/key_monitor")
|
||||||
ex_sym_key = normalize_exchange_symbol(symbol)
|
ex_sym_key = normalize_exchange_symbol(symbol)
|
||||||
try:
|
try:
|
||||||
ensure_markets_loaded()
|
ensure_markets_loaded()
|
||||||
@@ -4919,7 +5010,7 @@ def add_key():
|
|||||||
flash(
|
flash(
|
||||||
"⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。"
|
"⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。"
|
||||||
)
|
)
|
||||||
return redirect("/")
|
return redirect("/key_monitor")
|
||||||
|
|
||||||
@app.route("/add_order", methods=["POST"])
|
@app.route("/add_order", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@@ -4935,7 +5026,7 @@ def add_order():
|
|||||||
return redirect("/")
|
return redirect("/")
|
||||||
ok, reason = precheck_risk(conn, symbol, direction)
|
ok, reason = precheck_risk(conn, symbol, direction)
|
||||||
if not ok:
|
if not ok:
|
||||||
if "一次只能持有一个仓位" in reason:
|
if "已达最大持仓数" in reason:
|
||||||
try:
|
try:
|
||||||
tp_raw = parse_positive_float(d.get("tp"))
|
tp_raw = parse_positive_float(d.get("tp"))
|
||||||
sl_raw = parse_positive_float(d.get("sl"))
|
sl_raw = parse_positive_float(d.get("sl"))
|
||||||
@@ -4956,19 +5047,19 @@ def add_order():
|
|||||||
stop_loss=round_price_to_exchange(ex_miss, sl_raw) if sl_raw else 0,
|
stop_loss=round_price_to_exchange(ex_miss, sl_raw) if sl_raw else 0,
|
||||||
take_profit=round_price_to_exchange(ex_miss, tgt_raw) if tgt_raw else 0,
|
take_profit=round_price_to_exchange(ex_miss, tgt_raw) if tgt_raw else 0,
|
||||||
result="错过",
|
result="错过",
|
||||||
miss_reason="持仓占用:一次只能持有一个仓位",
|
miss_reason=f"持仓占用:{reason}",
|
||||||
opened_at=app_now_str(),
|
opened_at=app_now_str(),
|
||||||
closed_at=app_now_str(),
|
closed_at=app_now_str(),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
flash(f"风控拒绝下单:{reason}")
|
flash(f"风控拒绝下单:{reason}")
|
||||||
return redirect("/")
|
return redirect("/trade")
|
||||||
ok_live, reason_live = ensure_exchange_live_ready()
|
ok_live, reason_live = ensure_exchange_live_ready()
|
||||||
if not ok_live:
|
if not ok_live:
|
||||||
conn.close()
|
conn.close()
|
||||||
flash(f"风控拒绝下单:{reason_live}")
|
flash(f"风控拒绝下单:{reason_live}")
|
||||||
return redirect("/")
|
return redirect("/trade")
|
||||||
exchange_symbol = normalize_exchange_symbol(symbol)
|
exchange_symbol = normalize_exchange_symbol(symbol)
|
||||||
default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol)
|
default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol)
|
||||||
try:
|
try:
|
||||||
@@ -5039,7 +5130,13 @@ def add_order():
|
|||||||
if stop_loss <= 0 or take_profit <= 0:
|
if stop_loss <= 0 or take_profit <= 0:
|
||||||
conn.close()
|
conn.close()
|
||||||
flash("价格参数必须大于0")
|
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")
|
||||||
sl_adj = round_price_to_exchange(exchange_symbol, stop_loss)
|
sl_adj = round_price_to_exchange(exchange_symbol, stop_loss)
|
||||||
tp_adj = round_price_to_exchange(exchange_symbol, take_profit)
|
tp_adj = round_price_to_exchange(exchange_symbol, take_profit)
|
||||||
if sl_adj is not None:
|
if sl_adj is not None:
|
||||||
@@ -5509,6 +5606,7 @@ def del_order(id):
|
|||||||
closed_at=closed_at,
|
closed_at=closed_at,
|
||||||
)
|
)
|
||||||
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id))
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
send_wechat_msg(
|
send_wechat_msg(
|
||||||
@@ -5528,7 +5626,7 @@ def del_order(id):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
flash("已按实盘流程手动平仓")
|
flash("已按实盘流程手动平仓")
|
||||||
return redirect("/")
|
return redirect("/trade")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if is_no_position_error(str(e)):
|
if is_no_position_error(str(e)):
|
||||||
cancel_gate_swap_trigger_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]))
|
cancel_gate_swap_trigger_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]))
|
||||||
|
|||||||
@@ -149,7 +149,7 @@
|
|||||||
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
|
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body data-page="{{ page }}">
|
||||||
{% macro period_stats(title, s) %}
|
{% macro period_stats(title, s) %}
|
||||||
<div class="stats-period-block">
|
<div class="stats-period-block">
|
||||||
<h3>{{ title }}</h3>
|
<h3>{{ title }}</h3>
|
||||||
@@ -200,10 +200,11 @@
|
|||||||
<div class="rule-tip">实时价格更新时间:<span id="price-last-updated">--</span>(北京时间 UTC+8)</div>
|
<div class="rule-tip">实时价格更新时间:<span id="price-last-updated">--</span>(北京时间 UTC+8)</div>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{% if page == 'trade' %}
|
{% if page == 'key_monitor' %}
|
||||||
<div class="card monitor-card">
|
<div class="dual-panel-grid" style="grid-column:1/-1">
|
||||||
|
<div class="card">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
||||||
<h2 style="margin-bottom:0">关键位监控(5m)</h2>
|
<h2 style="margin-bottom:0">关键位监控</h2>
|
||||||
{% if focus_key_id %}
|
{% if focus_key_id %}
|
||||||
<a href="/key_focus?key_id={{ focus_key_id }}" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">放大查看K线(默认200根)</a>
|
<a href="/key_focus?key_id={{ focus_key_id }}" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">放大查看K线(默认200根)</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -225,44 +226,69 @@
|
|||||||
<input name="lower" step="0.0001" placeholder="下沿/支撑" required>
|
<input name="lower" step="0.0001" placeholder="下沿/支撑" required>
|
||||||
<button type="submit">添加</button>
|
<button type="submit">添加</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="list">
|
<div class="rule-tip">{{ key_gate_rule_text }}</div>
|
||||||
|
<div class="panel-scroll pos-list">
|
||||||
{% for k in key %}
|
{% for k in key %}
|
||||||
<div class="list-item" id="key-row-{{ k.id }}">
|
<div class="pos-card" id="key-row-{{ k.id }}">
|
||||||
<div><strong>{{ k.symbol }}</strong> | {{ k.monitor_type }} | <span class="badge direction">{{ '做多' if k.direction == 'long' else '做空' }}</span></div>
|
<div class="pos-card-head">
|
||||||
<div>
|
<div class="pos-card-symbol">
|
||||||
上:{{ price_fmt(k.symbol, k.upper) }} 下:{{ price_fmt(k.symbol, k.lower) }}
|
<strong>{{ k.symbol }}</strong>
|
||||||
| 已提醒:{{ k.notification_count or 0 }}/{{ k.max_notify or 3 }}
|
<span class="pos-side-badge {{ 'pos-side-long' if k.direction == 'long' else 'pos-side-short' }}">{{ '做多' if k.direction == 'long' else '做空' }}</span>
|
||||||
| 现价:<span id="key-price-{{ k.id }}">-</span>
|
<span class="badge direction" style="margin-left:4px">{{ k.monitor_type }}</span>
|
||||||
| 距上沿:<span id="key-up-diff-{{ k.id }}">-</span>
|
</div>
|
||||||
| 距下沿:<span id="key-low-diff-{{ k.id }}">-</span>
|
<button type="button" class="pos-close-btn" style="border:none;cursor:pointer" onclick="deleteKeyMonitor({{ k.id }})">删</button>
|
||||||
| 门控:<span id="key-gate-{{ k.id }}" style="color:#9aa">-</span>
|
|
||||||
<span id="key-gate-metrics-{{ k.id }}" style="margin-left:8px;color:#8fc8ff;font-size:.78rem"></span>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn-del" style="border:none;cursor:pointer" onclick="deleteKeyMonitor({{ k.id }})">删</button>
|
<div class="pos-meta">
|
||||||
|
<span class="pos-meta-item">上沿: {{ k.upper }}</span>
|
||||||
|
<span class="pos-meta-item">下沿: {{ k.lower }}</span>
|
||||||
|
<span class="pos-meta-item">已提醒: {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pos-grid">
|
||||||
|
<div class="pos-cell"><span class="pos-label">现价</span><span class="pos-value" id="key-price-{{ k.id }}">-</span></div>
|
||||||
|
<div class="pos-cell"><span class="pos-label">距上沿</span><span class="pos-value" id="key-up-diff-{{ k.id }}">-</span></div>
|
||||||
|
<div class="pos-cell"><span class="pos-label">距下沿</span><span class="pos-value" id="key-low-diff-{{ k.id }}">-</span></div>
|
||||||
|
<div class="pos-cell"><span class="pos-label">门控</span><span class="pos-value" id="key-gate-{{ k.id }}" style="color:#9aa">-</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="pos-meta" style="margin-top:8px"><span class="pos-meta-item" id="key-gate-metrics-{{ k.id }}" style="color:#8fc8ff"></span></div>
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="pos-empty">暂无监控中的关键位</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="key-history">
|
</div>
|
||||||
<h3>关键位历史(满次提醒或手动删除)</h3>
|
<div class="card">
|
||||||
<div class="sub">每种关键位触发后<strong>一次性结案</strong>并写入下方历史:箱体/收敛在计划 RR 达标时<strong>自动市价开仓</strong>;阻力/支撑仅<strong>单次企业微信提醒</strong>。详见项目根目录「关键位自动下单说明.md」。满多次提醒的旧逻辑已不适用。</div>
|
<h2 style="margin-bottom:8px">关键位历史</h2>
|
||||||
<div class="list">
|
<div class="sub" style="font-size:.72rem;color:#8892b0;margin-bottom:8px">失效或已结案的关键位</div>
|
||||||
{% for h in key_history %}
|
<div class="panel-scroll pos-list">
|
||||||
<div class="list-item">
|
{% for h in key_history %}
|
||||||
<div>
|
<div class="pos-card">
|
||||||
<strong>{{ h.symbol }}</strong> | {{ h.monitor_type }} | {{ '做多' if h.direction == 'long' else '做空' }} | {{ h.close_reason }}
|
<div class="pos-card-head">
|
||||||
<button type="button" class="table-del" style="margin-left:8px" onclick="deleteKeyHistory({{ h.id }})">删除</button>
|
<div class="pos-card-symbol">
|
||||||
|
<strong>{{ h.symbol }}</strong>
|
||||||
|
<span class="pos-side-badge {{ 'pos-side-long' if h.direction == 'long' else 'pos-side-short' }}">{{ '做多' if h.direction == 'long' else '做空' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>上:{{ price_fmt(h.symbol, h.upper) }} 下:{{ price_fmt(h.symbol, h.lower) }} | 提醒次数:{{ h.notification_count }} | {{ (h.closed_at or '-')[:16] }}</div>
|
<button type="button" class="table-del" onclick="deleteKeyHistory({{ h.id }})">删除</button>
|
||||||
{% if h.last_alert_message %}<div style="font-size:.78rem;color:#aab;margin-top:4px;white-space:pre-wrap">{{ h.last_alert_message[:200] }}{% if h.last_alert_message|length > 200 %}…{% endif %}</div>{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
<div class="pos-meta">
|
||||||
<div class="list-item" style="color:#8892b0">暂无历史</div>
|
<span class="pos-meta-item">{{ h.monitor_type }}</span>
|
||||||
{% endfor %}
|
<span class="pos-meta-item">{{ h.close_reason }}</span>
|
||||||
|
<span class="pos-meta-item">{{ (h.closed_at or '-')[:16] }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pos-meta">
|
||||||
|
<span class="pos-meta-item">上: {{ h.upper }} 下: {{ h.lower }}</span>
|
||||||
|
<span class="pos-meta-item">提醒: {{ h.notification_count }}</span>
|
||||||
|
</div>
|
||||||
|
{% if h.last_alert_message %}<div style="font-size:.75rem;color:#aab;margin-top:6px;white-space:pre-wrap">{{ h.last_alert_message[:180] }}{% if h.last_alert_message|length > 180 %}…{% endif %}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="pos-empty">暂无历史</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card order-card">
|
{% elif page == 'trade' %}
|
||||||
|
<div class="dual-panel-grid" style="grid-column:1/-1">
|
||||||
|
<div class="card">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
||||||
<h2 style="margin-bottom:0">实盘下单监控</h2>
|
<h2 style="margin-bottom:0">实盘下单监控</h2>
|
||||||
{% if focus_order_id %}
|
{% if focus_order_id %}
|
||||||
@@ -272,15 +298,15 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="rule-tip" id="order-rule-tip">
|
<div class="rule-tip" id="order-rule-tip">
|
||||||
规则:单仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;
|
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;
|
||||||
{% if can_trade %}可开仓{% else %}不可开仓(有持仓或未到北京时间 {{ reset_hour }}:00){% endif %};
|
{% if can_trade %}可开仓{% else %}不可开仓(持仓已满或未到北京时间 {{ reset_hour }}:00){% endif %};
|
||||||
按风险比例自动计算仓位
|
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
|
||||||
</div>
|
</div>
|
||||||
<div class="rule-tip">
|
<div class="rule-tip">
|
||||||
以损定仓:风险 {{ risk_percent }}% |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
|
以损定仓:风险 {{ risk_percent }}% |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
|
||||||
</div>
|
</div>
|
||||||
<div class="rule-tip">
|
<div class="rule-tip">
|
||||||
划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天<strong>北京时间 {{ auto_transfer_bj_hour }}:00</strong>起该整点小时内尝试;账簿按 <strong>UTC 自然日</strong>去重;界面时间为北京;将 {{ auto_transfer_to }} 补足到 {{ usdt_fmt(auto_transfer_amount) }}U,来自 {{ auto_transfer_from }})
|
划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天<strong>北京时间 {{ auto_transfer_bj_hour }}:00</strong>起该整点小时内尝试;账簿按 <strong>UTC 自然日</strong>去重;界面时间为北京;将 {{ auto_transfer_to }} 补足到 {{ auto_transfer_amount }}U,来自 {{ auto_transfer_from }})
|
||||||
</div>
|
</div>
|
||||||
<form action="/manual_transfer" method="post" class="form-row">
|
<form action="/manual_transfer" method="post" class="form-row">
|
||||||
<input name="amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额U" required>
|
<input name="amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额U" required>
|
||||||
@@ -296,7 +322,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<button type="submit">手动划转</button>
|
<button type="submit">手动划转</button>
|
||||||
</form>
|
</form>
|
||||||
<form action="/add_order" method="post" class="form-row">
|
<form id="add-order-form" action="/add_order" method="post" class="form-row">
|
||||||
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
||||||
<select id="order-direction" name="direction" required>
|
<select id="order-direction" name="direction" required>
|
||||||
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
@@ -323,9 +349,10 @@
|
|||||||
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
|
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
|
||||||
<button type="submit">开仓(以损定仓)</button>
|
<button type="submit">开仓(以损定仓)</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="pos-section">
|
</div>
|
||||||
<div class="pos-section-title">实时持仓</div>
|
<div class="card">
|
||||||
<div class="pos-list">
|
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||||
|
<div class="panel-scroll pos-list">
|
||||||
{% for o in order %}
|
{% for o in order %}
|
||||||
<div class="pos-card" id="order-row-{{ o.id }}">
|
<div class="pos-card" id="order-row-{{ o.id }}">
|
||||||
<div class="pos-card-head">
|
<div class="pos-card-head">
|
||||||
@@ -387,12 +414,13 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="pos-empty">暂无持仓</div>
|
<div class="pos-empty">暂无持仓</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% if page == 'records' %}
|
{% if page == 'records' %}
|
||||||
<div class="card full records-card">
|
<div class="card full records-card">
|
||||||
<h2>交易记录 & 错过机会</h2>
|
<h2>交易记录 & 错过机会</h2>
|
||||||
@@ -1149,8 +1177,9 @@ if(keyForm){
|
|||||||
alert((data && data.msg) || "日成交量排名读取失败");
|
alert((data && data.msg) || "日成交量排名读取失败");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const rankMax = data.rank_max || 30;
|
||||||
if(!data.in_top30){
|
if(!data.in_top30){
|
||||||
alert(`${data.symbol} 当前日成交量排名 ${data.rank}/${data.total},不在前30,已拦截。`);
|
alert(`${data.symbol} 当前日成交量排名 ${data.rank}/${data.total},不在前${rankMax},已拦截。`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
keyForm.submit();
|
keyForm.submit();
|
||||||
@@ -1164,6 +1193,19 @@ setTimeout(() => {
|
|||||||
if(document.getElementById("review-list")) loadReviews();
|
if(document.getElementById("review-list")) loadReviews();
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
|
|
||||||
|
const MANUAL_MIN_PLANNED_RR = {{ manual_min_planned_rr }};
|
||||||
|
function calcClientRr(direction, entry, sl, tp){
|
||||||
|
const e = Number(entry), s = Number(sl), t = Number(tp);
|
||||||
|
if(!Number.isFinite(e) || !Number.isFinite(s) || !Number.isFinite(t)) return null;
|
||||||
|
if(direction === 'short'){
|
||||||
|
if(s <= e || t >= e) return null;
|
||||||
|
return (e - t) / (s - e);
|
||||||
|
}
|
||||||
|
if(s >= e || t <= e) return null;
|
||||||
|
return (t - e) / (e - s);
|
||||||
|
}
|
||||||
|
|
||||||
let latestAvailableUsdt = null;
|
let latestAvailableUsdt = null;
|
||||||
const lastPriceMap = {};
|
const lastPriceMap = {};
|
||||||
|
|
||||||
@@ -1327,11 +1369,11 @@ function refreshAccountSnapshot(){
|
|||||||
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
|
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
|
||||||
latestAvailableUsdt = Number(data.available_trading_usdt);
|
latestAvailableUsdt = Number(data.available_trading_usdt);
|
||||||
}
|
}
|
||||||
const canTradeText = data.can_trade ? "可开仓" : "不可开仓(有持仓或未到北京时间 {{ reset_hour }}:00)";
|
const canTradeText = data.can_trade ? "可开仓" : `不可开仓(持仓 ${data.active_count||0}/${data.max_active_positions||{{ max_active_positions }}} 或未到北京时间 {{ reset_hour }}:00)`;
|
||||||
const tip = document.getElementById("order-rule-tip");
|
const tip = document.getElementById("order-rule-tip");
|
||||||
const avail = (latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)) ? `;交易账户可用约 ${formatUsdt2(latestAvailableUsdt)}U` : "";
|
const avail = (latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)) ? `;交易账户可用约 ${formatUsdt2(latestAvailableUsdt)}U` : "";
|
||||||
if(tip){
|
if(tip){
|
||||||
tip.innerText = `规则:单仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;${canTradeText}${avail}`;
|
tip.innerText = `规则:最多 ${data.max_active_positions || {{ max_active_positions }}} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;${canTradeText}${avail}`;
|
||||||
}
|
}
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
@@ -1383,10 +1425,99 @@ if(_journalFormEl){
|
|||||||
if(_jErSel) _jErSel.addEventListener("change", syncJournalEntryReasonOtherUi);
|
if(_jErSel) _jErSel.addEventListener("change", syncJournalEntryReasonOtherUi);
|
||||||
syncJournalEntryReasonOtherUi();
|
syncJournalEntryReasonOtherUi();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addOrderForm = document.getElementById("add-order-form");
|
||||||
|
if(addOrderForm){
|
||||||
|
addOrderForm.addEventListener("submit", function(ev){
|
||||||
|
if(addOrderForm.dataset.rrOk === "1"){
|
||||||
|
addOrderForm.dataset.rrOk = "0";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ev.preventDefault();
|
||||||
|
const direction = (document.getElementById("order-direction")||{}).value || "long";
|
||||||
|
const mode = (document.getElementById("sltp-mode")||{}).value || "price";
|
||||||
|
let sl, tp, entry;
|
||||||
|
if(mode === "pct"){
|
||||||
|
alert("百分比模式请确认盈亏比后再提交;建议使用价格模式以便校验。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sl = Number((document.getElementById("order-sl")||{}).value);
|
||||||
|
tp = Number((document.getElementById("order-tp")||{}).value);
|
||||||
|
entry = sl;
|
||||||
|
fetch(`/api/order_defaults?symbol=${encodeURIComponent((document.getElementById("order-symbol")||{}).value||"")}&direction=${encodeURIComponent(direction)}`)
|
||||||
|
.then(r=>r.json())
|
||||||
|
.then(data=>{
|
||||||
|
const px = data.last_price || data.price;
|
||||||
|
if(px) entry = Number(px);
|
||||||
|
const rr = calcClientRr(direction, entry, sl, tp);
|
||||||
|
if(rr === null || rr < MANUAL_MIN_PLANNED_RR){
|
||||||
|
alert(`计划盈亏比 ${rr === null ? '无效' : rr.toFixed(2)}:1 低于最低要求 ${MANUAL_MIN_PLANNED_RR}:1,已阻止人工下单。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addOrderForm.dataset.rrOk = "1";
|
||||||
|
addOrderForm.submit();
|
||||||
|
})
|
||||||
|
.catch(()=>{ alert("无法校验盈亏比,请稍后重试"); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
refreshOrderDefaults();
|
refreshOrderDefaults();
|
||||||
refreshPriceSnapshot();
|
refreshPriceSnapshotConditional();
|
||||||
setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }});
|
setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }});
|
||||||
setInterval(refreshPriceSnapshot, {{ price_refresh_seconds * 1000 }});
|
function refreshPriceSnapshotConditional(){
|
||||||
|
const page = document.body.getAttribute("data-page") || "";
|
||||||
|
fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{
|
||||||
|
const updatedEl = document.getElementById("price-last-updated");
|
||||||
|
if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at;
|
||||||
|
if(page === "key_monitor"){
|
||||||
|
(data.key_prices || []).forEach(k=>{
|
||||||
|
const pEl = document.getElementById(`key-price-${k.id}`);
|
||||||
|
if(pEl){ pEl.innerText = k.price_display || (Number.isFinite(Number(k.price)) ? Number(k.price).toFixed(6) : "-"); paintPriceTrend(pEl, `k-${k.id}`, Number(k.price)); }
|
||||||
|
const upEl = document.getElementById(`key-up-diff-${k.id}`);
|
||||||
|
if(upEl) upEl.innerText = `${formatSigned(k.upper_diff, 4)} (${formatSigned(k.upper_pct, 2)}%)`;
|
||||||
|
const lowEl = document.getElementById(`key-low-diff-${k.id}`);
|
||||||
|
if(lowEl) lowEl.innerText = `${formatSigned(k.lower_diff, 4)} (${formatSigned(k.lower_pct, 2)}%)`;
|
||||||
|
const gateEl = document.getElementById(`key-gate-${k.id}`);
|
||||||
|
if(gateEl){ gateEl.innerText = k.gate_summary || "-"; gateEl.style.color = k.gate_ok ? "#4cd97f" : "#ff8f8f"; }
|
||||||
|
const gateMetricEl = document.getElementById(`key-gate-metrics-${k.id}`);
|
||||||
|
if(gateMetricEl) gateMetricEl.innerText = k.gate_metrics || "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(page === "trade"){
|
||||||
|
(data.order_prices || []).forEach(o=>{
|
||||||
|
const pEl = document.getElementById(`order-price-${o.id}`);
|
||||||
|
if(pEl){
|
||||||
|
const hasMark = (()=>{ const x = o.exchange_mark_price; if(x===null||x===undefined||x==="")return false; const n=Number(x); return !Number.isNaN(n); })();
|
||||||
|
let disp = "";
|
||||||
|
if(hasMark && o.exchange_mark_price_display) disp = o.exchange_mark_price_display;
|
||||||
|
else if(o.price_display) disp = o.price_display;
|
||||||
|
else { const px = hasMark ? Number(o.exchange_mark_price) : Number(o.price); disp = Number.isFinite(px) ? px.toFixed(6) : "-"; }
|
||||||
|
pEl.innerText = disp;
|
||||||
|
const pxNum = hasMark ? Number(o.exchange_mark_price) : Number(o.price);
|
||||||
|
paintPriceTrend(pEl, `o-${o.id}`, Number.isFinite(pxNum) ? pxNum : px);
|
||||||
|
}
|
||||||
|
const exM = document.getElementById(`order-ex-margin-${o.id}`);
|
||||||
|
if(exM){
|
||||||
|
const mv = o.exchange_initial_margin;
|
||||||
|
const mn = (mv === null || mv === undefined || mv === "") ? NaN : Number(mv);
|
||||||
|
if(!Number.isNaN(mn)) exM.innerText = `${mn.toFixed(2)}U`;
|
||||||
|
else { const prc = (typeof data.positions_raw_count === "number") ? data.positions_raw_count : null; exM.innerText = (prc === 0) ? "无仓数据" : "-"; }
|
||||||
|
}
|
||||||
|
const pnlEl = document.getElementById(`order-pnl-${o.id}`);
|
||||||
|
if(pnlEl){
|
||||||
|
pnlEl.innerText = `${formatSigned(o.float_pnl, 2)}U (${formatSigned(o.float_pct, 2)}%)`;
|
||||||
|
pnlEl.classList.remove("price-up","price-down","price-flat");
|
||||||
|
if(Number(o.float_pnl) > 0) pnlEl.classList.add("price-up");
|
||||||
|
else if(Number(o.float_pnl) < 0) pnlEl.classList.add("price-down");
|
||||||
|
else pnlEl.classList.add("price-flat");
|
||||||
|
}
|
||||||
|
const rrEl = document.getElementById(`order-rr-${o.id}`);
|
||||||
|
if(rrEl) rrEl.innerText = formatRrRatio(o.rr_ratio);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch(()=>{});
|
||||||
|
}
|
||||||
|
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 界面与风控更新说明(Gate 实例)
|
||||||
|
|
||||||
|
与 `crypto_monitor_binance` 同版界面结构,交易所对接为 Gate.io。
|
||||||
|
|
||||||
|
## 顶栏导航(4 项)
|
||||||
|
|
||||||
|
| 顺序 | 名称 | 路由 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 1 | 关键位监控 | `/key_monitor` | 关键位添加、实时门控、历史 |
|
||||||
|
| 2 | 实盘下单 | `/trade` | 人工开仓、划转、实时持仓(**默认首页** `/` → `/trade`) |
|
||||||
|
| 3 | 交易记录与复盘 | `/records` | 未改动 |
|
||||||
|
| 4 | 统计分析 | `/stats` | 未改动 |
|
||||||
|
|
||||||
|
## 关键位监控页
|
||||||
|
|
||||||
|
- 标题去掉「5m」;规则条从 `.env` 读取。
|
||||||
|
- 左列活跃关键位、右列历史,均为 pos-card 风格,双列对齐。
|
||||||
|
|
||||||
|
## 实盘下单页
|
||||||
|
|
||||||
|
- 左列下单表单,右列实时持仓。
|
||||||
|
- 人工开仓盈亏比不得低于 `MANUAL_MIN_PLANNED_RR`(默认 1.4)。
|
||||||
|
|
||||||
|
## 持仓与计仓
|
||||||
|
|
||||||
|
- `MAX_ACTIVE_POSITIONS` 默认 **1**。
|
||||||
|
- 关键位连开时使用无仓资金快照(见 `KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT`)。
|
||||||
|
|
||||||
|
## 配置与升级
|
||||||
|
|
||||||
|
1. 合并 `.env.example` 新增项到本地 `.env`。
|
||||||
|
2. 重启 Gate 实例服务。
|
||||||
|
3. 强刷浏览器缓存。
|
||||||
Reference in New Issue
Block a user