做交易的人大概都有过这种时刻:挂了单就离开电脑去倒水,回来一看,止损早就触发了,单子成交了半小时,而你一无所知。或者反过来——人在外面,用手机瞄一眼行情,却不知道电脑上的仓位发生了什么。

TradingView 网页端本身没有「持仓变动推送到手机」的能力(通知邮件又慢又容易淹没在收件箱里),所以我写了一个油猴(Tampermonkey)脚本:实时监听 TradingView 页面里的持仓变化和订单成交通知,通过 Bark 推送到 iPhone,从事件发生到手机响铃,通常只要一两秒。

它能推送什么

脚本在页面里做两件事:

  1. 持仓监听:精确匹配 Positions 表格的表头(Symbol / Side / Qty / Position ID),检测三类事件——新建持仓、加仓/减仓、全部平仓。平仓需要连续 3 次轮询确认才推送,所以你只是切到 Orders 或 Account 选项卡看一眼,它绝不会误报「已平仓」。
  2. 成交通知:监听页面原生的 Toast 弹窗,捕获 Order filled、止损/止盈触发、订单被拒等事件,原文推送到手机。

推送效果大概是这样:

  • ⚡️ 新建持仓 (Open):ES1! Long 2 手 @ 5234.50
  • ⚡️ 持仓手数变动:ES1!: 从 2 手变更为 3 手 (加仓 +1)
  • 🛑 仓位已平仓 (Flat):ES1! Long 2 手 已全部平仓
  • 🚨 订单/成交通知:Order filled ...

准备工作

只需要两样东西:

  1. iPhone 上安装 Bark(App Store 免费应用)。打开 App 后复制你的专属 Key——形如 XXXXXXXXXXXXXXXX,这就是你的推送地址凭证。
  2. 浏览器安装 Tampermonkey 扩展(Chrome / Edge / Firefox 都有)。

安装脚本

  1. 复制本文末尾「完整脚本」代码块里的全部内容。
  2. 点击浏览器工具栏的 Tampermonkey 图标 → 「添加新脚本」(Create a new script)。
  3. 删掉编辑器里的示例代码,粘贴刚才复制的内容,Ctrl/Cmd + S 保存。
  4. 打开 tradingview.com 的任意图表页,右下角会出现一个「TV 推送监控」浮窗——脚本已经在运行了。

配置 Bark Key(不用改一行代码)

这是我最推荐的部分:你不需要打开脚本源码改配置。

  1. 打开 TradingView 图表页,浮窗上会显示「Bark Key 未配置,请填写」。
  2. 把 Bark App 里的 Key 粘贴进输入框,点击「测试发送」(或直接回车)。
  3. 手机收到测试推送 = 配置成功,Key 自动保存,输入框收起,显示「已配置 · XXXX…XXX」。

之后换电脑、重装脚本都不用重新配——Key 存在 Tampermonkey 的存储里,跟着浏览器账号走。

如果你更喜欢硬编码,也可以打开脚本把顶部的 const BARK_KEY = "YOUR_BARK_KEY_HERE" 改成你的 Key,效果一样(浮窗里保存的优先级更高)。

日常使用

浮窗本身就是一个控制面板:

  • 绿点 = 监控中,灰点 = 已暂停。点「推送监控」开关可以一键暂停/恢复全部监听与推送——比如周末复盘的时候不想被骚扰,关掉即可,状态会记住。
  • 紧急消息开关:开启后推送走 critical 级别,可以突破 iPhone 的专注模式/免打扰,音量拉满。盘中挂着止损单的时候建议开着,睡觉也不会漏掉;平时可以关掉用普通通知。
  • 浮窗可以拖拽(拖标题栏)、折叠(点 –),位置和折叠状态都会记住。
  • 万一浮窗找不到了(窗口缩小把它挤出了屏幕之类),打开 Tampermonkey 菜单,点「📍 复位浮窗到右下角」一键找回。实际上脚本每秒都会校验位置并自动拉回可视区,图表全屏时它也依然可见,这个菜单只是最后的保险。

