更新内容

This commit is contained in:
dekun
2026-05-11 07:57:37 +08:00
parent 7980df7d30
commit fc582a31c5
3 changed files with 300 additions and 38 deletions
+281 -34
View File
@@ -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: