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){