几个细节说明

  • 脚本只监听底部面板激活状态的 Positions 选项卡,切到 Orders / Account 不会误报。
  • 推送走 Bark 官方服务器 api.day.app;如果你自建了 Bark 服务端,把脚本顶部的 BARK_SERVER 改成自己的地址即可。
  • 脚本用 Shadow DOM 隔离了样式,不会和 TradingView 的界面互相干扰;输入框做了特殊处理,不会被 TradingView 的图表快捷键拦截。
  • 免费声明:依赖 TradingView 的页面 DOM 结构,如果 TradingView 大改版导致表格匹配失效,监听会安静地停止而不是误报——遇到这种情况等脚本更新就好。

完整脚本

点击代码块右上角的复制按钮,然后按上面「安装脚本」的步骤操作即可。当前版本 v3.4.2(仓库 v0.2.2)。

// ==UserScript==
// @name         TradingView 交易事件实时推送 (Bark) v3.1
// @namespace    https://finnapps.win/
// @version      3.4.2
// @description  精确监听持仓手数(Qty)变化与订单成交,彻底杜绝切Tab误报平仓与浮盈跳动;浮窗内可直接配置 Bark Key 并测试发送,支持紧急/普通消息切换,一键暂停全部监听与推送;浮窗永不丢失(自动拉回可视区、全屏可见、菜单可复位)
// @author       Finn & AA Bot
// @match        https://*.tradingview.com/chart/*
// @match        https://*.tradingview.com/
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @connect      api.day.app
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    // ================= 配置区域 =================
    // BARK_KEY 可保持占位符不动,安装后在浮窗中填写并测试保存
    const BARK_KEY = "YOUR_BARK_KEY_HERE";
    const BARK_SERVER = "https://api.day.app";
    const BARK_SOUND = "alarm";
    const CRITICAL_ALERT = true; // 「紧急消息」开关的默认值,可在浮窗中切换
    // ============================================

    const BARK_KEY_STORE = "barkKey";
    const CRITICAL_STORE = "criticalAlert";

    // 生效的 Key:浮窗保存的优先,其次回退到脚本顶部硬编码的
    function getBarkKey() {
        const stored = GM_getValue(BARK_KEY_STORE, "");
        if (stored) return stored;
        return BARK_KEY && BARK_KEY !== "YOUR_BARK_KEY_HERE" ? BARK_KEY : "";
    }

    function maskKey(key) {
        return key.length > 8 ? `${key.slice(0, 4)}…${key.slice(-3)}` : "已保存";
    }

    let criticalAlert = GM_getValue(CRITICAL_STORE, CRITICAL_ALERT);

    function setCriticalAlert(on) {
        criticalAlert = on;
        GM_setValue(CRITICAL_STORE, on);
        updatePanelUI();
        console.log(`[TV-Push] 推送级别已切换为${on ? "紧急(critical)" : "普通"}`);
    }

    // done(ok) 可选:传入时用于测试发送的结果回调,常规推送走 console 日志
    function sendBark(key, title, body, critical, done) {
        let url = `${BARK_SERVER}/${key}/${encodeURIComponent(title)}/${encodeURIComponent(body)}?sound=${BARK_SOUND}&group=TradingView`;
        if (critical) url += "&level=critical&volume=10";

        GM_xmlhttpRequest({
            method: "GET",
            url: url,
            timeout: 5000,
            onload: (res) => {
                const ok = res.status >= 200 && res.status < 300;
                if (done) {
                    done(ok);
                } else if (ok) {
                    console.log("[TV-Push] 推送成功:", title, body);
                } else {
                    console.error("[TV-Push] 推送异常:", title, res.status);
                }
            },
            onerror: (err) => {
                if (done) done(false);
                else console.error("[TV-Push] 推送失败:", err);
            }
        });
    }

    function pushToBark(title, body) {
        const key = getBarkKey();
        if (!key) return;
        sendBark(key, title, body, criticalAlert, null);
    }

    let lastPositions = {};
    let initialized = false;
    let emptyConfirmCount = 0; // 平仓确认计数器

    // ================= 浮窗控制面板(可拖拽 / 可折叠) =================
    const PANEL_ID = "tv-bark-float-panel";
    const STORAGE_KEY = "monitorEnabled";
    const PANEL_POS_KEY = "floatPanelPos";
    const PANEL_COLLAPSED_KEY = "floatPanelCollapsed";
    let monitorEnabled = GM_getValue(STORAGE_KEY, true);
    let panelHost = null;
    let panelEls = null;
    let panelCollapsed = GM_getValue(PANEL_COLLAPSED_KEY, false);
    let keySavedAt = 0; // Key 测试成功保存的时间戳,让成功提示在浮窗多停留几秒

    // 开关状态变化后重置基准:重新开启时以当时持仓重新记录,
    // 避免恢复监控的瞬间把已有仓位误报成"新建持仓"
    function resetMonitoringState() {
        lastPositions = {};
        initialized = false;
        emptyConfirmCount = 0;
    }

    function setMonitorEnabled(enabled) {
        monitorEnabled = enabled;
        GM_setValue(STORAGE_KEY, enabled);
        resetMonitoringState();
        updatePanelUI();
        console.log(`[TV-Push] 推送监控已${enabled ? "开启" : "关闭"}`);
    }

    // 用 Shadow DOM 隔离样式,避免与 TradingView 页面 CSS 互相干扰
    function buildPanel() {
        const host = document.createElement("div");
        host.id = PANEL_ID;
        Object.assign(host.style, { position: "fixed", zIndex: "2147483647" });

        // 恢复上次拖放位置,默认停靠右下角;越界拉回由 clampPanelIntoView 在挂载后处理
        const saved = GM_getValue(PANEL_POS_KEY, null);
        host.style.left = `${saved && Number.isFinite(saved.left) ? saved.left : window.innerWidth - 230}px`;
        host.style.top = `${saved && Number.isFinite(saved.top) ? saved.top : window.innerHeight - 190}px`;

        const root = host.attachShadow({ mode: "open" });
        root.innerHTML = `
        <style>
            .panel {
                pointer-events: auto;
                min-width: 190px;
                background: rgba(28, 32, 36, 0.96);
                border: 1px solid rgba(120, 128, 138, 0.45);
                border-radius: 10px;
                box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
                color: #d1d5db;
                user-select: none;
                overflow: hidden;
            }
            .header {
                display: flex;
                align-items: center;
                gap: 7px;
                padding: 7px 10px;
                cursor: move;
                background: rgba(255, 255, 255, 0.04);
                border-bottom: 1px solid rgba(120, 128, 138, 0.25);
                font-size: 12px;
                font-weight: 600;
            }
            .title { flex: 1; }
            .dot {
                width: 8px; height: 8px; border-radius: 50%;
                flex-shrink: 0; background: #8a8a8a;
            }
            .dot.on { background: #26a65b; box-shadow: 0 0 6px rgba(38, 166, 91, 0.8); }
            .min-btn {
                border: none; background: transparent; color: #9aa0a6;
                font-size: 14px; line-height: 1; cursor: pointer;
                padding: 0 5px; border-radius: 4px;
            }
            .min-btn:hover { color: #fff; background: rgba(255, 255, 255, 0.1); }
            .body {
                padding: 10px 12px;
                display: flex; flex-direction: column; gap: 8px;
            }
            .row {
                display: flex; align-items: center;
                justify-content: space-between; gap: 12px;
            }
            .label { font-size: 12px; }
            .status { font-size: 11px; color: #9aa0a6; }
            .status.on { color: #6fbf8a; }
            .switch { position: relative; width: 34px; height: 20px; flex-shrink: 0; display: inline-block; }
            .switch input { opacity: 0; width: 0; height: 0; position: absolute; }
            .slider {
                position: absolute; inset: 0;
                background: #4b5259; border-radius: 20px;
                cursor: pointer; transition: background 0.18s;
            }
            .slider::before {
                content: ""; position: absolute;
                width: 14px; height: 14px; left: 3px; top: 3px;
                background: #fff; border-radius: 50%;
                transition: transform 0.18s;
            }
            .switch input:checked + .slider { background: #26a65b; }
            .switch input:checked + .slider::before { transform: translateX(14px); }
            .key-section {
                display: flex; flex-direction: column; gap: 6px;
            }
            .test-btn {
                border: none; border-radius: 6px; cursor: pointer;
                background: #26a65b; color: #fff;
                font-size: 12px; font-weight: 600;
                padding: 5px 0; width: 100%;
            }
            .test-btn:hover { background: #2f7a4f; }
            .test-btn:disabled { background: #4b5259; cursor: default; }
            .key-msg { font-size: 11px; color: #9aa0a6; min-height: 14px; }
            .key-msg.ok { color: #6fbf8a; }
            .key-msg.err { color: #e06c75; }
            .key-status { font-size: 11px; color: #6fbf8a; }
        </style>
        <div class="panel">
            <div class="header">
                <span class="dot on"></span>
                <span class="title">TV 推送监控</span>
                <button class="min-btn" type="button" title="折叠">–</button>
            </div>
            <div class="body">
                <div class="row">
                    <span class="label">推送监控</span>
                    <label class="switch">
                        <input type="checkbox" class="monitor-toggle">
                        <span class="slider"></span>
                    </label>
                </div>
                <div class="row">
                    <span class="label">紧急消息</span>
                    <label class="switch">
                        <input type="checkbox" class="critical-toggle">
                        <span class="slider"></span>
                    </label>
                </div>
                <div class="row key-status-row">
                    <span class="label">Bark Key</span>
                    <span class="key-status">已配置</span>
                </div>
                <div class="key-section">
                    <div class="label">Bark Key 未配置,请填写:</div>
                    <slot></slot>
                    <button type="button" class="test-btn" hidden>测试发送</button>
                    <div class="key-msg"></div>
                </div>
                <div class="status main-status">监控中</div>
            </div>
        </div>`;

        // Bark Key 输入框必须放在 light DOM(host 的直接子节点,经上面的 <slot>
        // 渲染进面板):若放进 shadow DOM,按键事件会被重定向成 host <div>,
        // TradingView 的快捷键守卫(按 e.target 是否 input/textarea 判断)识别
        // 不到输入框,会把按键当成图表热键 preventDefault,导致无法打字/输入法失效
        const keyInput = document.createElement("input");
        keyInput.type = "text";
        keyInput.placeholder = "粘贴你的 Bark Key";
        keyInput.autocomplete = "off";
        keyInput.spellcheck = false;
        Object.assign(keyInput.style, {
            boxSizing: "border-box",
            width: "100%",
            display: "block",
            background: "rgba(0, 0, 0, 0.35)",
            border: "1px solid rgba(120, 128, 138, 0.45)",
            borderRadius: "6px",
            color: "#e5e7eb",
            fontSize: "12px",
            fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
            padding: "5px 8px",
            outline: "none",
            userSelect: "text",
            cursor: "text"
        });
        keyInput.addEventListener("focus", () => { keyInput.style.borderColor = "#26a65b"; });
        keyInput.addEventListener("blur", () => { keyInput.style.borderColor = "rgba(120, 128, 138, 0.45)"; });
        host.appendChild(keyInput);

        const panel = root.querySelector(".panel");
        const dot = root.querySelector(".dot");
        const minBtn = root.querySelector(".min-btn");
        const body = root.querySelector(".body");
        const toggle = root.querySelector(".monitor-toggle");
        const criticalToggle = root.querySelector(".critical-toggle");
        const status = root.querySelector(".main-status");
        const keyStatusRow = root.querySelector(".key-status-row");
        const keyStatus = root.querySelector(".key-status");
        const keySection = root.querySelector(".key-section");
        const testBtn = root.querySelector(".test-btn");
        const keyMsg = root.querySelector(".key-msg");

        // 阻止事件冒泡到 TradingView,避免误触发图表/面板/快捷键逻辑
        panel.addEventListener("mousedown", (e) => e.stopPropagation());
        panel.addEventListener("click", (e) => e.stopPropagation());
        panel.addEventListener("keydown", (e) => e.stopPropagation());

        toggle.addEventListener("change", () => setMonitorEnabled(toggle.checked));
        criticalToggle.addEventListener("change", () => setCriticalAlert(criticalToggle.checked));

        // 输入 Key 后才出现「测试发送」按钮;回车等同点击
        keyInput.addEventListener("input", () => {
            testBtn.hidden = !keyInput.value.trim();
            keyMsg.className = "key-msg";
            keyMsg.textContent = "";
        });
        keyInput.addEventListener("keydown", (e) => {
            if (e.key === "Enter" && !testBtn.hidden && !testBtn.disabled) testBtn.click();
        });
        testBtn.addEventListener("click", () => {
            const key = keyInput.value.trim();
            if (!key || testBtn.disabled) return;
            testBtn.disabled = true;
            keyMsg.className = "key-msg";
            keyMsg.textContent = "发送中…";
            sendBark(key, "✅ Bark 测试消息", criticalAlert ? "紧急推送通道已打通" : "普通推送通道已打通", criticalAlert, (ok) => {
                testBtn.disabled = false;
                if (ok) {
                    GM_setValue(BARK_KEY_STORE, key);
                    keySavedAt = Date.now();
                    keyInput.value = "";
                    testBtn.hidden = true;
                    keyMsg.className = "key-msg ok";
                    keyMsg.textContent = "✅ 测试成功,Key 已保存";
                    console.log("[TV-Push] Bark Key 已通过测试并保存");
                } else {
                    keyMsg.className = "key-msg err";
                    keyMsg.textContent = "❌ 发送失败,请检查 Key 与网络后重试";
                }
            });
        });

        function applyCollapsed() {
            body.style.display = panelCollapsed ? "none" : "";
            minBtn.textContent = panelCollapsed ? "+" : "–";
            minBtn.title = panelCollapsed ? "展开" : "折叠";
        }
        minBtn.addEventListener("click", (e) => {
            e.stopPropagation();
            panelCollapsed = !panelCollapsed;
            GM_setValue(PANEL_COLLAPSED_KEY, panelCollapsed);
            applyCollapsed();
        });
        applyCollapsed();

        // 拖拽:按住标题栏移动,松手后记住位置
        const header = root.querySelector(".header");
        header.addEventListener("mousedown", (e) => {
            if (e.target === minBtn) return;
            e.stopPropagation();
            e.preventDefault();
            const offX = e.clientX - host.getBoundingClientRect().left;
            const offY = e.clientY - host.getBoundingClientRect().top;
            const onMove = (ev) => {
                const rect = host.getBoundingClientRect();
                const x = Math.min(Math.max(0, ev.clientX - offX), Math.max(0, window.innerWidth - rect.width));
                const y = Math.min(Math.max(0, ev.clientY - offY), Math.max(0, window.innerHeight - rect.height));
                host.style.left = `${x}px`;
                host.style.top = `${y}px`;
            };
            const onUp = () => {
                document.removeEventListener("mousemove", onMove);
                document.removeEventListener("mouseup", onUp);
                GM_setValue(PANEL_POS_KEY, {
                    left: parseFloat(host.style.left),
                    top: parseFloat(host.style.top)
                });
            };
            document.addEventListener("mousemove", onMove);
            document.addEventListener("mouseup", onUp);
        });

        panelEls = { dot, toggle, criticalToggle, status, keyStatusRow, keyStatus, keySection, keyInput, testBtn, keyMsg, applyCollapsed };
        return host;
    }

    // 把浮窗拉回可视区内:窗口缩小/换屏幕后,保存的旧坐标可能已越界,
    // 每秒轮询时校验,保证浮窗永远找得到
    function clampPanelIntoView() {
        if (!panelHost) return;
        const rect = panelHost.getBoundingClientRect();
        const maxX = Math.max(0, window.innerWidth - rect.width);
        const maxY = Math.max(0, window.innerHeight - rect.height);
        const x = Math.min(Math.max(0, parseFloat(panelHost.style.left) || 0), maxX);
        const y = Math.min(Math.max(0, parseFloat(panelHost.style.top) || 0), maxY);
        if (`${x}px` !== panelHost.style.left) panelHost.style.left = `${x}px`;
        if (`${y}px` !== panelHost.style.top) panelHost.style.top = `${y}px`;
    }

    // TradingView 重渲染可能把浮窗从 DOM 摘掉,轮询时确保在场;
    // 全屏模式下挂到全屏元素内,否则会被全屏层整个盖住导致"失踪"
    function ensurePanelAttached() {
        if (!panelHost) panelHost = buildPanel();
        const container = document.fullscreenElement || document.body;
        if (!panelHost.isConnected || panelHost.parentElement !== container) {
            container.appendChild(panelHost);
            updatePanelUI();
        }
        clampPanelIntoView();
    }

    // 逃生口:浮窗万一找不到,从 Tampermonkey 菜单一键复位到右下角
    GM_registerMenuCommand("📍 复位浮窗到右下角", () => {
        GM_setValue(PANEL_POS_KEY, null);
        if (panelHost) {
            panelHost.style.left = `${window.innerWidth - 230}px`;
            panelHost.style.top = `${window.innerHeight - 190}px`;
            clampPanelIntoView();
            if (panelCollapsed) {
                panelCollapsed = false;
                GM_setValue(PANEL_COLLAPSED_KEY, false);
                panelEls.applyCollapsed();
            }
        }
        console.log("[TV-Push] 浮窗已复位到右下角");
    });

    function updatePanelUI() {
        if (!panelEls) return;
        panelEls.toggle.checked = monitorEnabled;
        panelEls.criticalToggle.checked = criticalAlert;
        panelEls.dot.className = "dot " + (monitorEnabled ? "on" : "off");
        panelEls.status.className = "status main-status " + (monitorEnabled ? "on" : "off");
        panelEls.status.textContent = monitorEnabled
            ? `监控中 · 跟踪 ${Object.keys(lastPositions).length} 个仓位`
            : "已暂停 · 不监听不推送";

        // Key 已配置则收起输入区(测试成功后多展示 4 秒提示),改为显示已配置状态
        const key = getBarkKey();
        const showKeyConfig = !key || Date.now() - keySavedAt < 4000;
        panelEls.keySection.style.display = showKeyConfig ? "" : "none";
        panelEls.keyStatusRow.style.display = key ? "" : "none";
        if (key) panelEls.keyStatus.textContent = `已配置 · ${maskKey(key)}`;
    }
    // ================================================================

    // --- 机制一:精确监听持仓数量(Qty)变动 ---
    function inspectPositionsTable() {
        // 先检查底部当前是否真正激活了 Positions 选项卡,避免切到 Orders 或 Account 误触发
        const activeTab = document.querySelector('button[aria-selected="true"], [data-name="tab-item"][data-active="true"]');
        const activeTabText = activeTab ? activeTab.innerText : '';
        
        // 查找所有 table
        const tables = document.querySelectorAll('table');
        let currentPositions = {};
        let foundPosTable = false;

        tables.forEach(table => {
            const thTexts = Array.from(table.querySelectorAll('th')).map(th => th.innerText.trim());
            // 必须严格匹配 Positions 表格专属表头
            if (thTexts.includes('Symbol') && thTexts.includes('Side') && thTexts.includes('Qty') && thTexts.includes('Position ID')) {
                foundPosTable = true;
                const rows = table.querySelectorAll('tbody tr, tr');
                rows.forEach(tr => {
                    const tds = Array.from(tr.querySelectorAll('td')).map(td => td.innerText.trim());
                    if (tds.length >= 4) {
                        const symbol = tds[0];
                        const side = tds[1];
                        const qty = parseInt(tds[2], 10);
                        const avgPrice = tds[3];

                        if (symbol && !isNaN(qty) && qty > 0) {
                            currentPositions[symbol] = { side, qty, avgPrice };
                        }
                    }
                });
            }
        });

        // 如果连持仓表都没找到(说明切到了别的Tab或底栏被收起),直接退出,绝不误判平仓
        if (!foundPosTable) {
            emptyConfirmCount = 0;
            return;
        }

        // 首次加载仅记录基准
        if (!initialized) {
            lastPositions = currentPositions;
            initialized = true;
            return;
        }

        // 1. 检查新增或手数变动的仓位
        for (const [sym, cur] of Object.entries(currentPositions)) {
            const prev = lastPositions[sym];
            if (!prev) {
                // 新建仓位
                pushToBark("⚡️ 新建持仓 (Open)", `${sym} ${cur.side} ${cur.qty} 手 @ ${cur.avgPrice}`);
            } else if (prev.qty !== cur.qty || prev.side !== cur.side) {
                // 手数变化(加仓/减仓)
                const diff = cur.qty - prev.qty;
                const action = diff > 0 ? `加仓 +${diff}` : `减仓 ${diff}`;
                pushToBark("⚡️ 持仓手数变动", `${sym}: 从 ${prev.qty} 手变更为 ${cur.qty} 手 (${action})`);
            }
        }

        // 2. 检查平仓(需连续 3 次轮询都确认无仓位,且当前持仓表明确在场)
        const hasPrev = Object.keys(lastPositions).length > 0;
        const hasCur = Object.keys(currentPositions).length > 0;

        if (hasPrev && !hasCur) {
            emptyConfirmCount++;
            if (emptyConfirmCount >= 3) { // 连续 3 秒确认真的平了
                for (const [sym, prev] of Object.entries(lastPositions)) {
                    pushToBark("🛑 仓位已平仓 (Flat)", `${sym} ${prev.side} ${prev.qty} 手 已全部平仓`);
                }
                lastPositions = {};
                emptyConfirmCount = 0;
            }
        } else {
            emptyConfirmCount = 0;
            lastPositions = currentPositions;
        }
    }

    // --- 机制二:原生 Toast 浮窗监听 ---
    const orderKeywords = [
        "Order filled", "Order executed", "Bracket order", 
        "Stop loss triggered", "Take profit triggered", "Order rejected",
        "订单已成交", "订单已执行", "已触及止损", "已触及止盈"
    ];
    const toastSentHistory = new Set();

    const observer = new MutationObserver((mutations) => {
        // 总开关关闭时彻底不监听:持仓轮询与 Toast 成交推送全部暂停
        if (!monitorEnabled) return;
        inspectPositionsTable();
        for (const m of mutations) {
            for (const node of m.addedNodes) {
                if (node.nodeType === Node.ELEMENT_NODE) {
                    const t = node.innerText || '';
                    if (t.length > 5 && t.length < 200) {
                        for (const kw of orderKeywords) {
                            if (t.includes(kw)) {
                                if (!toastSentHistory.has(t)) {
                                    toastSentHistory.add(t);
                                    setTimeout(() => toastSentHistory.delete(t), 5000);
                                    pushToBark("🚨 订单/成交通知", t.trim());
                                }
                                break;
                            }
                        }
                    }
                }
            }
        }
    });

    observer.observe(document.body, { childList: true, subtree: true });
    setInterval(() => {
        ensurePanelAttached();
        if (monitorEnabled) inspectPositionsTable();
        updatePanelUI();
    }, 1000);

    console.log(`[TV-Push v3.4.2] 精确防误报监听器已启动(推送监控: ${monitorEnabled ? "开" : "关"},Bark Key: ${getBarkKey() ? "已配置" : "未配置"};浮窗丢失时可从 Tampermonkey 菜单复位)`);
})();