macOS 菜单栏行情终端:垂直做深「行情监控 + 趋势可视化 + 告警 + 交易联动」。 目标:这个垂类里世界第一优雅。
| # | 问题 | 严重度 | 2.0 的解法 |
|---|---|---|---|
| 1 | K 线频道订阅在错误的 endpoint。OKX 于 2023-06-20 将 candle* 频道迁移至 wss://ws.okx.com:8443/ws/v5/business,1.x 仍订阅 /public,服务端直接报错 → 图表永远 "No Data"(.trae/specs/fix-no-data-charts 治标未治本) |
致命 | 双 socket 架构:public(tickers/books5)+ business(candle*),由 MarketHub 统一复用 |
| 2 | 没有历史回填。K 线只靠 WS 增量推送,启动后要等很久才有图 | 致命 | 启动即 REST GET /api/v5/market/candles 回填 ≤300 根,WS 增量按 ts 合并 |
| 3 | 心跳方向写反。OKX 要求客户端在空闲 <30s 时主动发 "ping"、等 "pong";1.x 却在等服务端 ping 再回 pong → 空闲 30s 必掉线 |
致命 | 空闲 18s 主动 ping,10s 未见任何消息判死重连(指数退避 + 抖动 + 自动重订阅) |
| 4 | 假连接状态:resume() 后立即置 connected;isConnected 永远 true;重试计数永不复位 |
高 | 真实状态机 idle→connecting→connected→degraded,UI 有连接指示灯 |
| 5 | 24h 涨跌幅从未赋值(priceChange24h 恒 0),UI 显示空字符串 |
高 | 从 ticker 的 open24h/last 计算,涨跌用色 + 箭头 |
| 6 | 菜单栏是纯文本 `BTC 62213 | CPU 12%`,无趋势、无颜色、无 sparkline —— 与「显示趋势」的核心需求相悖 | 高 |
| 7 | hover 用 transient NSPopover + 全局事件监视器 hack,抢焦点、关不掉、体验糟 | 高 | 非激活 NSPanel(不抢焦点),进出带宽限时 + 渐隐动画,点击可钉住 |
| 8 | CPU/MEM/NET 与行情揉在一起,定位涣散 | 中 | 全部移除,专注行情垂类 |
| 9 | 模型带 let id = UUID() 且参与 Equatable → 每帧全量 diff,SwiftUI 无效重绘 |
中 | Candle 以 ts 为身份,值语义严格 |
| 10 | “测试”是 assert 的静态函数枚举,release 下 assert 是空操作;UI 测试是空壳;无 E2E | 高 | swift-testing 单测(fixture 驱动)+ maystock-e2e 真实连 OKX 的端到端驱动 + make verify |
| 11 | 无通知、无告警、无交易能力 | — | AlertEngine + UNUserNotification + OKX 官方 CLI 交易桥 |
| 12 | JSONSerialization 手工挖字段、魔法数散落、Timer 轮询 0.5s 重画菜单栏 | 中 | 全 Codable 强类型解码;事件驱动 + 10Hz 合并节流 |
菜单栏(每个标的一个 item,可配置样式)
┌──────────────────────────────┐
│ ₿ 118,234 ↑1.2% ▁▂▃▅▆▇ │ ← 等宽数字 + 涨跌色 + sparkline
└──────────────────────────────┘
│ hover(150ms 后浮现,不抢焦点;点击=钉住)
▼
┌───────────────────────────────────────────┐
│ BTC-USDT 118,234.5 ↑ +1.24% 24h │ ● live
│ ┌───────────────────────────────────────┐ │
│ │ K线 / 折线 / 深度 / 成交量 · 1m…1D │ │ ← Canvas 高帧渲染
│ │ MA20 · 最新价虚线 · 十字光标读数 │ │
│ └───────────────────────────────────────┘ │
│ 24h高 119,102 低 116,880 量 12.4K BTC │
│ 买一 118,234.4 │ 卖一 118,234.5 价差 0.1 │
│ ⚑ 告警: >120,000 · +添加当前价告警 │
│ DEMO 多 0.0142 +12.40 (+1.24%) 工作台 → │
└───────────────────────────────────────────┘
2.1 起面板不再有手动买卖按键:下单一律经由策略工作台按信号自动执行, 面板只呈现当前模拟盘/实盘的仓位与收益率。详见 STRATEGY.md。
- 多标的:任意 OKX 现货/永续 instId(BTC-USDT 为默认练手标的)。
- 更新频率:tickers/books5 推送 ~100ms 级;菜单栏渲染合并节流至 10Hz;sparkline 1s 采样。
- 右键菜单:打开终端、本标的行情、策略、交易环境(模拟盘 / 实盘,实盘未解锁时置灰)、账户与连接、关于、退出。
- 终端窗口(2.2 起,取代分开的「设置」与「策略工作台」两个窗口):侧栏六页——总览 / 行情 / 策略 / 告警 / 账户与连接 / 设置; 每页顶部一条环境栏:当前账户、连接状态、权益、急停。
┌────────────────────────── MayStock.app (macOS 15+, AppKit+SwiftUI) ─────────────────────────┐
│ StatusItemController ── SparklineRenderer(CG) │
│ HoverPanelController(NSPanel .nonactivating) ── PanelRootView(SwiftUI) │
│ Charts: CandleChart · DepthChart · LineChart · VolumeStrip (全部 Canvas 自绘) │
│ TerminalWindow(NavigationSplitView): Overview · Markets · Strategies · Alerts · Account · Settings │
│ UISnapshotter: 离屏真实窗口渲染每个界面 → PNG(`make.sh snapshot`) │
│ NotificationService(UNUserNotificationCenter, bundle-guarded) │
└──────────────△──────────────────────────────────────────────────────────────────────────────┘
│ @Observable (InstrumentSession / ConfigStore / AlertCenter)
┌──────────────┴────────────── MayStockKit(纯 Foundation,Linux 可编译)────────────────────────┐
│ MarketHub ── OKXWSClient ×2 (public/business, actor) │
│ ── OKXRESTClient (candles 回填 / books / instruments / ticker) │
│ ── OKXWireDecoder (强类型 Codable) │
│ InstrumentSession: ticker · candles[bar] · book · SparklineBuffer(ring) │
│ AlertEngine: 规则求值(去抖/冷却/自动重挂) → AlertEvent │
│ TradeBridge: 官方 okx CLI (Agent Trade Kit) 子进程封装, --json / --demo │
│ SchwabBridge: 自研 schwabctl(Rust)子进程封装, --json / --live;SchwabVenue + ShadowBook │
│ ConfigStore: 版本化 JSON (v2, 自动迁移 v1, 丢弃 cpu/mem/net 项) │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
关键决策
- Kit 与 App 分层:MayStockKit 不依赖 AppKit,可在 Linux/CI 编译测试 —— E2E 驱动
maystock-e2e直接复用同一套引擎,「测试的就是线上跑的代码」。 - 行情按交易所走端口:
MarketHub每家交易所各持一个MarketFeed(实时推送)和一个MarketDataSource(历史、元数据、搜索),按自选项的venue路由,自己不认识任何一家的协议。OKX 是两条共享的 WebSocket(public 与 business 各一条,订阅表由 feed 维护,重连后自动重放);美股是轮询嘉信的/marketdata/v1/quotes(所有自选一次批量,交易时段每 3 秒、休市每 60 秒),schwabctl没登录时同一个 feed 自动改读 Yahoo Finance 的图表接口,并以MarketFeedEvent.source报出当前来源——页脚与账户页显示的是 feed 说的名字,不是 venue 的设计名。Ticker只有一种形状,涨跌的基准(24 小时前 / 昨收)和会话阶段随行情走,界面从上面读标签,不写死「24h」。 - 数据正确性:K 线以
ts为主键 replace-or-append;未确认 K 线(confirm=0)实时刷新;REST 回填与 WS 增量在同一 actor 内合并,无竞态。 - 密钥不归 App 保管,发单只有一条封闭的路:OKX 的 API Key 存在官方
okxCLI 的~/.okx/config.toml里,App 不另存;自 2026-09-25(May 拍板「内核直接签名下单」)起,OKX 订单由 Rust 内核读取所选 profile 的密钥直接签名 REST 请求——只在签名那一刻读、不写日志,能发的只有trade::wire::Action列出的动作,实盘锁在内核里先于读密钥检查(见docs/KERNEL.md§1.0.1);只有账单流水仍经 CLI。嘉信的 App Key/Secret 与 OAuth refresh token 由自研的schwabctl(schwabctl/,Rust,与内核同一套工具链,随 App 装进MayStock.app/Contents/MacOS/)存在钥匙串里,App 只通过schwabctl token拿 30 分钟有效的 access token 读行情,订单一律经schwabctl place --live发出——没有--live它拒绝发单,因为嘉信的 paperMoney 不对 API 开放、没有可兜底的模拟盘。MayStock 不存储任何私钥。默认 demo(模拟盘),实盘需在「账户与连接」页显式解锁,切换前先验证每家交易所的目标账户并确认,每个策略在实盘启动时再单独确认。 模拟盘与实盘是两个账户、两套密钥(OKX 对另一环境的 Key 一律回 "APIKey does not match current environment"),所以配置里每个环境各有一个 profile(trading.demoProfile/trading.liveProfile),TradeBridge按调用的 mode 选 profile;App 只读config.toml的 profile 名与demo标记,从不读密钥。嘉信的「模拟盘」是本地影子账户(ShadowBook):按实时行情在常规交易时段撮合,盘口加滑点假设、扣嘉信的费用组件、按 Reg T 两倍购买力拒单,落盘在shadow-schwab.json,账本读它和读交易所一模一样。 - 每家交易所一套账本、一条交易循环:
VenueBooks按 venue 各持两本台账(demo/live)、权益曲线、心跳与一个StrategyRunner;本金也按 venue 分池(strategy.capital,USDT 与 USD 永不相加)。运行器仍是单账户引擎,第二家交易所是多一份VenueBooks而不是引擎里多一个分支。OKX 的文件名保持原样(ledger-demo.json),其他交易所把 venue 写进文件名(ledger-schwab-demo.json、heartbeat-schwab.json)。 - Swift 6 工具链 + v5 语言模式:并发注解按 v6 纪律书写(actor/@MainActor/Sendable),语言模式暂锁 v5 保证首编通过,后续可无痛升 v6。
| 用途 | 通道/端点 | 说明 |
|---|---|---|
| 实时价 | WS tickers @ wss://ws.okx.com:8443/ws/v5/public |
last/bid/ask/open24h/high24h/low24h/vol24h,~100ms-1s |
| K 线 | WS candle{1m,5m,15m,1H,4H,1D,1W} @ wss://ws.okx.com:8443/ws/v5/business |
9 字段数组,confirm 标志 |
| K 线回填 | REST GET /api/v5/market/candles(≤300, limit≤100 分页)/ history-candles |
启动与切周期时 |
| 深度 | WS books5 @ public(5 档快照/100ms)+ 面板打开时 REST GET /api/v5/market/books?sz=50 |
深度图用 50 档,实时买一卖一用 books5 |
| 标的元数据 | REST GET /api/v5/public/instruments?instType=SPOT |
tickSz→小数位,添加标的时校验 |
| 心跳 | 空闲 18s 客户端发 "ping" → "pong" |
30s 无数据服务端断连 |
| 交易 | okx CLI(npm i -g @okx_ai/okx-trade-cli):okx spot place --instId … --json [--demo]、okx account balance --json |
官方 Agent Trade Kit |
限频遵循:REST candles 20 req/2s,回填分页间隔 ≥120ms;UI 侧节流不影响推送接收。
嘉信这边(Sources/MayStockKit/Schwab/,access token 来自 schwabctl token,限频 120 次/分钟):
| 用途 | 端点 | 说明 |
|---|---|---|
| 实时价 | GET /marketdata/v1/quotes?symbols=A,B&fields=quote,reference,regular,extended |
所有自选一次批量;lastPrice、closePrice(昨收)、openPrice、日内高低量、买一卖一;阶段由 /markets 的时段表判定 |
| K 线 | GET /marketdata/v1/pricehistory?symbol=&periodType=&frequencyType=&frequency=&startDate=&endDate= |
1m/5m/15m 直接取;没有小时频率,1H 由 30 分钟 bar 按 09:30 锚定拼成(每天 7 根,最后一根只有半小时);日线周线用 daily/weekly,戳在纽约 00:00(内核日历对日线只认交易日不认时钟);历史按 60 天一窗往回翻到空窗为止——实测 1m 一次能拿 60 天 12k 根,5m 至少 120 天,15m 至少 300 天 |
| 交易时段 | GET /marketdata/v1/markets?markets=equity&date= |
盘前/常规/盘后三段与是否开市,每个纽约日读一次;读不到时退到 04:00–09:30–16:00–20:00 的标准时段表 |
| 代码搜索 | `GET /marketdata/v1/instruments?symbol=&projection=symbol-regex | desc-search` |
| 账户 | GET /trader/v1/accounts/{hash}?fields=positions |
现金、liquidationValue、持仓(做空为负数),非股票资产只报告不折算 |
| 订单 | POST/GET/PUT/DELETE /trader/v1/accounts/{hash}/orders |
指令按持仓决定(BUY / SELL / SELL_SHORT / BUY_TO_COVER),穿越平仓拆成两单;止损止盈作为 TRIGGER + OCO 子单挂在开仓腿上;嘉信没有客户端订单号,策略标签在本地 schwab-order-tags.json 里按 orderId 记 |
| 成交 | GET /trader/v1/accounts/{hash}/transactions?types=TRADE |
每笔 TRADE 一条成交,费用条目合计记为负数 |
Yahoo 这边(Sources/MayStockKit/Yahoo/,没有 key 时的过渡源):
| 用途 | 端点 | 说明 |
|---|---|---|
| 实时价 | GET query1.finance.yahoo.com/v8/finance/chart/{symbol}?interval=1m&range=1d&includePrePost=true |
报价从 meta 读(regularMarketPrice、chartPreviousClose、日内高低量),最新价取最后一根有成交的分钟 bar,含盘前盘后;currentTradingPeriod 给出当天三个时段,用来判断阶段 |
| K 线 | 同上,interval = 1m/5m/15m/1h/1d/1wk,period1/period2 指定区间 |
1m 只有最近 7 天,5m/15m 60 天,1h 730 天,日线周线不限;没有 4H |
| 代码搜索 | GET query2.finance.yahoo.com/v1/finance/search?q= |
只保留美国交易所的股票与 ETF |
| 不存在的代码 | {"chart":{"result":null,"error":{"code":"Not Found",…}}} |
解成 MarketDataError.unknownInstrument,自选校验据此拒绝 |
非官方接口,v7 报价端点已经要 cookie 与 crumb,所以只用 chart。它的存在只在 Sources/MayStockKit/Yahoo/ 一个目录里;切换发生在 SchwabMarketDataSource / SchwabMarketFeed 内部,只有「未登录」这一种失败会退到它,网络错误与限频照实报错,每次切换都写日志。
规则 = (标的, 条件, 动作, 节律)
- 条件:
价格上穿 X/价格下穿 X/24h 涨跌幅越过 ±X%/N 分钟内波动超 ±X%(基于 sparkline 环形缓冲) - 节律:一次性 / 触发后冷却 T 自动重挂;上/下穿带 0.05% 迟滞防抖
- 动作:系统通知(可带声音)· 菜单栏项闪烁 · 可选 shell hook(环境变量注入
MAYSTOCK_INSTID/PRICE/RULE,可直接串okxCLI 实现「触价下单」)
- 数字一律
monospacedDigit;涨systemGreen、跌systemRed,跟随深浅色模式 - 菜单栏 sparkline:22×(52
68)pt Retina 位图,首尾价决定色相,面积渐变填充,48240 点 - 面板 360×340pt,
ultraThinMaterial背景,圆角 12,无标题栏;图表留白 8/12 栅格 - K 线:阳线空心可选/实心默认,MA20 橙色 1.2pt,最新价虚线 + 右侧价签胶囊
- 深度图:买绿卖红 25% 面积 + 1.5pt 描边,中价竖线,hover 读数(价格/累计量)
- 动效:面板 fade+2pt 位移 160ms ease-out;价格变动 120ms 色彩脉冲
| 层 | 手段 | 跑在哪 |
|---|---|---|
| 解码/合并/告警/格式化 | swift-testing 单测,真实抓包 fixture | swift test(Mac/Linux 均可) |
| TradeBridge | 假 okx 可执行桩(fixture 脚本)验证参数拼装与 JSON 解析 |
swift test |
| 真实 E2E | maystock-e2e doctor:REST 连通 → 回填 300 根 → 双 WS 订阅 → 收 ≥5 tick + ≥1 candle + book → 心跳往返 → 汇报延迟,非零退出码即失败 |
make e2e(需外网) |
| 全链路 | make verify = build + test + e2e |
用户 Mac 一键 |
| UI 冒烟 | Scripts/smoke-ui.sh:装载 app、AppleScript 校验 status item 存在 |
用户 Mac |
Sources/MayStockKit/{Models,OKX,Engine,Trading,Util}
Sources/MayStock/{App,StatusBar,Panel,Charts,Terminal,Design,Support}
Sources/maystock-e2e/ # E2E 驱动 & 诊断 CLI
Tests/{MayStockKitTests,LiveE2ETests}
docs/{DESIGN.md,ICON_PROMPT.md}
Makefile · Scripts/
自绘 400 档增量深度合并(checksum)、多交易所聚合、组合持仓盈亏、WS 私有频道订单回报 —— 皆列入 v2.1 候选,不为赶功能牺牲优雅。
v2.1 已落地其中的组合持仓盈亏(按策略归因,见 STRATEGY.md), 并在其上补齐了策略导入、多窗口回测与仓位分配。其余三项仍为非目标。