b2970e4c-45a2-423e-b84d-257.../Sources/data/Models.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

199 lines
6.7 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.

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