refactor: 移除 gate_bot,统一为三所架构并更新文档
删除 crypto_monitor_gate_bot 目录,中控与子代理改为 binance/okx/gate 三账户; 文档与 UI 文案「四所」改为「三所」;新增清库前一次性配置备份脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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'))} "
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1574
-1574
File diff suppressed because it is too large
Load Diff
+572
-572
File diff suppressed because it is too large
Load Diff
+269
-269
@@ -1,269 +1,269 @@
|
||||
/**
|
||||
* 四所实例共用 UI:复盘详情、盈亏着色等。
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,314 +1,314 @@
|
||||
/**
|
||||
* 交易日历组件:内照明心档案 + 四所统计分析共用。
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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,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,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 = (
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+252
-252
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
"""策略交易记录页:已结束趋势 / 顺势加仓快照(四所统一)。"""
|
||||
"""策略交易记录页:已结束趋势 / 顺势加仓快照(三所统一)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""策略结束快照:趋势回调 / 顺势加仓(四所共用)。"""
|
||||
"""策略结束快照:趋势回调 / 顺势加仓(三所共用)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,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,4 +1,4 @@
|
||||
"""账户冷静期 / 日冻结风控(四所实例共用)。"""
|
||||
"""账户冷静期 / 日冻结风控(三所实例共用)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
+140
-140
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user