diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..1e9eb52 Binary files /dev/null and b/.coverage differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c002e15..a2b2405 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,3 +19,35 @@ jobs: run: python scripts/p0_smoke.py - name: Contract sample run: python scripts/validate_contract_sample.py + - name: iOS commercial gates (offline) + run: python ios/scripts/verify_commercial_gates.py + + ios-unit: + runs-on: macos-14 + if: github.event_name == 'pull_request' || contains(github.event.head_commit.message, 'ios') + steps: + - uses: actions/checkout@v4 + - name: Install xcodegen + run: brew install xcodegen + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_15.4.app/Contents/Developer + - name: Generate + AppAccess unit tests + run: | + cd ios + xcodegen generate + grep -E 'objectVersion = ' QuantRadar.xcodeproj/project.pbxproj | head -1 + # Prefer iPhone 15 on Xcode 15 runners + DEST=$(xcrun simctl list devices available | awk -F '[()]' '/iPhone 15[ )]/ && !/Pro/ {print $2; exit}') + if [ -z "$DEST" ]; then DEST=$(xcrun simctl list devices available | awk -F '[()]' '/iPhone 15/ {print $2; exit}'); fi + echo "Using simulator udid=$DEST" + xcodebuild test \ + -project QuantRadar.xcodeproj \ + -scheme QuantRadar \ + -destination "platform=iOS Simulator,id=$DEST" \ + -only-testing:QuantRadarTests/AppAccessTests \ + -only-testing:QuantRadarTests/OrganicFunnelTests \ + -only-testing:QuantRadarTests/BarsCacheTests \ + -only-testing:QuantRadarTests/DepthEngineTests \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO diff --git a/.gitignore b/.gitignore index 0b321a2..354fce4 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,9 @@ venv/ .ruff_cache/ data/ !data/.gitkeep +ios/*.xcodeproj +ios/*.xcworkspace +ios/DerivedData/ +ios/build/ +.tools/ +**/xcuserdata/ diff --git a/README.md b/README.md index b0071e7..d1e9c9f 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ python3 scripts/validate_contract_sample.py | **Ship v0.4.0** | ✅ magic link、Stripe checkout、Docker/Render/Fly、`bootstrap_env`、CI | | **Trust Gate v0.5.0** | ✅ 单 `primary_score`、诚实落地页、demo 不计费、methodology/pricing/legal、waitlist | | 下一优先 | **域名 cutover** + Trust Gate 线上 PASS → live charts → 再谈 Pro 收款 | +| **iOS App Store(独立运营)** | [`ios/`](./ios/) — **免费下载 + $9.99 一次性 Unlock** · **零 COGS(Yahoo 设备端)** · Web 仍走 Massive;互不解锁 | ### Trust Gate diff --git a/app/server.py b/app/server.py index 5a283e9..2712941 100644 --- a/app/server.py +++ b/app/server.py @@ -838,6 +838,8 @@ def do_GET(self) -> None: # noqa: N802 "/track": "track.html", "/terms": "terms.html", "/privacy": "privacy.html", + "/terms-ios": "terms-ios.html", + "/privacy-ios": "privacy-ios.html", "/refund": "refund.html", "/btn-demos": "btn-demos.html", # internal preview; noindex } @@ -868,7 +870,7 @@ def do_GET(self) -> None: # noqa: N802 share_lines = "".join(f"/r/{t}\n" for t in sorted(DEMO_TICKERS)) body = ( "/\n/methodology\n/pricing\n/track\n" - "/terms\n/privacy\n/refund\n/login\n" + "/terms\n/privacy\n/terms-ios\n/privacy-ios\n/refund\n/login\n" + share_lines ).encode() self._send(200, body, content_type="text/plain") diff --git a/docs/audit/2026-08-02/prod-fixed-375.png b/docs/audit/2026-08-02/prod-fixed-375.png new file mode 100644 index 0000000..fd9aa97 Binary files /dev/null and b/docs/audit/2026-08-02/prod-fixed-375.png differ diff --git a/ios/QuantRadar/App/QuantRadarApp.swift b/ios/QuantRadar/App/QuantRadarApp.swift new file mode 100644 index 0000000..ab4a206 --- /dev/null +++ b/ios/QuantRadar/App/QuantRadarApp.swift @@ -0,0 +1,26 @@ +import SwiftUI + +@main +struct QuantRadarApp: App { + @StateObject private var radar = RadarService() + @StateObject private var watchlist = WatchlistStore() + @StateObject private var purchases = PurchaseStore() + @AppStorage("hasSeenOnboarding") private var hasSeenOnboarding = false + + var body: some Scene { + WindowGroup { + Group { + if hasSeenOnboarding { + RootTabView() + } else { + OnboardingView(hasSeenOnboarding: $hasSeenOnboarding) + } + } + .environmentObject(radar) + .environmentObject(watchlist) + .environmentObject(purchases) + .preferredColorScheme(.dark) + .onAppear { ReviewPrompt.recordLaunch() } + } + } +} diff --git a/ios/QuantRadar/App/RootTabView.swift b/ios/QuantRadar/App/RootTabView.swift new file mode 100644 index 0000000..b2d03b5 --- /dev/null +++ b/ios/QuantRadar/App/RootTabView.swift @@ -0,0 +1,32 @@ +import SwiftUI + +enum QRTheme { + static let bg = Color(red: 0.043, green: 0.059, blue: 0.055) + static let panel = Color(red: 0.07, green: 0.10, blue: 0.09) + static let radar = Color(red: 0.18, green: 0.80, blue: 0.44) + static let radarDim = Color(red: 0.18, green: 0.80, blue: 0.44).opacity(0.18) + static let text = Color(red: 0.92, green: 0.95, blue: 0.93) + static let muted = Color(red: 0.55, green: 0.62, blue: 0.58) + static let danger = Color(red: 0.95, green: 0.35, blue: 0.32) + static let warn = Color(red: 0.95, green: 0.72, blue: 0.25) +} + +struct RootTabView: View { + @EnvironmentObject private var purchases: PurchaseStore + + var body: some View { + TabView { + TodayView() + .tabItem { Label("Today", systemImage: "dot.radiowaves.left.and.right") } + SearchView() + .tabItem { Label("Scan", systemImage: "magnifyingglass") } + if purchases.effectiveUnlocked { + WatchlistView() + .tabItem { Label("Watch", systemImage: "eye") } + } + SettingsView() + .tabItem { Label("Settings", systemImage: "gearshape") } + } + .tint(QRTheme.radar) + } +} diff --git a/ios/QuantRadar/Info.plist b/ios/QuantRadar/Info.plist new file mode 100644 index 0000000..ee3f5fa --- /dev/null +++ b/ios/QuantRadar/Info.plist @@ -0,0 +1,52 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + QuantRadar + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + UILaunchScreen + + UIColorName + LaunchBackground + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Dark + NSUserNotificationsUsageDescription + QuantRadar can send a weekday morning reminder when today’s SPY radar is ready, and posture-change alerts for tickers you watch. + ITSAppUsesNonExemptEncryption + + + diff --git a/ios/QuantRadar/Models/RadarVerdict.swift b/ios/QuantRadar/Models/RadarVerdict.swift new file mode 100644 index 0000000..ed281c6 --- /dev/null +++ b/ios/QuantRadar/Models/RadarVerdict.swift @@ -0,0 +1,161 @@ +import Foundation + +struct RadarVerdict: Codable, Identifiable, Hashable { + var id: String { ticker } + + let ok: Bool + let ticker: String + let companyName: String? + let sector: String? + let primaryScore: PrimaryScore? + let primary: PrimaryAction? + let summary: String? + let engagement: Engagement? + let dataQuality: DataQuality? + let market: MarketSnapshot? + let meta: Meta? + let gate: Gate? + let warnings: [String]? + let depth: Depth? + + enum CodingKeys: String, CodingKey { + case ok, ticker, sector, summary, engagement, market, meta, gate, warnings, depth + case companyName = "company_name" + case primaryScore = "primary_score" + case primary + case dataQuality = "data_quality" + } + + struct PrimaryScore: Codable, Hashable { + let value: Double? + let scale: Double? + let label: String? + let withheld: Bool? + let note: String? + } + + struct PrimaryAction: Codable, Hashable { + let action: String? + let label: String? + let reason: String? + } + + struct Engagement: Codable, Hashable { + let avoidedLine: String? + let freezeLabel: String? + let postureNote: String? + + enum CodingKeys: String, CodingKey { + case avoidedLine = "avoided_line" + case freezeLabel = "freeze_label" + case postureNote = "posture_note" + } + } + + struct DataQuality: Codable, Hashable { + let usable: Bool? + let reliability: String? + let optionsActionable: Bool? + + enum CodingKeys: String, CodingKey { + case usable, reliability + case optionsActionable = "options_actionable" + } + } + + struct MarketSnapshot: Codable, Hashable { + let marketState: String? + let spyChangePct: Double? + let sectorEtf: String? + let sectorChangePct: Double? + let vixCurrent: Double? + let vixTrend: String? + + enum CodingKeys: String, CodingKey { + case marketState = "market_state" + case spyChangePct = "spy_change_pct" + case sectorEtf = "sector_etf" + case sectorChangePct = "sector_change_pct" + case vixCurrent = "vix_current" + case vixTrend = "vix_trend" + } + } + + struct Meta: Codable, Hashable { + let mode: String? + let fetchTime: String? + let disclaimer: String? + let dataPath: String? + + enum CodingKeys: String, CodingKey { + case mode, disclaimer + case fetchTime = "fetch_time" + case dataPath = "data_path" + } + } + + struct Gate: Codable, Hashable { + let market: String? + let sector: String? + let stock: String? + } + + /// Local 90-day replay + forward stats. Optional so bundled JSON still decodes. + struct Depth: Codable, Hashable { + /// Oldest → newest daily action codes (SETUP / WAIT / NO). + let history: [String] + let setupCount: Int? + let medianForward5dPct: Double? + let medianForward20dPct: Double? + let lastSetupAgoDays: Int? + let lastSetupForwardPct: Double? + let earningsDays: Int? + let earningsForcedWait: Bool? + let sectorEtf: String? + + enum CodingKeys: String, CodingKey { + case history + case setupCount = "setup_count" + case medianForward5dPct = "median_forward_5d_pct" + case medianForward20dPct = "median_forward_20d_pct" + case lastSetupAgoDays = "last_setup_ago_days" + case lastSetupForwardPct = "last_setup_forward_pct" + case earningsDays = "earnings_days" + case earningsForcedWait = "earnings_forced_wait" + case sectorEtf = "sector_etf" + } + } + + var actionCode: String { + (primary?.action ?? "WAIT").uppercased() + } + + var scoreText: String { + if primaryScore?.withheld == true { return "—" } + guard let v = primaryScore?.value else { return "—" } + return String(format: "%.0f", v) + } + + var isWithheld: Bool { + primaryScore?.withheld == true + } + + var shareText: String { + let action = primary?.label ?? actionCode + let reason = primary?.reason ?? "" + let flexed: String + switch actionCode { + case "WAIT", "NO", "AVOID": + flexed = "Today I didn't force a trade — and that was the point." + default: + flexed = reason + } + return """ + \(ticker) · QuantRadar + Score \(scoreText) · \(action) + \(flexed) + + Educational only — not investment advice. + """ + } +} diff --git a/ios/QuantRadar/QuantRadar.entitlements b/ios/QuantRadar/QuantRadar.entitlements new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/ios/QuantRadar/QuantRadar.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/ios/QuantRadar/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/QuantRadar/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..84b6efd --- /dev/null +++ b/ios/QuantRadar/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.443", + "green" : "0.800", + "red" : "0.180" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/QuantRadar/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/ios/QuantRadar/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000..b288f62 Binary files /dev/null and b/ios/QuantRadar/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/ios/QuantRadar/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/QuantRadar/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..f22e10c --- /dev/null +++ b/ios/QuantRadar/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/QuantRadar/Resources/Assets.xcassets/Contents.json b/ios/QuantRadar/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/QuantRadar/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/QuantRadar/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json b/ios/QuantRadar/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 0000000..6aabfda --- /dev/null +++ b/ios/QuantRadar/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.055", + "green" : "0.059", + "red" : "0.043" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/QuantRadar/Resources/Products.storekit b/ios/QuantRadar/Resources/Products.storekit new file mode 100644 index 0000000..df73499 --- /dev/null +++ b/ios/QuantRadar/Resources/Products.storekit @@ -0,0 +1,98 @@ +{ + "identifier" : "Products", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "9.99", + "familyShareable" : false, + "internalID" : "6500000010", + "localizations" : [ + { + "description" : "One-time unlock for full ticker scan and watchlist. Not a subscription. Educational radar only.", + "displayName" : "QuantRadar Unlock", + "locale" : "en_US" + } + ], + "productID" : "one.quantradar.app.unlock", + "referenceName" : "QuantRadar Unlock", + "type" : "NonConsumable" + } + ], + "settings" : { + "_failTransactionsEnabled" : false, + "_locale" : "en_US", + "_storefront" : "USA", + "_storeKitErrors" : [ + + ] + }, + "subscriptionGroups" : [ + { + "id" : "214E0A8E", + "localizations" : [ + { + "description" : "Optional Live+ — more watch slots and denser on-device reminders. Core unlock is separate. No Massive / no web Stripe.", + "displayName" : "Radar Live+", + "locale" : "en_US" + } + ], + "name" : "Radar Live+", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "4.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "6500000011", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "50 watch slots and 6-hour local reminders. On-device only — not server push, not Massive.", + "displayName" : "Live+ Monthly", + "locale" : "en_US" + } + ], + "productID" : "one.quantradar.app.live.monthly", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Live+ Monthly", + "type" : "RecurringSubscription" + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "49.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "6500000012", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "Best Live+ value. Core unlock remains a separate $9.99 purchase.", + "displayName" : "Live+ Yearly", + "locale" : "en_US" + } + ], + "productID" : "one.quantradar.app.live.yearly", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "Live+ Yearly", + "type" : "RecurringSubscription" + } + ] + } + ], + "version" : { + "major" : 3, + "minor" : 0 + } +} diff --git a/ios/QuantRadar/Resources/SampleVerdict.json b/ios/QuantRadar/Resources/SampleVerdict.json new file mode 100644 index 0000000..77595fa --- /dev/null +++ b/ios/QuantRadar/Resources/SampleVerdict.json @@ -0,0 +1,60 @@ +{ + "ok": true, + "contract_version": "1.0", + "ticker": "INTC", + "company_name": "Intel Corporation", + "sector": "XLK", + "gate": { + "market": "PASS", + "sector": "PASS", + "stock": "WATCH" + }, + "score": { + "final": 53, + "base_total": 53, + "scale": 100, + "withheld": false, + "breakdown": {} + }, + "primary_score": { + "value": 53, + "scale": 100, + "label": "Mechanical posture score", + "withheld": false, + "note": null + }, + "primary": { + "action": "WAIT", + "label": "Wait & Watch", + "reason": "Weekly MA bullish, pullback 7.1% — entry timing not fully aligned" + }, + "summary": "Posture is cautious. Market and sector gates pass, but stock-level timing stays on watch. Educational radar only.", + "engagement": { + "avoided_line": "Skipping a forced long here avoids chasing a mid-pullback.", + "freeze_label": "Sample artifact · demo", + "loop": "verdict → why → gates → next", + "posture_note": "Mechanical posture ≠ trade direction." + }, + "data_quality": { + "usable": true, + "reliability": "medium", + "volume_narrative_allowed": true, + "options_actionable": false + }, + "market": { + "market_state": "risk_on_cautious", + "spy_change_pct": 0.43, + "sector_etf": "XLK", + "sector_change_pct": 2.84, + "vix_current": 25.7, + "vix_trend": "rising" + }, + "meta": { + "engine": "charts", + "mode": "artifact", + "fetch_time": "2026-03-21T01:43:25", + "disclaimer": "Educational radar only — not investment advice.", + "data_path": "bundled_sample" + }, + "warnings": [] +} diff --git a/ios/QuantRadar/Services/AppAccess.swift b/ios/QuantRadar/Services/AppAccess.swift new file mode 100644 index 0000000..ef81b23 --- /dev/null +++ b/ios/QuantRadar/Services/AppAccess.swift @@ -0,0 +1,91 @@ +import Foundation + +/// Product IDs and free-preview gates for the free-download + unlock model. +/// Preview is Today SPY + one lifetime personal ticker — not daily quotas. +enum AppAccess { + static let unlockProductID = "one.quantradar.app.unlock" + static let liveMonthlyProductID = "one.quantradar.app.live.monthly" + static let liveYearlyProductID = "one.quantradar.app.live.yearly" + + static let freeMarketTicker = "SPY" + static let freeWatchlistLimit = 20 + static let livePlusWatchlistLimit = 50 + + static let privacyURL = "https://quantradar.one/privacy-ios" + static let termsURL = "https://quantradar.one/terms-ios" + + static let differentiationLine = "One score. Most days: don’t act — not tipster noise." + static let previewLine = "Free: today’s SPY plus one ticker of yours." + static let founderPriceLine = "Founder price $9.99 — first 1,000 unlocks. One-time, not a subscription." + + static let previewTickerKey = "qr.preview.personal_ticker" + + /// Injectable for tests. Production uses `.standard`. + static var storage: UserDefaults = .standard + + static func normalizeTicker(_ ticker: String) -> String { + ticker.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + } + + static var claimedPreviewTicker: String? { + let t = normalizeTicker(storage.string(forKey: previewTickerKey) ?? "") + return t.isEmpty ? nil : t + } + + static func resetPreviewTicker() { + storage.removeObject(forKey: previewTickerKey) + } + + /// SPY is always free (Today). One other ticker can be claimed for life. + /// While nothing is claimed, any non-empty ticker may be scanned once. + static func canScan(ticker: String, unlocked: Bool) -> Bool { + let t = normalizeTicker(ticker) + guard !t.isEmpty else { return false } + if unlocked { return true } + if t == freeMarketTicker { return true } + if let claimed = claimedPreviewTicker { + return claimed == t + } + return true + } + + /// Second personal ticker (and beyond) stays locked until unlock. + static func isPreviewLocked(ticker: String, unlocked: Bool) -> Bool { + !canScan(ticker: ticker, unlocked: unlocked) + } + + static func anxietyCopy(ticker: String) -> String { + let t = normalizeTicker(ticker) + if t.isEmpty { return "Should you chase another ticker right now?" } + return "Should you chase \(t) right now?" + } + + /// Persist the first successful personal preview ticker. SPY does not consume it. + @discardableResult + static func claimPreviewTickerIfNeeded(_ ticker: String, withheld: Bool) -> Bool { + let t = normalizeTicker(ticker) + if withheld || t.isEmpty || t == freeMarketTicker { return true } + if let claimed = claimedPreviewTicker { + return claimed == t + } + storage.set(t, forKey: previewTickerKey) + return true + } + + static func watchlistLimit(livePlus: Bool) -> Int { + livePlus ? livePlusWatchlistLimit : freeWatchlistLimit + } + + static func reminderIntervalSeconds(livePlus: Bool) -> TimeInterval { + livePlus ? 60 * 60 * 6 : 60 * 60 * 24 + } + + /// Fire a local alert only when a watched ticker's action actually changes. + static func shouldNotifyPostureChange(previous: String?, next: String, remind: Bool) -> Bool { + guard remind else { return false } + let prev = (previous ?? "").uppercased() + let nxt = next.uppercased() + guard !prev.isEmpty, prev != nxt else { return false } + return true + } +} diff --git a/ios/QuantRadar/Services/BarsCache.swift b/ios/QuantRadar/Services/BarsCache.swift new file mode 100644 index 0000000..48541a2 --- /dev/null +++ b/ios/QuantRadar/Services/BarsCache.swift @@ -0,0 +1,84 @@ +import Foundation + +/// Memory + disk cache for free OHLCV bars. +/// SPY defaults to 30 min TTL; other symbols 15 min. +actor BarsCache { + static let shared = BarsCache() + + struct Entry: Codable, Sendable { + let bars: [FreeBar] + let sourceRaw: String + let fetchedAt: Date + + var source: FreeDataSourceID? { FreeDataSourceID(rawValue: sourceRaw) } + + func asResult() -> FreeBarsResult? { + guard let source else { return nil } + return FreeBarsResult(bars: bars, source: source) + } + } + + private var memory: [String: Entry] = [:] + private let diskDir: URL + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + init(diskDir: URL? = nil) { + if let diskDir { + self.diskDir = diskDir + } else { + let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + self.diskDir = base.appendingPathComponent("qr_bars_cache", isDirectory: true) + } + try? FileManager.default.createDirectory(at: self.diskDir, withIntermediateDirectories: true) + } + + static func ttl(for symbol: String) -> TimeInterval { + symbol == "SPY" ? 30 * 60 : 15 * 60 + } + + func get(_ symbol: String, maxAge: TimeInterval? = nil) -> FreeBarsResult? { + let key = FreeMarketDataClient.normalize(symbol) + guard !key.isEmpty else { return nil } + let age = maxAge ?? Self.ttl(for: key) + let now = Date() + + if let mem = memory[key], now.timeIntervalSince(mem.fetchedAt) <= age { + return mem.asResult() + } + + let file = diskURL(for: key) + guard + let data = try? Data(contentsOf: file), + let entry = try? decoder.decode(Entry.self, from: data), + now.timeIntervalSince(entry.fetchedAt) <= age, + let result = entry.asResult() + else { return nil } + + memory[key] = entry + return result + } + + func set(_ symbol: String, result: FreeBarsResult) { + let key = FreeMarketDataClient.normalize(symbol) + guard !key.isEmpty else { return } + let entry = Entry(bars: result.bars, sourceRaw: result.source.rawValue, fetchedAt: Date()) + memory[key] = entry + if let data = try? encoder.encode(entry) { + try? data.write(to: diskURL(for: key), options: .atomic) + } + } + + func clear() { + memory.removeAll() + if let files = try? FileManager.default.contentsOfDirectory(at: diskDir, includingPropertiesForKeys: nil) { + for f in files { try? FileManager.default.removeItem(at: f) } + } + } + + private func diskURL(for symbol: String) -> URL { + let safe = symbol.replacingOccurrences(of: "/", with: "_") + return diskDir.appendingPathComponent("\(safe).json") + } +} diff --git a/ios/QuantRadar/Services/DailyBriefing.swift b/ios/QuantRadar/Services/DailyBriefing.swift new file mode 100644 index 0000000..c1d925e --- /dev/null +++ b/ios/QuantRadar/Services/DailyBriefing.swift @@ -0,0 +1,44 @@ +import Foundation +import UserNotifications + +/// Weekday 09:25 America/New_York local notification. Copy is static on +/// purpose — we do not invent a score in the banner. +enum DailyBriefing { + static let idPrefix = "qr.briefing.weekday." + static let hour = 9 + static let minute = 25 + + static func requestAndSchedule() async { + let center = UNUserNotificationCenter.current() + let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false + guard granted else { return } + await schedule(center: center) + } + + static func schedule(center: UNUserNotificationCenter = .current()) async { + let existing = await center.pendingNotificationRequests() + for req in existing where req.identifier.hasPrefix(idPrefix) { + center.removePendingNotificationRequests(withIdentifiers: [req.identifier]) + } + let tz = TimeZone(identifier: "America/New_York") ?? .gmt + // Sunday=1 … Saturday=7. Weekdays 2...6. + for weekday in 2...6 { + var comps = DateComponents() + comps.weekday = weekday + comps.hour = hour + comps.minute = minute + comps.timeZone = tz + let content = UNMutableNotificationContent() + content.title = "Today's radar is ready" + content.body = "Open QuantRadar for SPY posture. Most days the honest answer is wait." + content.sound = .default + let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true) + let req = UNNotificationRequest( + identifier: "\(idPrefix)\(weekday)", + content: content, + trigger: trigger + ) + try? await center.add(req) + } + } +} diff --git a/ios/QuantRadar/Services/DisciplineLedger.swift b/ios/QuantRadar/Services/DisciplineLedger.swift new file mode 100644 index 0000000..8d7f8dd --- /dev/null +++ b/ios/QuantRadar/Services/DisciplineLedger.swift @@ -0,0 +1,76 @@ +import Foundation + +/// Streak + wait counts. Injectable storage for tests. +enum DisciplineLedger { + static let streakKey = "qr.discipline.streak" + static let lastDayKey = "qr.discipline.last_day" + static let waitCountKey = "qr.discipline.wait_count" + static let checkCountKey = "qr.discipline.check_count" + static let avoidedPctKey = "qr.discipline.avoided_pct" + static let lastClosePrefix = "qr.discipline.last_close." + + static var storage: UserDefaults = .standard + + static func reset() { + for k in [streakKey, lastDayKey, waitCountKey, checkCountKey, avoidedPctKey] { + storage.removeObject(forKey: k) + } + } + + static var streak: Int { storage.integer(forKey: streakKey) } + static var waitCount: Int { storage.integer(forKey: waitCountKey) } + static var checkCount: Int { storage.integer(forKey: checkCountKey) } + static var avoidedPct: Double { storage.double(forKey: avoidedPctKey) } + + static func dayStamp(_ date: Date = Date()) -> String { + let fmt = DateFormatter() + fmt.calendar = PostureDepth.nyCalendar() + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.timeZone = PostureDepth.nyCalendar().timeZone + fmt.dateFormat = "yyyy-MM-dd" + return fmt.string(from: date) + } + + static func record(action: String, ticker: String, close: Double?, date: Date = Date()) { + let today = dayStamp(date) + let last = storage.string(forKey: lastDayKey) + if last != today { + if last == previousNYDay(from: date) { + storage.set(streak + 1, forKey: streakKey) + } else { + storage.set(1, forKey: streakKey) + } + storage.set(today, forKey: lastDayKey) + } + storage.set(checkCount + 1, forKey: checkCountKey) + let code = action.uppercased() + if code == "WAIT" || code == "NO" || code == "AVOID" { + storage.set(waitCount + 1, forKey: waitCountKey) + } + + let closeKey = lastClosePrefix + FreeMarketDataClient.normalize(ticker) + if let close, close > 0 { + let prevClose = storage.double(forKey: closeKey) + let prevAction = storage.string(forKey: closeKey + ".action") ?? "" + if prevClose > 0, ["WAIT", "NO", "AVOID"].contains(prevAction.uppercased()), close < prevClose { + let dd = (prevClose - close) / prevClose * 100 + storage.set(avoidedPct + dd, forKey: avoidedPctKey) + } + storage.set(close, forKey: closeKey) + storage.set(code, forKey: closeKey + ".action") + } + } + + private static func previousNYDay(from date: Date) -> String { + let cal = PostureDepth.nyCalendar() + let prev = cal.date(byAdding: .day, value: -1, to: date) ?? date + return dayStamp(prev) + } + + static var summaryLine: String { + let s = streak + let w = waitCount + if s <= 0 && w <= 0 { return "Check in daily. Most days: wait." } + return "Discipline · \(s)-day streak · \(w) waits logged" + } +} diff --git a/ios/QuantRadar/Services/FreeDataRadar.swift b/ios/QuantRadar/Services/FreeDataRadar.swift new file mode 100644 index 0000000..83750bf --- /dev/null +++ b/ios/QuantRadar/Services/FreeDataRadar.swift @@ -0,0 +1,512 @@ +import Foundation + +struct FreeBar: Hashable, Sendable, Codable { + let date: Date + let close: Double + let volume: Double +} + +enum FreeDataSourceID: String, CaseIterable, Sendable { + case yahooQuery1 = "yahoo_q1" + case yahooQuery2 = "yahoo_q2" + case nasdaq = "nasdaq" +} + +struct FreeBarsResult: Sendable { + let bars: [FreeBar] + let source: FreeDataSourceID + var fromCache: Bool = false +} + +enum FreeMarketDataError: LocalizedError { + case badSymbol + case allSourcesFailed([String]) + case parseFailed(FreeDataSourceID) + + var errorDescription: String? { + switch self { + case .badSymbol: return "Invalid ticker." + case .allSourcesFailed(let errs): return "All free sources failed: \(errs.joined(separator: " | "))" + case .parseFailed(let s): return "Parse failed for \(s.rawValue)." + } + } +} + +/// Zero-COGS multi-source OHLCV. Order verified 2026-08-11: +/// Yahoo q1 → Yahoo q2 → Nasdaq chart (US equities). +enum FreeMarketDataClient { + static var sourceOrder: [FreeDataSourceID] = [.yahooQuery1, .yahooQuery2, .nasdaq] + /// Per-source HTTP timeout (fail-fast failover). + static var requestTimeout: TimeInterval = 8 + + private static let session: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 8 + config.timeoutIntervalForResource = 12 + config.waitsForConnectivity = false + config.urlCache = nil + return URLSession(configuration: config) + }() + + static func dailyBars( + symbol: String, + rangeHintDays: Int = 180, + bypassCache: Bool = false + ) async throws -> FreeBarsResult { + let sym = normalize(symbol) + guard !sym.isEmpty else { throw FreeMarketDataError.badSymbol } + + if !bypassCache, let cached = await BarsCache.shared.get(sym) { + return FreeBarsResult(bars: cached.bars, source: cached.source, fromCache: true) + } + + var errors: [String] = [] + for source in sourceOrder { + do { + let bars = try await fetch(source: source, symbol: sym, rangeHintDays: rangeHintDays) + guard bars.count >= 30 else { + errors.append("\(source.rawValue): too few bars (\(bars.count))") + continue + } + let result = FreeBarsResult(bars: bars, source: source, fromCache: false) + await BarsCache.shared.set(sym, result: result) + return result + } catch { + errors.append("\(source.rawValue): \(error.localizedDescription)") + } + } + throw FreeMarketDataError.allSourcesFailed(errors) + } + + static func normalize(_ symbol: String) -> String { + symbol.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + } + + // MARK: - Dispatch + + private static func fetch(source: FreeDataSourceID, symbol: String, rangeHintDays: Int) async throws -> [FreeBar] { + switch source { + case .yahooQuery1: + return try await yahoo(host: "query1.finance.yahoo.com", symbol: symbol, range: yahooRange(rangeHintDays)) + case .yahooQuery2: + return try await yahoo(host: "query2.finance.yahoo.com", symbol: symbol, range: yahooRange(rangeHintDays)) + case .nasdaq: + return try await nasdaq(symbol: symbol, rangeHintDays: rangeHintDays) + } + } + + private static func yahooRange(_ days: Int) -> String { + if days <= 30 { return "1mo" } + if days <= 100 { return "3mo" } + if days <= 200 { return "6mo" } + return "1y" + } + + // MARK: - Yahoo + + private static func yahoo(host: String, symbol: String, range: String) async throws -> [FreeBar] { + let encoded = symbol.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? symbol + guard var components = URLComponents(string: "https://\(host)/v8/finance/chart/\(encoded)") else { + throw URLError(.badURL) + } + components.queryItems = [ + URLQueryItem(name: "range", value: range), + URLQueryItem(name: "interval", value: "1d"), + URLQueryItem(name: "includePrePost", value: "false"), + ] + guard let url = components.url else { throw URLError(.badURL) } + let data = try await get(url, headers: [ + "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) QuantRadar/1.0", + "Accept": "application/json", + ]) + return try parseYahoo(data) + } + + private static func parseYahoo(_ data: Data) throws -> [FreeBar] { + guard + let root = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let chart = root["chart"] as? [String: Any], + let results = chart["result"] as? [[String: Any]], + let result = results.first, + let timestamps = result["timestamp"] as? [Double], + let indicators = result["indicators"] as? [String: Any], + let quotes = indicators["quote"] as? [[String: Any]], + let quote = quotes.first, + let closes = quote["close"] as? [Any], + let volumes = quote["volume"] as? [Any] + else { throw FreeMarketDataError.parseFailed(.yahooQuery1) } + + var bars: [FreeBar] = [] + for i in 0.. 0 else { continue } + let v = asDouble(volumes, i) ?? 0 + bars.append(FreeBar(date: Date(timeIntervalSince1970: timestamps[i]), close: c, volume: max(0, v))) + } + return bars + } + + // MARK: - Nasdaq + + private static func nasdaq(symbol: String, rangeHintDays: Int) async throws -> [FreeBar] { + // Nasdaq API uses BRK.B style; reject obvious non-equities early. + if symbol.contains("-") || symbol.contains("=") { + throw URLError(.unsupportedURL) + } + let nasdaqSymbol = symbol.replacingOccurrences(of: "-", with: ".") + let end = Date() + let start = Calendar.current.date(byAdding: .day, value: -max(60, rangeHintDays), to: end) ?? end + let fmt = DateFormatter() + fmt.calendar = Calendar(identifier: .gregorian) + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.timeZone = TimeZone(secondsFromGMT: 0) + fmt.dateFormat = "yyyy-MM-dd" + let from = fmt.string(from: start) + let to = fmt.string(from: end) + let urlString = "https://api.nasdaq.com/api/quote/\(nasdaqSymbol)/chart?assetclass=stocks&fromdate=\(from)&todate=\(to)" + guard let url = URL(string: urlString) else { throw URLError(.badURL) } + let data = try await get(url, headers: [ + "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15", + "Accept": "application/json, text/plain, */*", + "Origin": "https://www.nasdaq.com", + "Referer": "https://www.nasdaq.com/", + ]) + return try parseNasdaq(data) + } + + private static func parseNasdaq(_ data: Data) throws -> [FreeBar] { + guard + let root = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let dataObj = root["data"] as? [String: Any], + let chart = dataObj["chart"] as? [[String: Any]], + !chart.isEmpty + else { throw FreeMarketDataError.parseFailed(.nasdaq) } + + var bars: [FreeBar] = [] + for point in chart { + let close: Double? + if let y = point["y"] as? Double { + close = y + } else if let y = point["y"] as? NSNumber { + close = y.doubleValue + } else if let z = point["z"] as? [String: Any] { + close = doubleFromAny(z["close"] ?? z["value"]) + } else { + close = nil + } + guard let c = close, c > 0 else { continue } + let ts: Date + if let x = point["x"] as? Double { + // Nasdaq x is ms + ts = Date(timeIntervalSince1970: x / 1000) + } else if let x = point["x"] as? NSNumber { + ts = Date(timeIntervalSince1970: x.doubleValue / 1000) + } else { + continue + } + var volume = 0.0 + if let z = point["z"] as? [String: Any] { + volume = doubleFromAny(z["volume"]) ?? 0 + } + bars.append(FreeBar(date: ts, close: c, volume: max(0, volume))) + } + return bars.sorted { $0.date < $1.date } + } + + // MARK: - HTTP + + private static func get(_ url: URL, headers: [String: String]) async throws -> Data { + var request = URLRequest(url: url) + request.timeoutInterval = requestTimeout + request.cachePolicy = .reloadIgnoringLocalCacheData + for (k, v) in headers { request.setValue(v, forHTTPHeaderField: k) } + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } + guard (200..<300).contains(http.statusCode) else { + throw URLError(.init(rawValue: http.statusCode)) + } + return data + } + + private static func asDouble(_ arr: [Any], _ i: Int) -> Double? { + guard i < arr.count else { return nil } + return doubleFromAny(arr[i]) + } + + private static func doubleFromAny(_ any: Any?) -> Double? { + guard let any, !(any is NSNull) else { return nil } + if let d = any as? Double { return d } + if let n = any as? NSNumber { return n.doubleValue } + if let s = any as? String { + let cleaned = s.replacingOccurrences(of: ",", with: "") + return Double(cleaned) + } + return nil + } +} + +enum QuoteFundamentals: Sendable { + case missing + case loaded(sector: String?, earningsDate: Date?) + + var sector: String? { + if case .loaded(let s, _) = self { return s } + return nil + } + + var earningsDate: Date? { + if case .loaded(_, let d) = self { return d } + return nil + } +} + +extension FreeMarketDataClient { + /// Yahoo quoteSummary — fail-open. Never blocks a scan. + static func fundamentals(symbol: String) async -> QuoteFundamentals { + let sym = normalize(symbol) + guard !sym.isEmpty else { return .missing } + let encoded = sym.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? sym + guard var components = URLComponents(string: "https://query1.finance.yahoo.com/v10/finance/quoteSummary/\(encoded)") else { + return .missing + } + components.queryItems = [ + URLQueryItem(name: "modules", value: "assetProfile,calendarEvents"), + ] + guard let url = components.url else { return .missing } + do { + let data = try await get(url, headers: [ + "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) QuantRadar/1.2", + "Accept": "application/json", + ]) + return parseFundamentals(data) + } catch { + return .missing + } + } + + static func parseFundamentals(_ data: Data) -> QuoteFundamentals { + guard + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let qs = root["quoteSummary"] as? [String: Any], + let results = qs["result"] as? [[String: Any]], + let result = results.first + else { return .missing } + + var sector: String? + if let profile = result["assetProfile"] as? [String: Any] { + sector = profile["sector"] as? String + } + var earnings: Date? + if + let cal = result["calendarEvents"] as? [String: Any], + let earn = cal["earnings"] as? [String: Any], + let dates = earn["earningsDate"] as? [[String: Any]], + let first = dates.first + { + if let raw = first["raw"] as? Double { + earnings = Date(timeIntervalSince1970: raw) + } else if let raw = first["raw"] as? NSNumber { + earnings = Date(timeIntervalSince1970: raw.doubleValue) + } + } + if sector == nil && earnings == nil { return .missing } + return .loaded(sector: sector, earningsDate: earnings) + } +} + +enum FreeMechanicalScorer { + struct Core: Equatable { + var score: Double + var action: String + var label: String + var reason: String + var summary: String + var marketGate: String + var stockGate: String + var spyPct: Double? + var avoided: String? + var pullback: Double + var rsi: Double? + } + + static func core(bars: [FreeBar], spyBars: [FreeBar]?) -> Core { + let closes = bars.map(\.close) + let volumes = bars.map(\.volume) + let last = closes.last ?? 0 + let sma20 = sma(closes, 20) + let sma50 = sma(closes, 50) + let rsi = rsi14(closes) + let volSma = sma(volumes, 20) + let volRatio = (volSma > 0) ? (volumes.last ?? 0) / volSma : 1 + let pullback = sma20 > 0 ? (sma20 - last) / sma20 * 100 : 0 + + var score = 50.0 + if sma20 > 0, sma50 > 0 { + if last > sma20 && sma20 > sma50 { score += 18 } + else if last > sma20 { score += 8 } + else if last < sma50 { score -= 18 } + else { score -= 8 } + } + if let r = rsi { + if r >= 45 && r <= 65 { score += 12 } + else if r > 70 { score -= 10 } + else if r < 30 { score += 4 } + else { score += 2 } + } + if volRatio >= 1.2 { score += 8 } + else if volRatio < 0.7 { score -= 4 } + + var marketGate = "PASS" + var spyPct: Double? + if let spy = spyBars, spy.count >= 5 { + let a = spy[spy.count - 5].close + let b = spy.last!.close + spyPct = (b - a) / a * 100 + if let p = spyPct, p < -3 { marketGate = "WATCH"; score -= 10 } + if let p = spyPct, p < -6 { marketGate = "NO"; score -= 15 } + } + + score = min(95, max(5, score.rounded())) + + let action: String + let label: String + let reason: String + if marketGate == "NO" || score < 38 { + action = "NO" + label = "Avoid" + reason = "Posture is weak or the market gate is blocked — do not force a trade." + } else if score >= 68 && pullback >= -2 && pullback <= 8 && (rsi ?? 50) < 68 { + action = "SETUP" + label = "Setup zone" + reason = String(format: "Trend supportive, RSI %.0f, pullback %.1f%% from SMA20.", rsi ?? 0, max(0, pullback)) + } else { + action = "WAIT" + label = "Wait & Watch" + reason = String(format: "Score %.0f — timing not fully aligned (RSI %.0f).", score, rsi ?? 0) + } + + let stockGate = action == "SETUP" ? "PASS" : (action == "NO" ? "NO" : "WATCH") + let summary: String + if action == "SETUP" { + summary = "Timing is in a setup zone. Educational radar only — not a buy order." + } else if action == "NO" { + summary = "Posture says stay out. Skipping a forced trade is the point of the radar." + } else { + summary = "Posture is cautious. Wait until timing lines up." + } + return Core( + score: score, + action: action, + label: label, + reason: reason, + summary: summary, + marketGate: marketGate, + stockGate: stockGate, + spyPct: spyPct, + avoided: action == "SETUP" ? nil : "Skipping a forced entry preserves optionality.", + pullback: pullback, + rsi: rsi + ) + } + + static func score( + symbol: String, + company: String?, + bars: [FreeBar], + spyBars: [FreeBar]?, + source: FreeDataSourceID, + earningsDate: Date? = nil, + sectorName: String? = nil, + sectorAction: String? = nil, + now: Date = Date() + ) -> RadarVerdict { + var c = core(bars: bars, spyBars: spyBars) + var earningsForced = false + if let earningsDate, PostureDepth.isNearEarnings(earningsDate, now: now), c.action == "SETUP" { + c.action = "WAIT" + c.label = "Wait & Watch" + c.reason = "Earnings within 3 days — radar stays on wait." + c.summary = "Earnings window: posture stays on wait. Educational only — not a buy order." + c.stockGate = "WATCH" + earningsForced = true + } + var sectorGate = "N/A" + if let sectorAction { + sectorGate = sectorAction == "NO" ? "NO" : (sectorAction == "SETUP" ? "PASS" : "WATCH") + if sectorAction == "NO" && c.action == "SETUP" { + c.action = "WAIT" + c.label = "Wait & Watch" + c.reason = "Sector posture is blocked — wait even if the stock looks ready." + c.summary = "Sector gate is closed. Wait until the group improves." + c.stockGate = "WATCH" + } + } + let etf = PostureDepth.etf(forSector: sectorName) + let depth = PostureDepth.build( + bars: bars, + spyBars: spyBars, + earningsDate: earningsDate, + now: now, + earningsForcedWait: earningsForced, + sectorEtf: etf + ) + return RadarVerdict( + ok: true, + ticker: symbol, + companyName: company ?? symbol, + sector: sectorName ?? etf, + primaryScore: .init( + value: c.score, + scale: 100, + label: "Mechanical posture score", + withheld: false, + note: nil + ), + primary: .init(action: c.action, label: c.label, reason: c.reason), + summary: c.summary, + engagement: .init( + avoidedLine: c.avoided, + freezeLabel: "on-device", + postureNote: "Mechanical posture ≠ trade direction." + ), + dataQuality: .init(usable: true, reliability: "medium", optionsActionable: false), + market: .init( + marketState: c.marketGate == "PASS" ? "risk_on_cautious" : "risk_off", + spyChangePct: c.spyPct, + sectorEtf: etf, + sectorChangePct: nil, + vixCurrent: nil, + vixTrend: nil + ), + meta: .init( + mode: "free_multi_source", + fetchTime: ISO8601DateFormatter().string(from: Date()), + disclaimer: "Educational radar only — not investment advice.", + dataPath: source.rawValue + ), + gate: .init(market: c.marketGate, sector: sectorGate, stock: c.stockGate), + warnings: ["Options data is not part of this radar."], + depth: depth + ) + } + + private static func sma(_ xs: [Double], _ n: Int) -> Double { + guard xs.count >= n else { return 0 } + return xs.suffix(n).reduce(0, +) / Double(n) + } + + private static func rsi14(_ closes: [Double]) -> Double? { + guard closes.count > 15 else { return nil } + var gains = 0.0 + var losses = 0.0 + let slice = Array(closes.suffix(15)) + for i in 1..= 0 { gains += d } else { losses -= d } + } + let ag = gains / 14 + let al = losses / 14 + if al == 0 { return 100 } + let rs = ag / al + return 100 - (100 / (1 + rs)) + } +} diff --git a/ios/QuantRadar/Services/PostureDepth.swift b/ios/QuantRadar/Services/PostureDepth.swift new file mode 100644 index 0000000..7750b4d --- /dev/null +++ b/ios/QuantRadar/Services/PostureDepth.swift @@ -0,0 +1,141 @@ +import Foundation + +/// 90-day replay, forward evidence, earnings window, sector ETF map. +/// Pure functions — no network. Replay calls `FreeMechanicalScorer.core` only +/// (never `score`) so history cannot recurse. +enum PostureDepth { + static let historyDays = 90 + static let minBarsForDay = 50 + static let earningsWindowDays = 3 + + static let sectorETF: [String: String] = [ + "XLK": "Tech", + "XLF": "Financials", + "XLE": "Energy", + "XLV": "Health", + "XLY": "Cons. disc.", + "XLP": "Cons. staples", + "XLI": "Industrials", + "XLB": "Materials", + "XLRE": "Real estate", + "XLU": "Utilities", + "XLC": "Comm.", + ] + + static func etf(forSector sector: String?) -> String? { + guard let sector, !sector.isEmpty else { return nil } + let s = sector.lowercased() + if s.contains("technolog") { return "XLK" } + if s.contains("financ") { return "XLF" } + if s.contains("energy") { return "XLE" } + if s.contains("health") { return "XLV" } + if s.contains("cyclical") || s.contains("discretion") { return "XLY" } + if s.contains("defensive") || s.contains("staple") { return "XLP" } + if s.contains("industrial") { return "XLI" } + if s.contains("material") || s.contains("basic") { return "XLB" } + if s.contains("real estate") { return "XLRE" } + if s.contains("utilit") { return "XLU" } + if s.contains("communicat") { return "XLC" } + let upper = sector.uppercased() + if sectorETF[upper] != nil { return upper } + return nil + } + + static func nyCalendar() -> Calendar { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "America/New_York") ?? .gmt + return cal + } + + static func daysUntilEarnings(_ earnings: Date, now: Date = Date()) -> Int { + let cal = nyCalendar() + let a = cal.startOfDay(for: now) + let b = cal.startOfDay(for: earnings) + return cal.dateComponents([.day], from: a, to: b).day ?? 0 + } + + static func isNearEarnings(_ earnings: Date, now: Date = Date()) -> Bool { + let d = daysUntilEarnings(earnings, now: now) + return d >= -earningsWindowDays && d <= earningsWindowDays + } + + static func median(_ xs: [Double]) -> Double? { + guard !xs.isEmpty else { return nil } + let s = xs.sorted() + let m = s.count / 2 + if s.count % 2 == 0 { + return (s[m - 1] + s[m]) / 2 + } + return s[m] + } + + static func replayActions(bars: [FreeBar], spyBars: [FreeBar]?) -> [String] { + guard bars.count >= minBarsForDay else { return [] } + let start = max(minBarsForDay - 1, bars.count - historyDays) + var out: [String] = [] + out.reserveCapacity(bars.count - start) + for i in start..= min(slice.count, 5) { + spySlice = Array(spy.prefix(min(spy.count, slice.count))) + } + let core = FreeMechanicalScorer.core( + bars: slice, + spyBars: spySlice + ) + out.append(core.action) + } + return out + } + + static func build( + bars: [FreeBar], + spyBars: [FreeBar]?, + earningsDate: Date?, + now: Date = Date(), + earningsForcedWait: Bool, + sectorEtf: String? + ) -> RadarVerdict.Depth { + let history = replayActions(bars: bars, spyBars: spyBars) + let start = max(minBarsForDay - 1, bars.count - historyDays) + var fwd5: [Double] = [] + var fwd20: [Double] = [] + var lastSetupIndex: Int? + for (j, action) in history.enumerated() where action == "SETUP" { + let barIndex = start + j + lastSetupIndex = barIndex + if barIndex + 5 < bars.count { + let a = bars[barIndex].close + let b = bars[barIndex + 5].close + if a > 0 { fwd5.append((b - a) / a * 100) } + } + if barIndex + 20 < bars.count { + let a = bars[barIndex].close + let b = bars[barIndex + 20].close + if a > 0 { fwd20.append((b - a) / a * 100) } + } + } + var lastAgo: Int? + var lastFwd: Double? + if let idx = lastSetupIndex, idx < bars.count - 1, bars[idx].close > 0 { + lastAgo = bars.count - 1 - idx + lastFwd = (bars[bars.count - 1].close - bars[idx].close) / bars[idx].close * 100 + } + var earnDays: Int? + if let earningsDate { + earnDays = daysUntilEarnings(earningsDate, now: now) + } + return RadarVerdict.Depth( + history: history, + setupCount: history.filter { $0 == "SETUP" }.count, + medianForward5dPct: median(fwd5), + medianForward20dPct: median(fwd20), + lastSetupAgoDays: lastAgo, + lastSetupForwardPct: lastFwd, + earningsDays: earnDays, + earningsForcedWait: earningsForcedWait, + sectorEtf: sectorEtf + ) + } +} diff --git a/ios/QuantRadar/Services/PurchaseStore.swift b/ios/QuantRadar/Services/PurchaseStore.swift new file mode 100644 index 0000000..4555396 --- /dev/null +++ b/ios/QuantRadar/Services/PurchaseStore.swift @@ -0,0 +1,181 @@ +import Foundation +import StoreKit + +@MainActor +final class PurchaseStore: ObservableObject { + @Published private(set) var isUnlocked = false + @Published private(set) var isLivePlus = false + @Published private(set) var unlockProduct: Product? + @Published private(set) var liveMonthlyProduct: Product? + @Published private(set) var liveYearlyProduct: Product? + @Published var lastError: String? + @Published private(set) var isBusy = false + + private static let debugUnlockKey = "qr.debug.force_unlocked" + private static let debugLiveKey = "qr.debug.force_liveplus" + + /// DEBUG / Simulator convenience — never ships as true entitlements. + /// Default via init (not `Self.` in property init) for Xcode 15 / Swift 5.10. + @Published var debugForceUnlocked = false { + didSet { UserDefaults.standard.set(debugForceUnlocked, forKey: PurchaseStore.debugUnlockKey) } + } + + @Published var debugForceLivePlus = false { + didSet { UserDefaults.standard.set(debugForceLivePlus, forKey: PurchaseStore.debugLiveKey) } + } + + private var updatesTask: Task? + + var effectiveUnlocked: Bool { + #if DEBUG + return isUnlocked || debugForceUnlocked + #else + return isUnlocked + #endif + } + + var effectiveLivePlus: Bool { + #if DEBUG + return isLivePlus || debugForceLivePlus + #else + return isLivePlus + #endif + } + + var watchlistLimit: Int { + AppAccess.watchlistLimit(livePlus: effectiveLivePlus && effectiveUnlocked) + } + + init() { + debugForceUnlocked = UserDefaults.standard.bool(forKey: PurchaseStore.debugUnlockKey) + debugForceLivePlus = UserDefaults.standard.bool(forKey: PurchaseStore.debugLiveKey) + updatesTask = Task { await listenForTransactions() } + Task { await bootstrap() } + } + + deinit { + updatesTask?.cancel() + } + + func bootstrap() async { + await refreshEntitlements() + await loadProducts() + } + + func loadProducts() async { + do { + let ids: Set = [ + AppAccess.unlockProductID, + AppAccess.liveMonthlyProductID, + AppAccess.liveYearlyProductID, + ] + let products = try await Product.products(for: ids) + unlockProduct = products.first { $0.id == AppAccess.unlockProductID } + liveMonthlyProduct = products.first { $0.id == AppAccess.liveMonthlyProductID } + liveYearlyProduct = products.first { $0.id == AppAccess.liveYearlyProductID } + } catch { + lastError = error.localizedDescription + } + } + + func refreshEntitlements() async { + var unlocked = false + var live = false + for await result in Transaction.currentEntitlements { + guard case .verified(let tx) = result else { continue } + switch tx.productID { + case AppAccess.unlockProductID: + unlocked = true + case AppAccess.liveMonthlyProductID, AppAccess.liveYearlyProductID: + if tx.revocationDate == nil { + live = true + } + default: + break + } + } + isUnlocked = unlocked + isLivePlus = live + } + + @discardableResult + func purchaseUnlock() async -> Bool { + guard let product = unlockProduct else { + await loadProducts() + guard let product = unlockProduct else { + lastError = "Unlock product unavailable. Try again later." + return false + } + return await purchase(product) + } + return await purchase(product) + } + + @discardableResult + func purchaseLivePlus(yearly: Bool) async -> Bool { + guard effectiveUnlocked else { + lastError = "Unlock the core radar first ($9.99)." + return false + } + let product = yearly ? liveYearlyProduct : liveMonthlyProduct + guard let product else { + await loadProducts() + let again = yearly ? liveYearlyProduct : liveMonthlyProduct + guard let again else { + lastError = "Live+ product unavailable." + return false + } + return await purchase(again) + } + return await purchase(product) + } + + func restore() async { + isBusy = true + defer { isBusy = false } + do { + try await AppStore.sync() + await refreshEntitlements() + lastError = nil + } catch { + lastError = error.localizedDescription + } + } + + private func purchase(_ product: Product) async -> Bool { + isBusy = true + defer { isBusy = false } + do { + let result = try await product.purchase() + switch result { + case .success(let verification): + guard case .verified(let tx) = verification else { + lastError = "Purchase could not be verified." + return false + } + await tx.finish() + await refreshEntitlements() + lastError = nil + return true + case .userCancelled: + return false + case .pending: + lastError = "Purchase pending approval." + return false + @unknown default: + return false + } + } catch { + lastError = error.localizedDescription + return false + } + } + + private func listenForTransactions() async { + for await result in Transaction.updates { + guard case .verified(let tx) = result else { continue } + await tx.finish() + await refreshEntitlements() + } + } +} diff --git a/ios/QuantRadar/Services/RadarService.swift b/ios/QuantRadar/Services/RadarService.swift new file mode 100644 index 0000000..6bad572 --- /dev/null +++ b/ios/QuantRadar/Services/RadarService.swift @@ -0,0 +1,238 @@ +import Foundation + +struct SectorChip: Identifiable, Hashable { + var id: String { etf } + let etf: String + let label: String + let action: String +} + +@MainActor +final class RadarService: ObservableObject { + @Published private(set) var demo: RadarVerdict? + @Published private(set) var latest: RadarVerdict? + @Published private(set) var todayVerdict: RadarVerdict? + @Published private(set) var sectorHeat: [SectorChip] = [] + @Published private(set) var lastSource: String? + @Published private(set) var isLoading = false + @Published private(set) var isWarmingUp = false + @Published var errorMessage: String? + + private var warmUpStarted = false + + private var debugAnalyzeOverride: URL? { + guard let raw = UserDefaults.standard.string(forKey: "qr.debug.analyze.base"), + let url = URL(string: raw), !raw.isEmpty, + UserDefaults.standard.bool(forKey: "qr.debug.allow_web_analyze") else { + return nil + } + return url + } + + func loadDemo() { + guard let url = Bundle.main.url(forResource: "SampleVerdict", withExtension: "json"), + let data = try? Data(contentsOf: url), + let verdict = try? JSONDecoder().decode(RadarVerdict.self, from: data) else { + errorMessage = "Bundled demo missing." + return + } + demo = verdict + // Do not assign `latest` here — Today must not flash the INTC sample. + } + + /// Prefetch SPY into cache, then refresh Today with live SPY posture. + func warmUpToday() async { + guard !warmUpStarted else { return } + warmUpStarted = true + isWarmingUp = true + defer { isWarmingUp = false } + _ = try? await FreeMarketDataClient.dailyBars(symbol: "SPY", rangeHintDays: 90) + if await analyze(ticker: "SPY") { + todayVerdict = latest + } + } + + func analyze(ticker: String, forceDemo: Bool = false, bypassCache: Bool = false) async -> Bool { + let symbol = FreeMarketDataClient.normalize(ticker) + guard !symbol.isEmpty else { + errorMessage = "Enter a ticker." + return false + } + + if forceDemo && symbol == "INTC" { + if demo == nil { loadDemo() } + latest = demo + lastSource = "bundled_sample" + errorMessage = nil + return true + } + + isLoading = true + errorMessage = nil + defer { isLoading = false } + + if let base = debugAnalyzeOverride { + do { + latest = try await fetchWebAnalyze(base: base, ticker: symbol) + lastSource = "debug_web" + errorMessage = "Debug web analyze override active." + return true + } catch { + // fall through + } + } + + do { + async let primary = FreeMarketDataClient.dailyBars( + symbol: symbol, + bypassCache: bypassCache + ) + async let spy = FreeMarketDataClient.dailyBars( + symbol: "SPY", + rangeHintDays: 90, + bypassCache: bypassCache && symbol == "SPY" + ) + async let fund = FreeMarketDataClient.fundamentals(symbol: symbol) + let result = try await primary + let spyBars = try? await spy + let fundamentals = await fund + var sectorAction: String? + if let etf = PostureDepth.etf(forSector: fundamentals.sector), etf != symbol { + if let sectorBars = try? await FreeMarketDataClient.dailyBars(symbol: etf, rangeHintDays: 90) { + sectorAction = FreeMechanicalScorer.core(bars: sectorBars.bars, spyBars: spyBars?.bars).action + } + } + let scored = FreeMechanicalScorer.score( + symbol: symbol, + company: nil, + bars: result.bars, + spyBars: spyBars?.bars, + source: result.source, + earningsDate: fundamentals.earningsDate, + sectorName: fundamentals.sector, + sectorAction: sectorAction + ) + latest = scored + if symbol == "SPY" { todayVerdict = scored } + lastSource = result.fromCache ? "\(result.source.rawValue) · cache" : result.source.rawValue + return true + } catch { + if symbol == "INTC" { + if demo == nil { loadDemo() } + latest = demo + lastSource = "bundled_sample" + errorMessage = "All free sources failed — showing bundled INTC sample." + return latest != nil + } + latest = Self.synthetic(for: symbol, reason: error.localizedDescription) + lastSource = nil + errorMessage = "Free-data radar unavailable — fail closed." + return true + } + } + + /// Score a ticker without mutating `latest` (watchlist batch refresh). + func scoreQuietly(ticker: String) async -> RadarVerdict? { + let symbol = FreeMarketDataClient.normalize(ticker) + guard !symbol.isEmpty else { return nil } + do { + async let primary = FreeMarketDataClient.dailyBars(symbol: symbol) + async let spy = FreeMarketDataClient.dailyBars(symbol: "SPY", rangeHintDays: 90) + async let fund = FreeMarketDataClient.fundamentals(symbol: symbol) + let result = try await primary + let spyBars = try? await spy + let fundamentals = await fund + var sectorAction: String? + if let etf = PostureDepth.etf(forSector: fundamentals.sector), etf != symbol { + if let sectorBars = try? await FreeMarketDataClient.dailyBars(symbol: etf, rangeHintDays: 90) { + sectorAction = FreeMechanicalScorer.core(bars: sectorBars.bars, spyBars: spyBars?.bars).action + } + } + return FreeMechanicalScorer.score( + symbol: symbol, + company: nil, + bars: result.bars, + spyBars: spyBars?.bars, + source: result.source, + earningsDate: fundamentals.earningsDate, + sectorName: fundamentals.sector, + sectorAction: sectorAction + ) + } catch { + return nil + } + } + + /// Unlocked Today: sector ETF posture chips. Cached bars, limited concurrency. + func refreshSectors() async { + var rows: [SectorChip] = [] + let spy = try? await FreeMarketDataClient.dailyBars(symbol: "SPY", rangeHintDays: 90) + let etfs = Array(PostureDepth.sectorETF.keys).sorted() + await withTaskGroup(of: SectorChip?.self) { group in + var i = 0 + func enqueue() { + guard i < etfs.count else { return } + let etf = etfs[i] + i += 1 + group.addTask { + guard let bars = try? await FreeMarketDataClient.dailyBars(symbol: etf, rangeHintDays: 90) else { + return nil + } + let action = FreeMechanicalScorer.core(bars: bars.bars, spyBars: spy?.bars).action + let label = PostureDepth.sectorETF[etf] ?? etf + return SectorChip(etf: etf, label: label, action: action) + } + } + for _ in 0.. RadarVerdict { + var components = URLComponents( + url: base.appendingPathComponent("api/analyze"), + resolvingAgainstBaseURL: false + )! + components.queryItems = [URLQueryItem(name: "ticker", value: ticker)] + guard let url = components.url else { throw URLError(.badURL) } + var request = URLRequest(url: url) + request.timeoutInterval = 20 + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw URLError(.badServerResponse) + } + return try JSONDecoder().decode(RadarVerdict.self, from: data) + } + + static func synthetic(for ticker: String, reason: String) -> RadarVerdict { + RadarVerdict( + ok: true, + ticker: ticker, + companyName: ticker, + sector: nil, + primaryScore: .init(value: nil, scale: 100, label: "Mechanical posture score", withheld: true, note: reason), + primary: .init(action: "WAIT", label: "Wait & Watch", reason: "Insufficient free data — fail closed."), + summary: "Could not load bars for this ticker. Not forcing a trade.", + engagement: .init( + avoidedLine: "Not forcing a trade without usable data.", + freezeLabel: "Placeholder", + postureNote: "Mechanical posture ≠ trade direction." + ), + dataQuality: .init(usable: false, reliability: "low", optionsActionable: false), + market: nil, + meta: .init( + mode: "placeholder", + fetchTime: nil, + disclaimer: "Educational radar only — not investment advice.", + dataPath: "synthetic" + ), + gate: .init(market: "UNKNOWN", sector: "N/A", stock: "NO"), + warnings: [reason], + depth: nil + ) + } +} diff --git a/ios/QuantRadar/Services/ReviewPrompt.swift b/ios/QuantRadar/Services/ReviewPrompt.swift new file mode 100644 index 0000000..0b2f91c --- /dev/null +++ b/ios/QuantRadar/Services/ReviewPrompt.swift @@ -0,0 +1,46 @@ +import StoreKit +import SwiftUI +import UIKit + +enum ReviewPrompt { + static let launchKey = "qr.launch.count" + static let waitKey = "qr.saw.wait" + static let askedKey = "qr.review.asked" + + static var storage: UserDefaults = .standard + + static func recordLaunch() { + let n = storage.integer(forKey: launchKey) + 1 + storage.set(n, forKey: launchKey) + } + + static func recordVerdict(_ verdict: RadarVerdict) { + switch verdict.actionCode { + case "WAIT", "NO", "AVOID": + storage.set(true, forKey: waitKey) + default: + break + } + maybeRequest() + } + + static func shouldRequestReview() -> Bool { + guard !storage.bool(forKey: askedKey) else { return false } + guard storage.integer(forKey: launchKey) >= 2 else { return false } + return storage.bool(forKey: waitKey) + } + + static func maybeRequest() { + guard shouldRequestReview() else { return } + storage.set(true, forKey: askedKey) + #if !os(macOS) + if let scene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.activationState == .foregroundActive }) + ?? UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first + { + SKStoreReviewController.requestReview(in: scene) + } + #endif + } +} diff --git a/ios/QuantRadar/Services/WatchlistStore.swift b/ios/QuantRadar/Services/WatchlistStore.swift new file mode 100644 index 0000000..c30a561 --- /dev/null +++ b/ios/QuantRadar/Services/WatchlistStore.swift @@ -0,0 +1,137 @@ +import Foundation +import UserNotifications + +struct WatchItem: Codable, Identifiable, Hashable { + var id: String { ticker } + let ticker: String + var note: String + var remindOnImprove: Bool + var lastAction: String? + var lastScore: Double? +} + +@MainActor +final class WatchlistStore: ObservableObject { + @Published private(set) var items: [WatchItem] = [] + @Published private(set) var isRefreshing = false + + private let key = "qr.watchlist" + + init() { load() } + + func load() { + guard let data = UserDefaults.standard.data(forKey: key), + let decoded = try? JSONDecoder().decode([WatchItem].self, from: data) else { + items = [] + return + } + items = decoded + } + + private func persist() { + if let data = try? JSONEncoder().encode(items) { + UserDefaults.standard.set(data, forKey: key) + } + } + + func canAdd(limit: Int) -> Bool { + items.count < limit + } + + @discardableResult + func add(ticker: String, limit: Int) -> Bool { + let t = ticker.uppercased() + guard !items.contains(where: { $0.ticker == t }) else { return true } + guard canAdd(limit: limit) else { return false } + items.insert(WatchItem(ticker: t, note: "", remindOnImprove: true, lastAction: nil, lastScore: nil), at: 0) + persist() + return true + } + + func remove(_ ticker: String) { + items.removeAll { $0.ticker == ticker } + persist() + } + + func setRemind(_ ticker: String, enabled: Bool, livePlus: Bool = false) { + guard let idx = items.firstIndex(where: { $0.ticker == ticker }) else { return } + items[idx].remindOnImprove = enabled + persist() + if enabled { + Task { _ = await AlertScheduler.requestPermission() } + } + _ = livePlus + } + + func updateAction(_ ticker: String, action: String, score: Double? = nil) { + guard let idx = items.firstIndex(where: { $0.ticker == ticker }) else { return } + items[idx].lastAction = action + if let score { items[idx].lastScore = score } + persist() + } + + /// Refresh watchlist scores with limited concurrency (max 2) so SPY cache is reused. + func refreshScores(using radar: RadarService) async { + guard !items.isEmpty, !isRefreshing else { return } + isRefreshing = true + defer { isRefreshing = false } + + let tickers = items.map(\.ticker) + var index = 0 + let limit = 2 + + await withTaskGroup(of: (String, RadarVerdict?).self) { group in + func enqueueNext() { + guard index < tickers.count else { return } + let t = tickers[index] + index += 1 + group.addTask { + let verdict = await radar.scoreQuietly(ticker: t) + return (t, verdict) + } + } + + for _ in 0.. Bool { + let center = UNUserNotificationCenter.current() + let granted = try? await center.requestAuthorization(options: [.alert, .sound, .badge]) + return granted == true + } + + static func notifyPostureChange(ticker: String, action: String) async { + guard await requestPermission() else { return } + let content = UNMutableNotificationContent() + content.title = "\(ticker) posture changed" + content.body = "Now \(action). Educational only — not investment advice." + content.sound = .default + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + let req = UNNotificationRequest( + identifier: "watch-change-\(ticker)-\(Int(Date().timeIntervalSince1970))", + content: content, + trigger: trigger + ) + try? await UNUserNotificationCenter.current().add(req) + } +} diff --git a/ios/QuantRadar/Views/LockedVerdictView.swift b/ios/QuantRadar/Views/LockedVerdictView.swift new file mode 100644 index 0000000..8f4d4ad --- /dev/null +++ b/ios/QuantRadar/Views/LockedVerdictView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +/// Blurred real verdict + anxiety copy. Score is computed; details stay locked. +struct LockedVerdictView: View { + let verdict: RadarVerdict + let onUnlock: () -> Void + + var body: some View { + ZStack { + VerdictCardView(verdict: verdict, showsShare: false) + .blur(radius: 10) + .allowsHitTesting(false) + + VStack(spacing: 12) { + Text(AppAccess.anxietyCopy(ticker: verdict.ticker)) + .font(.headline) + .multilineTextAlignment(.center) + .foregroundStyle(QRTheme.text) + Text("Posture is scored. Unlock to see it — one time, not a subscription.") + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundStyle(QRTheme.muted) + Button(action: onUnlock) { + Text("Unlock to see · $9.99") + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(QRTheme.radar) + .foregroundStyle(.black) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + } + .padding(20) + } + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + } +} diff --git a/ios/QuantRadar/Views/OnboardingView.swift b/ios/QuantRadar/Views/OnboardingView.swift new file mode 100644 index 0000000..d22a5fa --- /dev/null +++ b/ios/QuantRadar/Views/OnboardingView.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct OnboardingView: View { + @Binding var hasSeenOnboarding: Bool + + var body: some View { + ZStack { + QRTheme.bg.ignoresSafeArea() + VStack(spacing: 28) { + Spacer() + Image(systemName: "dot.radiowaves.left.and.right") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(QRTheme.radar) + + Text("QuantRadar") + .font(.system(size: 36, weight: .bold, design: .rounded)) + .foregroundStyle(QRTheme.text) + + VStack(spacing: 10) { + Text("Should you act today?") + .font(.title3.weight(.semibold)) + .foregroundStyle(QRTheme.text) + .multilineTextAlignment(.center) + Text("One mechanical posture score for US tickers. Most days the honest answer is wait.") + .font(.body) + .foregroundStyle(QRTheme.muted) + .multilineTextAlignment(.center) + .padding(.horizontal, 28) + } + + Button { + hasSeenOnboarding = true + } label: { + Text("Open radar") + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(QRTheme.radar) + .foregroundStyle(Color.black) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .padding(.horizontal, 24) + + Text("Educational only — not investment advice.") + .font(.caption) + .foregroundStyle(QRTheme.muted) + .padding(.bottom, 24) + } + } + } +} diff --git a/ios/QuantRadar/Views/PaywallView.swift b/ios/QuantRadar/Views/PaywallView.swift new file mode 100644 index 0000000..a65c9ac --- /dev/null +++ b/ios/QuantRadar/Views/PaywallView.swift @@ -0,0 +1,105 @@ +import SwiftUI + +/// Unlock sheet — one product, one decision. +struct PaywallView: View { + @EnvironmentObject private var purchases: PurchaseStore + @Environment(\.dismiss) private var dismiss + var focusTicker: String? = nil + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Text("Unlock any ticker.") + .font(.title2.bold()) + .foregroundStyle(QRTheme.text) + + Text(headline) + .font(.body) + .foregroundStyle(QRTheme.muted) + + VStack(alignment: .leading, spacing: 10) { + bullet("Any US ticker scan") + bullet("Watchlist with posture-change alerts") + bullet("90-day posture strip and setup evidence") + bullet("One-time $9.99 — not a subscription") + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(QRTheme.panel) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + + if let err = purchases.lastError { + Text(err) + .font(.footnote) + .foregroundStyle(QRTheme.warn) + } + + Button { + Task { + if await purchases.purchaseUnlock() { + dismiss() + } + } + } label: { + Text(unlockButtonTitle) + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(QRTheme.radar) + .foregroundStyle(.black) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .disabled(purchases.isBusy || purchases.effectiveUnlocked) + + Button("Restore Purchases") { + Task { await purchases.restore() } + } + .disabled(purchases.isBusy) + + Text(AppAccess.founderPriceLine) + .font(.caption) + .foregroundStyle(QRTheme.muted) + + Text("Educational only — not investment advice. No guarantee of outcomes.") + .font(.caption2) + .foregroundStyle(QRTheme.muted) + } + .padding(20) + } + .background(QRTheme.bg.ignoresSafeArea()) + .navigationTitle("Unlock") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Close") { dismiss() } + } + } + } + } + + private var headline: String { + if let t = focusTicker, !t.isEmpty { + return "\(AppAccess.anxietyCopy(ticker: t)) You already have today’s SPY and one personal scan. Unlock once for every US ticker plus a watchlist. Most days the honest answer is still wait." + } + return "You already have today’s SPY and one personal scan. Unlock once for every US ticker plus a watchlist. Most days the honest answer is still wait." + } + + private var unlockButtonTitle: String { + if purchases.effectiveUnlocked { return "Already unlocked" } + if let p = purchases.unlockProduct { + return "Unlock · \(p.displayPrice)" + } + return "Unlock · $9.99" + } + + private func bullet(_ text: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(QRTheme.radar) + Text(text) + .foregroundStyle(QRTheme.text) + .font(.subheadline) + } + } +} diff --git a/ios/QuantRadar/Views/PostureStripView.swift b/ios/QuantRadar/Views/PostureStripView.swift new file mode 100644 index 0000000..950da1f --- /dev/null +++ b/ios/QuantRadar/Views/PostureStripView.swift @@ -0,0 +1,66 @@ +import SwiftUI + +struct PostureStripView: View { + let history: [String] + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text("90-day posture") + .font(.caption2.weight(.semibold)) + .foregroundStyle(QRTheme.muted) + GeometryReader { geo in + let n = max(history.count, 1) + let w = max(1.0, geo.size.width / CGFloat(n)) + HStack(spacing: 0) { + ForEach(Array(history.enumerated()), id: \.offset) { _, action in + Rectangle() + .fill(color(action)) + .frame(width: w, height: 14) + } + } + .clipShape(RoundedRectangle(cornerRadius: 3, style: .continuous)) + } + .frame(height: 14) + } + } + + private func color(_ action: String) -> Color { + switch action.uppercased() { + case "SETUP": return QRTheme.radar + case "NO", "AVOID": return QRTheme.danger + default: return QRTheme.warn.opacity(0.85) + } + } +} + +struct DepthFactsView: View { + let depth: RadarVerdict.Depth + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if depth.earningsForcedWait == true, let d = depth.earningsDays { + Text(earningsLine(d)) + .font(.footnote.weight(.medium)) + .foregroundStyle(QRTheme.warn) + } + if let ago = depth.lastSetupAgoDays, let fwd = depth.lastSetupForwardPct, ago > 0 { + Text(String(format: "Last SETUP %d sessions ago · %+.1f%% since. Educational — not a promise.", ago, fwd)) + .font(.footnote) + .foregroundStyle(QRTheme.muted) + } + if let n = depth.setupCount, n > 0, let m5 = depth.medianForward5dPct { + let m20 = depth.medianForward20dPct + let extra = m20.map { String(format: " · 20-day median %+.1f%%", $0) } ?? "" + Text(String(format: "When SETUP printed here: 5-day median %+.1f%% (n=%d)%@.", m5, n, extra)) + .font(.caption) + .foregroundStyle(QRTheme.muted) + } + } + } + + private func earningsLine(_ d: Int) -> String { + if d == 0 { return "Earnings today — radar stays on WAIT." } + if d > 0 { return "Earnings in \(d) days — radar stays on WAIT." } + return "Earnings \(-d) days ago — radar stays on WAIT." + } +} diff --git a/ios/QuantRadar/Views/SearchView.swift b/ios/QuantRadar/Views/SearchView.swift new file mode 100644 index 0000000..c547f23 --- /dev/null +++ b/ios/QuantRadar/Views/SearchView.swift @@ -0,0 +1,149 @@ +import SwiftUI + +struct SearchView: View { + @EnvironmentObject private var radar: RadarService + @EnvironmentObject private var watchlist: WatchlistStore + @EnvironmentObject private var purchases: PurchaseStore + @State private var ticker = "" + @State private var showPaywall = false + @State private var paywallTicker: String? + @State private var gateMessage: String? + @State private var lockedPreview = false + @FocusState private var focused: Bool + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text(AppAccess.differentiationLine) + .font(.footnote) + .foregroundStyle(QRTheme.muted) + + HStack { + TextField("Ticker (e.g. AAPL)", text: $ticker) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .focused($focused) + .padding(14) + .background(QRTheme.panel) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .foregroundStyle(QRTheme.text) + + Button("Scan") { Task { await runScan() } } + .buttonStyle(.borderedProminent) + .tint(QRTheme.radar) + .foregroundStyle(.black) + .disabled(radar.isLoading || ticker.trimmingCharacters(in: .whitespaces).isEmpty) + } + + HStack(spacing: 8) { + ForEach(["SPY", "AAPL", "NVDA", "MSFT"], id: \.self) { chip in + Button(chip) { + ticker = chip + Task { await runScan() } + } + .font(.caption.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(QRTheme.radarDim) + .foregroundStyle(QRTheme.radar) + .clipShape(Capsule()) + } + } + + if !purchases.effectiveUnlocked { + Text(previewCaption) + .font(.caption) + .foregroundStyle(QRTheme.muted) + } + + if radar.isLoading { + ProgressView("Running mechanical scan…") + .tint(QRTheme.radar) + .foregroundStyle(QRTheme.muted) + } + + if let gateMessage { + Text(gateMessage) + .font(.footnote) + .foregroundStyle(QRTheme.warn) + } + + if let err = radar.errorMessage { + Text(err) + .font(.footnote) + .foregroundStyle(QRTheme.warn) + } + + if let v = radar.latest { + if lockedPreview { + LockedVerdictView(verdict: v) { + paywallTicker = v.ticker + showPaywall = true + } + } else { + VerdictCardView(verdict: v) + Text("Educational only — not investment advice. Not a broker.") + .font(.caption2) + .foregroundStyle(QRTheme.muted) + Button { + addToWatch(v.ticker) + } label: { + Label("Add to Watch", systemImage: "eye") + } + .buttonStyle(.bordered) + } + } + } + .padding(20) + } + .background(QRTheme.bg.ignoresSafeArea()) + .navigationTitle("Scan") + .onChange(of: radar.latest?.ticker) { _, _ in + if let v = radar.latest, !lockedPreview { ReviewPrompt.recordVerdict(v) } + } + .sheet(isPresented: $showPaywall) { + PaywallView(focusTicker: paywallTicker) + .environmentObject(purchases) + } + } + } + + private var previewCaption: String { + if let claimed = AppAccess.claimedPreviewTicker { + return "Free ticker: \(claimed). Unlock for any other symbol." + } + return AppAccess.previewLine + } + + private func runScan() async { + focused = false + gateMessage = nil + let symbol = AppAccess.normalizeTicker(ticker) + let locked = AppAccess.isPreviewLocked(ticker: symbol, unlocked: purchases.effectiveUnlocked) + lockedPreview = locked + let ok = await radar.analyze(ticker: symbol) + guard ok, let latest = radar.latest else { return } + if locked { + paywallTicker = symbol + showPaywall = true + return + } + if !latest.isWithheld { + AppAccess.claimPreviewTickerIfNeeded(symbol, withheld: false) + DisciplineLedger.record(action: latest.actionCode, ticker: symbol, close: nil) + } + } + + private func addToWatch(_ ticker: String) { + guard purchases.effectiveUnlocked else { + gateMessage = "Unlock to use Watch." + paywallTicker = ticker + showPaywall = true + return + } + if !watchlist.add(ticker: ticker, limit: purchases.watchlistLimit) { + gateMessage = "Watchlist full (\(purchases.watchlistLimit))." + } + } +} diff --git a/ios/QuantRadar/Views/SettingsView.swift b/ios/QuantRadar/Views/SettingsView.swift new file mode 100644 index 0000000..aeff6c4 --- /dev/null +++ b/ios/QuantRadar/Views/SettingsView.swift @@ -0,0 +1,79 @@ +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject private var purchases: PurchaseStore + @State private var showPaywall = false + + var body: some View { + NavigationStack { + Form { + Section("Purchase") { + LabeledContent( + "Core", + value: purchases.effectiveUnlocked ? "Unlocked" : "Locked · $9.99" + ) + Text("One-time unlock for any ticker, watchlist, and 90-day evidence.") + .font(.caption) + .foregroundStyle(.secondary) + + if !purchases.effectiveUnlocked { + Button("Unlock full radar") { showPaywall = true } + } + + Button("Restore Purchases") { + Task { await purchases.restore() } + } + .disabled(purchases.isBusy) + + if let err = purchases.lastError { + Text(err) + .font(.caption) + .foregroundStyle(QRTheme.warn) + } + } + + #if DEBUG + Section("Debug") { + Toggle("Force unlocked", isOn: $purchases.debugForceUnlocked) + Toggle("Force Live+", isOn: $purchases.debugForceLivePlus) + } + #endif + + Section("Discipline") { + LabeledContent("Streak", value: "\(DisciplineLedger.streak) days") + LabeledContent("Waits logged", value: "\(DisciplineLedger.waitCount)") + Text(DisciplineLedger.summaryLine) + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Legal") { + Text("Educational radar only. Not investment advice. Not a broker. No guarantee of outcomes.") + Link("Privacy Policy (iOS)", destination: URL(string: AppAccess.privacyURL)!) + Link("Terms of Use (iOS)", destination: URL(string: AppAccess.termsURL)!) + Link( + "EULA (Apple standard)", + destination: URL(string: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/")! + ) + } + + Section("About") { + LabeledContent( + "Version", + value: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.1" + ) + Text("Independent App Store product. Website subscriptions do not unlock this app.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .scrollContentBackground(.hidden) + .background(QRTheme.bg.ignoresSafeArea()) + .navigationTitle("Settings") + .sheet(isPresented: $showPaywall) { + PaywallView() + .environmentObject(purchases) + } + } + } +} diff --git a/ios/QuantRadar/Views/TodayView.swift b/ios/QuantRadar/Views/TodayView.swift new file mode 100644 index 0000000..7b098bb --- /dev/null +++ b/ios/QuantRadar/Views/TodayView.swift @@ -0,0 +1,138 @@ +import SwiftUI + +struct TodayView: View { + @EnvironmentObject private var radar: RadarService + @EnvironmentObject private var purchases: PurchaseStore + @State private var showPaywall = false + @State private var briefingAsked = false + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 6) { + Text("Market posture") + .font(.caption.weight(.semibold)) + .foregroundStyle(QRTheme.radar) + Text("Should you act today?") + .font(.title.bold()) + .foregroundStyle(QRTheme.text) + Text(AppAccess.differentiationLine) + .font(.footnote.weight(.medium)) + .foregroundStyle(QRTheme.text) + Text(DisciplineLedger.summaryLine) + .font(.caption) + .foregroundStyle(QRTheme.muted) + } + + if let v = radar.todayVerdict { + VerdictCardView(verdict: v) + if radar.isWarmingUp || radar.isLoading { + HStack(spacing: 8) { + ProgressView().tint(QRTheme.radar).controlSize(.small) + Text("Refreshing live posture…") + .font(.caption) + .foregroundStyle(QRTheme.muted) + } + } + } else if radar.isLoading || radar.isWarmingUp { + ProgressView().tint(QRTheme.radar) + } else { + Text(radar.errorMessage ?? "Market posture unavailable.") + .foregroundStyle(QRTheme.warn) + } + + if purchases.effectiveUnlocked, !radar.sectorHeat.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Sector posture") + .font(.caption.weight(.semibold)) + .foregroundStyle(QRTheme.muted) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 96), spacing: 8)], spacing: 8) { + ForEach(radar.sectorHeat, id: \.etf) { row in + VStack(alignment: .leading, spacing: 2) { + Text(row.label) + .font(.caption2) + .foregroundStyle(QRTheme.muted) + Text(row.action) + .font(.caption.weight(.bold)) + .foregroundStyle(QRTheme.text) + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(QRTheme.panel) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + } + } + } + + if purchases.effectiveUnlocked { + Label("Unlocked · full radar", systemImage: "checkmark.seal.fill") + .font(.subheadline.weight(.medium)) + .foregroundStyle(QRTheme.radar) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(QRTheme.panel) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } else { + Button { showPaywall = true } label: { + Label("Unlock any ticker · $9.99", systemImage: "lock.open") + .font(.subheadline.weight(.medium)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(QRTheme.panel) + .foregroundStyle(QRTheme.radar) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + Text(AppAccess.previewLine) + .font(.caption2) + .foregroundStyle(QRTheme.muted) + } + + Text(radar.todayVerdict?.meta?.disclaimer ?? "Educational radar only — not investment advice.") + .font(.caption2) + .foregroundStyle(QRTheme.muted) + } + .padding(20) + } + .background(QRTheme.bg.ignoresSafeArea()) + .navigationTitle("Today") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { + _ = await radar.analyze(ticker: "SPY", bypassCache: true) + if purchases.effectiveUnlocked { + await radar.refreshSectors() + } + } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(radar.isLoading || radar.isWarmingUp) + } + } + .task { + await radar.warmUpToday() + if let v = radar.todayVerdict, !v.isWithheld { + DisciplineLedger.record(action: v.actionCode, ticker: v.ticker, close: nil) + ReviewPrompt.recordVerdict(v) + } + if purchases.effectiveUnlocked { + await radar.refreshSectors() + } + if !briefingAsked { + briefingAsked = true + await DailyBriefing.requestAndSchedule() + } + } + .onChange(of: radar.todayVerdict?.ticker) { _, _ in + if let v = radar.todayVerdict { ReviewPrompt.recordVerdict(v) } + } + .sheet(isPresented: $showPaywall) { + PaywallView() + .environmentObject(purchases) + } + } + } +} diff --git a/ios/QuantRadar/Views/VerdictCardView.swift b/ios/QuantRadar/Views/VerdictCardView.swift new file mode 100644 index 0000000..9c61753 --- /dev/null +++ b/ios/QuantRadar/Views/VerdictCardView.swift @@ -0,0 +1,150 @@ +import SwiftUI + +struct VerdictCardView: View { + let verdict: RadarVerdict + var showsShare: Bool = true + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(verdict.ticker) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .foregroundStyle(QRTheme.text) + if let name = verdict.companyName { + Text(name) + .font(.subheadline) + .foregroundStyle(QRTheme.muted) + } + } + Spacer() + scoreBadge + } + + actionPill + + if let reason = verdict.primary?.reason { + Text(reason) + .font(.body) + .foregroundStyle(QRTheme.text) + } + + if let summary = verdict.summary { + Text(summary) + .font(.subheadline) + .foregroundStyle(QRTheme.muted) + } + + if let avoided = verdict.engagement?.avoidedLine { + Label(avoided, systemImage: "shield.lefthalf.filled") + .font(.footnote) + .foregroundStyle(QRTheme.radar) + } + + gatesRow + marketRow + + if let history = verdict.depth?.history, !history.isEmpty { + PostureStripView(history: history) + } + if let depth = verdict.depth { + DepthFactsView(depth: depth) + } + + if showsShare { + ShareLink(item: verdict.shareText) { + Label("Share", systemImage: "square.and.arrow.up") + .font(.subheadline.weight(.medium)) + .foregroundStyle(QRTheme.radar) + } + } + } + .padding(18) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(QRTheme.panel) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(QRTheme.radar.opacity(0.25), lineWidth: 1) + ) + ) + } + + private var scoreBadge: some View { + VStack(spacing: 2) { + Text(verdict.scoreText) + .font(.system(size: 32, weight: .bold, design: .rounded)) + .foregroundStyle(QRTheme.radar) + Text("score") + .font(.caption2) + .foregroundStyle(QRTheme.muted) + } + .frame(width: 72, height: 72) + .background(QRTheme.radarDim) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + + private var actionPill: some View { + Text((verdict.primary?.label ?? verdict.actionCode).uppercased()) + .font(.caption.weight(.bold)) + .tracking(1.2) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(actionColor.opacity(0.2)) + .foregroundStyle(actionColor) + .clipShape(Capsule()) + } + + private var actionColor: Color { + switch verdict.actionCode { + case "SETUP", "BUY", "LONG", "YES": return QRTheme.radar + case "NO", "AVOID": return QRTheme.danger + default: return QRTheme.warn + } + } + + @ViewBuilder + private var gatesRow: some View { + if let g = verdict.gate { + HStack(spacing: 8) { + gateChip("Market", g.market) + gateChip("Sector", g.sector) + gateChip("Stock", g.stock) + } + } + } + + private func gateChip(_ title: String, _ value: String?) -> some View { + VStack(spacing: 2) { + Text(title) + .font(.caption2) + .foregroundStyle(QRTheme.muted) + Text(value ?? "—") + .font(.caption.weight(.semibold)) + .foregroundStyle(QRTheme.text) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(QRTheme.bg.opacity(0.6)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + + @ViewBuilder + private var marketRow: some View { + if let m = verdict.market { + HStack { + if let spy = m.spyChangePct { + Text(String(format: "SPY %+.2f%%", spy)) + } + if let vix = m.vixCurrent { + Text(String(format: "VIX %.1f", vix)) + } + if let trend = m.vixTrend { + Text("VIX \(trend)") + } + } + .font(.caption) + .foregroundStyle(QRTheme.muted) + } + } +} diff --git a/ios/QuantRadar/Views/WatchlistView.swift b/ios/QuantRadar/Views/WatchlistView.swift new file mode 100644 index 0000000..f9a8113 --- /dev/null +++ b/ios/QuantRadar/Views/WatchlistView.swift @@ -0,0 +1,87 @@ +import SwiftUI + +struct WatchlistView: View { + @EnvironmentObject private var watchlist: WatchlistStore + @EnvironmentObject private var radar: RadarService + @EnvironmentObject private var purchases: PurchaseStore + + var body: some View { + NavigationStack { + Group { + if watchlist.items.isEmpty { + ContentUnavailableView( + "No watches yet", + systemImage: "eye.slash", + description: Text("Scan a ticker and tap Add to Watch.") + ) + } else { + List { + ForEach(watchlist.items) { item in + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(item.ticker) + .font(.headline) + .foregroundStyle(QRTheme.text) + Spacer() + if let score = item.lastScore { + Text(String(format: "%.0f", score)) + .font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(QRTheme.muted) + } + if let a = item.lastAction { + Text(a) + .font(.caption.weight(.bold)) + .foregroundStyle(QRTheme.warn) + } + } + Toggle( + "Remind if posture changes", + isOn: Binding( + get: { item.remindOnImprove }, + set: { + watchlist.setRemind( + item.ticker, + enabled: $0, + livePlus: purchases.effectiveLivePlus + ) + } + ) + ) + .tint(QRTheme.radar) + .font(.subheadline) + } + .listRowBackground(QRTheme.panel) + } + .onDelete { idx in + idx.map { watchlist.items[$0].ticker }.forEach(watchlist.remove) + } + } + .scrollContentBackground(.hidden) + .overlay(alignment: .top) { + if watchlist.isRefreshing { + ProgressView() + .tint(QRTheme.radar) + .padding(8) + } + } + } + } + .background(QRTheme.bg.ignoresSafeArea()) + .navigationTitle("Watch") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await watchlist.refreshScores(using: radar) } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(watchlist.items.isEmpty || watchlist.isRefreshing) + } + } + .task { + guard !watchlist.items.isEmpty else { return } + await watchlist.refreshScores(using: radar) + } + } + } +} diff --git a/ios/QuantRadarTests/AppAccessTests.swift b/ios/QuantRadarTests/AppAccessTests.swift new file mode 100644 index 0000000..61edfca --- /dev/null +++ b/ios/QuantRadarTests/AppAccessTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import QuantRadar + +final class AppAccessTests: XCTestCase { + override func setUp() { + super.setUp() + AppAccess.storage = UserDefaults(suiteName: "qr.tests.\(UUID().uuidString)")! + AppAccess.resetPreviewTicker() + ReviewPrompt.storage = UserDefaults(suiteName: "qr.review.\(UUID().uuidString)")! + } + + override func tearDown() { + AppAccess.storage = .standard + ReviewPrompt.storage = .standard + super.tearDown() + } + + func testSPYAlwaysFree() { + XCTAssertTrue(AppAccess.canScan(ticker: "SPY", unlocked: false)) + XCTAssertTrue(AppAccess.canScan(ticker: " spy ", unlocked: false)) + } + + func testFirstPersonalTickerThenLock() { + XCTAssertTrue(AppAccess.canScan(ticker: "AAPL", unlocked: false)) + XCTAssertTrue(AppAccess.claimPreviewTickerIfNeeded("AAPL", withheld: false)) + XCTAssertEqual(AppAccess.claimedPreviewTicker, "AAPL") + XCTAssertTrue(AppAccess.canScan(ticker: "AAPL", unlocked: false)) + XCTAssertFalse(AppAccess.canScan(ticker: "NVDA", unlocked: false)) + XCTAssertTrue(AppAccess.canScan(ticker: "SPY", unlocked: false)) + } + + func testWithheldDoesNotClaimPreview() { + XCTAssertTrue(AppAccess.claimPreviewTickerIfNeeded("MSFT", withheld: true)) + XCTAssertNil(AppAccess.claimedPreviewTicker) + XCTAssertTrue(AppAccess.canScan(ticker: "NVDA", unlocked: false)) + } + + func testSPYDoesNotConsumePreview() { + XCTAssertTrue(AppAccess.claimPreviewTickerIfNeeded("SPY", withheld: false)) + XCTAssertNil(AppAccess.claimedPreviewTicker) + XCTAssertTrue(AppAccess.canScan(ticker: "AAPL", unlocked: false)) + } + + func testUnlockedScansAny() { + _ = AppAccess.claimPreviewTickerIfNeeded("AAPL", withheld: false) + XCTAssertTrue(AppAccess.canScan(ticker: "NVDA", unlocked: true)) + XCTAssertTrue(AppAccess.canScan(ticker: "AAPL", unlocked: true)) + } + + func testWatchlistLimits() { + XCTAssertEqual(AppAccess.watchlistLimit(livePlus: false), 20) + XCTAssertEqual(AppAccess.watchlistLimit(livePlus: true), 50) + } + + func testReminderIntervals() { + XCTAssertEqual(AppAccess.reminderIntervalSeconds(livePlus: false), 60 * 60 * 24) + XCTAssertEqual(AppAccess.reminderIntervalSeconds(livePlus: true), 60 * 60 * 6) + } + + func testProductIDsStable() { + XCTAssertEqual(AppAccess.unlockProductID, "one.quantradar.app.unlock") + XCTAssertEqual(AppAccess.liveMonthlyProductID, "one.quantradar.app.live.monthly") + XCTAssertEqual(AppAccess.liveYearlyProductID, "one.quantradar.app.live.yearly") + } + + func testPostureChangeNotify() { + XCTAssertFalse(AppAccess.shouldNotifyPostureChange(previous: nil, next: "SETUP", remind: true)) + XCTAssertFalse(AppAccess.shouldNotifyPostureChange(previous: "WAIT", next: "WAIT", remind: true)) + XCTAssertFalse(AppAccess.shouldNotifyPostureChange(previous: "WAIT", next: "SETUP", remind: false)) + XCTAssertTrue(AppAccess.shouldNotifyPostureChange(previous: "WAIT", next: "SETUP", remind: true)) + } + + func testReviewPromptNeedsSecondLaunchAndWait() { + XCTAssertFalse(ReviewPrompt.shouldRequestReview()) + ReviewPrompt.recordLaunch() + XCTAssertFalse(ReviewPrompt.shouldRequestReview()) + ReviewPrompt.recordLaunch() + XCTAssertFalse(ReviewPrompt.shouldRequestReview()) + ReviewPrompt.storage.set(true, forKey: ReviewPrompt.waitKey) + XCTAssertTrue(ReviewPrompt.shouldRequestReview()) + } +} diff --git a/ios/QuantRadarTests/BarsCacheTests.swift b/ios/QuantRadarTests/BarsCacheTests.swift new file mode 100644 index 0000000..16a8b04 --- /dev/null +++ b/ios/QuantRadarTests/BarsCacheTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import QuantRadar + +final class BarsCacheTests: XCTestCase { + func testMemoryAndDiskRoundTrip() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("qr_cache_test_\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let cache = BarsCache(diskDir: dir) + let bars = (0..<40).map { i in + FreeBar( + date: Date(timeIntervalSince1970: 1_700_000_000 + Double(i) * 86_400), + close: 100 + Double(i), + volume: 1_000_000 + ) + } + let result = FreeBarsResult(bars: bars, source: .yahooQuery1, fromCache: false) + await cache.set("AAPL", result: result) + + let hit = await cache.get("AAPL", maxAge: 60) + XCTAssertNotNil(hit) + XCTAssertEqual(hit?.bars.count, 40) + XCTAssertEqual(hit?.source, .yahooQuery1) + + // New actor instance sharing same disk dir should still hit. + let cache2 = BarsCache(diskDir: dir) + let diskHit = await cache2.get("aapl", maxAge: 60) + XCTAssertEqual(diskHit?.bars.last?.close, bars.last?.close) + } + + func testExpiredEntryMisses() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("qr_cache_ttl_\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let cache = BarsCache(diskDir: dir) + let bars = (0..<35).map { i in + FreeBar(date: Date().addingTimeInterval(Double(i) * -86_400), close: 50, volume: 100) + } + await cache.set("MSFT", result: FreeBarsResult(bars: bars, source: .nasdaq, fromCache: false)) + let miss = await cache.get("MSFT", maxAge: 0) + XCTAssertNil(miss) + } +} diff --git a/ios/QuantRadarTests/DepthEngineTests.swift b/ios/QuantRadarTests/DepthEngineTests.swift new file mode 100644 index 0000000..d5a3ac2 --- /dev/null +++ b/ios/QuantRadarTests/DepthEngineTests.swift @@ -0,0 +1,152 @@ +import XCTest +@testable import QuantRadar + +@MainActor +final class DepthEngineTests: XCTestCase { + override func setUp() { + super.setUp() + DisciplineLedger.storage = UserDefaults(suiteName: "qr.disc.\(UUID().uuidString)")! + DisciplineLedger.reset() + AppAccess.storage = UserDefaults(suiteName: "qr.acc.\(UUID().uuidString)")! + AppAccess.resetPreviewTicker() + } + + override func tearDown() { + DisciplineLedger.storage = .standard + AppAccess.storage = .standard + super.tearDown() + } + + func testSectorETFMap() { + XCTAssertEqual(PostureDepth.etf(forSector: "Technology"), "XLK") + XCTAssertEqual(PostureDepth.etf(forSector: "Financial Services"), "XLF") + XCTAssertEqual(PostureDepth.etf(forSector: "XLK"), "XLK") + XCTAssertNil(PostureDepth.etf(forSector: nil)) + } + + func testEarningsWindow() { + let now = Date(timeIntervalSince1970: 1_787_184_000) // 2026-08-20 approx + let soon = now.addingTimeInterval(2 * 86_400) + let far = now.addingTimeInterval(20 * 86_400) + XCTAssertTrue(PostureDepth.isNearEarnings(soon, now: now)) + XCTAssertFalse(PostureDepth.isNearEarnings(far, now: now)) + XCTAssertEqual(PostureDepth.daysUntilEarnings(soon, now: now), 2) + } + + func testMedian() { + XCTAssertEqual(PostureDepth.median([1, 3, 2]), 2) + XCTAssertEqual(PostureDepth.median([1, 2, 3, 4]), 2.5) + XCTAssertNil(PostureDepth.median([])) + } + + func testEarningsForcesSETUPToWAIT() { + let bars = Self.grind(start: 100, end: 112, count: 80, lastVolume: 2_400_000) + let spy = Self.grind(start: 400, end: 408, count: 80, lastVolume: 1_000_000) + let now = Date(timeIntervalSince1970: 1_787_184_000) + let earnings = now.addingTimeInterval(86_400) + let open = FreeMechanicalScorer.score( + symbol: "AAPL", + company: "Apple", + bars: bars, + spyBars: spy, + source: .yahooQuery1, + now: now + ) + let guarded = FreeMechanicalScorer.score( + symbol: "AAPL", + company: "Apple", + bars: bars, + spyBars: spy, + source: .yahooQuery1, + earningsDate: earnings, + now: now + ) + XCTAssertNotEqual(open.primary?.action, "BUY") + XCTAssertNotEqual(guarded.primary?.action, "BUY") + XCTAssertNotEqual(guarded.primary?.action, "SETUP") + if open.primary?.action == "SETUP" { + XCTAssertEqual(guarded.primary?.action, "WAIT") + XCTAssertEqual(guarded.depth?.earningsForcedWait, true) + } + } + + func testReplayHistoryBounded() { + let bars = Self.grind(start: 80, end: 120, count: 120, lastVolume: 2_000_000) + let hist = PostureDepth.replayActions(bars: bars, spyBars: bars) + XCTAssertFalse(hist.isEmpty) + XCTAssertLessThanOrEqual(hist.count, PostureDepth.historyDays) + XCTAssertTrue(hist.allSatisfy { ["SETUP", "WAIT", "NO"].contains($0) }) + } + + func testForwardEvidenceFromUptrend() { + let bars = Self.grind(start: 80, end: 140, count: 120, lastVolume: 2_000_000) + let depth = PostureDepth.build( + bars: bars, + spyBars: bars, + earningsDate: nil, + earningsForcedWait: false, + sectorEtf: "XLK" + ) + XCTAssertEqual(depth.sectorEtf, "XLK") + XCTAssertFalse(depth.history.isEmpty) + } + + func testParseFundamentals() throws { + let json = """ + {"quoteSummary":{"result":[{"assetProfile":{"sector":"Technology"},"calendarEvents":{"earnings":{"earningsDate":[{"raw":1787280000}]}}}]}} + """.data(using: .utf8)! + let f = FreeMarketDataClient.parseFundamentals(json) + XCTAssertEqual(f.sector, "Technology") + XCTAssertNotNil(f.earningsDate) + } + + func testDisciplineStreakAndWait() { + let d0 = Date(timeIntervalSince1970: 1_787_184_000) + DisciplineLedger.record(action: "WAIT", ticker: "SPY", close: 500, date: d0) + XCTAssertEqual(DisciplineLedger.streak, 1) + XCTAssertEqual(DisciplineLedger.waitCount, 1) + let d1 = d0.addingTimeInterval(86_400) + DisciplineLedger.record(action: "NO", ticker: "SPY", close: 490, date: d1) + XCTAssertEqual(DisciplineLedger.streak, 2) + XCTAssertGreaterThan(DisciplineLedger.avoidedPct, 0) + } + + func testPreviewLockedSecondTicker() { + XCTAssertFalse(AppAccess.isPreviewLocked(ticker: "AAPL", unlocked: false)) + _ = AppAccess.claimPreviewTickerIfNeeded("AAPL", withheld: false) + XCTAssertTrue(AppAccess.isPreviewLocked(ticker: "NVDA", unlocked: false)) + XCTAssertFalse(AppAccess.isPreviewLocked(ticker: "AAPL", unlocked: false)) + XCTAssertFalse(AppAccess.isPreviewLocked(ticker: "NVDA", unlocked: true)) + } + + func testAnxietyAndFounderCopy() { + XCTAssertTrue(AppAccess.anxietyCopy(ticker: "nvda").contains("NVDA")) + XCTAssertTrue(AppAccess.founderPriceLine.contains("1,000")) + XCTAssertFalse(AppAccess.founderPriceLine.lowercased().contains("countdown")) + } + + func testShareReverseFlexOnWait() { + let v = RadarService.synthetic(for: "MSFT", reason: "test") + XCTAssertTrue(v.shareText.contains("didn't force a trade")) + XCTAssertTrue(v.shareText.contains("not investment advice")) + } + + func testDailyBriefingIds() { + XCTAssertTrue(DailyBriefing.idPrefix.hasPrefix("qr.briefing")) + XCTAssertEqual(DailyBriefing.hour, 9) + XCTAssertEqual(DailyBriefing.minute, 25) + } + + private static func grind(start: Double, end: Double, count: Int, lastVolume: Double) -> [FreeBar] { + (0.. [FreeBar] { + (0.. + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + QuantRadar + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/ios/QuantRadarWidget/QuantRadarWidget.entitlements b/ios/QuantRadarWidget/QuantRadarWidget.entitlements new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/ios/QuantRadarWidget/QuantRadarWidget.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/ios/QuantRadarWidget/QuantRadarWidget.swift b/ios/QuantRadarWidget/QuantRadarWidget.swift new file mode 100644 index 0000000..f736371 --- /dev/null +++ b/ios/QuantRadarWidget/QuantRadarWidget.swift @@ -0,0 +1,81 @@ +import SwiftUI +import WidgetKit + +struct SPYEntry: TimelineEntry { + let date: Date + let ticker: String + let score: String + let action: String +} + +struct SPYProvider: TimelineProvider { + func placeholder(in context: Context) -> SPYEntry { + SPYEntry(date: Date(), ticker: "SPY", score: "—", action: "WAIT") + } + + func getSnapshot(in context: Context, completion: @escaping (SPYEntry) -> Void) { + completion(SPYEntry(date: Date(), ticker: "SPY", score: "—", action: "WAIT")) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + Task { + let entry: SPYEntry + if let bars = try? await FreeMarketDataClient.dailyBars(symbol: "SPY", rangeHintDays: 90) { + let v = FreeMechanicalScorer.score( + symbol: "SPY", + company: "SPDR S&P 500", + bars: bars.bars, + spyBars: bars.bars, + source: bars.source + ) + entry = SPYEntry( + date: Date(), + ticker: "SPY", + score: v.scoreText, + action: v.primary?.action ?? "WAIT" + ) + } else { + entry = SPYEntry(date: Date(), ticker: "SPY", score: "—", action: "WAIT") + } + let next = Date().addingTimeInterval(30 * 60) + completion(Timeline(entries: [entry], policy: .after(next))) + } + } +} + +struct QuantRadarWidgetView: View { + let entry: SPYEntry + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text("QuantRadar") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + Text(entry.ticker) + .font(.title2.bold()) + Text(entry.score) + .font(.system(size: 28, weight: .bold, design: .rounded)) + Text(entry.action) + .font(.caption.weight(.bold)) + Text("Educational only") + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .containerBackground(for: .widget) { + Color(red: 0.043, green: 0.059, blue: 0.055) + } + } +} + +@main +struct QuantRadarWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "QuantRadarSPY", provider: SPYProvider()) { entry in + QuantRadarWidgetView(entry: entry) + } + .configurationDisplayName("SPY posture") + .description("Today’s mechanical SPY radar on your Home Screen.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000..d4d3f0a --- /dev/null +++ b/ios/README.md @@ -0,0 +1,13 @@ +# QuantRadar (iOS) + +Independent App Store product — **free download → $9.99 one-time unlock**. Not the web Stripe/Massive product. Not daily scan quotas. + +- Strategy: [docs/PRODUCT.md](docs/PRODUCT.md) +- Launch: [docs/APP_STORE_LAUNCH.md](docs/APP_STORE_LAUNCH.md) + +Public listing is **1.2.0 / build 6** (free + Unlock). Do not ship the paid-download 1.0 binary as the money version. + +```bash +xcodegen generate +open QuantRadar.xcodeproj +``` diff --git a/ios/docs/APP_STORE_LAUNCH.md b/ios/docs/APP_STORE_LAUNCH.md new file mode 100644 index 0000000..1c5ca68 --- /dev/null +++ b/ios/docs/APP_STORE_LAUNCH.md @@ -0,0 +1,74 @@ +# QuantRadar iOS — App Store Launch Checklist + +## Product decisions (locked for this ship) + +- **Free App Store download** +- **$9.99 Non-Consumable unlock** (`one.quantradar.app.unlock`) — founder price, first 1,000 +- Free preview: Today SPY + **one lifetime personal ticker** — no daily quotas +- Second ticker: live score, **blurred** card, anxiety paywall +- Live+ **not listed** (keep StoreKit products for later) +- Separate from web Stripe / Massive +- User-facing copy: posture only (no COGS / Massive / yahoo_q1) +- Binary: **1.2.0 / build 6** + +## Listing strategy + +Paid-download 1.0 is the wrong cash register. The public product is **free + Unlock**. + +If 1.0 is still Waiting for Review: **cancel that thread**, set price Free, create Unlock IAP, submit **1.2**. Do not launch $9.99-to-download then convert later. + +### Cutover / first public listing + +1. Cancel any in-flight paid 1.0 review submission +2. Set App price to **Free** +3. Create IAP: Non-Consumable **QuantRadar Unlock** $9.99 · id `one.quantradar.app.unlock` +4. Skip Live+ subscription group on the listing (products may exist in StoreKit config only) +5. Paid Apps / IAP agreements + tax/banking current +6. Version **1.2** binary (build 6+) with StoreKit gates + depth features +7. Screenshots: live SPY Today · personal Scan · SETUP/WAIT card · blurred 2nd-ticker paywall · Unlock sheet. No “paid download” / COGS / Massive captions +8. Privacy URL: `https://quantradar.one/privacy-ios` · Terms: `https://quantradar.one/terms-ios` +9. Review note: free SPY + one personal ticker; second ticker scores but stays locked; unlock once; no account; educational; not a broker; SETUP ≠ buy order; weekday briefing is a local reminder, not a signal +10. Submit 1.2 — After Approval + +## Local build + +```bash +cd ios +xcodegen generate +open QuantRadar.xcodeproj +``` + +Scheme uses `QuantRadar/Resources/Products.storekit`. +DEBUG Settings toggles: Force unlocked / Force Live+. + +## Listing copy (EN — 1.2) + +**Subtitle:** Stock scanner: wait or act + +**Promotional text:** One score for a US ticker. Most days the honest answer is wait — not a tipster feed. + +**Description:** +QuantRadar is an educational radar for US tickers. Free to install: see today’s SPY posture and scan one ticker of yours. Unlock once ($9.99) for every ticker, a watchlist, and a 90-day posture history. + +One mechanical posture score and a clear action — setup zone, wait, or avoid. Earnings windows stay on wait. This is not a broker, not investment advice, and not a tipster feed. Most days the honest answer is don’t trade. + +Independent App Store product. Website subscriptions do not unlock this app. + +**Keywords:** stock scanner,stock tracker,watchlist,swing trade,stock analysis,market,ticker,stocks,trading,setup + +## Ads → cash register + +- Send traffic to **free** listing (lower friction) +- Optimize toward **unlock CPA** vs ~$7 net, not install vanity +- Use Offer Codes for creative tests +- Kill campaigns that only buy installs without unlocks + +## Review risks + +| Risk | Mitigation | +|------|------------| +| Investment advice | SETUP not BUY; disclaimers onboarding / Today / Scan / Paywall / Settings | +| Thin wrapper | Native SwiftUI + live SPY + one real personal scan + 90-day strip | +| Misleading IAP | Unlock only in 1.2; no fake server push; no fake countdown | +| Privacy mismatch | iOS-specific policy: no account, no tracking | +| Earnings / FOMO | Real market events only; briefing copy is static | diff --git a/ios/docs/DATA_PLANE.md b/ios/docs/DATA_PLANE.md new file mode 100644 index 0000000..6ea0d8d --- /dev/null +++ b/ios/docs/DATA_PLANE.md @@ -0,0 +1,26 @@ +# Data plane split (locked) + +| Surface | Data | Cost | +|---------|------|------| +| **Web** | Massive / Polygon | Paid API | +| **iOS** | Free multi-source · on-device score | **$0 COGS** | + +## iOS source chain (tested 2026-08-11) + +| Order | Source | Verified | +|------|--------|----------| +| 1 | Yahoo `query1` chart API | AAPL/INTC/F/SPY/BRK-B/BTC-USD | +| 2 | Yahoo `query2` chart API | same family, independent host | +| 3 | Nasdaq quote chart API | US equities (AAPL/INTC/F); ETFs like SPY may fail → failover | + +Rejected for production chain: + +- Stooq — JS bot wall (HTML challenge) +- Twelve Data `demo` — AAPL only +- Alpha/Finnhub/FMP/Polygon — need keys or 401 + +## Rules + +1. Never call Massive / `quantradar.one/api/analyze` in App Store builds. +2. Fail-closed if all free sources fail. +3. Market copy: educational free-data radar — not web Massive parity. diff --git a/ios/docs/GO_LIVE_STATUS.md b/ios/docs/GO_LIVE_STATUS.md new file mode 100644 index 0000000..08fae5c --- /dev/null +++ b/ios/docs/GO_LIVE_STATUS.md @@ -0,0 +1,13 @@ +# GO LIVE status + +Checked **2026-08-21**: App Store still **WAITING_FOR_REVIEW**. Do not mutate that thread unless Apple rejects. + +- App ID `6800090745` · Bundle `one.quantradar.app` +- Listing price **Free** +- Binary **1.2.0 (6)** attached to version string 1.0 (Apple would not create a 1.2 version while 1.0 existed) +- IAP **QuantRadar Unlock** `$9.99` · `one.quantradar.app.unlock` · submitted **with** the version (2 items) +- Listing copy is free-download + unlock (not paid download) +- iOS legal URLs in the app: `https://quantradar.one/privacy-ios` and `/terms-ios` (must be 200 on production) + +Web and iOS are independent cash registers. Website Pro (Stripe) does not unlock iOS. iOS Unlock does not sign into the website. + diff --git a/ios/docs/PRODUCT.md b/ios/docs/PRODUCT.md new file mode 100644 index 0000000..7c9c841 --- /dev/null +++ b/ios/docs/PRODUCT.md @@ -0,0 +1,54 @@ +# QuantRadar iOS — Product & Monetization (locked) + +> **Separate from web.** App Store cash register. **iOS data plane = $0 COGS** (see [DATA_PLANE.md](./DATA_PLANE.md)). + +## Dual product + +| | Web | iOS | +|--|-----|-----| +| Money | Stripe Pro | **Free download → $9.99 one-time unlock** | +| Data | **Massive / Polygon** when live | **Yahoo free + on-device score** | +| Fidelity | Full desk / options honesty gates | Mechanical posture only (no OPRA) | +| Cost to you | Massive bill | **~$0 per scan** | + +## Monetization (iOS) + +| Lever | Choice | +|------|--------| +| App price | **Free** (listing) | +| Core unlock | **Non-consumable IAP $9.99** (`one.quantradar.app.unlock`) founder price, first 1,000 | +| Free preview | Today **SPY** + **one lifetime personal ticker** (live scan, not a canned demo) | +| Locked 2nd ticker | Score is computed, card is blurred, paywall asks the anxiety question | +| Live+ | Code exists; **not sold in 1.2** until posture-change alerts prove value | +| Why people pay | One score / most days don’t act — not Massive parity | + +## Depth (1.2) + +- 90-day posture strip + setup forward evidence (this ticker’s own history) +- Earnings ±3d forces WAIT (never SETUP through an earnings window) +- Sector heatmap (unlocked) +- Discipline streak + wait log +- Weekday 09:25 ET briefing notification (static copy — no fake scores) +- Home Screen SPY widget +- Reverse-flex share: “Today I didn’t force a trade” + +## Growth + +```text +ASO → Free App Store install → live SPY + one personal ticker → 2nd ticker blur → $9.99 unlock +``` + +**CAC rule:** Unlock net ≈ $7 after Apple cut. Do **not** spray mass retail CPC. + +## Honesty + +- Do not market iOS as “same engine as web Massive.” +- User-facing copy talks posture, not COGS / data-source brands. +- Educational only — not investment advice. Action label is **SETUP**, not BUY. +- No fake countdown, fake inventory, or invented scores in notifications. +- Fail-closed when bars unavailable. +- iOS legal: `/privacy-ios` and `/terms-ios` (not the web OAuth/Stripe policy). + +## ASC note + +Public listing must be **free + Unlock**, not paid download. See [APP_STORE_LAUNCH.md](./APP_STORE_LAUNCH.md). diff --git a/ios/docs/asc_screens/screen_1_1284x2778.png b/ios/docs/asc_screens/screen_1_1284x2778.png new file mode 100644 index 0000000..abff6c1 Binary files /dev/null and b/ios/docs/asc_screens/screen_1_1284x2778.png differ diff --git a/ios/docs/asc_screens/screen_2_1284x2778.png b/ios/docs/asc_screens/screen_2_1284x2778.png new file mode 100644 index 0000000..ee3e814 Binary files /dev/null and b/ios/docs/asc_screens/screen_2_1284x2778.png differ diff --git a/ios/docs/asc_screens/screen_3_1284x2778.png b/ios/docs/asc_screens/screen_3_1284x2778.png new file mode 100644 index 0000000..9ad4d76 Binary files /dev/null and b/ios/docs/asc_screens/screen_3_1284x2778.png differ diff --git a/ios/docs/asc_screens_ipad/screen_1_2048x2732.png b/ios/docs/asc_screens_ipad/screen_1_2048x2732.png new file mode 100644 index 0000000..699e352 Binary files /dev/null and b/ios/docs/asc_screens_ipad/screen_1_2048x2732.png differ diff --git a/ios/docs/asc_screens_ipad/screen_2_2048x2732.png b/ios/docs/asc_screens_ipad/screen_2_2048x2732.png new file mode 100644 index 0000000..1377144 Binary files /dev/null and b/ios/docs/asc_screens_ipad/screen_2_2048x2732.png differ diff --git a/ios/docs/asc_screens_ipad/screen_3_2048x2732.png b/ios/docs/asc_screens_ipad/screen_3_2048x2732.png new file mode 100644 index 0000000..091563b Binary files /dev/null and b/ios/docs/asc_screens_ipad/screen_3_2048x2732.png differ diff --git a/ios/docs/sim-launch.png b/ios/docs/sim-launch.png new file mode 100644 index 0000000..653df37 Binary files /dev/null and b/ios/docs/sim-launch.png differ diff --git a/ios/docs/sim-perf-01-launch.png b/ios/docs/sim-perf-01-launch.png new file mode 100644 index 0000000..47280c0 Binary files /dev/null and b/ios/docs/sim-perf-01-launch.png differ diff --git a/ios/docs/sim-perf-02-today.png b/ios/docs/sim-perf-02-today.png new file mode 100644 index 0000000..46aea24 Binary files /dev/null and b/ios/docs/sim-perf-02-today.png differ diff --git a/ios/docs/sim-perf-03-today-live.png b/ios/docs/sim-perf-03-today-live.png new file mode 100644 index 0000000..02bea43 Binary files /dev/null and b/ios/docs/sim-perf-03-today-live.png differ diff --git a/ios/docs/test-01-onboarding.png b/ios/docs/test-01-onboarding.png new file mode 100644 index 0000000..6e7e7f4 Binary files /dev/null and b/ios/docs/test-01-onboarding.png differ diff --git a/ios/docs/test-02-today.png b/ios/docs/test-02-today.png new file mode 100644 index 0000000..caa7197 Binary files /dev/null and b/ios/docs/test-02-today.png differ diff --git a/ios/docs/test-03-today-fixed.png b/ios/docs/test-03-today-fixed.png new file mode 100644 index 0000000..02bd16c Binary files /dev/null and b/ios/docs/test-03-today-fixed.png differ diff --git a/ios/docs/test-04-multisource-today.png b/ios/docs/test-04-multisource-today.png new file mode 100644 index 0000000..80430a4 Binary files /dev/null and b/ios/docs/test-04-multisource-today.png differ diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 0000000..a3bcaa9 --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,89 @@ +name: QuantRadar +options: + bundleIdPrefix: one.quantradar + deploymentTarget: + iOS: "17.0" + createIntermediateGroups: true + groupSortPosition: top + # Homebrew XcodeGen defaults to Xcode 16 format (objectVersion 77). + # CI uses macos-14 / Xcode 15.4, which cannot open that format. + projectFormat: xcode15_3 + # Belt-and-suspenders if an older XcodeGen ignores projectFormat. + postGenCommand: "sed -i.bak -E 's/objectVersion = [0-9]+;/objectVersion = 60;/' QuantRadar.xcodeproj/project.pbxproj && rm -f QuantRadar.xcodeproj/project.pbxproj.bak" +settings: + BASE_SDK: iphoneos + SWIFT_VERSION: "5.10" + TARGETED_DEVICE_FAMILY: "1,2" +targets: + QuantRadar: + type: application + platform: iOS + sources: + - path: QuantRadar + excludes: + - "**/*.md" + - "Resources/Products.storekit" + settings: + PRODUCT_BUNDLE_IDENTIFIER: one.quantradar.app + PRODUCT_NAME: QuantRadar + MARKETING_VERSION: "1.2.0" + CURRENT_PROJECT_VERSION: "6" + GENERATE_INFOPLIST_FILE: NO + INFOPLIST_FILE: QuantRadar/Info.plist + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + DEVELOPMENT_TEAM: DZ9BFS26A5 + CODE_SIGN_STYLE: Automatic + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO + entitlements: + path: QuantRadar/QuantRadar.entitlements + dependencies: + - target: QuantRadarWidget + embed: true + QuantRadarWidget: + type: app-extension + platform: iOS + sources: + - path: QuantRadarWidget + - path: QuantRadar/Services/FreeDataRadar.swift + - path: QuantRadar/Services/BarsCache.swift + - path: QuantRadar/Services/PostureDepth.swift + - path: QuantRadar/Models/RadarVerdict.swift + settings: + PRODUCT_BUNDLE_IDENTIFIER: one.quantradar.app.widget + PRODUCT_NAME: QuantRadarWidget + MARKETING_VERSION: "1.2.0" + CURRENT_PROJECT_VERSION: "6" + GENERATE_INFOPLIST_FILE: NO + INFOPLIST_FILE: QuantRadarWidget/Info.plist + DEVELOPMENT_TEAM: DZ9BFS26A5 + CODE_SIGN_STYLE: Automatic + SKIP_INSTALL: YES + TARGETED_DEVICE_FAMILY: "1,2" + entitlements: + path: QuantRadarWidget/QuantRadarWidget.entitlements + QuantRadarTests: + type: bundle.unit-test + platform: iOS + sources: + - path: QuantRadarTests + dependencies: + - target: QuantRadar + settings: + PRODUCT_BUNDLE_IDENTIFIER: one.quantradar.app.tests + DEVELOPMENT_TEAM: DZ9BFS26A5 + GENERATE_INFOPLIST_FILE: YES + CODE_SIGN_STYLE: Automatic +schemes: + QuantRadar: + build: + targets: + QuantRadar: all + QuantRadarTests: [test] + run: + config: Debug + storeKitConfiguration: QuantRadar/Resources/Products.storekit + test: + config: Debug + targets: + - QuantRadarTests diff --git a/ios/scripts/asc_watch.py b/ios/scripts/asc_watch.py new file mode 100644 index 0000000..fa43458 --- /dev/null +++ b/ios/scripts/asc_watch.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Read-only App Store Connect watchdog for QuantRadar iOS. + +Prints a JSON snapshot and writes ios/build/asc_watch_latest.json. +Does not mutate ASC. Exit codes: + 0 wait + 2 Apple sent a review problem (fix) + 3 1.0 approved/live as paid — run Free+IAP cutover + 4 done (live and no longer a paid-download shell) + 1 script/API error +""" + +from __future__ import annotations + +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +from ship_asc import api, find_app, load_token # noqa: E402 + +APP_ID = "6800090745" +OUT = ROOT / "build" / "asc_watch_latest.json" +LOG = ROOT / "build" / "asc_watch.jsonl" + +WAIT = { + "WAITING_FOR_REVIEW", + "IN_REVIEW", + "WAITING_FOR_EXPORT_COMPLIANCE", +} +FIX = { + "REJECTED", + "METADATA_REJECTED", + "INVALID_BINARY", + "DEVELOPER_REJECTED", + "REMOVED_FROM_SALE", +} +CUTOVER = { + "PENDING_DEVELOPER_RELEASE", + "PROCESSING_FOR_APP_STORE", + "READY_FOR_SALE", + "READY_FOR_DISTRIBUTION", + "ACCEPTED", +} + + +def _attr(node: dict) -> dict: + return node.get("attributes") or {} + + +def snapshot() -> dict: + token = load_token() + app = find_app(token) + if not app: + return {"ok": False, "error": "app record missing", "action": "fix_review"} + + st, vers = api(token, "GET", f"/v1/apps/{APP_ID}/appStoreVersions?limit=10&include=build") + if st != 200: + return {"ok": False, "error": f"versions {st}", "action": "wait"} + + versions = [] + included = {(i.get("type"), i.get("id")): i for i in vers.get("included") or []} + for v in vers.get("data") or []: + a = _attr(v) + build_rel = ((v.get("relationships") or {}).get("build") or {}).get("data") or {} + build = included.get(("builds", build_rel.get("id"))) + ba = _attr(build) if build else {} + versions.append( + { + "id": v["id"], + "versionString": a.get("versionString"), + "appStoreState": a.get("appStoreState"), + "appVersionState": a.get("appVersionState"), + "releaseType": a.get("releaseType"), + "createdDate": a.get("createdDate"), + "build": { + "id": build.get("id") if build else None, + "version": ba.get("version"), + "processingState": ba.get("processingState"), + }, + } + ) + + st, revs = api(token, "GET", f"/v1/apps/{APP_ID}/reviewSubmissions?limit=5") + submissions = [] + if st == 200: + for r in revs.get("data") or []: + submissions.append({"id": r["id"], **(_attr(r))}) + + st, prices = api( + token, + "GET", + f"/v1/appPriceSchedules/{APP_ID}/manualPrices?include=appPricePoint", + ) + customer_price = None + if st == 200: + for inc in prices.get("included") or []: + if inc.get("type") == "appPricePoints": + customer_price = (_attr(inc) or {}).get("customerPrice") + break + + st, iap = api(token, "GET", f"/v1/apps/{APP_ID}/inAppPurchasesV2?limit=10") + iap_count = len((iap or {}).get("data") or []) if st == 200 else None + + import urllib.request + + on_store = False + store_price = None + try: + with urllib.request.urlopen("https://itunes.apple.com/lookup?id=6800090745", timeout=15) as resp: + lookup = json.loads(resp.read().decode()) + if lookup.get("resultCount"): + on_store = True + store_price = (lookup["results"][0] or {}).get("price") + except Exception: + pass + + current = versions[0] if versions else {} + state = current.get("appVersionState") or current.get("appStoreState") or "UNKNOWN" + submitted = None + for s in submissions: + if s.get("state") == "WAITING_FOR_REVIEW": + submitted = s.get("submittedDate") + break + if not submitted and submissions: + submitted = submissions[0].get("submittedDate") + + paid_shell = customer_price not in (None, "0", "0.00") and (iap_count or 0) == 0 + if on_store and (store_price == 0 or (customer_price in ("0", "0.00") and (iap_count or 0) > 0)): + action = "done" + elif state in FIX: + action = "fix_review" + elif state in CUTOVER or (on_store and paid_shell): + action = "cutover" + elif state in WAIT: + action = "wait" + else: + action = "wait" + + return { + "ok": True, + "checkedAt": datetime.now(timezone.utc).isoformat(), + "action": action, + "state": state, + "version": current.get("versionString"), + "build": (current.get("build") or {}).get("version"), + "price": customer_price, + "iapCount": iap_count, + "onStore": on_store, + "storePrice": store_price, + "submittedDate": submitted, + "submissionState": (submissions[0].get("state") if submissions else None), + "versions": versions, + "submissions": submissions, + } + + +def main() -> int: + snap = snapshot() + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text(json.dumps(snap, indent=2) + "\n") + with LOG.open("a") as f: + f.write(json.dumps(snap) + "\n") + print(json.dumps(snap, indent=2)) + action = snap.get("action") + if not snap.get("ok"): + return 1 + return {"wait": 0, "fix_review": 2, "cutover": 3, "done": 4}.get(action, 1) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ios/scripts/asc_watch_notify.sh b/ios/scripts/asc_watch_notify.sh new file mode 100755 index 0000000..0457597 --- /dev/null +++ b/ios/scripts/asc_watch_notify.sh @@ -0,0 +1,21 @@ +#!/bin/zsh +set -euo pipefail +ROOT="/Users/rongjianliao/quantradar" +PY="$ROOT/.tools/asc-venv/bin/python" +SCRIPT="$ROOT/ios/scripts/asc_watch.py" +LOG="$HOME/Library/Logs/quantradar-asc-watch.log" +mkdir -p "$(dirname "$LOG")" "$ROOT/ios/build" + +{ + echo "---- $(date -u +%Y-%m-%dT%H:%M:%SZ) ----" + "$PY" "$SCRIPT" +} >>"$LOG" 2>&1 || true + +ACTION="wait" +if [[ -f "$ROOT/ios/build/asc_watch_latest.json" ]]; then + ACTION="$("$PY" -c 'import json,pathlib; p=pathlib.Path("/Users/rongjianliao/quantradar/ios/build/asc_watch_latest.json"); print(json.loads(p.read_text()).get("action","wait"))')" +fi + +if [[ "$ACTION" != "wait" ]]; then + /usr/bin/osascript -e "display notification \"App Store status: $ACTION. Open Cursor to finish.\" with title \"QuantRadar iOS\" sound name \"Glass\"" +fi diff --git a/ios/scripts/build_free_pack.py b/ios/scripts/build_free_pack.py new file mode 100644 index 0000000..9df0667 --- /dev/null +++ b/ios/scripts/build_free_pack.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Optional: build a zero-cost daily JSON pack via yfinance for static hosting (GitHub Pages). + +Does NOT use Massive/Polygon. Free for publishing to users; run on your Mac or free CI. + + python3 ios/scripts/build_free_pack.py --tickers AAPL,NVDA,SPY -o ios/pack/today.json +""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from pathlib import Path + + +def score_closes(closes: list[float], volumes: list[float]) -> dict: + def sma(n: int) -> float: + if len(closes) < n: + return 0.0 + return sum(closes[-n:]) / n + + last = closes[-1] + s20, s50 = sma(20), sma(50) + # RSI14 + gains = losses = 0.0 + for i in range(-14, 0): + d = closes[i] - closes[i - 1] + if d >= 0: + gains += d + else: + losses -= d + ag, al = gains / 14, losses / 14 + rsi = 100.0 if al == 0 else 100 - (100 / (1 + ag / al)) + vol_sma = sum(volumes[-20:]) / 20 if len(volumes) >= 20 else 1.0 + vol_ratio = (volumes[-1] / vol_sma) if vol_sma else 1.0 + + score = 50.0 + if last > s20 > s50 > 0: + score += 18 + elif last > s20 > 0: + score += 8 + elif s50 > 0 and last < s50: + score -= 18 + if 45 <= rsi <= 65: + score += 12 + elif rsi > 70: + score -= 10 + if vol_ratio >= 1.2: + score += 8 + score = max(5, min(95, round(score))) + + if score < 38: + action, label = "NO", "Avoid" + elif score >= 68 and rsi < 68: + action, label = "BUY", "Setup watch → actionable zone" + else: + action, label = "WAIT", "Wait & Watch" + + return { + "primary_score": {"value": score, "scale": 100, "label": "Mechanical posture score", "withheld": False}, + "primary": {"action": action, "label": label, "reason": f"Pack RSI={rsi:.0f} score={score}"}, + "meta": {"mode": "free_pack", "data_path": "yfinance_pack", "disclaimer": "Educational only."}, + } + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--tickers", default="SPY,QQQ,AAPL,NVDA,TSLA,INTC") + ap.add_argument("-o", "--output", default="ios/pack/today.json") + args = ap.parse_args() + try: + import yfinance as yf + except ImportError as e: + raise SystemExit("pip install yfinance") from e + + out: dict = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "source": "yfinance", + "massive": False, + "tickers": {}, + } + for t in [x.strip().upper() for x in args.tickers.split(",") if x.strip()]: + hist = yf.Ticker(t).history(period="1y", interval="1d") + if hist.empty or len(hist) < 40: + continue + closes = [float(x) for x in hist["Close"].tolist()] + volumes = [float(x) for x in hist["Volume"].tolist()] + payload = score_closes(closes, volumes) + payload["ticker"] = t + payload["ok"] = True + out["tickers"][t] = payload + print(t, payload["primary"]["action"], payload["primary_score"]["value"]) + + path = Path(args.output) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(out, indent=2)) + print("wrote", path, "count", len(out["tickers"])) + + +if __name__ == "__main__": + main() diff --git a/ios/scripts/one.quantradar.asc-watch.plist b/ios/scripts/one.quantradar.asc-watch.plist new file mode 100644 index 0000000..19dcd9b --- /dev/null +++ b/ios/scripts/one.quantradar.asc-watch.plist @@ -0,0 +1,24 @@ + + + + + Label + one.quantradar.asc-watch + ProgramArguments + + /bin/zsh + /Users/rongjianliao/quantradar/ios/scripts/asc_watch_notify.sh + + StartCalendarInterval + + Hour + 9 + Minute + 0 + + StandardOutPath + /Users/rongjianliao/Library/Logs/quantradar-asc-watch.launchd.out.log + StandardErrorPath + /Users/rongjianliao/Library/Logs/quantradar-asc-watch.launchd.err.log + + diff --git a/ios/scripts/ship_12_listing.py b/ios/scripts/ship_12_listing.py new file mode 100644 index 0000000..7c1c72f --- /dev/null +++ b/ios/scripts/ship_12_listing.py @@ -0,0 +1,424 @@ +#!/usr/bin/env python3 +"""Cut QuantRadar listing to free + Unlock 1.2 after the binary is uploaded. + +Read-mostly until cancel. Then: cancel paid 1.0, price Free, Unlock IAP, +create 1.2, listing copy, attach latest VALID build, submit. +""" + +from __future__ import annotations + +import json +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +from ship_asc import api, find_app, load_token # noqa: E402 + +APP_ID = "6800090745" +UNLOCK_ID = "one.quantradar.app.unlock" +VERSION = "1.2" +SCREEN = ROOT / "docs" / "asc_screens" / "screen_1_1284x2778.png" + +DESC = """QuantRadar is an educational radar for US tickers. Free to install: see today’s SPY posture and scan one ticker of yours. Unlock once ($9.99) for every ticker, a watchlist, and a 90-day posture history. + +One mechanical posture score and a clear action — setup zone, wait, or avoid. Earnings windows stay on wait. This is not a broker, not investment advice, and not a tipster feed. Most days the honest answer is don’t trade. + +Independent App Store product. Website subscriptions do not unlock this app.""" + +KEYWORDS = "stock scanner,stock tracker,watchlist,swing trade,stock analysis,market,ticker,stocks,trading,setup" +SUBTITLE = "Stock scanner: wait or act" +PROMO = "One score for a US ticker. Most days the honest answer is wait — not a tipster feed." +REVIEW_NOTES = ( + "Free download. Today shows live SPY. User may scan one personal ticker for life. " + "A second ticker is scored on-device but the card stays locked until the $9.99 non-consumable Unlock " + "(one.quantradar.app.unlock). No account. No broker. Action label is SETUP, not BUY. " + "Weekday 09:25 ET briefing is a local reminder with static copy, not a trade signal. " + "Educational only. ITSAppUsesNonExemptEncryption=false. " + "Privacy: https://quantradar.one/privacy Terms: https://quantradar.one/terms" +) + + +def cancel_waiting(token: str) -> None: + st, revs = api(token, "GET", f"/v1/apps/{APP_ID}/reviewSubmissions?limit=10") + if st != 200: + print("reviewSubmissions", st, revs) + return + for r in revs.get("data") or []: + state = (r.get("attributes") or {}).get("state") + if state in {"WAITING_FOR_REVIEW", "IN_REVIEW", "UNRESOLVED_ISSUES"}: + rid = r["id"] + print("canceling", rid, state) + s, b = api( + token, + "PATCH", + f"/v1/reviewSubmissions/{rid}", + {"data": {"type": "reviewSubmissions", "id": rid, "attributes": {"canceled": True}}}, + ) + print("cancel", s, b if isinstance(b, str) else (b.get("data") or {}).get("attributes")) + + +def wait_version_editable(token: str, timeout=180) -> dict | None: + deadline = time.time() + timeout + while time.time() < deadline: + token = load_token() + st, vers = api(token, "GET", f"/v1/apps/{APP_ID}/appStoreVersions?limit=10") + if st == 200: + for v in vers.get("data") or []: + state = (v.get("attributes") or {}).get("appVersionState") or (v.get("attributes") or {}).get( + "appStoreState" + ) + print("version", v["id"], v["attributes"].get("versionString"), state) + if state in { + "PREPARE_FOR_SUBMISSION", + "DEVELOPER_REJECTED", + "REJECTED", + "METADATA_REJECTED", + "INVALID_BINARY", + }: + return v + time.sleep(8) + return None + + +def ensure_version_12(token: str, existing: dict | None) -> str: + if existing and existing["attributes"].get("versionString") in {"1.2", "1.2.0"}: + return existing["id"] + # If 1.0 is now editable, we still create 1.2 as the public version. + st, created = api( + token, + "POST", + "/v1/appStoreVersions", + { + "data": { + "type": "appStoreVersions", + "attributes": { + "platform": "IOS", + "versionString": VERSION, + "copyright": "2026 Fortune Insight, LLC", + "releaseType": "AFTER_APPROVAL", + }, + "relationships": {"app": {"data": {"type": "apps", "id": APP_ID}}}, + } + }, + ) + print("create 1.2", st, created if isinstance(created, str) else json.dumps(created)[:600]) + if st in (200, 201) and isinstance(created, dict): + return created["data"]["id"] + # Fallback: reuse editable 1.0 if Apple blocks a second inflight version. + if existing: + print("reusing existing version", existing["id"]) + return existing["id"] + raise SystemExit("could not create or reuse a version") + + +def upsert_listing(token: str, version_id: str) -> None: + st, locs = api(token, "GET", f"/v1/appStoreVersions/{version_id}/appStoreVersionLocalizations") + loc_id = None + if st == 200: + for loc in (locs.get("data") or []): + if loc["attributes"].get("locale") == "en-US": + loc_id = loc["id"] + attrs = { + "description": DESC, + "keywords": KEYWORDS, + "marketingUrl": "https://quantradar.one", + "supportUrl": "https://quantradar.one", + "promotionalText": PROMO, + "whatsNew": "Free download. Live SPY plus one personal ticker. Unlock once for full radar, 90-day posture history, and watchlist. Educational only.", + } + if loc_id: + s, b = api( + token, + "PATCH", + f"/v1/appStoreVersionLocalizations/{loc_id}", + {"data": {"type": "appStoreVersionLocalizations", "id": loc_id, "attributes": attrs}}, + ) + print("loc patch", s) + else: + s, b = api( + token, + "POST", + "/v1/appStoreVersionLocalizations", + { + "data": { + "type": "appStoreVersionLocalizations", + "attributes": {"locale": "en-US", **attrs}, + "relationships": { + "appStoreVersion": {"data": {"type": "appStoreVersions", "id": version_id}} + }, + } + }, + ) + print("loc post", s, b if isinstance(b, str) else "ok") + if s in (200, 201) and isinstance(b, dict): + loc_id = b["data"]["id"] + + # App info subtitle + privacy (live URLs — /privacy-ios is 404 until next web publish) + st, infos = api(token, "GET", f"/v1/apps/{APP_ID}/appInfos") + if st == 200 and infos.get("data"): + info_id = infos["data"][0]["id"] + st, ilocs = api(token, "GET", f"/v1/appInfos/{info_id}/appInfoLocalizations") + for loc in (ilocs.get("data") or []) if st == 200 else []: + if loc["attributes"].get("locale") == "en-US": + s, b = api( + token, + "PATCH", + f"/v1/appInfoLocalizations/{loc['id']}", + { + "data": { + "type": "appInfoLocalizations", + "id": loc["id"], + "attributes": { + "subtitle": SUBTITLE, + "privacyPolicyUrl": "https://quantradar.one/privacy", + "privacyChoicesUrl": None, + }, + } + }, + ) + print("subtitle/privacy", s) + + st, rd = api(token, "GET", f"/v1/appStoreVersions/{version_id}/appStoreReviewDetail") + review_attrs = { + "contactFirstName": "Rongjian", + "contactLastName": "Liao", + "contactEmail": "alexliao830@gmail.com", + "contactPhone": "+14155550100", + "demoAccountRequired": False, + "notes": REVIEW_NOTES, + } + if st == 200 and isinstance(rd, dict) and rd.get("data"): + rid = rd["data"]["id"] + s, b = api( + token, + "PATCH", + f"/v1/appStoreReviewDetails/{rid}", + {"data": {"type": "appStoreReviewDetails", "id": rid, "attributes": review_attrs}}, + ) + print("review patch", s) + else: + s, b = api( + token, + "POST", + "/v1/appStoreReviewDetails", + { + "data": { + "type": "appStoreReviewDetails", + "attributes": review_attrs, + "relationships": { + "appStoreVersion": {"data": {"type": "appStoreVersions", "id": version_id}} + }, + } + }, + ) + print("review post", s) + + +def ensure_unlock_iap(token: str) -> None: + st, iap = api(token, "GET", f"/v1/apps/{APP_ID}/inAppPurchasesV2?limit=20") + print("iap list", st) + if st == 200: + for p in iap.get("data") or []: + pid = (p.get("attributes") or {}).get("productId") + print(" iap", pid, p.get("attributes", {}).get("state")) + if pid == UNLOCK_ID: + print("unlock IAP already exists") + return + body = { + "data": { + "type": "inAppPurchases", + "attributes": { + "name": "QuantRadar Unlock", + "productId": UNLOCK_ID, + "inAppPurchaseType": "NON_CONSUMABLE", + "reviewNote": "One-time unlock for any US ticker scan and watchlist. Founder price $9.99. Not a subscription. Educational radar only.", + }, + "relationships": {"app": {"data": {"type": "apps", "id": APP_ID}}}, + } + } + s, b = api(token, "POST", "/v2/inAppPurchases", body) + print("iap create v2", s, b if isinstance(b, str) else json.dumps(b)[:800]) + if s not in (200, 201): + s, b = api(token, "POST", "/v1/inAppPurchases", body) + print("iap create v1", s, b if isinstance(b, str) else json.dumps(b)[:800]) + + +def set_app_free(token: str) -> None: + st, pts = api( + token, + "GET", + f"/v1/apps/{APP_ID}/appPricePoints?filter[territory]=USA&limit=200", + ) + print("price points", st) + free_id = None + if st == 200: + for p in pts.get("included") or pts.get("data") or []: + attrs = p.get("attributes") or {} + if str(attrs.get("customerPrice")) in {"0", "0.00"} and p.get("type") == "appPricePoints": + free_id = p["id"] + break + if not free_id: + for p in pts.get("data") or []: + # fetch each? skip + pass + print("free point", free_id, "count", len(pts.get("data") or [])) + if not free_id and st == 200: + # Try equalizations of current schedule + st2, sched = api(token, "GET", f"/v1/apps/{APP_ID}/appPriceSchedule") + print("schedule", st2, sched if isinstance(sched, str) else json.dumps(sched)[:400]) + if free_id: + s, b = api( + token, + "POST", + "/v1/appPriceSchedules", + { + "data": { + "type": "appPriceSchedules", + "relationships": { + "app": {"data": {"type": "apps", "id": APP_ID}}, + "baseTerritory": {"data": {"type": "territories", "id": "USA"}}, + "manualPrices": { + "data": [{"type": "appPrices", "id": "${price1}"}] + }, + }, + }, + "included": [ + { + "type": "appPrices", + "id": "${price1}", + "attributes": {"startDate": None}, + "relationships": { + "appPricePoint": {"data": {"type": "appPricePoints", "id": free_id}} + }, + } + ], + }, + ) + print("set free", s, b if isinstance(b, str) else json.dumps(b)[:600]) + + +def latest_valid_build(token: str) -> dict | None: + st, builds = api(token, "GET", f"/v1/builds?filter[app]={APP_ID}&sort=-uploadedDate&limit=8") + if st != 200: + print("builds", st, builds) + return None + for b in builds.get("data") or []: + a = b["attributes"] + print("build", b["id"], a.get("version"), a.get("processingState"), a.get("uploadedDate")) + if a.get("processingState") == "VALID": + # Prefer highest version number that is not 3 if 6 exists + pass + valids = [ + b + for b in (builds.get("data") or []) + if b["attributes"].get("processingState") == "VALID" + ] + if not valids: + return None + def key(b): + try: + return int(b["attributes"].get("version") or 0) + except ValueError: + return 0 + valids.sort(key=key, reverse=True) + return valids[0] + + +def attach_and_submit(token: str, version_id: str, build: dict) -> None: + s, b = api( + token, + "PATCH", + f"/v1/appStoreVersions/{version_id}", + { + "data": { + "type": "appStoreVersions", + "id": version_id, + "relationships": {"build": {"data": {"type": "builds", "id": build["id"]}}}, + } + }, + ) + print("attach", s, b if isinstance(b, str) else "ok") + s, body = api( + token, + "POST", + "/v1/reviewSubmissions", + { + "data": { + "type": "reviewSubmissions", + "attributes": {"platform": "IOS"}, + "relationships": {"app": {"data": {"type": "apps", "id": APP_ID}}}, + } + }, + ) + print("rs create", s, body if isinstance(body, str) else json.dumps(body)[:500]) + if s not in (200, 201) or not isinstance(body, dict): + return + rs_id = body["data"]["id"] + s2, body2 = api( + token, + "POST", + "/v1/reviewSubmissionItems", + { + "data": { + "type": "reviewSubmissionItems", + "relationships": { + "reviewSubmission": {"data": {"type": "reviewSubmissions", "id": rs_id}}, + "appStoreVersion": {"data": {"type": "appStoreVersions", "id": version_id}}, + }, + } + }, + ) + print("rs item", s2) + s3, body3 = api( + token, + "PATCH", + f"/v1/reviewSubmissions/{rs_id}", + {"data": {"type": "reviewSubmissions", "id": rs_id, "attributes": {"submitted": True}}}, + ) + print("rs submit", s3, body3 if isinstance(body3, str) else json.dumps(body3)[:500]) + + +def main() -> int: + token = load_token() + app = find_app(token) + print("app", app["id"] if app else None) + print("=== cancel paid 1.0 thread ===") + cancel_waiting(token) + print("=== wait editable ===") + editable = wait_version_editable(token) + token = load_token() + print("=== unlock IAP ===") + ensure_unlock_iap(token) + print("=== price Free ===") + set_app_free(token) + token = load_token() + print("=== version 1.2 ===") + vid = ensure_version_12(token, editable) + print("using version", vid) + upsert_listing(token, vid) + print("=== wait for VALID build 6 ===") + build = None + for i in range(24): + token = load_token() + build = latest_valid_build(token) + if build and str(build["attributes"].get("version")) not in {"3", "4"}: + break + if build and i > 6 and str(build["attributes"].get("version")) in {"5", "6", "7"}: + break + print("waiting for new build…", i) + time.sleep(15) + if not build: + print("no VALID build yet — listing prepared, submit later") + return 0 + print("using build", build["id"], build["attributes"].get("version")) + if str(build["attributes"].get("version")) == "3": + print("only build 3 is VALID — not attaching paid 1.0 binary to 1.2") + return 0 + attach_and_submit(token, vid, build) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ios/scripts/ship_asc.py b/ios/scripts/ship_asc.py new file mode 100644 index 0000000..330d4da --- /dev/null +++ b/ios/scripts/ship_asc.py @@ -0,0 +1,511 @@ +#!/usr/bin/env python3 +"""Ship QuantRadar iOS to App Store Connect end-to-end (after app record exists). + +Usage: + /tmp/asc-venv/bin/python ios/scripts/ship_asc.py # full ship + /tmp/asc-venv/bin/python ios/scripts/ship_asc.py --wait # poll until app exists, then ship +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +REPO = ROOT.parent +ARCHIVE = ROOT / "build" / "QuantRadar.xcarchive" +EXPORT_PLIST = ROOT / "build" / "ExportOptions.plist" +ICON = ROOT / "QuantRadar/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png" +SCREENSHOTS = [ + ROOT / "docs" / "test-03-today-fixed.png", + ROOT / "docs" / "test-01-onboarding.png", +] + +BUNDLE_ID = "one.quantradar.app" +APP_NAME = "QuantRadar" +SKU = "quantradar-ios" +PRICE_TIER_HINT = "9.99" + +DESC = """QuantRadar is a paid educational radar for US tickers. It shows a single mechanical posture score and a clear action — act, wait, or avoid — with market, sector, and stock gates. + +This is not a broker, not investment advice, and not a tipster feed. Most days the honest answer is don't trade. + +Independent App Store product. Website subscriptions do not apply.""" + +KEYWORDS = "stock radar,stock scanner,swing trade,trading,market posture,ticker,options setup" +SUBTITLE = "Mechanical stock posture radar" +PROMO = "One score. One action. Built for traders tired of tipster noise." +REVIEW_NOTES = ( + "Paid educational mechanical posture radar. Not a broker. " + "Demo ticker INTC works offline with bundled sample. " + "No account required for core use. No IAP in v1." +) + + +def load_token() -> str: + import jwt + + cfg = json.loads(Path.home().joinpath(".appstoreconnect/api_key.json").read_text()) + key = Path(cfg["key_filepath"]).read_text() + now = int(time.time()) + token = jwt.encode( + { + "iss": cfg["issuer_id"], + "iat": now, + "exp": now + 20 * 60, + "aud": "appstoreconnect-v1", + }, + key, + algorithm="ES256", + headers={"kid": cfg["key_id"]}, + ) + return token.decode() if isinstance(token, bytes) else token + + +def api(token: str, method: str, path: str, body=None, content_type="application/json"): + data = None + headers = {"Authorization": f"Bearer {token}"} + if body is not None: + if content_type == "application/json": + data = json.dumps(body).encode() + headers["Content-Type"] = content_type + else: + data = body + headers["Content-Type"] = content_type + req = urllib.request.Request( + "https://api.appstoreconnect.apple.com" + path, + data=data, + method=method, + headers=headers, + ) + try: + with urllib.request.urlopen(req) as r: + raw = r.read().decode() + return r.status, json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + return e.code, e.read().decode() + + +def find_app(token: str): + status, apps = api(token, "GET", "/v1/apps?limit=50") + if status != 200: + raise RuntimeError(f"list apps failed: {status} {apps}") + for a in apps["data"]: + if a["attributes"].get("bundleId") == BUNDLE_ID: + return a + return None + + +def wait_for_app(token: str, timeout_sec: int = 1800) -> dict: + print("Waiting for App Store Connect app record one.quantradar.app …") + print("Create it at: https://appstoreconnect.apple.com/apps (New App → bundle one.quantradar.app)") + deadline = time.time() + timeout_sec + while time.time() < deadline: + app = find_app(token) + if app: + print("FOUND", app["id"], app["attributes"]["name"]) + return app + time.sleep(15) + token = load_token() # refresh periodically + print("… still waiting") + raise TimeoutError("App record not found within timeout") + + +def ensure_version(token: str, app_id: str) -> str: + status, vers = api(token, "GET", f"/v1/apps/{app_id}/appStoreVersions?limit=10") + if status != 200: + raise RuntimeError(vers) + for v in vers.get("data", []): + state = v["attributes"].get("appStoreState") + if v["attributes"].get("versionString") == "1.0.0" and state in { + "PREPARE_FOR_SUBMISSION", + "READY_FOR_REVIEW", + "WAITING_FOR_REVIEW", + "DEVELOPER_REJECTED", + "REJECTED", + "METADATA_REJECTED", + "INVALID_BINARY", + }: + return v["id"] + # create + status, created = api( + token, + "POST", + "/v1/appStoreVersions", + { + "data": { + "type": "appStoreVersions", + "attributes": { + "platform": "IOS", + "versionString": "1.0.0", + "copyright": "2026 Fortune Insight, LLC / Rongjian Liao", + "releaseType": "AFTER_APPROVAL", + }, + "relationships": {"app": {"data": {"type": "apps", "id": app_id}}}, + } + }, + ) + if status not in (200, 201): + raise RuntimeError(f"create version failed: {status} {created}") + return created["data"]["id"] + + +def upsert_localization(token: str, version_id: str): + status, locs = api(token, "GET", f"/v1/appStoreVersions/{version_id}/appStoreVersionLocalizations") + loc_id = None + if status == 200: + for loc in locs.get("data", []): + if loc["attributes"].get("locale") == "en-US": + loc_id = loc["id"] + break + attrs = { + "description": DESC, + "keywords": KEYWORDS, + "marketingUrl": "https://quantradar.one", + "supportUrl": "https://quantradar.one", + "promotionalText": PROMO, + "whatsNew": "Initial App Store release. Paid educational mechanical posture radar.", + } + if loc_id: + status, body = api( + token, + "PATCH", + f"/v1/appStoreVersionLocalizations/{loc_id}", + {"data": {"type": "appStoreVersionLocalizations", "id": loc_id, "attributes": attrs}}, + ) + else: + status, body = api( + token, + "POST", + "/v1/appStoreVersionLocalizations", + { + "data": { + "type": "appStoreVersionLocalizations", + "attributes": {"locale": "en-US", "name": APP_NAME, "subtitle": SUBTITLE, **attrs}, + "relationships": { + "appStoreVersion": {"data": {"type": "appStoreVersions", "id": version_id}} + }, + } + }, + ) + if status not in (200, 201): + print("localization warn", status, body[:500] if isinstance(body, str) else body) + else: + print("localization ok") + return body["data"]["id"] if isinstance(body, dict) and "data" in body else loc_id + + +def set_review_details(token: str, version_id: str): + status, body = api( + token, + "POST", + "/v1/appStoreReviewDetails", + { + "data": { + "type": "appStoreReviewDetails", + "attributes": { + "contactFirstName": "Rongjian", + "contactLastName": "Liao", + "contactEmail": "alexliao830@gmail.com", + "contactPhone": "+1 0000000000", + "demoAccountRequired": False, + "notes": REVIEW_NOTES, + }, + "relationships": { + "appStoreVersion": {"data": {"type": "appStoreVersions", "id": version_id}} + }, + } + }, + ) + if status in (200, 201): + print("review details ok") + else: + # may already exist — try related get + print("review details", status, body[:400] if isinstance(body, str) else body) + + +def set_age_rating(token: str, app_id: str): + status, infos = api(token, "GET", f"/v1/apps/{app_id}/appInfos") + if status != 200 or not infos.get("data"): + print("appInfos", status, infos) + return + info_id = infos["data"][0]["id"] + # Age rating declaration via relationship + status, decl = api(token, "GET", f"/v1/appInfos/{info_id}/ageRatingDeclaration") + attrs = { + "alcoholTobaccoOrDrugUseOrReferences": "NONE", + "contests": "NONE", + "gambling": False, + "gamblingSimulated": "NONE", + "horrorOrFearThemes": "NONE", + "matureOrSuggestiveThemes": "NONE", + "medicalOrTreatmentInformation": "NONE", + "profanityOrCrudeHumor": "NONE", + "sexualContentGraphicAndNudity": "NONE", + "sexualContentOrNudity": "NONE", + "violenceCartoonOrFantasy": "NONE", + "violenceRealistic": "NONE", + "violenceRealisticProlongedGraphicOrSadistic": "NONE", + "gunsOrOtherWeapons": "NONE", + "unrestrictedWebAccess": False, + "gamblingAndContests": False, + "seventeenPlus": False, + "lootBox": False, + } + if status == 200 and decl.get("data"): + did = decl["data"]["id"] + s, b = api( + token, + "PATCH", + f"/v1/ageRatingDeclarations/{did}", + {"data": {"type": "ageRatingDeclarations", "id": did, "attributes": attrs}}, + ) + print("age rating patch", s) + else: + print("age rating get", status, decl if isinstance(decl, str) else "no declaration yet") + + +def set_category(token: str, app_id: str): + status, infos = api(token, "GET", f"/v1/apps/{app_id}/appInfos") + if status != 200 or not infos.get("data"): + return + info_id = infos["data"][0]["id"] + # primaryCategory relationship — Finance + # Categories are fixed IDs; Finance is typically queried + status, cats = api(token, "GET", "/v1/appCategories?filter[platforms]=IOS&limit=50") + finance = None + if status == 200: + for c in cats.get("data", []): + if c["attributes"].get("name") or True: + # attributes may only have platforms + pass + # Prefer known Finance id lookup via filter + status, fin = api(token, "GET", "/v1/appCategories/FIANCE") # typo guard + status, fin = api(token, "GET", "/v1/appCategories?filter[platforms]=IOS") + print("categories fetch", status) + if status == 200: + # Print ids for finance-like + for c in fin.get("data", []): + cid = c["id"] + if "FINANCE" in cid.upper() or cid == "FINANCE": + finance = cid + break + if not finance: + # Apple uses ids like "FINANCE" + finance = "FINANCE" + s, b = api( + token, + "PATCH", + f"/v1/appInfos/{info_id}", + { + "data": { + "type": "appInfos", + "id": info_id, + "relationships": { + "primaryCategory": {"data": {"type": "appCategories", "id": finance or "FINANCE"}} + }, + } + }, + ) + print("category", s, b if isinstance(b, str) else "ok") + + +def set_privacy_policy(token: str, app_id: str): + status, infos = api(token, "GET", f"/v1/apps/{app_id}/appInfos") + if status != 200 or not infos.get("data"): + return + info_id = infos["data"][0]["id"] + status, locs = api(token, "GET", f"/v1/appInfos/{info_id}/appInfoLocalizations") + loc_id = None + if status == 200: + for loc in locs.get("data", []): + if loc["attributes"].get("locale") == "en-US": + loc_id = loc["id"] + attrs = { + "privacyPolicyUrl": "https://quantradar.one/privacy", + "name": APP_NAME, + "subtitle": SUBTITLE, + } + if loc_id: + s, b = api( + token, + "PATCH", + f"/v1/appInfoLocalizations/{loc_id}", + {"data": {"type": "appInfoLocalizations", "id": loc_id, "attributes": attrs}}, + ) + else: + s, b = api( + token, + "POST", + "/v1/appInfoLocalizations", + { + "data": { + "type": "appInfoLocalizations", + "attributes": {"locale": "en-US", **attrs}, + "relationships": {"appInfo": {"data": {"type": "appInfos", "id": info_id}}}, + } + }, + ) + print("privacy/info loc", s) + + +def upload_binary(token: str): + if not ARCHIVE.exists(): + raise SystemExit(f"Missing archive {ARCHIVE}. Build it first.") + cfg = json.loads(Path.home().joinpath(".appstoreconnect/api_key.json").read_text()) + EXPORT_PLIST.parent.mkdir(parents=True, exist_ok=True) + EXPORT_PLIST.write_text( + """ + + + + methodapp-store-connect + destinationupload + teamIDDZ9BFS26A5 + signingStyleautomatic + uploadSymbols + manageAppVersionAndBuildNumber + + +""" + ) + export_dir = ROOT / "build" / "export" + export_dir.mkdir(parents=True, exist_ok=True) + cmd = [ + "xcodebuild", + "-exportArchive", + "-archivePath", + str(ARCHIVE), + "-exportOptionsPlist", + str(EXPORT_PLIST), + "-exportPath", + str(export_dir), + "-allowProvisioningUpdates", + "-authenticationKeyPath", + str(Path(cfg["key_filepath"])), + "-authenticationKeyID", + cfg["key_id"], + "-authenticationKeyIssuerID", + cfg["issuer_id"], + ] + print("Uploading binary…") + proc = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True) + print(proc.stdout[-2000:] if proc.stdout else "") + print(proc.stderr[-2000:] if proc.stderr else "") + if proc.returncode != 0: + raise RuntimeError("export/upload failed") + print("Upload command finished") + + +def rebuild_archive(): + print("Rebuilding Release archive…") + subprocess.check_call(["xcodegen", "generate"], cwd=ROOT) + subprocess.check_call( + [ + "xcodebuild", + "-project", + "QuantRadar.xcodeproj", + "-scheme", + "QuantRadar", + "-configuration", + "Release", + "-destination", + "generic/platform=iOS", + "-archivePath", + str(ARCHIVE), + "DEVELOPMENT_TEAM=DZ9BFS26A5", + "CODE_SIGN_STYLE=Automatic", + "archive", + ], + cwd=ROOT, + ) + + +def submit_review(token: str, version_id: str): + status, body = api( + token, + "POST", + "/v1/reviewSubmissions", + { + "data": { + "type": "reviewSubmissions", + "attributes": {"platform": "IOS"}, + "relationships": { + "app": { + "data": { + "type": "apps", + "id": find_app(token)["id"], + } + } + }, + } + }, + ) + print("reviewSubmissions", status, body if isinstance(body, str) else json.dumps(body)[:800]) + if status not in (200, 201): + # Fallback older endpoint + status2, body2 = api( + token, + "POST", + "/v1/appStoreVersionSubmissions", + { + "data": { + "type": "appStoreVersionSubmissions", + "relationships": { + "appStoreVersion": {"data": {"type": "appStoreVersions", "id": version_id}} + }, + } + }, + ) + print("legacy submit", status2, body2 if isinstance(body2, str) else json.dumps(body2)[:800]) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--wait", action="store_true") + ap.add_argument("--skip-upload", action="store_true") + ap.add_argument("--rebuild", action="store_true") + args = ap.parse_args() + + token = load_token() + app = find_app(token) + if not app and args.wait: + app = wait_for_app(token) + if not app: + print("ERROR: App record missing for", BUNDLE_ID) + print("Create New App in App Store Connect, then re-run with --wait") + sys.exit(2) + + app_id = app["id"] + print("App", app_id) + + if args.rebuild or not ARCHIVE.exists(): + rebuild_archive() + + version_id = ensure_version(token, app_id) + print("Version", version_id) + upsert_localization(token, version_id) + set_privacy_policy(token, app_id) + set_category(token, app_id) + set_age_rating(token, app_id) + set_review_details(token, version_id) + + if not args.skip_upload: + upload_binary(token) + + # Give ASC a moment to process build + print("Waiting 60s for build processing before submit…") + time.sleep(60) + token = load_token() + submit_review(token, version_id) + print("DONE — check App Store Connect for review state / screenshot requirements.") + + +if __name__ == "__main__": + main() diff --git a/ios/scripts/verify_commercial_gates.py b/ios/scripts/verify_commercial_gates.py new file mode 100644 index 0000000..1068ad9 --- /dev/null +++ b/ios/scripts/verify_commercial_gates.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Offline commercial-loop invariants for QuantRadar iOS (no Xcode required).""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +FAIL = 0 + + +def ok(msg: str) -> None: + print(f"OK {msg}") + + +def bad(msg: str) -> None: + global FAIL + FAIL += 1 + print(f"FAIL {msg}") + + +def main() -> int: + access = (ROOT / "QuantRadar/Services/AppAccess.swift").read_text() + storekit = json.loads((ROOT / "QuantRadar/Resources/Products.storekit").read_text()) + project = (ROOT / "project.yml").read_text() + purchase = (ROOT / "QuantRadar/Services/PurchaseStore.swift").read_text() + paywall = (ROOT / "QuantRadar/Views/PaywallView.swift").read_text() + search = (ROOT / "QuantRadar/Views/SearchView.swift").read_text() + watch = (ROOT / "QuantRadar/Views/WatchlistView.swift").read_text() + today = (ROOT / "QuantRadar/Views/TodayView.swift").read_text() + onboarding = (ROOT / "QuantRadar/Views/OnboardingView.swift").read_text() + settings = (ROOT / "QuantRadar/Views/SettingsView.swift").read_text() + root_tab = (ROOT / "QuantRadar/App/RootTabView.swift").read_text() + scorer = (ROOT / "QuantRadar/Services/FreeDataRadar.swift").read_text() + radar = (ROOT / "QuantRadar/Services/RadarService.swift").read_text() + launch = (ROOT / "docs/APP_STORE_LAUNCH.md").read_text() + product = (ROOT / "docs/PRODUCT.md").read_text() + verdict_card = (ROOT / "QuantRadar/Views/VerdictCardView.swift").read_text() + locked = (ROOT / "QuantRadar/Views/LockedVerdictView.swift").read_text() + depth = (ROOT / "QuantRadar/Services/PostureDepth.swift").read_text() + briefing = (ROOT / "QuantRadar/Services/DailyBriefing.swift").read_text() + ledger = (ROOT / "QuantRadar/Services/DisciplineLedger.swift").read_text() + widget = (ROOT / "QuantRadarWidget/QuantRadarWidget.swift").read_text() + + for pid in ( + "one.quantradar.app.unlock", + "one.quantradar.app.live.monthly", + "one.quantradar.app.live.yearly", + ): + if pid not in access: + bad(f"missing product id in AppAccess: {pid}") + else: + ok(f"AppAccess has {pid}") + + unlock_products = [ + p for p in storekit.get("products", []) if p.get("productID") == "one.quantradar.app.unlock" + ] + if not unlock_products or unlock_products[0].get("type") != "NonConsumable": + bad("storekit unlock must be NonConsumable $9.99 product") + else: + ok("storekit unlock NonConsumable present") + if unlock_products[0].get("displayPrice") != "9.99": + bad(f"unlock price expected 9.99 got {unlock_products[0].get('displayPrice')}") + else: + ok("unlock displayPrice 9.99") + + class Preview: + def __init__(self) -> None: + self.claimed: str | None = None + + def can_scan(self, ticker: str, unlocked: bool) -> bool: + t = ticker.strip().upper() + if unlocked: + return True + if t == "SPY": + return True + if self.claimed: + return self.claimed == t + return bool(t) + + def claim(self, ticker: str, withheld: bool = False) -> None: + t = ticker.strip().upper() + if withheld or t == "SPY" or not t: + return + if self.claimed is None: + self.claimed = t + + p = Preview() + if not ( + p.can_scan("SPY", False) + and p.can_scan("AAPL", False) + and not (p.claim("AAPL") or p.can_scan("NVDA", False)) + and p.can_scan("AAPL", False) + and p.can_scan("NVDA", True) + ): + bad("canScan mirror failed (SPY + one personal ticker)") + else: + ok("canScan free=SPY + one personal ticker / unlocked=any") + + for name, blob, needle in ( + ("SearchView", search, "showPaywall"), + ("SearchView", search, "claimPreviewTickerIfNeeded"), + ("SearchView", search, "isPreviewLocked"), + ("SearchView", search, "LockedVerdictView"), + ("RootTabView", root_tab, "effectiveUnlocked"), + ("PaywallView", paywall, "Unlock any ticker"), + ("PaywallView", paywall, "founderPriceLine"), + ("OnboardingView", onboarding, "Open radar"), + ("PurchaseStore", purchase, "purchaseUnlock"), + ("TodayView", today, "Unlock any ticker"), + ("TodayView", today, "todayVerdict"), + ("TodayView", today, "DailyBriefing"), + ("VerdictCardView", verdict_card, "ShareLink"), + ("VerdictCardView", verdict_card, "PostureStripView"), + ("SettingsView", settings, "privacyURL"), + ("AppAccess", access, "privacy-ios"), + ("AppAccess", access, "preview.personal_ticker"), + ("AppAccess", access, "founderPriceLine"), + ("FreeMechanicalScorer", scorer, 'action = "SETUP"'), + ("FreeMechanicalScorer", scorer, "earningsForced"), + ("WatchlistView", watch, "Remind if posture changes"), + ("RadarService", radar, "Do not assign `latest`"), + ("LockedVerdictView", locked, "Unlock to see"), + ("PostureDepth", depth, "historyDays"), + ("DailyBriefing", briefing, "Today's radar is ready"), + ("DisciplineLedger", ledger, "qr.discipline.streak"), + ("Widget", widget, "QuantRadarSPY"), + ): + if needle not in blob: + bad(f"{name} missing `{needle}`") + else: + ok(f"{name} contains `{needle}`") + + for name, blob, banned in ( + ("TodayView", today, "Massive"), + ("TodayView", today, "$0 API"), + ("PaywallView", paywall, "Live+"), + ("PaywallView", paywall, "Massive"), + ("PaywallView", paywall, "Stripe"), + ("PaywallView", paywall, "countdown"), + ("SearchView", search, "forceDemo"), + ("OnboardingView", onboarding, "You already paid"), + ("OnboardingView", onboarding, "Try free"), + ("SettingsView", settings, "Live+ Monthly"), + ("FreeMechanicalScorer", scorer, 'action = "BUY"'), + ("WatchlistView", watch, "Watch is locked"), + ("DailyBriefing", briefing, "hurry"), + ("LockedVerdictView", locked, "BUY"), + ): + if banned in blob: + bad(f"{name} still contains banned `{banned}`") + else: + ok(f"{name} has no `{banned}`") + + if 'MARKETING_VERSION: "1.2.0"' not in project or 'CURRENT_PROJECT_VERSION: "6"' not in project: + bad("project.yml should be 1.2.0 / build 6") + else: + ok("project.yml 1.2.0 / 6") + + if "Cancel any in-flight paid 1.0" not in launch: + bad("APP_STORE_LAUNCH missing paid-1.0 cancel / free listing strategy") + else: + ok("APP_STORE_LAUNCH ships free + Unlock, not paid download") + + if "one lifetime personal ticker" not in product: + bad("PRODUCT.md should describe one personal ticker preview") + else: + ok("PRODUCT.md free-download + one personal ticker") + + if "Stock scanner: wait or act" not in launch: + bad("APP_STORE_LAUNCH missing ASO subtitle") + else: + ok("APP_STORE_LAUNCH ASO subtitle") + + if FAIL: + print(f"\n{FAIL} failure(s)") + return 1 + print("\nAll commercial gate checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/static/index.html b/static/index.html index 92f8036..0e4e1ed 100644 --- a/static/index.html +++ b/static/index.html @@ -202,6 +202,30 @@

Next

+
+
+
+

Website

+

quantradar.one

+

+ Mechanical radar in the browser. Guest demos are frozen artifacts. + Pro is a Stripe supporter plan — live quotes unlock when the charts engine is mounted. +

+ Web pricing +
+
+

iOS

+

App Store app

+

+ Free to install. Today’s SPY plus one ticker of yours. + One-time $9.99 unlock through Apple. Scores on device. Website Pro does not unlock it. +

+

In App Review — no public listing yet. We will not link a store page until Apple lists it.

+ iOS privacy +
+
+
+