b2970e4c-45a2-423e-b84d-257.../Sources/engine/FundStore.swift

212 lines
7.5 KiB
Swift
Raw Normal View History

//
// 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
}
}