refactor: 移除 gate_bot,统一为三所架构并更新文档

删除 crypto_monitor_gate_bot 目录,中控与子代理改为 binance/okx/gate 三账户;
文档与 UI 文案「四所」改为「三所」;新增清库前一次性配置备份脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-04 22:00:08 +08:00
parent be51eee73f
commit 9f67de3677
138 changed files with 26395 additions and 40057 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
"""AI 日复盘 / 周复盘:附图收集与 journal 文本格式化(所共用)。"""
"""AI 日复盘 / 周复盘:附图收集与 journal 文本格式化(所共用)。"""
from __future__ import annotations
import os
@@ -46,7 +46,7 @@ def journal_row_lines_for_ai(
*,
include_hold_duration: bool = True,
) -> str:
"""把 journal 字段拼成给 AI 的文本;所日复盘/周复盘共用。"""
"""把 journal 字段拼成给 AI 的文本;所日复盘/周复盘共用。"""
lines = [
(
f"{idx}. {_journal_nz(_row_get(row, 'coin'))} {_journal_nz(_row_get(row, 'tf'))} "
+150 -150
View File
@@ -1,150 +1,150 @@
/* 账户风控状态徽章 — 所实例 + 中控共用;兼容 data-theme light/dark */
:root,
html[data-theme="dark"] {
--risk-normal-fg: #9cf0c4;
--risk-normal-bg: rgba(36, 140, 96, 0.16);
--risk-normal-border: rgba(72, 190, 130, 0.42);
--risk-normal-glow: rgba(72, 190, 130, 0.35);
--risk-1h-fg: #ffd27a;
--risk-1h-bg: rgba(210, 150, 40, 0.16);
--risk-1h-border: rgba(230, 170, 60, 0.45);
--risk-1h-glow: rgba(230, 170, 60, 0.32);
--risk-4h-fg: #ffab8a;
--risk-4h-bg: rgba(210, 90, 55, 0.16);
--risk-4h-border: rgba(230, 110, 70, 0.48);
--risk-4h-glow: rgba(230, 110, 70, 0.34);
--risk-daily-fg: #ff9ec4;
--risk-daily-bg: rgba(190, 55, 100, 0.18);
--risk-daily-border: rgba(210, 75, 120, 0.5);
--risk-daily-glow: rgba(210, 75, 120, 0.36);
--risk-position-fg: #8ec8ff;
--risk-position-bg: rgba(55, 120, 210, 0.18);
--risk-position-border: rgba(75, 145, 230, 0.48);
--risk-position-glow: rgba(75, 145, 230, 0.34);
--risk-badge-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
}
html[data-theme="light"] {
--risk-normal-fg: #056b44;
--risk-normal-bg: rgba(10, 143, 92, 0.14);
--risk-normal-border: rgba(8, 122, 80, 0.38);
--risk-normal-glow: rgba(10, 143, 92, 0.22);
--risk-1h-fg: #8a5a00;
--risk-1h-bg: rgba(200, 140, 20, 0.14);
--risk-1h-border: rgba(170, 115, 10, 0.38);
--risk-1h-glow: rgba(200, 140, 20, 0.2);
--risk-4h-fg: #a83812;
--risk-4h-bg: rgba(210, 85, 35, 0.12);
--risk-4h-border: rgba(180, 65, 25, 0.36);
--risk-4h-glow: rgba(210, 85, 35, 0.2);
--risk-daily-fg: #9a1248;
--risk-daily-bg: rgba(180, 35, 80, 0.1);
--risk-daily-border: rgba(155, 28, 68, 0.34);
--risk-daily-glow: rgba(180, 35, 80, 0.18);
--risk-position-fg: #0b5cab;
--risk-position-bg: rgba(20, 100, 190, 0.12);
--risk-position-border: rgba(15, 85, 165, 0.36);
--risk-position-glow: rgba(20, 100, 190, 0.2);
--risk-badge-shadow: 0 1px 2px rgba(20, 50, 80, 0.1);
}
.risk-status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.03em;
line-height: 1.15;
padding: 5px 12px 5px 10px;
border-radius: 999px;
border: 1px solid var(--risk-border, transparent);
background: var(--risk-bg, transparent);
color: var(--risk-fg, inherit);
box-shadow: var(--risk-badge-shadow);
white-space: nowrap;
vertical-align: middle;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
/* 中控 iframe 内切页:避免徽章过渡动画造成 header 闪动 */
html[data-hub-linked="1"] .header-row .risk-status-badge {
transition: none;
}
.risk-status-badge::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
box-shadow: 0 0 0 1px color-mix(in srgb, currentColor 30%, transparent),
0 0 8px var(--risk-glow, currentColor);
opacity: 0.92;
}
.risk-status-normal {
--risk-fg: var(--risk-normal-fg);
--risk-bg: var(--risk-normal-bg);
--risk-border: var(--risk-normal-border);
--risk-glow: var(--risk-normal-glow);
}
.risk-status-freeze_1h {
--risk-fg: var(--risk-1h-fg);
--risk-bg: var(--risk-1h-bg);
--risk-border: var(--risk-1h-border);
--risk-glow: var(--risk-1h-glow);
}
.risk-status-freeze_4h {
--risk-fg: var(--risk-4h-fg);
--risk-bg: var(--risk-4h-bg);
--risk-border: var(--risk-4h-border);
--risk-glow: var(--risk-4h-glow);
}
.risk-status-freeze_daily {
--risk-fg: var(--risk-daily-fg);
--risk-bg: var(--risk-daily-bg);
--risk-border: var(--risk-daily-border);
--risk-glow: var(--risk-daily-glow);
}
.risk-status-freeze_position {
--risk-fg: var(--risk-position-fg);
--risk-bg: var(--risk-position-bg);
--risk-border: var(--risk-position-border);
--risk-glow: var(--risk-position-glow);
}
/* 实例页:与交易所标签并排 */
.header-row .risk-status-badge {
min-height: 28px;
}
/* 中控卡片标题内 */
.card-title .risk-status-badge,
.hub-tile-name .risk-status-badge {
font-size: 0.7rem;
padding: 3px 10px 3px 8px;
vertical-align: middle;
}
.card-title .risk-status-badge::before,
.hub-tile-name .risk-status-badge::before {
width: 6px;
height: 6px;
}
/* 账户风控状态徽章 — 所实例 + 中控共用;兼容 data-theme light/dark */
:root,
html[data-theme="dark"] {
--risk-normal-fg: #9cf0c4;
--risk-normal-bg: rgba(36, 140, 96, 0.16);
--risk-normal-border: rgba(72, 190, 130, 0.42);
--risk-normal-glow: rgba(72, 190, 130, 0.35);
--risk-1h-fg: #ffd27a;
--risk-1h-bg: rgba(210, 150, 40, 0.16);
--risk-1h-border: rgba(230, 170, 60, 0.45);
--risk-1h-glow: rgba(230, 170, 60, 0.32);
--risk-4h-fg: #ffab8a;
--risk-4h-bg: rgba(210, 90, 55, 0.16);
--risk-4h-border: rgba(230, 110, 70, 0.48);
--risk-4h-glow: rgba(230, 110, 70, 0.34);
--risk-daily-fg: #ff9ec4;
--risk-daily-bg: rgba(190, 55, 100, 0.18);
--risk-daily-border: rgba(210, 75, 120, 0.5);
--risk-daily-glow: rgba(210, 75, 120, 0.36);
--risk-position-fg: #8ec8ff;
--risk-position-bg: rgba(55, 120, 210, 0.18);
--risk-position-border: rgba(75, 145, 230, 0.48);
--risk-position-glow: rgba(75, 145, 230, 0.34);
--risk-badge-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
}
html[data-theme="light"] {
--risk-normal-fg: #056b44;
--risk-normal-bg: rgba(10, 143, 92, 0.14);
--risk-normal-border: rgba(8, 122, 80, 0.38);
--risk-normal-glow: rgba(10, 143, 92, 0.22);
--risk-1h-fg: #8a5a00;
--risk-1h-bg: rgba(200, 140, 20, 0.14);
--risk-1h-border: rgba(170, 115, 10, 0.38);
--risk-1h-glow: rgba(200, 140, 20, 0.2);
--risk-4h-fg: #a83812;
--risk-4h-bg: rgba(210, 85, 35, 0.12);
--risk-4h-border: rgba(180, 65, 25, 0.36);
--risk-4h-glow: rgba(210, 85, 35, 0.2);
--risk-daily-fg: #9a1248;
--risk-daily-bg: rgba(180, 35, 80, 0.1);
--risk-daily-border: rgba(155, 28, 68, 0.34);
--risk-daily-glow: rgba(180, 35, 80, 0.18);
--risk-position-fg: #0b5cab;
--risk-position-bg: rgba(20, 100, 190, 0.12);
--risk-position-border: rgba(15, 85, 165, 0.36);
--risk-position-glow: rgba(20, 100, 190, 0.2);
--risk-badge-shadow: 0 1px 2px rgba(20, 50, 80, 0.1);
}
.risk-status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.03em;
line-height: 1.15;
padding: 5px 12px 5px 10px;
border-radius: 999px;
border: 1px solid var(--risk-border, transparent);
background: var(--risk-bg, transparent);
color: var(--risk-fg, inherit);
box-shadow: var(--risk-badge-shadow);
white-space: nowrap;
vertical-align: middle;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
/* 中控 iframe 内切页:避免徽章过渡动画造成 header 闪动 */
html[data-hub-linked="1"] .header-row .risk-status-badge {
transition: none;
}
.risk-status-badge::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
box-shadow: 0 0 0 1px color-mix(in srgb, currentColor 30%, transparent),
0 0 8px var(--risk-glow, currentColor);
opacity: 0.92;
}
.risk-status-normal {
--risk-fg: var(--risk-normal-fg);
--risk-bg: var(--risk-normal-bg);
--risk-border: var(--risk-normal-border);
--risk-glow: var(--risk-normal-glow);
}
.risk-status-freeze_1h {
--risk-fg: var(--risk-1h-fg);
--risk-bg: var(--risk-1h-bg);
--risk-border: var(--risk-1h-border);
--risk-glow: var(--risk-1h-glow);
}
.risk-status-freeze_4h {
--risk-fg: var(--risk-4h-fg);
--risk-bg: var(--risk-4h-bg);
--risk-border: var(--risk-4h-border);
--risk-glow: var(--risk-4h-glow);
}
.risk-status-freeze_daily {
--risk-fg: var(--risk-daily-fg);
--risk-bg: var(--risk-daily-bg);
--risk-border: var(--risk-daily-border);
--risk-glow: var(--risk-daily-glow);
}
.risk-status-freeze_position {
--risk-fg: var(--risk-position-fg);
--risk-bg: var(--risk-position-bg);
--risk-border: var(--risk-position-border);
--risk-glow: var(--risk-position-glow);
}
/* 实例页:与交易所标签并排 */
.header-row .risk-status-badge {
min-height: 28px;
}
/* 中控卡片标题内 */
.card-title .risk-status-badge,
.hub-tile-name .risk-status-badge {
font-size: 0.7rem;
padding: 3px 10px 3px 8px;
vertical-align: middle;
}
.card-title .risk-status-badge::before,
.hub-tile-name .risk-status-badge::before {
width: 6px;
height: 6px;
}
+120 -120
View File
@@ -1,120 +1,120 @@
/**
* 账户风控徽章倒计时 — 所实例 + 中控共用。
*/
(function (global) {
"use strict";
function formatRemaining(totalSec) {
const sec = Math.max(0, Math.floor(Number(totalSec) || 0));
if (sec <= 0) return "";
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`;
if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`;
return `${s}s`;
}
function baseLabel(riskStatus, el) {
if (riskStatus && riskStatus.status_label) return String(riskStatus.status_label);
if (el && el.dataset && el.dataset.statusLabel) return String(el.dataset.statusLabel);
return "正常";
}
function resolveFreezeUntilMs(riskStatus) {
if (!riskStatus) return null;
const sec = Number(riskStatus.freeze_remaining_sec);
if (Number.isFinite(sec) && sec > 0) {
return Date.now() + sec * 1000;
}
const until = Number(riskStatus.freeze_until_ms);
return Number.isFinite(until) && until > 0 ? until : null;
}
function badgeText(riskStatus) {
const label = baseLabel(riskStatus, null);
const until = resolveFreezeUntilMs(riskStatus);
if (!until || until <= Date.now()) return label;
const cd = formatRemaining((until - Date.now()) / 1000);
return cd ? `${label} · ${cd}` : label;
}
function setNormalBadge(el) {
el.className = "risk-status-badge risk-status-normal";
el.dataset.statusLabel = "正常";
el.textContent = "正常";
el.title = "";
if (el.dataset) delete el.dataset.freezeUntilMs;
}
function refreshElement(el) {
if (!el) return;
const label = baseLabel(null, el);
const until = Number(el.dataset && el.dataset.freezeUntilMs);
if (!Number.isFinite(until) || until <= Date.now()) {
if (el.dataset && el.dataset.freezeUntilMs) {
setNormalBadge(el);
} else {
el.textContent = label;
}
return;
}
const cd = formatRemaining((until - Date.now()) / 1000);
el.textContent = cd ? `${label} · ${cd}` : label;
}
function applyToElement(el, riskStatus) {
if (!el || !riskStatus) return;
const st = riskStatus.status || "normal";
el.className = "risk-status-badge risk-status-" + st;
el.dataset.statusLabel = baseLabel(riskStatus, el);
const until = resolveFreezeUntilMs(riskStatus);
if (until) {
el.dataset.freezeUntilMs = String(until);
} else if (el.dataset) {
delete el.dataset.freezeUntilMs;
}
el.textContent = badgeText(riskStatus);
el.title = riskStatus.reason || "";
}
function formatBadgeHtml(riskStatus, esc) {
if (!riskStatus || typeof riskStatus !== "object") return "";
const safe = typeof esc === "function" ? esc : (s) => String(s);
const st = riskStatus.status || "normal";
const label = safe(riskStatus.status_label || "正常");
const title = safe(riskStatus.reason || "");
const text = safe(badgeText(riskStatus));
const until = resolveFreezeUntilMs(riskStatus);
const untilAttr =
until != null
? ` data-freeze-until-ms="${safe(String(Math.floor(until)))}"`
: "";
return (
`<span class="risk-status-badge risk-status-${safe(st)}" role="status"` +
` title="${title}" data-status-label="${label}"${untilAttr}>${text}</span>`
);
}
function tickAll(root) {
const scope = root || document;
scope.querySelectorAll(".risk-status-badge[data-freeze-until-ms]").forEach(refreshElement);
}
let timer = null;
function startTicker() {
if (timer) return;
tickAll();
timer = setInterval(() => tickAll(), 1000);
}
global.AccountRiskBadge = {
formatRemaining,
badgeText,
refreshElement,
applyToElement,
formatBadgeHtml,
tickAll,
startTicker,
};
})(typeof window !== "undefined" ? window : globalThis);
/**
* 账户风控徽章倒计时 — 所实例 + 中控共用。
*/
(function (global) {
"use strict";
function formatRemaining(totalSec) {
const sec = Math.max(0, Math.floor(Number(totalSec) || 0));
if (sec <= 0) return "";
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`;
if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`;
return `${s}s`;
}
function baseLabel(riskStatus, el) {
if (riskStatus && riskStatus.status_label) return String(riskStatus.status_label);
if (el && el.dataset && el.dataset.statusLabel) return String(el.dataset.statusLabel);
return "正常";
}
function resolveFreezeUntilMs(riskStatus) {
if (!riskStatus) return null;
const sec = Number(riskStatus.freeze_remaining_sec);
if (Number.isFinite(sec) && sec > 0) {
return Date.now() + sec * 1000;
}
const until = Number(riskStatus.freeze_until_ms);
return Number.isFinite(until) && until > 0 ? until : null;
}
function badgeText(riskStatus) {
const label = baseLabel(riskStatus, null);
const until = resolveFreezeUntilMs(riskStatus);
if (!until || until <= Date.now()) return label;
const cd = formatRemaining((until - Date.now()) / 1000);
return cd ? `${label} · ${cd}` : label;
}
function setNormalBadge(el) {
el.className = "risk-status-badge risk-status-normal";
el.dataset.statusLabel = "正常";
el.textContent = "正常";
el.title = "";
if (el.dataset) delete el.dataset.freezeUntilMs;
}
function refreshElement(el) {
if (!el) return;
const label = baseLabel(null, el);
const until = Number(el.dataset && el.dataset.freezeUntilMs);
if (!Number.isFinite(until) || until <= Date.now()) {
if (el.dataset && el.dataset.freezeUntilMs) {
setNormalBadge(el);
} else {
el.textContent = label;
}
return;
}
const cd = formatRemaining((until - Date.now()) / 1000);
el.textContent = cd ? `${label} · ${cd}` : label;
}
function applyToElement(el, riskStatus) {
if (!el || !riskStatus) return;
const st = riskStatus.status || "normal";
el.className = "risk-status-badge risk-status-" + st;
el.dataset.statusLabel = baseLabel(riskStatus, el);
const until = resolveFreezeUntilMs(riskStatus);
if (until) {
el.dataset.freezeUntilMs = String(until);
} else if (el.dataset) {
delete el.dataset.freezeUntilMs;
}
el.textContent = badgeText(riskStatus);
el.title = riskStatus.reason || "";
}
function formatBadgeHtml(riskStatus, esc) {
if (!riskStatus || typeof riskStatus !== "object") return "";
const safe = typeof esc === "function" ? esc : (s) => String(s);
const st = riskStatus.status || "normal";
const label = safe(riskStatus.status_label || "正常");
const title = safe(riskStatus.reason || "");
const text = safe(badgeText(riskStatus));
const until = resolveFreezeUntilMs(riskStatus);
const untilAttr =
until != null
? ` data-freeze-until-ms="${safe(String(Math.floor(until)))}"`
: "";
return (
`<span class="risk-status-badge risk-status-${safe(st)}" role="status"` +
` title="${title}" data-status-label="${label}"${untilAttr}>${text}</span>`
);
}
function tickAll(root) {
const scope = root || document;
scope.querySelectorAll(".risk-status-badge[data-freeze-until-ms]").forEach(refreshElement);
}
let timer = null;
function startTicker() {
if (timer) return;
tickAll();
timer = setInterval(() => tickAll(), 1000);
}
global.AccountRiskBadge = {
formatRemaining,
badgeText,
refreshElement,
applyToElement,
formatBadgeHtml,
tickAll,
startTicker,
};
})(typeof window !== "undefined" ? window : globalThis);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+269 -269
View File
@@ -1,269 +1,269 @@
/**
* 所实例共用 UI:复盘详情、盈亏着色等。
*/
(function (global) {
"use strict";
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function pnlClassFromValue(val) {
const n = Number(String(val == null ? "" : val).replace(/[^\d.-]/g, ""));
if (!Number.isFinite(n) || n === 0) return "";
return n > 0 ? "pnl-profit" : "pnl-loss";
}
function formatPnlSpan(val, suffix) {
const sfx = suffix == null ? "U" : suffix;
const cls = pnlClassFromValue(val);
const text = escapeHtml(val == null || val === "" ? "-" : val) + sfx;
return cls ? `<span class="${cls}">${text}</span>` : text;
}
function buildJournalDetailHtml(o, formatExitLine) {
const moodTags =
Array.isArray(o.mood_issues) && o.mood_issues.length
? o.mood_issues.join(",")
: o.mood_issues || "无";
const exitText =
typeof formatExitLine === "function" ? formatExitLine(o) : o.exit_reason || "无";
const lines = [
`币种/周期:${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "-")}`,
`开仓时间:${escapeHtml(o.open_datetime || "-")}`,
`平仓时间:${escapeHtml(o.close_datetime || "-")}`,
`持仓时长:${escapeHtml(o.hold_duration || "-")}`,
`盈亏:${formatPnlSpan(o.pnl)}`,
`开仓类型:${escapeHtml(o.entry_reason || "无")}`,
`平仓/离场:${escapeHtml(exitText)}`,
`预期RR${escapeHtml(o.expect_rr || "-")}`,
`实际RR${escapeHtml(o.real_rr || "-")}`,
`保本后盯盘:${escapeHtml(o.post_breakeven_stare || "-")}`,
`占用时新开仓:${escapeHtml(o.new_trade_while_occupied || "-")}`,
`心态标签:${escapeHtml(moodTags)}`,
`备注:${escapeHtml(o.note || "无")}`,
];
return lines.join("<br>");
}
function setJournalDetailBody(o, formatExitLine) {
const body = document.getElementById("detailBody");
if (!body) return;
body.classList.remove("md-review", "trade-record-detail-wrap");
body.classList.add("journal-detail-meta");
body.innerHTML = buildJournalDetailHtml(o, formatExitLine);
}
function openJournalDetailModal(id, journalCache, formatExitLine) {
const o = journalCache && journalCache[id];
if (!o) return;
const titleEl = document.getElementById("detailTitle");
if (titleEl) {
titleEl.innerText = `交易复盘详情|${o.coin || "-"} ${o.tf || "-"}`;
}
setJournalDetailBody(o, formatExitLine);
clearDetailActions();
const imgEl = document.getElementById("detailImage");
if (imgEl) {
if (o.image) {
imgEl.src = `/static/images/${o.image}`;
imgEl.style.display = "block";
} else {
imgEl.src = "";
imgEl.style.display = "none";
}
}
if (typeof setDetailModalFullscreen === "function") {
setDetailModalFullscreen(false);
}
const modal = document.getElementById("detailModal");
if (modal) modal.style.display = "flex";
}
function isMobileCompactRecords() {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia("(max-width: 720px)").matches;
}
function inferJournalDirection(o) {
const text = String((o && o.entry_reason) || "");
if (/做空|空头|short/i.test(text)) {
return { text: "做空", cls: "direction-short" };
}
if (/做多|多头|long/i.test(text)) {
return { text: "做多", cls: "direction-long" };
}
return null;
}
function renderJournalListHtml(data) {
if (!data || !data.length) return "";
const mobile = isMobileCompactRecords();
return data
.map(function (o) {
if (mobile) {
const dir = inferJournalDirection(o);
const pnlCls = pnlClassFromValue(o.pnl);
const dirHtml = dir
? `<span class="badge ${dir.cls}">${escapeHtml(dir.text)}</span>`
: `<span class="mrr-muted">-</span>`;
const id = escapeHtml(o.id);
return `<div class="mobile-record-row-wrap">
<button type="button" class="mobile-record-row" onclick="openJournalDetail('${id}')">
<span class="mrr-symbol">${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "")}</span>
<span class="mrr-dir">${dirHtml}</span>
<span class="mrr-pnl ${pnlCls}">${escapeHtml(o.pnl == null || o.pnl === "" ? "-" : o.pnl)}U</span>
</button>
<button type="button" class="mobile-record-del" title="删除" onclick="deleteJournal('${id}')">×</button>
</div>`;
}
const moodTags = (o.mood_issues || []).join(",") || "无";
const id = escapeHtml(o.id);
return `<div class="entry">
<div><strong>${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "-")}</strong> | 盈亏:${escapeHtml(o.pnl == null || o.pnl === "" ? "-" : o.pnl)}U</div>
<div>开:${escapeHtml(o.open_datetime || "-")} 平:${escapeHtml(o.close_datetime || "-")} 持仓:${escapeHtml(o.hold_duration || "-")}</div>
<div>心态标签:${escapeHtml(moodTags)}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:6px">
<button type="button" class="btn-del" style="border:none;cursor:pointer;background:#1f3a5a;color:#8fc8ff" onclick="openJournalDetail('${id}')">查看详情</button>
<button type="button" class="btn-del" onclick="deleteJournal('${id}')">删除</button>
</div>
</div>`;
})
.join("");
}
function parseTradeRecordRow(tr) {
const cells = tr.querySelectorAll("td");
if (cells.length < 14) return null;
const dirBadge = cells[2].querySelector(".badge");
return {
rowId: tr.id,
symbol: cells[0].textContent.trim(),
type: cells[1].textContent.trim(),
directionHtml: (dirBadge ? dirBadge.outerHTML : cells[2].innerHTML).trim(),
directionText: cells[2].textContent.trim(),
trigger: cells[3].textContent.trim(),
stopLoss: cells[4].textContent.trim(),
takeProfit: cells[5].textContent.trim(),
margin: cells[6].textContent.trim(),
leverage: cells[7].textContent.trim(),
holdMinutes: cells[8].textContent.trim(),
openedAt: cells[9].textContent.trim(),
closedAt: cells[10].textContent.trim(),
pnlHtml: cells[11].innerHTML.trim(),
pnlText: cells[11].textContent.trim(),
resultHtml: cells[12].innerHTML.trim(),
resultText: cells[12].textContent.trim(),
actionsHtml: cells[13].innerHTML,
};
}
function renderMobileTradeRow(tr) {
const row = parseTradeRecordRow(tr);
if (!row) return "";
const pnlCls = pnlClassFromValue(row.pnlText);
return `<button type="button" class="mobile-record-row" data-row-id="${escapeHtml(row.rowId)}">
<span class="mrr-symbol">${escapeHtml(row.symbol)}</span>
<span class="mrr-dir">${row.directionHtml}</span>
<span class="mrr-pnl ${pnlCls}">${escapeHtml(row.pnlText || "-")}</span>
</button>`;
}
function tradeDetailRow(label, valueHtml) {
return `<div class="trd-row"><span class="trd-label">${escapeHtml(label)}</span><span class="trd-value">${valueHtml}</span></div>`;
}
function buildTradeRecordDetailHtml(row) {
return `<div class="trade-record-detail">${
tradeDetailRow("品种", escapeHtml(row.symbol)) +
tradeDetailRow("类型", escapeHtml(row.type)) +
tradeDetailRow("方向", row.directionHtml) +
tradeDetailRow("成交价", escapeHtml(row.trigger)) +
tradeDetailRow("止损(开仓)", escapeHtml(row.stopLoss)) +
tradeDetailRow("止盈", escapeHtml(row.takeProfit)) +
tradeDetailRow("基数", escapeHtml(row.margin)) +
tradeDetailRow("杠杆", escapeHtml(row.leverage)) +
tradeDetailRow("持仓分钟", escapeHtml(row.holdMinutes)) +
tradeDetailRow("开仓时间", escapeHtml(row.openedAt)) +
tradeDetailRow("平仓时间", escapeHtml(row.closedAt)) +
tradeDetailRow("盈亏U", row.pnlHtml) +
tradeDetailRow("结果", row.resultHtml)
}</div>`;
}
function clearDetailActions() {
const el = document.getElementById("detailActions");
if (el) {
el.innerHTML = "";
el.style.display = "none";
}
}
function setDetailActionsHtml(html) {
let el = document.getElementById("detailActions");
if (!el) {
const panel = document.querySelector("#detailModal .panel");
if (!panel) return;
el = document.createElement("div");
el.id = "detailActions";
el.className = "detail-actions";
const body = document.getElementById("detailBody");
if (body && body.parentNode === panel) {
panel.insertBefore(el, body.nextSibling);
} else {
panel.appendChild(el);
}
}
el.innerHTML = html || "";
el.style.display = html ? "flex" : "none";
}
function openTradeRecordDetailModal(tr) {
const row = parseTradeRecordRow(tr);
if (!row) return;
const titleEl = document.getElementById("detailTitle");
if (titleEl) {
titleEl.innerText = `交易记录|${row.symbol}`;
}
const body = document.getElementById("detailBody");
if (body) {
body.classList.remove("md-review", "journal-detail-meta");
body.classList.add("trade-record-detail-wrap");
body.innerHTML = buildTradeRecordDetailHtml(row);
}
setDetailActionsHtml(
`<div class="detail-actions-inner">${row.actionsHtml}</div>`
);
const imgEl = document.getElementById("detailImage");
if (imgEl) {
imgEl.src = "";
imgEl.style.display = "none";
}
if (typeof setDetailModalFullscreen === "function") {
setDetailModalFullscreen(false);
}
const modal = document.getElementById("detailModal");
if (modal) modal.style.display = "flex";
}
global.InstanceUI = {
escapeHtml: escapeHtml,
pnlClassFromValue: pnlClassFromValue,
formatPnlSpan: formatPnlSpan,
buildJournalDetailHtml: buildJournalDetailHtml,
setJournalDetailBody: setJournalDetailBody,
openJournalDetailModal: openJournalDetailModal,
isMobileCompactRecords: isMobileCompactRecords,
inferJournalDirection: inferJournalDirection,
renderJournalListHtml: renderJournalListHtml,
parseTradeRecordRow: parseTradeRecordRow,
renderMobileTradeRow: renderMobileTradeRow,
buildTradeRecordDetailHtml: buildTradeRecordDetailHtml,
openTradeRecordDetailModal: openTradeRecordDetailModal,
clearDetailActions: clearDetailActions,
};
})(typeof window !== "undefined" ? window : globalThis);
/**
* 所实例共用 UI:复盘详情、盈亏着色等。
*/
(function (global) {
"use strict";
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function pnlClassFromValue(val) {
const n = Number(String(val == null ? "" : val).replace(/[^\d.-]/g, ""));
if (!Number.isFinite(n) || n === 0) return "";
return n > 0 ? "pnl-profit" : "pnl-loss";
}
function formatPnlSpan(val, suffix) {
const sfx = suffix == null ? "U" : suffix;
const cls = pnlClassFromValue(val);
const text = escapeHtml(val == null || val === "" ? "-" : val) + sfx;
return cls ? `<span class="${cls}">${text}</span>` : text;
}
function buildJournalDetailHtml(o, formatExitLine) {
const moodTags =
Array.isArray(o.mood_issues) && o.mood_issues.length
? o.mood_issues.join(",")
: o.mood_issues || "无";
const exitText =
typeof formatExitLine === "function" ? formatExitLine(o) : o.exit_reason || "无";
const lines = [
`币种/周期:${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "-")}`,
`开仓时间:${escapeHtml(o.open_datetime || "-")}`,
`平仓时间:${escapeHtml(o.close_datetime || "-")}`,
`持仓时长:${escapeHtml(o.hold_duration || "-")}`,
`盈亏:${formatPnlSpan(o.pnl)}`,
`开仓类型:${escapeHtml(o.entry_reason || "无")}`,
`平仓/离场:${escapeHtml(exitText)}`,
`预期RR${escapeHtml(o.expect_rr || "-")}`,
`实际RR${escapeHtml(o.real_rr || "-")}`,
`保本后盯盘:${escapeHtml(o.post_breakeven_stare || "-")}`,
`占用时新开仓:${escapeHtml(o.new_trade_while_occupied || "-")}`,
`心态标签:${escapeHtml(moodTags)}`,
`备注:${escapeHtml(o.note || "无")}`,
];
return lines.join("<br>");
}
function setJournalDetailBody(o, formatExitLine) {
const body = document.getElementById("detailBody");
if (!body) return;
body.classList.remove("md-review", "trade-record-detail-wrap");
body.classList.add("journal-detail-meta");
body.innerHTML = buildJournalDetailHtml(o, formatExitLine);
}
function openJournalDetailModal(id, journalCache, formatExitLine) {
const o = journalCache && journalCache[id];
if (!o) return;
const titleEl = document.getElementById("detailTitle");
if (titleEl) {
titleEl.innerText = `交易复盘详情|${o.coin || "-"} ${o.tf || "-"}`;
}
setJournalDetailBody(o, formatExitLine);
clearDetailActions();
const imgEl = document.getElementById("detailImage");
if (imgEl) {
if (o.image) {
imgEl.src = `/static/images/${o.image}`;
imgEl.style.display = "block";
} else {
imgEl.src = "";
imgEl.style.display = "none";
}
}
if (typeof setDetailModalFullscreen === "function") {
setDetailModalFullscreen(false);
}
const modal = document.getElementById("detailModal");
if (modal) modal.style.display = "flex";
}
function isMobileCompactRecords() {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia("(max-width: 720px)").matches;
}
function inferJournalDirection(o) {
const text = String((o && o.entry_reason) || "");
if (/做空|空头|short/i.test(text)) {
return { text: "做空", cls: "direction-short" };
}
if (/做多|多头|long/i.test(text)) {
return { text: "做多", cls: "direction-long" };
}
return null;
}
function renderJournalListHtml(data) {
if (!data || !data.length) return "";
const mobile = isMobileCompactRecords();
return data
.map(function (o) {
if (mobile) {
const dir = inferJournalDirection(o);
const pnlCls = pnlClassFromValue(o.pnl);
const dirHtml = dir
? `<span class="badge ${dir.cls}">${escapeHtml(dir.text)}</span>`
: `<span class="mrr-muted">-</span>`;
const id = escapeHtml(o.id);
return `<div class="mobile-record-row-wrap">
<button type="button" class="mobile-record-row" onclick="openJournalDetail('${id}')">
<span class="mrr-symbol">${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "")}</span>
<span class="mrr-dir">${dirHtml}</span>
<span class="mrr-pnl ${pnlCls}">${escapeHtml(o.pnl == null || o.pnl === "" ? "-" : o.pnl)}U</span>
</button>
<button type="button" class="mobile-record-del" title="删除" onclick="deleteJournal('${id}')">×</button>
</div>`;
}
const moodTags = (o.mood_issues || []).join(",") || "无";
const id = escapeHtml(o.id);
return `<div class="entry">
<div><strong>${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "-")}</strong> | 盈亏:${escapeHtml(o.pnl == null || o.pnl === "" ? "-" : o.pnl)}U</div>
<div>开:${escapeHtml(o.open_datetime || "-")} 平:${escapeHtml(o.close_datetime || "-")} 持仓:${escapeHtml(o.hold_duration || "-")}</div>
<div>心态标签:${escapeHtml(moodTags)}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:6px">
<button type="button" class="btn-del" style="border:none;cursor:pointer;background:#1f3a5a;color:#8fc8ff" onclick="openJournalDetail('${id}')">查看详情</button>
<button type="button" class="btn-del" onclick="deleteJournal('${id}')">删除</button>
</div>
</div>`;
})
.join("");
}
function parseTradeRecordRow(tr) {
const cells = tr.querySelectorAll("td");
if (cells.length < 14) return null;
const dirBadge = cells[2].querySelector(".badge");
return {
rowId: tr.id,
symbol: cells[0].textContent.trim(),
type: cells[1].textContent.trim(),
directionHtml: (dirBadge ? dirBadge.outerHTML : cells[2].innerHTML).trim(),
directionText: cells[2].textContent.trim(),
trigger: cells[3].textContent.trim(),
stopLoss: cells[4].textContent.trim(),
takeProfit: cells[5].textContent.trim(),
margin: cells[6].textContent.trim(),
leverage: cells[7].textContent.trim(),
holdMinutes: cells[8].textContent.trim(),
openedAt: cells[9].textContent.trim(),
closedAt: cells[10].textContent.trim(),
pnlHtml: cells[11].innerHTML.trim(),
pnlText: cells[11].textContent.trim(),
resultHtml: cells[12].innerHTML.trim(),
resultText: cells[12].textContent.trim(),
actionsHtml: cells[13].innerHTML,
};
}
function renderMobileTradeRow(tr) {
const row = parseTradeRecordRow(tr);
if (!row) return "";
const pnlCls = pnlClassFromValue(row.pnlText);
return `<button type="button" class="mobile-record-row" data-row-id="${escapeHtml(row.rowId)}">
<span class="mrr-symbol">${escapeHtml(row.symbol)}</span>
<span class="mrr-dir">${row.directionHtml}</span>
<span class="mrr-pnl ${pnlCls}">${escapeHtml(row.pnlText || "-")}</span>
</button>`;
}
function tradeDetailRow(label, valueHtml) {
return `<div class="trd-row"><span class="trd-label">${escapeHtml(label)}</span><span class="trd-value">${valueHtml}</span></div>`;
}
function buildTradeRecordDetailHtml(row) {
return `<div class="trade-record-detail">${
tradeDetailRow("品种", escapeHtml(row.symbol)) +
tradeDetailRow("类型", escapeHtml(row.type)) +
tradeDetailRow("方向", row.directionHtml) +
tradeDetailRow("成交价", escapeHtml(row.trigger)) +
tradeDetailRow("止损(开仓)", escapeHtml(row.stopLoss)) +
tradeDetailRow("止盈", escapeHtml(row.takeProfit)) +
tradeDetailRow("基数", escapeHtml(row.margin)) +
tradeDetailRow("杠杆", escapeHtml(row.leverage)) +
tradeDetailRow("持仓分钟", escapeHtml(row.holdMinutes)) +
tradeDetailRow("开仓时间", escapeHtml(row.openedAt)) +
tradeDetailRow("平仓时间", escapeHtml(row.closedAt)) +
tradeDetailRow("盈亏U", row.pnlHtml) +
tradeDetailRow("结果", row.resultHtml)
}</div>`;
}
function clearDetailActions() {
const el = document.getElementById("detailActions");
if (el) {
el.innerHTML = "";
el.style.display = "none";
}
}
function setDetailActionsHtml(html) {
let el = document.getElementById("detailActions");
if (!el) {
const panel = document.querySelector("#detailModal .panel");
if (!panel) return;
el = document.createElement("div");
el.id = "detailActions";
el.className = "detail-actions";
const body = document.getElementById("detailBody");
if (body && body.parentNode === panel) {
panel.insertBefore(el, body.nextSibling);
} else {
panel.appendChild(el);
}
}
el.innerHTML = html || "";
el.style.display = html ? "flex" : "none";
}
function openTradeRecordDetailModal(tr) {
const row = parseTradeRecordRow(tr);
if (!row) return;
const titleEl = document.getElementById("detailTitle");
if (titleEl) {
titleEl.innerText = `交易记录|${row.symbol}`;
}
const body = document.getElementById("detailBody");
if (body) {
body.classList.remove("md-review", "journal-detail-meta");
body.classList.add("trade-record-detail-wrap");
body.innerHTML = buildTradeRecordDetailHtml(row);
}
setDetailActionsHtml(
`<div class="detail-actions-inner">${row.actionsHtml}</div>`
);
const imgEl = document.getElementById("detailImage");
if (imgEl) {
imgEl.src = "";
imgEl.style.display = "none";
}
if (typeof setDetailModalFullscreen === "function") {
setDetailModalFullscreen(false);
}
const modal = document.getElementById("detailModal");
if (modal) modal.style.display = "flex";
}
global.InstanceUI = {
escapeHtml: escapeHtml,
pnlClassFromValue: pnlClassFromValue,
formatPnlSpan: formatPnlSpan,
buildJournalDetailHtml: buildJournalDetailHtml,
setJournalDetailBody: setJournalDetailBody,
openJournalDetailModal: openJournalDetailModal,
isMobileCompactRecords: isMobileCompactRecords,
inferJournalDirection: inferJournalDirection,
renderJournalListHtml: renderJournalListHtml,
parseTradeRecordRow: parseTradeRecordRow,
renderMobileTradeRow: renderMobileTradeRow,
buildTradeRecordDetailHtml: buildTradeRecordDetailHtml,
openTradeRecordDetailModal: openTradeRecordDetailModal,
clearDetailActions: clearDetailActions,
};
})(typeof window !== "undefined" ? window : globalThis);
+160 -160
View File
@@ -1,160 +1,160 @@
/**
* 关键位监控添加表单:类型切换显隐、成交量排名校验(所实例共用)。
*/
(function (global) {
const RS_TYPES = new Set([
"关键支撑阻力",
"关键阻力位",
"关键支撑位",
]);
function syncKeyMonitorFormFields() {
const typeEl = document.querySelector('#key-form [name="type"]');
const dirEl = document.getElementById("key-direction");
const modeEl = document.getElementById("key-sl-tp-mode");
const manualTp = document.getElementById("key-manual-tp");
const beWrap = document.getElementById("key-breakeven-wrap");
if (!typeEl) return;
const t = (typeEl.value || "").trim();
const autoTypes = new Set(["箱体突破", "收敛突破"]);
const fibTypes = new Set(["斐波回调0.618", "斐波回调0.786"]);
const fbTypes = new Set(["假突破"]);
const teTypes = new Set(["回调触价开仓", "突破触价开仓", "触价开仓"]);
const showAuto = autoTypes.has(t);
const showFb = fbTypes.has(t);
const showTe = teTypes.has(t);
const showBe = showAuto || fibTypes.has(t) || showFb || showTe;
const showDir = !RS_TYPES.has(t);
const upperEl = document.getElementById("key-upper");
const lowerEl = document.getElementById("key-lower");
const fbPriceEl = document.getElementById("key-fb-price");
const teEntryEl = document.getElementById("key-trigger-entry");
const teSlEl = document.getElementById("key-trigger-sl");
const teTpEl = document.getElementById("key-trigger-tp");
if (dirEl) {
dirEl.style.display = showDir ? "" : "none";
dirEl.required = showDir;
if (!showDir) dirEl.value = "";
}
if (modeEl) modeEl.style.display = showAuto ? "" : "none";
if (manualTp) {
const trend = showAuto && modeEl && modeEl.value === "trend_manual";
manualTp.style.display = trend ? "" : "none";
manualTp.required = !!trend;
}
if (beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
if (global.TimeCloseUI) global.TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
const hideBounds = showFb || showTe;
if (upperEl) {
upperEl.style.display = hideBounds ? "none" : "";
upperEl.required = !hideBounds;
if (hideBounds) upperEl.value = "";
}
if (lowerEl) {
lowerEl.style.display = hideBounds ? "none" : "";
lowerEl.required = !hideBounds;
if (hideBounds) lowerEl.value = "";
}
if (fbPriceEl) {
fbPriceEl.style.display = showFb ? "" : "none";
fbPriceEl.required = showFb;
if (!showFb) fbPriceEl.value = "";
fbPriceEl.placeholder =
dirEl && dirEl.value === "short"
? "高点(阻力)"
: dirEl && dirEl.value === "long"
? "低点(支撑)"
: "做空填高点/做多填低点";
}
[teEntryEl, teSlEl, teTpEl].forEach((el) => {
if (!el) return;
el.style.display = showTe ? "" : "none";
el.required = showTe;
if (!showTe) el.value = "";
});
}
function submitKeyForm(keyForm, label) {
if (
document.body &&
document.body.getAttribute("data-embed-shell") === "1" &&
global.InstanceEmbed &&
typeof global.InstanceEmbed.postFormAndReload === "function"
) {
global.InstanceEmbed.postFormAndReload(keyForm, label || "提交中…");
return;
}
if (global.FormSubmitGuard) global.FormSubmitGuard.nativeSubmitOnce(keyForm, label || "提交中…");
else keyForm.submit();
}
function bindKeyMonitorForm() {
const keyForm = document.getElementById("key-form");
const keyTypeSel = document.querySelector('#key-form [name="type"]');
const keyModeSel = document.getElementById("key-sl-tp-mode");
const keyDirSel = document.getElementById("key-direction");
if (keyTypeSel) keyTypeSel.addEventListener("change", syncKeyMonitorFormFields);
if (keyModeSel) keyModeSel.addEventListener("change", syncKeyMonitorFormFields);
if (keyDirSel) keyDirSel.addEventListener("change", syncKeyMonitorFormFields);
syncKeyMonitorFormFields();
if (global.TimeCloseUI) {
global.TimeCloseUI.bindTimeCloseForm(
"key-time-close-cb",
"key-time-close-hours",
"key-time-close-wrap"
);
}
if (!keyForm || keyForm.dataset.keyFormBound === "1") return;
keyForm.dataset.keyFormBound = "1";
keyForm.addEventListener("submit", (e) => {
e.preventDefault();
if (global.FormSubmitGuard && global.FormSubmitGuard.isLocked(keyForm)) return;
const symbolEl = keyForm.querySelector('[name="symbol"]');
const symbol = (symbolEl ? symbolEl.value : "").trim();
if (!symbol) {
alert("请先输入交易对");
return;
}
const typeVal = (keyForm.querySelector('[name="type"]') || {}).value || "";
if (typeVal === "假突破") {
submitKeyForm(keyForm, "提交中…");
return;
}
if (global.FormSubmitGuard) global.FormSubmitGuard.lock(keyForm, "校验排名中…");
fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`)
.then((r) => r.json().then((d) => ({ status: r.status, data: d })))
.then(({ status, data }) => {
if (status >= 400 || !data.ok) {
alert((data && data.msg) || "日成交量排名读取失败");
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
return;
}
const rankMax = data.rank_max || 30;
const inTop = data.in_top != null ? data.in_top : data.in_top30;
if (data.rank == null || !inTop) {
alert(
`${data.symbol} 当前日成交量排名 ${data.rank == null ? "—" : data.rank}/${data.total},不在前${rankMax},已拦截。`
);
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
return;
}
submitKeyForm(keyForm, "提交中…");
})
.catch(() => {
alert("日成交量排名检查失败,请稍后重试");
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
});
});
}
global.KeyMonitorForm = {
syncFields: syncKeyMonitorFormFields,
init: bindKeyMonitorForm,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", bindKeyMonitorForm);
} else {
bindKeyMonitorForm();
}
})(typeof window !== "undefined" ? window : globalThis);
/**
* 关键位监控添加表单:类型切换显隐、成交量排名校验(所实例共用)。
*/
(function (global) {
const RS_TYPES = new Set([
"关键支撑阻力",
"关键阻力位",
"关键支撑位",
]);
function syncKeyMonitorFormFields() {
const typeEl = document.querySelector('#key-form [name="type"]');
const dirEl = document.getElementById("key-direction");
const modeEl = document.getElementById("key-sl-tp-mode");
const manualTp = document.getElementById("key-manual-tp");
const beWrap = document.getElementById("key-breakeven-wrap");
if (!typeEl) return;
const t = (typeEl.value || "").trim();
const autoTypes = new Set(["箱体突破", "收敛突破"]);
const fibTypes = new Set(["斐波回调0.618", "斐波回调0.786"]);
const fbTypes = new Set(["假突破"]);
const teTypes = new Set(["回调触价开仓", "突破触价开仓", "触价开仓"]);
const showAuto = autoTypes.has(t);
const showFb = fbTypes.has(t);
const showTe = teTypes.has(t);
const showBe = showAuto || fibTypes.has(t) || showFb || showTe;
const showDir = !RS_TYPES.has(t);
const upperEl = document.getElementById("key-upper");
const lowerEl = document.getElementById("key-lower");
const fbPriceEl = document.getElementById("key-fb-price");
const teEntryEl = document.getElementById("key-trigger-entry");
const teSlEl = document.getElementById("key-trigger-sl");
const teTpEl = document.getElementById("key-trigger-tp");
if (dirEl) {
dirEl.style.display = showDir ? "" : "none";
dirEl.required = showDir;
if (!showDir) dirEl.value = "";
}
if (modeEl) modeEl.style.display = showAuto ? "" : "none";
if (manualTp) {
const trend = showAuto && modeEl && modeEl.value === "trend_manual";
manualTp.style.display = trend ? "" : "none";
manualTp.required = !!trend;
}
if (beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
if (global.TimeCloseUI) global.TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
const hideBounds = showFb || showTe;
if (upperEl) {
upperEl.style.display = hideBounds ? "none" : "";
upperEl.required = !hideBounds;
if (hideBounds) upperEl.value = "";
}
if (lowerEl) {
lowerEl.style.display = hideBounds ? "none" : "";
lowerEl.required = !hideBounds;
if (hideBounds) lowerEl.value = "";
}
if (fbPriceEl) {
fbPriceEl.style.display = showFb ? "" : "none";
fbPriceEl.required = showFb;
if (!showFb) fbPriceEl.value = "";
fbPriceEl.placeholder =
dirEl && dirEl.value === "short"
? "高点(阻力)"
: dirEl && dirEl.value === "long"
? "低点(支撑)"
: "做空填高点/做多填低点";
}
[teEntryEl, teSlEl, teTpEl].forEach((el) => {
if (!el) return;
el.style.display = showTe ? "" : "none";
el.required = showTe;
if (!showTe) el.value = "";
});
}
function submitKeyForm(keyForm, label) {
if (
document.body &&
document.body.getAttribute("data-embed-shell") === "1" &&
global.InstanceEmbed &&
typeof global.InstanceEmbed.postFormAndReload === "function"
) {
global.InstanceEmbed.postFormAndReload(keyForm, label || "提交中…");
return;
}
if (global.FormSubmitGuard) global.FormSubmitGuard.nativeSubmitOnce(keyForm, label || "提交中…");
else keyForm.submit();
}
function bindKeyMonitorForm() {
const keyForm = document.getElementById("key-form");
const keyTypeSel = document.querySelector('#key-form [name="type"]');
const keyModeSel = document.getElementById("key-sl-tp-mode");
const keyDirSel = document.getElementById("key-direction");
if (keyTypeSel) keyTypeSel.addEventListener("change", syncKeyMonitorFormFields);
if (keyModeSel) keyModeSel.addEventListener("change", syncKeyMonitorFormFields);
if (keyDirSel) keyDirSel.addEventListener("change", syncKeyMonitorFormFields);
syncKeyMonitorFormFields();
if (global.TimeCloseUI) {
global.TimeCloseUI.bindTimeCloseForm(
"key-time-close-cb",
"key-time-close-hours",
"key-time-close-wrap"
);
}
if (!keyForm || keyForm.dataset.keyFormBound === "1") return;
keyForm.dataset.keyFormBound = "1";
keyForm.addEventListener("submit", (e) => {
e.preventDefault();
if (global.FormSubmitGuard && global.FormSubmitGuard.isLocked(keyForm)) return;
const symbolEl = keyForm.querySelector('[name="symbol"]');
const symbol = (symbolEl ? symbolEl.value : "").trim();
if (!symbol) {
alert("请先输入交易对");
return;
}
const typeVal = (keyForm.querySelector('[name="type"]') || {}).value || "";
if (typeVal === "假突破") {
submitKeyForm(keyForm, "提交中…");
return;
}
if (global.FormSubmitGuard) global.FormSubmitGuard.lock(keyForm, "校验排名中…");
fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`)
.then((r) => r.json().then((d) => ({ status: r.status, data: d })))
.then(({ status, data }) => {
if (status >= 400 || !data.ok) {
alert((data && data.msg) || "日成交量排名读取失败");
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
return;
}
const rankMax = data.rank_max || 30;
const inTop = data.in_top != null ? data.in_top : data.in_top30;
if (data.rank == null || !inTop) {
alert(
`${data.symbol} 当前日成交量排名 ${data.rank == null ? "—" : data.rank}/${data.total},不在前${rankMax},已拦截。`
);
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
return;
}
submitKeyForm(keyForm, "提交中…");
})
.catch(() => {
alert("日成交量排名检查失败,请稍后重试");
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
});
});
}
global.KeyMonitorForm = {
syncFields: syncKeyMonitorFormFields,
init: bindKeyMonitorForm,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", bindKeyMonitorForm);
} else {
bindKeyMonitorForm();
}
})(typeof window !== "undefined" ? window : globalThis);
+160 -160
View File
@@ -1,160 +1,160 @@
/* 交易日历:内照明心 + 所统计分析共用,随 data-theme 浅/深切换 */
.trade-cal-wrap {
--trade-cal-wrap-bg: var(--inset-surface, rgba(0, 0, 0, 0.22));
--trade-cal-cell-bg: var(--section-surface, var(--inset-surface, rgba(0, 0, 0, 0.32)));
--trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #6366f1) 12%, var(--trade-cal-cell-bg));
--trade-cal-cell-hover-border: color-mix(in srgb, var(--accent, #6366f1) 45%, transparent);
--trade-cal-selected-border: rgba(59, 130, 246, 0.85);
--trade-cal-selected-bg: color-mix(in srgb, #3b82f6 16%, var(--trade-cal-cell-bg));
--trade-cal-selected-shadow: rgba(59, 130, 246, 0.45);
--trade-cal-sick-bg: color-mix(in srgb, var(--red, #ef4444) 14%, var(--trade-cal-cell-bg));
--trade-cal-sick-border: color-mix(in srgb, var(--red, #ef4444) 55%, transparent);
--trade-cal-sick-shadow: color-mix(in srgb, var(--red, #ef4444) 45%, transparent);
--trade-cal-sick-tag-bg: color-mix(in srgb, var(--red, #ef4444) 25%, transparent);
--trade-cal-sick-tag-fg: color-mix(in srgb, var(--red, #ef4444) 70%, #fff);
--trade-cal-pos: var(--green, #22c55e);
--trade-cal-neg: var(--red, #ef4444);
margin-top: 4px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border-soft, rgba(120, 140, 200, 0.28));
background: var(--trade-cal-wrap-bg);
}
.stats-calendar-wrap {
margin-bottom: 14px;
}
.trade-cal-wrap button.trade-cal-cell {
background: var(--trade-cal-cell-bg) !important;
background-image: none !important;
border: 1px solid transparent;
padding: 4px 3px;
min-height: 68px;
width: 100%;
box-shadow: none;
line-height: 1.15;
font-size: inherit;
text-align: center;
}
.trade-cal-wrap button.trade-cal-cell:disabled {
opacity: 1;
cursor: default;
}
.trade-cal-wrap .trade-cal-head .btn,
.trade-cal-wrap .trade-cal-head button {
min-height: 0;
min-width: 34px;
padding: 4px 12px;
line-height: 1.2;
}
.trade-cal-head {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.trade-cal-title {
font-size: 0.95rem;
font-weight: 600;
min-width: 120px;
text-align: center;
color: var(--text, #e8ecff);
}
.trade-cal-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 4px;
}
.trade-cal-wd {
text-align: center;
font-size: 0.72rem;
color: var(--muted, #8892b0);
}
.trade-cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.trade-cal-cell {
min-height: 62px;
padding: 4px 3px;
border-radius: 8px;
border: 1px solid transparent;
background: var(--trade-cal-cell-bg);
color: inherit;
font: inherit;
cursor: default;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 2px;
}
.trade-cal-cell.has-trade {
cursor: pointer;
}
.trade-cal-wrap button.trade-cal-cell.has-trade:hover {
background: var(--trade-cal-cell-hover-bg) !important;
background-image: none !important;
border-color: var(--trade-cal-cell-hover-border);
}
.trade-cal-cell.is-selected {
border-color: var(--trade-cal-selected-border);
background: var(--trade-cal-selected-bg);
box-shadow: 0 0 0 2px var(--trade-cal-selected-shadow);
}
.trade-cal-cell.is-sick-day {
border-color: var(--trade-cal-sick-border);
background: var(--trade-cal-sick-bg);
}
.trade-cal-cell.is-sick-day.is-selected {
border-color: var(--trade-cal-selected-border);
background: color-mix(in srgb, #3b82f6 14%, var(--trade-cal-sick-bg));
box-shadow: 0 0 0 2px var(--trade-cal-selected-shadow);
}
.trade-cal-day-num {
font-size: 0.78rem;
font-weight: 600;
color: var(--text, #e8ecff);
}
.trade-cal-pnl {
font-size: 0.72rem;
font-weight: 600;
line-height: 1.1;
color: var(--text, #e8ecff);
}
.trade-cal-cell.pnl-pos .trade-cal-pnl {
color: var(--trade-cal-pos);
}
.trade-cal-cell.pnl-neg .trade-cal-pnl {
color: var(--trade-cal-neg);
}
.trade-cal-cnt {
font-size: 0.65rem;
color: var(--muted, #8892b0);
font-weight: 500;
}
.trade-cal-sick-tag {
font-size: 0.62rem;
padding: 1px 4px;
border-radius: 4px;
background: var(--trade-cal-sick-tag-bg);
color: var(--trade-cal-sick-tag-fg);
font-weight: 600;
}
.trade-cal-pad {
background: transparent;
border: none;
min-height: 0;
}
html[data-theme="light"] .trade-cal-wrap {
--trade-cal-wrap-bg: var(--inset-surface, #eef3f8);
--trade-cal-cell-bg: var(--section-surface, #f6f9fc);
--trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #2563eb) 10%, #f6f9fc);
--trade-cal-selected-border: rgba(37, 99, 235, 0.75);
--trade-cal-selected-bg: color-mix(in srgb, #2563eb 12%, #f6f9fc);
--trade-cal-selected-shadow: rgba(37, 99, 235, 0.35);
--trade-cal-sick-tag-fg: #b91c1c;
}
/* 交易日历:内照明心 + 所统计分析共用,随 data-theme 浅/深切换 */
.trade-cal-wrap {
--trade-cal-wrap-bg: var(--inset-surface, rgba(0, 0, 0, 0.22));
--trade-cal-cell-bg: var(--section-surface, var(--inset-surface, rgba(0, 0, 0, 0.32)));
--trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #6366f1) 12%, var(--trade-cal-cell-bg));
--trade-cal-cell-hover-border: color-mix(in srgb, var(--accent, #6366f1) 45%, transparent);
--trade-cal-selected-border: rgba(59, 130, 246, 0.85);
--trade-cal-selected-bg: color-mix(in srgb, #3b82f6 16%, var(--trade-cal-cell-bg));
--trade-cal-selected-shadow: rgba(59, 130, 246, 0.45);
--trade-cal-sick-bg: color-mix(in srgb, var(--red, #ef4444) 14%, var(--trade-cal-cell-bg));
--trade-cal-sick-border: color-mix(in srgb, var(--red, #ef4444) 55%, transparent);
--trade-cal-sick-shadow: color-mix(in srgb, var(--red, #ef4444) 45%, transparent);
--trade-cal-sick-tag-bg: color-mix(in srgb, var(--red, #ef4444) 25%, transparent);
--trade-cal-sick-tag-fg: color-mix(in srgb, var(--red, #ef4444) 70%, #fff);
--trade-cal-pos: var(--green, #22c55e);
--trade-cal-neg: var(--red, #ef4444);
margin-top: 4px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border-soft, rgba(120, 140, 200, 0.28));
background: var(--trade-cal-wrap-bg);
}
.stats-calendar-wrap {
margin-bottom: 14px;
}
.trade-cal-wrap button.trade-cal-cell {
background: var(--trade-cal-cell-bg) !important;
background-image: none !important;
border: 1px solid transparent;
padding: 4px 3px;
min-height: 68px;
width: 100%;
box-shadow: none;
line-height: 1.15;
font-size: inherit;
text-align: center;
}
.trade-cal-wrap button.trade-cal-cell:disabled {
opacity: 1;
cursor: default;
}
.trade-cal-wrap .trade-cal-head .btn,
.trade-cal-wrap .trade-cal-head button {
min-height: 0;
min-width: 34px;
padding: 4px 12px;
line-height: 1.2;
}
.trade-cal-head {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.trade-cal-title {
font-size: 0.95rem;
font-weight: 600;
min-width: 120px;
text-align: center;
color: var(--text, #e8ecff);
}
.trade-cal-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 4px;
}
.trade-cal-wd {
text-align: center;
font-size: 0.72rem;
color: var(--muted, #8892b0);
}
.trade-cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.trade-cal-cell {
min-height: 62px;
padding: 4px 3px;
border-radius: 8px;
border: 1px solid transparent;
background: var(--trade-cal-cell-bg);
color: inherit;
font: inherit;
cursor: default;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 2px;
}
.trade-cal-cell.has-trade {
cursor: pointer;
}
.trade-cal-wrap button.trade-cal-cell.has-trade:hover {
background: var(--trade-cal-cell-hover-bg) !important;
background-image: none !important;
border-color: var(--trade-cal-cell-hover-border);
}
.trade-cal-cell.is-selected {
border-color: var(--trade-cal-selected-border);
background: var(--trade-cal-selected-bg);
box-shadow: 0 0 0 2px var(--trade-cal-selected-shadow);
}
.trade-cal-cell.is-sick-day {
border-color: var(--trade-cal-sick-border);
background: var(--trade-cal-sick-bg);
}
.trade-cal-cell.is-sick-day.is-selected {
border-color: var(--trade-cal-selected-border);
background: color-mix(in srgb, #3b82f6 14%, var(--trade-cal-sick-bg));
box-shadow: 0 0 0 2px var(--trade-cal-selected-shadow);
}
.trade-cal-day-num {
font-size: 0.78rem;
font-weight: 600;
color: var(--text, #e8ecff);
}
.trade-cal-pnl {
font-size: 0.72rem;
font-weight: 600;
line-height: 1.1;
color: var(--text, #e8ecff);
}
.trade-cal-cell.pnl-pos .trade-cal-pnl {
color: var(--trade-cal-pos);
}
.trade-cal-cell.pnl-neg .trade-cal-pnl {
color: var(--trade-cal-neg);
}
.trade-cal-cnt {
font-size: 0.65rem;
color: var(--muted, #8892b0);
font-weight: 500;
}
.trade-cal-sick-tag {
font-size: 0.62rem;
padding: 1px 4px;
border-radius: 4px;
background: var(--trade-cal-sick-tag-bg);
color: var(--trade-cal-sick-tag-fg);
font-weight: 600;
}
.trade-cal-pad {
background: transparent;
border: none;
min-height: 0;
}
html[data-theme="light"] .trade-cal-wrap {
--trade-cal-wrap-bg: var(--inset-surface, #eef3f8);
--trade-cal-cell-bg: var(--section-surface, #f6f9fc);
--trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #2563eb) 10%, #f6f9fc);
--trade-cal-selected-border: rgba(37, 99, 235, 0.75);
--trade-cal-selected-bg: color-mix(in srgb, #2563eb 12%, #f6f9fc);
--trade-cal-selected-shadow: rgba(37, 99, 235, 0.35);
--trade-cal-sick-tag-fg: #b91c1c;
}
+314 -314
View File
@@ -1,314 +1,314 @@
/**
* 交易日历组件:内照明心档案 + 所统计分析共用。
*/
(function (global) {
"use strict";
var WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function monthLabel(y, m) {
return y + "年" + m + "月";
}
function formatCalPnl(pnl) {
var n = Number(pnl);
if (!Number.isFinite(n)) n = 0;
return (n >= 0 ? "+" : "") + n.toFixed(1) + "U";
}
function dayHasTrade(info) {
if (!info) return false;
var cnt = Number(info.open_count);
if (Number.isFinite(cnt) && cnt > 0) return true;
var pnl = Number(info.pnl_total);
return Number.isFinite(pnl) && Math.abs(pnl) > 0.0001;
}
function dayOpenCount(info) {
var cnt = Number(info && info.open_count);
return Number.isFinite(cnt) && cnt > 0 ? cnt : 0;
}
function dayPnl(info) {
return Number(info && info.pnl_total) || 0;
}
function TradeStatsCalendar(config) {
this.gridEl = config.gridEl;
this.titleEl = config.titleEl;
this.prevBtn = config.prevBtn || null;
this.nextBtn = config.nextBtn || null;
this.apiUrl = config.apiUrl || "/api/stats/calendar";
this.buildQuery =
config.buildQuery ||
function (year, month) {
var q = new URLSearchParams();
q.set("year", String(year));
q.set("month", String(month));
return q;
};
this.parseResponse =
config.parseResponse ||
function (data) {
if (data && data.ok === false) return {};
return (data && data.days) || {};
};
this.fetchFn = config.fetchFn || null;
this.showSick = config.showSick !== false;
this.selectedDay = config.selectedDay || "";
this.onDayClick = config.onDayClick || null;
this.onMonthChange = config.onMonthChange || null;
this.year = config.year || 0;
this.month = config.month || 0;
this.days = {};
this.monthPnlTotal = 0;
this.monthOpenCount = 0;
this._navBound = false;
this._bindNav();
}
TradeStatsCalendar.prototype.ensureMonth = function (ref) {
if (this.year > 0 && this.month > 0) return;
var d;
if (ref instanceof Date) d = ref;
else if (typeof ref === "string" && ref.length >= 7) {
var p = ref.slice(0, 10).split("-");
this.year = parseInt(p[0], 10) || new Date().getFullYear();
this.month = parseInt(p[1], 10) || new Date().getMonth() + 1;
return;
} else d = new Date();
this.year = d.getFullYear();
this.month = d.getMonth() + 1;
};
TradeStatsCalendar.prototype.applyPayload = function (data) {
if (!data) return;
var y = Number(data.year);
var m = Number(data.month);
if (Number.isFinite(y) && y > 0) this.year = y;
if (Number.isFinite(m) && m > 0) this.month = m;
this.days = this.parseResponse(data) || {};
this.monthPnlTotal = Number(data.month_pnl_total) || 0;
this.monthOpenCount = Number(data.month_open_count) || 0;
if (!this.monthOpenCount) {
var self = this;
Object.keys(this.days).forEach(function (k) {
if (dayHasTrade(self.days[k])) {
self.monthOpenCount += dayOpenCount(self.days[k]);
self.monthPnlTotal += dayPnl(self.days[k]);
}
});
this.monthPnlTotal = Math.round(this.monthPnlTotal * 10000) / 10000;
}
};
function readStatsCalendarBootstrap() {
var el = document.getElementById("stats-calendar-bootstrap");
if (!el || !el.textContent) return null;
try {
return JSON.parse(el.textContent);
} catch (e) {
console.warn("[trade calendar] bootstrap parse", e);
return null;
}
}
TradeStatsCalendar.prototype.setSelectedDay = function (day) {
this.selectedDay = day || "";
this.render();
};
TradeStatsCalendar.prototype.render = function () {
if (!this.gridEl || !this.titleEl) return;
if (this.year <= 0 || this.month <= 0) this.ensureMonth(new Date());
var title = monthLabel(this.year, this.month);
if (this.monthOpenCount > 0) {
title +=
" · " + formatCalPnl(this.monthPnlTotal) + " · " + this.monthOpenCount + "笔";
}
this.titleEl.textContent = title;
var first = new Date(this.year, this.month - 1, 1);
var lastDay = new Date(this.year, this.month, 0).getDate();
var startWd = first.getDay();
var html =
'<div class="trade-cal-weekdays">' +
WEEKDAYS.map(function (w) {
return '<span class="trade-cal-wd">' + w + "</span>";
}).join("") +
'</div><div class="trade-cal-grid">';
var i;
for (i = 0; i < startWd; i++) {
html += '<span class="trade-cal-cell trade-cal-pad"></span>';
}
for (var d = 1; d <= lastDay; d++) {
var dayStr =
this.year +
"-" +
String(this.month).padStart(2, "0") +
"-" +
String(d).padStart(2, "0");
var info = this.days[dayStr];
var hasTrade = dayHasTrade(info);
var sick = this.showSick && info && info.has_sick;
var pnl = hasTrade ? dayPnl(info) : null;
var cnt = hasTrade ? dayOpenCount(info) : 0;
var cls =
"trade-cal-cell" +
(hasTrade ? " has-trade" : "") +
(sick ? " is-sick-day" : "") +
(this.selectedDay === dayStr ? " is-selected" : "") +
(pnl != null && pnl > 0.0001
? " pnl-pos"
: pnl != null && pnl < -0.0001
? " pnl-neg"
: "");
var body = '<span class="trade-cal-day-num">' + d + "</span>";
if (hasTrade) {
body +=
'<span class="trade-cal-pnl">' +
esc(formatCalPnl(pnl)) +
"</span>" +
'<span class="trade-cal-cnt">' +
cnt +
"笔</span>";
if (sick) body += '<span class="trade-cal-sick-tag">犯病</span>';
}
html +=
'<button type="button" class="' +
cls +
'" data-day="' +
dayStr +
'" data-sick="' +
(sick ? "1" : "0") +
'"' +
(hasTrade ? "" : " disabled") +
">" +
body +
"</button>";
}
html += "</div>";
this.gridEl.innerHTML = html;
var self = this;
this.gridEl.querySelectorAll(".trade-cal-cell[data-day]").forEach(function (btn) {
btn.addEventListener("click", function () {
var day = btn.getAttribute("data-day");
if (!day || !self.onDayClick) return;
self.selectedDay = day;
self.render();
self.onDayClick(day, btn.getAttribute("data-sick") === "1", self.days[day] || null);
});
});
};
TradeStatsCalendar.prototype.load = async function () {
this.ensureMonth(new Date());
this.render();
var q = this.buildQuery(this.year, this.month);
if (!q.has("year")) q.set("year", String(this.year));
if (!q.has("month")) q.set("month", String(this.month));
try {
var data;
if (this.fetchFn) {
data = await this.fetchFn(q);
} else {
var resp = await fetch(this.apiUrl + "?" + q.toString(), {
credentials: "same-origin",
});
if (!resp.ok) {
console.warn("[trade calendar] api", resp.status);
this.render();
return;
}
data = await resp.json();
}
this.applyPayload(data);
this.render();
if (this.onMonthChange) this.onMonthChange(this.year, this.month, this.days);
} catch (e) {
console.warn("[trade calendar]", e);
this.render();
}
};
TradeStatsCalendar.prototype.shiftMonth = function (delta) {
this.ensureMonth(new Date());
this.month += delta;
if (this.month > 12) {
this.month = 1;
this.year += 1;
} else if (this.month < 1) {
this.month = 12;
this.year -= 1;
}
void this.load();
};
TradeStatsCalendar.prototype._bindNav = function () {
if (this._navBound) return;
var self = this;
if (this.prevBtn) {
this.prevBtn.addEventListener("click", function () {
self.shiftMonth(-1);
});
}
if (this.nextBtn) {
this.nextBtn.addEventListener("click", function () {
self.shiftMonth(1);
});
}
this._navBound = true;
};
global.TradeStatsCalendar = TradeStatsCalendar;
global.statsCalendarWidget = null;
global.initInstanceStatsCalendar = function () {
var grid = document.getElementById("stats-calendar");
if (!grid || !global.TradeStatsCalendar) return null;
var bootstrap = readStatsCalendarBootstrap();
if (
global.statsCalendarWidget &&
global.statsCalendarWidget.gridEl === grid
) {
if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap);
global.statsCalendarWidget.render();
void global.statsCalendarWidget.load();
return global.statsCalendarWidget;
}
global.statsCalendarWidget = new TradeStatsCalendar({
gridEl: grid,
titleEl: document.getElementById("stats-cal-title"),
prevBtn: document.getElementById("stats-cal-prev"),
nextBtn: document.getElementById("stats-cal-next"),
apiUrl: "/api/stats/calendar",
showSick: false,
buildQuery: function (year, month) {
var q = new URLSearchParams();
q.set("year", String(year));
q.set("month", String(month));
var sel = document.getElementById("stats-segment-select");
if (sel) q.set("segment", sel.value || "all");
return q;
},
parseResponse: function (data) {
if (data && data.ok === false) return {};
return (data && data.days) || {};
},
});
if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap);
global.statsCalendarWidget.render();
void global.statsCalendarWidget.load();
return global.statsCalendarWidget;
};
global.initStatsCalendarWidget = global.initInstanceStatsCalendar;
})(window);
/**
* 交易日历组件:内照明心档案 + 所统计分析共用。
*/
(function (global) {
"use strict";
var WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function monthLabel(y, m) {
return y + "年" + m + "月";
}
function formatCalPnl(pnl) {
var n = Number(pnl);
if (!Number.isFinite(n)) n = 0;
return (n >= 0 ? "+" : "") + n.toFixed(1) + "U";
}
function dayHasTrade(info) {
if (!info) return false;
var cnt = Number(info.open_count);
if (Number.isFinite(cnt) && cnt > 0) return true;
var pnl = Number(info.pnl_total);
return Number.isFinite(pnl) && Math.abs(pnl) > 0.0001;
}
function dayOpenCount(info) {
var cnt = Number(info && info.open_count);
return Number.isFinite(cnt) && cnt > 0 ? cnt : 0;
}
function dayPnl(info) {
return Number(info && info.pnl_total) || 0;
}
function TradeStatsCalendar(config) {
this.gridEl = config.gridEl;
this.titleEl = config.titleEl;
this.prevBtn = config.prevBtn || null;
this.nextBtn = config.nextBtn || null;
this.apiUrl = config.apiUrl || "/api/stats/calendar";
this.buildQuery =
config.buildQuery ||
function (year, month) {
var q = new URLSearchParams();
q.set("year", String(year));
q.set("month", String(month));
return q;
};
this.parseResponse =
config.parseResponse ||
function (data) {
if (data && data.ok === false) return {};
return (data && data.days) || {};
};
this.fetchFn = config.fetchFn || null;
this.showSick = config.showSick !== false;
this.selectedDay = config.selectedDay || "";
this.onDayClick = config.onDayClick || null;
this.onMonthChange = config.onMonthChange || null;
this.year = config.year || 0;
this.month = config.month || 0;
this.days = {};
this.monthPnlTotal = 0;
this.monthOpenCount = 0;
this._navBound = false;
this._bindNav();
}
TradeStatsCalendar.prototype.ensureMonth = function (ref) {
if (this.year > 0 && this.month > 0) return;
var d;
if (ref instanceof Date) d = ref;
else if (typeof ref === "string" && ref.length >= 7) {
var p = ref.slice(0, 10).split("-");
this.year = parseInt(p[0], 10) || new Date().getFullYear();
this.month = parseInt(p[1], 10) || new Date().getMonth() + 1;
return;
} else d = new Date();
this.year = d.getFullYear();
this.month = d.getMonth() + 1;
};
TradeStatsCalendar.prototype.applyPayload = function (data) {
if (!data) return;
var y = Number(data.year);
var m = Number(data.month);
if (Number.isFinite(y) && y > 0) this.year = y;
if (Number.isFinite(m) && m > 0) this.month = m;
this.days = this.parseResponse(data) || {};
this.monthPnlTotal = Number(data.month_pnl_total) || 0;
this.monthOpenCount = Number(data.month_open_count) || 0;
if (!this.monthOpenCount) {
var self = this;
Object.keys(this.days).forEach(function (k) {
if (dayHasTrade(self.days[k])) {
self.monthOpenCount += dayOpenCount(self.days[k]);
self.monthPnlTotal += dayPnl(self.days[k]);
}
});
this.monthPnlTotal = Math.round(this.monthPnlTotal * 10000) / 10000;
}
};
function readStatsCalendarBootstrap() {
var el = document.getElementById("stats-calendar-bootstrap");
if (!el || !el.textContent) return null;
try {
return JSON.parse(el.textContent);
} catch (e) {
console.warn("[trade calendar] bootstrap parse", e);
return null;
}
}
TradeStatsCalendar.prototype.setSelectedDay = function (day) {
this.selectedDay = day || "";
this.render();
};
TradeStatsCalendar.prototype.render = function () {
if (!this.gridEl || !this.titleEl) return;
if (this.year <= 0 || this.month <= 0) this.ensureMonth(new Date());
var title = monthLabel(this.year, this.month);
if (this.monthOpenCount > 0) {
title +=
" · " + formatCalPnl(this.monthPnlTotal) + " · " + this.monthOpenCount + "笔";
}
this.titleEl.textContent = title;
var first = new Date(this.year, this.month - 1, 1);
var lastDay = new Date(this.year, this.month, 0).getDate();
var startWd = first.getDay();
var html =
'<div class="trade-cal-weekdays">' +
WEEKDAYS.map(function (w) {
return '<span class="trade-cal-wd">' + w + "</span>";
}).join("") +
'</div><div class="trade-cal-grid">';
var i;
for (i = 0; i < startWd; i++) {
html += '<span class="trade-cal-cell trade-cal-pad"></span>';
}
for (var d = 1; d <= lastDay; d++) {
var dayStr =
this.year +
"-" +
String(this.month).padStart(2, "0") +
"-" +
String(d).padStart(2, "0");
var info = this.days[dayStr];
var hasTrade = dayHasTrade(info);
var sick = this.showSick && info && info.has_sick;
var pnl = hasTrade ? dayPnl(info) : null;
var cnt = hasTrade ? dayOpenCount(info) : 0;
var cls =
"trade-cal-cell" +
(hasTrade ? " has-trade" : "") +
(sick ? " is-sick-day" : "") +
(this.selectedDay === dayStr ? " is-selected" : "") +
(pnl != null && pnl > 0.0001
? " pnl-pos"
: pnl != null && pnl < -0.0001
? " pnl-neg"
: "");
var body = '<span class="trade-cal-day-num">' + d + "</span>";
if (hasTrade) {
body +=
'<span class="trade-cal-pnl">' +
esc(formatCalPnl(pnl)) +
"</span>" +
'<span class="trade-cal-cnt">' +
cnt +
"笔</span>";
if (sick) body += '<span class="trade-cal-sick-tag">犯病</span>';
}
html +=
'<button type="button" class="' +
cls +
'" data-day="' +
dayStr +
'" data-sick="' +
(sick ? "1" : "0") +
'"' +
(hasTrade ? "" : " disabled") +
">" +
body +
"</button>";
}
html += "</div>";
this.gridEl.innerHTML = html;
var self = this;
this.gridEl.querySelectorAll(".trade-cal-cell[data-day]").forEach(function (btn) {
btn.addEventListener("click", function () {
var day = btn.getAttribute("data-day");
if (!day || !self.onDayClick) return;
self.selectedDay = day;
self.render();
self.onDayClick(day, btn.getAttribute("data-sick") === "1", self.days[day] || null);
});
});
};
TradeStatsCalendar.prototype.load = async function () {
this.ensureMonth(new Date());
this.render();
var q = this.buildQuery(this.year, this.month);
if (!q.has("year")) q.set("year", String(this.year));
if (!q.has("month")) q.set("month", String(this.month));
try {
var data;
if (this.fetchFn) {
data = await this.fetchFn(q);
} else {
var resp = await fetch(this.apiUrl + "?" + q.toString(), {
credentials: "same-origin",
});
if (!resp.ok) {
console.warn("[trade calendar] api", resp.status);
this.render();
return;
}
data = await resp.json();
}
this.applyPayload(data);
this.render();
if (this.onMonthChange) this.onMonthChange(this.year, this.month, this.days);
} catch (e) {
console.warn("[trade calendar]", e);
this.render();
}
};
TradeStatsCalendar.prototype.shiftMonth = function (delta) {
this.ensureMonth(new Date());
this.month += delta;
if (this.month > 12) {
this.month = 1;
this.year += 1;
} else if (this.month < 1) {
this.month = 12;
this.year -= 1;
}
void this.load();
};
TradeStatsCalendar.prototype._bindNav = function () {
if (this._navBound) return;
var self = this;
if (this.prevBtn) {
this.prevBtn.addEventListener("click", function () {
self.shiftMonth(-1);
});
}
if (this.nextBtn) {
this.nextBtn.addEventListener("click", function () {
self.shiftMonth(1);
});
}
this._navBound = true;
};
global.TradeStatsCalendar = TradeStatsCalendar;
global.statsCalendarWidget = null;
global.initInstanceStatsCalendar = function () {
var grid = document.getElementById("stats-calendar");
if (!grid || !global.TradeStatsCalendar) return null;
var bootstrap = readStatsCalendarBootstrap();
if (
global.statsCalendarWidget &&
global.statsCalendarWidget.gridEl === grid
) {
if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap);
global.statsCalendarWidget.render();
void global.statsCalendarWidget.load();
return global.statsCalendarWidget;
}
global.statsCalendarWidget = new TradeStatsCalendar({
gridEl: grid,
titleEl: document.getElementById("stats-cal-title"),
prevBtn: document.getElementById("stats-cal-prev"),
nextBtn: document.getElementById("stats-cal-next"),
apiUrl: "/api/stats/calendar",
showSick: false,
buildQuery: function (year, month) {
var q = new URLSearchParams();
q.set("year", String(year));
q.set("month", String(month));
var sel = document.getElementById("stats-segment-select");
if (sel) q.set("segment", sel.value || "all");
return q;
},
parseResponse: function (data) {
if (data && data.ok === false) return {};
return (data && data.days) || {};
},
});
if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap);
global.statsCalendarWidget.render();
void global.statsCalendarWidget.load();
return global.statsCalendarWidget;
};
global.initStatsCalendarWidget = global.initInstanceStatsCalendar;
})(window);
+1 -1
View File
@@ -1,4 +1,4 @@
"""Gate.io 资金划转(crypto_monitor_gate / crypto_monitor_gate_bot 共用)。"""
"""Gate.io 资金划转(crypto_monitor_gate 共用)。"""
from __future__ import annotations
from typing import Any, Callable, Optional
+1 -2
View File
@@ -1,4 +1,4 @@
"""中控备份与恢复:所 SQLite、K 线库、env、hub JSON。"""
"""中控备份与恢复:所 SQLite、K 线库、env、hub JSON。"""
from __future__ import annotations
import json
@@ -22,7 +22,6 @@ EXCHANGE_DIRS: list[tuple[str, str]] = [
("binance", "crypto_monitor_binance"),
("okx", "crypto_monitor_okx"),
("gate", "crypto_monitor_gate"),
("gate_bot", "crypto_monitor_gate_bot"),
]
HUB_JSON_FILES = (
+2 -2
View File
@@ -42,7 +42,7 @@ def _merge_query_into_path(path: str, **params: str) -> str:
def install_instance_theme_static(app) -> None:
"""仓库 lib/common/static 下 instance_theme.* 等供所页面共用。"""
"""仓库 lib/common/static 下 instance_theme.* 等供所页面共用。"""
import os
from flask import Response, send_file
@@ -96,7 +96,7 @@ def register_trade_stats_calendar_route(
reset_hour: int,
get_db_fn=None,
):
"""所统计分析页:按月返回各交易日盈亏/笔数。"""
"""所统计分析页:按月返回各交易日盈亏/笔数。"""
from flask import jsonify, request
from lib.trade.trade_stats_calendar_lib import build_trade_stats_calendar
+692 -692
View File
File diff suppressed because it is too large Load Diff
+252 -252
View File
@@ -1,252 +1,252 @@
"""ccxt 持仓标记价解析(实例 price_snapshot 与中控子代理共用)。"""
from __future__ import annotations
import math
from typing import Any, Callable
def _finite_or_none(x: Any) -> float | None:
try:
f = float(x)
return f if math.isfinite(f) else None
except (TypeError, ValueError):
return None
def _coerce_float(*values: Any) -> float | None:
for v in values:
if v is None or v == "":
continue
px = _finite_or_none(v)
if px is not None and px > 0:
return px
return None
def position_contracts(p: dict[str, Any]) -> float:
info = p.get("info") or {}
if not isinstance(info, dict):
info = {}
# OKX 等:info.pos 为交易所张数,优先于 ccxt contracts(加仓后后者可能滞后)
for k in ("pos", "positionAmt", "positionamt", "size"):
if k in info:
try:
v = float(info[k])
if v != 0:
return abs(v)
except (TypeError, ValueError):
pass
raw = p.get("contracts")
if raw is not None:
try:
v = float(raw)
if v != 0:
return abs(v)
except (TypeError, ValueError):
pass
return 0.0
def position_side_from_ccxt(p: dict[str, Any], contracts: float | None = None) -> str:
s = (p.get("side") or "").lower()
if s in ("long", "short"):
return s
c = contracts if contracts is not None else position_contracts(p)
if c > 0:
return "long"
if c < 0:
return "short"
return "long"
def parse_position_entry_price(p: dict[str, Any]) -> float | None:
"""所 ccxt 持仓开仓均价。"""
if not isinstance(p, dict):
return None
info = p.get("info") or {}
if not isinstance(info, dict):
info = {}
return _coerce_float(
p.get("entryPrice"),
p.get("entry_price"),
p.get("average"),
info.get("entryPrice"),
info.get("entry_price"),
info.get("avgPx"),
info.get("avgEntryPrice"),
info.get("avg_entry_price"),
info.get("avgPrice"),
info.get("openAvgPx"),
)
def estimate_linear_swap_upnl_usdt(
side: str,
entry: float | None,
mark: float | None,
contracts: float | None,
contract_size: float | None = None,
) -> float | None:
"""U 本位线性永续:浮盈 = (标记价 - 开仓价) × 张数 × contractSize(空头取反)。"""
e = _finite_or_none(entry)
m = _finite_or_none(mark)
c = _finite_or_none(contracts)
if e is None or m is None or c is None or c <= 0:
return None
mult = _finite_or_none(contract_size)
if mult is None or mult <= 0:
mult = 1.0
diff = (m - e) if (side or "long").strip().lower() == "long" else (e - m)
return round(diff * abs(c) * mult, 2)
def resolve_position_display_upnl(
side: str,
entry: float | None,
mark: float | None,
contracts: float | None,
contract_size: float | None,
exchange_upnl: float | None,
) -> float | None:
"""展示用浮盈:优先与标记价/张数一致的推算;与交易所值偏差过大时用推算值。"""
computed = estimate_linear_swap_upnl_usdt(
side, entry, mark, contracts, contract_size
)
if computed is None:
return exchange_upnl
if exchange_upnl is None:
return computed
ref = max(abs(computed), 1.0)
if abs(exchange_upnl - computed) / ref > 0.2:
return computed
return exchange_upnl
def _coerce_signed(*values: Any) -> float | None:
"""解析可正可负的数值(未实现盈亏等)。"""
for v in values:
if v is None or v == "":
continue
f = _finite_or_none(v)
if f is not None:
return f
return None
def parse_position_unrealized_pnl(p: dict[str, Any]) -> float | None:
"""所 ccxt 持仓统一解析未实现盈亏(Gate/OKX/Binance 字段名不一致)。"""
if not isinstance(p, dict):
return None
info = p.get("info") or {}
if not isinstance(info, dict):
info = {}
return _coerce_signed(
p.get("unrealizedPnl"),
p.get("unrealisedPnl"),
p.get("unrealized_pnl"),
p.get("unrealised_pnl"),
info.get("unrealised_pnl"),
info.get("unrealized_pnl"),
info.get("unrealisedPnl"),
info.get("unrealizedPnl"),
info.get("upl"),
info.get("uplLast"),
)
def enrich_ccxt_position_metrics_out(
position: dict[str, Any],
out: dict[str, Any],
*,
contract_size: float = 1.0,
funds_decimals: int = 2,
) -> dict[str, Any]:
"""
所 parse_ccxt_position_metrics 产出后统一:
- 标记价用 hub 兜底
- 未实现盈亏 = resolve(交易所值, entry/mark/张数/contractSize 推算)
"""
if not isinstance(position, dict) or not isinstance(out, dict):
return out
mark = _finite_or_none(out.get("mark_price"))
if mark is None or mark <= 0:
mp = parse_position_mark_price(position)
if mp is not None and mp > 0:
out["mark_price"] = round(mp, 8)
mark = mp
exchange_upnl = parse_position_unrealized_pnl(position)
if exchange_upnl is None:
exchange_upnl = _coerce_signed(out.get("unrealized_pnl"))
c = position_contracts(position)
if abs(c) < 1e-12:
return out
side = position_side_from_ccxt(position, c)
entry = parse_position_entry_price(position)
if entry is not None and entry > 0:
out["entry_price"] = round(entry, 8)
cs = contract_size if contract_size and contract_size > 0 else 1.0
upnl = resolve_position_display_upnl(
side, entry, mark, abs(c), cs, exchange_upnl
)
if upnl is not None:
out["unrealized_pnl"] = round(upnl, funds_decimals)
return out
def parse_position_mark_price(p: dict[str, Any]) -> float | None:
"""所 ccxt 持仓统一解析标记价(与 crypto_monitor_* parse_ccxt_position_metrics 口径一致)。"""
if not isinstance(p, dict):
return None
info = p.get("info") or {}
if not isinstance(info, dict):
info = {}
mark = _coerce_float(
p.get("markPrice"),
p.get("mark_price"),
p.get("mark"),
info.get("markPx"),
info.get("mark_price"),
info.get("markPrice"),
)
if mark is not None:
return mark
contracts = position_contracts(p)
if abs(contracts) >= 1e-12:
notional = _finite_or_none(p.get("notional"))
if notional is not None and abs(notional) > 0:
return abs(notional) / abs(contracts)
return None
def build_position_marks_list(
positions: list,
*,
format_mark_display: Callable[[str, float], str] | None = None,
) -> list[dict[str, Any]]:
"""从 fetch_positions 结果生成 position_marks,供 price_snapshot / 中控合并。"""
out: list[dict[str, Any]] = []
for p in positions or []:
if not isinstance(p, dict):
continue
c = position_contracts(p)
if abs(c) < 1e-12:
continue
mark = parse_position_mark_price(p)
if mark is None or mark <= 0:
continue
sym = (p.get("symbol") or "").strip()
side = position_side_from_ccxt(p, c)
row: dict[str, Any] = {
"symbol": sym,
"side": side,
"mark_price": mark,
}
if format_mark_display and sym:
try:
row["mark_price_display"] = format_mark_display(sym, mark)
except Exception:
row["mark_price_display"] = f"{mark:g}"
else:
row["mark_price_display"] = f"{mark:g}"
out.append(row)
return out
"""ccxt 持仓标记价解析(实例 price_snapshot 与中控子代理共用)。"""
from __future__ import annotations
import math
from typing import Any, Callable
def _finite_or_none(x: Any) -> float | None:
try:
f = float(x)
return f if math.isfinite(f) else None
except (TypeError, ValueError):
return None
def _coerce_float(*values: Any) -> float | None:
for v in values:
if v is None or v == "":
continue
px = _finite_or_none(v)
if px is not None and px > 0:
return px
return None
def position_contracts(p: dict[str, Any]) -> float:
info = p.get("info") or {}
if not isinstance(info, dict):
info = {}
# OKX 等:info.pos 为交易所张数,优先于 ccxt contracts(加仓后后者可能滞后)
for k in ("pos", "positionAmt", "positionamt", "size"):
if k in info:
try:
v = float(info[k])
if v != 0:
return abs(v)
except (TypeError, ValueError):
pass
raw = p.get("contracts")
if raw is not None:
try:
v = float(raw)
if v != 0:
return abs(v)
except (TypeError, ValueError):
pass
return 0.0
def position_side_from_ccxt(p: dict[str, Any], contracts: float | None = None) -> str:
s = (p.get("side") or "").lower()
if s in ("long", "short"):
return s
c = contracts if contracts is not None else position_contracts(p)
if c > 0:
return "long"
if c < 0:
return "short"
return "long"
def parse_position_entry_price(p: dict[str, Any]) -> float | None:
"""所 ccxt 持仓开仓均价。"""
if not isinstance(p, dict):
return None
info = p.get("info") or {}
if not isinstance(info, dict):
info = {}
return _coerce_float(
p.get("entryPrice"),
p.get("entry_price"),
p.get("average"),
info.get("entryPrice"),
info.get("entry_price"),
info.get("avgPx"),
info.get("avgEntryPrice"),
info.get("avg_entry_price"),
info.get("avgPrice"),
info.get("openAvgPx"),
)
def estimate_linear_swap_upnl_usdt(
side: str,
entry: float | None,
mark: float | None,
contracts: float | None,
contract_size: float | None = None,
) -> float | None:
"""U 本位线性永续:浮盈 = (标记价 - 开仓价) × 张数 × contractSize(空头取反)。"""
e = _finite_or_none(entry)
m = _finite_or_none(mark)
c = _finite_or_none(contracts)
if e is None or m is None or c is None or c <= 0:
return None
mult = _finite_or_none(contract_size)
if mult is None or mult <= 0:
mult = 1.0
diff = (m - e) if (side or "long").strip().lower() == "long" else (e - m)
return round(diff * abs(c) * mult, 2)
def resolve_position_display_upnl(
side: str,
entry: float | None,
mark: float | None,
contracts: float | None,
contract_size: float | None,
exchange_upnl: float | None,
) -> float | None:
"""展示用浮盈:优先与标记价/张数一致的推算;与交易所值偏差过大时用推算值。"""
computed = estimate_linear_swap_upnl_usdt(
side, entry, mark, contracts, contract_size
)
if computed is None:
return exchange_upnl
if exchange_upnl is None:
return computed
ref = max(abs(computed), 1.0)
if abs(exchange_upnl - computed) / ref > 0.2:
return computed
return exchange_upnl
def _coerce_signed(*values: Any) -> float | None:
"""解析可正可负的数值(未实现盈亏等)。"""
for v in values:
if v is None or v == "":
continue
f = _finite_or_none(v)
if f is not None:
return f
return None
def parse_position_unrealized_pnl(p: dict[str, Any]) -> float | None:
"""所 ccxt 持仓统一解析未实现盈亏(Gate/OKX/Binance 字段名不一致)。"""
if not isinstance(p, dict):
return None
info = p.get("info") or {}
if not isinstance(info, dict):
info = {}
return _coerce_signed(
p.get("unrealizedPnl"),
p.get("unrealisedPnl"),
p.get("unrealized_pnl"),
p.get("unrealised_pnl"),
info.get("unrealised_pnl"),
info.get("unrealized_pnl"),
info.get("unrealisedPnl"),
info.get("unrealizedPnl"),
info.get("upl"),
info.get("uplLast"),
)
def enrich_ccxt_position_metrics_out(
position: dict[str, Any],
out: dict[str, Any],
*,
contract_size: float = 1.0,
funds_decimals: int = 2,
) -> dict[str, Any]:
"""
所 parse_ccxt_position_metrics 产出后统一:
- 标记价用 hub 兜底
- 未实现盈亏 = resolve(交易所值, entry/mark/张数/contractSize 推算)
"""
if not isinstance(position, dict) or not isinstance(out, dict):
return out
mark = _finite_or_none(out.get("mark_price"))
if mark is None or mark <= 0:
mp = parse_position_mark_price(position)
if mp is not None and mp > 0:
out["mark_price"] = round(mp, 8)
mark = mp
exchange_upnl = parse_position_unrealized_pnl(position)
if exchange_upnl is None:
exchange_upnl = _coerce_signed(out.get("unrealized_pnl"))
c = position_contracts(position)
if abs(c) < 1e-12:
return out
side = position_side_from_ccxt(position, c)
entry = parse_position_entry_price(position)
if entry is not None and entry > 0:
out["entry_price"] = round(entry, 8)
cs = contract_size if contract_size and contract_size > 0 else 1.0
upnl = resolve_position_display_upnl(
side, entry, mark, abs(c), cs, exchange_upnl
)
if upnl is not None:
out["unrealized_pnl"] = round(upnl, funds_decimals)
return out
def parse_position_mark_price(p: dict[str, Any]) -> float | None:
"""所 ccxt 持仓统一解析标记价(与 crypto_monitor_* parse_ccxt_position_metrics 口径一致)。"""
if not isinstance(p, dict):
return None
info = p.get("info") or {}
if not isinstance(info, dict):
info = {}
mark = _coerce_float(
p.get("markPrice"),
p.get("mark_price"),
p.get("mark"),
info.get("markPx"),
info.get("mark_price"),
info.get("markPrice"),
)
if mark is not None:
return mark
contracts = position_contracts(p)
if abs(contracts) >= 1e-12:
notional = _finite_or_none(p.get("notional"))
if notional is not None and abs(notional) > 0:
return abs(notional) / abs(contracts)
return None
def build_position_marks_list(
positions: list,
*,
format_mark_display: Callable[[str, float], str] | None = None,
) -> list[dict[str, Any]]:
"""从 fetch_positions 结果生成 position_marks,供 price_snapshot / 中控合并。"""
out: list[dict[str, Any]] = []
for p in positions or []:
if not isinstance(p, dict):
continue
c = position_contracts(p)
if abs(c) < 1e-12:
continue
mark = parse_position_mark_price(p)
if mark is None or mark <= 0:
continue
sym = (p.get("symbol") or "").strip()
side = position_side_from_ccxt(p, c)
row: dict[str, Any] = {
"symbol": sym,
"side": side,
"mark_price": mark,
}
if format_mark_display and sym:
try:
row["mark_price_display"] = format_mark_display(sym, mark)
except Exception:
row["mark_price_display"] = f"{mark:g}"
else:
row["mark_price_display"] = f"{mark:g}"
out.append(row)
return out
+2 -2
View File
@@ -365,7 +365,7 @@ def _collect_scores(exchange, exchange_id: str) -> list[tuple[str, str, float]]:
return _scores_from_okx(exchange)
if ex_id == "binance":
return _scores_from_binance(exchange)
if ex_id in ("gateio", "gate", "gate_bot"):
if ex_id in ("gateio", "gate"):
return _scores_from_gate(exchange)
tickers = exchange.fetch_tickers()
return _scores_from_markets(exchange, tickers or {}, ex_id)
@@ -373,7 +373,7 @@ def _collect_scores(exchange, exchange_id: str) -> list[tuple[str, str, float]]:
def _uses_lightweight_volume_scores(exchange_id: str) -> bool:
ex_id = str(exchange_id or "").lower()
return ex_id in ("okx", "binance", "gateio", "gate", "gate_bot")
return ex_id in ("okx", "binance", "gateio", "gate")
def build_usdt_swap_volume_ranks(
+1 -2
View File
@@ -33,7 +33,6 @@ PATH_TO_EMBED_TAB: dict[str, str] = {
ORDER_RULE_TIPS_BY_EXCHANGE: dict[str, str] = {
"gate": "order_monitor_rule_tips_gate.html",
"gate_bot": "order_monitor_rule_tips_gate.html",
"binance": "order_monitor_rule_tips_binance.html",
"okx": "order_monitor_rule_tips_okx.html",
}
@@ -45,7 +44,7 @@ def order_rule_tips_template(exchange_key: str) -> str:
def include_transfer_block(exchange_key: str) -> bool:
return (exchange_key or "").strip().lower() in ("gate", "gate_bot")
return (exchange_key or "").strip().lower() == "gate"
def path_to_embed_tab(path: str) -> str | None:
+14 -14
View File
@@ -1,14 +1,14 @@
"""关键位监控表结构迁移(所共用)。"""
from __future__ import annotations
from typing import Any
def ensure_key_monitor_schema(conn: Any) -> None:
for sql in (
"ALTER TABLE key_monitors ADD COLUMN last_mark_price REAL",
):
try:
conn.execute(sql)
except Exception:
pass
"""关键位监控表结构迁移(所共用)。"""
from __future__ import annotations
from typing import Any
def ensure_key_monitor_schema(conn: Any) -> None:
for sql in (
"ALTER TABLE key_monitors ADD COLUMN last_mark_price REAL",
):
try:
conn.execute(sql)
except Exception:
pass
@@ -1,4 +1,4 @@
"""回调/突破触价开仓关键位监控:程序盯价、触达计划入场后市价成交(所共用逻辑)。"""
"""回调/突破触价开仓关键位监控:程序盯价、触达计划入场后市价成交(所共用逻辑)。"""
from __future__ import annotations
from datetime import datetime
+230 -230
View File
@@ -1,230 +1,230 @@
"""各交易所 app 模块 → strategy_register 配置(统一工厂)。"""
from __future__ import annotations
import sys
from typing import Any
def resolve_trading_app_module(app_module: Any = None) -> Any:
"""
须在 login_required 定义之后调用
PM2 / python app.py __name__ __main__请传入 sys.modules[__name__]
"""
if app_module is None:
main = sys.modules.get("__main__")
if main is not None and hasattr(main, "login_required"):
m = main
else:
import inspect
m = None
for fr in inspect.stack():
g = fr.frame.f_globals
if callable(g.get("login_required")) and callable(g.get("get_db")):
m = g
break
if m is None:
raise RuntimeError(
"策略交易注册失败:请使用 install_strategy_trading(app, repo_root, app_module=sys.modules[__name__])"
)
else:
m = app_module
if not hasattr(m, "login_required"):
raise RuntimeError(
"策略交易注册须在 login_required 定义之后执行(将 install_strategy_trading 放在 app.py 末尾)"
)
return m
def build_strategy_config(
app_module: Any = None, *, trend_enabled: bool = False, trend_disabled_note: str = ""
) -> dict:
m = resolve_trading_app_module(app_module)
def get_trading_capital_usdt(conn):
if hasattr(m, "get_exchange_capitals"):
_, tc = m.get_exchange_capitals(force=True)
if tc is not None:
return float(tc)
if hasattr(m, "get_available_trading_usdt"):
snap = m.get_available_trading_usdt()
if snap is not None:
return float(snap)
day = m.get_trading_day(m.app_now())
row = m.ensure_session(conn, day)
return float(row["current_capital"])
def get_position(ex_sym, direction):
qty = m.get_live_position_contracts(ex_sym, direction)
entry = None
try:
rows = m.exchange.fetch_positions([ex_sym])
for p in rows or []:
matcher = getattr(m, "_row_matches_monitor_direction", None)
if matcher and not matcher(direction, p):
continue
contracts = getattr(m, "_position_row_effective_contracts", lambda x: abs(float(x.get("contracts") or 0)))(p)
if contracts <= 0:
continue
coerce = getattr(m, "_coerce_float", None)
if coerce:
entry = coerce(
p.get("entryPrice"),
p.get("average"),
(p.get("info") or {}).get("entryPrice"),
)
if entry:
break
except Exception:
pass
return {"contracts": float(qty or 0), "entry_price": entry}
def amount_to_precision(ex_sym, amount):
try:
return float(m.exchange.amount_to_precision(ex_sym, float(amount)))
except Exception:
return None
def price_to_precision(ex_sym, price):
try:
return float(m.exchange.price_to_precision(ex_sym, float(price)))
except Exception:
return None
def market_add(ex_sym, direction, amount, leverage):
return m.place_exchange_order(ex_sym, direction, amount, leverage, stop_loss=None, take_profit=None)
def limit_add(ex_sym, direction, amount, price, leverage):
m.exchange.set_leverage(int(leverage), ex_sym)
side = "buy" if direction == "long" else "sell"
if hasattr(m, "build_okx_order_params"):
params = m.build_okx_order_params(direction, reduce_only=False)
elif hasattr(m, "build_binance_order_params"):
params = m.build_binance_order_params(direction, reduce_only=False)
elif hasattr(m, "build_gate_order_params"):
params = m.build_gate_order_params(direction, reduce_only=False)
else:
params = {}
return m.exchange.create_order(
ex_sym, "limit", side, float(amount), float(price), params if params is not None else {}
)
def replace_tpsl(ex_sym, direction, sl, tp, order_row):
row = order_row or {"symbol": ex_sym, "exchange_symbol": ex_sym, "direction": direction}
m.replace_active_monitor_tpsl_on_exchange(row, sl, tp)
def count_trends(conn):
try:
return int(
conn.execute(
"SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'"
).fetchone()[0]
)
except Exception:
return 0
def friendly_error(err):
fn = getattr(m, "friendly_exchange_error", None) or getattr(
m, "friendly_okx_error", None
)
if not callable(fn):
return str(err)
try:
snap = m.get_available_trading_usdt()
except Exception:
snap = None
try:
return fn(err, available_usdt=snap)
except TypeError:
return fn(err)
def limit_order_status(ex_sym, order_id):
fn = getattr(m, "fib_limit_order_status", None)
if callable(fn):
return fn(ex_sym, order_id)
return "unknown"
def cancel_limit_order(ex_sym, order_id):
fn = getattr(m, "cancel_fib_limit_order", None)
if callable(fn):
try:
return fn(ex_sym, order_id)
except Exception:
pass
if not order_id:
return False
try:
m.exchange.cancel_order(str(order_id), ex_sym)
return True
except Exception:
return False
def get_mark_price(symbol):
fn = getattr(m, "get_symbol_mark_price", None) or getattr(m, "get_price", None)
if not callable(fn):
return None
try:
return fn(symbol)
except Exception:
return None
def wechat_account_label():
fn = getattr(m, "_wechat_account_label", None)
if callable(fn):
try:
return fn()
except Exception:
pass
return getattr(m, "EXCHANGE_DISPLAY_NAME", "") or ""
def wechat_direction_text(direction):
fn = getattr(m, "_wechat_direction_text", None)
if callable(fn):
try:
return fn(direction)
except Exception:
pass
d = (direction or "long").strip().lower()
return "做多" if d == "long" else "做空"
def send_wechat(content):
fn = getattr(m, "send_wechat_msg", None)
if callable(fn):
fn(content)
note = trend_disabled_note or (
"趋势回调(自动补仓)请在 Gate 趋势机器人实例使用:/strategy/trend"
)
return {
"app_module": m,
"exchange_display": getattr(m, "EXCHANGE_DISPLAY_NAME", ""),
"trend_enabled": trend_enabled,
"trend_disabled_note": note,
"login_required": m.login_required,
"get_db": m.get_db,
"normalize_symbol_input": m.normalize_symbol_input,
"normalize_exchange_symbol": m.normalize_exchange_symbol,
"get_price": m.get_price,
"get_trading_capital_usdt": get_trading_capital_usdt,
"get_position": get_position,
"amount_to_precision": amount_to_precision,
"price_to_precision": price_to_precision,
"market_add": market_add,
"limit_add": limit_add,
"replace_tpsl": replace_tpsl,
"ensure_live_ready": m.ensure_exchange_live_ready,
"default_risk_percent": float(getattr(m, "RISK_PERCENT", 2)),
"default_leverage": m.infer_leverage,
"friendly_error": friendly_error,
"app_now_str": m.app_now_str,
"resolve_fill_price": m.resolve_order_entry_price,
"price_fmt": m.format_price_for_symbol,
"count_active_trend_plans": count_trends if trend_enabled else count_trends,
"limit_order_status": limit_order_status,
"cancel_limit_order": cancel_limit_order,
"get_mark_price": get_mark_price,
"send_wechat": send_wechat,
"format_price": getattr(m, "format_price_for_symbol", None),
"wechat_account_label": wechat_account_label,
"wechat_direction_text": wechat_direction_text,
}
"""各交易所 app 模块 → strategy_register 配置(统一工厂)。"""
from __future__ import annotations
import sys
from typing import Any
def resolve_trading_app_module(app_module: Any = None) -> Any:
"""
须在 login_required 定义之后调用
PM2 / python app.py __name__ __main__请传入 sys.modules[__name__]
"""
if app_module is None:
main = sys.modules.get("__main__")
if main is not None and hasattr(main, "login_required"):
m = main
else:
import inspect
m = None
for fr in inspect.stack():
g = fr.frame.f_globals
if callable(g.get("login_required")) and callable(g.get("get_db")):
m = g
break
if m is None:
raise RuntimeError(
"策略交易注册失败:请使用 install_strategy_trading(app, repo_root, app_module=sys.modules[__name__])"
)
else:
m = app_module
if not hasattr(m, "login_required"):
raise RuntimeError(
"策略交易注册须在 login_required 定义之后执行(将 install_strategy_trading 放在 app.py 末尾)"
)
return m
def build_strategy_config(
app_module: Any = None, *, trend_enabled: bool = False, trend_disabled_note: str = ""
) -> dict:
m = resolve_trading_app_module(app_module)
def get_trading_capital_usdt(conn):
if hasattr(m, "get_exchange_capitals"):
_, tc = m.get_exchange_capitals(force=True)
if tc is not None:
return float(tc)
if hasattr(m, "get_available_trading_usdt"):
snap = m.get_available_trading_usdt()
if snap is not None:
return float(snap)
day = m.get_trading_day(m.app_now())
row = m.ensure_session(conn, day)
return float(row["current_capital"])
def get_position(ex_sym, direction):
qty = m.get_live_position_contracts(ex_sym, direction)
entry = None
try:
rows = m.exchange.fetch_positions([ex_sym])
for p in rows or []:
matcher = getattr(m, "_row_matches_monitor_direction", None)
if matcher and not matcher(direction, p):
continue
contracts = getattr(m, "_position_row_effective_contracts", lambda x: abs(float(x.get("contracts") or 0)))(p)
if contracts <= 0:
continue
coerce = getattr(m, "_coerce_float", None)
if coerce:
entry = coerce(
p.get("entryPrice"),
p.get("average"),
(p.get("info") or {}).get("entryPrice"),
)
if entry:
break
except Exception:
pass
return {"contracts": float(qty or 0), "entry_price": entry}
def amount_to_precision(ex_sym, amount):
try:
return float(m.exchange.amount_to_precision(ex_sym, float(amount)))
except Exception:
return None
def price_to_precision(ex_sym, price):
try:
return float(m.exchange.price_to_precision(ex_sym, float(price)))
except Exception:
return None
def market_add(ex_sym, direction, amount, leverage):
return m.place_exchange_order(ex_sym, direction, amount, leverage, stop_loss=None, take_profit=None)
def limit_add(ex_sym, direction, amount, price, leverage):
m.exchange.set_leverage(int(leverage), ex_sym)
side = "buy" if direction == "long" else "sell"
if hasattr(m, "build_okx_order_params"):
params = m.build_okx_order_params(direction, reduce_only=False)
elif hasattr(m, "build_binance_order_params"):
params = m.build_binance_order_params(direction, reduce_only=False)
elif hasattr(m, "build_gate_order_params"):
params = m.build_gate_order_params(direction, reduce_only=False)
else:
params = {}
return m.exchange.create_order(
ex_sym, "limit", side, float(amount), float(price), params if params is not None else {}
)
def replace_tpsl(ex_sym, direction, sl, tp, order_row):
row = order_row or {"symbol": ex_sym, "exchange_symbol": ex_sym, "direction": direction}
m.replace_active_monitor_tpsl_on_exchange(row, sl, tp)
def count_trends(conn):
try:
return int(
conn.execute(
"SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'"
).fetchone()[0]
)
except Exception:
return 0
def friendly_error(err):
fn = getattr(m, "friendly_exchange_error", None) or getattr(
m, "friendly_okx_error", None
)
if not callable(fn):
return str(err)
try:
snap = m.get_available_trading_usdt()
except Exception:
snap = None
try:
return fn(err, available_usdt=snap)
except TypeError:
return fn(err)
def limit_order_status(ex_sym, order_id):
fn = getattr(m, "fib_limit_order_status", None)
if callable(fn):
return fn(ex_sym, order_id)
return "unknown"
def cancel_limit_order(ex_sym, order_id):
fn = getattr(m, "cancel_fib_limit_order", None)
if callable(fn):
try:
return fn(ex_sym, order_id)
except Exception:
pass
if not order_id:
return False
try:
m.exchange.cancel_order(str(order_id), ex_sym)
return True
except Exception:
return False
def get_mark_price(symbol):
fn = getattr(m, "get_symbol_mark_price", None) or getattr(m, "get_price", None)
if not callable(fn):
return None
try:
return fn(symbol)
except Exception:
return None
def wechat_account_label():
fn = getattr(m, "_wechat_account_label", None)
if callable(fn):
try:
return fn()
except Exception:
pass
return getattr(m, "EXCHANGE_DISPLAY_NAME", "") or ""
def wechat_direction_text(direction):
fn = getattr(m, "_wechat_direction_text", None)
if callable(fn):
try:
return fn(direction)
except Exception:
pass
d = (direction or "long").strip().lower()
return "做多" if d == "long" else "做空"
def send_wechat(content):
fn = getattr(m, "send_wechat_msg", None)
if callable(fn):
fn(content)
note = trend_disabled_note or (
"趋势回调(自动补仓)请在 Gate机器人实例使用:/strategy/trend"
)
return {
"app_module": m,
"exchange_display": getattr(m, "EXCHANGE_DISPLAY_NAME", ""),
"trend_enabled": trend_enabled,
"trend_disabled_note": note,
"login_required": m.login_required,
"get_db": m.get_db,
"normalize_symbol_input": m.normalize_symbol_input,
"normalize_exchange_symbol": m.normalize_exchange_symbol,
"get_price": m.get_price,
"get_trading_capital_usdt": get_trading_capital_usdt,
"get_position": get_position,
"amount_to_precision": amount_to_precision,
"price_to_precision": price_to_precision,
"market_add": market_add,
"limit_add": limit_add,
"replace_tpsl": replace_tpsl,
"ensure_live_ready": m.ensure_exchange_live_ready,
"default_risk_percent": float(getattr(m, "RISK_PERCENT", 2)),
"default_leverage": m.infer_leverage,
"friendly_error": friendly_error,
"app_now_str": m.app_now_str,
"resolve_fill_price": m.resolve_order_entry_price,
"price_fmt": m.format_price_for_symbol,
"count_active_trend_plans": count_trends if trend_enabled else count_trends,
"limit_order_status": limit_order_status,
"cancel_limit_order": cancel_limit_order,
"get_mark_price": get_mark_price,
"send_wechat": send_wechat,
"format_price": getattr(m, "format_price_for_symbol", None),
"wechat_account_label": wechat_account_label,
"wechat_direction_text": wechat_direction_text,
}
+1 -1
View File
@@ -2,7 +2,7 @@
Gate.io USDT 永续 策略交易交易所侧能力
实现方式 Gate 实例 app 通过 strategy_config.build_strategy_config(app_module) 注入
ccxt 下单精度 TP/SL本文件为文档与类型锚点避免在四个 app 重复实现滚仓公式
ccxt 下单精度 TP/SL本文件为文档与类型锚点避免在 app 重复实现滚仓公式
"""
from lib.strategy.strategy_exchange_base import StrategyExchangeAdapter
+1 -1
View File
@@ -1,4 +1,4 @@
"""策略交易记录页:已结束趋势 / 顺势加仓快照(所统一)。"""
"""策略交易记录页:已结束趋势 / 顺势加仓快照(所统一)。"""
from __future__ import annotations
import json
+1 -1
View File
@@ -1,4 +1,4 @@
"""策略结束快照:趋势回调 / 顺势加仓(所共用)。"""
"""策略结束快照:趋势回调 / 顺势加仓(所共用)。"""
from __future__ import annotations
import json
+4 -4
View File
@@ -230,7 +230,7 @@ def compute_trend_plan_core(
def calc_planned_reward_risk_ratio(
direction: str, entry_price: float, stop_loss: float, take_profit: float
) -> Optional[float]:
"""盈亏比(reward/risk),与所 calc_rr_ratio 口径一致。"""
"""盈亏比(reward/risk),与所 calc_rr_ratio 口径一致。"""
try:
entry = float(entry_price)
sl = float(stop_loss)
@@ -375,7 +375,7 @@ def trend_leg_grid_price(plan: dict, leg_idx: int) -> Optional[float]:
def trend_leg_display_price(plan: dict, leg_idx: int) -> Optional[float]:
"""
所统一单档展示价 = leg_fill_prices_json 实际记录否则计划网格首仓用均价/参考价
所统一单档展示价 = leg_fill_prices_json 实际记录否则计划网格首仓用均价/参考价
禁止为凑均价反推虚构成交价
"""
p = plan or {}
@@ -398,7 +398,7 @@ def trend_leg_display_price(plan: dict, leg_idx: int) -> Optional[float]:
def reconcile_trend_leg_fill_prices(plan: dict) -> list[float]:
"""首仓(0)+已补仓(1..legs_done) 展示价列表(所共用 trend_leg_display_price)。"""
"""首仓(0)+已补仓(1..legs_done) 展示价列表(所共用 trend_leg_display_price)。"""
p = plan or {}
if int(p.get("first_order_done") or 0) == 0:
return []
@@ -563,7 +563,7 @@ def build_trend_preview_level_rows(preview: dict) -> tuple[dict, list[dict]]:
def enrich_trend_dca_levels_with_tp(plan: dict, levels: list[dict]) -> list[dict]:
"""
所统一补仓表 enrich实例策略页 + 中控 monitor 共用
所统一补仓表 enrich实例策略页 + 中控 monitor 共用
触发价实际成交价或计划网格末档加仓后均价用持仓均价禁止反推虚构成交价
"""
if not levels:
+4 -4
View File
@@ -1,4 +1,4 @@
"""趋势回调:路由、轮询、页面数据(所共用,依赖各 app 模块交易所能力)。"""
"""趋势回调:路由、轮询、页面数据(所共用,依赖各 app 模块交易所能力)。"""
from __future__ import annotations
import inspect
@@ -138,8 +138,8 @@ def summarize_trend_dca_probe(cfg: dict, row) -> dict:
out["block_reason"] = "交易所无持仓"
else:
out["block_reason"] = (
"标记价已触达,轮询应自动下单;若仍未补请确认 PM2 进程 crypto_gate_bot "
"非 manual-agent-gate-bot)在运行,并查看 pm2 logs crypto_gate_bot"
"标记价已触达,轮询应自动下单;若仍未补请确认 PM2 进程 crypto_gate "
"或对应所 Flask 进程)在运行,并查看 pm2 logs"
)
elif not reached:
out["block_reason"] = f"标记价 {pf} 未触达下一档 {level}"
@@ -520,7 +520,7 @@ def _patch_hub_trend_views(app: Flask) -> None:
def patch_trend_hub_enrich(app: Flask, cfg: dict) -> None:
"""hub_bridge install 之后调用:所 /api/hub/monitor 趋势字段与策略页一致。"""
"""hub_bridge install 之后调用:所 /api/hub/monitor 趋势字段与策略页一致。"""
_patch_hub_monitor_enrich(app, cfg)
+2 -3
View File
@@ -77,9 +77,8 @@ def fetch_roll_page_data(
DEFAULT_TREND_DISABLED_NOTE = (
"趋势回调(预览、自动补仓、程序止盈)仅在 Gate 趋势机器人实例 "
"crypto_monitor_gate_bot,常见端口 5002)中启用"
"币安 / Gate 主站 / OKX 可使用本页「顺势加仓」;完整趋势回调请打开该实例。"
"趋势回调(预览、自动补仓、程序止盈)须在本实例 .env 设置 "
"`LIVE_TRADING_ENABLED=true` 并重启对应 PM2 进程(如 crypto_gate / crypto_okx / crypto_binance"
)
+1 -1
View File
@@ -1,4 +1,4 @@
"""策略计划(趋势回调 / 滚仓)开始与结束 — 企业微信推送(所共用)。"""
"""策略计划(趋势回调 / 滚仓)开始与结束 — 企业微信推送(所共用)。"""
from __future__ import annotations
from typing import Any, Optional
@@ -14,7 +14,7 @@
<div class="box">
<h1>趋势回调</h1>
<p>{{ trend_note }}</p>
<p style="color:#8892b0;font-size:.9rem">趋势回调含自动补仓档位,仅在 Gate 趋势机器人(crypto_monitor_gate_bot)实例中运行</p>
<p style="color:#8892b0;font-size:.9rem">趋势回调含自动补仓档位,在三所实例(Binance / Gate / OKX)中均可启用,须配置 LIVE_TRADING_ENABLED=true</p>
</div>
</body>
</html>
@@ -5,8 +5,8 @@
<summary class="tip-collapse-summary">趋势回调说明(本实例未启用)</summary>
<div class="tip-collapse-body rule-tip">
{{ trend_disabled_note }}<br><br>
趋势回调含自动补仓档位与预览执行,<strong>Gate 趋势机器人</strong><code>crypto_monitor_gate_bot</code>)实例中运行。
请访问实例同一菜单「策略交易 → 趋势回调」,或常用地址 <code>:5002/strategy/trend</code>
趋势回调含自动补仓档位与预览执行,在 <strong>Binance / Gate / OKX</strong> 各实例的「策略交易 → 趋势回调」中运行。
请访问对应实例同一菜单,或常用地址如 Gate <code>:5000/strategy/trend</code>
</div>
</details>
<p style="margin-top:12px;font-size:.85rem">
@@ -17,7 +17,7 @@
<strong>计划 #{{ p.plan_id }}</strong> 标记价 {{ p.mark_price }} 已触达补仓触发价 {{ p.next_trigger }},但未自动补仓:
{{ p.block_reason }}。
{% if not live_trading_enabled %}
请在 <code>crypto_monitor_gate_bot/.env</code> 设置 <code>LIVE_TRADING_ENABLED=true</code> 后重启 PM2 进程 <strong>crypto_gate_bot</strong>(不是 manual-agent-gate-bot)。
请在当前实例 <code>.env</code> 设置 <code>LIVE_TRADING_ENABLED=true</code> 后重启对应 PM2 进程(如 <strong>crypto_gate</strong><strong>crypto_okx</strong><strong>crypto_binance</strong>)。
{% endif %}
</div>
{% endif %}
+1 -1
View File
@@ -1,4 +1,4 @@
"""账户冷静期 / 日冻结风控(所实例共用)。"""
"""账户冷静期 / 日冻结风控(所实例共用)。"""
from __future__ import annotations
import os
+140 -140
View File
@@ -1,140 +1,140 @@
"""单日开仓次数:软提醒阈值 + 硬上限(所实例共用)。"""
from __future__ import annotations
import os
from typing import Any, Optional
def parse_daily_open_alert_threshold(raw: Any = None, *, default: int = 5) -> int:
"""AI 克制提醒阈值;至少 1。"""
try:
v = int(raw if raw is not None and str(raw).strip() != "" else default)
except (TypeError, ValueError):
v = default
return max(1, v)
def parse_daily_open_hard_limit(raw: Any = None, *, default: int = 0) -> int:
"""硬上限;0 表示不启用。至少 0。"""
try:
v = int(raw if raw is not None and str(raw).strip() != "" else default)
except (TypeError, ValueError):
v = default
return max(0, v)
def load_daily_open_limits_from_env(
env: Optional[dict[str, str]] = None,
) -> tuple[int, int]:
"""从环境变量读取 (alert_threshold, hard_limit)。"""
src = env if env is not None else os.environ
alert = parse_daily_open_alert_threshold(src.get("DAILY_OPEN_ALERT_THRESHOLD"))
hard = parse_daily_open_hard_limit(src.get("DAILY_OPEN_HARD_LIMIT"))
return alert, hard
def count_opens_for_trading_day(conn, trading_day: str) -> int:
"""本交易日已成功写入 order_monitors 的开仓次数。"""
td = (trading_day or "").strip()
if not td:
return 0
row = conn.execute(
"SELECT COUNT(*) FROM order_monitors WHERE session_date=?",
(td,),
).fetchone()
return int(row[0] if row else 0)
def daily_open_hard_limit_blocks(opens_today: int, hard_limit: int) -> bool:
return int(hard_limit) > 0 and int(opens_today) >= int(hard_limit)
def hard_limit_block_reason(opens_today: int, hard_limit: int, reset_hour: int) -> str:
return (
f"本交易日开仓次数已达上限({int(opens_today)}/{int(hard_limit)}),"
f"次日北京时间 {int(reset_hour)}:00 后恢复"
)
def check_daily_open_hard_limit(
conn,
trading_day: str,
hard_limit: int,
reset_hour: int,
) -> tuple[bool, str, int]:
"""返回 (允许继续开仓, 拒绝原因, 当日已开次数)。"""
opens_today = count_opens_for_trading_day(conn, trading_day)
if daily_open_hard_limit_blocks(opens_today, hard_limit):
return False, hard_limit_block_reason(opens_today, hard_limit, reset_hour), opens_today
return True, "", opens_today
def can_trade_new_open(
*,
time_allows: bool,
active_count: int,
max_active_positions: int,
opens_today: int,
hard_limit: int,
extra_blocks: bool = False,
) -> bool:
if extra_blocks:
return False
if not time_allows:
return False
if int(active_count) >= int(max_active_positions):
return False
if daily_open_hard_limit_blocks(opens_today, hard_limit):
return False
return True
def should_send_daily_open_alert(before: int, after: int, alert_threshold: int) -> bool:
return int(before) < int(alert_threshold) <= int(after)
def build_daily_open_alert_prompt(
trading_day: str,
opens_after: int,
alert_threshold: int,
*,
hard_limit: int = 0,
detail_line: str = "",
) -> str:
hard_txt = (
f"硬上限 {hard_limit} 次(已达后将禁止新开仓直至下一交易日)。"
if int(hard_limit) > 0
else "未配置单日硬上限。"
)
extra = f" {detail_line}" if detail_line else ""
return (
f"用户在北京时间交易日 {trading_day} 已累计开仓 {opens_after}"
f"AI 提醒阈值 {alert_threshold}{hard_txt}"
f"{extra}"
f"用户自述“上头了”。请给克制提醒。"
)
def format_daily_open_counter_line(
opens_today: int,
alert_threshold: int,
hard_limit: int,
) -> str:
if int(hard_limit) > 0:
return (
f"📅 当日开仓次数:{int(opens_today)} / 硬上限 {int(hard_limit)}"
f"AI 提醒阈值 {int(alert_threshold)}"
)
return (
f"📅 当日开仓次数:{int(opens_today)} / AI 提醒阈值 {int(alert_threshold)}"
)
def format_daily_open_summary_short(
opens_today: int,
alert_threshold: int,
hard_limit: int,
) -> str:
if int(hard_limit) > 0:
return f"本交易日累计开仓:{int(opens_today)}(硬上限 {int(hard_limit)},提醒 {int(alert_threshold)}"
return f"本交易日累计开仓:{int(opens_today)}(提醒阈值 {int(alert_threshold)}"
"""单日开仓次数:软提醒阈值 + 硬上限(所实例共用)。"""
from __future__ import annotations
import os
from typing import Any, Optional
def parse_daily_open_alert_threshold(raw: Any = None, *, default: int = 5) -> int:
"""AI 克制提醒阈值;至少 1。"""
try:
v = int(raw if raw is not None and str(raw).strip() != "" else default)
except (TypeError, ValueError):
v = default
return max(1, v)
def parse_daily_open_hard_limit(raw: Any = None, *, default: int = 0) -> int:
"""硬上限;0 表示不启用。至少 0。"""
try:
v = int(raw if raw is not None and str(raw).strip() != "" else default)
except (TypeError, ValueError):
v = default
return max(0, v)
def load_daily_open_limits_from_env(
env: Optional[dict[str, str]] = None,
) -> tuple[int, int]:
"""从环境变量读取 (alert_threshold, hard_limit)。"""
src = env if env is not None else os.environ
alert = parse_daily_open_alert_threshold(src.get("DAILY_OPEN_ALERT_THRESHOLD"))
hard = parse_daily_open_hard_limit(src.get("DAILY_OPEN_HARD_LIMIT"))
return alert, hard
def count_opens_for_trading_day(conn, trading_day: str) -> int:
"""本交易日已成功写入 order_monitors 的开仓次数。"""
td = (trading_day or "").strip()
if not td:
return 0
row = conn.execute(
"SELECT COUNT(*) FROM order_monitors WHERE session_date=?",
(td,),
).fetchone()
return int(row[0] if row else 0)
def daily_open_hard_limit_blocks(opens_today: int, hard_limit: int) -> bool:
return int(hard_limit) > 0 and int(opens_today) >= int(hard_limit)
def hard_limit_block_reason(opens_today: int, hard_limit: int, reset_hour: int) -> str:
return (
f"本交易日开仓次数已达上限({int(opens_today)}/{int(hard_limit)}),"
f"次日北京时间 {int(reset_hour)}:00 后恢复"
)
def check_daily_open_hard_limit(
conn,
trading_day: str,
hard_limit: int,
reset_hour: int,
) -> tuple[bool, str, int]:
"""返回 (允许继续开仓, 拒绝原因, 当日已开次数)。"""
opens_today = count_opens_for_trading_day(conn, trading_day)
if daily_open_hard_limit_blocks(opens_today, hard_limit):
return False, hard_limit_block_reason(opens_today, hard_limit, reset_hour), opens_today
return True, "", opens_today
def can_trade_new_open(
*,
time_allows: bool,
active_count: int,
max_active_positions: int,
opens_today: int,
hard_limit: int,
extra_blocks: bool = False,
) -> bool:
if extra_blocks:
return False
if not time_allows:
return False
if int(active_count) >= int(max_active_positions):
return False
if daily_open_hard_limit_blocks(opens_today, hard_limit):
return False
return True
def should_send_daily_open_alert(before: int, after: int, alert_threshold: int) -> bool:
return int(before) < int(alert_threshold) <= int(after)
def build_daily_open_alert_prompt(
trading_day: str,
opens_after: int,
alert_threshold: int,
*,
hard_limit: int = 0,
detail_line: str = "",
) -> str:
hard_txt = (
f"硬上限 {hard_limit} 次(已达后将禁止新开仓直至下一交易日)。"
if int(hard_limit) > 0
else "未配置单日硬上限。"
)
extra = f" {detail_line}" if detail_line else ""
return (
f"用户在北京时间交易日 {trading_day} 已累计开仓 {opens_after}"
f"AI 提醒阈值 {alert_threshold}{hard_txt}"
f"{extra}"
f"用户自述“上头了”。请给克制提醒。"
)
def format_daily_open_counter_line(
opens_today: int,
alert_threshold: int,
hard_limit: int,
) -> str:
if int(hard_limit) > 0:
return (
f"📅 当日开仓次数:{int(opens_today)} / 硬上限 {int(hard_limit)}"
f"AI 提醒阈值 {int(alert_threshold)}"
)
return (
f"📅 当日开仓次数:{int(opens_today)} / AI 提醒阈值 {int(alert_threshold)}"
)
def format_daily_open_summary_short(
opens_today: int,
alert_threshold: int,
hard_limit: int,
) -> str:
if int(hard_limit) > 0:
return f"本交易日累计开仓:{int(opens_today)}(硬上限 {int(hard_limit)},提醒 {int(alert_threshold)}"
return f"本交易日累计开仓:{int(opens_today)}(提醒阈值 {int(alert_threshold)}"
+136 -136
View File
@@ -1,136 +1,136 @@
"""
所共用计仓模式 risk以损定仓| full_margin全仓杠杆
env POSITION_SIZING_MODE 切换须无持仓由部署流程保证
"""
from __future__ import annotations
import os
from typing import Any, Optional, Tuple
MODE_RISK = "risk"
MODE_FULL_MARGIN = "full_margin"
VALID_MODES = frozenset({MODE_RISK, MODE_FULL_MARGIN})
OPEN_SOURCE_MANUAL = "manual"
OPEN_SOURCE_KEY_AUTO = "key_auto"
OPEN_SOURCE_KEY_FIB = "key_fib"
OPEN_SOURCE_KEY_TRIGGER = "key_trigger"
OPEN_SOURCE_TREND = "trend"
OPEN_SOURCE_ROLL = "roll"
FULL_MARGIN_BLOCKED_SOURCES = frozenset(
{OPEN_SOURCE_KEY_AUTO, OPEN_SOURCE_KEY_FIB, OPEN_SOURCE_TREND, OPEN_SOURCE_ROLL}
)
def normalize_position_sizing_mode(raw: Optional[str]) -> str:
v = (raw or MODE_RISK).strip().lower()
if v in ("full", "full_margin", "fullmargin", "全仓", "全仓杠杆"):
return MODE_FULL_MARGIN
return MODE_RISK if v in ("risk", "r", "以损定仓", "") else MODE_RISK
def load_position_sizing_mode(env: Optional[dict] = None) -> str:
e = env if env is not None else os.environ
return normalize_position_sizing_mode(e.get("POSITION_SIZING_MODE"))
def is_full_margin_mode(mode: str) -> bool:
return normalize_position_sizing_mode(mode) == MODE_FULL_MARGIN
def mode_label_zh(mode: str) -> str:
return "全仓杠杆" if is_full_margin_mode(mode) else "以损定仓"
def leverage_for_full_margin(symbol: str, btc_leverage: int, alt_leverage: int) -> int:
sym = (symbol or "").strip().upper()
if sym.startswith("BTC") or sym.startswith("ETH"):
return max(1, int(btc_leverage or 10))
return max(1, int(alt_leverage or 5))
def round_funds(value: float, decimals: int = 2) -> float:
return round(float(value), int(decimals))
def risk_percent_for_storage(mode: str, risk_percent: float) -> Optional[float]:
"""全仓杠杆:库内不写风险百分比(仅 risk_amount U)。"""
if is_full_margin_mode(mode):
return None
return risk_percent
def format_risk_display_text(
mode: str,
risk_percent: Optional[float],
risk_amount: Optional[float],
*,
decimals: int = 2,
) -> str:
"""持仓/通知「风险」文案:全仓仅 U;以损定仓为 %≈U。"""
amt: Optional[float] = None
if risk_amount is not None and risk_amount != "":
try:
amt = float(risk_amount)
except (TypeError, ValueError):
amt = None
if is_full_margin_mode(mode):
if amt is None:
return ""
return f"{round_funds(amt, decimals)}U"
pct: Optional[float] = None
if risk_percent is not None and risk_percent != "":
try:
pct = float(risk_percent)
except (TypeError, ValueError):
pct = None
pct_txt = f"{pct:g}" if pct is not None else ""
amt_txt = round_funds(amt, decimals) if amt is not None else ""
return f"{pct_txt}%≈{amt_txt}U"
def assert_open_source_allowed(mode: str, source: str) -> Tuple[bool, str]:
if not is_full_margin_mode(mode):
return True, ""
src = (source or "").strip().lower()
if src in FULL_MARGIN_BLOCKED_SOURCES:
return False, (
"当前为全仓杠杆模式(POSITION_SIZING_MODE=full_margin),"
"不允许关键位突破/斐波自动开仓、趋势回调与顺势加仓;"
"仅支持实盘人工下单与阻力/支撑提醒。"
)
return True, ""
def full_margin_requires_flat_position(active_count: int) -> Tuple[bool, str]:
if active_count > 0:
return False, "全仓杠杆模式仅允许单仓且无其它持仓,请先平仓后再开仓"
return True, ""
def compute_full_margin_sizing(
*,
symbol: str,
available_usdt: float,
capital_base: float,
buffer_ratio: float,
btc_leverage: int,
alt_leverage: int,
funds_decimals: int = 2,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
if available_usdt is None or float(available_usdt) <= 0:
return None, "全仓杠杆:无法读取合约账户可用保证金"
lev = leverage_for_full_margin(symbol, btc_leverage, alt_leverage)
margin = round_funds(float(available_usdt) * float(buffer_ratio), funds_decimals)
if margin <= 0:
return None, "全仓杠杆:可用保证金不足"
notional = round_funds(margin * lev, funds_decimals)
ratio = round(margin / float(capital_base) * 100, 2) if capital_base else 0.0
return {
"margin_capital": margin,
"leverage": lev,
"notional_value": notional,
"position_ratio": ratio,
"mode": MODE_FULL_MARGIN,
}, None
"""
所共用计仓模式 risk以损定仓| full_margin全仓杠杆
env POSITION_SIZING_MODE 切换须无持仓由部署流程保证
"""
from __future__ import annotations
import os
from typing import Any, Optional, Tuple
MODE_RISK = "risk"
MODE_FULL_MARGIN = "full_margin"
VALID_MODES = frozenset({MODE_RISK, MODE_FULL_MARGIN})
OPEN_SOURCE_MANUAL = "manual"
OPEN_SOURCE_KEY_AUTO = "key_auto"
OPEN_SOURCE_KEY_FIB = "key_fib"
OPEN_SOURCE_KEY_TRIGGER = "key_trigger"
OPEN_SOURCE_TREND = "trend"
OPEN_SOURCE_ROLL = "roll"
FULL_MARGIN_BLOCKED_SOURCES = frozenset(
{OPEN_SOURCE_KEY_AUTO, OPEN_SOURCE_KEY_FIB, OPEN_SOURCE_TREND, OPEN_SOURCE_ROLL}
)
def normalize_position_sizing_mode(raw: Optional[str]) -> str:
v = (raw or MODE_RISK).strip().lower()
if v in ("full", "full_margin", "fullmargin", "全仓", "全仓杠杆"):
return MODE_FULL_MARGIN
return MODE_RISK if v in ("risk", "r", "以损定仓", "") else MODE_RISK
def load_position_sizing_mode(env: Optional[dict] = None) -> str:
e = env if env is not None else os.environ
return normalize_position_sizing_mode(e.get("POSITION_SIZING_MODE"))
def is_full_margin_mode(mode: str) -> bool:
return normalize_position_sizing_mode(mode) == MODE_FULL_MARGIN
def mode_label_zh(mode: str) -> str:
return "全仓杠杆" if is_full_margin_mode(mode) else "以损定仓"
def leverage_for_full_margin(symbol: str, btc_leverage: int, alt_leverage: int) -> int:
sym = (symbol or "").strip().upper()
if sym.startswith("BTC") or sym.startswith("ETH"):
return max(1, int(btc_leverage or 10))
return max(1, int(alt_leverage or 5))
def round_funds(value: float, decimals: int = 2) -> float:
return round(float(value), int(decimals))
def risk_percent_for_storage(mode: str, risk_percent: float) -> Optional[float]:
"""全仓杠杆:库内不写风险百分比(仅 risk_amount U)。"""
if is_full_margin_mode(mode):
return None
return risk_percent
def format_risk_display_text(
mode: str,
risk_percent: Optional[float],
risk_amount: Optional[float],
*,
decimals: int = 2,
) -> str:
"""持仓/通知「风险」文案:全仓仅 U;以损定仓为 %≈U。"""
amt: Optional[float] = None
if risk_amount is not None and risk_amount != "":
try:
amt = float(risk_amount)
except (TypeError, ValueError):
amt = None
if is_full_margin_mode(mode):
if amt is None:
return ""
return f"{round_funds(amt, decimals)}U"
pct: Optional[float] = None
if risk_percent is not None and risk_percent != "":
try:
pct = float(risk_percent)
except (TypeError, ValueError):
pct = None
pct_txt = f"{pct:g}" if pct is not None else ""
amt_txt = round_funds(amt, decimals) if amt is not None else ""
return f"{pct_txt}%≈{amt_txt}U"
def assert_open_source_allowed(mode: str, source: str) -> Tuple[bool, str]:
if not is_full_margin_mode(mode):
return True, ""
src = (source or "").strip().lower()
if src in FULL_MARGIN_BLOCKED_SOURCES:
return False, (
"当前为全仓杠杆模式(POSITION_SIZING_MODE=full_margin),"
"不允许关键位突破/斐波自动开仓、趋势回调与顺势加仓;"
"仅支持实盘人工下单与阻力/支撑提醒。"
)
return True, ""
def full_margin_requires_flat_position(active_count: int) -> Tuple[bool, str]:
if active_count > 0:
return False, "全仓杠杆模式仅允许单仓且无其它持仓,请先平仓后再开仓"
return True, ""
def compute_full_margin_sizing(
*,
symbol: str,
available_usdt: float,
capital_base: float,
buffer_ratio: float,
btc_leverage: int,
alt_leverage: int,
funds_decimals: int = 2,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
if available_usdt is None or float(available_usdt) <= 0:
return None, "全仓杠杆:无法读取合约账户可用保证金"
lev = leverage_for_full_margin(symbol, btc_leverage, alt_leverage)
margin = round_funds(float(available_usdt) * float(buffer_ratio), funds_decimals)
if margin <= 0:
return None, "全仓杠杆:可用保证金不足"
notional = round_funds(margin * lev, funds_decimals)
ratio = round(margin / float(capital_base) * 100, 2) if capital_base else 0.0
return {
"margin_capital": margin,
"leverage": lev,
"notional_value": notional,
"position_ratio": ratio,
"mode": MODE_FULL_MARGIN,
}, None
+229 -229
View File
@@ -1,229 +1,229 @@
"""平仓交易:交易所口径双边成交额与手续费(所共用聚合逻辑)。"""
from __future__ import annotations
from typing import Any, Callable, Optional
def _coerce_ts_ms(raw: Any) -> int | None:
if raw in (None, ""):
return None
try:
v = int(raw)
return v if v > 1_000_000_000_000 else v * 1000
except (TypeError, ValueError):
return None
def quote_turnover_usdt_from_fill(trade: dict, *, contract_size: float = 1.0) -> float:
"""单笔成交的报价币成交额(USDT 口径)。"""
info = trade.get("info") or {}
if not isinstance(info, dict):
info = {}
for key in ("quoteQty", "quote_qty", "fillNotionalUsd", "notional"):
try:
v = float(info.get(key) or 0)
if v > 0:
return abs(v)
except (TypeError, ValueError):
continue
try:
cost = float(trade.get("cost") or 0)
if cost > 0:
return abs(cost)
except (TypeError, ValueError):
pass
try:
price = float(trade.get("price") or 0)
amount = float(trade.get("amount") or 0) * float(contract_size or 1.0)
if price > 0 and amount > 0:
return abs(price * amount)
except (TypeError, ValueError):
pass
return 0.0
def commission_usdt_from_fill(trade: dict) -> float:
"""单笔成交手续费(正数表示成本)。"""
fee = trade.get("fee")
if isinstance(fee, dict):
try:
cost = float(fee.get("cost") or 0)
except (TypeError, ValueError):
cost = 0.0
if cost != 0:
cur = str(fee.get("currency") or "USDT").upper()
if cur in ("USDT", "USD", "BUSD", "USDC"):
return abs(cost)
return abs(cost)
info = trade.get("info") or {}
if isinstance(info, dict):
for key in ("fee", "commission", "fillFee"):
try:
v = float(info.get(key) or 0)
if v != 0:
return abs(v)
except (TypeError, ValueError):
continue
return 0.0
def aggregate_bilateral_stats(
fills: list[dict],
*,
contract_size: float = 1.0,
) -> dict[str, float] | None:
"""双边成交额 = 开+平所有相关 fill 的报价币成交额之和;手续费 = fill fee 之和。"""
if not fills:
return None
turnover = 0.0
commission = 0.0
for t in fills:
turnover += quote_turnover_usdt_from_fill(t, contract_size=contract_size)
commission += commission_usdt_from_fill(t)
if turnover <= 0 and commission <= 0:
return None
return {
"exchange_turnover_usdt": round(turnover, 4),
"exchange_commission_usdt": round(commission, 4),
}
def filter_position_lifecycle_fills(
trades: list[dict],
direction: str,
open_ms: int | None,
close_ms: int | None,
*,
hedge_mode: bool = False,
close_buffer_ms: int = 15 * 60 * 1000,
) -> list[dict]:
"""
持仓生命周期内 fill=开买+平卖=开卖+平买
hedge_mode 时按 posSide direction 过滤
"""
direction = (direction or "long").strip().lower()
open_side = "buy" if direction == "long" else "sell"
close_side = "sell" if direction == "long" else "buy"
allowed_sides = {open_side, close_side}
upper = int(close_ms) + int(close_buffer_ms) if close_ms else None
out: list[dict] = []
for t in trades or []:
side = (t.get("side") or "").lower()
if side not in allowed_sides:
continue
ts = _coerce_ts_ms(t.get("timestamp"))
if ts is None:
continue
if open_ms and ts < int(open_ms) - 60_000:
continue
if upper and ts > upper:
continue
if hedge_mode:
info = t.get("info") or {}
if not isinstance(info, dict):
info = {}
pos_side = (info.get("posSide") or t.get("posSide") or "").lower()
if pos_side in ("long", "short") and pos_side != direction:
continue
out.append(t)
out.sort(key=lambda x: x.get("timestamp") or 0)
return out
def sum_binance_commission_income(entries: list[dict], trade_ids: set[str] | None) -> float | None:
"""Binance income 流水中 COMMISSION 合计(负值取绝对值为成本)。"""
if not entries:
return None
total = 0.0
found = False
for e in entries:
it = (e.get("incomeType") or e.get("income_type") or "").strip()
if it != "COMMISSION":
continue
if trade_ids:
tid = str(e.get("tradeId") or e.get("trade_id") or "").strip()
if tid and tid not in trade_ids:
continue
try:
total += float(e.get("income") or 0)
found = True
except (TypeError, ValueError):
continue
if not found:
return None
return round(abs(total), 4)
def trade_ids_from_fills(fills: list[dict]) -> set[str]:
out: set[str] = set()
for t in fills or []:
info = t.get("info") or {}
if not isinstance(info, dict):
info = {}
for key in ("id", "tradeId", "trade_id"):
raw = t.get(key) if key in t else info.get(key)
if raw is not None and str(raw).strip():
out.add(str(raw).strip())
break
return out
def merge_commission_prefer_income(
fill_commission: float,
income_commission: float | None,
) -> float:
if income_commission is not None and income_commission > 0:
return round(income_commission, 4)
return round(max(fill_commission, 0.0), 4)
def update_trade_record_stats_columns(
conn: Any,
trade_id: int,
turnover_usdt: float | None,
commission_usdt: float | None,
) -> None:
if turnover_usdt is None and commission_usdt is None:
return
conn.execute(
"""
UPDATE trade_records
SET exchange_turnover_usdt = COALESCE(?, exchange_turnover_usdt),
exchange_commission_usdt = COALESCE(?, exchange_commission_usdt)
WHERE id = ?
""",
(turnover_usdt, commission_usdt, int(trade_id)),
)
def attach_exchange_stats_to_trade(
conn: Any,
trade_id: int,
*,
fetch_fills: Callable[[], list[dict]],
contract_size: float = 1.0,
income_commission: float | None = None,
) -> dict[str, float] | None:
"""拉 fill 并写库;仅在新单平仓路径调用。"""
try:
fills = fetch_fills() or []
except Exception:
fills = []
stats = aggregate_bilateral_stats(fills, contract_size=contract_size)
if not stats and income_commission is None:
return None
turnover = stats.get("exchange_turnover_usdt") if stats else None
fill_comm = float(stats.get("exchange_commission_usdt") or 0) if stats else 0.0
commission = merge_commission_prefer_income(fill_comm, income_commission)
update_trade_record_stats_columns(
conn,
trade_id,
turnover,
commission if commission > 0 else None,
)
out = {}
if turnover is not None:
out["exchange_turnover_usdt"] = turnover
if commission > 0:
out["exchange_commission_usdt"] = commission
return out or None
"""平仓交易:交易所口径双边成交额与手续费(所共用聚合逻辑)。"""
from __future__ import annotations
from typing import Any, Callable, Optional
def _coerce_ts_ms(raw: Any) -> int | None:
if raw in (None, ""):
return None
try:
v = int(raw)
return v if v > 1_000_000_000_000 else v * 1000
except (TypeError, ValueError):
return None
def quote_turnover_usdt_from_fill(trade: dict, *, contract_size: float = 1.0) -> float:
"""单笔成交的报价币成交额(USDT 口径)。"""
info = trade.get("info") or {}
if not isinstance(info, dict):
info = {}
for key in ("quoteQty", "quote_qty", "fillNotionalUsd", "notional"):
try:
v = float(info.get(key) or 0)
if v > 0:
return abs(v)
except (TypeError, ValueError):
continue
try:
cost = float(trade.get("cost") or 0)
if cost > 0:
return abs(cost)
except (TypeError, ValueError):
pass
try:
price = float(trade.get("price") or 0)
amount = float(trade.get("amount") or 0) * float(contract_size or 1.0)
if price > 0 and amount > 0:
return abs(price * amount)
except (TypeError, ValueError):
pass
return 0.0
def commission_usdt_from_fill(trade: dict) -> float:
"""单笔成交手续费(正数表示成本)。"""
fee = trade.get("fee")
if isinstance(fee, dict):
try:
cost = float(fee.get("cost") or 0)
except (TypeError, ValueError):
cost = 0.0
if cost != 0:
cur = str(fee.get("currency") or "USDT").upper()
if cur in ("USDT", "USD", "BUSD", "USDC"):
return abs(cost)
return abs(cost)
info = trade.get("info") or {}
if isinstance(info, dict):
for key in ("fee", "commission", "fillFee"):
try:
v = float(info.get(key) or 0)
if v != 0:
return abs(v)
except (TypeError, ValueError):
continue
return 0.0
def aggregate_bilateral_stats(
fills: list[dict],
*,
contract_size: float = 1.0,
) -> dict[str, float] | None:
"""双边成交额 = 开+平所有相关 fill 的报价币成交额之和;手续费 = fill fee 之和。"""
if not fills:
return None
turnover = 0.0
commission = 0.0
for t in fills:
turnover += quote_turnover_usdt_from_fill(t, contract_size=contract_size)
commission += commission_usdt_from_fill(t)
if turnover <= 0 and commission <= 0:
return None
return {
"exchange_turnover_usdt": round(turnover, 4),
"exchange_commission_usdt": round(commission, 4),
}
def filter_position_lifecycle_fills(
trades: list[dict],
direction: str,
open_ms: int | None,
close_ms: int | None,
*,
hedge_mode: bool = False,
close_buffer_ms: int = 15 * 60 * 1000,
) -> list[dict]:
"""
持仓生命周期内 fill=开买+平卖=开卖+平买
hedge_mode 时按 posSide direction 过滤
"""
direction = (direction or "long").strip().lower()
open_side = "buy" if direction == "long" else "sell"
close_side = "sell" if direction == "long" else "buy"
allowed_sides = {open_side, close_side}
upper = int(close_ms) + int(close_buffer_ms) if close_ms else None
out: list[dict] = []
for t in trades or []:
side = (t.get("side") or "").lower()
if side not in allowed_sides:
continue
ts = _coerce_ts_ms(t.get("timestamp"))
if ts is None:
continue
if open_ms and ts < int(open_ms) - 60_000:
continue
if upper and ts > upper:
continue
if hedge_mode:
info = t.get("info") or {}
if not isinstance(info, dict):
info = {}
pos_side = (info.get("posSide") or t.get("posSide") or "").lower()
if pos_side in ("long", "short") and pos_side != direction:
continue
out.append(t)
out.sort(key=lambda x: x.get("timestamp") or 0)
return out
def sum_binance_commission_income(entries: list[dict], trade_ids: set[str] | None) -> float | None:
"""Binance income 流水中 COMMISSION 合计(负值取绝对值为成本)。"""
if not entries:
return None
total = 0.0
found = False
for e in entries:
it = (e.get("incomeType") or e.get("income_type") or "").strip()
if it != "COMMISSION":
continue
if trade_ids:
tid = str(e.get("tradeId") or e.get("trade_id") or "").strip()
if tid and tid not in trade_ids:
continue
try:
total += float(e.get("income") or 0)
found = True
except (TypeError, ValueError):
continue
if not found:
return None
return round(abs(total), 4)
def trade_ids_from_fills(fills: list[dict]) -> set[str]:
out: set[str] = set()
for t in fills or []:
info = t.get("info") or {}
if not isinstance(info, dict):
info = {}
for key in ("id", "tradeId", "trade_id"):
raw = t.get(key) if key in t else info.get(key)
if raw is not None and str(raw).strip():
out.add(str(raw).strip())
break
return out
def merge_commission_prefer_income(
fill_commission: float,
income_commission: float | None,
) -> float:
if income_commission is not None and income_commission > 0:
return round(income_commission, 4)
return round(max(fill_commission, 0.0), 4)
def update_trade_record_stats_columns(
conn: Any,
trade_id: int,
turnover_usdt: float | None,
commission_usdt: float | None,
) -> None:
if turnover_usdt is None and commission_usdt is None:
return
conn.execute(
"""
UPDATE trade_records
SET exchange_turnover_usdt = COALESCE(?, exchange_turnover_usdt),
exchange_commission_usdt = COALESCE(?, exchange_commission_usdt)
WHERE id = ?
""",
(turnover_usdt, commission_usdt, int(trade_id)),
)
def attach_exchange_stats_to_trade(
conn: Any,
trade_id: int,
*,
fetch_fills: Callable[[], list[dict]],
contract_size: float = 1.0,
income_commission: float | None = None,
) -> dict[str, float] | None:
"""拉 fill 并写库;仅在新单平仓路径调用。"""
try:
fills = fetch_fills() or []
except Exception:
fills = []
stats = aggregate_bilateral_stats(fills, contract_size=contract_size)
if not stats and income_commission is None:
return None
turnover = stats.get("exchange_turnover_usdt") if stats else None
fill_comm = float(stats.get("exchange_commission_usdt") or 0) if stats else 0.0
commission = merge_commission_prefer_income(fill_comm, income_commission)
update_trade_record_stats_columns(
conn,
trade_id,
turnover,
commission if commission > 0 else None,
)
out = {}
if turnover is not None:
out["exchange_turnover_usdt"] = turnover
if commission > 0:
out["exchange_commission_usdt"] = commission
return out or None