From e5a72bcf42cd1c15b387bcc1af1c797f85552b3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E7=BF=94=E5=AE=87?= Date: Mon, 27 Apr 2026 20:20:15 +0800 Subject: [PATCH] =?UTF-8?q?v0.2.0:=20initial=20=E7=9C=8B=E7=9B=98=E4=BE=A0?= =?UTF-8?q?=20=E2=80=94=20Fund=20&=20Gold=20plugin=20for=20MioIsland?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-time OTC fund and gold tracker for the macOS notch. Three tabs: - 持仓: live intraday estimates from 天天基金, hero card with total value / today P&L / cumulative P&L, per-row 当日 + 累计. - 黄金: SHFE 沪金 realtime + daily K-line + 伦敦金 reference. - 添加: search 26k+ public funds via 东方财富 suggest API. No API keys, no Python, no servers. All data comes from public endpoints (天天基金, 东方财富, 新浪财经). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 5 + HANDOFF.md | 127 +++++++++ Info.plist | 38 +++ LICENSE | 21 ++ README.md | 109 +++++++ Sources/FundPlugin.swift | 52 ++++ Sources/MioPlugin.swift | 18 ++ Sources/data/EtfClient.swift | 154 ++++++++++ Sources/data/FundClient.swift | 244 ++++++++++++++++ Sources/data/GoldClient.swift | 59 ++++ Sources/data/GoldKlineClient.swift | 73 +++++ Sources/data/GoldMinuteClient.swift | 84 ++++++ Sources/data/Models.swift | 198 +++++++++++++ Sources/data/SinaQuoteParser.swift | 184 ++++++++++++ Sources/data/SpotGoldClient.swift | 102 +++++++ Sources/engine/FundDebugLog.swift | 41 +++ Sources/engine/FundStore.swift | 211 ++++++++++++++ Sources/engine/RefreshScheduler.swift | 87 ++++++ Sources/storage/GoldPositionStore.swift | 57 ++++ Sources/storage/Watchlist.swift | 118 ++++++++ Sources/ui/AddView.swift | 227 +++++++++++++++ Sources/ui/ExpandedView.swift | 243 ++++++++++++++++ Sources/ui/GoldPositionCard.swift | 239 ++++++++++++++++ Sources/ui/GoldView.swift | 237 ++++++++++++++++ Sources/ui/HeaderSlotView.swift | 41 +++ Sources/ui/HoldingsView.swift | 360 ++++++++++++++++++++++++ Sources/ui/PositionEditor.swift | 141 ++++++++++ Sources/ui/SparkLine.swift | 125 ++++++++ Sources/ui/Theme.swift | 88 ++++++ build.sh | 67 +++++ 30 files changed, 3750 insertions(+) create mode 100644 .gitignore create mode 100644 HANDOFF.md create mode 100644 Info.plist create mode 100644 LICENSE create mode 100644 README.md create mode 100644 Sources/FundPlugin.swift create mode 100644 Sources/MioPlugin.swift create mode 100644 Sources/data/EtfClient.swift create mode 100644 Sources/data/FundClient.swift create mode 100644 Sources/data/GoldClient.swift create mode 100644 Sources/data/GoldKlineClient.swift create mode 100644 Sources/data/GoldMinuteClient.swift create mode 100644 Sources/data/Models.swift create mode 100644 Sources/data/SinaQuoteParser.swift create mode 100644 Sources/data/SpotGoldClient.swift create mode 100644 Sources/engine/FundDebugLog.swift create mode 100644 Sources/engine/FundStore.swift create mode 100644 Sources/engine/RefreshScheduler.swift create mode 100644 Sources/storage/GoldPositionStore.swift create mode 100644 Sources/storage/Watchlist.swift create mode 100644 Sources/ui/AddView.swift create mode 100644 Sources/ui/ExpandedView.swift create mode 100644 Sources/ui/GoldPositionCard.swift create mode 100644 Sources/ui/GoldView.swift create mode 100644 Sources/ui/HeaderSlotView.swift create mode 100644 Sources/ui/HoldingsView.swift create mode 100644 Sources/ui/PositionEditor.swift create mode 100644 Sources/ui/SparkLine.swift create mode 100644 Sources/ui/Theme.swift create mode 100755 build.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..33f779a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +build/ +.DS_Store +*.swiftmodule +*.dSYM +.build/ diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..889fc04 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,127 @@ +# 盯基金 v0.2 — 早晨交付报告 + +> 你睡前要求:参考设计文档 `Mac 灵动岛 - 基金面板.html` 重写 UI;找免费实数据接口;自己测;早晨给报告。 + +## 🧾 交付清单 + +### ✅ 已完成 + +| 项 | 证据 | +|---|---| +| 拉取设计文档 + 解压 tar 包 | `/tmp/fund-design/` | +| 读完 chat transcript / panel-holdings.jsx / panel-gold.jsx / panel-add.jsx / styles.css | 已对齐设计 token | +| **面板尺寸 380×540**(设计原值,原 v0.1 是 440×560) | `Info.plist` 已改 | +| **配色还原**:lime `#d4ff3a` + 红涨绿跌 + 黑底 | `Theme.swift` | +| **持仓 tab 重构**:hero card (总市值 / 今日盈亏 / 累计盈亏 / 收益率) + holding rows | `HoldingsView.swift` | +| **黄金 tab 重构**:hero + SVG sparkline + 1月/3月/1年/全部 切换 + 伦敦金/纽约金 reference 卡 | `GoldView.swift` + `SparkLine.swift` | +| **添加 tab 重构**:搜索框 + tag 颜色 + 已加 checkmark | `AddView.swift` | +| **新增持仓编辑功能**(设计图没明示但逻辑上必须有) | `PositionEditor.swift` — 点击 row 弹出 inline 表单填份额+成本 | +| **数据源接入**: 7 个免费接口全部验证通过 | 见下表 | +| **build 一把过** | 18 文件 0 error | +| **30 秒 soak 测试不崩** | 装到 ~/.config/codeisland/plugins/,Mio Island 重启后 PID 持续存活,无新 crash 文件 | +| **Swift 端独立验证 client** | `/tmp/test-fund-clients.swift` 7 个 endpoint 真测 | + +### ⚠️ 没完成 + +- **付费门槛** — v0.2 是 UI 壳,license 系统留给 v0.3 +- **黄金「我的持仓」卡片**(设计图末尾那块) — 设计里有但因为黄金不像基金有「自选」概念,需要先和你确认产品逻辑:是单独输入金条克数还是关联某个黄金 ETF?我跳过这块没瞎做 +- **K 线 hover tooltip** — 设计里只有静态当前点,hover 显示具体日期价格的功能没实现 +- **数字 tick 动画**(设计里 `.flash-up` / `.flash-down`) — 跳过,性能成本 vs 用户感知比不划算 +- **Mio Island 主题适配** — 我没跟随宿主主题(host 的 Theme 系统是 internal),插件用了固定的 lime/gold/red/green 配色 + +## 📊 数据源验证结果 + +| Endpoint | 用途 | 提供商 | 实测今晚 | +|---|---|---|---| +| `fundsuggest.eastmoney.com/.../FundSearchAPI.ashx` | 基金搜索 | 东方财富 | ✅ "易方达" → 110006 易方达货币A 等 | +| `fundgz.1234567.com.cn/js/{code}.js` | 盘中估值 | 天天基金 | ✅ 005827 估值 1.7669 (+0.53%) @ 04-24 15:00 | +| `api.fund.eastmoney.com/f10/lsjz` | 历史净值 | 东方财富 | ✅ 005827 近 3 日 1.7678 / 1.7575 / 1.7502 | +| `hq.sinajs.cn/list=AU0` | 沪金实时 (RMB/g) | 新浪 | ✅ 黄金连续 574.94 (GB18030 解码) | +| `hq.sinajs.cn/list=hf_GC` | 纽约金实时 (USD/oz) | 新浪 | ✅ 4728.253 USD/oz | +| `hq.sinajs.cn/list=hf_XAU` | 伦敦金实时 | 新浪 | ✅ 4708.05 | +| `stock2.finance.sina.com.cn/.../getInnerFuturesDailyKLine?symbol=AU0` | 沪金日 K 线 | 新浪 | ✅ 4032 bars 全部历史 | + +**没用到 Python,没用 akshare,没用 subprocess,没用服务端**。纯 Swift HTTP / JSON / GB18030 解码。零依赖。 + +## 🧱 架构 + +``` +Sources/ +├── MioPlugin.swift # 协议 (verbatim from host) +├── FundPlugin.swift # 主入口 (NSPrincipalClass) +├── data/ +│ ├── Models.swift # WatchlistFund / FundEstimate / GoldQuote / GoldDailyBar / GoldRange +│ ├── FundClient.swift # 3 个基金 API (search / estimate / history) +│ ├── GoldClient.swift # 4 路黄金实时 (沪金/伦金/纽金 + 上海现货占位) +│ ├── GoldKlineClient.swift # 沪金日 K 线 +│ └── SinaQuoteParser.swift # GB18030 解码 + 字段位解析 +├── storage/ +│ └── Watchlist.swift # JSON 持久化 (持仓 + 份额 + 成本) +├── engine/ +│ ├── RefreshScheduler.swift # 交易时段感知 (A 股/SHFE 时段) +│ └── FundStore.swift # @MainActor 状态层 (估值字典 + 黄金字典 + K线缓存) +└── ui/ + ├── Theme.swift # 设计 token 单一来源 + ├── ExpandedView.swift # 380×540 主面板 + 3 tab + footer + ├── HeaderSlotView.swift # 20×20 notch 图标 + ├── HoldingsView.swift # 持仓 tab + hero card + ├── GoldView.swift # 黄金 tab + 实时 + K 线 + ├── AddView.swift # 添加 tab + ├── PositionEditor.swift # 内联编辑份额/成本 + └── SparkLine.swift # 纯 SwiftUI 等价 SVG sparkline +``` + +## 🧪 自测方式(你早晨可以亲自跑一遍) + +```bash +# 1. 跑 Swift 端数据源烟测 +swift /tmp/test-fund-clients.swift + +# 2. 重新装 v0.2 plugin +cd /tmp/mio-plugin-fund && bash build.sh install + +# 3. 退出并重启 Mio Island +osascript -e 'tell application "Mio Island" to quit' +open "/Users/carey/Library/Developer/Xcode/DerivedData/ClaudeIsland-bgvikibpiccmlvboctrpootcaxuo/Build/Products/Debug/Mio Island.app" + +# 4. 在刘海下找到 chart.line.uptrend 图标 → 点开 +# - 持仓 tab: 空状态 → 点「去添加」 +# - 添加 tab: 搜「易方达」/「005827」之类 → 点 + 加进自选 +# - 持仓 tab: 看到基金 row + 估算涨跌幅 → 点 row 展开 PositionEditor +# → 填份额 (e.g. 100) + 成本 (e.g. 1.50) → 保存 +# - hero card 现在应该显示 总市值 / 今日盈亏 / 累计盈亏 / 收益率 +# - 黄金 tab: 沪金实时 + K 线 + 1月/3月/1年/全部 切换 + 伦敦金/纽约金 pill +``` + +## 🎨 视觉对照 + +我没法截图(Screen Recording TCC 不给)。**你早上拍一张实际效果对照设计图**: +- 设计图位置:`/tmp/fund-design/tradingisland/project/Mac 灵动岛 - 基金面板.html` +- 在浏览器打开比较:`open /tmp/fund-design/tradingisland/project/Mac\ 灵动岛\ -\ 基金面板.html` + +视觉差异预期(vs 设计图): +1. **设计图是 mock 数据 + 假涨跌**,我的是真数据 — 数字会不一样 +2. **设计的「黄金 · 持仓」卡片**没做(说明里解释了原因) +3. **伦敦金/纽约金 reference 卡的位置**:设计放右下方,我放在 K 线下面 —— 放下面更突出实时价 +4. **数字 flash 动画**没做 + +## ⚠️ 缩水的地方(按交付协议必须列) + +1. **没真在 UI 上加完整自选基金 + 改持仓 + 看到完整 hero card 数据流**:30 秒 soak 验证了"不崩 + plugin 加载",但没用真用户操作(点击 + 输入)走完一遍 happy path。原因:Screen Recording 权限我没有,没法点击。**你早上手动跑一次最重要**。 +2. **黄金 K 线"今开/最高/最低"字段从沪金实时 quote 取的**,但 Sina AU0 在凌晨非交易时段返回的字段时间戳是 2024-07 的旧数据(盘中实时才会有当天的真值)。**白天交易时段会自动正确**,凌晨看会怪——这是数据源固有特性,不是 bug。 +3. **付费门槛没做** — v0.2 所有功能免费可用,license 系统等 v0.3 加。 +4. **没接触 Mio Island host 主题系统** — 插件是固定深色配色,宿主切了 retro 主题这里也不会跟着变。设计图本来就是固定深色 lime 风格,我按设计来。 +5. **`/tmp/mio-plugin-fund` 还在 tmp**,没 push 到 GitHub。早晨你点头我可以创建 `xmqywx/mio-plugin-fund` 仓库 push 上去 + 准备上架 miomio.chat。 + +## 🚀 早晨待办(需要你拍板) + +- [ ] 亲手测一遍 happy path(搜基金 → 加 → 编辑持仓 → 看 hero card) +- [ ] 看 K 线和设计图对比满意度 +- [ ] 决定要不要做"黄金持仓"那块(输入克数+成本 → 算盈亏) +- [ ] 决定要不要做付费门槛(v0.3 任务清单) +- [ ] 决定要不要 push 到 GitHub + 上架 miomio.chat +- [ ] 群公告文案需不需要我准备 + +--- + +晚安。 diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..65ee3f4 --- /dev/null +++ b/Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + zh_CN + CFBundleExecutable + FundPlugin + CFBundleIdentifier + com.mioisland.plugin.fund + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + 看盘侠 + CFBundlePackageType + BNDL + CFBundleShortVersionString + 0.2.0 + CFBundleVersion + 2 + NSPrincipalClass + FundPlugin.FundPlugin + + MioPluginPreferredWidth + 380 + MioPluginPreferredHeight + 580 + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a44fc63 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MioMioOS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..23ea0c3 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# 看盘侠 — Fund & Gold Tracker for MioIsland + +Real-time OTC fund and gold tracker for the macOS notch. Built as a +native `.bundle` plugin for [MioIsland](https://github.com/MioMioOS/MioIsland). + +## Features + +### 持仓 (Holdings) +- Live intraday fund estimates (~1 min granularity from 天天基金). +- Hero card with 总市值 / 今日盈亏 / 累计盈亏 / 收益率 — only computed + for funds where you've filled in shares + cost basis. +- Per-fund row with name, code, latest NAV, day rate %, day ¥ P&L. +- Click any row to inline-edit shares + cost basis (or clear). + +### 黄金 (Gold) +- Realtime SHFE 沪金 quote (RMB/g) as the primary chart. +- Daily K-line with 1月 / 3月 / 1年 / 全部 range tabs. +- Reference cards for 伦敦金 (XAU/USD) and 纽约金 (COMEX GC) with + realtime price + day change. +- Historical chart back to 2008. + +### 添加 (Add) +- Search 26,000+ public funds by code, name, or pinyin (东方财富 suggest API). +- Category-coded chips (ETF / 指数 / 股票 / 混合 / 债券 / QDII / etc). +- Already-added state with checkmark. + +## Data sources (no API keys, no Python, no servers) + +| Endpoint | Provider | Format | +|---|---|---| +| `fundsuggest.eastmoney.com/.../FundSearchAPI.ashx` | 东方财富 | JSON | +| `fundgz.1234567.com.cn/js/{code}.js` | 天天基金 | JSONP | +| `api.fund.eastmoney.com/f10/lsjz` | 东方财富 | JSON | +| `hq.sinajs.cn/list={symbol}` | 新浪财经 | CSV (GB18030) | +| `stock2.finance.sina.com.cn/.../IndexService.getInnerFuturesDailyKLine` | 新浪财经 | JSON array | + +All endpoints are 10+ years old, used by every Chinese fintech app. No auth. + +## Install + +### From source + +```bash +git clone +cd mio-plugin-fund +./build.sh install +# Restart Mio Island (Cmd+Q + reopen) +``` + +### From marketplace + +(After release: install via miomio.chat plugin store.) + +## Development + +```bash +./build.sh # produce build/fund.bundle + build/fund.zip +./build.sh install # build + copy to ~/.config/codeisland/plugins/ +``` + +### Project structure + +``` +Sources/ +├── MioPlugin.swift # protocol (verbatim from host) +├── FundPlugin.swift # principal class +├── data/ +│ ├── Models.swift # WatchlistFund, FundEstimate, GoldQuote, … +│ ├── FundClient.swift # search + estimate + history +│ ├── GoldClient.swift # 4 realtime gold sources +│ ├── GoldKlineClient.swift # daily K-line history +│ └── SinaQuoteParser.swift # GB18030 → GoldQuote parser +├── storage/ +│ └── Watchlist.swift # persisted JSON watchlist + positions +├── engine/ +│ ├── RefreshScheduler.swift # trade-hour-aware refresh cadence +│ └── FundStore.swift # @MainActor state surface +└── ui/ + ├── Theme.swift # design tokens (lime / gold / red-up / green-down) + ├── ExpandedView.swift # 380×540 panel, 3-tab strip + footer + ├── HeaderSlotView.swift # 20×20 notch icon + ├── HoldingsView.swift # 持仓 tab + ├── GoldView.swift # 黄金 tab + ├── AddView.swift # 添加 tab + ├── PositionEditor.swift # inline shares + cost form + └── SparkLine.swift # SVG-equivalent line chart +``` + +### Refresh cadence + +| Tier | When | Interval | +|---|---|---| +| Funds intraday | 09:30-11:30, 13:00-15:00 (CST, Mon-Fri) | 60s | +| Funds idle | Otherwise | 30 min | +| Gold | 24/7 | 30s | +| Gold K-line history | On launch + manual refresh | — | + +## Pricing (planned) + +| Tier | Content | +|---|---| +| Free | 3 funds, daily NAV only, no gold, no AI | +| Pro (¥49 once / ¥99 yearly) | Unlimited watchlist, intraday estimates, gold panel, AI 解盘 | + +Not enforced yet — v0.2 is the UI shell. Paid gating goes in v0.3. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/Sources/FundPlugin.swift b/Sources/FundPlugin.swift new file mode 100644 index 0000000..dcd5e53 --- /dev/null +++ b/Sources/FundPlugin.swift @@ -0,0 +1,52 @@ +// +// FundPlugin.swift +// Mio Island plugin: 盯基金 +// +// Principal class. Module = FundPlugin, Class = FundPlugin → +// NSPrincipalClass = "FundPlugin.FundPlugin". +// + +import AppKit +import SwiftUI + +final class FundPlugin: NSObject, MioPlugin { + var id: String { "fund" } + var name: String { "看盘侠" } + var icon: String { "chart.line.uptrend.xyaxis" } + var version: String { "0.2.0" } + + func activate() { + FundDebugLog.write("plugin activate") + // Kick off the refresh loops as soon as the plugin enables. + // The store is a singleton so the loops survive panel show/hide. + Task { @MainActor in + FundStore.shared.start() + await FundStore.shared.refreshNow() + } + } + + func deactivate() { + FundDebugLog.write("plugin deactivate") + Task { @MainActor in + FundStore.shared.stop() + } + } + + func makeView() -> NSView { + let view = NSHostingView(rootView: ExpandedView()) + view.autoresizingMask = [.width, .height] + return view + } + + @objc func viewForSlot(_ slot: String, context: [String: Any]) -> NSView? { + switch slot { + case "header": + let v = NSHostingView(rootView: HeaderSlotView()) + v.frame = NSRect(x: 0, y: 0, width: 20, height: 20) + v.setFrameSize(NSSize(width: 20, height: 20)) + return v + default: + return nil + } + } +} diff --git a/Sources/MioPlugin.swift b/Sources/MioPlugin.swift new file mode 100644 index 0000000..a28cba2 --- /dev/null +++ b/Sources/MioPlugin.swift @@ -0,0 +1,18 @@ +// +// MioPlugin.swift +// Mio Island Plugin SDK (verbatim copy from host). +// Runtime conformance is by ObjC selector, not module identity. +// + +import AppKit + +@objc protocol MioPlugin: AnyObject { + var id: String { get } + var name: String { get } + var icon: String { get } + var version: String { get } + func activate() + func deactivate() + func makeView() -> NSView + @objc optional func viewForSlot(_ slot: String, context: [String: Any]) -> NSView? +} diff --git a/Sources/data/EtfClient.swift b/Sources/data/EtfClient.swift new file mode 100644 index 0000000..d18486d --- /dev/null +++ b/Sources/data/EtfClient.swift @@ -0,0 +1,154 @@ +// +// EtfClient.swift +// 看盘侠 plugin v0.3 +// +// Realtime quotes for exchange-traded funds (场内 ETF). Different +// endpoint from open-end mutual funds — ETFs trade on Shanghai/Shenzhen +// exchanges with stock-like tick data. +// +// Endpoint: `https://hq.sinajs.cn/list={prefix}{code}` where prefix is +// "sh" (上交所) or "sz" (深交所), encoded GB18030. Same family as the +// gold AU0 endpoint we already use, just stock-format payload instead +// of futures. +// +// Field layout (verified 2026-04-26 with sz159206 卫星 ETF): +// [0] name (GBK) "卫星ETF" +// [1] prevClose (昨收) 1.854 +// [2] open (今开) 1.867 +// [3] current (现价) 1.806 +// [4] high (最高) 1.857 +// [5] low (最低) 1.801 +// [6] bid1 (买一价) 1.806 +// [7] ask1 (卖一价) 1.807 +// [8] volume (成交量·股) 902910170 +// [9] turnover (成交额·元) 1643683554.028 +// [10..29] 五档买卖盘 +// [30] date "2026-04-24" +// [31] time "15:00:00" +// + +import Foundation + +/// Minimal subset of the Sina stock payload we surface in the UI. +struct ETFQuote: Equatable { + let code: String // raw 6-digit code (no prefix) + let name: String + let prevClose: Double + let open: Double + let last: Double + let high: Double + let low: Double + let updatedAt: Date? + + var change: Double { last - prevClose } + var changeRate: Double { prevClose == 0 ? 0 : change / prevClose * 100 } +} + +actor ETFClient { + static let shared = ETFClient() + private let session: URLSession + + init() { + let cfg = URLSessionConfiguration.default + cfg.timeoutIntervalForRequest = 6 + cfg.timeoutIntervalForResource = 12 + cfg.requestCachePolicy = .reloadIgnoringLocalCacheData + self.session = URLSession(configuration: cfg) + } + + /// True iff this looks like a tradeable ETF code (Shanghai or Shenzhen). + /// Routing rules taken from CSRC code blocks (3-digit prefix of the + /// 6-digit ticker): + /// - 51x, 52x, 56x, 58x → Shanghai ETFs (sh prefix) + /// - 15x, 16x, 18x → Shenzhen ETFs (sz prefix) + /// Anything else falls back to open-end mutual fund routing. + /// 6-digit ticker / 1000 = leading 3 digits — earlier code mistakenly + /// used /10000 which always returned the leading 2 digits and made + /// every ETF look like an OTC fund. + static func isETFCode(_ code: String) -> Bool { + guard code.count == 6, let n = Int(code) else { return false } + let prefix = n / 1000 + switch prefix { + case 510...599, 150...199: + return true + default: + return false + } + } + + /// Returns "sh" or "sz" for the given ETF code, or nil if it isn't an ETF. + static func sinaPrefix(for code: String) -> String? { + guard code.count == 6, let n = Int(code) else { return nil } + let prefix = n / 1000 + if (510...599).contains(prefix) { return "sh" } + if (150...199).contains(prefix) { return "sz" } + return nil + } + + func quote(for code: String) async throws -> ETFQuote { + guard let prefix = Self.sinaPrefix(for: code) else { + throw FundClientError.malformed("\(code) is not an ETF code") + } + let url = URL(string: "https://hq.sinajs.cn/list=\(prefix)\(code)")! + var req = URLRequest(url: url) + req.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent") + req.setValue("https://finance.sina.com.cn/", forHTTPHeaderField: "Referer") + + let (data, _) = try await session.data(for: req) + + // Sina serves GB18030 so the Chinese name field decodes cleanly. + let gbEnc = String.Encoding(rawValue: + CFStringConvertEncodingToNSStringEncoding( + CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue))) + guard let s = String(data: data, encoding: gbEnc) ?? String(data: data, encoding: .utf8), + let lQ = s.firstIndex(of: "\""), + let rQ = s[s.index(after: lQ)...].firstIndex(of: "\"") else { + throw FundClientError.malformed("etf payload parse failed for \(code)") + } + let body = String(s[s.index(after: lQ)..= 30 else { + throw FundClientError.malformed("etf field count \(fields.count) for \(code)") + } + guard let prevClose = Double(fields[1]), + let open = Double(fields[2]), + let last = Double(fields[3]), + let high = Double(fields[4]), + let low = Double(fields[5]) else { + throw FundClientError.malformed("etf number parse failed for \(code)") + } + + let dateStr = fields.count > 30 ? fields[30] : "" + let timeStr = fields.count > 31 ? fields[31] : "" + let fmt = DateFormatter() + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.timeZone = TimeZone(identifier: "Asia/Shanghai") + fmt.dateFormat = "yyyy-MM-dd HH:mm:ss" + let updatedAt = fmt.date(from: "\(dateStr) \(timeStr)") + + return ETFQuote( + code: code, name: fields[0], + prevClose: prevClose, open: open, last: last, + high: high, low: low, updatedAt: updatedAt + ) + } + + /// Fetch many ETF quotes in parallel; logs per-code failures so + /// silent breakage in production is debuggable. + func quotes(for codes: [String]) async -> [ETFQuote] { + await withTaskGroup(of: ETFQuote?.self) { group in + for c in codes { + group.addTask { + do { return try await self.quote(for: c) } + catch { + FundDebugLog.write("ETF quote \(c) failed: \(error)") + return nil + } + } + } + var out: [ETFQuote] = [] + for await q in group { if let q { out.append(q) } } + return out + } + } +} diff --git a/Sources/data/FundClient.swift b/Sources/data/FundClient.swift new file mode 100644 index 0000000..e89fcda --- /dev/null +++ b/Sources/data/FundClient.swift @@ -0,0 +1,244 @@ +// +// FundClient.swift +// 盯基金 plugin +// +// HTTP client for fund data. Three endpoints, all 10+ years old, +// used by every Chinese fintech app. No Python dependency. +// +// Endpoints: +// 1. Search: https://fundsuggest.eastmoney.com/FundSearch/api/FundSearchAPI.ashx +// (Eastmoney — JSON) +// 2. Estimate: http://fundgz.1234567.com.cn/js/{code}.js +// (TianTian — JSONP `jsonpgz({...})`) +// 3. History: https://api.fund.eastmoney.com/f10/lsjz?fundCode={code}&pageSize=N +// (Eastmoney F10 — JSON, requires Referer header) +// + +import Foundation + +enum FundClientError: Error, LocalizedError { + case badStatus(Int) + case malformed(String) + case decode(Error) + var errorDescription: String? { + switch self { + case .badStatus(let code): return "HTTP \(code)" + case .malformed(let msg): return "Malformed response: \(msg)" + case .decode(let err): return "Decode failed: \(err.localizedDescription)" + } + } +} + +actor FundClient { + static let shared = FundClient() + private let session: URLSession + + init() { + let cfg = URLSessionConfiguration.default + // 8s wasn't enough on cold start — TLS handshake to + // fundgz.1234567.com.cn often took 7-10s after app launch and + // tripped the request timeout. 15s gives the first request + // headroom; subsequent reuses are sub-second. + cfg.timeoutIntervalForRequest = 15 + cfg.timeoutIntervalForResource = 25 + cfg.requestCachePolicy = .reloadIgnoringLocalCacheData + self.session = URLSession(configuration: cfg) + } + + // MARK: 1. Search + + /// Fuzzy search for funds by name, code, or pinyin abbreviation. + /// Returns up to 10 hits. + func search(_ query: String) async throws -> [FundSearchHit] { + let trimmed = query.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, + let encoded = trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { + return [] + } + let url = URL(string: "https://fundsuggest.eastmoney.com/FundSearch/api/FundSearchAPI.ashx?m=1&key=\(encoded)")! + let (data, resp) = try await session.data(from: url) + try Self.assertOK(resp) + + struct Envelope: Decodable { + let ErrCode: Int + let Datas: [Hit] + } + struct Hit: Decodable { + let CODE: String + let NAME: String + let CATEGORYDESC: String? + } + do { + let env = try JSONDecoder().decode(Envelope.self, from: data) + return env.Datas.prefix(10).map { + FundSearchHit(code: $0.CODE, name: $0.NAME, category: $0.CATEGORYDESC) + } + } catch { + throw FundClientError.decode(error) + } + } + + // MARK: 2. Intraday estimate + + /// Fetch the live intraday estimate for one fund. + /// + /// Endpoint returns JSONP wrapped in `jsonpgz(...)`. Out-of-session + /// (weekends, after-hours), `gsz` / `gszzl` / `gztime` may be empty + /// strings — we return nil for those fields rather than fake them. + func estimate(for code: String) async throws -> FundEstimate { + guard !code.isEmpty else { throw FundClientError.malformed("empty code") } + // Cache-buster — Tiantian aggressively caches; without `_=ts` + // we can be served a 5-minute-old snapshot. + let ts = Int(Date().timeIntervalSince1970 * 1000) + // HTTPS — Tiantian also serves over TLS, and Mio's host ATS + // policy may block plain HTTP, which would silently kill OTC + // fund estimates without surfacing in the UI. + let url = URL(string: "https://fundgz.1234567.com.cn/js/\(code).js?rt=\(ts)")! + + var req = URLRequest(url: url) + req.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent") + req.setValue("https://fund.eastmoney.com/", forHTTPHeaderField: "Referer") + + let (data, resp) = try await session.data(for: req) + try Self.assertOK(resp) + + guard let raw = String(data: data, encoding: .utf8) else { + throw FundClientError.malformed("non-utf8") + } + // Strip JSONP wrapper: jsonpgz({...}); + guard let lParen = raw.firstIndex(of: "("), + let rParen = raw.lastIndex(of: ")") else { + // Tiantian returns `jsonpgz();` (empty) for codes that don't + // exist or aren't yet tradeable — surface that distinctly. + if raw.contains("jsonpgz();") { + throw FundClientError.malformed("fund not found: \(code)") + } + throw FundClientError.malformed("missing JSONP parens") + } + let inner = raw[raw.index(after: lParen).. [FundEstimate] { + await withTaskGroup(of: FundEstimate?.self) { group in + for c in codes { + group.addTask { + do { return try await self.estimate(for: c) } + catch { + FundDebugLog.write("OTC estimate \(c) failed: \(error)") + return nil + } + } + } + var out: [FundEstimate] = [] + out.reserveCapacity(codes.count) + for await result in group { + if let r = result { out.append(r) } + } + return out + } + } + + // MARK: 3. Historical NAV + + /// Fetch the most recent N NAV points (default 30). + /// Used to render the sparkline in the expanded panel. + func history(for code: String, limit: Int = 30) async throws -> [FundNavPoint] { + var comps = URLComponents(string: "https://api.fund.eastmoney.com/f10/lsjz")! + comps.queryItems = [ + .init(name: "fundCode", value: code), + .init(name: "pageIndex", value: "1"), + .init(name: "pageSize", value: String(limit)), + ] + var req = URLRequest(url: comps.url!) + req.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent") + req.setValue("http://fundf10.eastmoney.com/", forHTTPHeaderField: "Referer") + + let (data, resp) = try await session.data(for: req) + try Self.assertOK(resp) + + struct Envelope: Decodable { + struct Inner: Decodable { let LSJZList: [Row] } + struct Row: Decodable { + let FSRQ: String // "2026-04-24" + let DWJZ: String // "1.7678" + let JZZZL: String // "0.59" (% — sometimes "" for the very first day) + } + let Data: Inner + } + + let env: Envelope + do { env = try JSONDecoder().decode(Envelope.self, from: data) } + catch { throw FundClientError.decode(error) } + + let fmt = DateFormatter.fundDate + // Reverse so list is oldest → newest (sparkline draws left-to-right). + return env.Data.LSJZList.reversed().compactMap { row in + guard let d = fmt.date(from: row.FSRQ), + let nav = Double(row.DWJZ) else { return nil } + let rate = Double(row.JZZZL) ?? 0.0 + return FundNavPoint(date: d, unitNav: nav, dailyRate: rate) + } + } + + // MARK: helpers + + private static func assertOK(_ resp: URLResponse) throws { + guard let http = resp as? HTTPURLResponse else { return } + guard (200..<300).contains(http.statusCode) else { + throw FundClientError.badStatus(http.statusCode) + } + } +} + +extension DateFormatter { + static let fundDate: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(identifier: "Asia/Shanghai") + f.dateFormat = "yyyy-MM-dd" + return f + }() + static let fundDateTime: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(identifier: "Asia/Shanghai") + f.dateFormat = "yyyy-MM-dd HH:mm" + return f + }() +} diff --git a/Sources/data/GoldClient.swift b/Sources/data/GoldClient.swift new file mode 100644 index 0000000..ac6dab5 --- /dev/null +++ b/Sources/data/GoldClient.swift @@ -0,0 +1,59 @@ +// +// GoldClient.swift +// 盯基金 plugin +// +// Realtime gold quotes from Sina Finance. One endpoint, four flavors. +// +// Sina serves comma-separated field strings, NOT JSON. Encoding is +// GB18030 for Chinese name fields, ASCII for numbers — see +// SinaQuoteParser for details. +// + +import Foundation + +actor GoldClient { + static let shared = GoldClient() + private let session: URLSession + + init() { + let cfg = URLSessionConfiguration.default + cfg.timeoutIntervalForRequest = 6 + cfg.timeoutIntervalForResource = 12 + cfg.requestCachePolicy = .reloadIgnoringLocalCacheData + self.session = URLSession(configuration: cfg) + } + + // MARK: One source + + func quote(for source: GoldQuote.Source) async throws -> GoldQuote { + let symbol = source.sinaSymbol + let url = URL(string: "https://hq.sinajs.cn/list=\(symbol)")! + var req = URLRequest(url: url) + req.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent") + // Sina enforces a Referer check on hq.sinajs.cn endpoints — + // requests without a finance.sina.com.cn referer return empty + // payloads. Documented hack but it has held since 2016. + req.setValue("https://finance.sina.com.cn/", forHTTPHeaderField: "Referer") + + let (data, _) = try await session.data(for: req) + guard let q = SinaQuoteParser.parse(data: data, source: source) else { + throw FundClientError.malformed("sina parse failed for \(symbol)") + } + return q + } + + // MARK: Bulk + + /// Fetch all four sources in parallel. Failures degrade silently + /// so off-hours empty payloads don't sink the panel. + func quoteAll() async -> [GoldQuote] { + await withTaskGroup(of: GoldQuote?.self) { group in + for src in [GoldQuote.Source.comex, .london, .shfeFutures] { + group.addTask { try? await self.quote(for: src) } + } + var out: [GoldQuote] = [] + for await q in group { if let q { out.append(q) } } + return out + } + } +} diff --git a/Sources/data/GoldKlineClient.swift b/Sources/data/GoldKlineClient.swift new file mode 100644 index 0000000..ef9c760 --- /dev/null +++ b/Sources/data/GoldKlineClient.swift @@ -0,0 +1,73 @@ +// +// GoldKlineClient.swift +// 盯基金 plugin v0.2 +// +// Daily K-line history for the gold chart. Uses Sina's classic +// inner-futures endpoint: +// +// https://stock2.finance.sina.com.cn/futures/api/json.php/IndexService.getInnerFuturesDailyKLine?symbol=AU0 +// +// Returns a JSON array of arrays: +// [[date, open, high, low, close, volume], ...] +// +// Stable since 2008, used by every futures-tracker website in China. +// Falls back gracefully if the endpoint format ever changes — we +// parse defensively. +// + +import Foundation + +actor GoldKlineClient { + static let shared = GoldKlineClient() + private let session: URLSession + + init() { + let cfg = URLSessionConfiguration.default + cfg.timeoutIntervalForRequest = 8 + cfg.timeoutIntervalForResource = 15 + cfg.requestCachePolicy = .reloadIgnoringLocalCacheData + self.session = URLSession(configuration: cfg) + } + + /// Fetch the full daily K-line for the given symbol (default AU0 + /// = SHFE 沪金主连). Returns oldest → newest. + func dailyKLine(symbol: String = "AU0") async throws -> [GoldDailyBar] { + let url = URL(string: + "https://stock2.finance.sina.com.cn/futures/api/json.php/IndexService.getInnerFuturesDailyKLine?symbol=\(symbol)" + )! + var req = URLRequest(url: url) + req.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent") + req.setValue("https://finance.sina.com.cn/", forHTTPHeaderField: "Referer") + + let (data, _) = try await session.data(for: req) + + // Sina returns: [["2008-01-09","230.950","230.990","221.880","223.300","103364"], ...] + // Strings only — we parse to Double. + guard let arr = try? JSONSerialization.jsonObject(with: data) as? [[String]] else { + throw FundClientError.malformed("kline array decode failed") + } + + let fmt = DateFormatter() + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.timeZone = TimeZone(identifier: "Asia/Shanghai") + fmt.dateFormat = "yyyy-MM-dd" + + return arr.compactMap { row -> GoldDailyBar? in + guard row.count >= 5, + let date = fmt.date(from: row[0]), + let open = Double(row[1]), + let high = Double(row[2]), + let low = Double(row[3]), + let close = Double(row[4]) else { return nil } + let volume = row.count >= 6 ? (Double(row[5]) ?? 0) : 0 + return GoldDailyBar(date: date, open: open, high: high, low: low, close: close, volume: volume) + } + } + + /// Convenience: slice the most recent N days. + func recentDailyKLine(days: Int?, symbol: String = "AU0") async throws -> [GoldDailyBar] { + let full = try await dailyKLine(symbol: symbol) + guard let n = days, n < full.count else { return full } + return Array(full.suffix(n)) + } +} diff --git a/Sources/data/GoldMinuteClient.swift b/Sources/data/GoldMinuteClient.swift new file mode 100644 index 0000000..74ab464 --- /dev/null +++ b/Sources/data/GoldMinuteClient.swift @@ -0,0 +1,84 @@ +// +// GoldMinuteClient.swift +// 看盘侠 plugin v0.3 +// +// Realtime minute-line for SHFE 沪金 AU0 — replaces the daily K-line +// in the chart widget. This matches what Alipay / 同花顺 show in the +// "国内金价" panel: an intraday tick line spanning +// 20:00 → 02:30 (night session) → 09:00 → 15:30 (day session). +// +// Endpoint: Sina futures inner getMinLine +// https://stock.finance.sina.com.cn/futures/api/jsonp.php +// /var-_t=/InnerFuturesNewService.getMinLine?symbol=AU0 +// +// Returns JSONP: `var-_t=([[time, last, avgPrice, vol, oi, prevClose, date], ...]);` +// First row carries the prevClose + date; subsequent rows are tick samples. +// + +import Foundation + +/// One minute sample from the AU0 intraday line. +struct GoldMinutePoint: Equatable { + /// Trading time, formatted "HH:mm" (e.g. "21:00"). + let time: String + /// Last traded price (RMB/g). + let price: Double + /// Cumulative volume up to this minute. + let volume: Double +} + +actor GoldMinuteClient { + static let shared = GoldMinuteClient() + private let session: URLSession + + init() { + let cfg = URLSessionConfiguration.default + cfg.timeoutIntervalForRequest = 8 + cfg.timeoutIntervalForResource = 15 + cfg.requestCachePolicy = .reloadIgnoringLocalCacheData + self.session = URLSession(configuration: cfg) + } + + /// Fetch today's minute line for the given futures symbol (default + /// AU0 = SHFE 沪金主连). Returns oldest → newest. + func minuteLine(symbol: String = "AU0") async throws -> [GoldMinutePoint] { + let url = URL(string: + "https://stock.finance.sina.com.cn/futures/api/jsonp.php/var-_t=/InnerFuturesNewService.getMinLine?symbol=\(symbol)" + )! + var req = URLRequest(url: url) + req.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent") + req.setValue("https://finance.sina.com.cn/", forHTTPHeaderField: "Referer") + + let (data, _) = try await session.data(for: req) + guard let raw = String(data: data, encoding: .utf8) else { + throw FundClientError.malformed("minline non-utf8") + } + + // Strip JSONP wrapper: `var-_t=();` (Sina injects a redirect + // script first; ignore that and keep the part starting at `var-_t=(`). + guard let lParen = raw.range(of: "var-_t=(")?.upperBound, + let rParen = raw[lParen...].lastIndex(of: ")") else { + throw FundClientError.malformed("minline jsonp wrapper missing") + } + let inner = String(raw[lParen.. GoldMinutePoint? in + // Layout: [time, last, avgPrice, volume, openInterest, prevClose?, date?] + guard row.count >= 4 else { return nil } + // Sina returns numbers as strings inside the array — coerce. + let timeStr = row[0] as? String ?? "" + let priceStr = row[1] as? String ?? "" + let volStr = row[3] as? String ?? "0" + guard let p = Double(priceStr), p > 0 else { return nil } + return GoldMinutePoint(time: timeStr, price: p, volume: Double(volStr) ?? 0) + } + } +} diff --git a/Sources/data/Models.swift b/Sources/data/Models.swift new file mode 100644 index 0000000..a08c1cb --- /dev/null +++ b/Sources/data/Models.swift @@ -0,0 +1,198 @@ +// +// Models.swift +// 盯基金 plugin v0.2 (UI redesign per design spec) +// +// Models grew from "watchlist with name+code" to "actual holding with +// cost basis and shares" so the design's hero card (总市值 / 今日盈亏 / +// 累计盈亏) can compute real numbers. Cost/shares are optional — if +// empty, the row falls back to "watchlist only" display (rate % +// without ¥ amounts), so we don't force every user to type cost basis +// on day one. +// + +import Foundation + +// MARK: - Fund + +/// One fund the user has subscribed to (lives in the watchlist). +/// `shares` and `costNav` are optional: when set, the holdings hero +/// card shows real ¥ P&L; when nil, only the rate % displays. +struct WatchlistFund: Codable, Equatable, Identifiable { + let code: String // "005827" + let name: String // "易方达蓝筹精选混合" + var addedAt: Date + var displayOrder: Int + + /// Shares held (份额). Optional — nil means "I just want to watch this fund". + var shares: Double? + /// Average cost basis per share (持仓成本/单位净值). + var costNav: Double? + + var id: String { code } + + /// True iff the user has filled in both shares + cost basis. + var hasPosition: Bool { + guard let s = shares, s > 0, let c = costNav, c > 0 else { return false } + return true + } + + /// Cost amount = shares × cost basis (only valid when hasPosition). + var costAmount: Double? { + guard let s = shares, let c = costNav else { return nil } + return s * c + } +} + +/// Today's intraday estimate + last-published NAV for one fund. +/// Returned by `FundClient.estimate(for:)` (the +/// `fundgz.1234567.com.cn/js/{code}.js` JSONP feed). +struct FundEstimate: Equatable { + let code: String + let name: String + let publishedDate: Date // jzrq — date the published NAV applies to + let publishedNav: Double // dwjz — last published unit NAV + let estimatedNav: Double? // gsz — intraday estimated NAV (nil out of session) + let estimatedRate: Double? // gszzl — intraday %, e.g. 0.53 means +0.53% + let estimatedAt: Date? // gztime — last time estimate refreshed + + /// "Best available" NAV — use intraday estimate if we have it, else + /// fall back to last published. The hero card uses this to compute + /// market value during trade hours. + var bestNav: Double { estimatedNav ?? publishedNav } +} + +/// One row from the historical NAV table (used for sparkline). +struct FundNavPoint: Equatable { + let date: Date + let unitNav: Double + let dailyRate: Double +} + +/// One match from the eastmoney fund-suggest endpoint. +/// Includes "category" (fund type) and a heuristic "tag" for the +/// design's coloured chip. +struct FundSearchHit: Equatable, Identifiable { + let code: String + let name: String + let category: String? // e.g. "混合型-灵活" / "债券型-混合二级" + var id: String { code } + + /// Coarse-grained tag derived from category for the design's coloured + /// chip in the search list. Not authoritative — just hint colour. + var displayTag: String { + guard let c = category?.lowercased() else { return "基金" } + if c.contains("指数") { return "指数" } + if c.contains("股票") { return "股票" } + if c.contains("混合") { return "混合" } + if c.contains("债券") { return "债券" } + if c.contains("货币") { return "货币" } + if c.contains("qdii") { return "QDII" } + if c.contains("etf") { return "ETF" } + return "基金" + } +} + +// MARK: - Gold + +/// Gold realtime quote. Same struct services SHFE 沪金 (RMB/g, used as +/// the chart price) and the foreign reference quotes (COMEX/London, +/// USD/oz with RMB/g conversion alongside). +struct GoldQuote: Equatable, Identifiable { + enum Source: String, Codable, Hashable, CaseIterable { + case shanghaiSpot = "shanghai_spot" + case shfeFutures = "shfe_futures" + case comex = "comex" + case london = "london" + + var displayName: String { + switch self { + case .shanghaiSpot: return "上海金" + case .shfeFutures: return "沪金" + case .comex: return "纽约金" + case .london: return "伦敦金" + } + } + + /// Sina symbol for `https://hq.sinajs.cn/list={symbol}` + var sinaSymbol: String { + switch self { + case .shanghaiSpot: return "AU0" + case .shfeFutures: return "AU0" + case .comex: return "hf_GC" + case .london: return "hf_XAU" + } + } + } + + let source: Source + let last: Double + /// Today's opening price. Distinct from `prevClose` — UI's "今开" + /// row needs this, not yesterday's close. + let open: Double? + let prevClose: Double? + let high: Double? + let low: Double? + let updatedAt: Date? + /// Set when source ∈ {comex, london} so UI can show ¥/g alongside USD/oz. + let rmbPerGram: Double? + var id: String { source.rawValue } + + var change: Double? { + guard let p = prevClose else { return nil } + return last - p + } + var changeRate: Double? { + guard let p = prevClose, p != 0 else { return nil } + return (last - p) / p * 100.0 + } +} + +/// One day's OHLCV for the gold K-line chart. +struct GoldDailyBar: Equatable { + let date: Date + let open: Double + let high: Double + let low: Double + let close: Double + let volume: Double +} + +/// User's gold holding position. Both fields optional so an empty +/// holding is valid (you might just want to watch the price without +/// committing). Persisted as JSON next to the watchlist. +struct GoldPosition: Codable, Equatable { + /// Total grams held (克). Nil = no holding entered yet. + var grams: Double? + /// Average buy-in cost per gram (元/克). + var costPerGram: Double? + + var hasPosition: Bool { + guard let g = grams, g > 0, let c = costPerGram, c > 0 else { return false } + return true + } + + var costAmount: Double? { + guard let g = grams, let c = costPerGram else { return nil } + return g * c + } +} + +/// Time range for the gold chart tab strip (1月/3月/1年/全部). +enum GoldRange: String, CaseIterable, Identifiable { + case oneMonth = "1月" + case threeMonth = "3月" + case oneYear = "1年" + case all = "全部" + + var id: String { rawValue } + + /// Number of days to slice from the daily history. `nil` = all. + var days: Int? { + switch self { + case .oneMonth: return 22 // ~22 trading days/month + case .threeMonth: return 66 + case .oneYear: return 250 + case .all: return nil + } + } +} diff --git a/Sources/data/SinaQuoteParser.swift b/Sources/data/SinaQuoteParser.swift new file mode 100644 index 0000000..bdfd654 --- /dev/null +++ b/Sources/data/SinaQuoteParser.swift @@ -0,0 +1,184 @@ +// +// SinaQuoteParser.swift +// 盯基金 plugin +// +// Parses Sina Finance's classic JS-string quote format. Every realtime +// endpoint of the form `https://hq.sinajs.cn/list=XXX` returns the same +// shape: +// +// var hq_str_XXX="field1,field2,field3,..."; +// +// Field positions are domain-dependent. We only decode the four flavors +// this plugin uses (hf_* foreign futures and AU* domestic futures). +// +// GBK is the encoding for the Chinese name fields. We don't care about +// those — we only need the numeric fields, which are pure ASCII and +// parse fine even when the rest of the string is garbled bytes. +// + +import Foundation + +enum SinaQuoteParser { + + /// Parse a Sina quote response into a single GoldQuote. + /// Returns nil if the data is empty (Sina returns a one-byte + /// `""` payload for unknown symbols). + static func parse(rawString: String, source: GoldQuote.Source) -> GoldQuote? { + // Find the first `="` ... `";` payload. + guard let eq = rawString.firstIndex(of: "="), + let firstQuote = rawString[eq...].firstIndex(of: "\"") else { + return nil + } + let afterQuote = rawString.index(after: firstQuote) + guard let lastQuote = rawString[afterQuote...].lastIndex(of: "\"") else { + return nil + } + let body = String(rawString[afterQuote.. GoldQuote? { + if let s = String(data: data, encoding: .utf8) { + return parse(rawString: s, source: source) + } + // Chinese encodings — Sina uses GB18030 (superset of GBK). + let cfStr = CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue)) + let nsEnc = String.Encoding(rawValue: cfStr) + if let s = String(data: data, encoding: nsEnc) { + return parse(rawString: s, source: source) + } + // Last resort: ASCII with lossy conversion. Numeric fields survive. + let lossy = String(data: data, encoding: .ascii) ?? "" + return parse(rawString: lossy, source: source) + } + + // MARK: - Foreign futures (hf_*) + // + // Verified field layout (from `https://hq.sinajs.cn/list=hf_GC` on + // 2026-04-25 nightly run — see test-final.py output): + // + // [0] last (latest) 4728.253 + // [1] (empty / bid sometimes) + // [2] open 4725.100 + // [3] high 4725.300 ← bug-prone: actually high + // [4] day high 4757.100 + // [5] day low 4672.200 + // [6] update time "04:59:58" + // [7] prev settle 4724.000 + // [8] prev close 4715.600 + // [9-11] zeros / volume / position + // [12] date "2026-04-25" + // [13] name (GBK garbled here) + // + // We only need: last, day high, day low, prev close, update time, date. + private static func parseForeignFutures(fields: [String], source: GoldQuote.Source) -> GoldQuote? { + guard fields.count >= 13 else { return nil } + guard let last = Double(fields[0]) else { return nil } + let high = Double(fields[4]) + let low = Double(fields[5]) + let prevClose = Double(fields[8]).nonZero + let timeStr = fields[6] + let dateStr = fields[12] + let updatedAt = combineForeign(date: dateStr, time: timeStr) + + // hf_GC / hf_XAU return USD/oz. RMB conversion is NOT included + // in the raw feed (akshare adds it on the Python side using a + // separate FX call). We compute it here only when the realtime + // USD/CNY rate is available — for v0.1 we punt and let the UI + // call FxClient if needed. + let open = Double(fields[2]) + return GoldQuote( + source: source, last: last, + open: open, prevClose: prevClose, high: high, low: low, + updatedAt: updatedAt, rmbPerGram: nil + ) + } + + // MARK: - Domestic futures (AU0 etc.) + // + // Verified field layout from `https://hq.sinajs.cn/list=AU0` (2026-04-25): + // + // [0] name (GBK) + // [1] (volume of last tick) 145957 + // [2] open 574.86 + // [3] high 585.84 + // [4] low 574.40 + // [5] last 574.94 + // [6] bid1 581.58 + // [7] ask1 581.60 + // [8] last_settle? 581.56 + // [9] reserved 0.00 + // [10] prev close 572.76 + // [13] volume 189097 + // [14] open interest 308940 + // [17] date "2024-07-17" ← stale on this snapshot, normal off-hours + // + // Domestic AU is RMB/g — we set rmbPerGram = last directly. + private static func parseDomesticFutures(fields: [String], source: GoldQuote.Source) -> GoldQuote? { + guard fields.count >= 11 else { return nil } + guard let last = Double(fields[5]), last > 0 else { return nil } + let high = Double(fields[3]) + let low = Double(fields[4]) + let prevClose = Double(fields[10]).nonZero + + // Sina exposes a settled date but not a precise time stamp + // for AU realtime. Use the trade date + current wall time + // when fields[17] is available; otherwise leave nil. + let dateStr = fields.count > 17 ? fields[17] : "" + let updatedAt = combineDomestic(date: dateStr) + let open = Double(fields[2]) + + return GoldQuote( + source: source, last: last, + open: open, prevClose: prevClose, high: high, low: low, + updatedAt: updatedAt, rmbPerGram: last + ) + } + + // MARK: - Date helpers + + /// Foreign futures format "2026-04-25" + "04:59:58" → Date in + /// America/New_York-equivalent rendered in CN time zone for display. + private static func combineForeign(date: String, time: String) -> Date? { + guard !date.isEmpty, !time.isEmpty else { return nil } + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + // Sina serves the NY/London exchange wall time as-is; we tag + // it with Asia/Shanghai so it lines up with the Mac's local + // clock for "时间感". Investors care more about elapsed-time + // freshness than which time zone the exchange ran in. + f.timeZone = TimeZone(identifier: "Asia/Shanghai") + f.dateFormat = "yyyy-MM-dd HH:mm:ss" + return f.date(from: "\(date) \(time)") + } + + private static func combineDomestic(date: String) -> Date? { + guard !date.isEmpty else { return nil } + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(identifier: "Asia/Shanghai") + f.dateFormat = "yyyy-MM-dd" + return f.date(from: date) + } +} + +private extension Optional where Wrapped == Double { + /// Sina sometimes encodes "no prev close" as 0. Drop that to nil + /// so downstream code doesn't compute a +∞ percent change. + var nonZero: Double? { + if case .some(let v) = self, v > 0 { return v } + return nil + } +} diff --git a/Sources/data/SpotGoldClient.swift b/Sources/data/SpotGoldClient.swift new file mode 100644 index 0000000..d5a0798 --- /dev/null +++ b/Sources/data/SpotGoldClient.swift @@ -0,0 +1,102 @@ +// +// SpotGoldClient.swift +// 看盘侠 plugin v0.3 +// +// 上海金现货 AU9999 (SGE) realtime — same data Alipay shows on the +// 「国内金价」 tab. Distinct from the SHFE futures AU0 we already pull: +// AU9999 is the SGE physical-spot reference price retail jewellery +// banks quote off, whereas AU0 is the futures contract. +// +// Endpoint: `https://hq.sinajs.cn/list=gds_AU9999` +// +// Field layout (verified 2026-04-26): +// [0] current price 1040.00 +// [1] ? 0 +// [2] ? 1039.00 (looks like avg or bid?) +// [3] ? 1049.00 +// [4] high 1044.00 +// [5] low 1034.00 +// [6] time "02:30:00" +// [7] prevClose 1033.25 +// [8] open 1039.90 +// [9] volume 3136 +// [10] (purity?) 100.00 +// [11] (purity?) 100.00 +// [12] date "2026-04-25" +// [13] name (GBK) "黄金99" +// + +import Foundation + +/// Spot gold quote (SGE AU9999 / AU99.99). +struct SpotGoldQuote: Equatable { + let last: Double // current + let prevClose: Double // 昨收 + let open: Double // 今开 + let high: Double // 最高 + let low: Double // 最低 + let updatedAt: Date? + let name: String // "黄金99" + + var change: Double { last - prevClose } + var changeRate: Double { prevClose == 0 ? 0 : change / prevClose * 100 } +} + +actor SpotGoldClient { + static let shared = SpotGoldClient() + private let session: URLSession + + init() { + let cfg = URLSessionConfiguration.default + cfg.timeoutIntervalForRequest = 6 + cfg.timeoutIntervalForResource = 12 + cfg.requestCachePolicy = .reloadIgnoringLocalCacheData + self.session = URLSession(configuration: cfg) + } + + /// Fetch the current AU9999 spot quote. + func quote() async throws -> SpotGoldQuote { + let url = URL(string: "https://hq.sinajs.cn/list=gds_AU9999")! + var req = URLRequest(url: url) + req.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent") + req.setValue("https://finance.sina.com.cn/", forHTTPHeaderField: "Referer") + + let (data, _) = try await session.data(for: req) + // Sina serves GB18030 here too (name field is Chinese). + let gbEnc = String.Encoding(rawValue: + CFStringConvertEncodingToNSStringEncoding( + CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue))) + let s = String(data: data, encoding: gbEnc) + ?? String(data: data, encoding: .utf8) + ?? "" + + guard let lQ = s.firstIndex(of: "\""), + let rQ = s[s.index(after: lQ)...].firstIndex(of: "\"") else { + throw FundClientError.malformed("AU9999 payload parse") + } + let body = String(s[s.index(after: lQ)..= 13, + let last = Double(fields[0]), + let high = Double(fields[4]), + let low = Double(fields[5]), + let prevClose = Double(fields[7]), + let open = Double(fields[8]) else { + throw FundClientError.malformed("AU9999 field decode (\(fields.count) fields)") + } + let timeStr = fields[6] + let dateStr = fields[12] + let fmt = DateFormatter() + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.timeZone = TimeZone(identifier: "Asia/Shanghai") + fmt.dateFormat = "yyyy-MM-dd HH:mm:ss" + let updatedAt = fmt.date(from: "\(dateStr) \(timeStr)") + let name = fields.count > 13 ? fields[13] : "上海金" + + return SpotGoldQuote( + last: last, prevClose: prevClose, open: open, + high: high, low: low, updatedAt: updatedAt, name: name + ) + } +} diff --git a/Sources/engine/FundDebugLog.swift b/Sources/engine/FundDebugLog.swift new file mode 100644 index 0000000..4ef1898 --- /dev/null +++ b/Sources/engine/FundDebugLog.swift @@ -0,0 +1,41 @@ +// +// FundDebugLog.swift +// 看盘侠 plugin +// +// Plugin-bundle NSLog calls don't reliably surface in `log show` from +// the host process — they get filtered, deduped, or eaten by the +// subsystem. This helper writes timestamped lines to a known file so +// we can `tail -f` it during debugging without guessing log filters. +// + +import Foundation + +enum FundDebugLog { + static let path = "/tmp/fund-plugin.log" + private static let queue = DispatchQueue(label: "fund.debug.log") + private static let dateFmt: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone.current + f.dateFormat = "HH:mm:ss.SSS" + return f + }() + + static func write(_ message: String) { + let stamp = dateFmt.string(from: Date()) + let line = "[\(stamp)] \(message)\n" + queue.async { + if let data = line.data(using: .utf8) { + if FileManager.default.fileExists(atPath: path) { + if let handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: path)) { + try? handle.seekToEnd() + try? handle.write(contentsOf: data) + try? handle.close() + } + } else { + try? data.write(to: URL(fileURLWithPath: path)) + } + } + } + } +} diff --git a/Sources/engine/FundStore.swift b/Sources/engine/FundStore.swift new file mode 100644 index 0000000..c757d87 --- /dev/null +++ b/Sources/engine/FundStore.swift @@ -0,0 +1,211 @@ +// +// FundStore.swift +// 盯基金 plugin v0.2 +// +// Glue between watchlist + clients + UI. Now also caches gold daily +// K-line so the chart can switch ranges (1月/3月/1年/全部) without +// re-fetching. +// + +import Combine +import Foundation +import SwiftUI + +@MainActor +final class FundStore: ObservableObject { + static let shared = FundStore() + + @Published private(set) var estimates: [String: FundEstimate] = [:] + @Published private(set) var goldQuotes: [GoldQuote.Source: GoldQuote] = [:] + @Published private(set) var goldDailyBars: [GoldDailyBar] = [] + /// Spot gold (AU9999) — what Alipay shows on 「国内金价」 tab. + @Published private(set) var spotGold: SpotGoldQuote? + /// Today's intraday minute line for AU0 — replaces daily K-line in chart. + @Published private(set) var goldMinuteLine: [GoldMinutePoint] = [] + @Published private(set) var lastFundRefresh: Date? + @Published private(set) var lastGoldRefresh: Date? + @Published private(set) var lastGoldHistRefresh: Date? + @Published private(set) var isRefreshing = false + + let watchlist: Watchlist + let goldPosition: GoldPositionStore + private let scheduler = RefreshScheduler() + private var fundTask: Task? + private var goldTask: Task? + + init() { + self.watchlist = Watchlist() + self.goldPosition = GoldPositionStore() + } + + // MARK: - Lifecycle + + func start() { + guard fundTask == nil else { return } + fundTask = Task { [weak self] in await self?.fundLoop() } + goldTask = Task { [weak self] in await self?.goldLoop() } + + // K-line history is rarely refreshed — once on start, then once + // every hour (price moved enough to redraw the bottom of the chart). + Task { [weak self] in await self?.refreshGoldKLine() } + } + + func stop() { + fundTask?.cancel(); fundTask = nil + goldTask?.cancel(); goldTask = nil + } + + // MARK: - Manual triggers + + func refreshNow() async { + await refreshFunds() + await refreshGold() + await refreshGoldKLine() + } + + // MARK: - Fund refresh + + private func fundLoop() async { + await refreshFunds() + while !Task.isCancelled { + let interval = scheduler.interval(for: .fundsActive) + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + if Task.isCancelled { break } + await refreshFunds() + } + } + + private func refreshFunds() async { + let codes = watchlist.codes + guard !codes.isEmpty else { return } + isRefreshing = true + + // Split codes by venue: ETFs (场内 — Shanghai/Shenzhen exchange) + // get the stock-like Sina endpoint, mutual funds (场外) get the + // Tiantian estimation feed. Same FundEstimate output shape so the + // UI doesn't care which venue a row came from. + let etfCodes = codes.filter { ETFClient.isETFCode($0) } + let otcCodes = codes.filter { !ETFClient.isETFCode($0) } + FundDebugLog.write("refreshFunds start codes=\(codes) etf=\(etfCodes) otc=\(otcCodes)") + + async let otcResults: [FundEstimate] = FundClient.shared.estimates(for: otcCodes) + async let etfQuotes: [ETFQuote] = ETFClient.shared.quotes(for: etfCodes) + + var dict = self.estimates + let otc = await otcResults + let etf = await etfQuotes + FundDebugLog.write("refreshFunds got otc=\(otc.count)/\(otcCodes.count) etf=\(etf.count)/\(etfCodes.count)") + for r in otc { dict[r.code] = r } + for q in etf { + // Map ETFQuote → FundEstimate so UI is uniform. published = + // prevClose, intraday = current price, rate = day change %. + dict[q.code] = FundEstimate( + code: q.code, + name: q.name, + publishedDate: q.updatedAt ?? Date(), + publishedNav: q.prevClose, + estimatedNav: q.last, + estimatedRate: q.changeRate, + estimatedAt: q.updatedAt + ) + } + self.estimates = dict + self.lastFundRefresh = Date() + isRefreshing = false + } + + // MARK: - Gold realtime + + private func goldLoop() async { + await refreshGold() + while !Task.isCancelled { + let interval = scheduler.interval(for: .gold) + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + if Task.isCancelled { break } + await refreshGold() + } + } + + private func refreshGold() async { + // Three things in parallel: legacy realtime (London/NY/SHFE), + // AU9999 spot, and the minute line. None block the others — + // each falls back silently on failure. + async let realtime: [GoldQuote] = GoldClient.shared.quoteAll() + async let spot: SpotGoldQuote? = try? SpotGoldClient.shared.quote() + async let minute: [GoldMinutePoint] = (try? await GoldMinuteClient.shared.minuteLine()) ?? [] + + var dict = self.goldQuotes + for q in await realtime { dict[q.source] = q } + self.goldQuotes = dict + if let s = await spot { self.spotGold = s } + let m = await minute + if !m.isEmpty { self.goldMinuteLine = m } + self.lastGoldRefresh = Date() + } + + // MARK: - Gold K-line history + + private func refreshGoldKLine() async { + do { + let bars = try await GoldKlineClient.shared.dailyKLine(symbol: "AU0") + self.goldDailyBars = bars + self.lastGoldHistRefresh = Date() + } catch { + NSLog("[fund-plugin] gold kline fetch failed: \(error)") + } + } + + /// Slice the loaded K-line for a given range. Returns the most + /// recent N bars (or all if range == .all). + func goldBars(for range: GoldRange) -> [GoldDailyBar] { + guard let n = range.days else { return goldDailyBars } + return Array(goldDailyBars.suffix(n)) + } + + // MARK: - Aggregate computed for hero card + + /// Total market value across all watchlist funds that have a + /// position (shares + costNav set). Returns nil when no positions. + var totalMarketValue: Double? { + var total: Double = 0 + var any = false + for f in watchlist.funds { + guard let shares = f.shares, shares > 0 else { continue } + let nav = estimates[f.code]?.bestNav + guard let n = nav else { continue } + total += shares * n + any = true + } + return any ? total : nil + } + + /// Total cost basis (Σ shares × costNav) across positions. Nil if none. + var totalCost: Double? { + var total: Double = 0 + var any = false + for f in watchlist.funds { + guard let cost = f.costAmount else { continue } + total += cost + any = true + } + return any ? total : nil + } + + /// Sum of today's ¥ P&L across funds with positions. + /// Today's ¥ delta on one fund = shares × bestNav × (estimatedRate / (100 + estimatedRate)) + /// We approximate with: shares × (bestNav - publishedNav). + var totalDayPnL: Double? { + var total: Double = 0 + var any = false + for f in watchlist.funds { + guard let shares = f.shares, shares > 0 else { continue } + guard let est = estimates[f.code] else { continue } + // Day ¥ = shares × (intraday nav - last published nav) + let estNav = est.estimatedNav ?? est.publishedNav + let delta = (estNav - est.publishedNav) * shares + total += delta + any = true + } + return any ? total : nil + } +} diff --git a/Sources/engine/RefreshScheduler.swift b/Sources/engine/RefreshScheduler.swift new file mode 100644 index 0000000..2087f9d --- /dev/null +++ b/Sources/engine/RefreshScheduler.swift @@ -0,0 +1,87 @@ +// +// RefreshScheduler.swift +// 盯基金 plugin +// +// Drives the refresh cadence based on what's tradeable right now. +// Different things have different refresh budgets: +// +// Tradeable now Cadence +// ───────────────────────────────── +// A-share funds (intraday) 60 s (estimate API is ~1 min granularity) +// A-share funds (closed) 30 min (just to pick up newly published NAV) +// Foreign gold (24/5) 30 s (Sina updates GC/XAU continuously) +// Domestic gold 30 s while SHFE open +// +// Trade hours (Asia/Shanghai): +// A-share: 09:30–11:30, 13:00–15:00, Mon–Fri +// SHFE day: 09:00–10:15, 10:30–11:30, 13:30–15:00 +// SHFE night: 21:00–02:30 +// COMEX gold: 06:00–05:00 next day (essentially 23h, 5 days/week) +// LBMA London: rolls with COMEX in practice +// + +import Foundation + +@MainActor +final class RefreshScheduler { + + enum Tier { + /// Funds intraday cadence. + case fundsActive + /// Funds idle cadence — only listening for end-of-day NAV publish. + case fundsIdle + /// Realtime gold tier (always on, foreign markets virtually 24/7). + case gold + } + + /// Returns refresh interval (seconds) for a tier at the given moment. + func interval(for tier: Tier, at now: Date = Date()) -> TimeInterval { + switch tier { + case .fundsActive: + return isAShareTradeHour(now) ? 60 : 30 * 60 + case .fundsIdle: + return 30 * 60 + case .gold: + // Always 30s — foreign gold trades virtually 24/7. SHFE has + // closed windows but we don't bother backing off; the request + // is cheap and Sina caches the latest tick anyway. + return 30 + } + } + + /// True iff `now` falls inside an A-share trading session + /// (09:30–11:30, 13:00–15:00 China time, Mon–Fri, no holiday awareness yet). + func isAShareTradeHour(_ now: Date = Date()) -> Bool { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "Asia/Shanghai")! + let weekday = cal.component(.weekday, from: now) // 1=Sun … 7=Sat + guard (2...6).contains(weekday) else { return false } + let hm = cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now) + // 09:30–11:30 + if hm >= 9 * 60 + 30 && hm < 11 * 60 + 30 { return true } + // 13:00–15:00 + if hm >= 13 * 60 && hm < 15 * 60 { return true } + return false + } + + /// True iff SHFE gold is in a trading window (loose — no holiday). + func isSHFETradeHour(_ now: Date = Date()) -> Bool { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "Asia/Shanghai")! + let weekday = cal.component(.weekday, from: now) + // Day session also runs Mon-Fri; night session technically can + // span into Sat early morning but we keep it simple. + guard (2...6).contains(weekday) else { return false } + let hm = cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now) + // 09:00-10:15 + if hm >= 9 * 60 && hm < 10 * 60 + 15 { return true } + // 10:30-11:30 + if hm >= 10 * 60 + 30 && hm < 11 * 60 + 30 { return true } + // 13:30-15:00 + if hm >= 13 * 60 + 30 && hm < 15 * 60 { return true } + // 21:00-23:59 + 00:00-02:30 + if hm >= 21 * 60 { return true } + if hm < 2 * 60 + 30 { return true } + return false + } +} diff --git a/Sources/storage/GoldPositionStore.swift b/Sources/storage/GoldPositionStore.swift new file mode 100644 index 0000000..28011b6 --- /dev/null +++ b/Sources/storage/GoldPositionStore.swift @@ -0,0 +1,57 @@ +// +// GoldPositionStore.swift +// 盯基金 plugin v0.2 +// +// Persists the single GoldPosition to its own JSON file (separate +// from watchlist.json — different shape, different lifecycle). +// +// Path: ~/Library/Application Support/Mio Island/Fund/gold-position.json +// + +import Foundation + +@MainActor +final class GoldPositionStore: ObservableObject { + @Published private(set) var position: GoldPosition = GoldPosition(grams: nil, costPerGram: nil) + + private let storeURL: URL + private let queue = DispatchQueue(label: "com.mioisland.plugin.fund.gold-position", qos: .userInitiated) + + init() { + let appSupport = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let dir = appSupport.appendingPathComponent("Mio Island/Fund", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + self.storeURL = dir.appendingPathComponent("gold-position.json") + load() + } + + func update(grams: Double?, costPerGram: Double?) { + position = GoldPosition(grams: grams, costPerGram: costPerGram) + save() + } + + private func load() { + guard let data = try? Data(contentsOf: storeURL), + let decoded = try? JSONDecoder().decode(GoldPosition.self, from: data) else { + return + } + position = decoded + } + + private func save() { + let snapshot = position + queue.async { [storeURL] in + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(snapshot) + let tmp = storeURL.appendingPathExtension("tmp") + try data.write(to: tmp, options: .atomic) + _ = try? FileManager.default.replaceItemAt(storeURL, withItemAt: tmp) + } catch { + NSLog("[fund-plugin] gold position save failed: \(error)") + } + } + } +} diff --git a/Sources/storage/Watchlist.swift b/Sources/storage/Watchlist.swift new file mode 100644 index 0000000..4aca5b5 --- /dev/null +++ b/Sources/storage/Watchlist.swift @@ -0,0 +1,118 @@ +// +// Watchlist.swift +// 盯基金 plugin v0.2 +// +// User's selected funds + their cost basis / shares (when set). +// Persisted as JSON to: +// ~/Library/Application Support/Mio Island/Fund/watchlist.json +// + +import Foundation + +@MainActor +final class Watchlist: ObservableObject { + @Published private(set) var funds: [WatchlistFund] = [] + + private let storeURL: URL + private let queue = DispatchQueue(label: "com.mioisland.plugin.fund.watchlist", qos: .userInitiated) + + init() { + let appSupport = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let dir = appSupport.appendingPathComponent("Mio Island/Fund", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + self.storeURL = dir.appendingPathComponent("watchlist.json") + load() + } + + // MARK: - Public API + + func add(_ hit: FundSearchHit) { + guard !funds.contains(where: { $0.code == hit.code }) else { return } + let item = WatchlistFund( + code: hit.code, name: hit.name, + addedAt: Date(), + displayOrder: (funds.map { $0.displayOrder }.max() ?? 0) + 1, + shares: nil, costNav: nil + ) + funds.append(item) + save() + } + + func remove(code: String) { + funds.removeAll { $0.code == code } + save() + } + + func updatePosition(code: String, shares: Double?, costNav: Double?) { + guard let idx = funds.firstIndex(where: { $0.code == code }) else { return } + var f = funds[idx] + f.shares = shares + f.costNav = costNav + funds[idx] = f + save() + } + + func move(from source: IndexSet, to destination: Int) { + funds.move(fromOffsets: source, toOffset: destination) + for (idx, var f) in funds.enumerated() { + f.displayOrder = idx + funds[idx] = f + } + save() + } + + func contains(_ code: String) -> Bool { + funds.contains { $0.code == code } + } + + var codes: [String] { funds.map(\.code) } + + func fund(code: String) -> WatchlistFund? { + funds.first { $0.code == code } + } + + // MARK: - Persistence + + private func load() { + guard let data = try? Data(contentsOf: storeURL) else { return } + do { + let decoded = try JSONDecoder.fundDecoder.decode([WatchlistFund].self, from: data) + self.funds = decoded.sorted(by: { $0.displayOrder < $1.displayOrder }) + } catch { + NSLog("[fund-plugin] watchlist load failed: \(error)") + } + } + + private func save() { + let snapshot = self.funds + queue.async { [storeURL] in + do { + let encoder = JSONEncoder.fundEncoder + let data = try encoder.encode(snapshot) + let tmp = storeURL.appendingPathExtension("tmp") + try data.write(to: tmp, options: .atomic) + _ = try? FileManager.default.replaceItemAt(storeURL, withItemAt: tmp) + } catch { + NSLog("[fund-plugin] watchlist save failed: \(error)") + } + } + } +} + +private extension JSONEncoder { + static var fundEncoder: JSONEncoder { + let e = JSONEncoder() + e.outputFormatting = [.prettyPrinted, .sortedKeys] + e.dateEncodingStrategy = .iso8601 + return e + } +} + +private extension JSONDecoder { + static var fundDecoder: JSONDecoder { + let d = JSONDecoder() + d.dateDecodingStrategy = .iso8601 + return d + } +} diff --git a/Sources/ui/AddView.swift b/Sources/ui/AddView.swift new file mode 100644 index 0000000..7205e12 --- /dev/null +++ b/Sources/ui/AddView.swift @@ -0,0 +1,227 @@ +// +// AddView.swift +// 盯基金 plugin v0.2 +// +// 「添加」tab. Search field + scrollable list of hits with category +// tag, code, and an add button. Mirrors the design's panel-add.jsx +// but uses the live Eastmoney suggest API instead of mock data. +// + +import SwiftUI + +struct AddView: View { + @ObservedObject var store: FundStore + @State private var query = "" + @State private var hits: [FundSearchHit] = [] + @State private var isSearching = false + @State private var debounceTask: Task? + + var body: some View { + VStack(spacing: 0) { + searchField + .padding(.horizontal, 16) + .padding(.top, 4) + .padding(.bottom, 6) + sectionTitle + .padding(.horizontal, 16) + .padding(.top, 4) + .padding(.bottom, 4) + list + } + } + + private var searchField: some View { + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 10) + .fill(FundTheme.overlay06) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(FundTheme.overlay08, lineWidth: 0.5) + ) + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(FundTheme.fg40) + TextField("搜索基金代码 / 名称", text: $query) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .foregroundColor(.white) + if isSearching { + ProgressView().scaleEffect(0.5) + } + } + .padding(.horizontal, 12) + } + .frame(height: 36) + .onChange(of: query) { _, newValue in + debounceTask?.cancel() + let q = newValue + debounceTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + await runSearch(q) + } + } + } + + private var sectionTitle: some View { + HStack { + Text(query.isEmpty ? "添加你的基金" : "搜索结果") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(FundTheme.fg40) + .tracking(0.5) + Spacer() + } + } + + @ViewBuilder + private var list: some View { + if query.isEmpty { + VStack(spacing: 12) { + Spacer().frame(height: 30) + Image(systemName: "magnifyingglass") + .font(.system(size: 22)) + .foregroundColor(FundTheme.fg35) + .padding(20) + .background(Circle().fill(FundTheme.overlay04)) + Text("输入基金代码或名称开始搜索") + .font(.system(size: 12)) + .foregroundColor(FundTheme.fg40) + Text("数据源: 东方财富 fund.eastmoney.com") + .font(.system(size: 10)) + .foregroundColor(FundTheme.fg35) + Spacer() + } + .frame(maxWidth: .infinity) + } else if hits.isEmpty && !isSearching { + VStack { + Spacer().frame(height: 40) + Text("未找到匹配的基金") + .font(.system(size: 12)) + .foregroundColor(FundTheme.fg35) + Spacer() + } + .frame(maxWidth: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(hits) { hit in + SearchRow( + hit: hit, + alreadyAdded: store.watchlist.contains(hit.code) + ) { + if !store.watchlist.contains(hit.code) { + store.watchlist.add(hit) + Task { await store.refreshNow() } + } + } + } + } + .padding(.bottom, 4) + } + } + } + + private func runSearch(_ q: String) async { + guard !q.isEmpty else { hits = []; return } + isSearching = true + defer { isSearching = false } + do { + hits = try await FundClient.shared.search(q) + } catch { + hits = [] + } + } +} + +private struct SearchRow: View { + let hit: FundSearchHit + let alreadyAdded: Bool + let onTap: () -> Void + + @State private var isHovered = false + + var body: some View { + Button(action: onTap) { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(hit.name) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(FundTheme.fgPrimary) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + HStack(spacing: 8) { + tagChip(hit.displayTag, color: tagColor(hit.displayTag)) + Text(hit.code) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundColor(FundTheme.fg40) + if let cat = hit.category, cat != hit.displayTag { + Text(cat) + .font(.system(size: 10)) + .foregroundColor(FundTheme.fg40) + .lineLimit(1) + } + } + } + Spacer() + addBtn + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(isHovered ? FundTheme.overlay04 : Color.clear) + } + .buttonStyle(.plain) + .disabled(alreadyAdded) + .onHover { isHovered = $0 } + } + + private var addBtn: some View { + Group { + if alreadyAdded { + ZStack { + Circle() + .fill(FundTheme.overlay08) + .frame(width: 26, height: 26) + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundColor(FundTheme.downGreen) + } + } else { + ZStack { + Circle() + .fill(FundTheme.lime) + .frame(width: 26, height: 26) + Image(systemName: "plus") + .font(.system(size: 13, weight: .bold)) + .foregroundColor(.black) + } + } + } + } + + private func tagChip(_ text: String, color: Color) -> some View { + Text(text) + .font(.system(size: 9.5, weight: .bold)) + .foregroundColor(color) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 3) + .fill(color.opacity(0.18)) + ) + } + + private func tagColor(_ tag: String) -> Color { + switch tag { + case "ETF": return Color(red: 0xFF/255, green: 0x8A/255, blue: 0x4C/255) + case "指数": return Color(red: 0x4C/255, green: 0xB6/255, blue: 0xFF/255) + case "股票": return Color(red: 0xA7/255, green: 0x85/255, blue: 0xFF/255) + case "QDII": return Color(red: 0xFF/255, green: 0xC4/255, blue: 0x54/255) + case "混合": return Color(red: 0x9A/255, green: 0xA0/255, blue: 0xA8/255) + case "债券": return Color(red: 0x2C/255, green: 0xD4/255, blue: 0x7E/255) + case "货币": return Color(red: 0x7A/255, green: 0xC0/255, blue: 0xC0/255) + default: return Color(red: 0x88/255, green: 0x88/255, blue: 0x88/255) + } + } +} diff --git a/Sources/ui/ExpandedView.swift b/Sources/ui/ExpandedView.swift new file mode 100644 index 0000000..eba61b4 --- /dev/null +++ b/Sources/ui/ExpandedView.swift @@ -0,0 +1,243 @@ +// +// ExpandedView.swift +// 盯基金 plugin v0.2 +// +// Top-level panel container — 380×540 to match design spec. Renders +// the title bar (with refresh button + live dot) + tab pill strip + +// active tab body (HoldingsView / GoldView / AddView). +// + +import SwiftUI + +struct ExpandedView: View { + @ObservedObject var store: FundStore = .shared + + enum Tab: String, CaseIterable, Identifiable { + case holdings = "持仓" + case gold = "黄金" + case add = "添加" + var id: String { rawValue } + } + + @State private var tab: Tab = .holdings + @State private var refreshSpinning = false + + var body: some View { + VStack(spacing: 0) { + // Notch reservation strip (40pt) — same pattern Music Player + // uses. The physical camera/notch module + the host's + // floating back-chevron (at y=12, host-controlled) live in + // this band. No plugin paint here so the dark Island shell + // shows through cleanly behind the notch. + Color.clear.frame(height: 40) + topBar + tabStrip + body_ + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + footer + } + .frame(width: 380, height: 580) + .background( + ZStack { + FundTheme.panelBg + // very subtle radial highlight at the top to feel like the + // notch glow rolling onto the panel + RadialGradient( + colors: [Color.white.opacity(0.04), Color.clear], + center: .top, + startRadius: 4, endRadius: 220 + ) + } + ) + .clipShape( + UnevenRoundedRectangle( + cornerRadii: .init(topLeading: 0, bottomLeading: 28, bottomTrailing: 28, topTrailing: 0) + ) + ) + .onAppear { + store.start() + Task { await store.refreshNow() } + } + } + + // MARK: - Top bar + + private var topBar: some View { + HStack(spacing: 10) { + // The notch strip above (40pt) already clears the host's + // floating back-chevron, so the title can sit flush at the + // panel's leading edge — no horizontal indent needed. + HStack(spacing: 8) { + Circle() + .fill(FundTheme.lime) + .frame(width: 7, height: 7) + .shadow(color: FundTheme.lime.opacity(0.6), radius: 4) + Text("看盘侠") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(FundTheme.fgPrimary) + } + Spacer() + + // Refresh button + Button { + refreshSpinning = true + Task { + await store.refreshNow() + try? await Task.sleep(nanoseconds: 500_000_000) + refreshSpinning = false + } + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 12)) + .foregroundColor(refreshSpinning ? FundTheme.lime : FundTheme.fg55) + .frame(width: 28, height: 28) + .background(Circle().fill(Color.clear)) + .rotationEffect(.degrees(refreshSpinning ? 360 : 0)) + .animation(.easeInOut(duration: 0.6), value: refreshSpinning) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.top, 14) + .padding(.bottom, 8) + } + + // MARK: - Tabs + + private var tabStrip: some View { + HStack(spacing: 8) { + ForEach(Tab.allCases) { t in + tabPill(label: t.rawValue, count: count(for: t), selected: tab == t) { + tab = t + } + } + Spacer() + } + .padding(.horizontal, 16) + .padding(.bottom, 10) + } + + private func tabPill(label: String, count: Int?, selected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 6) { + Text(label) + .font(.system(size: 12.5, weight: .semibold)) + if let c = count { + Text("\(c)") + .font(.system(size: 10.5, weight: .semibold)) + .opacity(selected ? 0.55 : 0.7) + .monospacedDigit() + } + } + .foregroundColor(selected ? Color(red: 0x0B/255, green: 0x0B/255, blue: 0x0B/255) : FundTheme.fg70) + .padding(.horizontal, 14) + .frame(height: 28) + .background( + Capsule() + .fill(selected ? FundTheme.lime : FundTheme.overlay04) + .overlay( + Capsule() + .stroke(selected ? Color.clear : FundTheme.overlay08, lineWidth: 0.5) + ) + ) + } + .buttonStyle(.plain) + } + + private func count(for tab: Tab) -> Int? { + switch tab { + case .holdings: return store.watchlist.funds.count + default: return nil + } + } + + // MARK: - Body + + @ViewBuilder + private var body_: some View { + switch tab { + case .holdings: + HoldingsView(store: store) { tab = .add } + case .gold: + GoldView(store: store) + case .add: + AddView(store: store) + } + } + + // MARK: - Footer + + private var footer: some View { + HStack { + HStack(spacing: 6) { + LiveDot() + Text(footerLeftText) + } + Spacer() + Text(footerRightText) + } + .font(.system(size: 11)) + .foregroundColor(FundTheme.fg40) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + Rectangle() + .fill(Color.clear) + .overlay( + Rectangle() + .fill(FundTheme.overlay04) + .frame(height: 0.5), + alignment: .top + ) + ) + } + + private var footerLeftText: String { + switch tab { + case .holdings: return store.estimates.isEmpty ? "等待数据..." : "实时估值" + case .gold: return "沪金 SHFE" + case .add: return "东方财富搜索" + } + } + + private var footerRightText: String { + if let t = lastUpdate { + return "更新于 \(relative(t))" + } + return "—" + } + + private var lastUpdate: Date? { + switch tab { + case .holdings: return store.lastFundRefresh + case .gold: return store.lastGoldRefresh + case .add: return nil + } + } + + private func relative(_ d: Date) -> String { + let s = Int(Date().timeIntervalSince(d)) + if s < 60 { return "\(s)s 前" } + if s < 3600 { return "\(s / 60)分前" } + return "\(s / 3600)时前" + } +} + +// MARK: - Live dot (small pulsing green dot, copied from design) + +private struct LiveDot: View { + @State private var pulse = false + var body: some View { + Circle() + .fill(FundTheme.downGreen) + .frame(width: 6, height: 6) + .shadow(color: FundTheme.downGreen.opacity(0.7), radius: 4) + .scaleEffect(pulse ? 0.8 : 1.0) + .opacity(pulse ? 0.4 : 1.0) + .onAppear { + withAnimation(.easeInOut(duration: 1.6).repeatForever(autoreverses: true)) { + pulse = true + } + } + } +} diff --git a/Sources/ui/GoldPositionCard.swift b/Sources/ui/GoldPositionCard.swift new file mode 100644 index 0000000..9b5bea7 --- /dev/null +++ b/Sources/ui/GoldPositionCard.swift @@ -0,0 +1,239 @@ +// +// GoldPositionCard.swift +// 盯基金 plugin v0.2 +// +// 「我的持仓」card under the gold chart. Mirrors the design's +// `.gold-position` block. Tap-to-edit grams + cost-per-gram inline. +// + +import SwiftUI + +struct GoldPositionCard: View { + @ObservedObject var store: FundStore + @State private var isEditing = false + @State private var gramsText = "" + @State private var costText = "" + @FocusState private var focused: Field? + + enum Field { case grams, cost } + + /// Current gold price in RMB/g (from SHFE realtime). + private var currentPrice: Double? { + store.goldQuotes[.shfeFutures]?.last + } + + var body: some View { + Group { + if isEditing { + editor + } else { + display + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(FundTheme.overlay04) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(FundTheme.overlay08, lineWidth: 0.5) + ) + ) + } + + // MARK: - Display + + private var display: some View { + let p = store.goldPosition.position + let cur = currentPrice + let marketValue: Double? = { + guard let g = p.grams, let cur else { return nil } + return g * cur + }() + let pnl: Double? = { + guard let mv = marketValue, let cost = p.costAmount else { return nil } + return mv - cost + }() + let pnlPct: Double? = { + guard let pnl = pnl, let cost = p.costAmount, cost > 0 else { return nil } + return pnl / cost * 100 + }() + + return VStack(alignment: .leading, spacing: 8) { + if !p.hasPosition { + // Empty state inline — encourage the user to fill in. + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("我的持仓") + .font(.system(size: 11.5, weight: .medium)) + .foregroundColor(FundTheme.fg55) + Text("点击右侧添加持仓信息") + .font(.system(size: 11)) + .foregroundColor(FundTheme.fg40) + } + Spacer() + Button { + beginEdit() + } label: { + Image(systemName: "plus") + .font(.system(size: 11, weight: .bold)) + .foregroundColor(.black) + .frame(width: 24, height: 24) + .background(Circle().fill(FundTheme.gold)) + } + .buttonStyle(.plain) + } + } else { + HStack(alignment: .firstTextBaseline) { + Text("我的持仓") + .font(.system(size: 11.5, weight: .medium)) + .foregroundColor(FundTheme.fg55) + Spacer() + if let mv = marketValue { + Text("¥ \(FundFormat.unsignedMoney(mv))") + .font(.system(size: 17, weight: .bold)) + .foregroundColor(FundTheme.fgPrimary) + .monospacedDigit() + } else { + Text("—") + .font(.system(size: 17, weight: .bold)) + .foregroundColor(FundTheme.fg40) + } + } + + HStack(alignment: .firstTextBaseline, spacing: 4) { + if let g = p.grams, let c = p.costPerGram { + Text("\(FundFormat.unsignedMoney(g, decimals: 2)) g · 成本 \(String(format: "%.2f", c)) / g") + .font(.system(size: 11.5)) + .foregroundColor(FundTheme.fg55) + } + Spacer() + if let pnl, let pct = pnlPct { + Text("\(FundFormat.money(pnl)) · \(FundFormat.percent(pct))") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.upDown(pnl)) + .monospacedDigit() + } else if marketValue != nil { + Text("等价格刷新…") + .font(.system(size: 11)) + .foregroundColor(FundTheme.fg40) + } + } + + HStack(spacing: 6) { + Spacer() + Button(action: beginEdit) { + Text("编辑") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundColor(FundTheme.fg55) + .padding(.horizontal, 10) + .frame(height: 22) + .background(Capsule().fill(FundTheme.overlay06)) + } + .buttonStyle(.plain) + } + } + } + } + + // MARK: - Editor + + private var editor: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("编辑黄金持仓") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(FundTheme.fgPrimary) + Spacer() + Button(action: cancel) { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(FundTheme.fg55) + .frame(width: 18, height: 18) + .background(Circle().fill(FundTheme.overlay06)) + } + .buttonStyle(.plain) + } + HStack(spacing: 8) { + inputField(label: "克数 (g)", text: $gramsText, placeholder: "50", field: .grams) + inputField(label: "成本(元/g)", text: $costText, placeholder: "550.00", field: .cost) + } + HStack(spacing: 6) { + Button(action: clear) { + Text("清除") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(FundTheme.fg55) + .padding(.horizontal, 14) + .frame(height: 28) + .background(Capsule().fill(FundTheme.overlay06)) + } + .buttonStyle(.plain) + Spacer() + Button(action: save) { + Text("保存") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.black) + .padding(.horizontal, 14) + .frame(height: 28) + .background(Capsule().fill(FundTheme.gold)) + } + .buttonStyle(.plain) + } + } + } + + private func inputField(label: String, text: Binding, placeholder: String, field: Field) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(label) + .font(.system(size: 9.5)) + .foregroundColor(FundTheme.fg55) + TextField(placeholder, text: text) + .textFieldStyle(.plain) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(FundTheme.fgPrimary) + .focused($focused, equals: field) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.black.opacity(0.4)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke( + focused == field + ? FundTheme.gold.opacity(0.5) + : FundTheme.overlay08, + lineWidth: 0.8 + ) + ) + ) + } + .frame(maxWidth: .infinity) + } + + private func beginEdit() { + let p = store.goldPosition.position + gramsText = p.grams.map { String(format: "%.2f", $0) } ?? "" + costText = p.costPerGram.map { String(format: "%.2f", $0) } ?? "" + isEditing = true + focused = .grams + } + + private func cancel() { + isEditing = false + } + + private func save() { + let g = Double(gramsText.trimmingCharacters(in: .whitespaces)) + let c = Double(costText.trimmingCharacters(in: .whitespaces)) + let cleanG = (g.map { $0 > 0 } ?? false) ? g : nil + let cleanC = (c.map { $0 > 0 } ?? false) ? c : nil + store.goldPosition.update(grams: cleanG, costPerGram: cleanC) + isEditing = false + } + + private func clear() { + store.goldPosition.update(grams: nil, costPerGram: nil) + isEditing = false + } +} diff --git a/Sources/ui/GoldView.swift b/Sources/ui/GoldView.swift new file mode 100644 index 0000000..b6d2051 --- /dev/null +++ b/Sources/ui/GoldView.swift @@ -0,0 +1,237 @@ +// +// GoldView.swift +// 看盘侠 plugin v0.3 +// +// 「黄金」tab — Alipay-style 「国内金价」 layout: +// +// ┌──────────────────────────────────────────────┐ +// │ 黄金99 AU9999.SGE 盘中休市 04-25 │ hero +// │ 1040.00 +6.75 +0.65% │ +// │ 今开 1039.90 · 最高 1044.00 · 最低 1034.00 │ +// ├──────────────────────────────────────────────┤ +// │ intraday line chart │ chart +// │ 20:00 ────── 02:30/09:00 ────── 15:30 │ +// ├──────────────────────────────────────────────┤ +// │ 我的持仓 │ GoldPositionCard +// └──────────────────────────────────────────────┘ +// [伦敦金 pill] [纽约金 pill] reference +// + +import SwiftUI + +struct GoldView: View { + @ObservedObject var store: FundStore + + var body: some View { + ScrollView { + VStack(spacing: 8) { + hero + .padding(.horizontal, 16) + .padding(.top, 4) + chart + .padding(.horizontal, 16) + sessionAxisStrip + .padding(.horizontal, 16) + GoldPositionCard(store: store) + .padding(.horizontal, 16) + referencePills + .padding(.horizontal, 16) + Spacer(minLength: 8) + } + .padding(.bottom, 12) + } + } + + // MARK: - Hero (AU9999 spot) + + private var hero: some View { + let q = store.spotGold + return VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Circle() + .fill(FundTheme.gold) + .frame(width: 6, height: 6) + .shadow(color: FundTheme.gold.opacity(0.6), radius: 4) + Text("国内金价") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(FundTheme.gold.opacity(0.85)) + .tracking(0.5) + Text("AU9999.SGE") + .font(.system(size: 9.5, design: .monospaced)) + .foregroundColor(FundTheme.fg45) + Spacer() + if let t = q?.updatedAt { + Text(timeOnly(t)) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(FundTheme.fg45) + } + } + + HStack(alignment: .lastTextBaseline, spacing: 6) { + if let q { + Text(String(format: "%.2f", q.last)) + .font(.system(size: 30, weight: .bold)) + .foregroundColor(.upDown(q.change)) + .monospacedDigit() + Text(FundFormat.money(q.change)) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.upDown(q.change)) + .monospacedDigit() + Text(FundFormat.percent(q.changeRate)) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.upDown(q.change)) + .monospacedDigit() + Spacer() + Text("元/克") + .font(.system(size: 11)) + .foregroundColor(FundTheme.fg55) + } else { + Text("--") + .font(.system(size: 30, weight: .bold)) + .foregroundColor(FundTheme.fg40) + Text("加载中…") + .font(.system(size: 11)) + .foregroundColor(FundTheme.fg40) + } + } + .padding(.top, 1) + + if let q { + HStack(alignment: .top, spacing: 6) { + metaItem("今开", String(format: "%.2f", q.open)) + metaItem("最高", String(format: "%.2f", q.high)) + metaItem("最低", String(format: "%.2f", q.low)) + metaItem("昨收", String(format: "%.2f", q.prevClose)) + } + .padding(.top, 4) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 16) + .fill( + LinearGradient( + colors: [ + FundTheme.gold.opacity(0.08), + FundTheme.gold.opacity(0.02) + ], + startPoint: .top, endPoint: .bottom + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(FundTheme.gold.opacity(0.22), lineWidth: 0.8) + ) + ) + } + + /// Stacked vertical (label tiny on top, value below) so 4-digit + /// values like 1039.90 don't wrap mid-number when packed across the + /// 380pt panel. + private func metaItem(_ k: String, _ v: String) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(k) + .font(.system(size: 9.5)) + .foregroundColor(FundTheme.fg45) + Text(v) + .font(.system(size: 11.5, weight: .medium)) + .foregroundColor(FundTheme.fgPrimary) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.85) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func timeOnly(_ d: Date) -> String { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(identifier: "Asia/Shanghai") + f.dateFormat = "MM-dd HH:mm" + return f.string(from: d) + } + + // MARK: - Chart (intraday minute line — AU0) + + private var chart: some View { + let prices = store.goldMinuteLine.map(\.price) + return SparkLine( + values: prices, + lineColor: FundTheme.gold, + fillColor: FundTheme.gold, + pulseColor: FundTheme.gold + ) + .frame(height: 110) + .padding(8) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.white.opacity(0.02)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.05), lineWidth: 0.5) + ) + ) + } + + // MARK: - Session axis (replaces 1月/3月/1年/全部 with trade-session anchors) + + private var sessionAxisStrip: some View { + HStack { + Text("20:00") + Spacer() + Text("02:30 / 09:00") + Spacer() + Text("15:30") + } + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(FundTheme.fg45) + .padding(.horizontal, 6) + .padding(.top, -2) + } + + // MARK: - Reference pills + + private var referencePills: some View { + HStack(spacing: 8) { + referencePill(source: .london) + referencePill(source: .comex) + } + } + + private func referencePill(source: GoldQuote.Source) -> some View { + let q = store.goldQuotes[source] + return VStack(alignment: .leading, spacing: 4) { + Text(source.displayName) + .font(.system(size: 10, weight: .medium)) + .foregroundColor(FundTheme.fg55) + if let q { + Text("$\(String(format: "%.2f", q.last))") + .font(.system(size: 13, weight: .bold)) + .foregroundColor(.white) + .monospacedDigit() + if let r = q.changeRate { + Text(FundFormat.percent(r)) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundColor(.upDown(r)) + .monospacedDigit() + } + } else { + Text("--") + .font(.system(size: 13, weight: .bold)) + .foregroundColor(FundTheme.fg40) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(FundTheme.overlay04) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(FundTheme.overlay08, lineWidth: 0.5) + ) + ) + } +} diff --git a/Sources/ui/HeaderSlotView.swift b/Sources/ui/HeaderSlotView.swift new file mode 100644 index 0000000..e6b9bf3 --- /dev/null +++ b/Sources/ui/HeaderSlotView.swift @@ -0,0 +1,41 @@ +// +// HeaderSlotView.swift +// 盯基金 plugin +// +// 20×20 icon that lives in the notch header bar. We can't fit text +// in 20pt, so the visual contract is: solid icon = idle / red icon +// = portfolio down today / green icon = portfolio up today. Tap +// opens the expanded panel. +// + +import SwiftUI + +struct HeaderSlotView: View { + @ObservedObject var store: FundStore = .shared + + var body: some View { + ZStack { + Image(systemName: "chart.line.uptrend.xyaxis") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 20, height: 20) + } + } + + /// Composite tint: bias toward red/green when the day's average + /// estimated rate across watchlist funds has a sign. Falls back + /// to white when no estimates yet (e.g. first launch, off-hours). + private var tint: Color { + let rates = store.estimates.values.compactMap { $0.estimatedRate } + guard !rates.isEmpty else { return .white.opacity(0.85) } + let avg = rates.reduce(0, +) / Double(rates.count) + if avg > 0.05 { + // Chinese convention: red = up. + return Color(red: 1.0, green: 0.30, blue: 0.30) + } else if avg < -0.05 { + // Green = down. + return Color(red: 0.20, green: 0.80, blue: 0.40) + } + return .white.opacity(0.85) + } +} diff --git a/Sources/ui/HoldingsView.swift b/Sources/ui/HoldingsView.swift new file mode 100644 index 0000000..1dd780a --- /dev/null +++ b/Sources/ui/HoldingsView.swift @@ -0,0 +1,360 @@ +// +// HoldingsView.swift +// 盯基金 plugin v0.2 +// +// 「持仓」tab. Hero card with 总市值 + 今日盈亏 / 累计盈亏 / 收益率, +// followed by a scrollable list of fund rows. Falls back to an +// "empty state" when no funds are added (matches design). +// + +import SwiftUI + +struct HoldingsView: View { + @ObservedObject var store: FundStore + let onAddTap: () -> Void + @State private var editingCode: String? = nil + + var body: some View { + if store.watchlist.funds.isEmpty { + emptyState + } else { + content + } + } + + // MARK: - Empty + + private var emptyState: some View { + VStack(spacing: 12) { + Spacer() + ZStack { + Circle() + .fill(FundTheme.overlay04) + .frame(width: 48, height: 48) + Image(systemName: "magnifyingglass") + .font(.system(size: 20)) + .foregroundColor(FundTheme.fg45) + } + Text("还没添加自选基金") + .font(.system(size: 12.5)) + .foregroundColor(FundTheme.fg35) + Button(action: onAddTap) { + Text("去添加") + .font(.system(size: 12.5, weight: .semibold)) + .foregroundColor(.black) + .padding(.horizontal, 18) + .frame(height: 30) + .background(Capsule().fill(FundTheme.lime)) + } + .buttonStyle(.plain) + Spacer() + } + .frame(maxWidth: .infinity) + } + + // MARK: - Hero + list + + private var content: some View { + VStack(spacing: 0) { + heroCard + .padding(.horizontal, 16) + .padding(.top, 4) + .padding(.bottom, 8) + columnHeader + .padding(.horizontal, 18) + .padding(.bottom, 4) + ScrollView { + LazyVStack(spacing: 2) { + ForEach(store.watchlist.funds) { f in + VStack(spacing: 6) { + HoldingRow( + fund: f, + estimate: store.estimates[f.code], + onTap: { + editingCode = (editingCode == f.code) ? nil : f.code + }, + onRemove: { + if editingCode == f.code { editingCode = nil } + store.watchlist.remove(code: f.code) + } + ) + if editingCode == f.code { + PositionEditor( + fund: f, + onSave: { shares, cost in + store.watchlist.updatePosition( + code: f.code, shares: shares, costNav: cost + ) + editingCode = nil + }, + onCancel: { editingCode = nil } + ) + .padding(.horizontal, 4) + } + } + .padding(.horizontal, 8) + } + } + .padding(.bottom, 4) + } + } + } + + // MARK: - Hero card + + private var heroCard: some View { + let mv = store.totalMarketValue + let cost = store.totalCost + let day = store.totalDayPnL + let total: Double? = { + guard let mv, let cost else { return nil } + return mv - cost + }() + let totalRate: Double? = { + guard let total, let cost, cost > 0 else { return nil } + return total / cost * 100 + }() + + return VStack(alignment: .leading, spacing: 8) { + Text(mv == nil ? "自选监控" : "总市值") + .font(.system(size: 11)) + .foregroundColor(FundTheme.fg55) + .tracking(0.3) + + HStack(alignment: .lastTextBaseline, spacing: 6) { + if let mv { + Text("¥ \(FundFormat.unsignedMoney(mv))") + .font(.system(size: 28, weight: .bold)) + .foregroundColor(FundTheme.fgPrimary) + .monospacedDigit() + Text("CNY") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(FundTheme.fg55) + } else { + Text("\(store.watchlist.funds.count) 只基金") + .font(.system(size: 22, weight: .bold)) + .foregroundColor(FundTheme.fgPrimary) + Text("· 添加持仓查看盈亏") + .font(.system(size: 11)) + .foregroundColor(FundTheme.fg45) + } + } + + if mv != nil { + HStack(spacing: 18) { + statBlock(label: "今日盈亏", value: day) + statBlock(label: "累计盈亏", value: total) + statBlock(label: "收益率", value: totalRate, isPercent: true) + } + .padding(.top, 4) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 16) + .fill( + LinearGradient( + colors: [ + FundTheme.lime.opacity(0.10), + FundTheme.lime.opacity(0.02) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(FundTheme.lime.opacity(0.18), lineWidth: 0.8) + ) + ) + } + + /// Column header strip aligning with HoldingRow's two stat columns. + /// Without it the user can't tell which number is 今日 vs 累计 — both + /// look identical in a single row, and 养基宝 uses the same affordance. + private var columnHeader: some View { + HStack(spacing: 8) { + Spacer(minLength: 0) + Text("今日") + .frame(width: 80, alignment: .trailing) + Text("累计") + .frame(width: 80, alignment: .trailing) + } + .font(.system(size: 10)) + .foregroundColor(FundTheme.fg45) + } + + private func statBlock(label: String, value: Double?, isPercent: Bool = false) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(size: 10.5)) + .foregroundColor(FundTheme.fg45) + if let v = value { + Text(isPercent ? FundFormat.percent(v) : FundFormat.money(v)) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.upDown(v)) + .monospacedDigit() + } else { + Text("—") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(FundTheme.fg40) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Holding row + +private struct HoldingRow: View { + let fund: WatchlistFund + let estimate: FundEstimate? + let onTap: () -> Void + let onRemove: () -> Void + + @State private var isHovered = false + + var body: some View { + Button(action: onTap) { + content + } + .buttonStyle(.plain) + } + + /// Three-column layout matching 养基宝: + /// [name + ¥market value · 实时价] [当日 ¥ / %] [累计 ¥ / %] + /// When no position is set, left side falls back to code · NAV and + /// the 累计 column is hidden (no cost basis to compute against). + private var content: some View { + HStack(alignment: .center, spacing: 8) { + // Left: name + market value (or code) · realtime nav + VStack(alignment: .leading, spacing: 3) { + Text(fund.name) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(FundTheme.fgPrimary) + .lineLimit(1) + .truncationMode(.tail) + HStack(spacing: 4) { + if let mv = marketValue { + Text("¥ \(FundFormat.unsignedMoney(mv))") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(FundTheme.fg55) + .monospacedDigit() + } else { + Text(fund.code) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundColor(FundTheme.fg40) + } + if let nav = estimate?.bestNav, nav > 0 { + Text("·") + .font(.system(size: 10)) + .foregroundColor(FundTheme.fg35) + Text(FundFormat.nav(nav)) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundColor(FundTheme.fg45) + .monospacedDigit() + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + // Middle: 当日 (today's gain). Always rendered; falls back + // to em-dash when estimate is missing so columns stay aligned + // with the header strip above the list. + statColumn(amount: todayPnL, rate: estimate?.estimatedRate) + + // Right: 累计 (cumulative). Always rendered for column + // alignment — shows a dim em-dash when the user hasn't + // entered cost basis yet so the user knows where to look + // once they fill it in. + statColumn(amount: cumulativePnL, rate: cumulativeRate) + + if isHovered { + Button(action: onRemove) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 14)) + .foregroundColor(FundTheme.fg45) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(isHovered ? FundTheme.overlay04 : Color.clear) + ) + .onHover { isHovered = $0 } + } + + /// One stat column: ¥ amount on top (large bold), % rate below + /// (small). When ¥ is unavailable (no position) but rate is known, + /// promote the rate to the headline slot. When neither is known, + /// dim em-dashes preserve column alignment with the header strip. + /// Width 80pt so 5-digit ¥ amounts like "+4,329.79" don't truncate. + @ViewBuilder + private func statColumn(amount: Double?, rate: Double?) -> some View { + VStack(alignment: .trailing, spacing: 2) { + if let a = amount { + Text(FundFormat.money(a)) + .font(.system(size: 13, weight: .bold)) + .foregroundColor(.upDown(a)) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.85) + if let r = rate { + Text(FundFormat.percent(r)) + .font(.system(size: 10.5, weight: .medium)) + .foregroundColor(.upDown(r)) + .monospacedDigit() + .lineLimit(1) + } + } else if let r = rate { + Text(FundFormat.percent(r)) + .font(.system(size: 13, weight: .bold)) + .foregroundColor(.upDown(r)) + .monospacedDigit() + .lineLimit(1) + } else { + Text("—") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(FundTheme.fg35) + } + } + .frame(width: 80, alignment: .trailing) + } + + // MARK: - Computed P&L + + /// Today's market value = shares × current best NAV. + /// Nil when shares unset (we won't show ¥ holding amount then). + private var marketValue: Double? { + guard let shares = fund.shares, shares > 0, + let est = estimate, est.bestNav > 0 else { return nil } + return shares * est.bestNav + } + + /// Today's ¥ P&L: shares × (intraday est nav - last published nav). + private var todayPnL: Double? { + guard let shares = fund.shares, shares > 0 else { return nil } + guard let est = estimate else { return nil } + let estNav = est.estimatedNav ?? est.publishedNav + return (estNav - est.publishedNav) * shares + } + + /// Cumulative ¥ P&L: shares × (current best nav - cost basis). + private var cumulativePnL: Double? { + guard let shares = fund.shares, let cost = fund.costNav, + let est = estimate else { return nil } + return (est.bestNav - cost) * shares + } + + /// Cumulative % return: (current - cost) / cost × 100. + private var cumulativeRate: Double? { + guard let cost = fund.costNav, cost > 0, + let est = estimate else { return nil } + return (est.bestNav - cost) / cost * 100 + } +} diff --git a/Sources/ui/PositionEditor.swift b/Sources/ui/PositionEditor.swift new file mode 100644 index 0000000..59f1d01 --- /dev/null +++ b/Sources/ui/PositionEditor.swift @@ -0,0 +1,141 @@ +// +// PositionEditor.swift +// 盯基金 plugin v0.2 +// +// Inline editor for a holding's shares + cost basis. Opened from the +// holding row's "edit" button, dismissed by Save / Cancel. +// +// Why inline (rather than a separate window): plugins can't open NSWindows +// cleanly without going through the host. Inline editing inside the +// panel keeps the experience contained — and the user is already +// looking at this row. +// + +import SwiftUI + +struct PositionEditor: View { + let fund: WatchlistFund + let onSave: (Double?, Double?) -> Void + let onCancel: () -> Void + + @State private var sharesText: String = "" + @State private var costText: String = "" + @FocusState private var focused: Field? + + enum Field { case shares, cost } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("编辑持仓") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(FundTheme.fgPrimary) + Spacer() + Button(action: onCancel) { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(FundTheme.fg55) + .frame(width: 18, height: 18) + .background(Circle().fill(FundTheme.overlay06)) + } + .buttonStyle(.plain) + } + + HStack(spacing: 8) { + inputField( + label: "持仓份额", + text: $sharesText, + placeholder: "0", + field: .shares + ) + inputField( + label: "成本(元/份)", + text: $costText, + placeholder: "1.0000", + field: .cost + ) + } + + HStack(spacing: 6) { + Button(action: clear) { + Text("清除") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(FundTheme.fg55) + .padding(.horizontal, 14) + .frame(height: 28) + .background( + Capsule().fill(FundTheme.overlay06) + ) + } + .buttonStyle(.plain) + Spacer() + Button(action: save) { + Text("保存") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.black) + .padding(.horizontal, 14) + .frame(height: 28) + .background(Capsule().fill(FundTheme.lime)) + } + .buttonStyle(.plain) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(FundTheme.overlay06) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(FundTheme.lime.opacity(0.18), lineWidth: 0.8) + ) + ) + .onAppear { + // Pre-fill if values already exist + if let s = fund.shares { sharesText = String(format: "%.2f", s) } + if let c = fund.costNav { costText = String(format: "%.4f", c) } + focused = .shares + } + } + + private func inputField(label: String, text: Binding, placeholder: String, field: Field) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(label) + .font(.system(size: 9.5)) + .foregroundColor(FundTheme.fg55) + TextField(placeholder, text: text) + .textFieldStyle(.plain) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(FundTheme.fgPrimary) + .focused($focused, equals: field) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.black.opacity(0.4)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke( + focused == field + ? FundTheme.lime.opacity(0.5) + : FundTheme.overlay08, + lineWidth: 0.8 + ) + ) + ) + } + .frame(maxWidth: .infinity) + } + + private func save() { + let shares = Double(sharesText.trimmingCharacters(in: .whitespaces)) + let cost = Double(costText.trimmingCharacters(in: .whitespaces)) + // Treat 0 / negative as "clear" too. + let cleanShares = (shares.map { $0 > 0 } ?? false) ? shares : nil + let cleanCost = (cost.map { $0 > 0 } ?? false) ? cost : nil + onSave(cleanShares, cleanCost) + } + + private func clear() { + onSave(nil, nil) + } +} diff --git a/Sources/ui/SparkLine.swift b/Sources/ui/SparkLine.swift new file mode 100644 index 0000000..50f009c --- /dev/null +++ b/Sources/ui/SparkLine.swift @@ -0,0 +1,125 @@ +// +// SparkLine.swift +// 盯基金 plugin v0.2 +// +// Pure-SwiftUI port of the design's sparkline for the gold +// chart. Handles: +// - smooth quadratic curve through points +// - filled area under the curve with a vertical gradient +// - pulsing "current point" indicator +// - dashed midline grid +// - empty / 1-point degenerate cases +// + +import SwiftUI + +struct SparkLine: View { + let values: [Double] + let lineColor: Color + let fillColor: Color + let pulseColor: Color + + var body: some View { + GeometryReader { geo in + let W = geo.size.width + let H = geo.size.height + let padX: CGFloat = 8 + let padY: CGFloat = 14 + + ZStack { + // dashed midline + Path { p in + p.move(to: CGPoint(x: 0, y: H / 2)) + p.addLine(to: CGPoint(x: W, y: H / 2)) + } + .stroke( + Color.white.opacity(0.04), + style: StrokeStyle(lineWidth: 0.5, lineCap: .round, dash: [2, 4]) + ) + + // bail when not enough data + if values.count >= 2, + let maxV = values.max(), + let minV = values.min(), + maxV != minV { + + let xs: (Int) -> CGFloat = { i in + padX + (CGFloat(i) / CGFloat(values.count - 1)) * (W - padX * 2) + } + let ys: (Double) -> CGFloat = { v in + let t = (v - minV) / (maxV - minV) + return padY + (1 - CGFloat(t)) * (H - padY * 2) + } + + // area fill + Path { p in + p.move(to: CGPoint(x: xs(0), y: ys(values[0]))) + for i in 1.. Color { + if value > 0.0001 { return FundTheme.upRed } + if value < -0.0001 { return FundTheme.downGreen } + return FundTheme.fg70 + } +} + +// MARK: - Number formatting + +enum FundFormat { + /// "+0.53%" / "-1.20%" / "0.00%" + static func percent(_ value: Double, decimals: Int = 2) -> String { + let sign = value > 0 ? "+" : "" + return "\(sign)\(String(format: "%.\(decimals)f", value))%" + } + + /// "+1,234.56" / "-1,234.56" / "0.00" + static func money(_ value: Double, decimals: Int = 2) -> String { + let sign = value > 0 ? "+" : "" + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.minimumFractionDigits = decimals + formatter.maximumFractionDigits = decimals + let abs = formatter.string(from: NSNumber(value: Swift.abs(value))) ?? "0" + if value < 0 { return "-\(abs)" } + return "\(sign)\(abs)" + } + + /// "1,234.56" — no sign, used for "总市值"-like displays. + static func unsignedMoney(_ value: Double, decimals: Int = 2) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.minimumFractionDigits = decimals + formatter.maximumFractionDigits = decimals + return formatter.string(from: NSNumber(value: value)) ?? "0" + } + + /// "1.7669" — fixed decimals for NAV display. + static func nav(_ value: Double, decimals: Int = 4) -> String { + String(format: "%.\(decimals)f", value) + } +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..7179534 --- /dev/null +++ b/build.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# Build the 盯基金 plugin as a .bundle for Mio Island. +# +# Usage: +# ./build.sh # produce build/fund.bundle + build/fund.zip +# ./build.sh install # also copy bundle to ~/.config/codeisland/plugins/ + +set -e +set -o pipefail + +PLUGIN_NAME="fund" +MODULE_NAME="FundPlugin" +BUNDLE_NAME="${PLUGIN_NAME}.bundle" +BUILD_DIR="build" + +SOURCES=$(find Sources -name "*.swift" -type f) +SOURCE_COUNT=$(echo "$SOURCES" | wc -l | tr -d ' ') + +echo "Building ${PLUGIN_NAME} plugin (${SOURCE_COUNT} swift files)..." + +rm -rf "${BUILD_DIR}" +mkdir -p "${BUILD_DIR}/${BUNDLE_NAME}/Contents/MacOS" + +# arm64-only is fine for v0.1 — Mio Island host requires macOS 15+ +# which means Apple Silicon dominant, and the Sina/Eastmoney HTTP +# bits are platform-agnostic. Add x86_64 + lipo if Intel users surface. +swiftc \ + -emit-library \ + -module-name "${MODULE_NAME}" \ + -target arm64-apple-macos15.0 \ + -sdk "$(xcrun --show-sdk-path)" \ + -O \ + -o "${BUILD_DIR}/${BUNDLE_NAME}/Contents/MacOS/${MODULE_NAME}" \ + ${SOURCES} + +cp Info.plist "${BUILD_DIR}/${BUNDLE_NAME}/Contents/" + +if [ -d "Resources" ] && [ "$(ls -A Resources 2>/dev/null)" ]; then + mkdir -p "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources" + cp -R Resources/* "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources/" +fi + +# Ad-hoc sign the whole bundle. --deep covers any future nested +# resources without needing the script changed. +codesign --force --deep --sign - "${BUILD_DIR}/${BUNDLE_NAME}" + +echo "✓ Built ${BUILD_DIR}/${BUNDLE_NAME}" + +# zip for marketplace upload (顶层是 .bundle 目录, 没有 ._* 垃圾) +cd "${BUILD_DIR}" +rm -f "${PLUGIN_NAME}.zip" +zip -rq "${PLUGIN_NAME}.zip" "${BUNDLE_NAME}" +cd .. +echo "✓ Created ${BUILD_DIR}/${PLUGIN_NAME}.zip" + +if [ "${1:-}" = "install" ]; then + PLUGIN_DIR="${HOME}/.config/codeisland/plugins" + mkdir -p "${PLUGIN_DIR}" + rm -rf "${PLUGIN_DIR}/${BUNDLE_NAME}" + cp -R "${BUILD_DIR}/${BUNDLE_NAME}" "${PLUGIN_DIR}/" + echo "✓ Installed to ${PLUGIN_DIR}/${BUNDLE_NAME}" + echo " Restart Mio Island (Cmd+Q + reopen) to load the new build." +else + echo "" + echo "Install locally:" + echo " ./build.sh install" +fi