b2970e4c-45a2-423e-b84d-257.../Sources/engine/FundStore.swift
徐翔宇 e5a72bcf42 v0.2.0: initial 看盘侠 — Fund & Gold plugin for MioIsland
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) <noreply@anthropic.com>
2026-04-27 20:20:15 +08:00

212 lines
7.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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<Void, Never>?
private var goldTask: Task<Void, Never>?
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
}
}