更新内容
This commit is contained in:
+281
-34
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user