diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 616077f..313e4b7 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -239,10 +239,10 @@ def _wechat_trading_capital_text(fallback=None): except Exception: trading_capital = None if trading_capital is not None: - return f"{round(float(trading_capital), 4)}U" + return f"{round(float(trading_capital), FUNDS_DECIMALS)}U" if fallback is not None: try: - return f"{round(float(fallback), 4)}U" + return f"{round(float(fallback), FUNDS_DECIMALS)}U" except Exception: pass return "-" @@ -271,7 +271,7 @@ def build_wechat_close_message( try: if pnl_amount is not None: pv = float(pnl_amount) - pnl_disp = f"{'+' if pv > 0 else ''}{round(pv, 4)} U" + pnl_disp = f"{'+' if pv > 0 else ''}{round(pv, FUNDS_DECIMALS)} U" else: pnl_disp = "-" except (TypeError, ValueError): @@ -1352,19 +1352,19 @@ def _compute_period_metrics(trades): closed = len(trades) wins = sum(1 for p, _, _ in trades if p > 0) losses = sum(1 for p, _, _ in trades if p < 0) - net = round(sum(p for p, _, _ in trades), 4) + net = round(sum(p for p, _, _ in trades), FUNDS_DECIMALS) loss_sum_raw = sum(p for p, _, _ in trades if p < 0) - loss_sum_u = round(abs(loss_sum_raw), 4) if loss_sum_raw < 0 else 0.0 + loss_sum_u = round(abs(loss_sum_raw), FUNDS_DECIMALS) if loss_sum_raw < 0 else 0.0 neg_pnls = [p for p, _, _ in trades if p < 0] pos_pnls = [p for p, _, _ in trades if p > 0] - max_single_loss = round(min(neg_pnls), 4) if neg_pnls else None - max_single_profit = round(max(pos_pnls), 4) if pos_pnls else None + max_single_loss = round(min(neg_pnls), FUNDS_DECIMALS) if neg_pnls else None + max_single_profit = round(max(pos_pnls), FUNDS_DECIMALS) if pos_pnls else None cum = peak = max_dd = 0.0 for p, _, _ in trades: cum += p peak = max(peak, cum) max_dd = max(max_dd, peak - cum) - max_dd = round(max_dd, 4) + max_dd = round(max_dd, FUNDS_DECIMALS) streak = 0 for p, _, _ in reversed(trades): if p < 0: @@ -1388,7 +1388,7 @@ def _compute_period_metrics(trades): else: run = 0 worst_day = min(daily.keys(), key=lambda x: daily[x]) - worst_day_pnl = round(daily[worst_day], 4) + worst_day_pnl = round(daily[worst_day], FUNDS_DECIMALS) win_rate_pct = round(wins / (wins + losses) * 100, 2) if (wins + losses) else None return { "closed_count": closed, @@ -1619,10 +1619,10 @@ def update_session_capital(conn, session_date, pnl_amount): new_capital = float(session_row["current_capital"]) + float(pnl_amount) conn.execute( "UPDATE trading_sessions SET current_capital = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", - (round(new_capital, 4), session_date) + (round(new_capital, FUNDS_DECIMALS), session_date) ) conn.commit() - return round(new_capital, 4) + return round(new_capital, FUNDS_DECIMALS) def calc_hold_seconds(opened_at_str, closed_at_dt): @@ -1684,17 +1684,66 @@ def to_effective_trade_dict(row): return item +# USDT 等资金类:展示与入库舍入统一为 2 位小数(与交易所常见口径一致) +FUNDS_DECIMALS = 2 + + +def format_funds_u(value): + if value in (None, ""): + return "-" + try: + return f"{float(value):.{FUNDS_DECIMALS}f}" + except (TypeError, ValueError): + return str(value) + + +def round_funds(value): + try: + return round(float(value), FUNDS_DECIMALS) + except (TypeError, ValueError): + return None + + +def _ccxt_swap_symbol_for_precision(symbol): + """解析为 ccxt markets 中的永续 symbol,供 price_to_precision 使用。""" + raw = (symbol or "").strip() + if not raw: + return None + try: + ensure_markets_loaded() + markets = getattr(exchange, "markets", {}) or {} + except Exception: + return None + upper = raw.upper().replace(" ", "") + candidates = [] + candidates.append(normalize_exchange_symbol(raw)) + if upper.endswith("USDT") and len(upper) > 4 and "/" not in raw and ":" not in raw: + candidates.append(f"{upper[:-4]}/USDT:USDT") + if "/" not in raw and ":" not in raw and upper.isalnum() and not upper.endswith("USDT"): + candidates.append(f"{upper}/USDT:USDT") + for c in candidates: + if c and c in markets: + return c + return None + + def format_price_for_symbol(symbol, value): if value in (None, ""): return "-" try: v = float(value) - except Exception: + except (TypeError, ValueError): return str(value) if v == 0: return "0" + try: + ex_sym = _ccxt_swap_symbol_for_precision(symbol) + if ex_sym: + return str(exchange.price_to_precision(ex_sym, v)) + except Exception: + pass av = abs(v) - # 根据币价量级动态精度:低价币保留更多小数,高价币减少噪音位数 + # 无法加载市场或无该合约时:按价格量级回退(尽量不阻断页面) if av >= 10000: d = 2 elif av >= 100: @@ -1734,7 +1783,7 @@ def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage): pnl_ratio = (trigger - exit_p) / trigger else: pnl_ratio = (exit_p - trigger) / trigger - return round(margin * lev * pnl_ratio, 4) + return round(margin * lev * pnl_ratio, FUNDS_DECIMALS) except Exception: return 0.0 @@ -1784,7 +1833,7 @@ def calc_risk_amount_from_plan(direction, entry_price, stop_loss, margin_capital notional = float(margin_capital) * float(leverage) if notional <= 0: return None - return round(notional * rf, 6) + return round(notional * rf, FUNDS_DECIMALS) except Exception: return None @@ -1910,7 +1959,7 @@ def enrich_order_item(raw_item, current_capital): notional = item.get("notional_value") ratio = item.get("position_ratio") if notional is None: - notional = round(margin * lev, 4) if margin and lev else 0 + notional = round(margin * lev, FUNDS_DECIMALS) if margin and lev else 0 if ratio is None: ratio = round(margin / current_capital * 100, 2) if current_capital else 0 item["notional_value"] = notional @@ -2083,7 +2132,7 @@ def friendly_exchange_error(err, available_usdt=None): or "margin" in low and ("not enough" in low or "不足" in msg) or "balance" in low and "insufficient" in low ): - tail = f"(当前交易账户可用约 {round(available_usdt, 4)}U)" if available_usdt is not None else "" + tail = f"(当前交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}U)" if available_usdt is not None else "" return f"交易所下单失败:保证金不足 {tail}。请降低保证金/杠杆,或先划转USDT到合约账户。" clean = re.sub(r"\s+", " ", msg).strip() return f"交易所下单失败:{clean}" @@ -2173,7 +2222,7 @@ def auto_transfer_once_per_day(): conn.commit() conn.close() return - needed = round(max(target_amount - float(to_balance), 0), 4) + needed = round(max(target_amount - float(to_balance), 0), FUNDS_DECIMALS) if needed <= 0: conn.execute( "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", @@ -2185,12 +2234,12 @@ def auto_transfer_once_per_day(): if from_balance is not None and from_balance < needed: conn.execute( "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", - ("auto_daily", transfer_day, needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "failed", f"{AUTO_TRANSFER_FROM}账户USDT不足,需{needed}U,当前{round(from_balance,4)}U") + ("auto_daily", transfer_day, needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "failed", f"{AUTO_TRANSFER_FROM}账户USDT不足,需{needed}U,当前{round(from_balance, FUNDS_DECIMALS)}U") ) conn.commit() conn.close() send_wechat_msg( - f"自动划转失败:{AUTO_TRANSFER_FROM}余额不足,需{needed}U,当前{round(from_balance,4)}U\n" + f"自动划转失败:{AUTO_TRANSFER_FROM}余额不足,需{needed}U,当前{round(from_balance, FUNDS_DECIMALS)}U\n" f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" ) return @@ -2688,13 +2737,21 @@ def parse_ccxt_position_metrics(position, order_leverage=None): mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("mark_price"), info.get("markPrice")) out = {} if initial is not None and initial > 0: - out["initial_margin"] = round(initial, 4) + out["initial_margin"] = round(initial, FUNDS_DECIMALS) if notional is not None and notional > 0: - out["notional"] = round(notional, 4) + out["notional"] = round(notional, FUNDS_DECIMALS) if unrealized is not None: - out["unrealized_pnl"] = round(unrealized, 6) + out["unrealized_pnl"] = round(unrealized, FUNDS_DECIMALS) if mark is not None and mark > 0: - out["mark_price"] = round(mark, 8) + ps = p.get("symbol") + try: + ex_sym = _ccxt_swap_symbol_for_precision(ps or "") + if ex_sym: + out["mark_price"] = float(exchange.price_to_precision(ex_sym, mark)) + else: + out["mark_price"] = round(mark, 8) + except Exception: + out["mark_price"] = round(mark, 8) return out or None @@ -3817,8 +3874,8 @@ def render_main_page(page="trade"): local_current_capital = float(session_row["current_capital"]) funding_capital, trading_capital = get_exchange_capitals() # 资金账户:仅展示交易所读取结果(含 0)。不可用 TOTAL_CAPITAL 兜底,否则会与实盘不符。 - funding_usdt = round(funding_capital, 4) if funding_capital is not None else None - current_capital = round(trading_capital, 4) if trading_capital is not None else round(local_current_capital, 4) + funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None + current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS) recommended_capital = get_recommended_capital(current_capital) key_list = conn.execute("SELECT * FROM key_monitors").fetchall() key_history = conn.execute("SELECT * FROM key_monitor_history ORDER BY id DESC LIMIT 80").fetchall() @@ -3880,6 +3937,7 @@ def render_main_page(page="trade"): breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, occupied_miss_total=occupied_miss_total, price_fmt=format_price_for_symbol, + funds_fmt=format_funds_u, entry_reason_options=list(ENTRY_REASON_OPTIONS), entry_reason_other_value=ENTRY_REASON_OTHER, exchange_display=EXCHANGE_DISPLAY_NAME, @@ -3919,8 +3977,8 @@ def api_account_snapshot(): session_row = ensure_session(conn, trading_day) local_current_capital = float(session_row["current_capital"]) funding_capital, trading_capital = get_exchange_capitals(force=True) - funding_usdt = round(funding_capital, 4) if funding_capital is not None else None - current_capital = round(trading_capital, 4) if trading_capital is not None else round(local_current_capital, 4) + funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None + current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS) recommended_capital = get_recommended_capital(current_capital) active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0] conn.close() @@ -3929,7 +3987,7 @@ def api_account_snapshot(): return jsonify({ "funding_usdt": funding_usdt, "current_capital": current_capital, - "available_trading_usdt": round(available_trading_usdt, 4) 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, "active_count": active_count, "can_trade": can_trade, @@ -3995,19 +4053,21 @@ def api_price_snapshot(): vol_now = round(float(gate.get("vol_break") or 0), 4) vol_avg = round(float(gate.get("avg20") or 0), 4) amp_pct = round(float(gate.get("amp_pct") or 0), 4) - cfm_close = round(float(gate.get("confirm_close") or 0), 8) - edge = round(float(gate.get("edge_price") or 0), 8) + cfm_close = float(gate.get("confirm_close") or 0) + edge = float(gate.get("edge_price") or 0) gate_metrics = ( f"量值:{vol_now}/{vol_avg} " f"幅值:{amp_pct}% " - f"二确值:{cfm_close}@{edge}" + f"二确值:{format_price_for_symbol(r['symbol'], cfm_close)}@{format_price_for_symbol(r['symbol'], edge)}" ) except Exception: gate_metrics = "" + sym_k = r["symbol"] key_prices.append({ "id": r["id"], - "symbol": r["symbol"], + "symbol": sym_k, "price": round(price, 6), + "price_display": format_price_for_symbol(sym_k, price), "upper_diff": upper_diff, "upper_pct": upper_pct, "lower_diff": lower_diff, @@ -4026,7 +4086,7 @@ def api_price_snapshot(): leverage = float(r["leverage"] or 0) entry = float(r["trigger_price"] or 0) pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0 - pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0 + pnl_pct = round((pnl / margin * 100), 2) if margin > 0 else 0 rr_ratio = calc_rr_ratio(r["direction"], entry, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]) ex_sym = resolve_monitor_exchange_symbol(r) prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"]) @@ -4036,13 +4096,15 @@ def api_price_snapshot(): "id": r["id"], "symbol": r["symbol"], "price": round(price, 6), - "float_pnl": round(pnl, 6), + "price_display": format_price_for_symbol(ex_sym, price), + "float_pnl": round(pnl, FUNDS_DECIMALS), "float_pct": pnl_pct, "rr_ratio": rr_ratio, - "plan_margin": round(margin, 4) if margin else None, + "plan_margin": round(margin, FUNDS_DECIMALS) if margin else None, "exchange_initial_margin": None, "exchange_notional": None, "exchange_mark_price": None, + "exchange_mark_price_display": None, "pnl_source": "plan", } if ex_metrics: @@ -4051,13 +4113,15 @@ def api_price_snapshot(): if ex_metrics.get("notional") is not None: payload["exchange_notional"] = ex_metrics["notional"] if ex_metrics.get("mark_price") is not None: - payload["exchange_mark_price"] = ex_metrics["mark_price"] + mp = ex_metrics["mark_price"] + payload["exchange_mark_price"] = mp + payload["exchange_mark_price_display"] = format_price_for_symbol(ex_sym, mp) if ex_metrics.get("unrealized_pnl") is not None: - payload["float_pnl"] = round(float(ex_metrics["unrealized_pnl"]), 6) + payload["float_pnl"] = round(float(ex_metrics["unrealized_pnl"]), FUNDS_DECIMALS) payload["pnl_source"] = "exchange" denom = ex_metrics.get("initial_margin") or margin payload["float_pct"] = ( - round((payload["float_pnl"] / float(denom)) * 100, 4) if denom and float(denom) > 0 else pnl_pct + round((payload["float_pnl"] / float(denom)) * 100, 2) if denom and float(denom) > 0 else pnl_pct ) order_prices.append(payload) @@ -4109,7 +4173,7 @@ def api_order_defaults(): "exchange_symbol": exchange_symbol, "direction": direction, "leverage": leverage, - "available_trading_usdt": round(available, 4) if available is not None else None + "available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None }) @@ -4122,7 +4186,7 @@ def order_focus(): session_row = ensure_session(conn, trading_day) local_current_capital = float(session_row["current_capital"]) _, trading_capital_live = get_exchange_capitals() - current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4) + current_capital = round(trading_capital_live, FUNDS_DECIMALS) if trading_capital_live is not None else round(local_current_capital, FUNDS_DECIMALS) raw_orders = conn.execute("SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC").fetchall() conn.close() orders = [enrich_order_item(row_to_dict(r), current_capital) for r in raw_orders] @@ -4161,7 +4225,7 @@ def api_order_kline(): session_row = ensure_session(conn, trading_day) local_current_capital = float(session_row["current_capital"]) _, trading_capital_live = get_exchange_capitals() - current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4) + current_capital = round(trading_capital_live, FUNDS_DECIMALS) if trading_capital_live is not None else round(local_current_capital, FUNDS_DECIMALS) row = conn.execute("SELECT * FROM order_monitors WHERE id=? AND status='active'", (order_id,)).fetchone() conn.close() if not row: @@ -4194,7 +4258,7 @@ def api_order_kline(): leverage = float(order_item.get("leverage") or 0) entry = float(order_item.get("trigger_price") or 0) float_pnl = calc_pnl(order_item.get("direction") or "long", entry, current_price, margin, leverage) if current_price else 0 - float_pct = round((float_pnl / margin * 100), 4) if margin > 0 else 0 + float_pct = round((float_pnl / margin * 100), 2) if margin > 0 else 0 return jsonify({ "ok": True, @@ -4207,13 +4271,17 @@ def api_order_kline(): "trigger_price": order_item.get("trigger_price"), "stop_loss": order_item.get("stop_loss"), "take_profit": order_item.get("take_profit"), + "trigger_price_display": format_price_for_symbol(exchange_symbol, order_item.get("trigger_price")), + "stop_loss_display": format_price_for_symbol(exchange_symbol, order_item.get("stop_loss")), + "take_profit_display": format_price_for_symbol(exchange_symbol, order_item.get("take_profit")), "margin_capital": order_item.get("margin_capital"), "leverage": order_item.get("leverage"), "position_ratio": order_item.get("position_ratio"), "rr_ratio": order_item.get("rr_ratio"), "breakeven_enabled": bool(int(order_item.get("breakeven_enabled") or 0)), "current_price": round(float(current_price), 8) if current_price else None, - "float_pnl": round(float(float_pnl), 6), + "current_price_display": format_price_for_symbol(exchange_symbol, current_price) if current_price else None, + "float_pnl": round(float(float_pnl), FUNDS_DECIMALS), "float_pct": float_pct, }, "candles": candles, @@ -4311,6 +4379,8 @@ def api_key_kline(): "direction": key_row["direction"] or "long", "upper": upper, "lower": lower, + "upper_display": format_price_for_symbol(exchange_symbol, upper) if upper is not None else None, + "lower_display": format_price_for_symbol(exchange_symbol, lower) if lower is not None else None, "notification_count": int(key_row["notification_count"] or 0), "upper_diff": upper_diff, "upper_pct": upper_pct, @@ -4324,6 +4394,7 @@ def api_key_kline(): "timeframe": timeframe, "limit": limit, "current_price": round(float(current_price), 8) if current_price is not None else None, + "current_price_display": format_price_for_symbol(exchange_symbol, current_price) if current_price is not None else None, "key_monitor": key_info, "candles": candles, "updated_at": app_now_str(), @@ -4466,18 +4537,18 @@ def add_order(): flash("止损方向不合法:请检查入场方向与止损价格关系") return redirect("/") risk_percent = max(0.01, float(RISK_PERCENT)) - risk_amount = round(capital_base * risk_percent / 100.0, 4) - notional_value = round(risk_amount / risk_fraction, 4) - margin_capital = round(notional_value / leverage, 4) + risk_amount = round(capital_base * risk_percent / 100.0, FUNDS_DECIMALS) + notional_value = round(risk_amount / risk_fraction, FUNDS_DECIMALS) + margin_capital = round(notional_value / leverage, FUNDS_DECIMALS) if capital_base and margin_capital > capital_base: conn.close() flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例") return redirect("/") if available_usdt is not None: - max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), FUNDS_DECIMALS) if margin_capital > max_margin: conn.close() - flash(f"保证金不足:交易账户可用约 {round(available_usdt,4)}U,当前最多建议 {max_margin}U") + flash(f"保证金不足:交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}U,当前最多建议 {max_margin}U") return redirect("/") position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 try: @@ -4592,9 +4663,9 @@ def add_order(): _, trading_capital_after = get_exchange_capitals(force=True) account_base_display = ( - round(float(trading_capital_after), 4) + round(float(trading_capital_after), FUNDS_DECIMALS) if trading_capital_after is not None - else round(float(capital_base), 4) + else round(float(capital_base), FUNDS_DECIMALS) ) account_name = (os.getenv("BINANCE_ACCOUNT_LABEL") or "binance实盘账户").strip() dir_text = "多头(long)" if direction == "long" else "空头(short)" @@ -4620,7 +4691,7 @@ def add_order(): "🧾 订单基础信息", f"🔖 交易所订单 ID:{open_order_id}", f"📈 交易风格:{style_zh}", - f"⚠️ 单笔风控风险:{risk_percent}% ≈ {round(float(risk_amount_final), 4)} U", + f"⚠️ 单笔风控风险:{risk_percent}% ≈ {round(float(risk_amount_final), FUNDS_DECIMALS)} U", "📊 仓位配置详情", f"账户基数:{account_base_display} USDT", f"合约杠杆:{leverage} 倍", @@ -5350,7 +5421,7 @@ def api_trade_record_review_update(): reviewed_closed_at, reviewed_stop_loss, reviewed_take_profit, - round(reviewed_pnl_amount, 4), + round(reviewed_pnl_amount, FUNDS_DECIMALS), reviewed_result or None, reviewed_miss_reason or None, hold_seconds, diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html index 7d59cb9..2d8f9a1 100644 --- a/crypto_monitor_binance/templates/index.html +++ b/crypto_monitor_binance/templates/index.html @@ -130,14 +130,14 @@
开单次数
{{ s.opens_count }}
平仓笔数
{{ s.closed_count }}
胜率
{% if s.win_rate_pct is not none %}{{ s.win_rate_pct }}%{% else %}-{% endif %}
-
净盈亏(U)
{{ s.net_pnl_u }}
-
亏损额合计(U)
{{ s.loss_sum_u }}
-
单笔最大亏损(U)
{% if s.max_single_loss is not none %}{{ s.max_single_loss }}{% else %}-{% endif %}
-
单笔最大盈利(U)
{% if s.max_single_profit is not none %}{{ s.max_single_profit }}{% else %}-{% endif %}
-
最大回撤(U)
{{ s.max_drawdown_u }}
+
净盈亏(U)
{{ funds_fmt(s.net_pnl_u) }}
+
亏损额合计(U)
{{ funds_fmt(s.loss_sum_u) }}
+
单笔最大亏损(U)
{% if s.max_single_loss is not none %}{{ funds_fmt(s.max_single_loss) }}{% else %}-{% endif %}
+
单笔最大盈利(U)
{% if s.max_single_profit is not none %}{{ funds_fmt(s.max_single_profit) }}{% else %}-{% endif %}
+
最大回撤(U)
{{ funds_fmt(s.max_drawdown_u) }}
当前连续亏损笔数
{{ s.consecutive_losses }}
最长连续亏损(交易日)
{{ s.max_loss_streak_days }} 天
-
期内最大亏损日
{% if s.worst_day %}{{ s.worst_day }}({{ s.worst_day_pnl }}U){% else %}-{% endif %}
+
期内最大亏损日
{% if s.worst_day %}{{ s.worst_day }}({{ funds_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}
{% endmacro %} @@ -165,9 +165,9 @@
总交易
{{ total }}
错过次数
{{ miss_count }}
胜率
{{ rate }}%
-
资金账户(USDT)
{% if funding_usdt is not none %}{{ funding_usdt }}U{% else %}—{% endif %}
+
资金账户(USDT)
{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}
交易日
{{ trading_day }}
-
当日资金(交易账户)
{{ current_capital }}U
+
当日资金(交易账户)
{{ funds_fmt(current_capital) }}U
实时价格更新时间:--(北京时间 UTC+8)
@@ -300,14 +300,14 @@
{{ o.symbol }} | {{ '做多' if o.direction == 'long' else '做空' }}
- 风格:{{ o.trade_style or 'trend' }} | 风险:{{ o.risk_percent or '-' }}%≈{{ o.risk_amount or '-' }}U - | {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ o.breakeven_price or '-' }}{% else %}移动保本:关{% endif %} + 风格:{{ o.trade_style or 'trend' }} | 风险:{{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U + | {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
- 成交:{{ o.trigger_price }} 止损:{{ o.stop_loss }} 止盈:{{ o.take_profit }} + 成交:{{ price_fmt(o.symbol, o.trigger_price) }} 止损:{{ price_fmt(o.symbol, o.stop_loss) }} 止盈:{{ price_fmt(o.symbol, o.take_profit) }} | 盈亏比:{% if o.rr_ratio is not none %}1:{{ '%.2f'|format(o.rr_ratio) }}{% else %}-{% endif %} | 现价:- | 浮盈亏:- - | 计划基数:{{ o.margin_capital }}U | 所保证金:- + | 计划基数:{{ funds_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U | 所保证金:- | 杠杆:{{ o.leverage }}x | 仓位占比:{{ o.position_ratio }}%
平仓 @@ -335,18 +335,18 @@ {{ r.symbol }} {{ r.monitor_type }} {{ '做多' if r.direction == 'long' else '做空' }} - {{ r.trigger_price }} + {{ price_fmt(r.symbol, r.trigger_price) }} {% set stop_show = r.effective_stop_loss or r.initial_stop_loss or r.stop_loss %} {% set tp_show = r.effective_take_profit or r.take_profit %} {{ price_fmt(r.symbol, stop_show) }} {{ price_fmt(r.symbol, tp_show) }} - {{ r.margin_capital or '-' }} + {% if r.margin_capital is not none and r.margin_capital != '' %}{{ funds_fmt(r.margin_capital) }}{% else %}-{% endif %} {{ r.leverage or '-' }} {{ r.effective_hold_minutes or 0 }} {{ (r.effective_opened_at or '-')[:16] }} {{ (r.effective_closed_at or r.created_at or '-')[:16] }} {% set pnl_val = (r.effective_pnl_amount or 0)|float %} - {{ r.effective_pnl_amount or 0 }} + {{ funds_fmt(r.effective_pnl_amount or 0) }} {% set effective_result = r.effective_result %} {% if effective_result in ["止盈","保本止盈","移动止盈"] %}{{ effective_result }} @@ -1091,7 +1091,7 @@ setTimeout(() => { let latestAvailableUsdt = null; const lastPriceMap = {}; -function formatSigned(v, digits=4){ +function formatSigned(v, digits=2){ if(v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-"; const n = Number(v); const sign = n > 0 ? "+" : ""; @@ -1121,7 +1121,7 @@ function refreshPriceSnapshot(){ (data.key_prices || []).forEach(k=>{ const pEl = document.getElementById(`key-price-${k.id}`); if(pEl){ - pEl.innerText = Number(k.price).toFixed(6); + 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}`); @@ -1146,17 +1146,25 @@ function refreshPriceSnapshot(){ 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); })(); - const px = hasMark ? Number(o.exchange_mark_price) : Number(o.price); - const decimals = hasMark ? 8 : 6; - pEl.innerText = px.toFixed(decimals); - paintPriceTrend(pEl, `o-${o.id}`, px); + 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(4)}U`; + exM.innerText = `${mn.toFixed(2)}U`; } else { const prc = (typeof data.positions_raw_count === "number") ? data.positions_raw_count : null; exM.innerText = (prc === 0) ? "无仓数据" : "-"; @@ -1164,7 +1172,7 @@ function refreshPriceSnapshot(){ } const pnlEl = document.getElementById(`order-pnl-${o.id}`); if(pnlEl){ - pnlEl.innerText = `${formatSigned(o.float_pnl, 4)}U (${formatSigned(o.float_pct, 2)}%)`; + 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"); @@ -1198,7 +1206,7 @@ function refreshOrderDefaults(){ const fullEl = document.getElementById("use-full-margin"); const marginEl = document.getElementById("order-margin"); if(fullEl && marginEl && fullEl.checked){ - const m = Math.max(latestAvailableUsdt * {{ full_margin_buffer_ratio }}, 0).toFixed(4); + const m = Math.max(latestAvailableUsdt * {{ full_margin_buffer_ratio }}, 0).toFixed(2); marginEl.value = m; } } @@ -1209,18 +1217,18 @@ function refreshAccountSnapshot(){ fetch("/api/account_snapshot").then(r=>r.json()).then(data=>{ if (typeof data.funding_usdt !== "undefined") { const el = document.getElementById("total-capital"); - if(el) el.innerText = (data.funding_usdt === null || data.funding_usdt === undefined) ? "—" : `${data.funding_usdt}U`; + if(el) el.innerText = (data.funding_usdt === null || data.funding_usdt === undefined) ? "—" : `${Number(data.funding_usdt).toFixed(2)}U`; } if (typeof data.current_capital !== "undefined") { const el = document.getElementById("current-capital"); - if(el) el.innerText = `${data.current_capital}U`; + if(el) el.innerText = `${Number(data.current_capital).toFixed(2)}U`; } if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) { latestAvailableUsdt = Number(data.available_trading_usdt); } const canTradeText = data.can_trade ? "可开仓" : "不可开仓(有持仓或未到北京时间 {{ reset_hour }}:00)"; const tip = document.getElementById("order-rule-tip"); - const avail = (latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)) ? `;交易账户可用约${latestAvailableUsdt}U` : ""; + const avail = (latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)) ? `;交易账户可用约${latestAvailableUsdt.toFixed(2)}U` : ""; if(tip){ tip.innerText = `规则:单仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;${canTradeText}${avail}`; } @@ -1236,7 +1244,7 @@ if(fullMarginEl){ fullMarginEl.addEventListener("change", function(){ const marginEl = document.getElementById("order-margin"); if(marginEl && this.checked && latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)){ - marginEl.value = Math.max(latestAvailableUsdt * {{ full_margin_buffer_ratio }}, 0).toFixed(4); + marginEl.value = Math.max(latestAvailableUsdt * {{ full_margin_buffer_ratio }}, 0).toFixed(2); } }); } diff --git a/crypto_monitor_binance/templates/key_focus_v2.html b/crypto_monitor_binance/templates/key_focus_v2.html index d4b3492..26af46d 100644 --- a/crypto_monitor_binance/templates/key_focus_v2.html +++ b/crypto_monitor_binance/templates/key_focus_v2.html @@ -166,7 +166,7 @@ function addLine(price, title, color){ function paintMeta(data){ const key = data.key_monitor || null; document.getElementById("m-symbol").innerText = data.symbol || "-"; - document.getElementById("m-price").innerText = fmt(data.current_price,8); + document.getElementById("m-price").innerText = data.current_price_display || fmt(data.current_price,8); if(!key){ document.getElementById("m-type").innerText = "未匹配到关键位"; @@ -180,8 +180,8 @@ function paintMeta(data){ document.getElementById("m-type").innerText = key.monitor_type || "-"; document.getElementById("m-direction").innerText = key.direction === "short" ? "做空" : "做多"; - document.getElementById("m-upper").innerText = fmt(key.upper,8); - document.getElementById("m-lower").innerText = fmt(key.lower,8); + document.getElementById("m-upper").innerText = key.upper_display || fmt(key.upper,8); + document.getElementById("m-lower").innerText = key.lower_display || fmt(key.lower,8); document.getElementById("m-updiff").innerText = `${fmtSigned(key.upper_diff,4)} (${fmtSigned(key.upper_pct,2)}%)`; document.getElementById("m-lowdiff").innerText = `${fmtSigned(key.lower_diff,4)} (${fmtSigned(key.lower_pct,2)}%)`; } diff --git a/crypto_monitor_binance/templates/order_focus.html b/crypto_monitor_binance/templates/order_focus.html index 0811e93..c0992d4 100644 --- a/crypto_monitor_binance/templates/order_focus.html +++ b/crypto_monitor_binance/templates/order_focus.html @@ -140,13 +140,13 @@ function addLine(price, title, color){ function paintOrder(order){ document.getElementById("m-symbol").innerText = order.symbol || "-"; document.getElementById("m-direction").innerText = (order.direction === "short") ? "做空" : "做多"; - document.getElementById("m-entry").innerText = fmt(order.trigger_price, 8); - document.getElementById("m-sl").innerText = fmt(order.stop_loss, 8); - document.getElementById("m-tp").innerText = fmt(order.take_profit, 8); + document.getElementById("m-entry").innerText = order.trigger_price_display || fmt(order.trigger_price, 8); + document.getElementById("m-sl").innerText = order.stop_loss_display || fmt(order.stop_loss, 8); + document.getElementById("m-tp").innerText = order.take_profit_display || fmt(order.take_profit, 8); document.getElementById("m-rr").innerText = (order.rr_ratio === null || typeof order.rr_ratio === "undefined") ? "-" : `1:${Number(order.rr_ratio).toFixed(2)}`; - document.getElementById("m-price").innerText = fmt(order.current_price, 8); + document.getElementById("m-price").innerText = order.current_price_display || fmt(order.current_price, 8); const pnlEl = document.getElementById("m-pnl"); - pnlEl.innerText = `${fmt(order.float_pnl, 4)}U (${fmt(order.float_pct, 2)}%)`; + pnlEl.innerText = `${fmt(order.float_pnl, 2)}U (${fmt(order.float_pct, 2)}%)`; pnlEl.style.color = Number(order.float_pnl || 0) > 0 ? "#4cd97f" : (Number(order.float_pnl || 0) < 0 ? "#ff6666" : "#d6deff"); } diff --git a/crypto_monitor_binance/templates/order_focus_v2.html b/crypto_monitor_binance/templates/order_focus_v2.html index 9c9add3..f9bceab 100644 --- a/crypto_monitor_binance/templates/order_focus_v2.html +++ b/crypto_monitor_binance/templates/order_focus_v2.html @@ -142,15 +142,15 @@ function addLine(price, title, color){ function paintOrder(order){ document.getElementById("m-symbol").innerText = order.symbol || "-"; document.getElementById("m-direction").innerText = (order.direction === "short") ? "做空" : "做多"; - document.getElementById("m-entry").innerText = fmt(order.trigger_price, 8); - document.getElementById("m-sl").innerText = fmt(order.stop_loss, 8); - document.getElementById("m-tp").innerText = fmt(order.take_profit, 8); + document.getElementById("m-entry").innerText = order.trigger_price_display || fmt(order.trigger_price, 8); + document.getElementById("m-sl").innerText = order.stop_loss_display || fmt(order.stop_loss, 8); + document.getElementById("m-tp").innerText = order.take_profit_display || fmt(order.take_profit, 8); document.getElementById("m-rr").innerText = (order.rr_ratio === null || typeof order.rr_ratio === "undefined") ? "-" : `1:${Number(order.rr_ratio).toFixed(2)}`; document.getElementById("m-breakeven").innerText = (order.breakeven_enabled === false || order.breakeven_enabled === 0) ? "关闭" : "开启"; - document.getElementById("m-price").innerText = fmt(order.current_price, 8); + document.getElementById("m-price").innerText = order.current_price_display || fmt(order.current_price, 8); const pnlEl = document.getElementById("m-pnl"); - pnlEl.innerText = `${fmt(order.float_pnl, 4)}U (${fmt(order.float_pct, 2)}%)`; + pnlEl.innerText = `${fmt(order.float_pnl, 2)}U (${fmt(order.float_pct, 2)}%)`; pnlEl.style.color = Number(order.float_pnl || 0) > 0 ? "#4cd97f" : (Number(order.float_pnl || 0) < 0 ? "#ff6666" : "#d6deff"); } diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index be80b84..c562f58 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -238,10 +238,10 @@ def _wechat_trading_capital_text(fallback=None): except Exception: trading_capital = None if trading_capital is not None: - return f"{round(float(trading_capital), 4)}U" + return f"{round(float(trading_capital), 2)}U" if fallback is not None: try: - return f"{round(float(fallback), 4)}U" + return f"{round(float(fallback), 2)}U" except Exception: pass return "-" @@ -265,12 +265,12 @@ def build_wechat_close_message( ep = format_price_for_symbol(symbol, trigger_price) cp = format_price_for_symbol(symbol, current_price) tp = format_price_for_symbol(symbol, take_profit) - sl = format_price_for_symbol(symbol, stop_loss) + sl = format_wechat_scalar_2dp(stop_loss) cap_txt = _wechat_trading_capital_text(session_capital_fallback) try: if pnl_amount is not None: pv = float(pnl_amount) - pnl_disp = f"{'+' if pv > 0 else ''}{round(pv, 4)} U" + pnl_disp = f"{'+' if pv > 0 else ''}{round(pv, 2)} U" else: pnl_disp = "-" except (TypeError, ValueError): @@ -300,7 +300,7 @@ def build_wechat_close_message( def build_wechat_breakeven_message(symbol, direction, arm_txt, now_rr, locked_r, new_sl): - sl_fmt = format_price_for_symbol(symbol, new_sl) + sl_fmt = format_wechat_scalar_2dp(new_sl) return "\n".join( [ f"# 🛡️ {symbol} 保护位更新", @@ -975,6 +975,7 @@ def init_db(): breakeven_armed INTEGER DEFAULT 0, breakeven_price REAL, notional_value REAL, position_ratio REAL, base_amount REAL, order_amount REAL, exchange_order_id TEXT, exchange_close_order_id TEXT, + exchange_margin_usdt REAL, opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, opened_at_ms INTEGER, session_date TEXT, status TEXT DEFAULT "active")''') @@ -1088,6 +1089,10 @@ def init_db(): c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 1") except Exception: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_margin_usdt REAL") + except Exception: + pass try: c.execute("UPDATE order_monitors SET opened_at = datetime('now') WHERE opened_at IS NULL OR opened_at = ''") except: pass @@ -1351,19 +1356,19 @@ def _compute_period_metrics(trades): closed = len(trades) wins = sum(1 for p, _, _ in trades if p > 0) losses = sum(1 for p, _, _ in trades if p < 0) - net = round(sum(p for p, _, _ in trades), 4) + net = round(sum(p for p, _, _ in trades), 2) loss_sum_raw = sum(p for p, _, _ in trades if p < 0) - loss_sum_u = round(abs(loss_sum_raw), 4) if loss_sum_raw < 0 else 0.0 + loss_sum_u = round(abs(loss_sum_raw), 2) if loss_sum_raw < 0 else 0.0 neg_pnls = [p for p, _, _ in trades if p < 0] pos_pnls = [p for p, _, _ in trades if p > 0] - max_single_loss = round(min(neg_pnls), 4) if neg_pnls else None - max_single_profit = round(max(pos_pnls), 4) if pos_pnls else None + max_single_loss = round(min(neg_pnls), 2) if neg_pnls else None + max_single_profit = round(max(pos_pnls), 2) if pos_pnls else None cum = peak = max_dd = 0.0 for p, _, _ in trades: cum += p peak = max(peak, cum) max_dd = max(max_dd, peak - cum) - max_dd = round(max_dd, 4) + max_dd = round(max_dd, 2) streak = 0 for p, _, _ in reversed(trades): if p < 0: @@ -1387,7 +1392,7 @@ def _compute_period_metrics(trades): else: run = 0 worst_day = min(daily.keys(), key=lambda x: daily[x]) - worst_day_pnl = round(daily[worst_day], 4) + worst_day_pnl = round(daily[worst_day], 2) win_rate_pct = round(wins / (wins + losses) * 100, 2) if (wins + losses) else None return { "closed_count": closed, @@ -1640,9 +1645,8 @@ def to_effective_trade_dict(row): return item -def format_price_for_symbol(symbol, value): - if value in (None, ""): - return "-" +def format_price_magnitude_fallback(value): + """无 markets 或解析失败时的价格展示兜底(按量级)。""" try: v = float(value) except Exception: @@ -1650,7 +1654,6 @@ def format_price_for_symbol(symbol, value): if v == 0: return "0" av = abs(v) - # 根据币价量级动态精度:低价币保留更多小数,高价币减少噪音位数 if av >= 10000: d = 2 elif av >= 100: @@ -1667,6 +1670,88 @@ def format_price_for_symbol(symbol, value): return text.rstrip("0").rstrip(".") if "." in text else text +def resolve_ccxt_price_symbol(symbol): + """将界面/库中的品种名转为 ccxt 永续合约 id(如 BTC/USDT -> BTC/USDT:USDT)。""" + s = (symbol or "").strip() + if not s: + return "" + if "/" not in s and ":" not in s: + s = f"{s.upper()}/USDT" + else: + s = s.upper() + return normalize_exchange_symbol(s) + + +def round_price_to_exchange(exchange_symbol, price): + """与交易所 tick 对齐后的 float,供入库与计算;失败时退回 float(price)。""" + if price in (None, ""): + return None + try: + v = float(price) + except (TypeError, ValueError): + return None + if not exchange_symbol: + return v + try: + ensure_markets_loaded() + s = exchange.price_to_precision(exchange_symbol, v) + return float(s) + except Exception: + return v + + +def format_price_for_symbol(symbol, value): + """价格展示:与交易所 price_to_precision 一致(与入库 round_price_to_exchange 对齐)。""" + if value in (None, ""): + return "-" + try: + v = float(value) + except Exception: + return str(value) + ex = resolve_ccxt_price_symbol(symbol) + if not ex: + return format_price_magnitude_fallback(v) + try: + ensure_markets_loaded() + return exchange.price_to_precision(ex, v) + except Exception: + return format_price_magnitude_fallback(v) + + +def format_usdt(value): + """USDT 资金类展示:固定两位小数。""" + if value in (None, ""): + return "-" + try: + return f"{float(value):.2f}" + except (TypeError, ValueError): + return str(value) + + +def format_signed_usdt(value): + """USDT 盈亏等可正可负:+1.23 / -0.50 / 0.00""" + if value in (None, ""): + return "-" + try: + v = float(value) + except (TypeError, ValueError): + return str(value) + if v == 0: + return "0.00" + sign = "+" if v > 0 else "" + return f"{sign}{v:.2f}" + + +def format_wechat_scalar_2dp(value): + """企业微信推送:数值统一两位小数(与交易所 tick 无关)。""" + if value in (None, ""): + return "-" + try: + return f"{float(value):.2f}" + except (TypeError, ValueError): + return str(value) + + def format_hold_minutes(minutes): if not minutes: return "0分钟" @@ -1866,7 +1951,7 @@ def enrich_order_item(raw_item, current_capital): notional = item.get("notional_value") ratio = item.get("position_ratio") if notional is None: - notional = round(margin * lev, 4) if margin and lev else 0 + notional = round(margin * lev, 2) if margin and lev else 0 if ratio is None: ratio = round(margin / current_capital * 100, 2) if current_capital else 0 item["notional_value"] = notional @@ -2140,7 +2225,7 @@ def friendly_exchange_error(err, available_usdt=None): or "margin" in low and ("not enough" in low or "不足" in msg) or "balance" in low and "insufficient" in low ): - tail = f"(当前交易账户可用约 {round(available_usdt, 4)}U)" if available_usdt is not None else "" + tail = f"(当前交易账户可用约 {round(available_usdt, 2)}U)" if available_usdt is not None else "" return f"交易所下单失败:保证金不足 {tail}。请降低保证金/杠杆,或先划转USDT到合约账户。" clean = re.sub(r"\s+", " ", msg).strip() return f"交易所下单失败:{clean}" @@ -2236,7 +2321,7 @@ def auto_transfer_once_per_day(): if needed <= 0: conn.execute( "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", - ("auto_daily", transfer_day, 0, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "skipped", f"{AUTO_TRANSFER_TO}账户已达到目标{target_amount}U") + ("auto_daily", transfer_day, 0, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "skipped", f"{AUTO_TRANSFER_TO}账户已达到目标{round(float(target_amount), 2)}U") ) conn.commit() conn.close() @@ -2244,12 +2329,12 @@ def auto_transfer_once_per_day(): if from_balance is not None and from_balance < needed: conn.execute( "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", - ("auto_daily", transfer_day, needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "failed", f"{AUTO_TRANSFER_FROM}账户USDT不足,需{needed}U,当前{round(from_balance,4)}U") + ("auto_daily", transfer_day, needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "failed", f"{AUTO_TRANSFER_FROM}账户USDT不足,需{round(needed, 2)}U,当前{round(from_balance, 2)}U") ) conn.commit() conn.close() send_wechat_msg( - f"自动划转失败:{AUTO_TRANSFER_FROM}余额不足,需{needed}U,当前{round(from_balance,4)}U\n" + f"自动划转失败:{AUTO_TRANSFER_FROM}余额不足,需{round(needed, 2)}U,当前{round(from_balance, 2)}U\n" f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" ) return @@ -2263,13 +2348,13 @@ def auto_transfer_once_per_day(): conn.close() if ok: send_wechat_msg( - f"自动划转成功:补足到{target_amount}U,实际划转{needed}U " + f"自动划转成功:补足到{round(float(target_amount), 2)}U,实际划转{round(needed, 2)}U " f"{AUTO_TRANSFER_FROM}->{AUTO_TRANSFER_TO}\n" f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" ) else: send_wechat_msg( - f"自动划转失败:计划补足到{target_amount}U,需划转{needed}U\n原因:{msg}\n" + f"自动划转失败:计划补足到{round(float(target_amount), 2)}U,需划转{round(needed, 2)}U\n原因:{msg}\n" f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" ) @@ -2724,17 +2809,17 @@ def parse_ccxt_position_metrics(position, order_leverage=None): mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("mark_price"), info.get("markPrice")) out = {} if initial is not None and initial > 0: - out["initial_margin"] = round(initial, 4) + out["initial_margin"] = round(initial, 2) if notional is not None and notional > 0: - out["notional"] = round(notional, 4) + out["notional"] = round(notional, 2) if unrealized is not None: - out["unrealized_pnl"] = round(unrealized, 6) + out["unrealized_pnl"] = round(unrealized, 2) if mark is not None and mark > 0: out["mark_price"] = round(mark, 8) return out or None -def get_live_position_exchange_metrics(exchange_symbol, direction): +def get_live_position_exchange_metrics(exchange_symbol, direction, order_leverage=None): ensure_markets_loaded() if not exchange_private_api_configured() or not exchange_symbol: return None @@ -2746,7 +2831,72 @@ def get_live_position_exchange_metrics(exchange_symbol, direction): except Exception: return None p = _select_live_position_row(rows, exchange_symbol, direction) - return parse_ccxt_position_metrics(p) + return parse_ccxt_position_metrics(p, order_leverage=order_leverage) + + +def _order_row_exchange_margin_usdt(row): + if not row: + return None + try: + keys = row.keys() + except Exception: + return None + if "exchange_margin_usdt" not in keys: + return None + v = row["exchange_margin_usdt"] + if v is None: + return None + try: + x = float(v) + except (TypeError, ValueError): + return None + return x if x > 0 else None + + +def margin_capital_for_trade_record(order_row): + """trade_records.基数:优先交易所持仓保证金快照,旧数据无快照时回退计划保证金。""" + ex = _order_row_exchange_margin_usdt(order_row) + if ex is not None: + return round(ex, 2) + if not order_row: + return None + try: + v = order_row["margin_capital"] + except (TypeError, KeyError, IndexError): + return None + if v is None: + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def try_persist_exchange_margin_for_order(conn, order_id, exchange_symbol, direction, order_leverage=None, max_attempts=6, sleep_s=0.45): + """开仓成功后持仓可见时拉取交易所保证金并写入 order_monitors(平仓后无法再取)。""" + if not conn or not order_id or not exchange_private_api_configured(): + return False + direction = (direction or "long").lower() + ex_sym = (exchange_symbol or "").strip() + if not ex_sym: + return False + n = max(1, int(max_attempts)) + delay = max(0.05, float(sleep_s)) + for _ in range(n): + pm = get_live_position_exchange_metrics(ex_sym, direction, order_leverage=order_leverage) + if pm and pm.get("initial_margin") is not None: + try: + v = float(pm["initial_margin"]) + except (TypeError, ValueError): + v = 0.0 + if v > 0: + conn.execute( + "UPDATE order_monitors SET exchange_margin_usdt=? WHERE id=?", + (round(v, 4), int(order_id)), + ) + return True + time.sleep(delay) + return False def opened_at_str_to_ms(opened_at_str): @@ -3055,7 +3205,7 @@ def reconcile_external_closes(conn, days=None): stop_loss=r["stop_loss"], initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], take_profit=r["take_profit"], - margin_capital=r["margin_capital"], + margin_capital=margin_capital_for_trade_record(r), leverage=r["leverage"], pnl_amount=pnl_amount, hold_seconds=hold_seconds, @@ -3429,6 +3579,21 @@ def check_order_monitors(): pid, sym, direction, trigger_price, stop_loss, take_profit = r["id"], r["symbol"], r["direction"], r["trigger_price"], r["stop_loss"], r["take_profit"] margin_capital = r["margin_capital"] or DAILY_START_CAPITAL leverage = r["leverage"] or infer_leverage(sym) + trade_basis_row = row_to_dict(r) + ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) + if _order_row_exchange_margin_usdt(r) is None and exchange_private_api_configured(): + pm = get_live_position_exchange_metrics(ex_sym, direction, order_leverage=leverage) + if pm and pm.get("initial_margin") is not None: + try: + mv = float(pm["initial_margin"]) + if mv > 0: + conn.execute( + "UPDATE order_monitors SET exchange_margin_usdt=? WHERE id=?", + (round(mv, 4), pid), + ) + trade_basis_row["exchange_margin_usdt"] = round(mv, 4) + except (TypeError, ValueError): + pass session_date = r["session_date"] or get_trading_day() p = get_price(sym) if not p: continue @@ -3466,6 +3631,7 @@ def check_order_monitors(): direction == "long" and new_sl > float(stop_loss) ) if should_move: + new_sl = round_price_to_exchange(resolve_monitor_exchange_symbol(r), new_sl) conn.execute( "UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?", (new_sl, new_sl, pid), @@ -3585,7 +3751,7 @@ def check_order_monitors(): stop_loss=stop_loss, initial_stop_loss=r["initial_stop_loss"] or stop_loss, take_profit=take_profit, - margin_capital=margin_capital, + margin_capital=margin_capital_for_trade_record(trade_basis_row), leverage=leverage, pnl_amount=pnl_amount, hold_seconds=hold_seconds, @@ -3655,7 +3821,7 @@ def check_order_monitors(): stop_loss=stop_loss, initial_stop_loss=r["initial_stop_loss"] or stop_loss, take_profit=take_profit, - margin_capital=margin_capital, + margin_capital=margin_capital_for_trade_record(trade_basis_row), leverage=leverage, pnl_amount=pnl_amount, hold_seconds=hold_seconds, @@ -3720,7 +3886,7 @@ def force_close_before_reset(): stop_loss=r["stop_loss"], initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], take_profit=r["take_profit"], - margin_capital=margin_capital, + margin_capital=margin_capital_for_trade_record(r), leverage=leverage, pnl_amount=pnl_amount, hold_seconds=hold_seconds, @@ -3853,9 +4019,9 @@ def render_main_page(page="trade"): local_current_capital = float(session_row["current_capital"]) funding_capital, trading_capital = get_exchange_capitals() # 资金账户:仅展示交易所读取结果(含 0)。不可用 TOTAL_CAPITAL 兜底,否则会与实盘不符。 - funding_usdt = round(funding_capital, 4) if funding_capital is not None else None - current_capital = round(trading_capital, 4) if trading_capital is not None else round(local_current_capital, 4) - recommended_capital = get_recommended_capital(current_capital) + 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) + recommended_capital = round(float(get_recommended_capital(current_capital)), 2) key_list = conn.execute("SELECT * FROM key_monitors").fetchall() key_history = conn.execute("SELECT * FROM key_monitor_history ORDER BY id DESC LIMIT 80").fetchall() stats_bundle = compute_stats_bundle(conn, trading_day, now) @@ -3916,6 +4082,8 @@ def render_main_page(page="trade"): breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, occupied_miss_total=occupied_miss_total, price_fmt=format_price_for_symbol, + usdt_fmt=format_usdt, + signed_usdt_fmt=format_signed_usdt, entry_reason_options=list(ENTRY_REASON_OPTIONS), entry_reason_other_value=ENTRY_REASON_OTHER, exchange_display=EXCHANGE_DISPLAY_NAME, @@ -3955,9 +4123,9 @@ def api_account_snapshot(): session_row = ensure_session(conn, trading_day) local_current_capital = float(session_row["current_capital"]) funding_capital, trading_capital = get_exchange_capitals(force=True) - funding_usdt = round(funding_capital, 4) if funding_capital is not None else None - current_capital = round(trading_capital, 4) if trading_capital is not None else round(local_current_capital, 4) - recommended_capital = get_recommended_capital(current_capital) + 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) + recommended_capital = round(float(get_recommended_capital(current_capital)), 2) active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0] conn.close() can_trade = trading_day_reset_allows_new_open(now) and active_count == 0 @@ -3965,7 +4133,7 @@ def api_account_snapshot(): return jsonify({ "funding_usdt": funding_usdt, "current_capital": current_capital, - "available_trading_usdt": round(available_trading_usdt, 4) 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, "active_count": active_count, "can_trade": can_trade, @@ -3983,6 +4151,11 @@ def api_price_snapshot(): ).fetchall() conn.close() + try: + ensure_markets_loaded() + except Exception: + pass + symbol_set = set() for r in key_rows: symbol_set.add(r["symbol"]) @@ -4044,10 +4217,16 @@ def api_price_snapshot(): ) except Exception: gate_metrics = "" + px_disp = format_price_for_symbol(r["symbol"], price) + try: + price_num = float(px_disp) if px_disp != "-" else float(price) + except Exception: + price_num = float(price) key_prices.append({ "id": r["id"], "symbol": r["symbol"], - "price": round(price, 6), + "price": price_num, + "price_display": px_disp, "upper_diff": upper_diff, "upper_pct": upper_pct, "lower_diff": lower_diff, @@ -4075,11 +4254,10 @@ def api_price_snapshot(): payload = { "id": r["id"], "symbol": r["symbol"], - "price": round(price, 6), - "float_pnl": round(pnl, 6), + "float_pnl": round(pnl, 2), "float_pct": pnl_pct, "rr_ratio": rr_ratio, - "plan_margin": round(margin, 4) if margin else None, + "plan_margin": round(margin, 2) if margin else None, "exchange_initial_margin": None, "exchange_notional": None, "exchange_mark_price": None, @@ -4093,12 +4271,24 @@ def api_price_snapshot(): if ex_metrics.get("mark_price") is not None: payload["exchange_mark_price"] = ex_metrics["mark_price"] if ex_metrics.get("unrealized_pnl") is not None: - payload["float_pnl"] = round(float(ex_metrics["unrealized_pnl"]), 6) + payload["float_pnl"] = round(float(ex_metrics["unrealized_pnl"]), 2) payload["pnl_source"] = "exchange" denom = ex_metrics.get("initial_margin") or margin payload["float_pct"] = ( round((payload["float_pnl"] / float(denom)) * 100, 4) if denom and float(denom) > 0 else pnl_pct ) + px_for_fmt = float(price) + if ex_metrics and ex_metrics.get("mark_price") is not None: + try: + px_for_fmt = float(ex_metrics["mark_price"]) + except (TypeError, ValueError): + pass + px_disp = format_price_for_symbol(r["symbol"], px_for_fmt) + try: + payload["price"] = float(px_disp) if px_disp != "-" else px_for_fmt + except Exception: + payload["price"] = px_for_fmt + payload["price_display"] = px_disp order_prices.append(payload) return jsonify({ @@ -4149,7 +4339,7 @@ def api_order_defaults(): "exchange_symbol": exchange_symbol, "direction": direction, "leverage": leverage, - "available_trading_usdt": round(available, 4) if available is not None else None + "available_trading_usdt": round(available, 2) if available is not None else None }) @@ -4162,7 +4352,7 @@ def order_focus(): session_row = ensure_session(conn, trading_day) local_current_capital = float(session_row["current_capital"]) _, trading_capital_live = get_exchange_capitals() - current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4) + current_capital = round(trading_capital_live, 2) if trading_capital_live is not None else round(local_current_capital, 2) raw_orders = conn.execute("SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC").fetchall() conn.close() orders = [enrich_order_item(row_to_dict(r), current_capital) for r in raw_orders] @@ -4201,7 +4391,7 @@ def api_order_kline(): session_row = ensure_session(conn, trading_day) local_current_capital = float(session_row["current_capital"]) _, trading_capital_live = get_exchange_capitals() - current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4) + current_capital = round(trading_capital_live, 2) if trading_capital_live is not None else round(local_current_capital, 2) row = conn.execute("SELECT * FROM order_monitors WHERE id=? AND status='active'", (order_id,)).fetchone() conn.close() if not row: @@ -4236,24 +4426,29 @@ def api_order_kline(): float_pnl = calc_pnl(order_item.get("direction") or "long", entry, current_price, margin, leverage) if current_price else 0 float_pct = round((float_pnl / margin * 100), 4) if margin > 0 else 0 + sym = order_item["symbol"] return jsonify({ "ok": True, "timeframe": timeframe, "limit": limit, "order": { "id": order_item["id"], - "symbol": order_item["symbol"], + "symbol": sym, "direction": order_item.get("direction") or "long", "trigger_price": order_item.get("trigger_price"), "stop_loss": order_item.get("stop_loss"), "take_profit": order_item.get("take_profit"), + "trigger_price_display": format_price_for_symbol(sym, order_item.get("trigger_price")), + "stop_loss_display": format_price_for_symbol(sym, order_item.get("stop_loss")), + "take_profit_display": format_price_for_symbol(sym, order_item.get("take_profit")), "margin_capital": order_item.get("margin_capital"), "leverage": order_item.get("leverage"), "position_ratio": order_item.get("position_ratio"), "rr_ratio": order_item.get("rr_ratio"), "breakeven_enabled": bool(int(order_item.get("breakeven_enabled") or 0)), "current_price": round(float(current_price), 8) if current_price else None, - "float_pnl": round(float(float_pnl), 6), + "current_price_display": format_price_for_symbol(sym, current_price) if current_price else None, + "float_pnl": round(float(float_pnl), 2), "float_pct": float_pct, }, "candles": candles, @@ -4386,8 +4581,15 @@ def add_key(): flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前30,已拒绝添加关键位") return redirect("/") conn = get_db() + ex_sym_key = normalize_exchange_symbol(symbol) + try: + ensure_markets_loaded() + except Exception: + pass + upper_px = round_price_to_exchange(ex_sym_key, float(d["upper"])) + lower_px = round_price_to_exchange(ex_sym_key, float(d["lower"])) conn.execute("INSERT INTO key_monitors (symbol,monitor_type,direction,upper,lower) VALUES (?,?,?,?,?)", - (symbol, d["type"], d.get("direction", "long"), d["upper"], d["lower"])) + (symbol, d["type"], d.get("direction", "long"), upper_px, lower_px)) conn.commit() conn.close() flash(f"添加成功({symbol} 日成交量排名 {rank}/{total})") @@ -4414,14 +4616,19 @@ def add_order(): tgt_raw = parse_positive_float(d.get("tgt")) except Exception: tp_raw = sl_raw = tgt_raw = None + ex_miss = normalize_exchange_symbol(symbol) + try: + ensure_markets_loaded() + except Exception: + pass insert_trade_record( conn, symbol=symbol, monitor_type="下单监控", direction=direction if direction in ("long", "short") else "long", - trigger_price=tp_raw or 0, - stop_loss=sl_raw or 0, - take_profit=tgt_raw or 0, + trigger_price=round_price_to_exchange(ex_miss, tp_raw) if tp_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, result="错过", miss_reason="持仓占用:一次只能持有一个仓位", opened_at=app_now_str(), @@ -4467,6 +4674,13 @@ def add_order(): conn.close() flash("获取交易所实时价格失败,请稍后重试") return redirect("/") + try: + ensure_markets_loaded() + except Exception: + pass + lp_r = round_price_to_exchange(exchange_symbol, live_price) + if lp_r is not None: + live_price = lp_r sltp_mode = (d.get("sltp_mode") or "price").strip().lower() if sltp_mode not in ("price", "pct"): sltp_mode = "price" @@ -4500,6 +4714,12 @@ def add_order(): conn.close() flash("价格参数必须大于0") return redirect("/") + sl_adj = round_price_to_exchange(exchange_symbol, stop_loss) + tp_adj = round_price_to_exchange(exchange_symbol, take_profit) + if sl_adj is not None: + stop_loss = sl_adj + if tp_adj is not None: + take_profit = tp_adj risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) if risk_fraction is None: conn.close() @@ -4517,7 +4737,7 @@ def add_order(): max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) if margin_capital > max_margin: conn.close() - flash(f"保证金不足:交易账户可用约 {round(available_usdt,4)}U,当前最多建议 {max_margin}U") + flash(f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U") return redirect("/") position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 try: @@ -4533,6 +4753,10 @@ def add_order(): flash(friendly_exchange_error(e, available_usdt=available_usdt)) return redirect("/") + trigger_price = round_price_to_exchange(exchange_symbol, trigger_price) + stop_loss = round_price_to_exchange(exchange_symbol, stop_loss) + take_profit = round_price_to_exchange(exchange_symbol, take_profit) + make_order_chart = d.get("order_chart", "").lower() in ("1", "true", "on", "yes") opened_at_bj = app_now_str() opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) @@ -4542,9 +4766,10 @@ def add_order(): breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) or risk_amount if direction == "short": - breakeven_price = round(float(trigger_price) * (1 - breakeven_offset_pct / 100.0), 8) + breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) else: - breakeven_price = round(float(trigger_price) * (1 + breakeven_offset_pct / 100.0), 8) + breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) + breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0 conn.execute( "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", @@ -4557,6 +4782,8 @@ def add_order(): ) conn.commit() new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage) + conn.commit() opens_today_after = conn.execute( "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", (trading_day,), @@ -4632,9 +4859,9 @@ def add_order(): _, trading_capital_after = get_exchange_capitals(force=True) account_base_display = ( - round(float(trading_capital_after), 4) + round(float(trading_capital_after), 2) if trading_capital_after is not None - else round(float(capital_base), 4) + else round(float(capital_base), 2) ) account_name = (os.getenv("GATE_ACCOUNT_LABEL") or "gate实盘账户").strip() dir_text = "多头(long)" if direction == "long" else "空头(short)" @@ -4645,12 +4872,12 @@ def add_order(): ) rr_show = planned_rr if planned_rr is not None else "-" try: - rr_show_fmt = round(float(planned_rr), 4) if planned_rr is not None else None + rr_show_fmt = f"{float(planned_rr):.2f}" if planned_rr is not None else None except (TypeError, ValueError): rr_show_fmt = None rr_line = f"RR {rr_show_fmt} : 1" if rr_show_fmt is not None else f"RR {rr_show} : 1" ep_wx = format_price_for_symbol(symbol, trigger_price) - sl_wx = format_price_for_symbol(symbol, stop_loss) + sl_wx = format_wechat_scalar_2dp(stop_loss) tp_wx = format_price_for_symbol(symbol, take_profit) be_wx = format_price_for_symbol(symbol, breakeven_price) style_zh = "Swing 波段" if trade_style == "swing" else "Trend 趋势" @@ -4660,13 +4887,13 @@ def add_order(): "🧾 订单基础信息", f"🔖 交易所订单 ID:{open_order_id}", f"📈 交易风格:{style_zh}", - f"⚠️ 单笔风控风险:{risk_percent}% ≈ {round(float(risk_amount_final), 4)} U", + f"⚠️ 单笔风控风险:{risk_percent}% ≈ {round(float(risk_amount_final), 2)} U", "📊 仓位配置详情", f"账户基数:{account_base_display} USDT", f"合约杠杆:{leverage} 倍", - f"名义仓位:{notional_value} USDT", + f"名义仓位:{format_wechat_scalar_2dp(notional_value)} USDT", f"仓位占比:{position_ratio}%", - f"合约张数:{amount} 张", + f"合约张数:{format_wechat_scalar_2dp(amount)} 张", f"折算标的:{base_amount} {journal_coin_from_symbol(symbol)}", "🎯 价位 & 盈亏比", f"开仓成交价:{ep_wx}", @@ -4683,8 +4910,8 @@ def add_order(): send_wechat_msg("\n".join(wx_lines)) flash_lines = [ - f"实盘开单成功:风格 {trade_style};风险 {risk_percent}%≈{risk_amount_final}U;基数 {margin_capital}U,杠杆 {leverage}x,名义仓位 {notional_value}U,仓位占比 {position_ratio}%,合约张数 {amount}(折算标的 {base_amount})," - f"计划RR {planned_rr if planned_rr is not None else '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)", + f"实盘开单成功:风格 {trade_style};风险 {risk_percent}%≈{round(float(risk_amount_final), 2)}U;基数 {round(float(margin_capital), 2)}U,杠杆 {leverage}x,名义仓位 {format_wechat_scalar_2dp(notional_value)}U,仓位占比 {position_ratio}%,合约张数 {format_wechat_scalar_2dp(amount)}(折算标的 {base_amount})," + f"计划RR {format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)", f"本交易日累计开仓:{opens_today_after}", ] if chart_url: @@ -4694,7 +4921,7 @@ def add_order(): if opens_today_before < DAILY_OPEN_ALERT_THRESHOLD <= opens_today_after: advice = ai_short_advice( f"用户在北京时间交易日 {trading_day} 已累计开仓 {opens_today_after} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。" - f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{margin_capital}U。" + f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{round(float(margin_capital), 2)}U。" f"用户自述“上头了”。请给克制提醒。" ) if advice: @@ -4931,6 +5158,7 @@ def del_order(id): cancel_gate_swap_trigger_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) session_date = row["session_date"] or get_trading_day() session_capital = update_session_capital(conn, session_date, pnl_amount) + row_snap = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() or row insert_trade_record( conn, symbol=row["symbol"], @@ -4940,7 +5168,7 @@ def del_order(id): stop_loss=row["stop_loss"], initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], take_profit=row["take_profit"], - margin_capital=row["margin_capital"], + margin_capital=margin_capital_for_trade_record(row_snap), leverage=row["leverage"], pnl_amount=pnl_amount, hold_seconds=hold_seconds, @@ -4985,6 +5213,7 @@ def del_order(id): hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) session_date = row["session_date"] or get_trading_day(closed_at_dt) update_session_capital(conn, session_date, pnl_amount) + row_snap = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() or row insert_trade_record( conn, symbol=row["symbol"], @@ -4994,7 +5223,7 @@ def del_order(id): stop_loss=row["stop_loss"], initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], take_profit=row["take_profit"], - margin_capital=row["margin_capital"], + margin_capital=margin_capital_for_trade_record(row_snap), leverage=row["leverage"], pnl_amount=pnl_amount, hold_seconds=hold_seconds, @@ -5025,15 +5254,28 @@ def del_order(id): def add_miss(): d = request.form direction = d.get("direction", "long") + sym_in = normalize_symbol_input(d.get("symbol")) + ex_sym = normalize_exchange_symbol(sym_in) + try: + ensure_markets_loaded() + except Exception: + pass + try: + tp_px = round_price_to_exchange(ex_sym, float(d["tp"])) + sl_px = round_price_to_exchange(ex_sym, float(d["sl"])) + tgt_px = round_price_to_exchange(ex_sym, float(d["tgt"])) + except Exception: + flash("价格格式错误") + return redirect("/records") conn = get_db() insert_trade_record( conn, - symbol=d["symbol"], + symbol=sym_in, monitor_type=d["type"], direction=direction, - trigger_price=d["tp"], - stop_loss=d["sl"], - take_profit=d["tgt"], + trigger_price=tp_px, + stop_loss=sl_px, + take_profit=tgt_px, result="错过", miss_reason=d["reason"], opened_at=app_now_str(), @@ -5379,11 +5621,20 @@ def api_trade_record_review_update(): reviewed_entry_reason_update = s or None conn = get_db() - row = conn.execute("SELECT risk_amount FROM trade_records WHERE id=?", (rec_id,)).fetchone() + row = conn.execute("SELECT risk_amount, symbol FROM trade_records WHERE id=?", (rec_id,)).fetchone() if not row: conn.close() return jsonify({"ok": False, "msg": "记录不存在"}), 404 risk_amount = row["risk_amount"] + ex_review = resolve_ccxt_price_symbol(row["symbol"]) + try: + ensure_markets_loaded() + except Exception: + pass + if reviewed_stop_loss is not None: + reviewed_stop_loss = round_price_to_exchange(ex_review, reviewed_stop_loss) + if reviewed_take_profit is not None: + reviewed_take_profit = round_price_to_exchange(ex_review, reviewed_take_profit) actual_rr = calc_actual_rr(reviewed_pnl_amount, risk_amount) base_params = [ reviewed_opened_at, diff --git a/crypto_monitor_gate/templates/index.html b/crypto_monitor_gate/templates/index.html index 7d59cb9..41e968c 100644 --- a/crypto_monitor_gate/templates/index.html +++ b/crypto_monitor_gate/templates/index.html @@ -130,14 +130,14 @@
开单次数
{{ s.opens_count }}
平仓笔数
{{ s.closed_count }}
胜率
{% if s.win_rate_pct is not none %}{{ s.win_rate_pct }}%{% else %}-{% endif %}
-
净盈亏(U)
{{ s.net_pnl_u }}
-
亏损额合计(U)
{{ s.loss_sum_u }}
-
单笔最大亏损(U)
{% if s.max_single_loss is not none %}{{ s.max_single_loss }}{% else %}-{% endif %}
-
单笔最大盈利(U)
{% if s.max_single_profit is not none %}{{ s.max_single_profit }}{% else %}-{% endif %}
-
最大回撤(U)
{{ s.max_drawdown_u }}
+
净盈亏(U)
{{ signed_usdt_fmt(s.net_pnl_u) }}
+
亏损额合计(U)
{{ usdt_fmt(s.loss_sum_u) }}
+
单笔最大亏损(U)
{% if s.max_single_loss is not none %}{{ signed_usdt_fmt(s.max_single_loss) }}{% else %}-{% endif %}
+
单笔最大盈利(U)
{% if s.max_single_profit is not none %}{{ usdt_fmt(s.max_single_profit) }}{% else %}-{% endif %}
+
最大回撤(U)
{{ usdt_fmt(s.max_drawdown_u) }}
当前连续亏损笔数
{{ s.consecutive_losses }}
最长连续亏损(交易日)
{{ s.max_loss_streak_days }} 天
-
期内最大亏损日
{% if s.worst_day %}{{ s.worst_day }}({{ s.worst_day_pnl }}U){% else %}-{% endif %}
+
期内最大亏损日
{% if s.worst_day %}{{ s.worst_day }}({{ signed_usdt_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}
{% endmacro %} @@ -165,9 +165,9 @@
总交易
{{ total }}
错过次数
{{ miss_count }}
胜率
{{ rate }}%
-
资金账户(USDT)
{% if funding_usdt is not none %}{{ funding_usdt }}U{% else %}—{% endif %}
+
资金账户(USDT)
{% if funding_usdt is not none %}{{ usdt_fmt(funding_usdt) }}U{% else %}—{% endif %}
交易日
{{ trading_day }}
-
当日资金(交易账户)
{{ current_capital }}U
+
当日资金(交易账户)
{{ usdt_fmt(current_capital) }}U
实时价格更新时间:--(北京时间 UTC+8)
@@ -202,7 +202,7 @@
{{ k.symbol }} | {{ k.monitor_type }} | {{ '做多' if k.direction == 'long' else '做空' }}
- 上:{{ k.upper }} 下:{{ k.lower }} + 上:{{ price_fmt(k.symbol, k.upper) }} 下:{{ price_fmt(k.symbol, k.lower) }} | 已提醒:{{ k.notification_count or 0 }}/{{ k.max_notify or 3 }} | 现价:- | 距上沿:- @@ -224,7 +224,7 @@ {{ h.symbol }} | {{ h.monitor_type }} | {{ '做多' if h.direction == 'long' else '做空' }} | {{ h.close_reason }}
-
上:{{ h.upper }} 下:{{ h.lower }} | 提醒次数:{{ h.notification_count }} | {{ (h.closed_at or '-')[:16] }}
+
上:{{ price_fmt(h.symbol, h.upper) }} 下:{{ price_fmt(h.symbol, h.lower) }} | 提醒次数:{{ h.notification_count }} | {{ (h.closed_at or '-')[:16] }}
{% if h.last_alert_message %}
{{ h.last_alert_message[:200] }}{% if h.last_alert_message|length > 200 %}…{% endif %}
{% endif %}
{% else %} @@ -252,7 +252,7 @@ 以损定仓:风险 {{ risk_percent }}% |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
- 划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00起该整点小时内尝试;账簿按 UTC 自然日去重;界面时间为北京;将 {{ auto_transfer_to }} 补足到 {{ auto_transfer_amount }}U,来自 {{ auto_transfer_from }}) + 划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00起该整点小时内尝试;账簿按 UTC 自然日去重;界面时间为北京;将 {{ auto_transfer_to }} 补足到 {{ usdt_fmt(auto_transfer_amount) }}U,来自 {{ auto_transfer_from }})
@@ -300,14 +300,14 @@
{{ o.symbol }} | {{ '做多' if o.direction == 'long' else '做空' }}
- 风格:{{ o.trade_style or 'trend' }} | 风险:{{ o.risk_percent or '-' }}%≈{{ o.risk_amount or '-' }}U - | {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ o.breakeven_price or '-' }}{% else %}移动保本:关{% endif %} + 风格:{{ o.trade_style or 'trend' }} | 风险:{{ o.risk_percent or '-' }}%≈{% if o.risk_amount is not none %}{{ usdt_fmt(o.risk_amount) }}{% else %}-{% endif %}U + | {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
- 成交:{{ o.trigger_price }} 止损:{{ o.stop_loss }} 止盈:{{ o.take_profit }} + 成交:{{ price_fmt(o.symbol, o.trigger_price) }} 止损:{{ price_fmt(o.symbol, o.stop_loss) }} 止盈:{{ price_fmt(o.symbol, o.take_profit) }} | 盈亏比:{% if o.rr_ratio is not none %}1:{{ '%.2f'|format(o.rr_ratio) }}{% else %}-{% endif %} | 现价:- | 浮盈亏:- - | 计划基数:{{ o.margin_capital }}U | 所保证金:- + | 计划基数:{% if o.margin_capital is not none %}{{ usdt_fmt(o.margin_capital) }}{% else %}-{% endif %}U | 所保证金:- | 杠杆:{{ o.leverage }}x | 仓位占比:{{ o.position_ratio }}%
平仓 @@ -335,18 +335,18 @@ {{ r.symbol }} {{ r.monitor_type }} {{ '做多' if r.direction == 'long' else '做空' }} - {{ r.trigger_price }} + {{ price_fmt(r.symbol, r.trigger_price) }} {% set stop_show = r.effective_stop_loss or r.initial_stop_loss or r.stop_loss %} {% set tp_show = r.effective_take_profit or r.take_profit %} {{ price_fmt(r.symbol, stop_show) }} {{ price_fmt(r.symbol, tp_show) }} - {{ r.margin_capital or '-' }} + {% if r.margin_capital is not none %}{{ usdt_fmt(r.margin_capital) }}{% else %}-{% endif %} {{ r.leverage or '-' }} {{ r.effective_hold_minutes or 0 }} {{ (r.effective_opened_at or '-')[:16] }} {{ (r.effective_closed_at or r.created_at or '-')[:16] }} {% set pnl_val = (r.effective_pnl_amount or 0)|float %} - {{ r.effective_pnl_amount or 0 }} + {{ signed_usdt_fmt(r.effective_pnl_amount or 0) }} {% set effective_result = r.effective_result %} {% if effective_result in ["止盈","保本止盈","移动止盈"] %}{{ effective_result }} @@ -602,7 +602,7 @@ function openJournalDetail(id){ `开仓时间:${o.open_datetime || "-"}`, `平仓时间:${o.close_datetime || "-"}`, `持仓时长:${o.hold_duration || "-"}`, - `盈亏:${o.pnl || "-"}U`, + `盈亏:${formatJournalPnlUi(o.pnl)}U`, `开仓类型:${o.entry_reason || "无"}`, `平仓/离场:${formatJournalExitOneLine(o)}`, `预期RR:${o.expect_rr || "-"}`, @@ -685,7 +685,7 @@ function editTradeRecordReview(t){ if(stopLoss === null) return; const takeProfit = prompt("止盈价格(核对后用于统计)", String(t.take_profit ?? "")); if(takeProfit === null) return; - const pnl = prompt("最终盈亏(可手工核对后填写)", String(t.pnl_amount ?? "")); + const pnl = prompt("最终盈亏(可手工核对后填写)", (t.pnl_amount === null || typeof t.pnl_amount === "undefined") ? "" : (Number.isFinite(Number(t.pnl_amount)) ? Number(t.pnl_amount).toFixed(2) : String(t.pnl_amount))); if(pnl === null) return; const result = prompt("结果(止盈/止损/保本止盈/移动止盈/手动平仓)", String(t.result || "")); if(result === null) return; @@ -751,7 +751,7 @@ function loadJournals(){ journalCache[o.id] = o; const moodTags = (o.mood_issues || []).join(",") || "无"; html += `
-
${o.coin||"-"} ${o.tf||"-"} | 盈亏:${o.pnl||"-"}U
+
${o.coin||"-"} ${o.tf||"-"} | 盈亏:${formatJournalPnlUi(o.pnl)}U
开:${o.open_datetime||"-"} 平:${o.close_datetime||"-"} 持仓:${o.hold_duration||"-"}
心态标签:${moodTags}
@@ -908,7 +908,7 @@ function fillJournalFromTrade(t){ setJournalField("close_datetime", toDatetimeLocalFromBeijing(t.closed_at)); setJournalField("coin", coinFromSymbol(t.symbol)); setJournalField("tf", "5m"); - setJournalField("pnl", (t.pnl_amount === null || typeof t.pnl_amount === "undefined") ? "" : String(t.pnl_amount)); + setJournalField("pnl", (t.pnl_amount === null || typeof t.pnl_amount === "undefined") ? "" : (Number.isFinite(Number(t.pnl_amount)) ? Number(t.pnl_amount).toFixed(2) : String(t.pnl_amount))); const rr = calcExpectedRrFromTrade(t); setJournalField("expect_rr", rr); let realRr = rr; @@ -919,7 +919,7 @@ function fillJournalFromTrade(t){ } setJournalField("real_rr", realRr); const riskHint = document.getElementById("risk-amount-hint"); - if(riskHint){ riskHint.value = (Number.isFinite(riskAmount) && riskAmount > 0) ? String(riskAmount) : ""; } + if(riskHint){ riskHint.value = (Number.isFinite(riskAmount) && riskAmount > 0) ? riskAmount.toFixed(2) : ""; } const entryHint = document.getElementById("entry-price-hint"); if(entryHint){ entryHint.value = t.trigger_price || ""; } const stopHint = document.getElementById("stop-loss-hint"); @@ -1098,6 +1098,30 @@ function formatSigned(v, digits=4){ return `${sign}${n.toFixed(digits)}`; } +function formatUsdt2(v){ + if(v === null || typeof v === "undefined" || v === "") return "-"; + const n = Number(v); + if(Number.isNaN(n)) return "-"; + return n.toFixed(2); +} + +function formatSignedUsdt2(v){ + if(v === null || typeof v === "undefined" || v === "" || Number.isNaN(Number(v))) return "-"; + const n = Number(v); + if(n === 0) return "0.00"; + const sign = n > 0 ? "+" : ""; + return `${sign}${n.toFixed(2)}`; +} + +function formatJournalPnlUi(v){ + if(v === null || typeof v === "undefined" || v === "") return "-"; + const raw = String(v).trim(); + if(!raw) return "-"; + const n = Number(raw.replace(/,/g, "")); + if(Number.isFinite(n)) return formatSignedUsdt2(n); + return raw; +} + function paintPriceTrend(el, key, value){ if(!el) return; const prev = lastPriceMap[key]; @@ -1121,7 +1145,7 @@ function refreshPriceSnapshot(){ (data.key_prices || []).forEach(k=>{ const pEl = document.getElementById(`key-price-${k.id}`); if(pEl){ - pEl.innerText = Number(k.price).toFixed(6); + pEl.innerText = (k.price_display && k.price_display !== "-") ? k.price_display : Number(k.price).toFixed(6); paintPriceTrend(pEl, `k-${k.id}`, Number(k.price)); } const upEl = document.getElementById(`key-up-diff-${k.id}`); @@ -1145,18 +1169,19 @@ function refreshPriceSnapshot(){ (data.order_prices || []).forEach(o=>{ const pEl = document.getElementById(`order-price-${o.id}`); if(pEl){ + const pxd = (o.price_display && o.price_display !== "-") ? o.price_display : null; const hasMark = (()=>{ const x = o.exchange_mark_price; if(x===null||x===undefined||x==="")return false; const n=Number(x); return !Number.isNaN(n); })(); const px = hasMark ? Number(o.exchange_mark_price) : Number(o.price); const decimals = hasMark ? 8 : 6; - pEl.innerText = px.toFixed(decimals); - paintPriceTrend(pEl, `o-${o.id}`, px); + pEl.innerText = pxd !== null ? pxd : px.toFixed(decimals); + paintPriceTrend(pEl, `o-${o.id}`, pxd !== null ? Number(pxd) : 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(4)}U`; + exM.innerText = `${formatUsdt2(mn)}U`; } else { const prc = (typeof data.positions_raw_count === "number") ? data.positions_raw_count : null; exM.innerText = (prc === 0) ? "无仓数据" : "-"; @@ -1164,7 +1189,7 @@ function refreshPriceSnapshot(){ } const pnlEl = document.getElementById(`order-pnl-${o.id}`); if(pnlEl){ - pnlEl.innerText = `${formatSigned(o.float_pnl, 4)}U (${formatSigned(o.float_pct, 2)}%)`; + pnlEl.innerText = `${formatSignedUsdt2(o.float_pnl)}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"); @@ -1198,7 +1223,7 @@ function refreshOrderDefaults(){ const fullEl = document.getElementById("use-full-margin"); const marginEl = document.getElementById("order-margin"); if(fullEl && marginEl && fullEl.checked){ - const m = Math.max(latestAvailableUsdt * {{ full_margin_buffer_ratio }}, 0).toFixed(4); + const m = Math.max(latestAvailableUsdt * {{ full_margin_buffer_ratio }}, 0).toFixed(2); marginEl.value = m; } } @@ -1209,18 +1234,18 @@ function refreshAccountSnapshot(){ fetch("/api/account_snapshot").then(r=>r.json()).then(data=>{ if (typeof data.funding_usdt !== "undefined") { const el = document.getElementById("total-capital"); - if(el) el.innerText = (data.funding_usdt === null || data.funding_usdt === undefined) ? "—" : `${data.funding_usdt}U`; + if(el) el.innerText = (data.funding_usdt === null || data.funding_usdt === undefined) ? "—" : `${formatUsdt2(data.funding_usdt)}U`; } if (typeof data.current_capital !== "undefined") { const el = document.getElementById("current-capital"); - if(el) el.innerText = `${data.current_capital}U`; + if(el) el.innerText = `${formatUsdt2(data.current_capital)}U`; } if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) { latestAvailableUsdt = Number(data.available_trading_usdt); } const canTradeText = data.can_trade ? "可开仓" : "不可开仓(有持仓或未到北京时间 {{ reset_hour }}:00)"; const tip = document.getElementById("order-rule-tip"); - const avail = (latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)) ? `;交易账户可用约${latestAvailableUsdt}U` : ""; + const avail = (latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)) ? `;交易账户可用约 ${formatUsdt2(latestAvailableUsdt)}U` : ""; if(tip){ tip.innerText = `规则:单仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;${canTradeText}${avail}`; } @@ -1236,7 +1261,7 @@ if(fullMarginEl){ fullMarginEl.addEventListener("change", function(){ const marginEl = document.getElementById("order-margin"); if(marginEl && this.checked && latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)){ - marginEl.value = Math.max(latestAvailableUsdt * {{ full_margin_buffer_ratio }}, 0).toFixed(4); + marginEl.value = Math.max(latestAvailableUsdt * {{ full_margin_buffer_ratio }}, 0).toFixed(2); } }); } diff --git a/crypto_monitor_gate/templates/order_focus.html b/crypto_monitor_gate/templates/order_focus.html index 0811e93..c0992d4 100644 --- a/crypto_monitor_gate/templates/order_focus.html +++ b/crypto_monitor_gate/templates/order_focus.html @@ -140,13 +140,13 @@ function addLine(price, title, color){ function paintOrder(order){ document.getElementById("m-symbol").innerText = order.symbol || "-"; document.getElementById("m-direction").innerText = (order.direction === "short") ? "做空" : "做多"; - document.getElementById("m-entry").innerText = fmt(order.trigger_price, 8); - document.getElementById("m-sl").innerText = fmt(order.stop_loss, 8); - document.getElementById("m-tp").innerText = fmt(order.take_profit, 8); + document.getElementById("m-entry").innerText = order.trigger_price_display || fmt(order.trigger_price, 8); + document.getElementById("m-sl").innerText = order.stop_loss_display || fmt(order.stop_loss, 8); + document.getElementById("m-tp").innerText = order.take_profit_display || fmt(order.take_profit, 8); document.getElementById("m-rr").innerText = (order.rr_ratio === null || typeof order.rr_ratio === "undefined") ? "-" : `1:${Number(order.rr_ratio).toFixed(2)}`; - document.getElementById("m-price").innerText = fmt(order.current_price, 8); + document.getElementById("m-price").innerText = order.current_price_display || fmt(order.current_price, 8); const pnlEl = document.getElementById("m-pnl"); - pnlEl.innerText = `${fmt(order.float_pnl, 4)}U (${fmt(order.float_pct, 2)}%)`; + pnlEl.innerText = `${fmt(order.float_pnl, 2)}U (${fmt(order.float_pct, 2)}%)`; pnlEl.style.color = Number(order.float_pnl || 0) > 0 ? "#4cd97f" : (Number(order.float_pnl || 0) < 0 ? "#ff6666" : "#d6deff"); } diff --git a/crypto_monitor_gate/templates/order_focus_v2.html b/crypto_monitor_gate/templates/order_focus_v2.html index 9c9add3..f9bceab 100644 --- a/crypto_monitor_gate/templates/order_focus_v2.html +++ b/crypto_monitor_gate/templates/order_focus_v2.html @@ -142,15 +142,15 @@ function addLine(price, title, color){ function paintOrder(order){ document.getElementById("m-symbol").innerText = order.symbol || "-"; document.getElementById("m-direction").innerText = (order.direction === "short") ? "做空" : "做多"; - document.getElementById("m-entry").innerText = fmt(order.trigger_price, 8); - document.getElementById("m-sl").innerText = fmt(order.stop_loss, 8); - document.getElementById("m-tp").innerText = fmt(order.take_profit, 8); + document.getElementById("m-entry").innerText = order.trigger_price_display || fmt(order.trigger_price, 8); + document.getElementById("m-sl").innerText = order.stop_loss_display || fmt(order.stop_loss, 8); + document.getElementById("m-tp").innerText = order.take_profit_display || fmt(order.take_profit, 8); document.getElementById("m-rr").innerText = (order.rr_ratio === null || typeof order.rr_ratio === "undefined") ? "-" : `1:${Number(order.rr_ratio).toFixed(2)}`; document.getElementById("m-breakeven").innerText = (order.breakeven_enabled === false || order.breakeven_enabled === 0) ? "关闭" : "开启"; - document.getElementById("m-price").innerText = fmt(order.current_price, 8); + document.getElementById("m-price").innerText = order.current_price_display || fmt(order.current_price, 8); const pnlEl = document.getElementById("m-pnl"); - pnlEl.innerText = `${fmt(order.float_pnl, 4)}U (${fmt(order.float_pct, 2)}%)`; + pnlEl.innerText = `${fmt(order.float_pnl, 2)}U (${fmt(order.float_pct, 2)}%)`; pnlEl.style.color = Number(order.float_pnl || 0) > 0 ? "#4cd97f" : (Number(order.float_pnl || 0) < 0 ? "#ff6666" : "#d6deff"); } diff --git a/crypto_monitor_gate_bot/.env b/crypto_monitor_gate_bot/.env index 797589c..bcb689a 100644 --- a/crypto_monitor_gate_bot/.env +++ b/crypto_monitor_gate_bot/.env @@ -37,8 +37,9 @@ BTC_LEVERAGE=10 ALT_LEVERAGE=5 # 交易日重置小时(北京时间) TRADING_DAY_RESET_HOUR=8 -# 整点前禁止新开仓:true=启用(默认),false=关闭(仍可保留 8 点作为交易日划分) -TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true +# Gate 平仓历史:同步「趋势回调」交易记录与交易所已实现盈亏(北京日期 00:00 起,与 APP_TIMEZONE 一致);留空则从近 90 天拉取 +# EXCHANGE_POSITION_SYNC_FROM_BJ=2026-05-14 +# EXCHANGE_POSITION_HISTORY_LIMIT=200 # 是否开启 Gate 实盘下单(false=只做本地流程,true=真实下单) LIVE_TRADING_ENABLED=true diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index 6e9c81e..2f5f5fd 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -93,6 +93,11 @@ TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv( "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true" ).lower() in ("1", "true", "yes", "on") APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai") +# 交易所「平仓历史」同步:自北京日期 00:00 起(与 APP_TIMEZONE 一致);空则取最近 90 天 +EXCHANGE_POSITION_SYNC_FROM_BJ = (os.getenv("EXCHANGE_POSITION_SYNC_FROM_BJ") or "").strip() +EXCHANGE_POSITION_HISTORY_LIMIT = max(50, min(1000, int(os.getenv("EXCHANGE_POSITION_HISTORY_LIMIT", "200")))) + +_LAST_POSITION_HISTORY_SYNC_AT = 0.0 def _resolve_app_tz(): @@ -1182,6 +1187,26 @@ def init_db(): try: c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_entry_reason TEXT") except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN trend_plan_id INTEGER") + except Exception: + pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN exchange_realized_pnl REAL") + except Exception: + pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT") + except Exception: + pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT") + except Exception: + pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT") + except Exception: + pass try: c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_score INTEGER") except: pass @@ -1286,6 +1311,36 @@ def init_db(): )""" ) + c.execute( + """CREATE TABLE IF NOT EXISTS trend_pullback_preview_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + preview_id TEXT NOT NULL UNIQUE, + symbol TEXT NOT NULL, + exchange_symbol TEXT NOT NULL, + direction TEXT NOT NULL, + leverage INTEGER NOT NULL, + stop_loss REAL NOT NULL, + add_upper REAL NOT NULL, + take_profit REAL NOT NULL, + risk_percent REAL NOT NULL, + snapshot_available_usdt REAL NOT NULL, + snapshot_at TEXT, + live_price_ref REAL, + plan_margin_capital REAL, + target_order_amount REAL, + first_order_amount REAL, + remainder_total REAL, + dca_legs INTEGER, + per_leg_amount REAL, + grid_prices_json TEXT, + leg_amounts_json TEXT, + expires_at_ms INTEGER NOT NULL, + preview_created_at TEXT, + outcome TEXT DEFAULT 'open', + executed_plan_id INTEGER + )""" + ) + conn.commit() conn.close() @@ -1710,9 +1765,57 @@ def to_effective_trade_dict(row): item["effective_hold_seconds"] = get_effective_trade_field(row, "reviewed_hold_seconds", "hold_seconds", item.get("hold_seconds")) er_eff = get_effective_trade_field(row, "reviewed_entry_reason", "entry_reason", item.get("entry_reason")) item["effective_entry_reason"] = (str(er_eff).strip() if er_eff is not None else "") or "" + mt = (item.get("monitor_type") or "").strip() + ex_pnl = item.get("exchange_realized_pnl") + ex_open = item.get("exchange_opened_at") + ex_close = item.get("exchange_closed_at") + if mt == MONITOR_TYPE_TREND and ex_pnl is not None and str(ex_pnl).strip() != "": + try: + item["display_pnl_amount"] = float(ex_pnl) + except (TypeError, ValueError): + item["display_pnl_amount"] = float(item.get("effective_pnl_amount") or 0) + item["display_pnl_source"] = "exchange" + eo = (str(ex_open).strip() if ex_open else "") or item.get("effective_opened_at") or "" + ec = (str(ex_close).strip() if ex_close else "") or item.get("effective_closed_at") or "" + item["display_opened_at"] = eo[:16] if eo else "-" + item["display_closed_at"] = ec[:16] if ec else "-" + else: + try: + item["display_pnl_amount"] = float(item.get("effective_pnl_amount") or 0) + except (TypeError, ValueError): + item["display_pnl_amount"] = 0.0 + item["display_pnl_source"] = "local" + eo = item.get("effective_opened_at") or "" + ec = item.get("effective_closed_at") or "" + item["display_opened_at"] = (eo[:16] if eo else "-") + item["display_closed_at"] = (ec[:16] if ec else "-") return item +def format_money_usdt(value): + """资金类展示:固定两位小数(USDT)。""" + if value is None or value == "": + return "—" + try: + return f"{round(float(value), 2):.2f}" + except (TypeError, ValueError): + return "—" + + +def _exchange_unified_symbol_for_format(symbol_str): + if not symbol_str: + return None + s = str(symbol_str).strip() + if not s: + return None + try: + if ":" in s or "/" in s: + return normalize_exchange_symbol(s) + return normalize_exchange_symbol(f"{s}/USDT") + except Exception: + return None + + def format_price_for_symbol(symbol, value): if value in (None, ""): return "-" @@ -1722,8 +1825,14 @@ def format_price_for_symbol(symbol, value): return str(value) if v == 0: return "0" + sym = _exchange_unified_symbol_for_format(symbol) + if sym and exchange_private_api_configured(): + try: + ensure_markets_loaded() + return str(exchange.price_to_precision(sym, v)) + except Exception: + pass av = abs(v) - # 根据币价量级动态精度:低价币保留更多小数,高价币减少噪音位数 if av >= 10000: d = 2 elif av >= 100: @@ -1740,6 +1849,70 @@ def format_price_for_symbol(symbol, value): return text.rstrip("0").rstrip(".") if "." in text else text +def format_amount_for_symbol(symbol, value): + """合约张数等:尽量与交易所 amount 精度一致。""" + if value in (None, ""): + return "-" + try: + v = float(value) + except Exception: + return str(value) + sym = _exchange_unified_symbol_for_format(symbol) + if sym and exchange_private_api_configured(): + try: + ensure_markets_loaded() + return str(exchange.amount_to_precision(sym, v)) + except Exception: + pass + text = f"{v:.8f}" + return text.rstrip("0").rstrip(".") if "." in text else text + + +def insert_trend_preview_snapshot(conn, preview_id, created, exp_ms, pl): + """生成预览成功后归档一条快照(与 trend_pullback_previews 同参)。""" + conn.execute( + """INSERT INTO trend_pullback_preview_snapshots ( + preview_id,symbol,exchange_symbol,direction,leverage,stop_loss,add_upper,take_profit,risk_percent, + snapshot_available_usdt,snapshot_at,live_price_ref,plan_margin_capital,target_order_amount,first_order_amount,remainder_total, + dca_legs,per_leg_amount,grid_prices_json,leg_amounts_json,expires_at_ms,preview_created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + preview_id, + pl["symbol"], + pl["exchange_symbol"], + pl["direction"], + pl["leverage"], + pl["stop_loss"], + pl["add_upper"], + pl["take_profit"], + pl["risk_percent"], + pl["snapshot_available_usdt"], + pl["snapshot_at"], + pl["live_price_ref"], + pl["plan_margin_capital"], + pl["target_order_amount"], + pl["first_order_amount"], + pl["remainder_total"], + pl["dca_legs"], + pl["per_leg_amount"], + pl["grid_prices_json"], + pl["leg_amounts_json"], + exp_ms, + created, + ), + ) + + +def preview_snapshot_outcome_label(outcome): + o = (outcome or "").strip().lower() + return { + "open": "待确认", + "executed": "已执行", + "cancelled": "已取消", + "expired": "已过期", + }.get(o, outcome or "-") + + def format_hold_minutes(minutes): if not minutes: return "0分钟" @@ -1887,6 +2060,7 @@ def insert_trade_record( closed_at=None, closed_at_ms=None, exchange_trade_id=None, + trend_plan_id=None, ): hold_minutes = calc_hold_minutes(hold_seconds) open_ts = opened_at or app_now_str() @@ -1894,12 +2068,12 @@ def insert_trade_record( open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts) close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts) conn.execute( - "INSERT INTO trade_records (symbol,monitor_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO trade_records (symbol,monitor_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,trend_plan_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( symbol, monitor_type, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, pnl_amount, hold_seconds, trade_style, risk_amount, planned_rr, actual_rr, hold_minutes, - open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id + open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id, trend_plan_id ) ) @@ -2395,6 +2569,15 @@ def precheck_trend_pullback_start(conn): def _trend_cleanup_stale_previews(conn): ms = int(time.time() * 1000) + stale = conn.execute("SELECT id FROM trend_pullback_previews WHERE expires_at_ms < ?", (ms,)).fetchall() + for row in stale: + try: + conn.execute( + "UPDATE trend_pullback_preview_snapshots SET outcome='expired' WHERE preview_id=? AND outcome='open'", + (row["id"],), + ) + except Exception: + pass conn.execute("DELETE FROM trend_pullback_previews WHERE expires_at_ms < ?", (ms,)) @@ -3030,6 +3213,236 @@ def get_live_position_exchange_metrics(exchange_symbol, direction): return parse_ccxt_position_metrics(p) +def _unified_symbol_for_match(symbol_str): + """统一 BTC/USDT:USDT 与 BTC/USDT 便于与 trade_records.symbol 比对。""" + x = (symbol_str or "").strip().upper() + if ":" in x: + x = x.split(":")[0] + return x + + +def exchange_position_sync_since_ms(): + """Gate fetch_positions_history 的 since(毫秒,含当日 0 点)。""" + s = EXCHANGE_POSITION_SYNC_FROM_BJ + if s: + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d", 10)): + try: + chunk = s[:ln] if len(s) >= ln else s[:10] + dt = datetime.strptime(chunk, fmt) + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + continue + dt0 = app_now() - timedelta(days=90) + try: + aware0 = datetime(dt0.year, dt0.month, dt0.day, 0, 0, 0, tzinfo=APP_TZ) + except Exception: + aware0 = datetime.now(APP_TZ) + return int(aware0.timestamp() * 1000) + + +def _coerce_ts_ms(val): + if val is None or val == "": + return None + try: + v = float(val) + except (TypeError, ValueError): + return None + if v > 1e12: + return int(v) + if v > 1e10: + return int(v) + return int(v * 1000.0) + + +def _normalize_gate_position_history_entry(p): + if not p or not isinstance(p, dict): + return None + info = p.get("info") or {} + sym = p.get("symbol") or "" + side = (p.get("side") or "").strip().lower() + if side not in ("long", "short"): + sz = info.get("accum_size") if info.get("accum_size") is not None else info.get("size") + try: + szf = float(sz) + if szf > 0: + side = "long" + elif szf < 0: + side = "short" + except (TypeError, ValueError): + side = "" + rp = p.get("realizedPnl") + if rp is None: + rp = info.get("pnl") + try: + rp_f = float(rp) if rp is not None and str(rp).strip() != "" else None + except (TypeError, ValueError): + rp_f = None + close_ms = _coerce_ts_ms(p.get("lastUpdateTimestamp")) + if close_ms is None: + close_ms = _coerce_ts_ms(info.get("time")) + open_ms = _coerce_ts_ms(p.get("timestamp")) + if open_ms is None: + open_ms = _coerce_ts_ms(info.get("first_open_time")) + c_raw = str(info.get("contract") or "").strip() + t_raw = info.get("time") + sync_key = f"{c_raw}|{t_raw}|{side}" + return { + "symbol_u": _unified_symbol_for_match(sym), + "side": side, + "close_ms": close_ms, + "open_ms": open_ms, + "pnl": rp_f, + "sync_key": sync_key, + } + + +def fetch_gate_positions_close_history(): + if not exchange_private_api_configured(): + return [] + ensure_markets_loaded() + since_ms = exchange_position_sync_since_ms() + try: + rows = exchange.fetch_positions_history( + None, + since=int(since_ms), + limit=int(EXCHANGE_POSITION_HISTORY_LIMIT), + params={"settle": "usdt"}, + ) + except Exception: + try: + rows = exchange.fetch_positions_history( + None, + since=int(since_ms), + limit=int(EXCHANGE_POSITION_HISTORY_LIMIT), + params={}, + ) + except Exception: + return [] + out = [] + for p in rows or []: + h = _normalize_gate_position_history_entry(p) + if h and h["close_ms"] and h["side"] in ("long", "short") and h["symbol_u"]: + out.append(h) + return out + + +def sync_trend_trade_records_from_exchange(conn): + global _LAST_POSITION_HISTORY_SYNC_AT + if not exchange_private_api_configured(): + return + now = time.time() + if now - _LAST_POSITION_HISTORY_SYNC_AT < 25.0: + return + try: + hist = fetch_gate_positions_close_history() + except Exception: + return + if not hist: + _LAST_POSITION_HISTORY_SYNC_AT = now + return + candidates = conn.execute( + """ + SELECT id, symbol, direction, closed_at, opened_at, trend_plan_id, exchange_sync_key + FROM trade_records + WHERE monitor_type = ? AND (exchange_sync_key IS NULL OR TRIM(exchange_sync_key) = '') + ORDER BY id DESC + LIMIT 120 + """, + (MONITOR_TYPE_TREND,), + ).fetchall() + if not candidates: + _LAST_POSITION_HISTORY_SYNC_AT = now + return + used = set() + for tr in candidates: + tid = None + if "trend_plan_id" in tr.keys() and tr["trend_plan_id"]: + try: + tid = int(tr["trend_plan_id"]) + except (TypeError, ValueError): + tid = None + plan_open_ms = None + if tid: + prow = conn.execute("SELECT opened_at FROM trend_pullback_plans WHERE id=?", (tid,)).fetchone() + if prow and prow["opened_at"]: + plan_open_ms = opened_at_str_to_ms(prow["opened_at"]) + close_ms_trade = opened_at_str_to_ms(tr["closed_at"]) or opened_at_str_to_ms(tr["opened_at"]) + if close_ms_trade is None: + continue + best = None + best_d = None + for h in hist: + sk = h["sync_key"] + if not sk or sk in used: + continue + if h["symbol_u"] != _unified_symbol_for_match(tr["symbol"]): + continue + if h["side"] != (tr["direction"] or "long").strip().lower(): + continue + cm = h["close_ms"] + if cm is None: + continue + if plan_open_ms is not None: + if cm < plan_open_ms - 15 * 60 * 1000: + continue + if cm > plan_open_ms + 15 * 86400 * 1000: + continue + else: + if abs(cm - close_ms_trade) > 3 * 86400 * 1000: + continue + d = abs(cm - close_ms_trade) + if best_d is None or d < best_d: + best_d = d + best = h + if best is None or best_d is None or best_d > 25 * 60 * 1000: + continue + sk = best["sync_key"] + if sk in used: + continue + eo = ms_to_app_local_str(best["open_ms"]) if best.get("open_ms") else None + ec = ms_to_app_local_str(best["close_ms"]) if best.get("close_ms") else None + pnl_val = best.get("pnl") + if pnl_val is None: + pnl_val = 0.0 + conn.execute( + """ + UPDATE trade_records + SET exchange_realized_pnl = ?, exchange_opened_at = ?, exchange_closed_at = ?, exchange_sync_key = ? + WHERE id = ? + """, + (float(pnl_val), eo, ec, sk, int(tr["id"])), + ) + used.add(sk) + _LAST_POSITION_HISTORY_SYNC_AT = now + conn.commit() + + +def trend_plan_history_status_label(status): + s = (status or "").strip().lower() + return { + "stopped_tp": "止盈结束", + "stopped_sl": "止损结束", + "stopped_manual": "手动结束", + }.get(s, status or "-") + + +def enrich_active_trend_plan_row(row): + d = row_to_dict(row) + ex_sym = d.get("exchange_symbol") or normalize_exchange_symbol(d.get("symbol") or "") + direction = (d.get("direction") or "long").lower() + m = get_live_position_exchange_metrics(ex_sym, direction) + if m and m.get("unrealized_pnl") is not None: + d["floating_pnl"] = float(m["unrealized_pnl"]) + else: + d["floating_pnl"] = None + if m and m.get("mark_price") is not None: + d["floating_mark"] = float(m["mark_price"]) + else: + d["floating_mark"] = None + return d + + def opened_at_str_to_ms(opened_at_str): if not opened_at_str: return None @@ -3795,6 +4208,7 @@ def _trend_finalize_plan(conn, row, result_label, exit_price, closed_at=None): result=res, opened_at=opened_at, closed_at=closed_at, + trend_plan_id=int(row["id"]), ) st = "stopped_tp" if result_label == "止盈" else ("stopped_sl" if result_label == "止损" else "stopped_manual") conn.execute( @@ -4417,9 +4831,9 @@ def render_main_page(page="trade"): local_current_capital = float(session_row["current_capital"]) funding_capital, trading_capital = get_exchange_capitals() # 资金账户:仅展示交易所读取结果(含 0)。不可用 TOTAL_CAPITAL 兜底,否则会与实盘不符。 - funding_usdt = round(funding_capital, 4) if funding_capital is not None else None - current_capital = round(trading_capital, 4) if trading_capital is not None else round(local_current_capital, 4) - recommended_capital = get_recommended_capital(current_capital) + 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) + recommended_capital = round(get_recommended_capital(current_capital), 2) key_list = conn.execute("SELECT * FROM key_monitors").fetchall() key_history = conn.execute("SELECT * FROM key_monitor_history ORDER BY id DESC LIMIT 80").fetchall() stats_bundle = compute_stats_bundle(conn, trading_day, now) @@ -4427,6 +4841,11 @@ def render_main_page(page="trade"): order_list = [] for o in raw_order_list: order_list.append(enrich_order_item(row_to_dict(o), current_capital)) + if page in ("trade", "records", "plan_history"): + try: + sync_trend_trade_records_from_exchange(conn) + except Exception: + pass raw_records = conn.execute("SELECT * FROM trade_records ORDER BY id DESC").fetchall() records = [to_effective_trade_dict(r) for r in raw_records] total = len(records) @@ -4443,9 +4862,27 @@ def render_main_page(page="trade"): trend_active = conn.execute( "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" ).fetchone()[0] - trend_plans = conn.execute( + trend_plans_raw = conn.execute( "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC" ).fetchall() + trend_plans = [enrich_active_trend_plan_row(r) for r in trend_plans_raw] + plan_history = [] + preview_snapshots = [] + if page == "plan_history": + plan_history_raw = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status != 'active' ORDER BY id DESC LIMIT 100" + ).fetchall() + for pr in plan_history_raw: + pd = row_to_dict(pr) + pd["status_label"] = trend_plan_history_status_label(pd.get("status")) + plan_history.append(pd) + snap_rows = conn.execute( + "SELECT * FROM trend_pullback_preview_snapshots ORDER BY id DESC LIMIT 150" + ).fetchall() + for sr in snap_rows: + sd = row_to_dict(sr) + sd["outcome_label"] = preview_snapshot_outcome_label(sd.get("outcome")) + preview_snapshots.append(sd) can_trade = ( trading_day_reset_allows_new_open(now) and active_count == 0 @@ -4508,6 +4945,9 @@ def render_main_page(page="trade"): active_count=active_count, can_trade=can_trade, trend_plans=trend_plans, + plan_history=plan_history, + preview_snapshots=preview_snapshots, + exchange_sync_from_label=(EXCHANGE_POSITION_SYNC_FROM_BJ or "最近90天"), trend_pullback_dca_legs=TREND_PULLBACK_DCA_LEGS, trend_pullback_preview_ttl=TREND_PULLBACK_PREVIEW_TTL_SECONDS, trend_preview=trend_preview, @@ -4518,13 +4958,15 @@ def render_main_page(page="trade"): trend_preview_max_drift_pct=TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT, focus_key_id=(key_list[0]["id"] if key_list else None), focus_order_id=(order_list[0]["id"] if order_list else None), - data_export_version=2, + data_export_version=3, key_alert_max_times=KEY_ALERT_MAX_TIMES, risk_percent=RISK_PERCENT, breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER, breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, occupied_miss_total=occupied_miss_total, price_fmt=format_price_for_symbol, + amt_fmt=format_amount_for_symbol, + money_fmt=format_money_usdt, entry_reason_options=list(ENTRY_REASON_OPTIONS), entry_reason_other_value=ENTRY_REASON_OTHER, exchange_display=EXCHANGE_DISPLAY_NAME, @@ -4555,6 +4997,25 @@ def stats_page(): return render_main_page("stats") +@app.route("/plan_history") +@login_required +def plan_history_page(): + return render_main_page("plan_history") + + +@app.route("/api/preview_snapshot/") +@login_required +def api_preview_snapshot(sid): + conn = get_db() + row = conn.execute("SELECT * FROM trend_pullback_preview_snapshots WHERE id=?", (sid,)).fetchone() + conn.close() + if not row: + return jsonify({"ok": False, "msg": "not_found"}), 404 + d = row_to_dict(row) + d["outcome_label"] = preview_snapshot_outcome_label(d.get("outcome")) + return jsonify({"ok": True, "snapshot": d}) + + @app.route("/api/account_snapshot") @login_required def api_account_snapshot(): @@ -4564,9 +5025,9 @@ def api_account_snapshot(): session_row = ensure_session(conn, trading_day) local_current_capital = float(session_row["current_capital"]) funding_capital, trading_capital = get_exchange_capitals(force=True) - funding_usdt = round(funding_capital, 4) if funding_capital is not None else None - current_capital = round(trading_capital, 4) if trading_capital is not None else round(local_current_capital, 4) - recommended_capital = get_recommended_capital(current_capital) + 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) + recommended_capital = round(get_recommended_capital(current_capital), 2) active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0] conn.close() can_trade = trading_day_reset_allows_new_open(now) and active_count == 0 @@ -4574,7 +5035,7 @@ def api_account_snapshot(): return jsonify({ "funding_usdt": funding_usdt, "current_capital": current_capital, - "available_trading_usdt": round(available_trading_usdt, 4) 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, "active_count": active_count, "can_trade": can_trade, @@ -5366,6 +5827,7 @@ def preview_trend_pullback(): created, ), ) + insert_trend_preview_snapshot(conn, pid, created, exp_ms, payload) conn.commit() conn.close() flash(f"预览已生成,有效期 {TREND_PULLBACK_PREVIEW_TTL_SECONDS} 秒,请核对后点击「确认执行」。") @@ -5444,7 +5906,7 @@ def execute_trend_pullback(): trading_day = get_trading_day(now) opened_at = app_now_str() opened_ms = _to_ms_with_fallback(None, opened_at) - conn.execute( + cur = conn.execute( """INSERT INTO trend_pullback_plans ( status,symbol,exchange_symbol,direction,leverage,stop_loss,add_upper,take_profit,risk_percent, snapshot_available_usdt,snapshot_at,plan_margin_capital,target_order_amount,first_order_amount,remainder_total, @@ -5481,11 +5943,16 @@ def execute_trend_pullback(): f"预览ID:{pid[:8]}…", ), ) + new_plan_id = int(cur.lastrowid) + conn.execute( + "UPDATE trend_pullback_preview_snapshots SET outcome='executed', executed_plan_id=? WHERE preview_id=?", + (new_plan_id, pid), + ) conn.execute("DELETE FROM trend_pullback_previews WHERE id=?", (pid,)) conn.commit() conn.close() flash( - f"趋势回调已执行:可用余额(执行时){round(snap, 4)}U;计划保证金约 {round(margin_plan, 4)}U;" + f"趋势回调已执行:可用余额(执行时){round(snap, 2)}U;计划保证金约 {round(margin_plan, 2)}U;" f"总张数约 {target_amt},首仓 {first_amt},补仓 {n_legs} 档;已挂交易所止损,止盈由程序监控。" ) return redirect(url_for("trade_page")) @@ -5497,6 +5964,10 @@ def cancel_trend_pullback_preview(): pid = (request.form.get("preview_id") or "").strip() conn = get_db() if pid: + conn.execute( + "UPDATE trend_pullback_preview_snapshots SET outcome='cancelled' WHERE preview_id=? AND outcome='open'", + (pid,), + ) conn.execute("DELETE FROM trend_pullback_previews WHERE id=?", (pid,)) conn.commit() conn.close() @@ -5546,6 +6017,28 @@ def stop_trend_pullback(pid): return redirect("/trade") +@app.route("/delete_trend_plan_history/", methods=["POST"]) +@login_required +def delete_trend_plan_history(pid): + conn = get_db() + row = conn.execute("SELECT id, status FROM trend_pullback_plans WHERE id=?", (pid,)).fetchone() + if not row: + conn.close() + flash("计划不存在") + return redirect(request.referrer or url_for("plan_history_page")) + if (row["status"] or "").strip() == "active": + conn.close() + flash("运行中的计划请使用「结束计划」,不可从历史中删除") + return redirect(request.referrer or url_for("plan_history_page")) + conn.execute("DELETE FROM trade_records WHERE trend_plan_id=?", (pid,)) + conn.execute("DELETE FROM trend_pullback_preview_snapshots WHERE executed_plan_id=?", (pid,)) + conn.execute("DELETE FROM trend_pullback_plans WHERE id=?", (pid,)) + conn.commit() + conn.close() + flash("已删除该计划历史及关联趋势交易记录(若有)") + return redirect(request.referrer or url_for("plan_history_page")) + + @app.route("/delete_key_monitor/", methods=["POST"]) @login_required def delete_key_monitor(kid): @@ -5622,7 +6115,8 @@ def export_trade_records(): rows = conn.execute( "SELECT id,symbol,monitor_type,direction,trigger_price,stop_loss,take_profit,margin_capital,leverage," "pnl_amount,hold_seconds,hold_minutes,opened_at,closed_at,result,miss_reason," - "entry_reason,reviewed_entry_reason,created_at FROM trade_records ORDER BY id ASC" + "entry_reason,reviewed_entry_reason,created_at,trend_plan_id,exchange_realized_pnl," + "exchange_opened_at,exchange_closed_at,exchange_sync_key FROM trade_records ORDER BY id ASC" ).fetchall() conn.close() head_base = [ @@ -5645,6 +6139,11 @@ def export_trade_records(): "entry_reason", "reviewed_entry_reason", "created_at", + "trend_plan_id", + "exchange_realized_pnl", + "exchange_opened_at", + "exchange_closed_at", + "exchange_sync_key", ] head = head_base + ["开仓类型"] data = [] diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html index 2215b91..8e0d526 100644 --- a/crypto_monitor_gate_bot/templates/index.html +++ b/crypto_monitor_gate_bot/templates/index.html @@ -130,14 +130,14 @@
开单次数
{{ s.opens_count }}
平仓笔数
{{ s.closed_count }}
胜率
{% if s.win_rate_pct is not none %}{{ s.win_rate_pct }}%{% else %}-{% endif %}
-
净盈亏(U)
{{ s.net_pnl_u }}
-
亏损额合计(U)
{{ s.loss_sum_u }}
-
单笔最大亏损(U)
{% if s.max_single_loss is not none %}{{ s.max_single_loss }}{% else %}-{% endif %}
-
单笔最大盈利(U)
{% if s.max_single_profit is not none %}{{ s.max_single_profit }}{% else %}-{% endif %}
-
最大回撤(U)
{{ s.max_drawdown_u }}
+
净盈亏(U)
{{ money_fmt(s.net_pnl_u) }}
+
亏损额合计(U)
{{ money_fmt(s.loss_sum_u) }}
+
单笔最大亏损(U)
{% if s.max_single_loss is not none %}{{ money_fmt(s.max_single_loss) }}{% else %}-{% endif %}
+
单笔最大盈利(U)
{% if s.max_single_profit is not none %}{{ money_fmt(s.max_single_profit) }}{% else %}-{% endif %}
+
最大回撤(U)
{{ money_fmt(s.max_drawdown_u) }}
当前连续亏损笔数
{{ s.consecutive_losses }}
最长连续亏损(交易日)
{{ s.max_loss_streak_days }} 天
-
期内最大亏损日
{% if s.worst_day %}{{ s.worst_day }}({{ s.worst_day_pnl }}U){% else %}-{% endif %}
+
期内最大亏损日
{% if s.worst_day %}{{ s.worst_day }}({{ money_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}
{% endmacro %} @@ -149,12 +149,13 @@ {% with msg=get_flashed_messages() %}{% if msg %}
{{ msg[0] }}
{% endif %}{% endwith %}
- 数据导出(v{{ data_export_version }} CSV,UTF-8;交易记录含开仓类型列): + 数据导出(v{{ data_export_version }} CSV,UTF-8;交易记录含开仓类型列及交易所对齐字段): 交易记录
@@ -162,9 +163,9 @@
总交易
{{ total }}
错过次数
{{ miss_count }}
胜率
{{ rate }}%
-
资金账户(USDT)
{% if funding_usdt is not none %}{{ funding_usdt }}U{% else %}—{% endif %}
+
资金账户(USDT)
{% if funding_usdt is not none %}{{ money_fmt(funding_usdt) }}U{% else %}—{% endif %}
交易日
{{ trading_day }}
-
当日资金(交易账户)
{{ current_capital }}U
+
当日资金(交易账户)
{{ money_fmt(current_capital) }}U
实时价格更新时间:--(北京时间 UTC+8)
@@ -188,7 +189,7 @@ 以损定仓:风险 {{ risk_percent }}% |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
- 划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00起该整点小时内尝试;账簿按 UTC 自然日去重;界面时间为北京;将 {{ auto_transfer_to }} 补足到 {{ auto_transfer_amount }}U,来自 {{ auto_transfer_from }}) + 划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00起该整点小时内尝试;账簿按 UTC 自然日去重;界面时间为北京;将 {{ auto_transfer_to }} 补足到 {{ money_fmt(auto_transfer_amount) }}U,来自 {{ auto_transfer_from }})
@@ -236,14 +237,14 @@
{{ o.symbol }} | {{ '做多' if o.direction == 'long' else '做空' }}
- 风格:{{ o.trade_style or 'trend' }} | 风险:{{ o.risk_percent or '-' }}%≈{{ o.risk_amount or '-' }}U - | {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ o.breakeven_price or '-' }}{% else %}移动保本:关{% endif %} + 风格:{{ o.trade_style or 'trend' }} | 风险:{{ o.risk_percent or '-' }}%≈{{ money_fmt(o.risk_amount) }}U + | {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
- 成交:{{ o.trigger_price }} 止损:{{ o.stop_loss }} 止盈:{{ o.take_profit }} + 成交:{{ price_fmt(o.symbol, o.trigger_price) }} 止损:{{ price_fmt(o.symbol, o.stop_loss) }} 止盈:{{ price_fmt(o.symbol, o.take_profit) }} | 盈亏比:{% if o.rr_ratio is not none %}1:{{ '%.2f'|format(o.rr_ratio) }}{% else %}-{% endif %} | 现价:- | 浮盈亏:- - | 计划基数:{{ o.margin_capital }}U | 所保证金:- + | 计划基数:{{ money_fmt(o.margin_capital) }}U | 所保证金:- | 杠杆:{{ o.leverage }}x | 仓位占比:{{ o.position_ratio }}%
平仓 @@ -282,15 +283,15 @@
{{ trend_preview.symbol }} {{ '做多' if trend_preview.direction == 'long' else '做空' }} {{ trend_preview.leverage }}x | - 预览可用快照 {{ trend_preview.snapshot_available_usdt }} U | 参考价 {{ trend_preview.live_price_ref }} | - 计划保证金≈{{ trend_preview.plan_margin_capital }} U | 总张≈{{ trend_preview.target_order_amount }}(首仓 {{ trend_preview.first_order_amount }} + 补仓 {{ trend_preview.remainder_total }})
- 止损 {{ trend_preview.stop_loss }} | 补仓上沿 {{ trend_preview.add_upper }} | 止盈 {{ trend_preview.take_profit }} | 风险比例 {{ trend_preview.risk_percent }}% + 预览可用快照 {{ money_fmt(trend_preview.snapshot_available_usdt) }} U | 参考价 {{ price_fmt(trend_preview.symbol, trend_preview.live_price_ref) }} | + 计划保证金≈{{ money_fmt(trend_preview.plan_margin_capital) }} U | 总张≈{{ amt_fmt(trend_preview.symbol, trend_preview.target_order_amount) }}(首仓 {{ amt_fmt(trend_preview.symbol, trend_preview.first_order_amount) }} + 补仓 {{ amt_fmt(trend_preview.symbol, trend_preview.remainder_total) }})
+ 止损 {{ price_fmt(trend_preview.symbol, trend_preview.stop_loss) }} | 补仓上沿 {{ price_fmt(trend_preview.symbol, trend_preview.add_upper) }} | 止盈 {{ price_fmt(trend_preview.symbol, trend_preview.take_profit) }} | 风险比例 {{ trend_preview.risk_percent }}%
{% for row in trend_preview_levels %} - + {% endfor %}
#补仓触发价该档张数
{{ row.i }}{{ row.price }}{{ row.contracts }}
{{ row.i }}{{ price_fmt(trend_preview.symbol, row.price) }}{{ amt_fmt(trend_preview.symbol, row.contracts) }}
@@ -331,9 +332,10 @@
#{{ t.id }} {{ t.symbol }} | {{ '做多' if t.direction == 'long' else '做空' }} | {{ t.leverage }}x
- 可用快照:{{ t.snapshot_available_usdt }}U | 计划保证金≈{{ t.plan_margin_capital }}U | 总张≈{{ t.target_order_amount }} 首仓{{ t.first_order_amount }} 补仓档{{ t.dca_legs }} -
止损:{{ t.stop_loss }} 补仓上沿:{{ t.add_upper }} 止盈:{{ t.take_profit }} -
均价:{{ t.avg_entry_price }} 已补仓:{{ t.legs_done }}/{{ t.dca_legs }} + 可用快照:{{ money_fmt(t.snapshot_available_usdt) }}U | 计划保证金≈{{ money_fmt(t.plan_margin_capital) }}U | 总张≈{{ amt_fmt(t.symbol, t.target_order_amount) }} 首仓{{ amt_fmt(t.symbol, t.first_order_amount) }} 补仓档{{ t.dca_legs }} +
止损:{{ price_fmt(t.symbol, t.stop_loss) }} 补仓上沿:{{ price_fmt(t.symbol, t.add_upper) }} 止盈:{{ price_fmt(t.symbol, t.take_profit) }} +
均价:{{ price_fmt(t.symbol, t.avg_entry_price) }} 已补仓:{{ t.legs_done }}/{{ t.dca_legs }} +
浮盈亏(交易所): {% if t.floating_pnl is not none %}{{ money_fmt(t.floating_pnl) }} U{% else %}—{% endif %}{% if t.floating_mark is not none %} | 标记价: {{ price_fmt(t.symbol, t.floating_mark) }}{% endif %}
结束计划
@@ -349,25 +351,24 @@

交易记录

- + {% for r in record %} - {% set pnl_val = (r.pnl_amount or 0)|float %} - + {% set stop_show = r.effective_stop_loss or r.initial_stop_loss or r.stop_loss %} {% set tp_show = r.effective_take_profit or r.take_profit %} - + - - - {% set pnl_val = (r.effective_pnl_amount or 0)|float %} - + + + {% set pnl_val = (r.display_pnl_amount or 0)|float %} +
品种类型方向成交止损止盈基数杠杆持仓分钟开仓时间(北京)平仓时间(北京)盈亏U结果操作
品种类型方向成交止损止盈基数杠杆持仓分钟开仓(展示)平仓(展示)盈亏U(展示)结果操作
{{ r.symbol }} {{ r.monitor_type }} {{ '做多' if r.direction == 'long' else '做空' }}{{ r.trigger_price }}{{ price_fmt(r.symbol, r.trigger_price) }}{{ price_fmt(r.symbol, stop_show) }} {{ price_fmt(r.symbol, tp_show) }}{{ r.margin_capital or '-' }}{% if r.margin_capital is not none and r.margin_capital != '' %}{{ money_fmt(r.margin_capital) }}{% else %}-{% endif %} {{ r.leverage or '-' }} {{ r.effective_hold_minutes or 0 }}{{ (r.effective_opened_at or '-')[:16] }}{{ (r.effective_closed_at or r.created_at or '-')[:16] }}{{ r.effective_pnl_amount or 0 }}{{ r.display_opened_at }}{{ r.display_closed_at }}{{ money_fmt(r.display_pnl_amount) }}{% if r.monitor_type == '趋势回调' and r.display_pnl_source == 'local' %}{% elif r.monitor_type == '趋势回调' and r.display_pnl_source == 'exchange' %}{% endif %} {% set effective_result = r.effective_result %} {% if effective_result in ["止盈","保本止盈","移动止盈"] %}{{ effective_result }} @@ -407,6 +408,64 @@ {% endif %} + {% if page == 'plan_history' %} +
+

已结束的趋势回调计划

+
删除将同时移除 trend_plan_id 关联的「趋势回调」交易记录及该计划对应的预览快照归档。交易所平仓同步起点(北京日期):{{ exchange_sync_from_label }}EXCHANGE_POSITION_SYNC_FROM_BJ)。
+ {% if plan_history and plan_history|length > 0 %} +
+ + + {% for p in plan_history %} + + + + + + + + + + + + {% endfor %} +
ID品种方向杠杆状态结束开仓时间计划保证金≈操作
{{ p.id }}{{ p.symbol }}{{ '做多' if p.direction == 'long' else '做空' }}{{ p.leverage }}x{{ p.status_label }}{{ p.message or '-' }}{{ (p.opened_at or '-')[:16] }}{% if p.plan_margin_capital is not none %}{{ money_fmt(p.plan_margin_capital) }}{% else %}-{% endif %} + + + +
+
+ {% else %} +
暂无已结束的计划
+ {% endif %} +
+
+

预览快照(自本版本起留存)

+
每次「生成预览」自动归档;取消、过期或执行后仍可点开查看当时参数。执行后状态为「已执行」并带关联计划 ID。
+ {% if preview_snapshots and preview_snapshots|length > 0 %} +
+ + + {% for s in preview_snapshots %} + + + + + + + + + + + {% endfor %} +
ID时间品种方向杠杆状态快照余额U操作
{{ s.id }}{{ (s.preview_created_at or '-')[:16] }}{{ s.symbol }}{{ '多' if s.direction == 'long' else '空' }}{{ s.leverage }}x{{ s.outcome_label }}{% if s.executed_plan_id %} #{{ s.executed_plan_id }}{% endif %}{{ money_fmt(s.snapshot_available_usdt) }}
+
+ {% else %} +
暂无预览快照(新版本生成预览后将出现在此)
+ {% endif %} +
+ {% endif %} + {% if page == 'stats' %}
@@ -484,6 +543,41 @@ function validateJournalEntryReason(){ function showImage(src){document.getElementById("bigImg").src=src;document.getElementById("imgModal").style.display="flex";} function closeModal(){document.getElementById("imgModal").style.display="none";} function forceCloseDetailModal(){document.getElementById("detailModal").style.display="none";} +function fmtU2(n){ + if(n === null || n === undefined || n === "") return "-"; + const x = Number(n); + if(Number.isNaN(x)) return String(n); + return x.toFixed(2); +} +function openPreviewSnapshotDetail(id){ + fetch(`/api/preview_snapshot/${id}`).then(r=>r.json()).then(data=>{ + if(!data.ok){ alert((data && data.msg) || "加载失败"); return; } + const s = data.snapshot; + const lines = [ + `预览ID:${s.preview_id || "-"}`, + `归档状态:${s.outcome_label || "-"}`, + `关联计划ID:${s.executed_plan_id != null ? s.executed_plan_id : "-"}`, + "", + `${s.symbol || "-"} ${s.direction === "long" ? "做多" : "做空"} ${s.leverage || "-"}x`, + `可用快照U:${fmtU2(s.snapshot_available_usdt)}`, + `参考价:${s.live_price_ref != null ? s.live_price_ref : "-"}`, + `计划保证金≈U:${fmtU2(s.plan_margin_capital)}`, + `总张数:${s.target_order_amount != null ? s.target_order_amount : "-"}`, + `首仓/补仓余:${s.first_order_amount != null ? s.first_order_amount : "-"} / ${s.remainder_total != null ? s.remainder_total : "-"}`, + `补仓档数:${s.dca_legs != null ? s.dca_legs : "-"}`, + `止损 / 补仓上沿 / 止盈:${s.stop_loss} / ${s.add_upper} / ${s.take_profit}`, + `风险%:${s.risk_percent != null ? s.risk_percent : "-"}`, + `网格价 JSON:${s.grid_prices_json || "[]"}`, + `分档张数 JSON:${s.leg_amounts_json || "[]"}`, + `创建时间:${s.preview_created_at || "-"}`, + `预览过期(ms):${s.expires_at_ms != null ? s.expires_at_ms : "-"}`, + ].join("\n"); + document.getElementById("detailTitle").innerText = `预览快照 #${id}`; + document.getElementById("detailBody").innerText = lines; + document.getElementById("detailImage").style.display = "none"; + document.getElementById("detailModal").style.display = "flex"; + }).catch(()=>{ alert("网络错误"); }); +} function closeDetailModal(e){if(e.target && e.target.id==="detailModal"){forceCloseDetailModal();}} const journalCache = {}; @@ -1060,7 +1154,7 @@ function refreshPriceSnapshot(){ const mv = o.exchange_initial_margin; const mn = (mv === null || mv === undefined || mv === "") ? NaN : Number(mv); if(!Number.isNaN(mn)){ - exM.innerText = `${mn.toFixed(4)}U`; + exM.innerText = `${mn.toFixed(2)}U`; } else { const prc = (typeof data.positions_raw_count === "number") ? data.positions_raw_count : null; exM.innerText = (prc === 0) ? "无仓数据" : "-"; @@ -1068,7 +1162,7 @@ function refreshPriceSnapshot(){ } const pnlEl = document.getElementById(`order-pnl-${o.id}`); if(pnlEl){ - pnlEl.innerText = `${formatSigned(o.float_pnl, 4)}U (${formatSigned(o.float_pct, 2)}%)`; + 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"); @@ -1102,7 +1196,7 @@ function refreshOrderDefaults(){ const fullEl = document.getElementById("use-full-margin"); const marginEl = document.getElementById("order-margin"); if(fullEl && marginEl && fullEl.checked){ - const m = Math.max(latestAvailableUsdt * {{ full_margin_buffer_ratio }}, 0).toFixed(4); + const m = Math.max(latestAvailableUsdt * {{ full_margin_buffer_ratio }}, 0).toFixed(2); marginEl.value = m; } } @@ -1113,18 +1207,18 @@ function refreshAccountSnapshot(){ fetch("/api/account_snapshot").then(r=>r.json()).then(data=>{ if (typeof data.funding_usdt !== "undefined") { const el = document.getElementById("total-capital"); - if(el) el.innerText = (data.funding_usdt === null || data.funding_usdt === undefined) ? "—" : `${data.funding_usdt}U`; + if(el) el.innerText = (data.funding_usdt === null || data.funding_usdt === undefined) ? "—" : `${Number(data.funding_usdt).toFixed(2)}U`; } if (typeof data.current_capital !== "undefined") { const el = document.getElementById("current-capital"); - if(el) el.innerText = `${data.current_capital}U`; + if(el) el.innerText = `${Number(data.current_capital).toFixed(2)}U`; } if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) { latestAvailableUsdt = Number(data.available_trading_usdt); } const canTradeText = data.can_trade ? "可开仓" : "不可开仓(有持仓或未到北京时间 {{ reset_hour }}:00)"; const tip = document.getElementById("order-rule-tip"); - const avail = (latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)) ? `;交易账户可用约${latestAvailableUsdt}U` : ""; + const avail = (latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)) ? `;交易账户可用约${latestAvailableUsdt.toFixed(2)}U` : ""; if(tip){ tip.innerText = `规则:单仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;${canTradeText}${avail}`; } @@ -1140,7 +1234,7 @@ if(fullMarginEl){ fullMarginEl.addEventListener("change", function(){ const marginEl = document.getElementById("order-margin"); if(marginEl && this.checked && latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)){ - marginEl.value = Math.max(latestAvailableUsdt * {{ full_margin_buffer_ratio }}, 0).toFixed(4); + marginEl.value = Math.max(latestAvailableUsdt * {{ full_margin_buffer_ratio }}, 0).toFixed(2); } }); } diff --git a/crypto_monitor_gate_bot/趋势回调策略说明.md b/crypto_monitor_gate_bot/趋势回调策略说明.md index cbf6d5e..0fd2e71 100644 --- a/crypto_monitor_gate_bot/趋势回调策略说明.md +++ b/crypto_monitor_gate_bot/趋势回调策略说明.md @@ -50,6 +50,24 @@ 用户可「取消预览」删除 `trend_pullback_previews` 中对应记录;过期记录会在新预览或页面加载时清理。 +### 3.4 界面:计划历史与运行中浮动盈亏 + +- **计划历史(页顶卡片)** + - 仅展示 **`trend_pullback_plans` 中已结束的计划**(`status != 'active'`,如止盈结束、止损结束、手动结束)。 + - **不包含**仅存在于 `trend_pullback_previews`、从未「确认执行」的预览。 + - 每行提供 **删除**:删除该计划行,并删除 `trade_records` 中 **`trend_plan_id` 与之相同** 且类型为「趋势回调」的记录(用于与计划一一对应的新数据;历史旧行若无 `trend_plan_id` 则不会随删)。 +- **运行中的计划(交易执行页)** + - 在计划摘要下方展示 **浮盈亏(交易所)**:来自 Gate 当前持仓接口的 **未实现盈亏**(及标记价,若可得);与本地按均价估算可能略有差异,以交易所为准便于对照。 + +### 3.5 交易记录与交易所「已实现盈亏」对齐 + +- 平仓时仍会写入一条 **`trade_records`**(`monitor_type=趋势回调`),其中的 **`pnl_amount` 等为本地估算**(`calc_pnl`,不含手续费、资金费等完整账单口径)。 +- 打开 **「交易执行」或「交易记录」** 页面时,若已配置 **`GATE_API_KEY` / `GATE_API_SECRET`**(不要求 `LIVE_TRADING_ENABLED=true`,只读即可),应用会按节流策略(同进程约 **25 秒**内最多一次)调用 Gate **`fetch_positions_history`(平仓历史)**,为尚未写入 `exchange_sync_key` 的趋势回调记录 **匹配一条平仓记录**,并回填: + - **`exchange_realized_pnl`**:交易所口径已实现盈亏(与 App「历史仓位」更接近); + - **`exchange_opened_at` / `exchange_closed_at`**:换算为应用时区(默认北京)下的开、平时间字符串。 +- **交易记录表**展示列「开仓(展示) / 平仓(展示) / 盈亏U(展示)」:对「趋势回调」行,若已同步则优先显示交易所字段(界面小字 **「所」**);未同步前仍显示本地复盘字段(小字 **「估」**)。 +- 匹配规则概要:同品种、同方向、平仓时间与本地 `closed_at` 接近,并结合 **`trend_plan_id`** 对应计划的 `opened_at` 收窄时间窗;极端情况下若短时间多笔同向同品种,仍存在错配可能,可对照 `exchange_sync_key` 与交易所记录。 + --- ## 4. 与「机器人下单监控」的差异 @@ -82,10 +100,16 @@ | `MONITOR_POLL_SECONDS` | 监控轮询间隔(秒) | `3` | | `LIVE_TRADING_ENABLED` | 是否允许真实下单 | `false` | | `FULL_MARGIN_BUFFER_RATIO` | 计划保证金相对可用余额上限比例 | `0.98` | +| `APP_TIMEZONE` | 应用墙钟与「北京日期」同步起点时区(如 `Asia/Shanghai`) | `Asia/Shanghai` | +| `EXCHANGE_POSITION_SYNC_FROM_BJ` | 拉取 Gate **平仓历史** 的最早日期(`YYYY-MM-DD`,按 `APP_TIMEZONE` 当日 **00:00** 起算)。**留空**则从近 **90 天** 起拉取 | 空 | +| `EXCHANGE_POSITION_HISTORY_LIMIT` | 单次拉取平仓历史条数上限(50–1000) | `200` | --- ## 7. 数据库 - **`trend_pullback_previews`**:未执行的预览行(含 `expires_at_ms`),执行成功或取消后删除;过期可被清理。 -- **`trend_pullback_plans`**:已执行且运行中的计划;字段含快照可用余额、计划保证金、总张数、首仓张数、补仓 JSON、网格价 JSON、已补仓档数、均价、状态等。平仓结果写入 `trade_records`,`monitor_type` 为 **`趋势回调`**。 +- **`trend_pullback_plans`**:趋势回调计划。执行后写入一行,`status='active'` 表示运行中;止盈 / 止损 / 手动结束后变为 **`stopped_tp` / `stopped_sl` / `stopped_manual`** 等非 `active` 状态,并出现在页顶 **计划历史**。字段含快照可用余额、计划保证金、总张数、首仓张数、补仓 JSON、网格价 JSON、已补仓档数、均价、`opened_at`、`message`(结束说明)等。 +- **`trade_records`**(`monitor_type=趋势回调`):每次计划结束插入一行;含本地估算盈亏等。新写入行带 **`trend_plan_id`** 指向 `trend_pullback_plans.id`。另含 **`exchange_realized_pnl`、`exchange_opened_at`、`exchange_closed_at`、`exchange_sync_key`**,由页面触发的交易所平仓历史同步填充(见 3.5)。 + +**CSV 导出**:交易记录导出为 **v3**,包含上述交易所对齐字段及 `trend_plan_id`。 diff --git a/crypto_monitor_gate_bot/部署文档.md b/crypto_monitor_gate_bot/部署文档.md index bdd558e..0a84b24 100644 --- a/crypto_monitor_gate_bot/部署文档.md +++ b/crypto_monitor_gate_bot/部署文档.md @@ -150,6 +150,24 @@ TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT=5 - **生成预览**与**确认执行**时都会读取 **Gate 永续账户 USDT 可用余额**;请尽量使用 **单独子账户** 承载策略资金。 +**界面与对账(与策略说明 3.4–3.5 节一致)** + +- 页顶 **计划历史**:仅 **已结束** 的趋势计划(不含未执行预览);可 **删除** 计划行,并删除 `trend_plan_id` 关联的「趋势回调」`trade_records`(新数据;旧行无 `trend_plan_id` 不级联)。 +- **运行中计划**展示交易所 **未实现盈亏**(浮盈亏)。 +- **交易记录**:趋势单在配置 API Key 后,打开「交易执行 / 交易记录」页会按节流(约 **25 秒**内同进程最多一次)拉取 Gate **平仓历史**,回填 **`exchange_realized_pnl`** 等;列表展示优先用交易所口径(见策略说明)。 + +**与交易所对齐的可选环境变量** + +```env +# 平仓历史同步起点:北京日期 YYYY-MM-DD 的 0 点(与 APP_TIMEZONE 一致);留空则从近 90 天拉取 +# EXCHANGE_POSITION_SYNC_FROM_BJ=2026-05-14 +# EXCHANGE_POSITION_HISTORY_LIMIT=200 +``` + +说明:同步 **只读** 交易所接口,**不要求** `LIVE_TRADING_ENABLED=true`;无 Key 时不拉取,界面仍可用(浮盈亏可能为「—」、交易记录仍为本地「估」)。 + +**交易记录 CSV**:导出为 **v3**,含 `trend_plan_id` 与交易所对齐列(详见策略说明数据库一节)。 + --- ## 6. 手工启动 Flask(验证)