diff --git a/crypto_monitor_gate/__pycache__/app.cpython-310.pyc b/crypto_monitor_gate/__pycache__/app.cpython-310.pyc
new file mode 100644
index 0000000..0e1efc5
Binary files /dev/null and b/crypto_monitor_gate/__pycache__/app.cpython-310.pyc differ
diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py
index 1aa858d..3b97cdc 100644
--- a/crypto_monitor_gate/app.py
+++ b/crypto_monitor_gate/app.py
@@ -414,19 +414,68 @@ def _extract_json_object(text):
return None
+def _journal_row_lines_for_ai(idx, row, *, include_hold_duration=True):
+ """把 journal 字段拼成给 AI 的文本;字段之外的事实不要指望模型自己猜。"""
+ def nz(v, default="无"):
+ if v is None:
+ return default
+ s = str(v).strip()
+ return s if s else default
+
+ lines = [
+ f"{idx}. {nz(row['coin'])} {nz(row['tf'])} | 盈亏:{nz(row['pnl'])}U | 实际RR:{nz(row['real_rr'])} | 预期RR:{nz(row['expect_rr'])}",
+ f" 开仓逻辑:{nz(row['entry_reason'])}",
+ f" 平仓/离场(交易员自述):{nz(row['exit_reason'])}",
+ ]
+ if include_hold_duration:
+ lines.append(f" 持仓时长:{nz(row['hold_duration'])}")
+ ee_bits = [
+ nz(row["early_exit"]),
+ nz(row["early_exit_reason"]),
+ nz(row["early_exit_trigger"]),
+ nz(row["early_exit_note"]),
+ ]
+ if any(x != "无" for x in ee_bits):
+ lines.append(
+ " 提前离场记录:"
+ f"{ee_bits[0]} | 原因:{ee_bits[1]} | 触发:{ee_bits[2]} | 备注:{ee_bits[3]}"
+ )
+ mood_bits = f"心态标签:{nz(row['mood_issues'])}"
+ if row["mood_score"] is not None:
+ mood_bits += f" | 自评心态分:{row['mood_score']}"
+ lines.append(f" {mood_bits}")
+ if nz(row["post_breakeven_stare"]) != "无":
+ lines.append(f" 保本后盯盘:{nz(row['post_breakeven_stare'])}")
+ if nz(row["new_trade_while_occupied"]) != "无":
+ lines.append(f" 占用时新开仓:{nz(row['new_trade_while_occupied'])}")
+ if nz(row["note"]) != "无":
+ lines.append(f" 备注:{nz(row['note'])}")
+ return "\n".join(lines) + "\n"
+
+
def ai_review(trades_text, period_title, image_paths=None):
prompt = f"""
-你是一位专业交易教练。下面是用户的{period_title}交易记录,请做专业、简洁、可执行的复盘:
-1. 总体盈亏结构
-2. 心态问题与执行漏洞(请给每笔交易一个1-10的心态分并简短说明)
-3. 提前离场、乱开仓、扛单等行为分析
-4. 给出具体改进建议(最多3条)
-5. 若附带截图,请结合图中价格行为、结构、进出场位置一起分析(看不清时请明确说明不确定)
+你是一位专业交易教练。下面是用户的{period_title}交易记录,请做简洁、可执行的复盘(中文)。
+
+【硬性规则 — 必须遵守】
+- 你只能根据「交易记录」里**明确出现的字段**陈述事实;禁止编造:是否触发止损、是否扛单、亏损是否扩大、图上具体结构/进出场点位等记录里**没有**的信息。
+- 「平仓/离场」只是交易员自述摘要,不是客观成交明细;若记录未写明代币是否打到止损价、是否软件平仓等,不要断言执行路径,可用「在记录有限前提下,一种可能是……」或简短写「执行路径记录不足,无法判断」。
+- 「提前离场」类结论必须优先依据记录中的「提前离场记录」字段;若该段全为「无」或未出现有效内容,不得写道「明显扛单」「拒不止损」「未执行硬止损」等。
+- 实际RR为负只说明结果相对于预期RR不利,不等同于「风控失灵」或「止损纪律崩溃」,除非记录里另有依据。
+- 禁止用语:人身攻击、夸张定性(如「致命伤」「灾难」);语气克制、对事不对人。
+- 若有截图且你能辨认,再结合图讨论;看不清或无明确定位则明确说「无法从图确认」,不得虚构 K 线故事。
+
+【输出结构】
+1. 总体盈亏结构(紧扣笔数、盈亏数字与 RR,少形容词)
+2. 心态与执行(每笔 1–10 分 + 一句依据;依据必须对应记录字段)
+3. 行为标签(提前离场 / 乱开仓 / 扛单等):仅在有字段或自述支撑时点名;否则写「记录未勾选或未描述,不作强加」
+4. 改进建议(最多 3 条,每条具体可执行)
+5. 图表(若有且可读):结合价格行为简述;否则一两句说明无法看图分析
+
交易记录:
{trades_text}
-用中文输出,直接给结论与建议。
""".strip()
- payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.3}}
+ payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}}
images = []
for p in image_paths or []:
b64 = _read_image_base64(p)
@@ -677,8 +726,9 @@ def generate_multi_timeframe_chart_png(
rows = _ohlcv_to_rows(ohlcv)[-limit:]
title = f"{title_prefix} | {tf} x{len(rows)}"
points = []
- marker_tfs = set(marker_timeframes or [])
- if marker_payload and tf in marker_tfs:
+ tf_key = str(tf).strip().lower()
+ marker_tfs = {str(x).strip().lower() for x in (marker_timeframes or []) if str(x).strip()}
+ if marker_payload and tf_key in marker_tfs:
entry_idx, entry_price = _pick_marker_point(rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price"))
exit_idx, exit_price = _pick_marker_point(rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price"))
if entry_idx is not None and entry_price is not None:
@@ -1398,6 +1448,66 @@ def normalize_exchange_symbol(symbol):
return sym
+def resolve_monitor_exchange_symbol(row):
+ """将监控行上的 symbol / exchange_symbol 统一到 ccxt 永续合约 symbol,便于与 fetch_positions 结果比对。"""
+ raw = ""
+ try:
+ if row["exchange_symbol"]:
+ raw = str(row["exchange_symbol"]).strip()
+ except (KeyError, IndexError, TypeError):
+ raw = ""
+ if not raw:
+ try:
+ raw = str(row["symbol"] or "").strip()
+ except (KeyError, IndexError, TypeError):
+ raw = ""
+ return normalize_exchange_symbol(raw) if raw else ""
+
+
+def _position_contract_symbol_match(position_symbol, wanted_exchange_symbol):
+ if not position_symbol or not wanted_exchange_symbol:
+ return False
+ a = normalize_exchange_symbol(str(position_symbol).strip())
+ b = normalize_exchange_symbol(str(wanted_exchange_symbol).strip())
+ return a == b
+
+
+def _position_matches_wanted_contract(wanted_unified_sym, position_dict):
+ """统一 symbol 比对;不一致时用 Gate 原始 contract 与 ccxt market.id 对齐(兼容 1000PEPE 等命名差异)。"""
+ if not wanted_unified_sym or not position_dict:
+ return False
+ ps = position_dict.get("symbol")
+ if _position_contract_symbol_match(ps, wanted_unified_sym):
+ return True
+ try:
+ ensure_markets_loaded()
+ mid = (exchange.market(wanted_unified_sym).get("id") or "").strip().upper()
+ info = position_dict.get("info") or {}
+ c_raw = str(info.get("contract") or "").strip().upper()
+ if mid and c_raw and mid == c_raw:
+ return True
+ except Exception:
+ pass
+ return False
+
+
+def _position_row_effective_contracts(p):
+ """张数:优先 ccxt contracts,否则用 Gate 原始 size/pos(避免统一层为 0 时被误判空仓)。"""
+ if not p:
+ return 0.0
+ info = p.get("info") or {}
+ for val in (p.get("contracts"), info.get("size"), info.get("pos")):
+ if val is None or val == "":
+ continue
+ try:
+ x = abs(float(val))
+ if x > 0:
+ return x
+ except (TypeError, ValueError):
+ continue
+ return 0.0
+
+
def normalize_symbol_input(symbol):
sym = (symbol or "").strip().upper()
if not sym:
@@ -1766,6 +1876,11 @@ def ensure_exchange_live_ready():
return True, ""
+def exchange_private_api_configured():
+ """仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等。"""
+ return bool(GATE_API_KEY and GATE_API_SECRET)
+
+
def _extract_usdt_total(balance):
usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {}
total_map = balance.get("total", {}) if isinstance(balance, dict) else {}
@@ -1980,7 +2095,7 @@ def get_synced_leverage(exchange_symbol, direction):
try:
positions = exchange.fetch_positions([exchange_symbol])
for p in positions:
- if p.get("symbol") != exchange_symbol:
+ if not _position_matches_wanted_contract(exchange_symbol, p):
continue
info = p.get("info", {}) or {}
side = (p.get("side") or info.get("posSide") or "").lower()
@@ -2489,7 +2604,7 @@ def get_live_position_contracts(exchange_symbol, direction):
return None
total = 0.0
for p in rows:
- if p.get("symbol") != exchange_symbol:
+ if not _position_matches_wanted_contract(exchange_symbol, p):
continue
info = p.get("info", {}) or {}
side = (p.get("side") or info.get("posSide") or "").lower()
@@ -2513,6 +2628,110 @@ def get_live_position_contracts(exchange_symbol, direction):
return total
+def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False):
+ """在 fetch_positions 结果中取与当前监控方向一致、张数最大的一条(与 get_live_position_contracts 过滤规则一致)。"""
+ if not rows:
+ return None
+ candidates = []
+ for p in rows:
+ if not _position_matches_wanted_contract(exchange_symbol, p):
+ continue
+ info = p.get("info", {}) or {}
+ side = (p.get("side") or info.get("posSide") or "").lower()
+ contracts = _position_row_effective_contracts(p)
+ if contracts <= 0:
+ continue
+ if (not relax_hedge) and GATE_POS_MODE == "hedge":
+ if side and side != (direction or "").lower():
+ continue
+ candidates.append((contracts, p))
+ if not candidates and (not relax_hedge) and GATE_POS_MODE == "hedge":
+ return _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=True)
+ if not candidates:
+ return None
+ candidates.sort(key=lambda x: x[0], reverse=True)
+ return candidates[0][1]
+
+
+def _coerce_float(*values):
+ for v in values:
+ if v is None or v == "":
+ continue
+ try:
+ return float(v)
+ except (TypeError, ValueError):
+ continue
+ return None
+
+
+def parse_ccxt_position_metrics(position, order_leverage=None):
+ """
+ 从 ccxt 统一持仓结构解析保证金/名义/未实现盈亏(Gate 等所字段略有差异,做多键兜底)。
+ 与 App「仓位保证金」对齐时优先用 initialMargin;缺失时再尝试 info 内字段。
+ """
+ if not position:
+ return None
+ p = position
+ info = p.get("info", {}) or {}
+ # Gate 全仓:ccxt 的 initialMargin 常为空;collateral 来自 API 的 margin,与 App「保证金」一致
+ initial = _coerce_float(p.get("collateral"), p.get("initialMargin"), p.get("margin"))
+ if initial is None or initial <= 0:
+ initial = _coerce_float(
+ info.get("margin"),
+ info.get("cross_margin"),
+ info.get("iso_margin"),
+ info.get("initial_margin"),
+ info.get("position_margin"),
+ info.get("initialMargin"),
+ )
+ notional = _coerce_float(p.get("notional"), p.get("notionalValue"))
+ if notional is None or notional <= 0:
+ notional = _coerce_float(info.get("value"))
+ if notional is not None:
+ notional = abs(notional)
+ # 全仓且 API margin 为 0 时:用名义/杠杆粗算展示(与交易所「约占用」接近)
+ if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage:
+ try:
+ lev = float(order_leverage)
+ if lev > 0:
+ approx = notional / lev
+ if approx > 0:
+ initial = approx
+ except (TypeError, ValueError):
+ pass
+ unrealized = _coerce_float(
+ p.get("unrealizedPnl"),
+ info.get("unrealised_pnl"),
+ info.get("unrealized_pnl"),
+ )
+ 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)
+ if notional is not None and notional > 0:
+ out["notional"] = round(notional, 4)
+ if unrealized is not None:
+ out["unrealized_pnl"] = round(unrealized, 6)
+ 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):
+ ensure_markets_loaded()
+ if not exchange_private_api_configured() or not exchange_symbol:
+ return None
+ try:
+ rows = exchange.fetch_positions(None, {"settle": "usdt"}) or []
+ except Exception:
+ try:
+ rows = exchange.fetch_positions([exchange_symbol]) or []
+ except Exception:
+ return None
+ p = _select_live_position_row(rows, exchange_symbol, direction)
+ return parse_ccxt_position_metrics(p)
+
+
def opened_at_str_to_ms(opened_at_str):
if not opened_at_str:
return None
@@ -3742,7 +3961,7 @@ def api_price_snapshot():
conn = get_db()
key_rows = conn.execute("SELECT id,symbol,monitor_type,direction,upper,lower FROM key_monitors").fetchall()
order_rows = conn.execute(
- "SELECT id,symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage FROM order_monitors WHERE status='active'"
+ "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage FROM order_monitors WHERE status='active'"
).fetchall()
conn.close()
@@ -3758,6 +3977,18 @@ def api_price_snapshot():
if p is not None:
prices[s] = float(p)
+ all_swap_positions = []
+ if exchange_private_api_configured():
+ try:
+ ensure_markets_loaded()
+ # 显式 USDT 本位;不传 symbols 拉全量,再在本地按合约对齐
+ all_swap_positions = exchange.fetch_positions(None, {"settle": "usdt"}) or []
+ except Exception:
+ try:
+ all_swap_positions = exchange.fetch_positions() or []
+ except Exception:
+ all_swap_positions = []
+
key_prices = []
for r in key_rows:
price = prices.get(r["symbol"])
@@ -3819,19 +4050,44 @@ def api_price_snapshot():
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
rr_ratio = calc_rr_ratio(r["direction"], entry, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"])
- order_prices.append({
+ ex_sym = resolve_monitor_exchange_symbol(r)
+ prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"])
+ lev_row = r["leverage"] if "leverage" in r.keys() else None
+ ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=lev_row) if prow else None
+ payload = {
"id": r["id"],
"symbol": r["symbol"],
"price": round(price, 6),
"float_pnl": round(pnl, 6),
"float_pct": pnl_pct,
"rr_ratio": rr_ratio,
- })
+ "plan_margin": round(margin, 4) if margin else None,
+ "exchange_initial_margin": None,
+ "exchange_notional": None,
+ "exchange_mark_price": None,
+ "pnl_source": "plan",
+ }
+ if ex_metrics:
+ if ex_metrics.get("initial_margin") is not None:
+ payload["exchange_initial_margin"] = ex_metrics["initial_margin"]
+ 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"]
+ if ex_metrics.get("unrealized_pnl") is not None:
+ payload["float_pnl"] = round(float(ex_metrics["unrealized_pnl"]), 6)
+ 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
+ )
+ order_prices.append(payload)
return jsonify({
"updated_at": app_now_str(),
"key_prices": key_prices,
- "order_prices": order_prices
+ "order_prices": order_prices,
+ "positions_raw_count": len(all_swap_positions),
})
@@ -4840,7 +5096,11 @@ def add_journal():
filename=chart_fname,
filename_prefix="journal",
marker_payload=marker_payload,
- marker_timeframes={"15m", "5m"},
+ marker_timeframes=(
+ {x.strip().lower() for x in ORDER_CHART_TFS if x and str(x).strip()}
+ if ORDER_CHART_TFS
+ else {"5m", "15m", "1h", "4h"}
+ ),
)
if saved:
image_filename = saved
@@ -5183,15 +5443,8 @@ def ai_daily_review():
text = f"【每日交易记录】{date}\n总笔数:{len(rows)}\n\n"
for idx, row in enumerate(rows, 1):
- issues = row["mood_issues"] or "无"
- exit_one = (row["exit_reason"] or "").strip() or "无"
- text += (
- f"{idx}. {row['coin']} {row['tf']} | 盈亏:{row['pnl']}U | RR:{row['real_rr']}\n"
- f" 开仓类型:{row['entry_reason'] or '无'}\n"
- f" 心态标签:{issues}\n"
- f" 平仓/离场:{exit_one}\n"
- f" 问题:{issues}\n\n"
- )
+ text += _journal_row_lines_for_ai(idx, row)
+ text += "\n"
image_paths = []
for row in rows:
@@ -5229,14 +5482,8 @@ def ai_weekly_review():
text = f"【周交易记录】{start_date}~{end_date}\n总笔数:{len(rows)}\n\n"
for idx, row in enumerate(rows, 1):
- issues = row["mood_issues"] or "无"
- exit_one = (row["exit_reason"] or "").strip() or "无"
- text += (
- f"{idx}. {row['coin']} {row['tf']} | 盈亏:{row['pnl']}U | RR:{row['real_rr']}\n"
- f" 开仓类型:{row['entry_reason'] or '无'}\n"
- f" 平仓/离场:{exit_one}\n"
- f" 心态标签:{issues} | 持仓:{row['hold_duration']}\n\n"
- )
+ text += _journal_row_lines_for_ai(idx, row)
+ text += "\n"
image_paths = []
for row in rows:
diff --git a/crypto_monitor_gate/templates/index.html b/crypto_monitor_gate/templates/index.html
index 77ef344..bcdb3ab 100644
--- a/crypto_monitor_gate/templates/index.html
+++ b/crypto_monitor_gate/templates/index.html
@@ -282,7 +282,7 @@
启用移动保本(关闭则仅保留初始止损与交易所挂单)
成交价自动取交易所实时+成交回报
@@ -303,7 +303,8 @@
| 盈亏比:{% if o.rr_ratio is not none %}1:{{ '%.2f'|format(o.rr_ratio) }}{% else %}-{% endif %}
| 现价:-
| 浮盈亏:-
- | 保证金:{{ o.margin_capital }}U | 杠杆:{{ o.leverage }}x | 仓位占比:{{ o.position_ratio }}%
+ | 计划基数:{{ o.margin_capital }}U | 所保证金:-
+ | 杠杆:{{ o.leverage }}x | 仓位占比:{{ o.position_ratio }}%
平仓
@@ -1092,8 +1093,22 @@ function refreshPriceSnapshot(){
(data.order_prices || []).forEach(o=>{
const pEl = document.getElementById(`order-price-${o.id}`);
if(pEl){
- pEl.innerText = Number(o.price).toFixed(6);
- paintPriceTrend(pEl, `o-${o.id}`, Number(o.price));
+ 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);
+ }
+ 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`;
+ } else {
+ const prc = (typeof data.positions_raw_count === "number") ? data.positions_raw_count : null;
+ exM.innerText = (prc === 0) ? "无仓数据" : "-";
+ }
}
const pnlEl = document.getElementById(`order-pnl-${o.id}`);
if(pnlEl){