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

155 lines
5.9 KiB
Swift

//
// 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)..<rQ])
let fields = body.components(separatedBy: ",")
guard fields.count >= 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
}
}
}