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

88 lines
3.5 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.

//
// RefreshScheduler.swift
// plugin
//
// Drives the refresh cadence based on what's tradeable right now.
// Different things have different refresh budgets:
//
// Tradeable now Cadence
//
// A-share funds (intraday) 60 s (estimate API is ~1 min granularity)
// A-share funds (closed) 30 min (just to pick up newly published NAV)
// Foreign gold (24/5) 30 s (Sina updates GC/XAU continuously)
// Domestic gold 30 s while SHFE open
//
// Trade hours (Asia/Shanghai):
// A-share: 09:3011:30, 13:0015:00, MonFri
// SHFE day: 09:0010:15, 10:3011:30, 13:3015:00
// SHFE night: 21:0002:30
// COMEX gold: 06:0005:00 next day (essentially 23h, 5 days/week)
// LBMA London: rolls with COMEX in practice
//
import Foundation
@MainActor
final class RefreshScheduler {
enum Tier {
/// Funds intraday cadence.
case fundsActive
/// Funds idle cadence only listening for end-of-day NAV publish.
case fundsIdle
/// Realtime gold tier (always on, foreign markets virtually 24/7).
case gold
}
/// Returns refresh interval (seconds) for a tier at the given moment.
func interval(for tier: Tier, at now: Date = Date()) -> TimeInterval {
switch tier {
case .fundsActive:
return isAShareTradeHour(now) ? 60 : 30 * 60
case .fundsIdle:
return 30 * 60
case .gold:
// Always 30s foreign gold trades virtually 24/7. SHFE has
// closed windows but we don't bother backing off; the request
// is cheap and Sina caches the latest tick anyway.
return 30
}
}
/// True iff `now` falls inside an A-share trading session
/// (09:3011:30, 13:0015:00 China time, MonFri, no holiday awareness yet).
func isAShareTradeHour(_ now: Date = Date()) -> Bool {
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "Asia/Shanghai")!
let weekday = cal.component(.weekday, from: now) // 1=Sun 7=Sat
guard (2...6).contains(weekday) else { return false }
let hm = cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now)
// 09:3011:30
if hm >= 9 * 60 + 30 && hm < 11 * 60 + 30 { return true }
// 13:0015:00
if hm >= 13 * 60 && hm < 15 * 60 { return true }
return false
}
/// True iff SHFE gold is in a trading window (loose no holiday).
func isSHFETradeHour(_ now: Date = Date()) -> Bool {
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "Asia/Shanghai")!
let weekday = cal.component(.weekday, from: now)
// Day session also runs Mon-Fri; night session technically can
// span into Sat early morning but we keep it simple.
guard (2...6).contains(weekday) else { return false }
let hm = cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now)
// 09:00-10:15
if hm >= 9 * 60 && hm < 10 * 60 + 15 { return true }
// 10:30-11:30
if hm >= 10 * 60 + 30 && hm < 11 * 60 + 30 { return true }
// 13:30-15:00
if hm >= 13 * 60 + 30 && hm < 15 * 60 { return true }
// 21:00-23:59 + 00:00-02:30
if hm >= 21 * 60 { return true }
if hm < 2 * 60 + 30 { return true }
return false
}
}