mirror of
https://github.com/VGEAREN/Stockbar.git
synced 2026-10-07 15:37:57 +08:00
PRIOR BEHAVIOR (data-loss bug): AppStateTests.setUp() constructed AppState() directly, which used the real ~/Library/Application Support/Stockbar/ for stocks.json. Each test method that did 'sut.stocks = [s1, s2]' triggered didSet → saveStocks → silently overwrote the user's actual portfolio file with 2 test entries. backupIfNeeded then rolled all 10 backup snapshots forward, eventually erasing every historical copy of real data. This destroyed a user's 29-stock portfolio when xcodebuild test was run during Korean stock feature development (2026-05-14 10:08). FIX: - AppState gains a static appSupportDirOverride: URL? hook - AppStateTests.setUp() creates a unique tmpDir, sets the override, tearDown resets and removes the directory - Verified: after running all 7 AppStateTests, the real stocks.json is byte-identical to before the run. This must land before any further test run touches AppState.
401 lines
15 KiB
Swift
401 lines
15 KiB
Swift
import SwiftUI
|
||
import Combine
|
||
import AppKit
|
||
import os
|
||
|
||
@MainActor
|
||
final class AppState: ObservableObject {
|
||
|
||
// MARK: - 持久化(settings.json)
|
||
|
||
@Published var config: AppSettings = AppSettings() {
|
||
didSet { saveSettings(config) }
|
||
}
|
||
|
||
// MARK: - 持久化(stocks.json)
|
||
|
||
@Published var stocks: [Stock] = [] {
|
||
didSet { saveStocks(stocks) }
|
||
}
|
||
|
||
// MARK: - 持久化(watchlists.json)
|
||
|
||
@Published var watchlists: [Watchlist] = [] {
|
||
didSet { saveWatchlists(watchlists) }
|
||
}
|
||
|
||
// MARK: - 实时状态
|
||
|
||
@Published var quotes: [String: Quote] = [:]
|
||
@Published var exchangeRates: ExchangeRates = ExchangeRates()
|
||
@Published var isLoading: Bool = false
|
||
@Published var lastUpdateTime: Date? = nil
|
||
@Published var hasError: Bool = false
|
||
|
||
// MARK: - 文件路径
|
||
//
|
||
// 测试隔离:把 appSupportDirOverride 设为临时目录,测试就不会写真实用户数据。
|
||
// 若不设置,使用真实 ~/Library/Application Support/Stockbar/。
|
||
|
||
static var appSupportDirOverride: URL?
|
||
|
||
private static var appSupportDir: URL {
|
||
if let override = appSupportDirOverride {
|
||
try? FileManager.default.createDirectory(at: override, withIntermediateDirectories: true)
|
||
return override
|
||
}
|
||
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||
let dir = support.appendingPathComponent("Stockbar")
|
||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||
return dir
|
||
}
|
||
|
||
private static var stocksFileURL: URL { appSupportDir.appendingPathComponent("stocks.json") }
|
||
private static var settingsFileURL: URL { appSupportDir.appendingPathComponent("settings.json") }
|
||
private static var watchlistsFileURL: URL { appSupportDir.appendingPathComponent("watchlists.json") }
|
||
|
||
// MARK: - 加载
|
||
|
||
private static func loadStocks() -> [Stock] {
|
||
guard let data = try? Data(contentsOf: stocksFileURL),
|
||
let val = try? JSONDecoder().decode([Stock].self, from: data) else { return [] }
|
||
return val
|
||
}
|
||
|
||
private static func loadSettings() -> AppSettings {
|
||
guard let data = try? Data(contentsOf: settingsFileURL),
|
||
let val = try? JSONDecoder().decode(AppSettings.self, from: data) else { return AppSettings() }
|
||
return val
|
||
}
|
||
|
||
private static func loadWatchlists() -> [Watchlist] {
|
||
guard let data = try? Data(contentsOf: watchlistsFileURL),
|
||
let val = try? JSONDecoder().decode([Watchlist].self, from: data) else { return [] }
|
||
return val
|
||
}
|
||
|
||
// MARK: - 保存
|
||
|
||
private func saveStocks(_ stocks: [Stock]) {
|
||
if stocks.isEmpty, !Self.loadStocks().isEmpty { return }
|
||
guard let data = try? JSONEncoder().encode(stocks) else { return }
|
||
Self.backupIfNeeded()
|
||
do {
|
||
try data.write(to: Self.stocksFileURL, options: .atomic)
|
||
} catch {
|
||
logToFile("saveStocks: failed to write stocks.json: \(error)")
|
||
}
|
||
}
|
||
|
||
private func saveWatchlists(_ list: [Watchlist]) {
|
||
let encoder = JSONEncoder()
|
||
encoder.outputFormatting = .prettyPrinted
|
||
guard let data = try? encoder.encode(list) else { return }
|
||
try? data.write(to: Self.watchlistsFileURL, options: .atomic)
|
||
}
|
||
|
||
private func saveSettings(_ settings: AppSettings) {
|
||
let encoder = JSONEncoder()
|
||
encoder.outputFormatting = .prettyPrinted
|
||
guard let data = try? encoder.encode(settings) else { return }
|
||
do {
|
||
try data.write(to: Self.settingsFileURL, options: .atomic)
|
||
} catch {
|
||
logToFile("saveSettings: failed to write settings.json: \(error)")
|
||
}
|
||
}
|
||
|
||
/// 滚动备份 stocks.json,最多保留 10 份
|
||
private static func backupIfNeeded() {
|
||
let fm = FileManager.default
|
||
let src = stocksFileURL
|
||
guard fm.fileExists(atPath: src.path) else { return }
|
||
let dir = src.deletingLastPathComponent()
|
||
for i in stride(from: 9, through: 1, by: -1) {
|
||
let from = dir.appendingPathComponent("stocks.\(i).json")
|
||
let to = dir.appendingPathComponent("stocks.\(i + 1).json")
|
||
if fm.fileExists(atPath: from.path) {
|
||
do { try fm.removeItem(at: to) } catch { logToFile("backupIfNeeded: removeItem \(to.lastPathComponent) failed: \(error)") }
|
||
do { try fm.moveItem(at: from, to: to) } catch { logToFile("backupIfNeeded: moveItem \(from.lastPathComponent) -> \(to.lastPathComponent) failed: \(error)") }
|
||
}
|
||
}
|
||
let backup = dir.appendingPathComponent("stocks.1.json")
|
||
do { try fm.removeItem(at: backup) } catch { logToFile("backupIfNeeded: removeItem \(backup.lastPathComponent) failed: \(error)") }
|
||
do { try fm.copyItem(at: src, to: backup) } catch { logToFile("backupIfNeeded: copyItem to \(backup.lastPathComponent) failed: \(error)") }
|
||
}
|
||
|
||
// MARK: - 设置快捷访问(视图直接绑定这些属性)
|
||
|
||
var statusBarStockId: String {
|
||
get { config.statusBarStockId }
|
||
set { config.statusBarStockId = newValue }
|
||
}
|
||
|
||
var refreshInterval: Int {
|
||
get { config.refreshInterval }
|
||
set { config.refreshInterval = newValue }
|
||
}
|
||
|
||
var colorScheme: ColorTheme {
|
||
get { config.colorScheme }
|
||
set { config.colorScheme = newValue }
|
||
}
|
||
|
||
var displayCurrency: DisplayCurrency {
|
||
get { config.displayCurrency }
|
||
set { config.displayCurrency = newValue }
|
||
}
|
||
|
||
// MARK: - 刷新调度
|
||
|
||
private var scheduler: RefreshScheduler?
|
||
|
||
init() {
|
||
appLogger.info("AppState init start")
|
||
logToFile("AppState init start")
|
||
_stocks = Published(wrappedValue: Self.loadStocks())
|
||
_config = Published(wrappedValue: Self.loadSettings())
|
||
_watchlists = Published(wrappedValue: Self.loadWatchlists())
|
||
appLogger.info("AppState stocks loaded: \(self.stocks.count)")
|
||
logToFile("AppState stocks loaded: \(self.stocks.count)")
|
||
setupScheduler()
|
||
appLogger.info("AppState init complete")
|
||
logToFile("AppState init complete")
|
||
NSWorkspace.shared.notificationCenter.addObserver(
|
||
forName: NSWorkspace.didWakeNotification,
|
||
object: nil,
|
||
queue: .main
|
||
) { [weak self] _ in
|
||
Task { @MainActor [weak self] in self?.restartScheduler() }
|
||
}
|
||
}
|
||
|
||
func setupScheduler() {
|
||
scheduler = RefreshScheduler { [weak self] in await self?.refresh() }
|
||
scheduler?.start(interval: TimeInterval(refreshInterval))
|
||
}
|
||
|
||
func restartScheduler() {
|
||
scheduler?.stop()
|
||
setupScheduler()
|
||
}
|
||
|
||
func forceRefresh() { scheduler?.forceRefresh() }
|
||
|
||
// MARK: - 数据拉取
|
||
|
||
func refresh() async {
|
||
isLoading = true
|
||
hasError = false
|
||
do {
|
||
let all = stocks.map(\.id)
|
||
let sinaCodes = all.filter { !$0.hasPrefix("hk") && !KoreanStockID.isKorean($0) }
|
||
let hkCodes = all.filter { $0.hasPrefix("hk") }
|
||
let krIds = all.filter { KoreanStockID.isKorean($0) }
|
||
|
||
let isOvernight = Self.usMarketSession() == "夜盘"
|
||
let usCodes = isOvernight ? all.filter { $0.hasPrefix("usr_") } : []
|
||
|
||
// 所有数据源并行请求
|
||
async let sinaResult = DataService.fetchSinaQuotes(codes: sinaCodes)
|
||
async let hkResult = DataService.fetchTencentHKQuotes(codes: hkCodes)
|
||
async let krResult = DataService.fetchKoreanQuotes(ids: krIds)
|
||
async let ratesResult = CurrencyService.fetchRates()
|
||
async let overnightResult = PythService.fetchOvernightPrices(codes: usCodes)
|
||
|
||
let (s, h) = try await (sinaResult, hkResult)
|
||
let kr = await krResult
|
||
let rates = await ratesResult
|
||
let overnight = await overnightResult
|
||
|
||
// 夜盘时段:清除新浪的盘后旧价格,用夜盘实时价替换
|
||
var merged = s
|
||
if isOvernight {
|
||
for key in merged.keys where key.hasPrefix("usr_") {
|
||
merged[key]?.extendedPrice = overnight[key]
|
||
}
|
||
}
|
||
|
||
quotes.merge(merged) { $1 }
|
||
quotes.merge(h) { $1 }
|
||
quotes.merge(kr) { $1 }
|
||
exchangeRates = rates
|
||
lastUpdateTime = Date()
|
||
syncStockNamesFromQuotes()
|
||
} catch {
|
||
hasError = true
|
||
}
|
||
isLoading = false
|
||
}
|
||
|
||
private func syncStockNamesFromQuotes() {
|
||
var updated = stocks
|
||
var changed = false
|
||
for i in updated.indices {
|
||
if let q = quotes[updated[i].id], !q.name.isEmpty, q.name != updated[i].name {
|
||
updated[i].name = q.name
|
||
changed = true
|
||
}
|
||
}
|
||
if changed { stocks = updated }
|
||
}
|
||
|
||
// MARK: - 观察仓
|
||
|
||
var activeWatchlist: Watchlist? {
|
||
guard let id = config.activeWatchlistId else { return nil }
|
||
return watchlists.first(where: { $0.id == id })
|
||
}
|
||
|
||
var activeWatchlistName: String {
|
||
activeWatchlist?.name ?? "真实持仓"
|
||
}
|
||
|
||
func effectiveCostPrice(for stock: Stock) -> Double? {
|
||
if let wl = activeWatchlist, let entry = wl.entries[stock.id] {
|
||
return entry.costPrice
|
||
}
|
||
return stock.costPrice
|
||
}
|
||
|
||
func effectiveShares(for stock: Stock) -> Double? {
|
||
if let wl = activeWatchlist, let entry = wl.entries[stock.id] {
|
||
return entry.holdingShares
|
||
}
|
||
return stock.holdingShares
|
||
}
|
||
|
||
func effectivePnl(stock: Stock, quote: Quote) -> Double? {
|
||
guard let cost = effectiveCostPrice(for: stock),
|
||
let shares = effectiveShares(for: stock) else { return nil }
|
||
return (quote.price - cost) * shares
|
||
}
|
||
|
||
func effectiveDailyPnl(stock: Stock, quote: Quote) -> Double? {
|
||
guard let shares = effectiveShares(for: stock) else { return nil }
|
||
// 休市日数据是昨天的,不显示日盈亏
|
||
guard quote.isToday else { return nil }
|
||
return quote.change * shares
|
||
}
|
||
|
||
func effectivePnlPercent(stock: Stock, quote: Quote) -> Double? {
|
||
guard let cost = effectiveCostPrice(for: stock), cost > 0 else { return nil }
|
||
return (quote.price - cost) / cost * 100
|
||
}
|
||
|
||
// MARK: - 持仓汇总
|
||
|
||
var totalPnL: Double {
|
||
stocks.compactMap { s -> Double? in
|
||
guard let q = quotes[s.id], let pnl = effectivePnl(stock: s, quote: q) else { return nil }
|
||
return exchangeRates.convert(pnl, from: s.market, to: displayCurrency)
|
||
}.reduce(0, +)
|
||
}
|
||
|
||
var totalDailyPnL: Double {
|
||
stocks.compactMap { s -> Double? in
|
||
guard let q = quotes[s.id], let pnl = effectiveDailyPnl(stock: s, quote: q) else { return nil }
|
||
return exchangeRates.convert(pnl, from: s.market, to: displayCurrency)
|
||
}.reduce(0, +)
|
||
}
|
||
|
||
var totalCost: Double {
|
||
stocks.compactMap { s -> Double? in
|
||
guard let cost = effectiveCostPrice(for: s),
|
||
let shares = effectiveShares(for: s) else { return nil }
|
||
return exchangeRates.convert(cost * shares, from: s.market, to: displayCurrency)
|
||
}.reduce(0, +)
|
||
}
|
||
|
||
var totalPnLPercent: Double {
|
||
guard totalCost > 0 else { return 0 }
|
||
return totalPnL / totalCost * 100
|
||
}
|
||
|
||
var totalDailyPnLPercent: Double {
|
||
guard totalCost > 0 else { return 0 }
|
||
return totalDailyPnL / totalCost * 100
|
||
}
|
||
|
||
var hasPnLData: Bool {
|
||
stocks.contains { effectiveCostPrice(for: $0) != nil && effectiveShares(for: $0) != nil }
|
||
}
|
||
|
||
// MARK: - 状态栏
|
||
|
||
var statusBarStock: Stock? {
|
||
if statusBarStockId.hasPrefix("__") { return nil }
|
||
return stocks.first(where: { $0.id == statusBarStockId }) ?? stocks.first
|
||
}
|
||
|
||
var statusBarQuote: Quote? {
|
||
guard let s = statusBarStock else { return nil }
|
||
return quotes[s.id]
|
||
}
|
||
|
||
// MARK: - 颜色辅助
|
||
|
||
func quoteColor(for quote: Quote) -> Color {
|
||
if quote.isUp { return Color(config.upColorName) }
|
||
if quote.isDown { return Color(config.downColorName) }
|
||
return .secondary
|
||
}
|
||
|
||
func pnlColor(_ pnl: Double) -> Color {
|
||
if pnl > 0 { return Color(config.upColorName) }
|
||
if pnl < 0 { return Color(config.downColorName) }
|
||
return .secondary
|
||
}
|
||
|
||
// MARK: - 韩股交易时段
|
||
|
||
/// 韩股交易时段:返回 "盘中" 或 nil(非交易时段 / 周末)。
|
||
/// KST 固定 UTC+9,无夏令时。
|
||
nonisolated static func koreanMarketSession(at now: Date = Date()) -> String? {
|
||
var cal = Calendar(identifier: .gregorian)
|
||
cal.timeZone = TimeZone(identifier: "Asia/Seoul")!
|
||
let comps = cal.dateComponents([.weekday, .hour, .minute], from: now)
|
||
let wd = comps.weekday ?? 1
|
||
// Swift Calendar weekday: 1=周日, 2=周一, ..., 6=周五, 7=周六
|
||
guard wd >= 2 && wd <= 6 else { return nil }
|
||
let t = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
|
||
if t >= 9 * 60 && t <= 15 * 60 + 30 { return "盘中" }
|
||
return nil
|
||
}
|
||
|
||
// MARK: - 美股交易时段
|
||
|
||
static func usMarketSession() -> String? {
|
||
var cal = Calendar(identifier: .gregorian)
|
||
cal.timeZone = TimeZone(identifier: "America/New_York")!
|
||
let now = Date()
|
||
let comps = cal.dateComponents([.weekday, .hour, .minute], from: now)
|
||
// Swift Calendar weekday: 1=周日, 2=周一, 3=周二, ..., 6=周五, 7=周六
|
||
let wd = comps.weekday ?? 1
|
||
let t = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
|
||
switch t {
|
||
case 0..<240:
|
||
// ET 00:00–04:00 凌晨段(前一晚夜盘延续)
|
||
// 周一凌晨(wd=2)=周日夜盘, 周二~周五凌晨(wd=3~6), 周六凌晨(wd=7)=周五夜盘
|
||
guard wd >= 2 else { return nil } // 周日凌晨无交易
|
||
return "夜盘"
|
||
case 240..<570:
|
||
guard wd >= 2, wd <= 6 else { return nil } // 周一~周五
|
||
return "盘前" // ET 04:00–09:30
|
||
case 570..<960:
|
||
guard wd >= 2, wd <= 6 else { return nil }
|
||
return "盘中" // ET 09:30–16:00
|
||
case 960..<1200:
|
||
guard wd >= 2, wd <= 6 else { return nil }
|
||
return "盘后" // ET 16:00–20:00
|
||
case 1200..<1440:
|
||
// ET 20:00–24:00 晚间段
|
||
// 周日晚(wd=1)=周一夜盘开始, 周一~周五晚(wd=2~6)
|
||
guard wd >= 1, wd <= 6 else { return nil } // 仅排除周六晚(wd=7)
|
||
return "夜盘" // ET 20:00–24:00
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
}
|