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

85 lines
3.5 KiB
Swift

//
// GoldMinuteClient.swift
// plugin v0.3
//
// Realtime minute-line for SHFE AU0 replaces the daily K-line
// in the chart widget. This matches what Alipay / show in the
// "" panel: an intraday tick line spanning
// 20:00 02:30 (night session) 09:00 15:30 (day session).
//
// Endpoint: Sina futures inner getMinLine
// https://stock.finance.sina.com.cn/futures/api/jsonp.php
// /var-_t=/InnerFuturesNewService.getMinLine?symbol=AU0
//
// Returns JSONP: `var-_t=([[time, last, avgPrice, vol, oi, prevClose, date], ...]);`
// First row carries the prevClose + date; subsequent rows are tick samples.
//
import Foundation
/// One minute sample from the AU0 intraday line.
struct GoldMinutePoint: Equatable {
/// Trading time, formatted "HH:mm" (e.g. "21:00").
let time: String
/// Last traded price (RMB/g).
let price: Double
/// Cumulative volume up to this minute.
let volume: Double
}
actor GoldMinuteClient {
static let shared = GoldMinuteClient()
private let session: URLSession
init() {
let cfg = URLSessionConfiguration.default
cfg.timeoutIntervalForRequest = 8
cfg.timeoutIntervalForResource = 15
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
self.session = URLSession(configuration: cfg)
}
/// Fetch today's minute line for the given futures symbol (default
/// AU0 = SHFE ). Returns oldest newest.
func minuteLine(symbol: String = "AU0") async throws -> [GoldMinutePoint] {
let url = URL(string:
"https://stock.finance.sina.com.cn/futures/api/jsonp.php/var-_t=/InnerFuturesNewService.getMinLine?symbol=\(symbol)"
)!
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)
guard let raw = String(data: data, encoding: .utf8) else {
throw FundClientError.malformed("minline non-utf8")
}
// Strip JSONP wrapper: `var-_t=(<json>);` (Sina injects a redirect
// script first; ignore that and keep the part starting at `var-_t=(`).
guard let lParen = raw.range(of: "var-_t=(")?.upperBound,
let rParen = raw[lParen...].lastIndex(of: ")") else {
throw FundClientError.malformed("minline jsonp wrapper missing")
}
let inner = String(raw[lParen..<rParen])
// Empty payload (off-hours / no session yet today) is `null`.
if inner.trimmingCharacters(in: .whitespaces) == "null" { return [] }
guard let innerData = inner.data(using: .utf8),
let arr = try? JSONSerialization.jsonObject(with: innerData) as? [[Any]] else {
throw FundClientError.malformed("minline array decode")
}
return arr.compactMap { row -> GoldMinutePoint? in
// Layout: [time, last, avgPrice, volume, openInterest, prevClose?, date?]
guard row.count >= 4 else { return nil }
// Sina returns numbers as strings inside the array coerce.
let timeStr = row[0] as? String ?? ""
let priceStr = row[1] as? String ?? ""
let volStr = row[3] as? String ?? "0"
guard let p = Double(priceStr), p > 0 else { return nil }
return GoldMinutePoint(time: timeStr, price: p, volume: Double(volStr) ?? 0)
}
}
}