diff --git a/.agents/skills/source-command-goal/SKILL.md b/.agents/skills/source-command-goal/SKILL.md new file mode 100644 index 0000000..48b740e --- /dev/null +++ b/.agents/skills/source-command-goal/SKILL.md @@ -0,0 +1,31 @@ +--- +name: "source-command-goal" +description: "Migrated source command `goal`" +--- + +# source-command-goal + +Use this skill when the user asks to run the migrated source command `goal`. + +## Command Template + +# QuantRadar 商业冲刺 · 一轮执行 + +继续 QuantRadar 商业冲刺(Desk→Revenue)。 + +SSOT:先完整读 `~/quantradar/GROK_GOAL_COMMERCIAL.md`(QD 轨道 + 编排 SOP + 红线); +边界与铁律从 `~/quantradar/GROK_GOAL.md` v3(路径 C、单写者、Skill 采掘环)。 + +本轮任务: +- 若本条消息末尾指定了 QD-ID,执行该任务; +- 否则按文档 §五推荐序取下一个 ⬜ QD-ID。 + +强制守规: +1. `git -C ~/quantradar pull --rebase` 后开工;TodoWrite 建轮内清单 +2. explore 子代理 ≤2 个且只读;全仓唯一写者是主 agent +3. 验证闸门:`python3 -m unittest discover tests` 全绿;UI 改动出 375/1440 截图存 `docs/audit/{date}/`;计费一律先 Stripe test mode;密钥零入库零打印 +4. 契约 / 计费 / auth 改动完成后必跑 verifier(bugbot)复审 +5. 卡住走 L1 本机 skill → L2 仓内 → L3 X 双源比对,记 `docs/SKILL_INHERIT.md` +6. 收尾:commit `qr(QD-ID): …` → push → `PROGRESS.md` 一行 → 结束语含「可 Manus pull 发布」→ 回写 GROK_GOAL_COMMERCIAL.md 状态(覆盖 QR-ID 时同步 GROK_GOAL.md) + +红线(违反即停):假社会证明 / 假稀缺 / cancel 迷宫 / 伪造 Greeks 或新鲜度 / QD1-0 裁决前把 live 吹成已可用 / 第三方 tracker。 diff --git a/.env.example b/.env.example index dce01ed..7aa9ee9 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Copy to .env (gitignored) or set in Render/Fly dashboard. +# Copy to .env (gitignored); production environment location: docs/CORRECT_OPS.md. # python3 scripts/bootstrap_env.py PUBLIC_BASE_URL=https://quantradar.one @@ -20,7 +20,7 @@ SESSION_SECRET=replace-with-long-random-string GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= -# Magic link email (optional — without SMTP, links go to console + data/last_magic_link.txt) +# Magic link email (optional; production requires SMTP, no public debug delivery) SMTP_HOST= SMTP_PORT=587 SMTP_USER= @@ -36,7 +36,14 @@ STRIPE_PRICE_ID= STRIPE_PRICE_ID_MONTHLY= STRIPE_PRICE_ID_YEARLY= STRIPE_PRICE_ID_PORTFOLIO_PRO_MONTHLY= +STRIPE_PRICE_ID_REPORT= +STRIPE_PRICE_ID_BUMP= STRIPE_WEBHOOK_SECRET= +STRIPE_PORTAL_CONFIGURATION_ID= +# Durable billing ledger (default: data/billing.sqlite3). Preserve across releases. +# QUANTRADAR_BILLING_DB=/absolute/persistent/path/billing.sqlite3 +# Test checkout audit only; requires TEST keys/prices, never the production .env: +# QR_STRIPE_TEST_ACCOUNT=acct_your_sandbox # NEVER set on production hosts — skips webhook signature verify (unit tests only): # QUANTRADAR_STRIPE_WEBHOOK_TEST=1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2b2405..e8299fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: with: python-version: "3.12" - name: Unit tests - run: python -m unittest discover -s tests -v + run: python scripts/test_isolated.py - name: P0 smoke (in-process) run: python scripts/p0_smoke.py - name: Contract sample @@ -24,14 +24,13 @@ jobs: 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 + - name: Generate and run all offline iOS unit tests run: | cd ios xcodegen generate @@ -44,10 +43,7 @@ jobs: -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 \ + -only-testing:QuantRadarTests \ CODE_SIGNING_ALLOWED=NO \ CODE_SIGN_IDENTITY="" \ CODE_SIGNING_REQUIRED=NO diff --git a/.gitignore b/.gitignore index 354fce4..1978934 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ ios/DerivedData/ ios/build/ .tools/ **/xcuserdata/ +.webhook_secret_pending +free_engine/.cache/ diff --git a/Dockerfile b/Dockerfile index eb85329..b466978 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,7 @@ COPY app ./app COPY static ./static COPY fixtures ./fixtures COPY schemas ./schemas +COPY free_engine ./free_engine COPY scripts ./scripts # Non-root diff --git a/GROK_GOAL.md b/GROK_GOAL.md index c748991..3c83629 100644 --- a/GROK_GOAL.md +++ b/GROK_GOAL.md @@ -1,3 +1,5 @@ +> **2026-09-06 状态更正**:下文为历史计划,旧 Manus/Render、支持者权益和已上架推断不作为当前事实。当前运行于 Nube VPS,待发布成果与剩余闸门见 [验收记录](docs/audit/2026-09-06/README.md) 和 [CORRECT_OPS](docs/CORRECT_OPS.md)。244 项测试及真实 Stripe sandbox 生命周期已通过,正式 Portal 已准备;数据许可、生产切换和 Apple 审核尚未完成,未宣称全量上线或盈利。 + # GROK /goal — QuantRadar 极致产品优化 v3 > **唯一主任务源**(产品仓 SSOT):本文件 `Alexaliao001/quantradar/GROK_GOAL.md` diff --git a/GROK_GOAL_COMMERCIAL.md b/GROK_GOAL_COMMERCIAL.md index 2f8d546..5a237d2 100644 --- a/GROK_GOAL_COMMERCIAL.md +++ b/GROK_GOAL_COMMERCIAL.md @@ -1,3 +1,5 @@ +> **2026-09-06 状态更正**:下文为历史计划,旧 Manus/Render、支持者权益和已上架推断不作为当前事实。当前运行于 Nube VPS,待发布成果与剩余闸门见 [验收记录](docs/audit/2026-09-06/README.md) 和 [CORRECT_OPS](docs/CORRECT_OPS.md)。244 项测试及真实 Stripe sandbox 生命周期已通过,正式 Portal 已准备;数据许可、生产切换和 Apple 审核尚未完成,未宣称全量上线或盈利。 + # GROK /goal — QuantRadar 商业冲刺 v1(Desk → Revenue) > **本文件**:`~/quantradar/GROK_GOAL_COMMERCIAL.md` — `GROK_GOAL.md` v3 的**商业子战役**执行层 diff --git a/PROGRESS.md b/PROGRESS.md index ed6e7e1..bb45148 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,3 +1,9 @@ +2026-09-06 | **STRIPE-SANDBOX** | JJ 账号真实 test mode 完成美元/英镑报告支付、签名回调、90 日交付/隔离、退款撤券、Pro→Portfolio、期末取消、时钟续费/失败/补缴;修复退款后旧优惠 Checkout 卡住,244 测试与独立复核通过;正式独立 Portal 已准备;Marketstack 询问已发送,工单 307916 | 待数据书面授权后配套切换 Nube/正式计费,Apple 仍 build 7 排队;无全量上线或盈利结论 + +2026-09-06 | **BUILD8-UPLOAD** | App Store Connect build 8(1.2.0)上传成功且 VALID;原 build 7 审核仍 WAITING_FOR_REVIEW;已备份审核/IAP资料并准备双项目替换和地区价格文案;补充 Marketstack $9.99 商用候选及未发送询问 | 仍待数据授权、Stripe test 重认证/真实购买验收;未全量上线 + +2026-09-06 | **LAUNCH-HARDENING** | 完成交易日/独立数据源、持久化单股付费交付/退款/防重复订阅、每日观察列表与真实下载;Python 238 通过,iOS 46(4 外部接口检查跳过)通过,375/1440 截图;build 8 本地归档;两条遗留 Stripe 付款链接已停用;生产热备份及异机校验完成 | 尚未全量上线:待数据授权、Stripe sandbox 生命周期、正式 Portal/配置和 Apple 审核;见 docs/audit/2026-09-06/README.md + # PROGRESS — QuantRadar 2026-08-02 | **AUDIT-FIX** | 安全/诚实审计落地:bootstrap 默认关+公网禁 demo;SESSION_SECRET 公网失败关闭;plan 仅 users store;缺/非法分数 withheld;market/sector 不再假 pass;magic 不落盘/日志脱敏;login+magic 限流;Stripe customer_id 降级回退;health storage_note;72+ 回归测 | push 后需手动 Trigger Render diff --git a/app/charts_facade.py b/app/charts_facade.py index 73e12fa..a590ccd 100644 --- a/app/charts_facade.py +++ b/app/charts_facade.py @@ -6,10 +6,15 @@ from __future__ import annotations import json +import copy import os import re import subprocess import sys +import threading +import time +from concurrent.futures import Future +from collections import OrderedDict from pathlib import Path from typing import Any @@ -22,20 +27,24 @@ r"^[A-Za-z0-9][A-Za-z0-9._-]{0,180}\.(png|jpg|jpeg|webp|svg)$", re.I, ) +_SCAN_LOCK = threading.Lock() +_SCAN_SLOTS = threading.BoundedSemaphore(2) +_SCAN_FLIGHTS: dict[tuple, Future] = {} +_SCAN_CACHE: OrderedDict = OrderedDict() def charts_dir() -> Path: env = os.environ.get("CHARTS_DIR", "").strip() if env: return Path(env).expanduser().resolve() - return (Path.home() / "charts").resolve() + return (REPO_ROOT / "free_engine").resolve() def resolve_mode(explicit: str | None = None) -> str: if explicit in {"live", "artifact"}: return explicit - env = os.environ.get("QUANTRADAR_MODE", "artifact").strip().lower() - return env if env in {"live", "artifact"} else "artifact" + env = os.environ.get("QUANTRADAR_MODE", "live").strip().lower() + return env if env in {"live", "artifact"} else "live" def resolve_chart_asset(basename: str) -> Path | None: @@ -101,7 +110,42 @@ def load_charts_json(path: Path) -> dict[str, Any]: return data -def run_fetch_all(ticker: str, sector: str | None = None, timeout: int = 300) -> dict[str, Any]: +def run_fetch_all(ticker: str, sector: str | None = None, timeout: int = 75) -> dict[str, Any]: + """Share identical scans, bound engine processes, and cache one completed-session result.""" + from free_engine.market_calendar import completed_session + key = (str(charts_dir()), ticker, sector, completed_session()) + with _SCAN_LOCK: + cached = _SCAN_CACHE.get(key) + if cached and time.monotonic() - cached[0] < 60: + return copy.deepcopy(cached[1]) + flight = _SCAN_FLIGHTS.get(key) + owner = flight is None + if owner: + if not _SCAN_SLOTS.acquire(blocking=False): + raise RuntimeError("Scan capacity is busy. Please retry shortly.") + flight = Future() + _SCAN_FLIGHTS[key] = flight + if not owner: + return copy.deepcopy(flight.result(timeout=timeout + 5)) + try: + payload = _run_fetch_all(ticker, sector, timeout) + with _SCAN_LOCK: + _SCAN_CACHE[key] = (time.monotonic(), payload) + _SCAN_CACHE.move_to_end(key) + while len(_SCAN_CACHE) > 64: + _SCAN_CACHE.popitem(last=False) + flight.set_result(payload) + return copy.deepcopy(payload) + except Exception as exc: + flight.set_exception(exc) + raise + finally: + with _SCAN_LOCK: + _SCAN_FLIGHTS.pop(key, None) + _SCAN_SLOTS.release() + + +def _run_fetch_all(ticker: str, sector: str | None = None, timeout: int = 75) -> dict[str, Any]: """Subprocess charts fetch_all.py; parse stdout JSON.""" cdir = charts_dir() script = cdir / "fetch_all.py" @@ -160,6 +204,12 @@ def _map_or_fail( sources=sources, quality=quality, ) + if mode == "live" and payload.get("daily_bars"): + from free_engine.replay import build_replay + try: + mapped["_replay"] = build_replay(payload) + except ValueError: + mapped["warnings"].append("Historical replay is unavailable for this coverage window.") return mapped @@ -235,11 +285,11 @@ def analyze( return fail_response( ticker=t, contract_version=req["contract_version"], - error="artifact_not_found", - error_detail=( - f"no charts artifact for {t}; place fixtures/charts_sample/{t}_analysis.json " - "or set CHARTS_DIR with reports/**/assets" - ), + error="artifact_not_found", + error_detail=( + f"No frozen sample for {t} — frozen demos cover INTC and AAPL only. " + "Switch Mode → live for a free multi-source scan of any ticker." + ), mode="artifact", sources=[{"name": "charts.artifact", "role": "engine", "status": "missing"}], warnings=[f"no charts artifact for {t}"], diff --git a/app/contract.py b/app/contract.py index e5d0a80..9d096b1 100644 --- a/app/contract.py +++ b/app/contract.py @@ -384,6 +384,8 @@ def map_charts_payload( "engine": "charts", "mode": mode, "fetch_time": fetch_time, + "market_as_of": (payload.get("data_quality") or {}).get("market_as_of"), + "market_close": payload["daily_bars"][-1][1] if payload.get("daily_bars") else None, "generated_at": (indicator_data or {}).get("generated_at") if isinstance(indicator_data, dict) else None, diff --git a/app/engagement.py b/app/engagement.py new file mode 100644 index 0000000..49c47e5 --- /dev/null +++ b/app/engagement.py @@ -0,0 +1,473 @@ +"""Engagement layer: scan ledger, avoidance moments, watchlist, daily digest. + +Everything here is real-data only. Avoidance moments compare the close at scan +time with a later close from the free engine — never invented. When no SMTP +keys are configured the digest is archived to disk and the UI says so honestly +(same stance as the waitlist archive). +""" + +from __future__ import annotations + +import json +import os +import threading +import time +from pathlib import Path +from typing import Any + +REPO = Path(__file__).resolve().parent.parent +DATA_DIR = REPO / "data" +LEDGER_PATH = DATA_DIR / "ledger.json" +PRICE_CACHE_PATH = DATA_DIR / "price_cache.json" +DIGEST_DIR = DATA_DIR / "digest" + +_LOCK = threading.Lock() +_LEDGER_CAP = 2000 +_PRICE_TTL_SEC = 6 * 3600 +AVOID_DROP_PCT = 5.0 +WATCH_LIMITS = {"free": 1, "pro": 10, "portfolio_pro": 50} + + +def _load_json(path: Path, default: Any) -> Any: + try: + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + pass + return default + + +def _save_json(path: Path, obj: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(obj, ensure_ascii=False, indent=1), encoding="utf-8") + tmp.replace(path) + + +# ---------------------------------------------------------------- scan ledger + +def record_scan(ticker: str, result: dict[str, Any], email: str | None) -> None: + """Save the authenticated scan's own completed-session close, without another fetch.""" + import math + from free_engine.market_calendar import completed_session + + meta = result.get("meta") or {} + close = meta.get("market_close") + if (not email or not result.get("ok") or meta.get("mode") != "live" + or meta.get("market_as_of") != completed_session() + or not isinstance(close, (int, float)) or not math.isfinite(close) or close <= 0): + return + entry = {"ticker": ticker.upper(), "ts": time.time(), "as_of": meta["market_as_of"], + "action": str((result.get("primary") or {}).get("action") or ""), + "score": (result.get("score") or {}).get("final"), "email": email, "close": close} + with _LOCK: + store = _load_json(LEDGER_PATH, {"version": 2, "entries": []}) + entries = store.get("entries") or [] + entries.append(entry) + store["entries"] = entries[-_LEDGER_CAP:] + _save_json(LEDGER_PATH, store) + cache = _load_json(PRICE_CACHE_PATH, {}) + cache[ticker.upper()] = {"close": close, "ts": entry["ts"], "as_of": entry["as_of"]} + _save_json(PRICE_CACHE_PATH, cache) + + +def _engine_dir() -> Path: + charts_dir = Path(os.environ.get("CHARTS_DIR", "")).expanduser() + return charts_dir if (charts_dir / "free_sources.py").is_file() else REPO / "free_engine" + + +def avoidance_events(email: str | None, limit: int = 5) -> list[dict[str, Any]]: + """Real avoidance moments: scans we called NO/PUT whose cached close later fell >=5%. + + Cache-only by design: the response path never makes network calls. Fresh + closes are refreshed by record_scan (background) and the daily digest. + """ + from free_engine.market_calendar import completed_session + if not email: + return [] + cutoff = completed_session() + store = _load_json(LEDGER_PATH, {"entries": []}) + cache = _load_json(PRICE_CACHE_PATH, {}) + now = time.time() + out: list[dict[str, Any]] = [] + for e in reversed(store.get("entries") or []): + if (e.get("email") or None) != email: + continue + if str(e.get("action") or "").upper() not in {"NO", "PUT"}: + continue + then = float(e.get("close") or 0) + if then <= 0: + continue + hit = cache.get(str(e.get("ticker"))) + if not isinstance(hit, dict) or now - float(hit.get("ts") or 0) > _PRICE_TTL_SEC: + continue + if hit.get("as_of") != cutoff or not e.get("as_of") or hit["as_of"] <= e["as_of"]: + continue + try: + cur = float(hit.get("close")) + except Exception: + continue + drop = (then - cur) / then * 100.0 + if drop >= AVOID_DROP_PCT: + out.append( + { + "ticker": e.get("ticker"), + "then_ts": e.get("ts"), + "then_as_of": e["as_of"], + "now_as_of": hit["as_of"], + "then_close": then, + "now_close": cur, + "drop_pct": round(drop, 1), + "action_then": e.get("action"), + } + ) + if len(out) >= limit: + break + return out + + +# ------------------------------------------------------------------ watchlist + +def public_engagement(user: dict[str, Any]) -> dict[str, Any]: + """Fields for /api/auth/status so the UI can render the desk honestly.""" + email = str(user.get("email") or "") + if not email: + return {} + from app.users import get_user + + u = get_user(email) + if not u: + return {} + return { + "watchlist": [str(t).upper() for t in (u.get("watchlist") or [])], + "watchlist_limit": watch_limit(email), + "daily_digest": bool(u.get("daily_digest")), + "report_granted": bool(u.get("report_granted")), + "bump_csv_priority": bool(u.get("bump_csv_priority")), + } + + +def get_watchlist(email: str) -> list[str]: + from app import users + + u = users.get_user(email) + if not u: + return [] + wl = u.get("watchlist") + return [str(t).upper() for t in wl] if isinstance(wl, list) else [] + + +def watch_limit(email: str) -> int: + from app import users + + plan = users.resolve_plan(email) + return WATCH_LIMITS.get(plan, WATCH_LIMITS["free"]) + + +def add_watch(email: str, ticker: str) -> tuple[bool, str, list[str]]: + from app import users + + t = (ticker or "").strip().upper() + if not t or not t.replace("-", "").replace(".", "").isalnum(): + return False, "invalid ticker", [] + limit = watch_limit(email) + with users._LOCK: # noqa: SLF001 - same-store transactional update + store = users._load() # noqa: SLF001 + u = store["users"].get(users.normalize_email(email)) + if not isinstance(u, dict): + return False, "no account", [] + wl = [str(x).upper() for x in (u.get("watchlist") or [])] + if t in wl: + return True, "already watching", wl + if len(wl) >= limit: + return False, "limit", wl + wl.append(t) + u["watchlist"] = wl + store["users"][users.normalize_email(email)] = u + users._save(store) # noqa: SLF001 + return True, "added", wl + + +def remove_watch(email: str, ticker: str) -> list[str]: + from app import users + + t = (ticker or "").strip().upper() + with users._LOCK: # noqa: SLF001 + store = users._load() # noqa: SLF001 + u = store["users"].get(users.normalize_email(email)) + if not isinstance(u, dict): + return [] + wl = [str(x).upper() for x in (u.get("watchlist") or []) if str(x).upper() != t] + u["watchlist"] = wl + store["users"][users.normalize_email(email)] = u + users._save(store) # noqa: SLF001 + return wl + + +# ---------------------------------------------------------------- daily digest + +def digest_optins() -> list[dict[str, Any]]: + from app import users + + out = [] + with users._LOCK: # noqa: SLF001 + store = users._load() # noqa: SLF001 + for email, u in (store.get("users") or {}).items(): + if isinstance(u, dict) and u.get("daily_digest"): + out.append({"email": email, "watchlist": u.get("watchlist") or []}) + return out + + +def set_digest_optin(email: str, on: bool) -> None: + from app import users + + with users._LOCK: # noqa: SLF001 + store = users._load() # noqa: SLF001 + u = store["users"].get(users.normalize_email(email)) + if isinstance(u, dict): + u["daily_digest"] = bool(on) + store["users"][users.normalize_email(email)] = u + users._save(store) # noqa: SLF001 + + +def build_digest(email: str | None = None) -> dict[str, Any]: + """Build one account's digest, or archive the opt-in batch for a worker.""" + import sys + from app.users import normalize_email + + charts_dir = Path(os.environ.get("CHARTS_DIR", "")).expanduser() + engine_dir = charts_dir if (charts_dir / "fetch_all.py").is_file() else REPO / "free_engine" + if str(engine_dir) not in sys.path: + sys.path.insert(0, str(engine_dir)) + import free_sources as fs # type: ignore + + from free_aggregate import aggregate_bars + from market_calendar import completed_bars, completed_session + from datetime import datetime, timezone + batch_now = datetime.now(timezone.utc) + from free_mechanical import core + + def bars_for(symbol: str) -> list: + try: + sources, _ = fs.fetch_all_sources(symbol, days=365, cache_dir=engine_dir / ".cache", as_of=batch_now) + agg = aggregate_bars(sources) + bars = completed_bars(agg["bars"], now=batch_now) + if set(agg["disagree_days"]) & {row[0] for row in bars[-50:]}: + return [] + return bars + except Exception: + return [] + + spy_bars = bars_for("SPY") + market_core = core(spy_bars, spy_bars or None) + spy_pct = market_core["spy_pct"] + market_open = None if market_core["market_gate"] == "UNKNOWN" else market_core["market_gate"] == "PASS" + + users_rows = [] + recipients = ( + [{"email": normalize_email(email), "watchlist": get_watchlist(email)}] + if email is not None else digest_optins() + ) + for row in recipients: + items = [] + for t in row["watchlist"]: + bars = bars_for(t) + closes = [row[1] for row in bars] + if not closes: + items.append({"ticker": t, "state": "no data"}) + continue + sma20 = sum(closes[-20:]) / min(20, len(closes)) + last = closes[-1] + items.append( + { + "ticker": t, + "close": last, + "above_sma20": last >= sma20, + "as_of": bars[-1][0], + "action": core(bars, spy_bars or None)["action"], + } + ) + users_rows.append({"email": row["email"], "items": items}) + + digest = { + "date": completed_session(batch_now), + "market": {"spy_pct_5d": spy_pct, "gate_open": market_open}, + "users": users_rows, + "sent": False, # SMTP not configured yet — archived honestly + } + if email is None: + DIGEST_DIR.mkdir(parents=True, exist_ok=True) + with _LOCK: + _save_json(DIGEST_DIR / f"{digest['date']}.json", digest) + return digest + + +def smtp_configured() -> bool: + return bool(os.environ.get("SMTP_HOST", "").strip() and os.environ.get("SMTP_USER", "").strip()) + + +# ------------------------------------------------------------------ /today + +def recent_closes(ticker: str, n: int = 5) -> list[float]: + """Last n closes for the replay teaser, read from the warm engine cache. + + Zero network: if the cache is cold we return [] and the UI hides the block. + """ + engine_dir = _engine_dir() + cache_dir = engine_dir / ".cache" + t = (ticker or "").upper() + for src in ("yahoo_q1", "yahoo_q2", "nasdaq"): + safe = "".join(ch if ch.isalnum() else "_" for ch in f"{src}:{t}") + p = cache_dir / f"{safe}.json" + try: + obj = json.loads(p.read_text(encoding="utf-8")) + rows = obj.get("v") if isinstance(obj, dict) else None + if isinstance(rows, list) and len(rows) >= n: + return [float(r[1]) for r in rows[-n:]] + except Exception: + continue + return [] + + +TODAY_CACHE_PATH = DATA_DIR / "today_cache.json" +TODAY_TTL_SEC = 3600 +TODAY_UNIVERSE = ( + "SPY QQQ AAPL MSFT NVDA AMD INTC TSLA META AMZN GOOGL AVGO NFLX " + "JPM BAC XLF V VISA MA KO PEP XLP JNJ LLY UNH XLV XOM CVX XLE " + "CAT BA XLI PLTR SMCI HOOD SOFI BABA JD NIO MU" +).split() + +_today_lock = threading.Lock() +_today_building = False + + +def _compute_today() -> dict[str, Any]: + """Full synchronous compute — parallel fetches, warm engine cache.""" + import sys + from concurrent.futures import ThreadPoolExecutor + + engine_dir = _engine_dir() + if str(engine_dir) not in sys.path: + sys.path.insert(0, str(engine_dir)) + import free_mechanical as fm # type: ignore + import free_sources as fs # type: ignore + + cache_dir = engine_dir / ".cache" + + from free_aggregate import aggregate_bars + from market_calendar import completed_bars, completed_session + from datetime import datetime, timezone + batch_now = datetime.now(timezone.utc) + + def bars_for(symbol: str) -> list: + try: + sources, _ = fs.fetch_all_sources(symbol, days=365, cache_dir=cache_dir, as_of=batch_now) + agg = aggregate_bars(sources) + bars = completed_bars(agg["bars"], now=batch_now) + if set(agg["disagree_days"]) & {row[0] for row in bars[-50:]}: + return [] + return bars + except Exception: + return [] + + spy_bars = bars_for("SPY") + market_core = fm.core(spy_bars, spy_bars or None) + spy_pct = market_core["spy_pct"] + market_gate = market_core["market_gate"] + gate_state = {"PASS": "open", "WATCH": "watch", "NO": "closed"}.get(market_gate, "unknown") + + universe = [t for t in TODAY_UNIVERSE if t != "SPY"] + results: dict[str, list] = {} + # Low concurrency: Yahoo/Nasdaq free endpoints rate-limit bursts (403). + with ThreadPoolExecutor(max_workers=3) as pool: + futures = {pool.submit(bars_for, t): t for t in universe} + for fut, t in futures.items(): + try: + results[t] = fut.result(timeout=90) + except Exception: + results[t] = [] + + rows = [] + for t in universe: + bars = results.get(t) or [] + if len(bars) < 50: + continue + c = fm.core(bars, spy_bars or None) + rows.append( + { + "ticker": t, + "close": round(float(c["last"]), 2), + "score": c["score"], + "action": c["action"], + } + ) + + passing = [r for r in rows if r["action"] == "SETUP"] + passing.sort(key=lambda r: -r["score"]) + return { + "ts": time.time(), + "market_as_of": completed_session(batch_now), + "scanned": len(rows), + "unavailable": len(universe) - len(rows), + "market": { + "spy_pct_5d": round(spy_pct, 2) if spy_pct is not None else None, + "gate_open": None if market_gate == "UNKNOWN" else market_gate == "PASS", + "gate_state": gate_state, + "stale": not bool(spy_bars), + "as_of": spy_bars[-1][0] if spy_bars else None, + }, + "passing": len(passing), + "top": passing[:5], + "note": "Mechanical screen only — educational, not investment advice.", + "computing": False, + } + + +def build_today(force: bool = False) -> dict[str, Any]: + """Daily gate overview. Never blocks the request thread. + + Fresh cache (1h) → returned instantly. Otherwise a background thread + computes it once; callers get the stale payload or a computing flag and + the page polls. Honest counts only. + """ + global _today_building + now = time.time() + cached = _load_json(TODAY_CACHE_PATH, {}) + engine_dir = _engine_dir() + import sys + if str(engine_dir) not in sys.path: + sys.path.insert(0, str(engine_dir)) + from market_calendar import completed_session + + expected = completed_session() + payload = (cached or {}).get("payload") or {} + current = bool(expected) and payload.get("market_as_of") == expected + if not current: + cached = {"payload": {"market": {"gate_open": None, "gate_state": "unknown", "stale": True}, + "top": [], "passing": 0, "scanned": 0}} + fresh = current and now - float(cached.get("ts") or 0) < TODAY_TTL_SEC + if fresh and not force: + return cached.get("payload") or {} + + with _today_lock: + global _today_building # noqa: PLW0603 + if _today_building: + payload = (cached or {}).get("payload") or {} + return {**payload, "computing": True} + _today_building = True + + def _worker() -> None: + global _today_building # noqa: PLW0603 + try: + payload = _compute_today() + with _today_lock: + _save_json(TODAY_CACHE_PATH, {"ts": time.time(), "payload": payload}) + except Exception: + pass + finally: + with _today_lock: + _today_building = False + + threading.Thread(target=_worker, daemon=True).start() + payload = (cached or {}).get("payload") or {} + return {**payload, "computing": True} diff --git a/app/funnel.py b/app/funnel.py index 5ab7860..27b601e 100644 --- a/app/funnel.py +++ b/app/funnel.py @@ -31,6 +31,9 @@ "analyze_run", "signup", "checkout_start", + "report_checkout_start", + "report_paid", + "report_fulfilled", "pro_active", "live_run", "notify_save", diff --git a/app/paid_delivery.py b/app/paid_delivery.py new file mode 100644 index 0000000..5f53d23 --- /dev/null +++ b/app/paid_delivery.py @@ -0,0 +1,418 @@ +"""Durable report ownership, recoverable delivery, and subscription state.""" + +from __future__ import annotations + +import json +import os +import re +import sqlite3 +import threading +import time +import uuid +from contextlib import contextmanager +from pathlib import Path + +from app import users +from app.contract import normalize_request +from free_engine.replay import build_replay, build_report_bundle + + +_schema_lock = threading.Lock() +_initialized_databases: set[tuple[str, int]] = set() + + +@contextmanager +def database(): + path = Path(os.environ.get("QUANTRADAR_BILLING_DB", str(users.USERS_PATH.with_name("billing.sqlite3")))) + path.parent.mkdir(parents=True, exist_ok=True) + db = sqlite3.connect(path, timeout=35) + db.row_factory = sqlite3.Row + try: + identity = (str(path.resolve()), path.stat().st_ino) + with _schema_lock: + if identity not in _initialized_databases: + os.chmod(path, 0o600) + db.execute("PRAGMA journal_mode=WAL") + db.executescript(""" + CREATE TABLE IF NOT EXISTS report_orders ( + id TEXT PRIMARY KEY, owner TEXT NOT NULL, request_key TEXT NOT NULL, + ticker TEXT NOT NULL, sector TEXT, bump INTEGER NOT NULL, + amount INTEGER NOT NULL, currency TEXT NOT NULL DEFAULT 'usd', + created_at REAL NOT NULL, as_of TEXT NOT NULL, inputs TEXT NOT NULL, + checkout_id TEXT UNIQUE, checkout_url TEXT, payment_intent TEXT UNIQUE, + payment_state TEXT NOT NULL DEFAULT 'unpaid', paid_at REAL, + delivery_state TEXT NOT NULL DEFAULT 'pending', bundle TEXT, + attempts INTEGER NOT NULL DEFAULT 0, retry_at REAL NOT NULL DEFAULT 0, + lease_until REAL NOT NULL DEFAULT 0, lease_token TEXT, error TEXT, + legacy INTEGER NOT NULL DEFAULT 0, credit_code TEXT, + credit_state TEXT NOT NULL DEFAULT 'pending', + UNIQUE(owner, request_key) + ); + CREATE TABLE IF NOT EXISTS billing_events ( + id TEXT PRIMARY KEY, type TEXT NOT NULL, processed_at REAL NOT NULL, result TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS payment_refunds ( + payment_intent TEXT PRIMARY KEY, fully_refunded INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS subscriptions ( + id TEXT PRIMARY KEY, owner TEXT NOT NULL, customer_id TEXT NOT NULL, + plan TEXT NOT NULL, price_id TEXT NOT NULL, status TEXT NOT NULL, + paid_through REAL NOT NULL, updated_at REAL NOT NULL + ); + CREATE TABLE IF NOT EXISTS stripe_customers ( + owner TEXT PRIMARY KEY, request_id TEXT NOT NULL, customer_id TEXT + ); + CREATE TABLE IF NOT EXISTS subscription_checkouts ( + owner TEXT PRIMARY KEY, id TEXT UNIQUE NOT NULL, + plan TEXT NOT NULL, interval TEXT NOT NULL, price_id TEXT NOT NULL, + coupon_id TEXT, customer_id TEXT NOT NULL, + checkout_id TEXT, checkout_url TEXT, + state TEXT NOT NULL DEFAULT 'creating', created_at REAL NOT NULL + ); + CREATE INDEX IF NOT EXISTS subscriptions_owner ON subscriptions(owner); + CREATE INDEX IF NOT EXISTS reports_owner ON report_orders(owner, created_at); + CREATE TABLE IF NOT EXISTS watch_reports ( + id TEXT PRIMARY KEY, owner TEXT NOT NULL, as_of TEXT NOT NULL, + roster TEXT NOT NULL, results TEXT NOT NULL DEFAULT '{}', + created_at REAL NOT NULL, state TEXT NOT NULL DEFAULT 'queued', + csv_enabled INTEGER NOT NULL, retry_at REAL NOT NULL DEFAULT 0, + lease_until REAL NOT NULL DEFAULT 0, lease_token TEXT, UNIQUE(owner,as_of) + ); + """) + for table in ("watch_reports", "report_orders"): + if "lease_token" not in {row[1] for row in db.execute(f"PRAGMA table_info({table})")}: + db.execute(f"ALTER TABLE {table} ADD COLUMN lease_token TEXT") + _initialized_databases.add(identity) + yield db + db.commit() + except Exception: + db.rollback() + raise + finally: + db.close() + + +def event_once(event_id: str, event_type: str, apply): + if not event_id or not event_id.startswith("evt_"): + raise ValueError("verified event ID required") + with database() as db: + db.execute("BEGIN IMMEDIATE") + prior = db.execute("SELECT result FROM billing_events WHERE id=?", (event_id,)).fetchone() + if prior: + return {**json.loads(prior[0]), "duplicate": True} + result = apply(db) + if result.get("ok"): + db.execute("INSERT INTO billing_events VALUES(?,?,?,?)", + (event_id, event_type, time.time(), json.dumps(result))) + else: + db.rollback() + return result + + +def prepare_report_order(owner: str, ticker: str, *, sector=None, bump=False, request_key: str): + if type(bump) is not bool: + raise ValueError("bump must be true or false") + if not isinstance(request_key, str) or not re.fullmatch(r"[A-Za-z0-9_-]{16,80}", request_key): + raise ValueError("a unique request_key is required") + owner = users.normalize_email(owner) + request = normalize_request(ticker, sector=sector, mode="live") + ticker, sector = request["ticker"], request["sector"] + with database() as db: + prior = db.execute("SELECT * FROM report_orders WHERE owner=? AND request_key=?", (owner, request_key)).fetchone() + if prior: + if (prior["ticker"], prior["sector"], bool(prior["bump"])) != (ticker, sector, bump): + raise ValueError("request_key already belongs to another report") + return dict(prior) + payload = validated_report_payload(ticker, sector) + cutoff = payload["data_quality"]["market_as_of"] + order_id = uuid.uuid4().hex + inputs = json.dumps(payload, separators=(",", ":"), allow_nan=False) + with database() as db: + db.execute("INSERT OR IGNORE INTO report_orders(id,owner,request_key,ticker,sector,bump,amount,created_at,as_of,inputs) VALUES(?,?,?,?,?,?,?,?,?,?)", + (order_id, owner, request_key, ticker, sector, int(bump), 900 + 500 * bump, time.time(), cutoff, inputs)) + saved = db.execute("SELECT * FROM report_orders WHERE owner=? AND request_key=?", (owner, request_key)).fetchone() + if (saved["ticker"], saved["sector"], bool(saved["bump"])) != (ticker, sector, bump): + raise ValueError("request_key already belongs to another report") + return dict(saved) + + +def validated_report_payload(ticker: str, sector: str | None = None) -> dict: + from app.charts_facade import run_fetch_all + from free_engine.market_calendar import completed_session + from app.quality import assess_charts_payload + + payload = run_fetch_all(ticker, sector) + cutoff = (payload.get("data_quality") or {}).get("market_as_of") + if cutoff != completed_session() or not assess_charts_payload(payload, ticker)["usable"]: + raise ValueError("Current completed-session data is unavailable; no payment was started") + replay = build_replay(payload) + if len(replay) != 90 or any(row["score"] is None for row in replay): + raise ValueError("90-session report history is unavailable; no payment was started") + return payload + + +def attach_checkout(order_id: str, session: dict): + if not session.get("id") or not session.get("url"): + raise ValueError("Stripe did not return a checkout session") + with database() as db: + db.execute("UPDATE report_orders SET checkout_id=?, checkout_url=? WHERE id=? AND checkout_id IS NULL", + (session["id"], session["url"], order_id)) + + +def _legacy_checkout_order(db, session: dict): + """Bind paid checkouts created before ticker-specific report orders existed.""" + meta = session.get("metadata") or {} + owner = users.normalize_email(meta.get("email") or "") + bump = meta.get("bump") == "1" + if (not owner or "@" not in owner or session.get("mode") != "payment" + or session.get("payment_status") != "paid" or session.get("currency") != "usd" + or session.get("amount_total") != 900 + 500 * bump + or meta.get("product") != "quantradar_report"): + raise ValueError("Legacy report payment is invalid") + existing = db.execute("SELECT * FROM report_orders WHERE checkout_id=?", (session["id"],)).fetchone() + if existing: + return existing + account = users.get_user(owner) or {} + old_coupon = account.get("report_coupon") + if old_coupon and db.execute("SELECT 1 FROM report_orders WHERE credit_code=? AND checkout_id IS NOT NULL", (old_coupon,)).fetchone(): + old_coupon = None + claim = db.execute("SELECT id FROM report_orders WHERE owner=? AND legacy=1 AND checkout_id IS NULL LIMIT 1", (owner,)).fetchone() + if claim: + db.execute("UPDATE report_orders SET checkout_id=?,amount=?,payment_state='unpaid',bump=?,credit_code=?,credit_state=? WHERE id=?", + (session["id"], session["amount_total"], int(bump), old_coupon, "ready" if old_coupon else "pending", claim["id"])) + else: + db.execute("""INSERT INTO report_orders + (id,owner,request_key,ticker,bump,amount,created_at,as_of,inputs,checkout_id,delivery_state,legacy,credit_code,credit_state) + VALUES(?,?,?,'',?,?,?,'','{}',?,'awaiting_ticker',1,?,?)""", + (uuid.uuid4().hex, owner, "legacy-" + session["id"], int(bump), session["amount_total"], time.time(), session["id"], old_coupon, "ready" if old_coupon else "pending")) + return db.execute("SELECT * FROM report_orders WHERE checkout_id=?", (session["id"],)).fetchone() + + +def mark_report_paid(db, session: dict, *, paid_at: float | None = None) -> dict: + meta = session.get("metadata") or {} + order_id = meta.get("order_id") + row = db.execute("SELECT * FROM report_orders WHERE id=? AND checkout_id=?", (order_id, session.get("id"))).fetchone() + if not order_id: + row = _legacy_checkout_order(db, session) + order_id = row["id"] + if not row: + raise ValueError("No bound report order for this checkout") + if (session.get("mode") != "payment" or session.get("payment_status") != "paid" + or session.get("amount_total") != row["amount"] or session.get("currency") != row["currency"] + or meta.get("product") != "quantradar_report" or meta.get("email") != row["owner"]): + raise ValueError("Report payment does not match the saved order") + intent = session.get("payment_intent") + if not isinstance(intent, str) or not intent.startswith("pi_"): + raise ValueError("Report payment intent missing") + if row["payment_intent"] and row["payment_intent"] != intent: + raise ValueError("This order is already bound to another payment") + newly_paid = row["payment_intent"] is None + if newly_paid: + refund = db.execute("SELECT fully_refunded FROM payment_refunds WHERE payment_intent=?", (intent,)).fetchone() + state = "refunded" if refund and refund[0] else "partially_refunded" if refund else "paid" + delivery = "ready" if row["bundle"] else "queued" if row["ticker"] else "awaiting_ticker" + db.execute("UPDATE report_orders SET payment_state=?, paid_at=?, payment_intent=?, delivery_state=?,credit_state=? WHERE id=?", + (state, paid_at if paid_at is not None else time.time(), intent, delivery, + ("revoking" if row["credit_code"] else "revoked") if refund else row["credit_state"], order_id)) + return {"ok": True, "action": "report_paid", "order_id": order_id, "email": row["owner"], + "duplicate_payment": not newly_paid} + + +def record_refund(db, charge: dict) -> dict: + intent = charge.get("payment_intent") + if not isinstance(intent, str) or not intent.startswith("pi_"): + return {"ok": True, "action": "ignored"} + # Successful charge refunds are cumulative. Persist even when checkout has not arrived. + db.execute("""INSERT INTO payment_refunds VALUES(?,?) ON CONFLICT(payment_intent) DO UPDATE + SET fully_refunded=MAX(payment_refunds.fully_refunded,excluded.fully_refunded)""", + (intent, int(bool(charge.get("refunded"))))) + full = db.execute("SELECT fully_refunded FROM payment_refunds WHERE payment_intent=?", (intent,)).fetchone()[0] + db.execute("""UPDATE report_orders SET payment_state=?,credit_state=CASE WHEN credit_code IS NULL + THEN 'revoked' ELSE 'revoking' END,retry_at=0 WHERE payment_intent=?""", + ("refunded" if full else "partially_refunded", intent)) + return {"ok": True, "action": "refund_recorded"} + + +def report_summary(row) -> dict: + return {key: row[key] for key in ("id", "ticker", "as_of", "bump", "amount", "currency", "created_at", "payment_state", "delivery_state", "error", "legacy", "credit_state", "paid_at")} + + +def list_reports(owner: str) -> list[dict]: + ensure_legacy_claim(owner) + with database() as db: + return [report_summary(row) for row in db.execute("SELECT * FROM report_orders WHERE owner=? ORDER BY created_at DESC LIMIT 100", (users.normalize_email(owner),))] + + +def ensure_legacy_claim(owner: str): + owner = users.normalize_email(owner) + account = users.get_user(owner) or {} + if not account.get("report_granted"): + return + with database() as db: + db.execute("BEGIN IMMEDIATE") + if db.execute("SELECT 1 FROM report_orders WHERE owner=? AND legacy=1 LIMIT 1", (owner,)).fetchone(): + return + db.execute("""INSERT OR IGNORE INTO report_orders + (id,owner,request_key,ticker,bump,amount,created_at,as_of,inputs,payment_state,delivery_state,legacy,credit_code,credit_state) + VALUES(?,?,?,'',?,0,?,'','{}','paid','awaiting_ticker',1,?,?)""", + (uuid.uuid4().hex, owner, "legacy-existing-grant", int(bool(account.get("bump_csv_priority"))), time.time(), + account.get("report_coupon"), "ready" if account.get("report_coupon") else "legacy")) + + +def claim_legacy_report(owner: str, order_id: str, ticker: str) -> dict: + owner = users.normalize_email(owner) + request = normalize_request(ticker, mode="live") + with database() as db: + row = db.execute("SELECT * FROM report_orders WHERE id=? AND owner=? AND legacy=1", (order_id, owner)).fetchone() + if not row: + raise ValueError("Legacy report claim not found") + if row["ticker"]: + if row["ticker"] == request["ticker"]: + return report_summary(row) + raise ValueError("This legacy report has already been assigned") + if row["payment_state"] not in {"paid", "partially_refunded"}: + raise ValueError("This report is not paid") + payload = validated_report_payload(request["ticker"]) + with database() as db: + updated = db.execute("""UPDATE report_orders SET ticker=?,as_of=?,inputs=?,delivery_state='queued' + WHERE id=? AND owner=? AND ticker='' AND payment_state IN ('paid','partially_refunded')""", + (request["ticker"], payload["data_quality"]["market_as_of"], json.dumps(payload, allow_nan=False), order_id, owner)) + if updated.rowcount != 1: + raise ValueError("This report has already been assigned") + return report_summary(db.execute("SELECT * FROM report_orders WHERE id=?", (order_id,)).fetchone()) + + +def report_credit(owner: str) -> str | None: + from app.stripe_billing import coupon_redeemable + with database() as db: + rows = db.execute("""SELECT credit_code FROM report_orders WHERE owner=? AND payment_state='paid' + AND credit_state='ready' AND (paid_at>? OR (legacy=1 AND paid_at IS NULL)) ORDER BY paid_at DESC""", + (users.normalize_email(owner), time.time() - 7 * 86400)).fetchall() + legacy = users.get_user(owner) or {} + code = legacy.get("report_coupon") if legacy.get("report_granted") else None + legacy_available = code and not db.execute("SELECT 1 FROM report_orders WHERE credit_code=?", (code,)).fetchone() + for row in rows: + if coupon_redeemable(row[0]): + return row[0] + if legacy_available and coupon_redeemable(str(code)): + return str(code) + return None + + +def get_report(owner: str, order_id: str) -> dict | None: + with database() as db: + row = db.execute("SELECT * FROM report_orders WHERE owner=? AND id=?", (users.normalize_email(owner), order_id)).fetchone() + if not row: + return None + result = report_summary(row) + if row["payment_state"] in {"paid", "partially_refunded"} and row["delivery_state"] == "ready": + result["bundle"] = json.loads(row["bundle"]) + return result + + +def fulfill_next(*, now: float | None = None) -> bool: + now = time.time() if now is None else now + with database() as db: + db.execute("BEGIN IMMEDIATE") + row = db.execute("""SELECT * FROM report_orders + WHERE ((payment_state IN ('paid','partially_refunded') + AND (delivery_state IN ('queued','retrying','building') OR (delivery_state='ready' AND credit_state='pending'))) + OR credit_state='revoking') + AND retry_at <= ? AND lease_until <= ? + ORDER BY paid_at - CASE WHEN bump=1 THEN 60 ELSE 0 END, id LIMIT 1""", (now, now)).fetchone() + if row is None: + return False + lease_token = uuid.uuid4().hex + db.execute("UPDATE report_orders SET lease_until=?,lease_token=?, attempts=attempts+1 WHERE id=?", (now + 120, lease_token, row["id"])) + if row["credit_state"] == "revoking": + from app.stripe_billing import revoke_credit_coupon + revoked = revoke_credit_coupon(row["credit_code"]) + if revoked and row["credit_code"]: + from app.subscription_checkout import revoke_coupon_checkouts + try: + revoke_coupon_checkouts(row["credit_code"]) + except Exception: + revoked = False + with database() as db: + db.execute("UPDATE report_orders SET credit_state=?,lease_until=0,lease_token=NULL,retry_at=? WHERE id=? AND lease_token=?", + ("revoked" if revoked else "revoking", now + 60, row["id"], lease_token)) + return True + try: + bundle = json.loads(row["bundle"]) if row["bundle"] else build_report_bundle(json.loads(row["inputs"]), include_csv=bool(row["bump"] or row["legacy"])) + encoded = json.dumps(bundle, separators=(",", ":"), allow_nan=False) + with database() as db: + updated = db.execute("UPDATE report_orders SET bundle=?, delivery_state='ready', error=NULL WHERE id=? AND lease_token=?", + (encoded, row["id"], lease_token)) + if not updated.rowcount: + return True + except Exception: + with database() as db: + db.execute("UPDATE report_orders SET delivery_state='retrying', lease_until=0,lease_token=NULL, retry_at=?, error=? WHERE id=? AND lease_token=?", + (now + min(3600, 30 * 2 ** min(row["attempts"], 7)), "Report delivery is retrying. Your payment is recorded.", row["id"], lease_token)) + return True + # Credit failures never take away a completed download; keep the lease through issuance. + with database() as db: + current = db.execute("SELECT * FROM report_orders WHERE id=?", (row["id"],)).fetchone() + if current["lease_token"] != lease_token: + return True + if current["credit_state"] == "pending" and current["payment_state"] == "paid": + from app.stripe_billing import create_credit_coupon + expired = now >= row["paid_at"] + 7 * 86400 + try: + coupon = None if expired else create_credit_coupon(row["owner"], order_id=row["id"], paid_at=row["paid_at"]) + except Exception: + coupon = None + with database() as db: + db.execute("""UPDATE report_orders SET credit_code=?,credit_state=CASE WHEN payment_state='paid' + THEN ? ELSE ? END,retry_at=? WHERE id=? AND lease_token=?""", + (coupon, "ready" if coupon else "expired" if expired else "pending", + "revoking" if coupon else "revoked", now + 60, row["id"], lease_token)) + with database() as db: + db.execute("UPDATE report_orders SET lease_until=0,lease_token=NULL WHERE id=? AND lease_token=?", (row["id"], lease_token)) + return True + + +_worker_lock = threading.Lock() +_worker_started = False + + +def start_worker(): + global _worker_started + with _worker_lock: + if _worker_started: + return + _worker_started = True + + def work(): + from app import watch_reports + next_schedule = 0 + while True: + try: + if fulfill_next(): + continue + if time.monotonic() >= next_schedule: + watch_reports.schedule_optins() + next_schedule = time.monotonic() + 60 + if watch_reports.process_next(): + continue + except Exception: + pass + time.sleep(2) + + threading.Thread(target=work, name="report-delivery", daemon=True).start() + + +def subscription_plan(owner: str, *, now: float | None = None) -> str | None: + with database() as db: + rows = db.execute("SELECT * FROM subscriptions WHERE owner=?", (users.normalize_email(owner),)).fetchall() + if not rows: + return None # Legacy/manual accounts remain under the existing account policy. + now = time.time() if now is None else now + active = {row["plan"] for row in rows if row["status"] in {"active", "trialing"} and row["paid_through"] > now} + return "portfolio_pro" if "portfolio_pro" in active else "pro" if "pro" in active else "free" + + +def has_open_subscription(owner: str) -> bool: + with database() as db: + return db.execute("SELECT 1 FROM subscriptions WHERE owner=? AND status NOT IN ('canceled','incomplete_expired') LIMIT 1", + (users.normalize_email(owner),)).fetchone() is not None diff --git a/app/public_surface.py b/app/public_surface.py index 3cf34ce..3d8b049 100644 --- a/app/public_surface.py +++ b/app/public_surface.py @@ -59,6 +59,21 @@ def harden_public_analyze( """Return a copy safe to send on public HTTP (does not mutate input).""" out = deepcopy(result) pro_live = is_pro_live_audience(user, out) + prior_replay = out.pop("engagement_replay", {}) + replay = out.pop("_replay", []) or prior_replay.get("rows", []) + if replay: + replay = replay[-90:] + shown = replay if pro_live else replay[-5:] + out["engagement_replay"] = { + "rows": shown, + "recent_closes": [row["close"] for row in shown if row.get("close") is not None], + "days_shown": len(shown), + "full_days": len(replay), + "usable_days": sum(row.get("score") is not None for row in shown), + "unlocked": pro_live, + "teaser_note": "Reconstructed mechanical replay; historical earnings and sector gates unavailable." + if pro_live else "Free shows 5 completed sessions. Pro includes up to 90 sessions of reconstructed posture.", + } arts = out.get("artifacts") if isinstance(out.get("artifacts"), dict) else {} charts_in = arts.get("charts") if isinstance(arts.get("charts"), dict) else {} diff --git a/app/quality.py b/app/quality.py index 2671046..a74d761 100644 --- a/app/quality.py +++ b/app/quality.py @@ -140,7 +140,7 @@ def assess_charts_payload(payload: dict[str, Any] | None, ticker: str) -> dict[s return { "usable": False, "error": "invalid_ticker", - "error_detail": f"{t} is not a real security symbol", + "error_detail": f"{t} is not a recognized security symbol", "reasons": ["blocked_placeholder_ticker"], "warnings": [], "volume": {}, @@ -151,7 +151,7 @@ def assess_charts_payload(payload: dict[str, Any] | None, ticker: str) -> dict[s return { "usable": False, "error": "no_data", - "error_detail": f"no charts payload for {t}", + "error_detail": f"No market data found for {t}. Check the symbol, or try a major ticker.", "reasons": ["empty_payload"], "warnings": [], "volume": {}, @@ -171,7 +171,7 @@ def assess_charts_payload(payload: dict[str, Any] | None, ticker: str) -> dict[s return { "usable": False, "error": "no_data", - "error_detail": f"charts payload for {t} lacks mechanical_scores", + "error_detail": f"No usable score could be computed for {t} from the free data sources.", "reasons": ["missing_mechanical_scores"], "warnings": [], "volume": extract_volume_snapshot(payload), @@ -230,7 +230,7 @@ def assess_charts_payload(payload: dict[str, Any] | None, ticker: str) -> dict[s return { "usable": False, "error": "no_data", - "error_detail": f"insufficient engine data for {t}: {', '.join(reasons)}", + "error_detail": f"Not enough reliable data for {t} right now: {', '.join(reasons)}. Try again later.", "reasons": reasons, "warnings": warnings, "volume": volume, diff --git a/app/server.py b/app/server.py index 6747d23..8e6c0d6 100644 --- a/app/server.py +++ b/app/server.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +import ipaddress import os import re import subprocess @@ -21,7 +22,8 @@ from app import CONTRACT_VERSION, __version__ from app import auth as authlib -from app.charts_facade import analyze, charts_dir, resolve_chart_asset +from app import engagement, paid_delivery, watch_reports +from app.charts_facade import analyze, charts_dir, resolve_chart_asset, resolve_mode from app.contract import validate_response from app.envload import load_dotenv from app import funnel @@ -39,7 +41,8 @@ ) # Demo tickers: free artifact sample only (TG-5) — never live, never "credits" -DEMO_TICKERS = frozenset({"INTC", "NVDA", "AAPL", "MU", "TSLA", "AMD"}) +# Must match shipped fixtures/charts_sample/*.json (share pages + sitemap) +DEMO_TICKERS = frozenset({"INTC", "AAPL"}) # Load .env before bootstrap / auth config (import order matters on public hosts) load_dotenv() @@ -58,13 +61,14 @@ _RATE_HITS: dict[str, list[float]] = {} -def _rate_limit_max(authenticated: bool = False) -> int: +def _rate_limit_max(authenticated: bool = False, plan: str = "free") -> int: try: guest = max(1, int(os.environ.get("QUANTRADAR_RATE_LIMIT", "30"))) authed = max(guest, int(os.environ.get("QUANTRADAR_RATE_LIMIT_AUTH", "120"))) + paid = max(authed, int(os.environ.get("QUANTRADAR_RATE_LIMIT_PRO", "240"))) except ValueError: - guest, authed = 30, 120 - return authed if authenticated else guest + guest, authed, paid = 30, 120, 240 + return paid if authenticated and is_paid_plan(plan) else authed if authenticated else guest def _rate_limit_window() -> float: @@ -74,11 +78,11 @@ def _rate_limit_window() -> float: return 60.0 -def check_rate_limit(client: str, *, authenticated: bool = False) -> bool: +def check_rate_limit(client: str, *, authenticated: bool = False, plan: str = "free") -> bool: """Return True if allowed, False if limited.""" now = time.time() window = _rate_limit_window() - limit = _rate_limit_max(authenticated) + limit = _rate_limit_max(authenticated, plan) key = f"{'a' if authenticated else 'g'}:{client}" with _RATE_LOCK: hits = [t for t in _RATE_HITS.get(key, []) if now - t < window] @@ -90,16 +94,57 @@ def check_rate_limit(client: str, *, authenticated: bool = False) -> bool: return True +def _live_guest_budget(client: str) -> bool: + """Tighter per-IP budget for guest live scans. + + Live runs a real multi-source engine subprocess per scan; a single crawler + must not burn host capacity or upstream free APIs. Signed-in users (any + plan) use the normal authenticated budget. + """ + now = time.time() + try: + limit = max(1, int(os.environ.get("QUANTRADAR_LIVE_GUEST_RATE", "6"))) + except ValueError: + limit = 6 + window = _rate_limit_window() + key = f"live:{client}" + with _RATE_LOCK: + hits = [t for t in _RATE_HITS.get(key, []) if now - t < window] + if len(hits) >= limit: + _RATE_HITS[key] = hits + return False + hits.append(now) + _RATE_HITS[key] = hits + return True + + +_GIT_SHA_CACHE: str | None = None +_GIT_SHA_RESOLVED = False + + def git_sha() -> str | None: + """Memoized — rev-parse forks git on every call otherwise (health polls).""" + global _GIT_SHA_CACHE, _GIT_SHA_RESOLVED + if _GIT_SHA_RESOLVED: + return _GIT_SHA_CACHE + manifest = REPO_ROOT / "SOURCE_HEAD" + if manifest.is_file(): + value = manifest.read_text(encoding="utf-8").strip() + if re.fullmatch(r"[0-9a-f]{40}", value): + _GIT_SHA_CACHE, _GIT_SHA_RESOLVED = value, True + return value try: out = subprocess.check_output( ["git", "-C", str(REPO_ROOT), "rev-parse", "--short", "HEAD"], text=True, stderr=subprocess.DEVNULL, + timeout=3, ) - return out.strip() or None + _GIT_SHA_CACHE = out.strip() or None except Exception: - return None + _GIT_SHA_CACHE = None + _GIT_SHA_RESOLVED = True + return _GIT_SHA_CACHE def health_payload() -> dict[str, Any]: @@ -123,12 +168,12 @@ def health_payload() -> dict[str, Any]: charts_status = "mounted" data_path = "charts_engine" product_note = ( - "charts engine mounted; live mode can subprocess fetch_all for Pro sessions " - "(login + plan=pro)." + "charts engine mounted; live multi-source scans are open to everyone " + "(guests have tighter limits; paid plans add saved research)." ) pro_value = "live_ready" pro_value_note = ( - "Pro includes live analyze on this host (login + plan=pro). See docs/PRO_VALUE.md." + "Pro includes 10 saved tickers, daily watchlist reports, up to 90 sessions of replay, and higher scan request limits." ) elif fixture_tickers: charts_status = "artifact_only" @@ -139,8 +184,7 @@ def health_payload() -> dict[str, Any]: ) pro_value = "supporter_until_mount" pro_value_note = ( - "Pro is a supporter plan on this host; live unlocks automatically when " - "charts_status=mounted. See docs/PRO_VALUE.md." + "Live scans and new reports are unavailable until a working data engine is mounted." ) else: charts_status = "unavailable" @@ -148,7 +192,7 @@ def health_payload() -> dict[str, Any]: product_note = "no charts dir and no fixtures — analyze will fail until data is mounted." pro_value = "supporter_until_mount" pro_value_note = ( - "Pro is a supporter plan until a charts engine is mounted. See docs/PRO_VALUE.md." + "Live scans and new reports are unavailable until a working data engine is mounted." ) return { @@ -169,15 +213,15 @@ def health_payload() -> dict[str, Any]: "live_available": charts_status == "mounted", "pro_value": pro_value, "pro_value_note": pro_value_note, - "mode_default": os.environ.get("QUANTRADAR_MODE", "artifact"), + "mode_default": os.environ.get("QUANTRADAR_MODE", "live"), # Auth: guest + optional Google session; never Manus "auth": a["auth_mode"], "manus_login": False, "guest_access": True, "google_oauth": a["google_oauth"], "login_path": "/login", - "live_requires_login": True, - "live_requires_pro": True, + "live_requires_login": False, + "live_requires_pro": False, "p0_gates": True, # Render Free has no persistent disk — accounts/plans reset on redeploy "storage_durable": os.environ.get("QUANTRADAR_STORAGE_DURABLE", "").strip().lower() @@ -263,7 +307,14 @@ def log_message(self, fmt: str, *args: Any) -> None: print(f"[shell] {self.address_string()} {line}", file=sys_stderr) def _client_id(self) -> str: - return self.client_address[0] if self.client_address else "unknown" + peer = self.client_address[0] if self.client_address else "unknown" + if peer in {"127.0.0.1", "::1"}: + forwarded = self.headers.get("X-Forwarded-For", "").rsplit(",", 1)[-1].strip() + try: + return str(ipaddress.ip_address(forwarded)) + except ValueError: + pass + return peer def _current_user(self) -> dict[str, Any] | None: user = authlib.user_from_cookie_header(self.headers.get("Cookie")) @@ -281,23 +332,30 @@ def _current_user(self) -> dict[str, Any] | None: def _send( self, code: int, - body: dict[str, Any] | bytes, + body: dict[str, Any] | bytes | str, content_type: str = "application/json", *, extra_headers: list[tuple[str, str]] | None = None, auth_tag: str | None = None, + cache_control: str | None = None, + etag: str | None = None, ) -> None: if isinstance(body, dict): # allow_nan=False: NaN is not valid JSON and breaks browser JSON.parse raw = json.dumps( body, ensure_ascii=False, indent=2, allow_nan=False ).encode("utf-8") + elif isinstance(body, str): + raw = body.encode("utf-8") else: raw = body self.send_response(code) - self.send_header("Content-Type", f"{content_type}; charset=utf-8") + textual = content_type.startswith("text/") or content_type in {"application/json", "image/svg+xml"} + self.send_header("Content-Type", f"{content_type}; charset=utf-8" if textual else content_type) self.send_header("Content-Length", str(len(raw))) - self.send_header("Cache-Control", "no-store") + self.send_header("Cache-Control", cache_control or "no-store") + if etag: + self.send_header("ETag", etag) tag = auth_tag if tag is None: tag = "session" if self._current_user() else "guest" @@ -310,6 +368,13 @@ def _send( if not self._head_request: self.wfile.write(raw) + def _send_304(self, *, cache_control: str, etag: str) -> None: + """Conditional revalidation hit — headers only, zero body bytes.""" + self.send_response(304) + self.send_header("Cache-Control", cache_control) + self.send_header("ETag", etag) + self.end_headers() + def _redirect(self, location: str, *, extra_headers: list[tuple[str, str]] | None = None) -> None: self.send_response(302) self.send_header("Location", location) @@ -434,6 +499,22 @@ def _analyze_response(self, result: dict[str, Any], *, user: dict[str, Any] | No ) except Exception: pass + # Engagement layer: ledger for real avoidance moments (never blocks). + try: + if not self._head_request and result.get("ok") and not result.get("demo") and not result.get("sample"): + engagement.record_scan( + str(result.get("ticker") or ""), + result, + (user or {}).get("email"), + ) + events = engagement.avoidance_events((user or {}).get("email")) + if events: + result["engagement_moments"] = { + "avoided": events, + "note": "Real history from your scans — the radar said NO and the price fell. Educational, not advice.", + } + except Exception: + pass public = harden_public_analyze(result, user=user) self._send( http_code_for_result(public), @@ -449,7 +530,7 @@ def _run_analyze( request_id: str | None = None, ) -> None: user = self._current_user() - if not check_rate_limit(self._client_id(), authenticated=bool(user)): + if not check_rate_limit(str(user["email"]) if user else self._client_id(), authenticated=bool(user), plan=user["plan"] if user else "free"): self._send( 429, { @@ -462,47 +543,31 @@ def _run_analyze( return mode_norm = (mode or "").strip().lower() or None - # Live mode requires login + Pro plan (uses real charts fetch / keys) - if mode_norm == "live" and not user: - self._send( - 401, - { - "ok": False, - "error": "login_required", - "error_detail": "mode=live requires sign-in. Guest may use artifact/sample.", - "login": "/login", - "contract_version": CONTRACT_VERSION, - "gate": {"signal": "NO"}, - "score": {"final": None, "scale": 100, "withheld": True}, - "artifacts": {"charts": {}}, - "sources": [], - "warnings": ["live mode requires sign-in and Pro"], - "ticker": (ticker or "").upper() or "UNKNOWN", - }, - auth_tag="guest", - ) - return - if mode_norm == "live" and user: - plan = str(user.get("plan") or "free").lower() - if not is_paid_plan(plan): + # Live is open to everyone (free multi-source engine); guests get a + # tighter per-IP budget so a crawler cannot burn host + upstream APIs. + # Paid plans add saved research and higher request limits. + if resolve_mode(mode_norm) == "live" and not user: + if not _live_guest_budget(self._client_id()): self._send( - 403, + 429, { "ok": False, - "error": "plan_required", - "error_detail": "mode=live requires Pro. Upgrade at /pricing.", + "error": "rate_limited", + "error_detail": ( + "Guest live scans are limited to protect the free data " + "path. Sign in (free) for higher limits, or view paid plans " + "for saved research and larger watchlists." + ), "login": "/login", - "pricing": "/pricing", - "plan": plan, "contract_version": CONTRACT_VERSION, "gate": {"signal": "NO"}, "score": {"final": None, "scale": 100, "withheld": True}, "artifacts": {"charts": {}}, "sources": [], - "warnings": ["live mode requires Pro plan"], + "warnings": ["guest live budget exceeded"], "ticker": (ticker or "").upper() or "UNKNOWN", }, - auth_tag="session", + auth_tag="guest", ) return @@ -575,12 +640,81 @@ def do_GET(self) -> None: # noqa: N802 return # --- Auth API (own session, not Manus) --- + if path == "/api/watch-reports" or path.startswith("/api/watch-reports/"): + user = self._current_user() + if not user: + self._send(401, {"ok": False, "error": "login_required"}) + return + if path == "/api/watch-reports": + self._send(200, {"ok": True, "reports": watch_reports.list_reports(user["email"]), + "can_create": is_paid_plan(user["plan"]), + "watchlist": engagement.get_watchlist(user["email"]), + "watchlist_limit": engagement.watch_limit(user["email"]), + "automatic": bool(engagement.public_engagement(user).get("daily_digest"))}) + return + parts = path.split("/") + report = watch_reports.get_report(user["email"], parts[3]) + if not report or len(parts) != 5 or parts[4] not in {"json", "csv"}: + self._send(404, {"ok": False, "error": "report_not_found"}) + return + if parts[4] == "csv" and not report["csv_enabled"]: + self._send(403, {"ok": False, "error": "portfolio_export_required"}) + return + data = watch_reports.csv_export(report) if parts[4] == "csv" else json.dumps(report, indent=2) + self._send(200, data, content_type="text/csv" if parts[4] == "csv" else "application/json", + cache_control="private, no-store", extra_headers=[("Content-Disposition", f'attachment; filename="watchlist-{report["as_of"]}.{parts[4]}"')]) + return + + if path == "/api/reports" or path.startswith("/api/reports/"): + user = self._current_user() + if not user: + self._send(401, {"ok": False, "error": "login_required"}) + return + owner = str(user["email"]) + if path == "/api/reports": + self._send(200, {"ok": True, "reports": paid_delivery.list_reports(owner)}) + return + parts = path.split("/") + report = paid_delivery.get_report(owner, parts[3]) + if not report: + self._send(404, {"ok": False, "error": "report_not_found"}) + return + bundle = report.pop("bundle", None) + if len(parts) == 4: + report["assets"] = list(bundle["assets"]) + ["report.zip"] if bundle else [] + self._send(200, {"ok": True, "report": report}) + return + if len(parts) == 6 and parts[4] == "assets" and bundle: + name = parts[5] + if name == "report.zip": + from free_engine.replay import zip_bundle + data, mime = zip_bundle(bundle), "application/zip" + elif name in bundle["assets"]: + data = bundle["assets"][name].encode("utf-8") + mime = {"json": "application/json", "csv": "text/csv", "svg": "image/svg+xml"}[name.rsplit(".", 1)[1]] + else: + self._send(404, {"ok": False, "error": "asset_not_found"}) + return + self._send(200, data, content_type=mime, cache_control="private, no-store", + extra_headers=[("Content-Disposition", f'attachment; filename="{report["ticker"]}-{name}"'), + ("X-Content-Type-Options", "nosniff")]) + return + self._send(404, {"ok": False, "error": "report_not_ready"}) + return + if path in {"/api/auth/status", "/api/me"}: user = self._current_user() public = None if user: public = authlib.session_user_public(user) public["plan"] = str(user.get("plan") or "free") + # Engagement layer entitlements (watchlist caps, digest opt-in). + try: + full = engagement.public_engagement(user) + if full: + public.update(full) + except Exception: + pass body = { "ok": True, **authlib.auth_status_public(), @@ -721,6 +855,12 @@ def do_GET(self) -> None: # noqa: N802 "yearly": "$249", "portfolio_pro_monthly": "$99", }, + "available_prices": { + "monthly": bool(stripe_billing.stripe_configured() and stripe_billing.price_id_for_interval("monthly")), + "yearly": bool(stripe_billing.stripe_configured() and stripe_billing.price_id_for_interval("yearly")), + "portfolio_pro_monthly": bool(stripe_billing.stripe_configured() and stripe_billing.price_id_for_plan("portfolio_pro")), + "report": bool(stripe_billing.stripe_configured() and stripe_billing.price_id_report()), + }, "live_available": bool(health.get("live_available")), "pro_value": health.get("pro_value"), "pro_value_note": health.get("pro_value_note"), @@ -774,6 +914,51 @@ def do_GET(self) -> None: # noqa: N802 self._send(200, load_track_record()) return + if path == "/api/today": + try: + payload = engagement.build_today() + except Exception as exc: + self._send(502, {"ok": False, "error": "today_unavailable", "error_detail": str(exc)[:200]}) + return + self._send(200, {"ok": True, **payload}, auth_tag="none") + return + + if path == "/api/watchlist": + user = self._current_user() + if not user: + self._send(401, {"ok": False, "error": "login_required", "login": "/login"}) + return + self._send( + 200, + { + "ok": True, + "watchlist": engagement.get_watchlist(str(user.get("email"))), + "limit": engagement.watch_limit(str(user.get("email"))), + }, + ) + return + + if path == "/api/ledger": + user = self._current_user() + if user: + self._send( + 200, + {"ok": True, "avoided": engagement.avoidance_events(str(user.get("email")))}, + ) + else: + # Guests get honest aggregate counts only — no other users' events. + n = len(engagement.avoidance_events(None)) + self._send( + 200, + { + "ok": True, + "avoided_count": n, + "avoided": [], + "note": "Sign in to see avoidance moments from your own scans.", + }, + ) + return + # QD2-0: serve chart PNGs by basename (fixtures/assets or CHARTS_DIR) if path.startswith("/api/charts/"): raw_name = path[len("/api/charts/") :].strip("/") @@ -793,7 +978,12 @@ def do_GET(self) -> None: # noqa: N802 ".webp": "image/webp", ".svg": "image/svg+xml", }.get(suffix, "application/octet-stream") - self._send(200, target.read_bytes(), content_type=ctype) + self._send( + 200, + target.read_bytes(), + content_type=ctype, + cache_control="public, max-age=3600", + ) return if path in {"/", "/index.html"}: @@ -815,8 +1005,8 @@ def do_GET(self) -> None: # noqa: N802 "ok": False, "error": "not_a_demo_ticker", "error_detail": ( - f"{raw_t} is not a published demo share. " - f"Try /r/INTC or open /?demo=INTC." + f"{raw_t} has no frozen demo card. Live scans are free for " + f"everyone — run one at /?live={raw_t}." ), "ticker": raw_t, } @@ -839,6 +1029,9 @@ def do_GET(self) -> None: # noqa: N802 "/methodology": "methodology.html", "/pricing": "pricing.html", "/track": "track.html", + "/today": "today.html", + "/reports": "reports.html", + "/watchlist": "watchlist.html", "/terms": "terms.html", "/privacy": "privacy.html", "/terms-ios": "terms-ios.html", @@ -861,6 +1054,7 @@ def do_GET(self) -> None: # noqa: N802 "Allow: /methodology\n" "Allow: /pricing\n" "Allow: /track\n" + "Allow: /today\n" "Disallow: /api/\n" "Disallow: /api/auth/\n" "Disallow: /btn-demos\n" @@ -872,14 +1066,24 @@ def do_GET(self) -> None: # noqa: N802 if path == "/sitemap.txt": share_lines = "".join(f"/r/{t}\n" for t in sorted(DEMO_TICKERS)) body = ( - "/\n/methodology\n/pricing\n/track\n" + "/\n/methodology\n/pricing\n/track\n/today\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") return - # static assets + # favicon — browsers request this unconditionally; serve a real icon + if path == "/favicon.ico" or path == "/favicon.svg": + fav = STATIC_DIR / "favicon.svg" + if fav.is_file(): + self._send(200, fav.read_bytes(), content_type="image/svg+xml", cache_control="public, max-age=86400") + else: + self._redirect("/static/og-default.svg") + return + + # static assets — ETag (mtime+size) + short max-age: instant 304s, + # never stale beyond one day, no-store stays for API/HTML entry points. if path.startswith("/static/"): rel = path[len("/static/") :] target = (STATIC_DIR / rel).resolve() @@ -903,7 +1107,21 @@ def do_GET(self) -> None: # noqa: N802 ctype = "image/webp" elif target.suffix == ".woff2": ctype = "font/woff2" - self._send(200, target.read_bytes(), content_type=ctype) + st = target.stat() + etag = f'"{st.st_mtime_ns:x}-{st.st_size:x}"' + # Conditional GET — browsers carrying the same ETag get a 304. + inm = (self.headers.get("If-None-Match") or "").strip() + weak_ok = inm == etag or inm == "W/" + etag or inm == "*" + if weak_ok: + self._send_304(cache_control="public, max-age=3600, must-revalidate", etag=etag) + return + self._send( + 200, + target.read_bytes(), + content_type=ctype, + cache_control="public, max-age=3600, must-revalidate", + etag=etag, + ) return self._send(404, {"ok": False, "error": "not found", "path": path}) @@ -1076,6 +1294,157 @@ def do_POST(self) -> None: # noqa: N802 self._send(200, result) return + if path == "/api/watchlist/add": + user = self._current_user() + if not user: + self._send(401, {"ok": False, "error": "login_required", "login": "/login"}) + return + try: + body = self._read_json() + except Exception: + body = {} + email = str(user.get("email")) + ok, why, wl = engagement.add_watch(email, str(body.get("ticker") or "")) + if not ok and why == "limit": + self._send( + 200, + { + "ok": False, + "error": "watchlist_limit", + "watchlist": wl, + "limit": engagement.watch_limit(email), + "error_detail": ( + "Free accounts watch 1 ticker, Pro up to 10, and Portfolio Pro up to 50." + ), + "pricing": "/pricing", + }, + ) + return + self._send(200, {"ok": ok, "watchlist": wl, "limit": engagement.watch_limit(email), "error": None if ok else why}) + return + + if path == "/api/watchlist/remove": + user = self._current_user() + if not user: + self._send(401, {"ok": False, "error": "login_required", "login": "/login"}) + return + try: + body = self._read_json() + except Exception: + body = {} + wl = engagement.remove_watch(str(user.get("email")), str(body.get("ticker") or "")) + self._send(200, {"ok": True, "watchlist": wl}) + return + + if path == "/api/digest/opt": + user = self._current_user() + if not user: + self._send(401, {"ok": False, "error": "login_required", "login": "/login"}) + return + try: + body = self._read_json() + except Exception: + body = {} + on = bool(body.get("on", True)) + engagement.set_digest_optin(str(user.get("email")), on) + self._send( + 200, + { + "ok": True, + "on": on, + "smtp": engagement.smtp_configured(), + "note": "Daily reports appear in your watchlist with Pro. Email delivery is not included." if on else "Automatic daily reports turned off.", + }, + ) + return + + if path == "/api/watch-reports": + user = self._current_user() + if not user: + self._send(401, {"ok": False, "error": "login_required"}) + return + if not self._check_rate_or_reject(authenticated=True): + return + try: + report_id = watch_reports.request_report(user["email"]) + except ValueError as exc: + self._send(400, {"ok": False, "error": "report_unavailable", "error_detail": str(exc)}) + return + self._send(200, {"ok": True, "id": report_id}) + return + + if path == "/api/digest/build": + user = self._current_user() + if not user or not is_paid_plan(user.get("plan")): + self._send(403, {"ok": False, "error": "pro_required"}) + return + if not self._check_rate_or_reject(authenticated=True): + return + try: + digest = engagement.build_digest(email=str(user["email"])) + except Exception as exc: + self._send(502, {"ok": False, "error": "digest_failed", "error_detail": str(exc)[:200]}) + return + self._send(200, {"ok": True, "digest": digest}) + return + + if path == "/api/billing/checkout_report": + user = self._current_user() + if not user: + self._send(401, {"ok": False, "error": "login_required", "login": "/login"}) + return + if not stripe_billing.stripe_configured(): + self._send(503, {"ok": False, "error": "stripe_not_configured"}) + return + try: + body = self._read_json() + except Exception: + body = {} + if not self._check_rate_or_reject(authenticated=True): + return + try: + order = paid_delivery.prepare_report_order( + str(user["email"]), str(body.get("ticker") or ""), sector=body.get("sector"), + bump=body.get("bump", False), request_key=body.get("request_key"), + ) + session = stripe_billing.create_report_checkout( + customer_email=str(user.get("email") or ""), + with_bump=bool(order["bump"]), order=order, + ) + except ValueError as exc: + self._send(400, {"ok": False, "error": "report_unavailable", "error_detail": str(exc)}) + return + except Exception as exc: + self._send(502, {"ok": False, "error": "stripe_error", "error_detail": str(exc)[:500]}) + return + try: + funnel.track( + "report_checkout_start", + extra={"bump": bool(order["bump"])}, + ticker=order["ticker"], + email=str(user.get("email") or ""), + ok=True, + ) + except Exception: + pass + self._send(200, {"ok": True, **session}) + return + + if path == "/api/billing/portal": + user = self._current_user() + if not user: + self._send(401, {"ok": False, "error": "login_required"}) + return + if not self._check_rate_or_reject(authenticated=True): + return + try: + session = stripe_billing.create_billing_portal(str(user["email"])) + except Exception: + self._send(503, {"ok": False, "error": "billing_portal_unavailable", "error_detail": "Billing management is temporarily unavailable. Please retry or contact support."}) + return + self._send(200, {"ok": True, **session}) + return + if path == "/api/billing/checkout": user = self._current_user() if not user: @@ -1108,12 +1477,15 @@ def do_POST(self) -> None: # noqa: N802 body = {} interval = str(body.get("interval") or "monthly") plan = stripe_billing.normalize_checkout_plan(str(body.get("plan") or "pro")) + email = str(user.get("email") or "") + # $9 report buyers carry a once-only credit toward Pro's first month. + coupon = paid_delivery.report_credit(email) try: - # Ignore client price_id — only server env Price IDs + plan/interval. - session = stripe_billing.create_checkout_session( - customer_email=str(user.get("email") or "") or None, - price_id=None, + from app import subscription_checkout + session = subscription_checkout.start( + email, interval=interval, + coupon_id=coupon, plan=plan, ) except Exception as exc: @@ -1180,7 +1552,7 @@ def do_POST(self) -> None: # noqa: N802 if not result.get("ok"): self._send(422, {"ok": False, **result}) return - if result.get("action") in {"plan_pro", "plan_portfolio_pro"}: + if result.get("activated") and not result.get("duplicate"): try: funnel.track( "pro_active", @@ -1194,6 +1566,28 @@ def do_POST(self) -> None: # noqa: N802 self._send(200, {"ok": True, **result}) return + if path.startswith("/api/reports/") and path.endswith("/claim"): + user = self._current_user() + if not user: + self._send(401, {"ok": False, "error": "login_required"}) + return + if not self._check_rate_or_reject(authenticated=True): + return + parts = path.split("/") + try: + if len(parts) != 5: + raise ValueError("Invalid report route") + body = self._read_json() + report = paid_delivery.claim_legacy_report(str(user["email"]), parts[3], str(body.get("ticker") or "")) + except ValueError as exc: + self._send(400, {"ok": False, "error": "claim_unavailable", "error_detail": str(exc)}) + return + except Exception: + self._send(503, {"ok": False, "error": "data_unavailable", "error_detail": "Current report data is unavailable. Your claim is preserved."}) + return + self._send(200, {"ok": True, "report": report}) + return + if path != "/api/analyze": self._send(404, {"ok": False, "error": "not found"}) return @@ -1229,6 +1623,7 @@ def main() -> None: host = os.environ.get("HOST", "0.0.0.0") port = int(os.environ.get("PORT", "8765")) server = ThreadingHTTPServer((host, port), Handler) + paid_delivery.start_worker() a = authlib.auth_status_public() print( f"quantradar shell v{__version__} contract={CONTRACT_VERSION} " diff --git a/app/share_page.py b/app/share_page.py index 05058ef..e713a76 100644 --- a/app/share_page.py +++ b/app/share_page.py @@ -39,8 +39,9 @@ def render_share_html(ticker: str, result: dict[str, Any]) -> bytes: avoided = str(eng.get("avoided_line") or "") else: primary = "UNAVAILABLE" - label = "No demo artifact for this ticker" + label = "No frozen demo for this ticker — live scans are free" why = str(result.get("error_detail") or result.get("error") or "Artifact not found.") + freeze = "Live multi-source scans are free for everyone" title = f"{t} · {primary} · score {score_txt} — QuantRadar" desc = ( @@ -48,7 +49,10 @@ def render_share_html(ticker: str, result: dict[str, Any]) -> bytes: + (f" ({label})" if label else "") + f". Score {score_txt}/100. Educational demo — not investment advice." ) - desk_href = f"/?demo={_esc(t)}" + desk_href = f"/?demo={_esc(t)}" if ok else f"/?live={_esc(t)}" + desk_label = "Open full desk" if ok else f"Run free live scan of {_esc(t)}" + secondary_href = "/pricing" if ok else "/methodology" + secondary_label = "Pricing" if ok else "How the score works" put_note = "" if primary.startswith("PUT"): put_note = "

PUT is hedge bias — not a sell order.

" @@ -59,6 +63,7 @@ def render_share_html(ticker: str, result: dict[str, Any]) -> bytes: + {_esc(title)} @@ -67,6 +72,7 @@ def render_share_html(ticker: str, result: dict[str, Any]) -> bytes: + @@ -93,8 +99,8 @@ def render_share_html(ticker: str, result: dict[str, Any]) -> bytes:

{_esc(freeze)}

{_esc(posture)}

- Open full desk - Pricing + {desk_label} + {secondary_label}

Educational only — not investment advice. Fortune Insight, LLC.

diff --git a/app/stripe_billing.py b/app/stripe_billing.py index 0ddeb33..bde547e 100644 --- a/app/stripe_billing.py +++ b/app/stripe_billing.py @@ -17,6 +17,14 @@ CHECKOUT_PLANS = frozenset({"pro", "portfolio_pro"}) +class StripeRequestError(RuntimeError): + def __init__(self, status: int, error: dict): + super().__init__(str(error.get("message") or "Stripe request failed")) + self.coupon_rejected = status == 400 and error.get("type") == "invalid_request_error" and ( + error.get("code") == "coupon_expired" or + (error.get("code") == "resource_missing" and str(error.get("param") or "").startswith("discounts"))) + + def stripe_secret() -> str: return ( os.environ.get("QUANTRADAR_STRIPE_SECRET_KEY", "").strip() @@ -69,6 +77,19 @@ def price_id_for_interval(interval: str | None) -> str: ) +def price_id_report() -> str: + return ( + os.environ.get("STRIPE_PRICE_ID_REPORT", "").strip() + or os.environ.get("QUANTRADAR_STRIPE_PRICE_ID_REPORT", "").strip() + ) + + +def price_id_bump() -> str: + return ( + os.environ.get("STRIPE_PRICE_ID_BUMP", "").strip() + or os.environ.get("QUANTRADAR_STRIPE_PRICE_ID_BUMP", "").strip() + ) + def price_id_for_plan(plan: str | None, interval: str | None = None) -> str: """Resolve Stripe Price ID for checkout plan + interval.""" plan_n = normalize_checkout_plan(plan) @@ -87,6 +108,9 @@ def create_checkout_session( interval: str | None = None, plan: str | None = "pro", mode: str = "subscription", + coupon_id: str | None = None, + customer_id: str | None = None, + idempotency_key: str | None = None, ) -> dict[str, Any]: """Create a Stripe Checkout Session. Returns {id, url, plan}.""" secret = stripe_secret() @@ -121,7 +145,10 @@ def create_checkout_session( "line_items[0][quantity]": "1", } if customer_email: - data["customer_email"] = customer_email + if customer_id: + data["customer"] = customer_id + else: + data["customer_email"] = customer_email data["client_reference_id"] = customer_email data["metadata[email]"] = customer_email data["metadata[interval]"] = iv @@ -132,6 +159,13 @@ def create_checkout_session( data["subscription_data[metadata][interval]"] = iv data["subscription_data[metadata][plan]"] = plan_n data["subscription_data[metadata][product]"] = product + if idempotency_key: + data["metadata[checkout_attempt]"] = idempotency_key + data["subscription_data[metadata][checkout_attempt]"] = idempotency_key + + if coupon_id and plan_n == "pro" and (idempotency_key or coupon_redeemable(coupon_id)): + data.pop("allow_promotion_codes", None) + data["discounts[0][coupon]"] = coupon_id body = urllib.parse.urlencode(data).encode() req = urllib.request.Request( @@ -141,6 +175,7 @@ def create_checkout_session( "Authorization": f"Bearer {secret}", "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "QuantRadar-Stripe/0.5", + **({"Idempotency-Key": idempotency_key} if idempotency_key else {}), }, method="POST", ) @@ -148,8 +183,12 @@ def create_checkout_session( with urllib.request.urlopen(req, timeout=30) as resp: obj = json.loads(resp.read().decode()) except urllib.error.HTTPError as exc: - detail = exc.read().decode(errors="replace")[:800] - raise RuntimeError(f"stripe checkout failed: {exc.code} {detail}") from exc + detail = exc.read().decode(errors="replace") + try: + error = json.loads(detail).get("error") or {} + except (ValueError, AttributeError): + error = {} + raise StripeRequestError(exc.code, error) from exc return { "id": obj.get("id"), "url": obj.get("url"), @@ -159,6 +198,177 @@ def create_checkout_session( } +def create_report_checkout(*, customer_email: str, with_bump: bool = False, order: dict | None = None) -> dict[str, Any]: + """One-time $9 deep report (+ optional $5 CSV/priority order bump). + + Payment mode, never subscription — no negative-option surface at all. + """ + secret = stripe_secret() + if not secret: + raise RuntimeError("Stripe not configured") + report_price = price_id_report() + if not report_price: + raise RuntimeError("STRIPE_PRICE_ID_REPORT not configured") + if order is None or order["owner"] != customer_email or bool(order["bump"]) != with_bump: + raise ValueError("A saved report order is required before checkout") + if order.get("checkout_id"): + return {"id": order["checkout_id"], "url": order["checkout_url"], "order_id": order["id"], "product": "report"} + base = public_base_url() + data: dict[str, str] = { + "mode": "payment", + "success_url": f"{base}/reports?order={order['id']}", + "cancel_url": f"{base}/pricing?checkout=cancel", + "line_items[0][price]": report_price, + "line_items[0][quantity]": "1", + "customer_email": customer_email, + "client_reference_id": customer_email, + "metadata[email]": customer_email, + "metadata[product]": "quantradar_report", + "metadata[order_id]": order["id"], + "metadata[ticker]": order["ticker"], + "metadata[as_of]": order["as_of"], + "custom_text[submit][message]": f"{order['ticker']} report · snapshot as of {order['as_of']} · one-time payment.", + "payment_intent_data[metadata][order_id]": order["id"], + "payment_intent_data[metadata][product]": "quantradar_report", + } + bump_price = price_id_bump() + if with_bump and not bump_price: + raise ValueError("The CSV add-on price is not configured") + if with_bump and bump_price: + data["line_items[1][price]"] = bump_price + data["line_items[1][quantity]"] = "1" + data["metadata[bump]"] = "1" + body = urllib.parse.urlencode(data).encode() + req = urllib.request.Request( + "https://api.stripe.com/v1/checkout/sessions", + data=body, + headers={ + "Authorization": f"Bearer {secret}", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "QuantRadar-Stripe/0.5", + "Idempotency-Key": "report-checkout-" + order["id"], + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + obj = json.loads(resp.read().decode()) + except urllib.error.HTTPError as exc: + detail = exc.read().decode(errors="replace")[:800] + raise RuntimeError(f"stripe checkout failed: {exc.code} {detail}") from exc + from app import paid_delivery + paid_delivery.attach_checkout(order["id"], obj) + return {"id": obj.get("id"), "url": obj.get("url"), "product": "report", "order_id": order["id"]} + + +def create_credit_coupon(email: str, amount_cents: int = 900, *, order_id: str | None = None, paid_at: float | None = None) -> str | None: + """Once-only coupon crediting the $9 report toward Pro's first month. + + Expires in 7 days — the credit is a real, time-boxed incentive, and the + countdown shown to the buyer is truthful. + """ + secret = stripe_secret() + if not secret: + return None + if not order_id or paid_at is None: + raise ValueError("A paid order is required to issue report credit") + expires = int(paid_at) + 7 * 24 * 3600 + data = urllib.parse.urlencode( + { + "id": "qr-report-" + order_id, + "amount_off": str(amount_cents), + "currency": "usd", + "duration": "once", + "max_redemptions": "1", + "redeem_by": str(expires), + "name": "QuantRadar report credit", + "metadata[email]": email, + "metadata[order_id]": order_id, + } + ).encode() + req = urllib.request.Request( + "https://api.stripe.com/v1/coupons", + data=data, + headers={ + "Authorization": f"Bearer {secret}", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "QuantRadar-Stripe/0.5", + "Idempotency-Key": "report-credit-" + order_id, + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + obj = json.loads(resp.read().decode()) + return str(obj.get("id") or "") or None + except urllib.error.HTTPError as exc: + if exc.code in {400, 409}: + try: + existing = stripe_get("coupons/" + urllib.parse.quote("qr-report-" + order_id, safe="")) + if (existing.get("metadata") or {}).get("order_id") == order_id: + return str(existing["id"]) + except Exception: + pass + return None + except Exception: + return None + + +def coupon_redeemable(coupon_id: str | None) -> bool: + """True only if the coupon exists and Stripe would still accept it + (not expired by redeem_by, not exhausted max_redemptions).""" + secret = stripe_secret() + cid = (coupon_id or "").strip() + if not secret or not cid: + return False + req = urllib.request.Request( + f"https://api.stripe.com/v1/coupons/{cid}", + headers={"Authorization": f"Bearer {secret}"}, + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + obj = json.loads(resp.read().decode()) + except Exception: + return False + if obj.get("valid") is False: + return False + redeem_by = obj.get("redeem_by") + if isinstance(redeem_by, (int, float)) and time.time() > redeem_by: + return False + max_red = obj.get("max_redemptions") + times = obj.get("times_redeemed") or 0 + if isinstance(max_red, int) and times >= max_red: + return False + return True + + +def revoke_credit_coupon(coupon_id: str | None) -> bool: + if not coupon_id: + return True + if not stripe_secret(): + return False + request = urllib.request.Request( + "https://api.stripe.com/v1/coupons/" + urllib.parse.quote(coupon_id, safe=""), + headers={"Authorization": "Bearer " + stripe_secret()}, method="DELETE") + try: + with urllib.request.urlopen(request, timeout=20): + return True + except urllib.error.HTTPError as exc: + return exc.code == 404 + except Exception: + return False + + +def pro_checkout_with_credit( + *, customer_email: str, interval: str = "monthly", coupon_id: str | None = None, + plan: str = "pro", +) -> dict[str, Any]: + return create_checkout_session( + customer_email=customer_email, interval=interval, plan=plan, + coupon_id=coupon_id if plan == "pro" else None, + ) + + def verify_webhook_signature(payload: bytes, sig_header: str | None, *, tolerance_sec: int = 300) -> bool: """Verify Stripe-Signature header (t=...,v1=...).""" secret = webhook_secret() @@ -205,61 +415,140 @@ def _plan_from_metadata(meta: dict[str, Any] | None) -> str: return plan_from_product(str(meta.get("product") or "")) -def apply_webhook_event(event: dict[str, Any]) -> dict[str, Any]: - """Apply plan changes from a verified Stripe event. Returns action summary.""" - from app.users import set_plan +def stripe_get(path: str) -> dict[str, Any]: + secret = stripe_secret() + if not secret: + raise RuntimeError("Stripe is not configured") + request = urllib.request.Request("https://api.stripe.com/v1/" + path, + headers={"Authorization": f"Bearer {secret}"}) + with urllib.request.urlopen(request, timeout=20) as response: + return json.loads(response.read().decode()) + + +def stripe_post(path: str, data: dict, *, idempotency_key: str) -> dict[str, Any]: + request = urllib.request.Request("https://api.stripe.com/v1/" + path, + data=urllib.parse.urlencode(data).encode(), method="POST", + headers={"Authorization": "Bearer " + stripe_secret(), "Content-Type": "application/x-www-form-urlencoded", + "Idempotency-Key": idempotency_key}) + with urllib.request.urlopen(request, timeout=20) as response: + return json.loads(response.read().decode()) + + +def create_billing_portal(email: str, *, plan: str | None = None, interval: str | None = None) -> dict: + from app import paid_delivery, users + account = users.get_user(email) or {} + with paid_delivery.database() as db: + row = db.execute("SELECT * FROM subscriptions WHERE owner=? AND status NOT IN ('canceled','incomplete_expired') ORDER BY updated_at DESC LIMIT 1", (users.normalize_email(email),)).fetchone() + customer = row["customer_id"] if row else account.get("stripe_customer_id") + if not customer: + raise ValueError("No Stripe subscription is linked to this account") + data = {"customer": str(customer), "return_url": public_base_url() + "/pricing"} + configuration = os.environ.get("STRIPE_PORTAL_CONFIGURATION_ID", "").strip() + if configuration: + data["configuration"] = configuration + if plan and row: + subscription = stripe_get("subscriptions/" + urllib.parse.quote(row["id"], safe="")) + items = (subscription.get("items") or {}).get("data") or [] + price = price_id_for_plan(plan, interval) + if not price: + raise ValueError("Selected plan is not configured") + if len(items) == 1 and items[0].get("id"): + data.update({"flow_data[type]": "subscription_update_confirm", + "flow_data[subscription_update_confirm][subscription]": row["id"], + "flow_data[subscription_update_confirm][items][0][id]": items[0]["id"], + "flow_data[subscription_update_confirm][items][0][price]": price, + "flow_data[subscription_update_confirm][items][0][quantity]": "1"}) + request = urllib.request.Request("https://api.stripe.com/v1/billing_portal/sessions", + data=urllib.parse.urlencode(data).encode(), + headers={"Authorization": "Bearer " + stripe_secret(), "Content-Type": "application/x-www-form-urlencoded"}, method="POST") + with urllib.request.urlopen(request, timeout=20) as response: + session = json.loads(response.read().decode()) + return {"id": session["id"], "url": session["url"], "billing_portal": True} + + +def _sync_subscription(db, subscription_id: str, *, expected_owner: str | None = None) -> dict[str, Any]: + # Read current Stripe state while the event transaction serializes handlers. + # Delivery order and event.created cannot reliably order subscription changes. + if not subscription_id.startswith("sub_"): + raise ValueError("subscription ID required") + subscription = stripe_get("subscriptions/" + urllib.parse.quote(subscription_id, safe="")) + metadata = subscription.get("metadata") or {} + known_products = {"quantradar_pro", "quantradar_portfolio_pro"} + items = (subscription.get("items") or {}).get("data") or [] + prices = {price_id_for_interval("monthly"): "pro", price_id_for_interval("yearly"): "pro", + price_id_for_plan("portfolio_pro"): "portfolio_pro"} + prices.pop("", None) + if metadata.get("product") not in known_products and not any( + (item.get("price", {}).get("id") if isinstance(item.get("price"), dict) else item.get("price")) in prices for item in items + ): + return {"ok": True, "action": "ignored", "reason": "unrelated_product"} + if len(items) != 1: + return {"ok": False, "error": "unsupported_subscription_items"} + price = items[0].get("price") or {} + price_id = price.get("id") if isinstance(price, dict) else price + plan = prices.get(price_id) + if plan is None: + return {"ok": False, "error": "unrecognized_subscription_price"} + from app.users import normalize_email, find_email_by_stripe_customer + customer = subscription.get("customer") + customer = customer.get("id") if isinstance(customer, dict) else customer + prior = db.execute("SELECT owner FROM subscriptions WHERE id=?", (subscription_id,)).fetchone() + email = metadata.get("email") or (prior["owner"] if prior else None) or find_email_by_stripe_customer(customer) or expected_owner + if not email: + return {"ok": False, "error": "no_email"} + email = normalize_email(email) + if expected_owner and email != normalize_email(expected_owner): + raise ValueError("Subscription owner does not match the checkout account") + previously_active = db.execute("SELECT 1 FROM subscriptions WHERE owner=? AND status IN ('active','trialing') AND paid_through>? LIMIT 1", (email, time.time())).fetchone() is not None + paid_through = subscription.get("current_period_end") or items[0].get("current_period_end") or subscription.get("trial_end") or 0 + status = str(subscription.get("status") or "unknown") + db.execute("""INSERT INTO subscriptions VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET + owner=excluded.owner,customer_id=excluded.customer_id,plan=excluded.plan,price_id=excluded.price_id, + status=excluded.status,paid_through=excluded.paid_through,updated_at=excluded.updated_at""", + (subscription_id, email, customer, plan, price_id, status, float(paid_through), time.time())) + if metadata.get("checkout_attempt"): + db.execute("UPDATE subscription_checkouts SET state='complete' WHERE owner=? AND id=?", (email, metadata["checkout_attempt"])) + effective = {row[0] for row in db.execute("SELECT plan FROM subscriptions WHERE owner=? AND status IN ('active','trialing') AND paid_through>?", (email, time.time()))} + plan = "portfolio_pro" if "portfolio_pro" in effective else "pro" if "pro" in effective else "free" + return {"ok": True, "action": "plan_" + plan, "plan": plan, "email": email, "customer_id": customer, + "activated": not previously_active and plan != "free"} + +def apply_webhook_event(event: dict[str, Any]) -> dict[str, Any]: + """Only verified events enter here; persist each effect once on durable storage.""" + from app import paid_delivery etype = str(event.get("type") or "") - data_obj = (event.get("data") or {}).get("object") or {} - if not isinstance(data_obj, dict): + obj = (event.get("data") or {}).get("object") or {} + if not isinstance(obj, dict): return {"ok": False, "error": "bad_event_object", "type": etype} - if etype == "checkout.session.completed": - email = _email_from_checkout_session(data_obj) - if not email: - return {"ok": False, "error": "no_email", "type": etype} - payment_status = str(data_obj.get("payment_status") or "").strip().lower() - if payment_status not in {"paid", "no_payment_required"}: - return { - "ok": False, - "error": "not_paid", - "type": etype, - "status": payment_status or "missing", - } - meta = data_obj.get("metadata") if isinstance(data_obj.get("metadata"), dict) else {} - plan_n = _plan_from_metadata(meta) - customer_id = data_obj.get("customer") - if isinstance(customer_id, dict): - customer_id = customer_id.get("id") - user = set_plan( - email, - plan_n, - stripe_customer_id=str(customer_id) if customer_id else None, - ) - action = "plan_portfolio_pro" if plan_n == "portfolio_pro" else "plan_pro" - return {"ok": True, "action": action, "email": email, "user": user, "type": etype, "plan": plan_n} - - if etype in { - "customer.subscription.deleted", - "customer.subscription.paused", - }: - from app.users import find_email_by_stripe_customer - - email = None - meta = data_obj.get("metadata") if isinstance(data_obj.get("metadata"), dict) else {} - if meta.get("email"): - email = str(meta["email"]).strip().lower() - # Fallback: customer_email not always present on subscription objects - if not email and data_obj.get("customer_email"): - email = str(data_obj["customer_email"]).strip().lower() - customer_id = data_obj.get("customer") - if isinstance(customer_id, dict): - customer_id = customer_id.get("id") - if not email and customer_id: - email = find_email_by_stripe_customer(str(customer_id)) - if not email: - return {"ok": False, "error": "no_email", "type": etype} - user = set_plan(email, "free") - return {"ok": True, "action": "plan_free", "email": email, "user": user, "type": etype} - - return {"ok": True, "action": "ignored", "type": etype} + def apply(db): + if etype in {"checkout.session.completed", "checkout.session.async_payment_succeeded"}: + metadata = obj.get("metadata") or {} + product = metadata.get("product") + if product not in {"quantradar_report", "quantradar_pro", "quantradar_portfolio_pro"}: + return {"ok": True, "action": "ignored", "reason": "unrelated_product"} + if obj.get("payment_status") not in {"paid", "no_payment_required"}: + return {"ok": True, "action": "awaiting_payment"} + if product == "quantradar_report": + return paid_delivery.mark_report_paid(db, obj, paid_at=event.get("created")) + if obj.get("mode") != "subscription": + return {"ok": False, "error": "wrong_checkout_mode"} + return _sync_subscription(db, str(obj.get("subscription") or "")) + if etype.startswith("customer.subscription."): + return _sync_subscription(db, str(obj.get("id") or "")) + if etype in {"invoice.paid", "invoice.payment_failed", "invoice.payment_action_required"}: + subscription_id = obj.get("subscription") or ((obj.get("parent") or {}).get("subscription_details") or {}).get("subscription") + if subscription_id: + return _sync_subscription(db, str(subscription_id)) + if etype == "charge.refunded": + return paid_delivery.record_refund(db, obj) + if etype in {"checkout.session.expired", "checkout.session.async_payment_failed"}: + db.execute("UPDATE report_orders SET payment_state='expired' WHERE checkout_id=? AND payment_state='unpaid'", (obj.get("id"),)) + return {"ok": True, "action": "ignored", "type": etype} + + result = paid_delivery.event_once(str(event.get("id") or ""), etype, apply) + if result.get("ok") and result.get("action", "").startswith("plan_") and not result.get("duplicate"): + from app.users import set_plan + set_plan(result["email"], result["plan"], stripe_customer_id=result.get("customer_id")) + return result diff --git a/app/subscription_checkout.py b/app/subscription_checkout.py new file mode 100644 index 0000000..f7a8435 --- /dev/null +++ b/app/subscription_checkout.py @@ -0,0 +1,179 @@ +"""One recoverable subscription checkout per account, across clicks and restarts.""" + +from __future__ import annotations + +import time +import uuid +import urllib.parse + +from app import paid_delivery, stripe_billing as stripe, users + + +class CouponRejected(Exception): + pass + + +def _reconcile_existing(owner: str, customer: str): + """Legacy customers may have paid before the local subscription ledger existed.""" + prices = {stripe.price_id_for_plan("pro", "monthly"), stripe.price_id_for_plan("pro", "yearly"), stripe.price_id_for_plan("portfolio_pro")} + prices.discard("") + path = "subscriptions?customer=" + urllib.parse.quote(customer, safe="") + "&status=all&limit=100" + for _ in range(20): + page = stripe.stripe_get(path) + for subscription in page["data"]: + related = (subscription.get("metadata") or {}).get("product") in {"quantradar_pro", "quantradar_portfolio_pro"} + related = related or any((item.get("price") or {}).get("id") in prices for item in (subscription.get("items") or {}).get("data", [])) + if related and subscription.get("status") not in {"canceled", "incomplete_expired"}: + with paid_delivery.database() as db: + db.execute("BEGIN IMMEDIATE") + result = stripe._sync_subscription(db, subscription["id"], expected_owner=owner) + if not result.get("ok") or result.get("email") != owner: + raise RuntimeError("Existing subscription reconciliation is incomplete") + if not page.get("has_more"): + return + path = path.split("&starting_after=")[0] + "&starting_after=" + urllib.parse.quote(page["data"][-1]["id"], safe="") + raise RuntimeError("Existing subscriptions could not be fully reconciled") + + +def _customer(owner: str) -> str: + existing = (users.get_user(owner) or {}).get("stripe_customer_id") + with paid_delivery.database() as db: + db.execute("INSERT OR IGNORE INTO stripe_customers VALUES(?,?,?)", (owner, uuid.uuid4().hex, existing)) + row = db.execute("SELECT * FROM stripe_customers WHERE owner=?", (owner,)).fetchone() + if row["customer_id"]: + return row["customer_id"] + customer = stripe.stripe_post("customers", {"email": owner, "metadata[product]": "quantradar"}, + idempotency_key="qr-customer-" + row["request_id"]) + if not str(customer.get("id", "")).startswith("cus_"): + raise RuntimeError("Stripe customer creation is incomplete") + with paid_delivery.database() as db: + db.execute("UPDATE stripe_customers SET customer_id=? WHERE owner=? AND customer_id IS NULL", (customer["id"], owner)) + return db.execute("SELECT customer_id FROM stripe_customers WHERE owner=?", (owner,)).fetchone()[0] + + +def _current_attempt(owner: str, plan: str, interval: str, price: str, coupon: str | None, customer: str): + with paid_delivery.database() as db: + db.execute("BEGIN IMMEDIATE") + if db.execute("SELECT 1 FROM subscriptions WHERE owner=? AND status NOT IN ('canceled','incomplete_expired') LIMIT 1", (owner,)).fetchone(): + return None + row = db.execute("SELECT * FROM subscription_checkouts WHERE owner=?", (owner,)).fetchone() + if row is None or row["state"] == "closed": + db.execute("""INSERT INTO subscription_checkouts(owner,id,plan,interval,price_id,coupon_id,customer_id,created_at) + VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(owner) DO UPDATE SET + id=excluded.id,plan=excluded.plan,interval=excluded.interval,price_id=excluded.price_id, + coupon_id=excluded.coupon_id,customer_id=excluded.customer_id,checkout_id=NULL,checkout_url=NULL, + state='creating',created_at=excluded.created_at""", + (owner, "qr-sub-" + uuid.uuid4().hex, plan, interval, price, coupon, customer, time.time())) + row = db.execute("SELECT * FROM subscription_checkouts WHERE owner=?", (owner,)).fetchone() + return dict(row) + + +def _recover_session(attempt: dict) -> dict: + path = "checkout/sessions?customer=" + urllib.parse.quote(attempt["customer_id"], safe="") + "&limit=100" + for _ in range(20): + page = stripe.stripe_get(path) + for session in page["data"]: + if (session.get("metadata") or {}).get("checkout_attempt") == attempt["id"]: + return session + if not page.get("has_more"): + break + path = path.split("&starting_after=")[0] + "&starting_after=" + urllib.parse.quote(page["data"][-1]["id"], safe="") + raise ValueError("A previous checkout needs reconciliation. Please contact support; no new payment was started.") + + +def revoke_coupon_checkouts(coupon_id: str) -> None: + """Finish revocation without creating a checkout or changing a paid subscription.""" + with paid_delivery.database() as db: + attempts = [dict(row) for row in db.execute( + "SELECT * FROM subscription_checkouts WHERE coupon_id=? AND state!='closed'", (coupon_id,))] + for attempt in attempts: + session = (stripe.stripe_get("checkout/sessions/" + urllib.parse.quote(attempt["checkout_id"], safe="")) + if attempt["checkout_id"] else _recover_session(attempt)) + if session.get("status") == "open": + session = stripe.stripe_post("checkout/sessions/" + urllib.parse.quote(session["id"], safe="") + "/expire", {}, + idempotency_key="expire-" + attempt["id"]) + if session.get("status") not in {"expired", "complete"}: + raise ValueError("Discounted checkout revocation is pending") + with paid_delivery.database() as db: + db.execute("UPDATE subscription_checkouts SET checkout_id=?,checkout_url=?,state=? WHERE owner=? AND id=?", + (session["id"], session.get("url"), "closed" if session["status"] == "expired" else "complete", + attempt["owner"], attempt["id"])) + + +def _session(attempt: dict) -> dict: + if attempt["checkout_id"]: + return stripe.stripe_get("checkout/sessions/" + urllib.parse.quote(attempt["checkout_id"], safe="")) + # Stripe may prune idempotency keys after 24h. Never blindly recreate an old uncertain result. + if time.time() - attempt["created_at"] >= 23 * 3600: + return _recover_session(attempt) + try: + session = stripe.create_checkout_session(customer_email=attempt["owner"], customer_id=attempt["customer_id"], + price_id=attempt["price_id"], plan=attempt["plan"], interval=attempt["interval"], + coupon_id=attempt["coupon_id"], idempotency_key=attempt["id"]) + except stripe.StripeRequestError as error: + if not error.coupon_rejected or not attempt["coupon_id"]: + raise + with paid_delivery.database() as db: + db.execute("UPDATE subscription_checkouts SET state='closed' WHERE owner=? AND id=? AND checkout_id IS NULL", (attempt["owner"], attempt["id"])) + raise CouponRejected from error + if not session.get("id") or not session.get("url"): + raise RuntimeError("Checkout creation is incomplete. Retry to recover the same checkout.") + with paid_delivery.database() as db: + updated = db.execute("UPDATE subscription_checkouts SET checkout_id=?,checkout_url=?,state=CASE WHEN state='complete' THEN state ELSE 'open' END WHERE owner=? AND id=?", + (session["id"], session["url"], attempt["owner"], attempt["id"])) + if updated.rowcount != 1: + raise ValueError("Checkout changed in another window. Please retry.") + # Fetch authoritative state: a lost response can hide a completed payment. + return stripe.stripe_get("checkout/sessions/" + urllib.parse.quote(session["id"], safe="")) + + +def start(owner: str, *, plan: str, interval: str, coupon_id: str | None = None) -> dict: + owner = users.normalize_email(owner) + plan = stripe.normalize_checkout_plan(plan) + interval = "yearly" if plan == "pro" and interval.lower() in {"year", "yearly", "annual", "annually"} else "monthly" + price = stripe.price_id_for_plan(plan, interval) + if not price: + raise ValueError("This plan is temporarily unavailable") + customer = _customer(owner) + with paid_delivery.database() as db: + prior = db.execute("SELECT state FROM subscription_checkouts WHERE owner=?", (owner,)).fetchone() + if prior is None or prior["state"] == "closed": + _reconcile_existing(owner, customer) + for _ in range(3): + attempt = _current_attempt(owner, plan, interval, price, coupon_id if plan == "pro" else None, customer) + if attempt is None: + return stripe.create_billing_portal(owner, plan=plan, interval=interval) + try: + session = _session(attempt) + except CouponRejected: + coupon_id = paid_delivery.report_credit(owner) + continue + status = session.get("status") + if status == "complete": + subscription = session.get("subscription") + if not subscription: + raise ValueError("Your payment is being confirmed. Please retry shortly; no second subscription was started.") + with paid_delivery.database() as db: + db.execute("BEGIN IMMEDIATE") + result = stripe._sync_subscription(db, str(subscription), expected_owner=owner) + if not result.get("ok") or result.get("email") != owner or not result.get("action", "").startswith("plan_"): + raise RuntimeError("Subscription confirmation is pending") + if paid_delivery.has_open_subscription(owner): + return stripe.create_billing_portal(owner, plan=plan, interval=interval) + status = "expired" # A completed checkout is not payable again after cancellation. + if status == "open": + # A refund can revoke a credit while Stripe creates or recovers this session. + coupon_id = paid_delivery.report_credit(owner) if plan == "pro" else None + if status == "open" and (attempt["plan"], attempt["interval"], attempt["price_id"], attempt["coupon_id"]) == (plan, interval, price, coupon_id): + if not session.get("url"): + raise RuntimeError("Checkout URL is unavailable") + return {"id": session["id"], "url": session["url"], "plan": plan, "interval": interval} + if status == "open": + expired = stripe.stripe_post("checkout/sessions/" + urllib.parse.quote(session["id"], safe="") + "/expire", {}, + idempotency_key="expire-" + attempt["id"]) + status = expired.get("status") + if status != "expired": + raise ValueError("Previous checkout status is unresolved. Please retry; no new payment was started.") + with paid_delivery.database() as db: + db.execute("UPDATE subscription_checkouts SET state='closed' WHERE owner=? AND id=?", (owner, attempt["id"])) + raise ValueError("Checkout changed in another window. Please retry.") diff --git a/app/users.py b/app/users.py index e663e74..f78b073 100644 --- a/app/users.py +++ b/app/users.py @@ -112,6 +112,11 @@ def public_user(u: dict[str, Any]) -> dict[str, Any]: "name": u.get("name") or None, "plan": u.get("plan") or "free", "created_at": u.get("created_at"), + "watchlist": [str(t).upper() for t in (u.get("watchlist") or [])], + "report_granted": bool(u.get("report_granted")), + "bump_csv_priority": bool(u.get("bump_csv_priority")), + "daily_digest": bool(u.get("daily_digest")), + "report_coupon": u.get("report_coupon") or None, } @@ -272,6 +277,10 @@ def resolve_plan(email: str | None, *, session_plan: str | None = None) -> str: u = get_user(email) if not u: return "free" + from app.paid_delivery import subscription_plan + billed_plan = subscription_plan(email) + if billed_plan is not None: + return billed_plan plan = str(u.get("plan") or "free").strip().lower() return plan if plan in VALID_PLANS else "free" @@ -313,6 +322,37 @@ def set_plan( return public_user(u) +def grant_report(email: str, *, bump: bool = False) -> dict[str, Any]: + """Grant the one-time $9 deep report entitlement (and $5 bump if taken).""" + email_n = normalize_email(email) + with _LOCK: + store = _load() + u = store["users"].get(email_n) + if not isinstance(u, dict): + raise ValueError("no account") + u = dict(u) + u["report_granted"] = True + u["report_granted_at"] = time.time() + if bump: + u["bump_csv_priority"] = True + store["users"][email_n] = u + _save(store) + return public_user(u) + + +def set_report_coupon(email: str, coupon_id: str | None) -> None: + """Remember the once-only $9→Pro credit coupon id for this account.""" + email_n = normalize_email(email) + with _LOCK: + store = _load() + u = store["users"].get(email_n) + if isinstance(u, dict): + u = dict(u) + u["report_coupon"] = coupon_id + store["users"][email_n] = u + _save(store) + + def find_email_by_stripe_customer(customer_id: str | None) -> str | None: """Look up email for a Stripe customer id (cancel webhook fallback).""" cid = (customer_id or "").strip() diff --git a/app/watch_reports.py b/app/watch_reports.py new file mode 100644 index 0000000..4576a47 --- /dev/null +++ b/app/watch_reports.py @@ -0,0 +1,140 @@ +"""Account-owned daily watchlist snapshots, with recoverable per-ticker work.""" + +from __future__ import annotations + +import csv +import io +import json +import time +import uuid + +from app import engagement, paid_delivery, users +from free_engine.market_calendar import completed_session + + +def request_report(owner: str) -> str: + owner = users.normalize_email(owner) + plan = users.resolve_plan(owner) + if not users.is_paid_plan(plan): + raise ValueError("Pro is required to create daily watchlist reports") + cutoff = completed_session() + if cutoff is None: + raise ValueError("Market calendar coverage is unavailable") + roster = engagement.get_watchlist(owner)[:engagement.WATCH_LIMITS[plan]] + if not roster: + raise ValueError("Add a ticker to your watchlist first") + with paid_delivery.database() as db: + db.execute("""INSERT OR IGNORE INTO watch_reports + (id,owner,as_of,roster,created_at,csv_enabled) VALUES(?,?,?,?,?,?)""", + (uuid.uuid4().hex, owner, cutoff, json.dumps(roster), time.time(), int(plan == "portfolio_pro"))) + return db.execute("SELECT id FROM watch_reports WHERE owner=? AND as_of=?", (owner, cutoff)).fetchone()[0] + + +def schedule_optins(): + for account in engagement.digest_optins(): + if users.is_paid_plan(users.resolve_plan(account["email"])): + try: + request_report(account["email"]) + except ValueError: + pass + + +def _public(row) -> dict: + results = json.loads(row["results"]) + roster = json.loads(row["roster"]) + return { + "id": row["id"], "as_of": row["as_of"], "created_at": row["created_at"], + "state": row["state"], "tickers": roster, "total": len(roster), + "processed": len(results), "ready": sum(r["status"] == "ready" for r in results.values()), + "csv_enabled": bool(row["csv_enabled"]), + "rows": [{k: v for k, v in results[ticker].items() if k != "attempts"} + for ticker in roster if ticker in results], + "note": "A daily watchlist snapshot, not portfolio returns or trade recommendations. Changes compare prior saved snapshots. Missing data remains UNKNOWN.", + } + + +def list_reports(owner: str) -> list[dict]: + with paid_delivery.database() as db: + return [_public(row) for row in db.execute("SELECT * FROM watch_reports WHERE owner=? ORDER BY as_of DESC LIMIT 30", (users.normalize_email(owner),))] + + +def get_report(owner: str, report_id: str) -> dict | None: + with paid_delivery.database() as db: + row = db.execute("SELECT * FROM watch_reports WHERE owner=? AND id=?", (users.normalize_email(owner), report_id)).fetchone() + return _public(row) if row else None + + +def csv_export(report: dict) -> str: + output = io.StringIO(newline="") + fields = ("ticker", "as_of", "status", "close", "score", "action", "market_gate", "previous_action", "changed", "reliability", "note") + writer = csv.DictWriter(output, fieldnames=fields, extrasaction="ignore") + writer.writeheader() + writer.writerows(report["rows"]) + return output.getvalue() + + +def process_next(*, now: float | None = None) -> bool: + from app.charts_facade import run_fetch_all + from app.quality import assess_charts_payload + + now = time.time() if now is None else now + with paid_delivery.database() as db: + db.execute("BEGIN IMMEDIATE") + job = db.execute("""SELECT * FROM watch_reports WHERE state IN ('queued','building','retrying') + AND retry_at<=? AND lease_until<=? ORDER BY created_at LIMIT 1""", (now, now)).fetchone() + if job is None: + return False + roster, results = json.loads(job["roster"]), json.loads(job["results"]) + candidates = [t for t in roster if t not in results] or [t for t in roster if results[t]["status"] != "ready" and results[t]["attempts"] < 3] + if not candidates: + state = "ready" if all(r["status"] == "ready" for r in results.values()) else "partial" + db.execute("UPDATE watch_reports SET state=?,lease_until=0,lease_token=NULL WHERE id=?", (state, job["id"])) + return True + ticker = candidates[0] + attempt = results.get(ticker, {}).get("attempts", 0) + 1 + results[ticker] = {**results.get(ticker, {}), "ticker": ticker, "as_of": job["as_of"], + "status": "unavailable", "action": "UNKNOWN", "score": None, + "note": "Data fetch is pending or interrupted.", "attempts": attempt} + lease_token = uuid.uuid4().hex + db.execute("UPDATE watch_reports SET state='building',lease_until=?,lease_token=?,results=? WHERE id=?", + (now + 120, lease_token, json.dumps(results), job["id"])) + previous = db.execute("SELECT results FROM watch_reports WHERE owner=? AND as_of 2026-07-12 定版。之后部署/改版只认本文件,避免 Manus / 仓库 / 线上三套混谈。 +核验日期:2026-09-06。此文件取代此前的 Render / Manus 部署说明。 -## 1. 唯一权威源(SSOT) +## 当前生产 -| 项 | 值 | -|----|-----| -| 代码 | `https://github.com/Alexaliao001/quantradar` **main** | -| 当前基线 | `d8132cc` · **v0.6.0**(path-C 壳 + 邮箱密码登录 + Trust Gate) | -| 引擎 | `~/charts`(stock-charts),壳**不算**分、不抄 generate_charts | -| **不是**权威 | Manus 任务里的旧 SPA、Manus 独有 diff、trycloudflare 临时隧道 | +| 项目 | 已核验值 | +|---|---| +| 代码仓库 | https://github.com/Alexaliao001/quantradar | +| 网站 | https://quantradar.one ,www 同源 | +| 主机 | Nube VPS,SSH alias `nube-sin` | +| Web 进程 | systemd `quantradar`,`www-data`,Python 3.12 `python3 -m app` | +| 工作目录 | `/opt/nube-sites/apps/quantradar` | +| 线上独立引擎目录 | `/opt/nube-sites/apps/charts-engine`,由 `CHARTS_DIR` 指定 | +| 环境 | `/opt/nube-sites/secrets/quantradar.env`;不进入 Git、日志或报告 | +| 代理 | Caddy → `127.0.0.1:8765` | +| 数据 | 工作目录的 `data/`;新版本增加 `billing.sqlite3`(WAL) | +| 目前线上 health | v0.7.0,git_sha=null,live;尚未发布本次修复 | -```text -写代码 → 只改本机仓库 → 本机打磨(docs/LOCAL_POLISH.md) - → commit + push GitHub main -跑线上 → Render Blueprint / Docker 自部署(docs/SELF_DEPLOY.md) -域名 → DNS 指向 Render(GlobalDomain / Cloudflare) -禁止 → Manus 当运行时 / Manus pull 发布 / Manus agent 修产品 -``` +`git_sha=null` 无法证明线上等于某个提交。待发布包加入 `SOURCE_HEAD` 后,必须核对本机提交、包清单和线上 health 三者一致。无需变更 DNS。 -## 2. 明确:线上 ≠ 仓库 +## 当前发布前条件 -| 表面 | 是什么 | 是否最新仓库 | -|------|--------|--------------| -| GitHub main | Grok 建设的 path-C 壳 | ✅ 是 | -| `127.0.0.1:8765` | 本地 `python -m app` | ✅ 应与仓库一致 | -| `quantradar.one` | Manus 旧 SPA(tRPC / app-auth / 假统计) | ❌ **不是** | +1. 数据许可须覆盖网页、iOS、计算结果及拟出售的 JSON/CSV 下载。公开 API 可访问不等于已有商用授权;见 `DATA_LICENSING.md`。 +2. Stripe 测试环境必须验证实际结账、签名 webhook、交付、退款、续费、取消、计划切换。本轮已通过真实 sandbox 验收,见 `docs/audit/2026-09-06/stripe-sandbox.json`。`scripts/audit_billing.py` 拒绝 live key,仅验证金额;生命周期另由浏览器、签名事件和 Test Clock 验证。 +3. 正式账户核对遗留 Payment Links、价格、Portal 和 webhook。Portfolio Pro 价格尚未接入,禁止仅靠页面展示宣称可购买。 +4. 完成 Python 隔离测试、iOS 测试、375/1440 页面验收、签名归档检查。 -验收是否 cutover 成功,只看: +已准备独立正式 Portal `bpc_1UCTPA7uBhbslGrGuE1dZAda`,不是默认配置,尚未写入生产环境。允许 Pro 月/年、Portfolio 月切换,差额即时开票,期末取消。正式环境应通过 `STRIPE_PORTAL_CONFIGURATION_ID` 明确选择它。当前 webhook `we_1U6uvl7uBhbslGrG01EauQuf` 仍只有四种旧事件;须随新版一同补全事件配置,不能将已创建 Portal 当作已完成生产接线。 -```bash -curl -sS https://quantradar.one/health -# 必须: "service":"quantradar-shell" 且 "manus_login":false -# 且 version/git_sha 对齐 GitHub main -``` +## 发布步骤 -## 3. 最正确部署路径(默认走这条) +1. `python3 scripts/test_isolated.py`;生产或真实账户目录不要直接运行 unittest。 +2. 提交并推送经复核的源码。`python3 scripts/package_release.py /absolute/private/output-directory` 只打包已提交的运行文件,拒绝脏工作区;包里不含 `.env`、`data`、缓存或 iOS 签名材料。 +3. 上传至 `/opt/nube-sites/releases/quantradar-/`,先核对 SHA-256 和展开后的 `SHA256SUMS`,不可直接覆盖线上目录。 +4. 短暂停止 `quantradar` 后,将当前 Web、独立引擎和环境完整备份到专属私有目录,下载一份到独立主机。确认 JSON 可读;如有 SQLite,使用 backup API 或同时保留一致的主库/WAL。热拷贝只作准备性备份,不能替代停机一致备份。 +5. 仅替换 `app/`、`static/`、`free_engine/`、`schemas/`、`fixtures/`、`requirements.txt`、`SOURCE_HEAD` 和清单。同步独立引擎全部 Python 与交易日历 JSON,保持同一提交;保留 `.cache`、`data/` 与 `.env`。 +6. 保持 `www-data` 可写数据和引擎缓存,秘密权限不放宽。启动服务,检查本地与公网 `/health` 均为 JSON、提交一致;检查页面、401/404 账号隔离、真实数据日期及完整下载。 +7. 实际支付验收在测试环境先完成,再确认正式价格、Portal、签名 webhook 配置。真实客户付款不作为自动化测试手段。 -**不要用 Manus 当应用运行时。** Manus 不适合长期跑 path-C Python 壳;且烧 Lite/Max 额度、易分叉。 +## 回滚 -### Manus(用户锁定 · 2026-07-26) +回滚仅恢复前一版本的代码和引擎。不得以旧 `data/` 覆盖新订单、账号、退款或订阅状态。若需要数据迁移回滚,先备份现有数据库并单独设计迁移;不要自动恢复旧付款快照。 -- **产品发布与运行时:完全不用 Manus** -- 开发 / 打磨 / 修 bug:只在本机 + GitHub -- 线上:Render(或 Fly/Docker)自部署 — 见 `docs/SELF_DEPLOY.md` -- Manus 若仍绑着 `quantradar.one`:**只解绑域名**(控制台点一下,零 agent) +## iOS 独立发布 -### 推荐顺序 +网页 Stripe 与 Apple Unlock 权限独立。build 8(App/Widget 1.2.0)已归档、签名及日历校验通过,并上传 App Store Connect,处理状态 VALID(build ID `719a2f67-d5c3-49c3-ab98-7886189b0fe2`)。App Store Connect 现有版本 1.0 / build 7 为 WAITING_FOR_REVIEW,不能描述为已上架。确认新 build 上传并 VALID 后,才替换原审核构建;新审核提交需同时保留现有 IAP 项,不能只提交 App。 -```text -① 本机 PORT=8765 打磨到 docs/LOCAL_POLISH.md 全绿 -② commit + push GitHub main -③ Render Blueprint 部署(python -m app) -④ DNS → Render;解绑 Manus 旧 SPA(若有) -⑤ curl /health + p0_smoke --live + TRUST_GATE -``` +## 证据 -### 环境变量(生产) - -```bash -HOST=0.0.0.0 -# PORT 由平台注入 -QUANTRADAR_MODE=artifact -PUBLIC_BASE_URL=https://quantradar.one -SESSION_SECRET=<随机长串> -QUANTRADAR_BOOTSTRAP_DEMO=0 # 生产关掉 demo 管理员 -QUANTRADAR_DEV_LOGIN=0 -# 可选:ALLOW_REGISTER=1 -# 以后再加:GOOGLE_*、STRIPE、SMTP -``` - -### 本机对照(开发) - -```bash -cd ~/quantradar -git pull -python3 -m app -# http://127.0.0.1:8765/login — 邮箱密码 -``` - -### 临时公网预览(可选,非生产) - -```bash -cloudflared tunnel --url http://127.0.0.1:8765 -``` - -仅用于验收,**不要**写死进 DNS。 - -## 4. Manus 的正确角色(尽量少用) - -| 做 | 不做 | -|----|------| -| 若域名仍挂在 Manus:在设置里**解绑/停发**旧 SPA | 不在 Manus 里当 SSOT 改业务代码 | -| 需要时 **Lite 一句**:停旧站 / 查域名绑定 | 不用 Max 长任务重构部署 | -| | 不搞 wrangler / Worker 硬扛 Python | -| | 不接 Manus app-auth | - -## 5. 认证策略(产品) - -1. **现在**:自建邮箱+密码(`/login`,`data/users.json`) -2. **以后**:再加 Google OAuth(env 配齐即显示) -3. **永远不要**:`manus.im/app-auth` - -## 6. 禁止事项 - -- 把 Manus 任务输出当成仓库真相 -- 在 Manus 大改却不 push 回 GitHub -- 未改 DNS 就宣称「已发布到 quantradar.one」 -- 同时维护 tRPC SPA 与 path-C 两套公式 - -## 7. Cutover 完成检查单 - -- [ ] `git -C ~/quantradar log -1` 与 origin/main 一致 -- [ ] 托管进程 `python -m app`,health = `quantradar-shell` -- [ ] `quantradar.one/health` 同上 + `git_sha` 对齐 -- [ ] `/login` 邮箱密码可用;`/api/oauth/*` = 410 -- [ ] 无假 1247/896;单 `primary_score`;demo 不计费 -- [ ] `docs/TRUST_GATE.md` 项通过 +本次本地验收:`docs/audit/2026-09-06/README.md`。生产切换后追加新的发布回执,不把本地测试结论改写成生产验证。 diff --git a/docs/DATA_LICENSING.md b/docs/DATA_LICENSING.md new file mode 100644 index 0000000..9b48513 --- /dev/null +++ b/docs/DATA_LICENSING.md @@ -0,0 +1,34 @@ +# 数据授权与成本核实 + +核验日期:2026-09-06。以下是官方公开资料和项目内查找结果,不代表已签合同。当前未在项目文档找到 Yahoo/Nasdaq 商用及再分发授权;已向项目所有者询问是否另有授权。 + +Nasdaq 当前条款第 6/7 节对个人非商用、出售/分发及衍生用途有限制,不能从公开端点可访问推定可售卖数据报告:[Nasdaq Legal](https://www.nasdaq.com/legal)。Yahoo 也需确认适用授权,而非仅依赖项目代码的 MIT 许可。 + +## 候选方案(未购买,Marketstack 已询问) + +| 方案 | 官网月付起价 | 与当前产品相关的限制 | +|---|---:|---| +| Marketstack Basic | $9.99/月 | 定价页明确列 Commercial Use;网页/iOS 展示、JSON/CSV 再分发和订阅终止后已购文件保留权仍待确认 | +| Twelve Data Venture | $499/月 | 面向客户的商业展示;JSON/CSV 再分发仍需单独书面约定 | +| Twelve Data Enterprise | $1,099/月 | 页面列外部分发能力,但具体数据、区域、下载和交易所费用仍须合同确认 | +| Massive Stocks Business | $2,499/月 | 商用/展示、FMV 盘中及 SIP 日终;下载、再分发、历史日线完整性需按实际用例确认 | + +来源:[Twelve Data 商业价格](https://twelvedata.com/pricing-business)、[商业与个人用途](https://support.twelvedata.com/en/articles/5332349-commercial-and-personal-usage)、[条款](https://twelvedata.com/terms)、[Massive Stocks](https://massive.com/stocks)。官网价不是 QuantRadar 已取得的完整报价。补充来源:[Marketstack 定价](https://marketstack.com/pricing/)、[服务协议](https://marketstack.com/agreement)。Marketstack 的更低标价说明不能把 $499 当作商用数据的市场最低成本;但也不能仅凭 Commercial Use 标签宣称已取得付费 CSV/JSON 再分发权。 + +仅以 $29/月毛收入覆盖表中数据月费,依次至少需要 1 / 18 / 38 / 87 个订阅;以 $99/月则需 1 / 6 / 12 / 26 个。此算式未计支付手续费、退款、税、服务器、支持、获客及附加许可,不能作为净利润预测。 + +## 需要明确写入供应商答复的用例 + +- 美国股票与 ETF 完整日终 OHLCV,至少 139 个交易日,含 SPY/行业 ETF;覆盖与复权方法不能静默变更。 +- 网页和 iOS 客户端展示、服务器缓存、机械评分及历史重建。 +- 付费单股 JSON/CSV、4 张图、观察列表 JSON/CSV;允许范围、保留期限、终止后的已购文件访问。 +- 免费来宾、注册用户、订阅用户、网站域名和 iOS 包名,是否按用户、交易所或设备另计费用。 +- 历史数据及衍生结果是否可转出;第三方署名、归属、记录和删除要求。 + +优先确认低成本日终商用授权及下载边界。现阶段不据此购买高额订阅,也不在授权未明时扩大收费数据分发。若只有展示许可,应先调整交付承诺和价格,再上线相应版本。 + +补充排查:[EODHD 商用说明](https://eodhd.com/financial-apis/commercial-vs-personal-license-use)明确其一般定价页套餐仅供个人使用,商用需另行报价,不能把个人 API 套餐当作可售卖报告的授权。未购买套餐。 + +已于 2026-09-06 07:15(Asia/Shanghai)从公司 Outlook 邮箱向 APILayer 官方支持邮箱发送 [Marketstack 授权询问](MARKETSTACK_LICENSE_INQUIRY.md),并核对发送记录,当前等待书面答复。最新 APILayer 套餐页将 $9.99/月方案称为 Starter,因此询问中使用 Basic/Starter,见 [官方套餐页](https://app.apilayer.com/signup/marketstack/starter)。其官网页脚另链接 [APILayer 法律条款入口](https://www.ideracorp.com/legal/APILayer);实际许可须与订单和数据使用范围一并确认。 + +APILayer 已自动确认工单 **307916**,说明周一办公时间处理;尚无实质授权或报价答复。 diff --git a/docs/MARKETSTACK_LICENSE_INQUIRY.md b/docs/MARKETSTACK_LICENSE_INQUIRY.md new file mode 100644 index 0000000..6a09a1a --- /dev/null +++ b/docs/MARKETSTACK_LICENSE_INQUIRY.md @@ -0,0 +1,23 @@ +# Marketstack 授权询问(已发送,待答复) + +项目所有者要求继续完全处理后,已于 2026-09-06 07:15:28(Asia/Shanghai)从 `fortuneinsight@outlook.com` 发送至 `support@apilayer.com`,并通过 Outlook 搜索核对收件人、主题、正文和发送时间。收件地址来自 [APILayer 官方支持说明](https://blog.apilayer.com/introducing-apilayer-platinum-support-api-experience/)。当前仅完成询问发送,尚未收到授权或报价,没有购买套餐。 + +07:15:41 收到自动回执,工单 **307916**;供应商说明周一办公时间处理。自动回执不构成许可批准。以下保留已发送正文。 + +Subject: Confirm commercial display and downloadable-report rights for QuantRadar + +Hello Marketstack team, + +We are evaluating the $9.99/month Basic/Starter plan for QuantRadar (quantradar.one), a US-stock research website and native iOS app from Fortune Insight, LLC. Your pricing page lists Commercial Use. Before subscribing, please confirm in writing whether this plan covers: + +- Display of US equity and ETF daily-close history, including SPY and sector ETFs, to free and paid users on web and iOS. +- Server-side caching and mechanical scoring/reconstruction from at least 139 trading sessions, without exposing our API key. +- One-time paid reports containing price/volume history in JSON, charts and optional CSV; paid watchlist snapshots with JSON/CSV exports for up to 50 stocks per account. +- Continued access to previously purchased reports if a customer's subscription ends, and retention rights if our Marketstack subscription ends. + +Please specify any required attribution, end-user or exchange permissions, redistribution addendum, added fees and request-counting rules. Does this plan include complete US end-of-day volume and split-adjusted history for these uses? + +If any item requires a different license, please quote the lowest-cost suitable option. This is an inquiry only, not an order or acceptance of a subscription. + +Thank you, +QuantRadar / Fortune Insight, LLC diff --git a/docs/MONETIZATION_LADDER.md b/docs/MONETIZATION_LADDER.md new file mode 100644 index 0000000..c4e247b --- /dev/null +++ b/docs/MONETIZATION_LADDER.md @@ -0,0 +1,156 @@ +# Monetization Ladder — 层层递进的商业化设计(v1) + +> 日期:2026-09-02 · 产品:quantradar.one + iOS QuantRadar +> 原则:**利用人性,但站在长期信任一侧。** 所有杠杆只用真实价值+真实心理,不用欺骗。 +> 原因不是道德说教,而是利润约束:FTC 2025-26 正严打欺骗性漏斗(见红线章),一击即罚款+退款; +> 而本品牌资产就是"诚实"(no fake social proof / fail-closed)。信任→留存→LTV,长期收益高于一次性收割。 + +--- + +## 0. 研究依据(摘要) + +### 行为科学(可用杠杆,含量化基准) + +| 杠杆 | 机制 | 证据/基准 | +|---|---|---| +| 承诺一致性 | 先拿小承诺(注册/加自选/首单$9),大承诺(订阅)概率显著上升 | Human N A/B:购买拆两步+先小承诺 → 订阅 +31.26%;Cialdini 六原则之一 | +| 支付即筛选 | 付过哪怕 $7 的人,自我身份从"浏览者"变"客户" | 价值阶梯研究:小额首单是"终极意图信号",优于一切免费试用线索 | +| 损失厌恶/禀赋 | 体验过价值后,失去它=损失;已付的 $9 不用掉=浪费 | 价值阶梯:upsell 成为"保护已有投入"的自然延伸 | +| 显著性/默认 | 用户漏看优惠、预测错未来需求 → 要在"对的时刻"把对的offer放显著位 | NBER/Lyft RCT:15% 高需求用户本可省钱却漏订;时机与显著性决定转化 | +| 互惠 | 先给真价值,再要回报 | 免费 live 扫描即互惠入口 | + +健康基准(价值阶梯研究):order bump 接受率 25–35%;首单→订阅即时升级 20–30%; +downsell 捕获 15–25%;14 天邮件序列再转化 10–15%。合计前端买家 35–45% 转订阅 +(对比:freemium 2–3%,标准免费试用 14–18%)。 + +### 监管红线(FTC Negative Option / ROSCA,2025–2026 执法清单) + +2025 年以来 FTC 新诉 5 案、和解 6 案:Amazon、Instacart、Chegg、Match、Uber、JustAnswer、Adobe、RagingBull(交易类 App!)等。 +投诉量 2020 年 33/天 → 2025 年 90+/天。被执法的行为: + +1. **未获明确知情同意就收费**(预勾选付费框、trial 静默转扣费)— ROSCA 核心 +2. **误导性陈述**(假倒计时、假"剩余名额"、假社会证明、夸大的业绩宣称) +3. **取消困难**(取消路径比开通复杂、挽留墙阻拦)— "Click to Cancel" 精神 +4. 金融类额外:业绩/胜率宣称需可审计;假设性锚点必须标注"例算" + +**本设计的每一条机制都必须通过第 4 章红线自检。** + +--- + +## 1. 七层阶梯总览 + +``` +L0 互惠引流($0,无需登录) 免费 live 任意标的 + 分享循环 + 今日门控总览 +L1 微承诺($0,行为承诺) 注册→限额↑;加自选;订每日雷达邮件 +L2 破冰首单($9 一次性) 单标的深度报告;$9 可抵 Pro 首月;结账 order bump +$5 +L3 核心订阅($29/月) 触发器:规避时刻 / 自选上限 / 7天免卡体验(禀赋) +L4 扩张($249/年 + 附加) 真实数学触发年付;CSV/优先队列附加 +L5 高客单($99/月 Pro+ 或咨询) 重度用户;后置 +iOS 平行收银台 $9.99 一次性解锁;与网页互不解锁(既有承诺),场景化互推 +``` + +每层只做一件事:**让用户以最小风险说"是",并自然看见下一层。** + +--- + +## 2. 各层详细设计 + +### L0 互惠引流 — "先给真价值" +- 免费 live 扫描任意标的(已上线)= 互惠 + 意图筛选的起点 +- 分享循环 `/?live=T`(已上线)= 免费获客 +- **新增 /today「今日门控总览」**:每天批量跑一次(复用缓存),公开页展示: + 今日扫描 N 只 → 通过三层门控 M 只(列前 5,其余需注册)。 + 人性:好奇心缺口 + 真实社会证明(数字是真的,可审计)。 + 成本:每日一次批处理,全部走免费源缓存。 + +### L1 微承诺 — "让他成为使用者" +- 注册(免费)→ 更高限额(已上线)。文案不说"注册享更多",说真话:"注册防止滥用,限额更高" +- **网页版自选股(新增,iOS 已有)**:第一次"加自选"=关键小承诺,埋点 watchlist_add +- **每日雷达邮件(新增发送通道,归档已有)**:订邮件=留下触达权; + 邮件内容=今日门控总览+用户自选的状态变化(真实事件,无事件不发=诚实差异点) + +### L2 破冰首单 — "$9 打破支付屏障" +- **新 SKU:单标的深度报告 $9 一次性**(Stripe one-time): + 内容=完整合约 JSON + 全部图表 + 90 天姿态回放 + CSV。真实交付,无空气。 +- **一致性杠杆**:购买后 upsell 页:"Pro 首月 $29,这 $9 全额抵扣 — 7 天内有效"。 + 损失厌恶:不升级=丢掉已付的 $9 抵扣权(真实、限时、明示)。 +- **Order bump(结账页加购,不预勾选)**:"+$5 加 CSV 导出 & 优先扫描队列"。基准接受率 25–35%。 +- Downsell:拒绝 upsell 者不追骚扰,14 天内 2 封邮件(真实事件触发,非连发)。 + +### L3 核心订阅 — "在对的时刻要 Pro" +触发器(全部真实事件,非弹窗骚扰): +1. **规避时刻**(最强):用户历史扫过的 NO 标的后续下跌 ≥5% → 结果页/邮件展示 + "雷达曾让你避开这次入场"(真实事件,/track 账本)→ Pro CTA。这是诚实品牌独有的转化核弹。 +2. **自选上限**:免费 1 只自选、Pro 10 只。第 2 只加自选时提示(真实功能差,非假模糊)。 +3. **禀赋体验**:7 天 Pro 全功能,**不绑卡**,到期询问(endowment effect,且零 ROSCA 风险)。 + 禁止:绑卡试用静默转扣费。 +- 锚点既有:"$249/年 ≈ 一次避免 −8%($3k 仓位)≈ 8 个月 Pro"——保留"例算"标注。 + +### L4 扩张 — "真实数学推年付" +- 月付满 2 个月(已付 $58)→ 展示:"继续月付今年还需 $X;年付从今起更省 $Y"(真实计算) +- 附加:CSV/API 导出、优先队列、额外自选槽(单卖或打包) + +### L5 高客单 — 后置 +- Pro+ $99/月(全附加+每周深度报告)或 1:1 仓位雷达配置咨询。等 L3 留存稳定后再做。 + +### iOS 平行线 +- $9.99 一次性(已上线)。网页结果页脚:"手机上看?iOS 一次买断 $9.99"——场景化互推,不互解锁(既有承诺)。 + +--- + +## 3. 结果页转化设计(核心战场) + +现有区块保持诚实判定不变;在 verdict 之后插入三个**真实**转化层: + +1. **信号变化提醒块** + 免费可见:"此姿态将在下一收盘刷新;翻转时免费版不通知。" + CTA(次级按钮):"获取翻转提醒 — Pro"。= 真实限制 + 损失厌恶。 +2. **90 天姿态回放块** + 免费可见:最近 5 日真实迷你走势 + 当前姿态;完整 90 天=Pro。 + = 真实部分信息(给真的小样),**绝不假模糊/假数字**。 +3. **规避账本块**(有真实事件时) + "你扫过的 X 在 NO 后下跌 7.2% — 雷达让你避开了一次坏入场。" + /track 链接。 + = 信任+损失厌恶+真实社会证明三合一。 + +**按钮顺序(承诺阶梯,研究:先小承诺 +31%):** +主按钮=微承诺(加自选 / 订提醒意向);次=Pro;末=分享。 +**不把付费放第一位**——先让他说小"是"。 + +文案纪律:所有锚点标"例算";所有数字必须来自真实账本;无事件不造事件。 + +--- + +## 4. 红线自检表(每条机制上线前必过) + +| 检查 | 规则 | +|---|---| +| 同意 | 付费框不预勾选;trial 不绑卡或绑卡前单独明示同意(ROSCA) | +| 稀缺 | 只用真实稀缺(限额/抵扣时限),无假倒计时/假名额/假"他人正在看" | +| 取消 | 一键取消,取消路径 ≤ 开通路径(现有承诺保持) | +| 业绩 | 无胜率/收益承诺;"avoided −8%" 永远是假设例算 | +| 数据 | 免费小样=真实数据;不模糊伪造 | +| 邮件 | 真实事件触发;无事件不发;一键退订 | + +--- + +## 5. 指标与实验 + +漏斗事件:scan → register → watchlist_add → email_optin → deep_report_buy($9) +→ pro_start → annual_upgrade;以及 avoidance_moment_shown → pro_start(核心假设)。 + +基准目标(研究值,先跑 30 天再校准):bump 25%、首单→Pro 20%、downsell 15%。 + +实验顺序: +1. A/B:结果页转化层 开/关 → pro_start 率 +2. L2 定价 $7 / $9 / $17 +3. 规避时刻 CTA 文案 A/B + +--- + +## 6. 实施优先级 + +1. **结果页转化层 + 规避账本触发**(零新 SKU,最大杠杆,复用 /track) +2. **网页自选股 + 上限门**(微承诺基础设施) +3. **$9 深度报告 + 抵扣 + bump**(Stripe one-time 已有能力) +4. **每日雷达邮件**(发送通道) +5. /today 门控总览、年付数学触发、L5 —— 后置 diff --git a/docs/PRO_VALUE.md b/docs/PRO_VALUE.md index 743da11..b29b8bf 100644 --- a/docs/PRO_VALUE.md +++ b/docs/PRO_VALUE.md @@ -1,57 +1,25 @@ -# Pro value adjudication (QD1-0) +# 付费价值与验收边界 -> **Verdict: B — supporter price until charts are mounted.** -> Date: 2026-07-16 · Host: `quantradar.one` (Render Free `quantradar-shell`) +2026-09-06:以下是已实现、待发布的产品定义。生产仍为先前版本,发布状态见 `CORRECT_OPS.md`。 -## Decision +| 权益 | Free | Pro | Portfolio Pro | +|---|---|---|---| +| 完成交易日扫描 | 有,来宾较严限流 | 有,较高限额 | 有,较高限额 | +| 保存股票 | 1 | 10 | 50 | +| 历史机械重建 | 5 个交易日 | 至多 90 个 | 至多 90 个 | +| 每日观察列表快照 | 无 | 站内 JSON | 站内 JSON + CSV | +| 订阅标价 | 免费 | $29/月或 $249/年 | $99/月 | -| Question | Answer | -|----------|--------| -| Can this production host mount `~/charts` and run `mode=live` today? | **No** | -| May we sell Pro as “live desk available now”? | **No** | -| What is Pro today? | **Supporter plan**: session + higher limits + **automatic live unlock** when `/health.charts_status == "mounted"` | -| When does verdict flip to A? | Paid/always-on host with real `CHARTS_DIR` + `fetch_all.py` + Massive/Polygon key (never in git) + smoke Pro live without artifact fallback | +单股报告 $9 一次性付款,绑定股票、日期、不可变输入。付款后生成 JSON、4 张图和 90 个交易日机械重建;可选 $5 CSV/队列优先,加购默认不勾选。覆盖不足时先拒绝结账。实际付款后 7 天内可用一次 $9 Pro 抵扣;退款、过期、已用券不应重新发放。 -## Production evidence (2026-07-16) +每日快照需订阅有效且主动启用。中断后继续处理,最多三次取数;缺失明确标记。恢复历史日期时不拿今天的行业/财报条件冒充历史条件。已保存的已购内容在降级后保留,账号间不能访问。 -`GET https://quantradar.one/health`: +这些价值是研究整理和复盘,不能当作盈利、已发信号回测、仓位建议或实时行情。JSON/CSV 再分发和商业展示须取得明确数据权限,见 `DATA_LICENSING.md`。Email 尚未配置,不销售自动邮件交付。 -| Field | Value | -|-------|-------| -| `charts_status` | `artifact_only` | -| `charts_reachable` | `false` | -| `fetch_all_present` | `false` | -| `data_path` | `artifact_fixtures` | -| `mode_default` | `artifact` | +## 商业验收 -Matches `docs/SITES_LIVE.md` / `render.yaml` (`plan: free`, `QUANTRADAR_MODE=artifact`, no charts tree in Dockerfile). +目前无证据证明订阅能持续盈利。Stripe 历史域名关联核查有一笔 $0.99 已付 Checkout,对应订阅已取消;当前产品标记范围未发现活跃订阅或已付 Checkout。此结果不是整个 Stripe 账户的收入报表。 -## Why Free cannot run live (honest constraints) +先确认数据许可和完整支付交付,再用真实客户验证:首批 10 位付费用户,逐个记录购买来源、首次成功报告、7/28 日回访、续订与退款原因。此人数是实验目标,不是现有用户或统计保证。 -1. **No charts tree** on the shell image — only `fixtures/charts_sample`. -2. **Charts deps** (pandas/matplotlib/requests) are outside the stdlib-only shell design. -3. **Cold start + 10–20s fetch** is a bad fit for Render Free sleep. -4. **Massive/Polygon personal keys** must not power a public raw-feed product (conclusions-only policy). - -## Path to verdict A (later ops) - -Document only — not implemented on Free: - -1. Separate always-on host (or paid plan) with disk + charts checkout. -2. Set `CHARTS_DIR` to a directory containing `fetch_all.py`. -3. Inject `POLYGON_API_KEY` on that host only (never commit). -4. Keep default `QUANTRADAR_MODE=artifact`; allow Pro `mode=live`. -5. Accept only when `/health` shows `charts_status=mounted` **and** a Pro session live run returns non-artifact data without silent fake scores. - -Until then, UI + `/health.pro_value` stay on **`supporter_until_mount`**. - -## Product copy (locked by this verdict) - -- Free = frozen demo artifacts. Not a live market feed. -- Pro = supporter price ($29/mo · $249/yr). Live **auto-unlocks** when engine is mounted — not sold as available on this host today. -- Server gates (login + `plan=pro` for live) remain so A can turn on without a billing rewrite. - -## Related backlog - -- QD5-0 / QR2-1 — free OHLCV refresh path (Yahoo) so Pro has tangible refresh value without Massive. -- QD1-1 — Stripe production prices/webhook (money path; value stance is this doc). +不靠回测收益、避免亏损金额、虚假人数或人工倒计时催单。获客文案围绕“每天查看持有关注股票的变化”;外部发送仍需明确授权。停止增加功能,直到观察到首批真实使用与流失原因。 diff --git a/docs/audit/2026-09-06/README.md b/docs/audit/2026-09-06/README.md new file mode 100644 index 0000000..e856dfa --- /dev/null +++ b/docs/audit/2026-09-06/README.md @@ -0,0 +1,128 @@ +# QuantRadar 本次验收记录 + +日期:2026-09-06。状态:Web 修复及真实 Stripe 测试环境验收完成;iOS 1.2.0(9)于 17:45 重新送审,App 与 Unlock 内购均为 WAITING_FOR_REVIEW。StoreKit 购买验收和数据授权尚待完成,商业全量发布尚未完成。 + +## 改动与用途 + +- Web / iOS / Widget 只使用完成的交易日;纽约节假日与提前收市共用 2026–2028 日历,缺数据为 UNKNOWN,SPY/个股日期对齐。Yahoo 两个入口只算一个来源;跨收市缓存不能掩盖旧数据。 +- Web 并发取数限制为 2 个进程,相同请求合并与短时缓存。账号隔离的扫描记录保存实际收盘日期及价格,不用“扫描后收益”包装日线差值。 +- 单股报告在结账前验证 90 日重建所需覆盖并冻结输入;SQLite 记录订单、付款、退款和事件。异步生成 JSON/图表/CSV,重试及 worker lease 避免迟到结果覆盖交付。 +- 每个账号只有一个可恢复的订阅结账尝试,网络失败/多次点击/更换套餐不会盲目新建第二个可付 Session;旧订阅先与 Stripe 对账。 +- 真实退款测试发现旧优惠结账链接会阻塞再次订阅:现在返回链接前重新核对价格和当前抵扣资格;退款 worker 确认未完成的优惠 Checkout 已失效后才完成撤销,网络失败可重试。 +- Pro 10 / Portfolio 50 股票每日快照、姿态比较、JSON 和 Portfolio CSV;保存后降级仍可访问。自动生成需主动启用,邮件不在当前交付范围。 +- 价格页、登录回流、报告与观察列表可使用;年费、一次性报告、可选加购和 Web/iOS 独立权益明确。 + +## 本地验证 + +| 检查 | 结果 | 范围 | +|---|---|---| +| `python3 scripts/test_isolated.py` | 244 项通过,19.413 秒 | 临时目录;不载入真实账户或支付密钥;含 6 项退款结账回归 | +| iOS XCTest | 52 项,4 跳过,0 失败 | build 9;4 项真实外部服务检查为显式 opt-in,未运行;StoreKit 集成验收另列,仍 BLOCKED | +| iOS offline commercial gates | 通过 | 源码及配置静态检查,不能代替真实购买 | +| `p0_smoke --base http://127.0.0.1:8769` | 通过 | 本地 fixture / HTTP,包括无效股票、契约、OAuth 410 | +| contract sample validator | 通过 | INTC fixture,验证生成物未纳入本次修改 | +| 独立只读复核 | 通过本次修复范围 | 支付/退款/旧 worker/账号隔离/日期与历史重建 | +| 页面 375 / 1440 | 无页面横向溢出 | Pricing、Reports、Watchlist;截图见本目录 | +| 本地 CSV HTTP 下载 | 200,183 字节,1 行,Content-Length 一致 | 合成 TESTCO 预览账号;浏览器下载按钮触发正常,不代表真实付款 | +| iOS 模拟器首页 | 可启动并显示日终日期 | 现有本地模拟器数据,不是收益验证 | +| build 9 Release 归档 | ARCHIVE SUCCEEDED,签名检查通过 | App/Widget 1.2.0 (9),上传后 VALID,已选入新审核;Release 不含调试启动开关或 StoreKit 测试配置 | +| build 9 原生流程 | 三台模拟器通过 | iPhone 17 Pro、iPhone 13 Pro Max、iPad Pro 12.9:未购买时写计划 → 保存 → 复盘 → 重启保留 | + +Python 测试仍有三个既有测试 HTTP socket ResourceWarning,没有功能测试失败。 + +截图: +- [Pricing 桌面](pricing-1440.png)、[手机](pricing-375.png) +- [Reports 桌面](reports-1440.png)、[手机](reports-375.png) +- [Watchlist 桌面](watchlist-1440.png)、[手机结果](watchlist-results-375.png) +- [iOS 模拟器](ios-simulator-home.png) + +本机最终归档:`ios/build/ios43-release-20260906/QuantRadar.xcarchive`(gitignored)。build 8 归档为早前阶段记录,已由 build 9 替代。 + +## 正式 Stripe 核查与已执行修复 + +实际账户已核对为 Fortune Insight LLC。完整分页检查含产品 6、价格 8、Payment Links 5、Checkout 199(2 页)、独立 Checkout 行项目 204(199 次)、订阅 1;各列表最终 has_more=false。 + +扩展到 QuantRadar 价格、域名及产品后的关联范围:31 个 Checkout,已付 1、开放 0;1 个订阅已取消。历史订单原价 $99、折扣 $98.01,实付 $0.99。不能用这个范围代表整个账户收入。 + +以下两条绕过账号绑定及防重流程的旧链接已经 POST 停用,并 GET 确认 active=false;操作前完整对象已保存在私有备份,未产生任何扣款: + +- `plink_1TDM037uBhbslGrGHh3mTQfm`(Pro 月付) +- `plink_1TDM077uBhbslGrGP3HlYZVg`(Portfolio 月付) + +待发布配置: +- Portfolio 现有价格 `price_1TDM067uBhbslGrGtxzYXEq1`,$99/月,active;尚未配置进运行环境。 +- 原默认 Portal 允许期末取消,但 `subscription_update.enabled=false`,不能承载新套餐切换。 +- 测试验收后已创建并 GET 展开核对独立正式 Portal `bpc_1UCTPA7uBhbslGrGuE1dZAda`:允许 Pro 月/年与 Portfolio 月套餐切换、即时结算差额、期末取消、付款方式和发票管理。它不是默认配置,尚未接入生产环境;旧默认 Portal 未修改。 +- Webhook 需补全订阅、发票、异步支付、Session 到期、退款事件;尚未更新正式配置。 +- Stripe 插件认证仍过期,但已通过用户登录的 Chrome JJ 工作账号取得测试访问,完整测试流程已完成,见下节。 + +## 真实 Stripe 测试环境验收 + +账号 `acct_1SwTY37uBhbslGrG`;所有付款对象 `livemode=false`。隔离的 Nube Python 3.12 应用经 SSH 隧道供浏览器访问;仅载入测试密钥与 Stripe CLI 的签名 secret,未启用 webhook 绕过。行情采用合成 TESTCO 数据,不代表真实市场表现。脱敏回执:[stripe-sandbox.json](stripe-sandbox.json)。 + +| 流程 | 实际结果 | +|---|---| +| 六种 Checkout 金额 | $9 报告、$14 含 CSV、$29 月、$249 年、报告抵扣后 $20 月、$99 Portfolio 均正确;金额检查 Session 已关闭 | +| 美元报告购买 | 浏览器用官方测试卡付 $14;签名回调后 ready;JSON 含 90 个交易日,CSV 12,217 字节、ZIP 10,421 字节;其他账号下载返回 404 | +| 英镑自适应定价 | 浏览器付 £10.77;Session 集成金额仍为 USD 1,400 分,`presentment_details` 为 GBP 1,077;2026-01-28.clover 事件处理后报告 ready | +| 报告退款 | 两笔测试报告均全额退款成功,账号不再可下载;第二笔验证后台自动关闭 $20 优惠 Checkout 并将 credit 标记 revoked,再次结账为新 $29 Session | +| 月订阅与升级 | 浏览器付 $29 后 app=pro;Portal 升级 Portfolio,显示并支付 $70 差额,下期 $99;签名回调后 app=portfolio_pro | +| Portal 取消 | UI 确认 2026-10-06 终止,当前权益仍为 portfolio_pro。实际对象以 `cancel_at` 等于 item 的 period end 表示期末取消,`cancel_at_period_end` 为 false,不能仅凭这个布尔值判断失败 | +| Test Clock 续费与到期 | 首付及下一周期发票各 $29 paid;期末取消后 Stripe=canceled、app=free | +| 扣款失败与补缴 | 官方失败测试支付方式使下一周期 past_due、app=free;换回成功方式并补缴后 active、app=pro | +| 回调与独立复核 | 39 次签名事件转发返回 200、0 次非 2xx;只读复核另做 10 项定向验证,未发现本次 diff 的剩余可复现 P1/P2 | + +退款前创建的旧 $20 Session 在旧代码下仍显示 open,但浏览器显示错误;没有观察到退款后成功按优惠价扣款。修复针对已复现的重结账阻塞和撤销未完成问题,不能描述为已证实的扣款漏洞。 + +这些是实际 Stripe sandbox 对象及测试时钟事件,不是真实收入,也不代表生产支付已经切换。 + +验收后已取消两条尚在存续的测试订阅、关闭剩余可付测试链接、删除测试时钟,测试账号最终为 free。隔离服务、SSH 隧道、临时 CLI、Cookie 和测试凭据已清理;保留测试产品/价格/Portal 供后续复验。正式配置准备回执:[stripe-production-prepared.json](stripe-production-prepared.json),尚未应用到生产运行环境。 + +## 生产与备份 + +生产 Nube 服务保持运行,Web/引擎修复尚未切换,health 仍 v0.7.0 / git_sha=null。无 DNS 变更。 + +已生成生产准备性热备份并下载本机,SHA-256: +`217d369cd9ee3037914e4de2c85598e74a5b0cecbee9bd850f9b6065d1ced107` + +共 296 个 tar 条目,4 个账户/业务 JSON 文件可解析,包含代码、引擎、环境。私有档案不进入 Git。这个热备份不是停机一致性备份;激活新版本之前还必须按 `docs/CORRECT_OPS.md` 做短暂停机备份。回滚只恢复代码,不覆盖新付款数据。 + +## 剩余商业与外部闸门 + +1. Yahoo/Nasdaq 商用和下载再分发授权尚未提供;已核实候选方案和成本,见 [DATA_LICENSING](../../DATA_LICENSING.md)。授权范围决定付费文件交付能否发布。 +2. Stripe 测试生命周期已通过,独立正式 Portal 已准备。取得数据授权后,再配套更新生产 Portal/价格/Webhook 并切换服务,完成公网验收。 +3. Apple 当前为 1.2.0 / build 9,App 与 Unlock IAP 均 WAITING_FOR_REVIEW,发布方式 MANUAL。尚需 Apple 审核通过、StoreKit / TestFlight 购买恢复验收和数据授权;送审不等于上线。 +4. 没有“保证赚钱”的证据。待上述条件完成,用真实客户的购买、交付、28 日回访、续订、退款与净收入判断效果,不用模拟数据或安装量冒充盈利。 + +## 早前 build 8 上传阶段记录(审核状态已被下方 build 9 记录替代) + +- Xcode export/upload 返回 0,并输出 EXPORT SUCCEEDED。 +- App Store Connect 新 build 8:`719a2f67-d5c3-49c3-ab98-7886189b0fe2`,1.2.0,processingState=VALID。 +- 再次读取现有商店版本:1.0 / build 7 / WAITING_FOR_REVIEW,AFTER_APPROVAL;本次未取消或替换它。 +- 现有版本、英文描述、审核资料、两个 review items、IAP version/本地化/审核图/定价计划/可用性元数据已私有备份。 +- 商店当前英文描述仍写 $9.99,和已核验的美国 $9.90 不一致;已准备替换为按地区显示价格的 PATCH 草稿,待可编辑时执行,不篡改排队中的审核。 +- 准备的替换请求包含新 build 8、现有 App version 和 IAP version `407ef5c5-194c-4240-832b-9213cfcdf561`;未提交。 +- 更低标价候选 Marketstack Basic/Starter 为 $9.99/月,但 CSV/JSON 分发权未确认。书面授权询问现已发送,等待供应商答复;未购买。 +- 当时 Stripe 插件返回 UNAUTHORIZED;之后已使用 JJ 账号完成真实测试验收,见上节。 + +上传时的运行代码为 `510707af7eafb31a8056efbefb42febce4efe1ec`;此后新增退款结账修复,必须使用包含该修复的新发布包,不能将旧包当作最终代码。 + +## 早前继续处理:授权询问已发送 + +- 07:15:28(Asia/Shanghai)通过 `fortuneinsight@outlook.com` 向 `support@apilayer.com` 发送 [授权询问](../../MARKETSTACK_LICENSE_INQUIRY.md),Outlook 查询已核对主题、收件人、正文与时间。邮件不构成订单、合同接受或授权已获批。 +- 07:15:41 收到 APILayer 自动回执,工单 **307916**;其说明周一办公时间处理。回执不等于商用及下载再分发授权。 +- 用户在 Chrome JJ 工作账号登录后,测试认证已恢复。临时使用官方 Stripe CLI 1.50.10,下载校验通过;密钥、Cookie 和完整敏感回执不入库。 +- App Store Connect 再次确认 build 8 为 VALID;当前版本仍选 build 7,WAITING_FOR_REVIEW / AFTER_APPROVAL。生产 health 仍为 v0.7.0 / git_sha=null;未切换新版。 +- PR #5 新增退款结账修复及六项回归,244 项隔离测试通过;未把发送邮件、sandbox 付款或已准备 Portal 计作数据授权、生产切换或盈利。 + +## 最新 iOS 4.3(a) 整改与重新送审 + +Apple 历史反馈为 8 月 28 日 Guideline 4.3(a)。本次 build 9 新增免费原生计划与复盘流程:Plan 为默认页,记录理由、触发条件、失效条件和复盘日期,保存原计划并追加一次有日期的复盘。同股票同日记录不再覆盖,旧日志不会因条数上限被静默删除。修正进行中扫描与当前购买权益同步、缺数据 UNKNOWN / PAUSE 一致性,以及大字号引导页截断。 + +- 源码 `73b6e311c7ac3261cd9d11159f5b0c06998fa84a` 的 [CI 34025153542](https://github.com/Alexaliao001/quantradar/actions/runs/34025153542) Python / iOS 两个任务均 SUCCESS。 +- 取消旧 build 7 审核后,设置 1.2.0 / build 9、MANUAL 发布,替换英文文案和 6 张真实原生截图;文案移除与地区售价不符的固定价格。 +- 17:45 提交 `07500692-0e8e-4347-b06f-395810c8d5b0`,API 与 Safari 均显示 App 1.2.0 (9) 和 QuantRadar Unlock 等待审核。并非审核通过或正式上架。 +- StoreKit 框架测试在当前 Xcode 26.6 / iOS 26.5 运行时因配置保存 Code 3 失败;Xcode 原样生成的对照配置也失败,因此明确标记 BLOCKED,未计入通过。TestFlight sandbox 购买、取消和重启恢复仍待验收。 +- Web 发布包仍为先前已验收的 `ba871745...`,本次 iOS 送审没有切换 Web 生产服务。数据授权仍未确认。 + +详细结果与脱敏回执:[build 9 验收记录](../../../ios/docs/review-build9/README.md)。 diff --git a/docs/audit/2026-09-06/ios-simulator-home.png b/docs/audit/2026-09-06/ios-simulator-home.png new file mode 100644 index 0000000..45ab335 Binary files /dev/null and b/docs/audit/2026-09-06/ios-simulator-home.png differ diff --git a/docs/audit/2026-09-06/pricing-1440.png b/docs/audit/2026-09-06/pricing-1440.png new file mode 100644 index 0000000..6ce3312 Binary files /dev/null and b/docs/audit/2026-09-06/pricing-1440.png differ diff --git a/docs/audit/2026-09-06/pricing-375.png b/docs/audit/2026-09-06/pricing-375.png new file mode 100644 index 0000000..df8c22a Binary files /dev/null and b/docs/audit/2026-09-06/pricing-375.png differ diff --git a/docs/audit/2026-09-06/reports-1440.png b/docs/audit/2026-09-06/reports-1440.png new file mode 100644 index 0000000..6d841ff Binary files /dev/null and b/docs/audit/2026-09-06/reports-1440.png differ diff --git a/docs/audit/2026-09-06/reports-375.png b/docs/audit/2026-09-06/reports-375.png new file mode 100644 index 0000000..b45a092 Binary files /dev/null and b/docs/audit/2026-09-06/reports-375.png differ diff --git a/docs/audit/2026-09-06/stripe-production-prepared.json b/docs/audit/2026-09-06/stripe-production-prepared.json new file mode 100644 index 0000000..914eecb --- /dev/null +++ b/docs/audit/2026-09-06/stripe-production-prepared.json @@ -0,0 +1,46 @@ +{ + "account_id": "acct_1SwTY37uBhbslGrG", + "portal_id": "bpc_1UCTPA7uBhbslGrGuE1dZAda", + "portal_is_default": false, + "portal_active": true, + "configuration_verified": true, + "environment_patch": { + "STRIPE_PRICE_ID_MONTHLY": "price_1TDM027uBhbslGrGK7mMVtEp", + "STRIPE_PRICE_ID_YEARLY": "price_1UAm7G7uBhbslGrGPM9kdw94", + "STRIPE_PRICE_ID_PORTFOLIO_PRO_MONTHLY": "price_1TDM067uBhbslGrGtxzYXEq1", + "STRIPE_PRICE_ID_REPORT": "price_1UAyR47uBhbslGrGiTe27Z6Q", + "STRIPE_PRICE_ID_BUMP": "price_1UAyR87uBhbslGrGZcDGuZJp", + "STRIPE_PORTAL_CONFIGURATION_ID": "bpc_1UCTPA7uBhbslGrGuE1dZAda" + }, + "production_environment_modified": false, + "webhooks_modified": false, + "existing_webhooks": [ + { + "id": "we_1U6uvl7uBhbslGrG01EauQuf", + "url": "https://quantradar.one/api/billing/webhook", + "status": "enabled", + "api_version": null, + "enabled_events": [ + "checkout.session.completed", + "customer.subscription.updated", + "customer.subscription.deleted", + "invoice.payment_failed" + ] + } + ], + "required_webhook_events": [ + "checkout.session.completed", + "checkout.session.async_payment_succeeded", + "checkout.session.async_payment_failed", + "checkout.session.expired", + "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted", + "invoice.paid", + "invoice.payment_failed", + "invoice.payment_action_required", + "charge.refunded" + ], + "tested_api_version": "2026-01-28.clover", + "status": "Prepared only. Apply environment and webhook changes together with the new runtime after data licensing is resolved; verify API-version compatibility at cutover." +} diff --git a/docs/audit/2026-09-06/stripe-sandbox.json b/docs/audit/2026-09-06/stripe-sandbox.json new file mode 100644 index 0000000..d928698 --- /dev/null +++ b/docs/audit/2026-09-06/stripe-sandbox.json @@ -0,0 +1,229 @@ +{ + "date": "2026-09-06", + "account_id": "acct_1SwTY37uBhbslGrG", + "mode": "test", + "real_money_charged": false, + "environment": { + "runtime": "isolated Nube Python 3.12 application, localhost 8771 via SSH tunnel", + "market_data": "synthetic TESTCO fixture, not licensed market data or trading results", + "webhook": "Stripe CLI forwards actual signed sandbox events; no signature bypass", + "api_version": "2026-01-28.clover", + "production_modified_by_qa": false + }, + "amount_checks_usd": { + "report": 9, + "report_with_csv": 14, + "pro_monthly": 29, + "pro_yearly": 249, + "pro_monthly_report_credit": 20, + "portfolio_monthly": 99 + }, + "usd_report": { + "checkout": { + "id": "cs_test_b19BEM5ZYo6HtdqjvCr0GTLIK7gw0rWIXdTD8B44M59syUk735fFPeKIbn", + "livemode": false, + "status": "complete", + "payment_status": "paid", + "currency": "usd", + "amount_total": 1400, + "presentment_details": null, + "payment_intent": "pi_3UCT1n7uBhbslGrG0R48KTLc", + "subscription": null + }, + "downloads": { + "report.json": { + "sessions": 90 + }, + "replay.csv": { + "bytes": 12217 + }, + "report.zip": { + "bytes": 10421 + } + }, + "other_account_download_status": 404, + "refund": { + "id": "re_3UCT1n7uBhbslGrG0R1Uu6Oh", + "status": "succeeded", + "currency": "usd", + "amount": 1400, + "payment_intent": "pi_3UCT1n7uBhbslGrG0R48KTLc" + } + }, + "gbp_report": { + "checkout": { + "id": "cs_test_b1YQHsb2MFkfbrorP9BEFOSd9VbSBvyPMdgSYZtZXfxZTrqt3k2apxXk86", + "livemode": false, + "status": "complete", + "payment_status": "paid", + "currency": "usd", + "amount_total": 1400, + "presentment_details": { + "presentment_amount": 1077, + "presentment_currency": "gbp" + }, + "payment_intent": "pi_3UCTLu7uBhbslGrG0XGr8sjl", + "subscription": null + }, + "signed_event": { + "id": "evt_1UCTLw7uBhbslGrGiVz4KcUZ", + "api_version": "2026-01-28.clover", + "presentment_details": { + "presentment_amount": 1077, + "presentment_currency": "gbp" + } + }, + "delivery": { + "id": "af470d535bc5446d9dbe9d5459e1b63d", + "ticker": "TESTCO", + "as_of": "2026-09-04", + "bump": 1, + "amount": 1400, + "currency": "usd", + "created_at": 1788651872.1526346, + "payment_state": "paid", + "delivery_state": "ready", + "error": null, + "legacy": 0, + "credit_state": "ready", + "paid_at": 1788652231.0, + "assets": [ + "report.json", + "price.svg", + "rsi.svg", + "volume.svg", + "posture.svg", + "replay.csv", + "report.zip" + ] + }, + "refund": { + "id": "re_3UCTLu7uBhbslGrG0OFNwTyj", + "status": "succeeded", + "currency": "usd", + "amount": 1400, + "payment_intent": "pi_3UCTLu7uBhbslGrG0XGr8sjl" + }, + "revocation": { + "order": "af470d535bc5446d9dbe9d5459e1b63d", + "old_credit_checkout": "cs_test_a1xITMdYE19X3XdWg58trwuuBaqNLdvBKq5xwqM1Fd1PSvpWgHUu7iBvt3", + "checkout_status": "expired", + "payment_state": "refunded", + "credit_state": "revoked", + "assets": [], + "download_statuses": { + "report.json": 404, + "replay.csv": 404, + "report.zip": 404 + } + }, + "replacement_amount_usd_cents": 2900 + }, + "subscription": { + "checkout": { + "id": "cs_test_b1mi5tPSLAQs8SvDVpUSuPAvuf0F12CfshdZsYcUyhblpMSPbnwJQNDiPs", + "livemode": false, + "status": "complete", + "payment_status": "paid", + "currency": "usd", + "amount_total": 2900, + "presentment_details": null, + "payment_intent": null, + "subscription": "sub_1UCT4Y7uBhbslGrGHCwOmMxP" + }, + "portal_upgrade": { + "from": "pro", + "to": "portfolio_pro", + "proration_paid_usd_cents": 7000, + "next_cycle_usd_cents": 9900, + "source": "Stripe Portal UI and signed webhook verified app plan" + }, + "portal_cancel": { + "id": "sub_1UCT4Y7uBhbslGrGHCwOmMxP", + "status": "active", + "cancel_at_period_end": false, + "cancel_at": 1791243151, + "item_period_ends": [ + 1791243151 + ], + "plan": "portfolio_pro" + } + }, + "test_clock": { + "id": "clock_1UCT6t7uBhbslGrGVh4ZCIvL", + "renewal_invoices": [ + { + "id": "in_1UCT7f7uBhbslGrGyx4PCkXf", + "status": "paid", + "amount_paid": 2900, + "currency": "usd", + "billing_reason": "subscription_cycle" + }, + { + "id": "in_1UCT6w7uBhbslGrG9qYvllQD", + "status": "paid", + "amount_paid": 2900, + "currency": "usd", + "billing_reason": "subscription_create" + } + ], + "period_end_cancel": { + "subscription": { + "id": "sub_1UCT6w7uBhbslGrGGT3I2ZVe", + "status": "canceled", + "livemode": false + }, + "app_plan": "free" + }, + "failed_renewal": { + "subscription": { + "id": "sub_1UCT9s7uBhbslGrGAFzN3MnL", + "status": "past_due", + "livemode": false + }, + "app_plan": "free" + }, + "payment_recovery": { + "subscription": { + "id": "sub_1UCT9s7uBhbslGrGAFzN3MnL", + "status": "active", + "livemode": false + }, + "app_plan": "pro" + } + }, + "fix": { + "problem": "After report refund the old discounted Checkout remained open but errored in the browser; same-plan re-checkout returned that unusable session. No successful post-refund discounted charge was observed.", + "resolution": "Recheck price and current credit before returning a session; refund worker expires unfinished credited Checkouts before marking credit revoked.", + "old_session": "cs_test_a1zTsbLzh6WfuwMdbsRh9RrQnP4SP0Ek6J6a7N3DPaQfLrxjpaVml3PRy3", + "old_session_status": "expired", + "new_session_amount_usd_cents": 2900, + "local_tests": 244, + "independent_review": "PASS; 10 directed checks; no remaining reproducible P1/P2 in diff" + }, + "limitations": [ + "Sandbox payments are not revenue or a live production checkout test.", + "Test-clock renewals are accelerated sandbox simulations, not elapsed real customer renewals.", + "Data redistribution license remains pending; production code and webhook configuration have not been activated." + ], + "webhook_forwarding": { + "http_200_count": 39, + "http_non_2xx_count": 0 + }, + "cleanup": { + "canceled_subscriptions": [ + "sub_1UCT4Y7uBhbslGrGHCwOmMxP", + "sub_1UCT9s7uBhbslGrGAFzN3MnL" + ], + "expired_sessions": [ + "cs_test_b1tAfRhR6qRzadFLepw37lqdBgX5xKA0RfY0RpbZnJNIxDZAuwo5KVEds9" + ], + "test_clock_deleted": "clock_1UCT6t7uBhbslGrGVh4ZCIvL", + "reusable_test_products_and_portal_retained": true, + "final_app_plans": { + "sub": "free", + "clock": "free" + }, + "isolated_runtime": "Stopped; isolated remote runtime, temporary local tools and test credentials removed after evidence capture." + } +} diff --git a/docs/audit/2026-09-06/watchlist-1440.png b/docs/audit/2026-09-06/watchlist-1440.png new file mode 100644 index 0000000..c4b59d5 Binary files /dev/null and b/docs/audit/2026-09-06/watchlist-1440.png differ diff --git a/docs/audit/2026-09-06/watchlist-375.png b/docs/audit/2026-09-06/watchlist-375.png new file mode 100644 index 0000000..c8a5efb Binary files /dev/null and b/docs/audit/2026-09-06/watchlist-375.png differ diff --git a/docs/audit/2026-09-06/watchlist-results-375.png b/docs/audit/2026-09-06/watchlist-results-375.png new file mode 100644 index 0000000..7b6182b Binary files /dev/null and b/docs/audit/2026-09-06/watchlist-results-375.png differ diff --git a/fixtures/charts_sample/AAPL_analysis.json b/fixtures/charts_sample/AAPL_analysis.json new file mode 100644 index 0000000..a7dd26d --- /dev/null +++ b/fixtures/charts_sample/AAPL_analysis.json @@ -0,0 +1,2742 @@ +{ + "ticker": "AAPL", + "fetch_time": "2026-03-21T05:27:50.690473-04:00", + "data_quality": { + "api_calls_total": 78, + "api_errors": 3, + "failed_endpoints": [ + "/v2/aggs/ticker/I:VIX/range/1/day/2026-02-19/2026-03-21", + "/v3/quotes/AAPL", + "/v2/aggs/ticker/I:TNX/range/1/day/2026-02-19/2026-03-21" + ], + "timeframes_missing": [], + "timeframes_ok": 4, + "option_chain_ok": true, + "option_expiry_count": 5, + "spy_ok": true, + "sector_ok": true, + "reliability": "medium", + "warnings": [] + }, + "mechanical_scores": { + "state": { + "code": "C", + "name": "bottom_formation", + "reason": "Mixed MA arrangement, potential bottoming" + }, + "base_score": { + "volume_price": { + "total": 1, + "max": 35, + "obv_consistency": "1/4", + "obv_range": "0-11", + "volume_ratio": 2.14, + "daily_obv": "below_ma" + }, + "momentum": { + "total": 9, + "max": 25, + "rsi": { + "score": 8, + "max": 12, + "value": 41.0 + }, + "macd": { + "score": 1, + "max": 13, + "daily_cross": "bearish", + "weekly_cross": "bearish" + } + }, + "trend": { + "total": 4, + "max": 20, + "ma_alignment": "1/4" + }, + "risk": { + "total": 17, + "max": 20, + "deductions": [ + "daily MACD bearish cross: -3" + ] + }, + "total": 31 + }, + "adjustments": { + "adx_fuel": { + "score": 5, + "adx": 20.0, + "fuel": "ignition", + "source": "monthly" + }, + "vix_resonance": { + "score": -9, + "vix": 26.8, + "vix_trend": "rising", + "spy_pct": -1.21, + "sector_pct": -1.95 + }, + "iv_environment": { + "score": -1, + "atm_iv": 26.9, + "iv_environment": "moderate", + "iv_premium": 6.5, + "spread_pct": 0 + }, + "technical_crowding": { + "score": 0, + "triggered": false, + "ma_ratio": "1/4", + "obv_ratio": "1/4", + "bb_pct": 12.1 + }, + "pullback_health": null, + "total": -5 + }, + "special_evaluations": { + "bearish_strength": null, + "bounce_evaluation": { + "total": 20, + "confluence": { + "score": 3, + "max": 35, + "hits": [ + "round_number" + ], + "count": 1 + }, + "oversold": { + "score": 4, + "max": 25, + "rsi": 41.0, + "bb_pct": 12.1 + }, + "bounce_signal": { + "score": 12, + "max": 25 + }, + "selling_exhaustion": { + "score": 1, + "max": 15, + "vol_ratio": 1.28 + } + } + }, + "entry_timing": { + "total": 21, + "max": 40, + "grade": "C", + "components": { + "pullback_depth": { + "score": 6, + "max": 12, + "pullback_pct": 9.6, + "weekly_bullish": false + }, + "rsi_position": { + "score": 10, + "max": 10, + "rsi": 41.0 + }, + "bb_position": { + "score": 5, + "max": 8, + "bb_pct": 12.1 + }, + "volume_pattern": { + "score": 0, + "max": 6, + "vol_ratio": 1.28 + }, + "ma_proximity": { + "score": 0, + "max": 4, + "nearest_ma": null + } + } + }, + "trend_persistence": { + "trend_intact": true, + "trend_score": 68, + "max_score": 100, + "stock_stage": 1, + "action_recommendation": "TIGHTEN", + "degradation_signals": [ + "Weekly EMA not bullish" + ], + "components": { + "weekly_ema": 15, + "weinstein_stage": 15, + "monthly_trend": 20, + "obv_multi_tf": 8, + "adx_fuel": 10 + } + }, + "final_score": 26, + "signal_mechanical": "NO", + "signal_timing_gated": "NO" + }, + "indicator_data": { + "ticker": "AAPL", + "generated_at": "2026-03-21_17-27-46", + "timeframes": { + "monthly": { + "timeframe": "monthly", + "price": 247.99, + "emas": { + "ema10": 251.232, + "ema20": 237.4532, + "ema50": 206.2466, + "ema100": 178.4603 + }, + "sma200": null, + "ema_arrangement": "bullish", + "ema10_distance_pct": -1.29, + "rsi": 56.6103, + "rsi_sma": 60.1679, + "rsi_zone": "neutral", + "macd": 18.4374, + "macd_signal": 17.7883, + "macd_histogram": 0.649, + "macd_cross": "bullish", + "adx": 19.976, + "plus_di": 25.1327, + "minus_di": 21.5735, + "adx_fuel": "ignition", + "obv": 8378218962.3191, + "obv_smma": 6325095203.1432, + "obv_status": "above_ma", + "bb_upper": 285.195, + "bb_middle": 239.061, + "bb_lower": 192.927, + "bb_position_pct": 59.7, + "volume_ratio": 0.57, + "recent_high": 278.85, + "pullback_pct": 11.07, + "vol_3d_avg": 879026028.0, + "vol_breakout_ratio": 0.82, + "hv_20": 18.9, + "hv_60": 24.2 + }, + "weekly": { + "timeframe": "weekly", + "price": 247.99, + "emas": { + "ema10": 258.0403, + "ema20": 258.2642, + "ema50": 246.9648, + "ema100": 230.7197 + }, + "sma200": 198.464, + "ema_arrangement": "mixed", + "ema10_distance_pct": -3.89, + "rsi": 45.7032, + "rsi_sma": 54.2661, + "rsi_zone": "neutral", + "macd": 2.3796, + "macd_signal": 5.9831, + "macd_histogram": -3.6035, + "macd_cross": "bearish", + "adx": 19.8005, + "plus_di": 25.4377, + "minus_di": 23.7259, + "adx_fuel": "ignition", + "obv": 5159742009.0319, + "obv_smma": 5900985254.1724, + "obv_status": "below_ma", + "bb_upper": 286.4349, + "bb_middle": 265.3505, + "bb_lower": 244.2661, + "bb_position_pct": 8.8, + "volume_ratio": 1.01, + "recent_high": 278.12, + "pullback_pct": 10.83, + "vol_3d_avg": 202713243.0, + "vol_breakout_ratio": 0.91, + "hv_20": 23.7, + "hv_60": 32.6 + }, + "daily": { + "timeframe": "daily", + "price": 247.99, + "emas": { + "ema10": 253.441, + "ema20": 257.2699, + "ema50": 261.3732, + "ema100": 260.9303 + }, + "sma200": null, + "ema_arrangement": "mixed", + "ema10_distance_pct": -2.15, + "rsi": 34.8768, + "rsi_sma": 40.9754, + "rsi_zone": "neutral", + "macd": -4.2087, + "macd_signal": -2.855, + "macd_histogram": -1.3537, + "macd_cross": "bearish", + "adx": 23.2494, + "plus_di": 14.0471, + "minus_di": 32.8383, + "adx_fuel": "mid_trend", + "obv": 429902719.6687, + "obv_smma": 596236493.2008, + "obv_status": "below_ma", + "bb_upper": 275.8266, + "bb_middle": 259.988, + "bb_lower": 244.1494, + "bb_position_pct": 12.1, + "volume_ratio": 2.14, + "recent_high": 274.23, + "pullback_pct": 9.57, + "vol_3d_avg": 53124841.0, + "vol_breakout_ratio": 1.28, + "hv_20": 20.4, + "hv_60": 24.1 + }, + "hourly": { + "timeframe": "hourly", + "price": 248.19, + "emas": { + "ema10": 248.6068, + "ema20": 249.5186, + "ema50": 252.1888, + "ema100": 255.6365 + }, + "sma200": 262.0138, + "ema_arrangement": "bearish", + "ema10_distance_pct": -0.17, + "rsi": 37.0856, + "rsi_sma": 34.569, + "rsi_zone": "neutral", + "macd": -1.3109, + "macd_signal": -1.4027, + "macd_histogram": 0.0918, + "macd_cross": "bullish", + "adx": 45.3606, + "plus_di": 6.8097, + "minus_di": 28.738, + "adx_fuel": "mature", + "obv": -217828461.672, + "obv_smma": -209519933.7063, + "obv_status": "below_ma", + "bb_upper": 252.2585, + "bb_middle": 249.3966, + "bb_lower": 246.5346, + "bb_position_pct": 28.9, + "volume_ratio": 2.04, + "recent_high": 252.52, + "pullback_pct": 1.71, + "vol_3d_avg": 5002035.0, + "vol_breakout_ratio": 1.17, + "hv_20": 11.8, + "hv_60": 16.7 + } + }, + "chart_files": { + "monthly": { + "price": "/Users/rongjianliao/charts/reports/site-live/2026-03-21_172740_aapl-technical-intelligence-report/assets/AAPL_monthly_price_2026-03-21_17-27-46.png", + "indicators": "/Users/rongjianliao/charts/reports/site-live/2026-03-21_172740_aapl-technical-intelligence-report/assets/AAPL_monthly_indicators_2026-03-21_17-27-46.png" + }, + "weekly": { + "price": "/Users/rongjianliao/charts/reports/site-live/2026-03-21_172740_aapl-technical-intelligence-report/assets/AAPL_weekly_price_2026-03-21_17-27-46.png", + "indicators": "/Users/rongjianliao/charts/reports/site-live/2026-03-21_172740_aapl-technical-intelligence-report/assets/AAPL_weekly_indicators_2026-03-21_17-27-46.png" + }, + "daily": { + "price": "/Users/rongjianliao/charts/reports/site-live/2026-03-21_172740_aapl-technical-intelligence-report/assets/AAPL_daily_price_2026-03-21_17-27-46.png", + "indicators": "/Users/rongjianliao/charts/reports/site-live/2026-03-21_172740_aapl-technical-intelligence-report/assets/AAPL_daily_indicators_2026-03-21_17-27-46.png" + }, + "hourly": { + "price": "/Users/rongjianliao/charts/reports/site-live/2026-03-21_172740_aapl-technical-intelligence-report/assets/AAPL_hourly_price_2026-03-21_17-27-46.png", + "indicators": "/Users/rongjianliao/charts/reports/site-live/2026-03-21_172740_aapl-technical-intelligence-report/assets/AAPL_hourly_indicators_2026-03-21_17-27-46.png" + } + }, + "multi_timeframe_summary": { + "ma_alignment": "1/4", + "ma_bullish_count": 1, + "obv_alignment": "1/4", + "obv_above_count": 1, + "valid_timeframe_count": 4, + "monthly_adx_fuel": "ignition" + } + }, + "market_env": { + "spy_price": 648.57, + "spy_prev_close": 656.51, + "spy_change_pct": -1.21, + "spy_available": true, + "sector_etf": "XLK", + "sector_price": 135.29, + "sector_change_pct": -1.95, + "vix_current": 26.78, + "vix_30d_ago": 19.09, + "vix_change_pct": 40.3, + "vix_trend": "rising", + "vix_sma5": 24.36, + "vix_sma20": 23.37, + "vix_source": "yfinance", + "spy_ema50": null, + "spy_ema50_slope": null, + "market_state": "closed", + "server_time": "2026-03-21T05:27:41-04:00", + "holidays": [ + { + "date": "2026-04-03", + "name": "Good Friday", + "status": "closed" + }, + { + "date": "2026-04-03", + "name": "Good Friday", + "status": "closed" + }, + { + "date": "2026-05-25", + "name": "Memorial Day", + "status": "closed" + } + ] + }, + "fundamentals": { + "prev_close": 247.99, + "company_name": "Apple Inc.", + "sector": "ELECTRONIC COMPUTERS", + "sic_code": "3571", + "market_cap": 3640775908600.0, + "total_employees": 166000, + "homepage": "https://www.apple.com", + "description": "Apple is among the largest companies in the world, with a broad portfolio of hardware and software products targeted at consumers and businesses. Apple's iPhone makes up a majority of the firm sales, ", + "financials": { + "revenue": 143756000000.0, + "net_income": 42097000000.0, + "gross_profit": 69231000000.0, + "eps_diluted": 2.84, + "operating_income": 50852000000.0, + "investing_cf": -4886000000.0, + "operating_cf": 53925000000.0, + "capex": -2373000000.0, + "fcf": 51552000000.0, + "period": "Q1", + "fiscal_year": "2026", + "filing_date": "2026-01-30" + }, + "ttm": { + "revenue": 435617000000.0, + "revenue_source": "polygon", + "net_income": 117777000000.0, + "gross_profit": 206157000000.0, + "operating_income": 141070000000.0, + "eps_diluted": 7.8999999999999995, + "capex": -12148000000.0, + "fcf": 123324000000.0, + "operating_cf": 135472000000.0, + "capex_to_revenue_pct": 2.8, + "capex_intensity": "low", + "investing_cf": null, + "cf_source": "yfinance", + "cf_note": "capex = pure PP&E from yfinance", + "yoy_revenue_growth": 15.7 + }, + "quote": { + "bid": 0, + "ask": 0, + "spread_pct": 0.0, + "midpoint": 247.99 + }, + "option_chain": { + "expiries": { + "2026-04-10": { + "dte": 20, + "contracts": [ + { + "ticker": "O:AAPL260410C00235000", + "type": "call", + "strike": 235, + "expiry": "2026-04-10", + "iv": 0.27775339759817463, + "delta": 0.8370234497367673, + "gamma": 0.015016769244700665, + "theta": -0.11570685554644508, + "vega": 0.14827029350827556, + "open_interest": 408, + "volume": 37, + "bid": 16.1, + "ask": 17.25, + "mid": 16.68, + "last": 16.02, + "break_even": 251.675 + }, + { + "ticker": "O:AAPL260410C00240000", + "type": "call", + "strike": 240, + "expiry": "2026-04-10", + "iv": 0.26806975473499334, + "delta": 0.7539417338276039, + "gamma": 0.019988495028098262, + "theta": -0.1380368779928339, + "vega": 0.19742619295030425, + "open_interest": 337, + "volume": 78, + "bid": 12.15, + "ask": 13.1, + "mid": 12.62, + "last": 12.07, + "break_even": 252.625 + }, + { + "ticker": "O:AAPL260410C00250000", + "type": "call", + "strike": 250, + "expiry": "2026-04-10", + "iv": 0.2449996092392883, + "delta": 0.5174257348765249, + "gamma": 0.02803779425357078, + "theta": -0.15459043721842927, + "vega": 0.2345333461708929, + "open_interest": 1501, + "volume": 625, + "bid": 5.65, + "ask": 6.1, + "mid": 5.88, + "last": 5.6, + "break_even": 255.875 + }, + { + "ticker": "O:AAPL260410C00255000", + "type": "call", + "strike": 255, + "expiry": "2026-04-10", + "iv": 0.22862452224474397, + "delta": 0.37222674917414517, + "gamma": 0.028509562197256255, + "theta": -0.13517701692649303, + "vega": 0.2098012424652596, + "open_interest": 1003, + "volume": 628, + "bid": 3.3, + "ask": 3.45, + "mid": 3.38, + "last": 3.32, + "break_even": 258.375 + }, + { + "ticker": "O:AAPL260410C00260000", + "type": "call", + "strike": 260, + "expiry": "2026-04-10", + "iv": 0.22165292267331346, + "delta": 0.2388437227553148, + "gamma": 0.024021132854635856, + "theta": -0.10591398289233998, + "vega": 0.16419736686210715, + "open_interest": 1369, + "volume": 713, + "bid": 1.77, + "ask": 1.84, + "mid": 1.81, + "last": 1.75, + "break_even": 261.805 + }, + { + "ticker": "O:AAPL260410P00235000", + "type": "put", + "strike": 235, + "expiry": "2026-04-10", + "iv": 0.344123794655953, + "delta": -0.2075057975798738, + "gamma": 0.014358454676138914, + "theta": -0.14053097091438396, + "vega": 0.15083831093428687, + "open_interest": 538, + "volume": 100, + "bid": 2.34, + "ask": 2.62, + "mid": 2.48, + "last": 2.61, + "break_even": 232.52 + }, + { + "ticker": "O:AAPL260410P00240000", + "type": "put", + "strike": 240, + "expiry": "2026-04-10", + "iv": 0.3293761330043195, + "delta": -0.2851343145880544, + "gamma": 0.017585585243066866, + "theta": -0.1564449638680878, + "vega": 0.1987092957552004, + "open_interest": 836, + "volume": 531, + "bid": 3.6, + "ask": 3.7, + "mid": 3.65, + "last": 3.65, + "break_even": 236.35 + }, + { + "ticker": "O:AAPL260410P00250000", + "type": "put", + "strike": 250, + "expiry": "2026-04-10", + "iv": 0.292289013585907, + "delta": -0.4841504452579613, + "gamma": 0.02371559500178608, + "theta": -0.16187109442956502, + "vega": 0.23443691606136383, + "open_interest": 751, + "volume": 227, + "bid": 6.45, + "ask": 7.15, + "mid": 6.8, + "last": 7.2, + "break_even": 243.2 + }, + { + "ticker": "O:AAPL260410P00255000", + "type": "put", + "strike": 255, + "expiry": "2026-04-10", + "iv": 0.28568291904049153, + "delta": -0.6017375685594628, + "gamma": 0.023471724631921133, + "theta": -0.1495821335202105, + "vega": 0.23761364179492392, + "open_interest": 753, + "volume": 41, + "bid": 9.15, + "ask": 9.9, + "mid": 9.53, + "last": 9.97, + "break_even": 245.475 + }, + { + "ticker": "O:AAPL260410P00260000", + "type": "put", + "strike": 260, + "expiry": "2026-04-10", + "iv": 0.28904913424265993, + "delta": -0.7051670843500822, + "gamma": 0.020774154211725418, + "theta": -0.1315867131167768, + "vega": 0.21089089419248944, + "open_interest": 530, + "volume": 30, + "bid": 12.65, + "ask": 13.4, + "mid": 13.03, + "last": 13.45, + "break_even": 246.975 + } + ] + }, + "2026-05-01": { + "dte": 41, + "contracts": [ + { + "ticker": "O:AAPL260501C00235000", + "type": "call", + "strike": 235, + "expiry": "2026-05-01", + "iv": 0.2964436147281378, + "delta": 0.7562437098743807, + "gamma": 0.012635935384060442, + "theta": -0.1095622484116962, + "vega": 0.27576773082035316, + "open_interest": 63, + "volume": 84, + "bid": 18.95, + "ask": 19.95, + "mid": 19.45, + "last": 19.32, + "break_even": 254.45 + }, + { + "ticker": "O:AAPL260501C00240000", + "type": "call", + "strike": 240, + "expiry": "2026-05-01", + "iv": 0.296454858905694, + "delta": 0.6867705643052079, + "gamma": 0.014450589233431675, + "theta": -0.12202458969333715, + "vega": 0.2808364846905159, + "open_interest": 15, + "volume": 62, + "bid": 15.7, + "ask": 16.2, + "mid": 15.95, + "last": 15.9, + "break_even": 255.95 + }, + { + "ticker": "O:AAPL260501C00250000", + "type": "call", + "strike": 250, + "expiry": "2026-05-01", + "iv": 0.2790556992920356, + "delta": 0.5298405805841789, + "gamma": 0.01730449767200043, + "theta": -0.12583303860268852, + "vega": 0.3325204531931766, + "open_interest": 167, + "volume": 145, + "bid": 9.45, + "ask": 9.85, + "mid": 9.65, + "last": 9, + "break_even": 259.65 + }, + { + "ticker": "O:AAPL260501C00255000", + "type": "call", + "strike": 255, + "expiry": "2026-05-01", + "iv": 0.2661718402907884, + "delta": 0.4418269423689861, + "gamma": 0.017738236074484177, + "theta": -0.11639097378325167, + "vega": 0.33887044407016526, + "open_interest": 198, + "volume": 104, + "bid": 6.9, + "ask": 7.3, + "mid": 7.1, + "last": 6.7, + "break_even": 262.1 + }, + { + "ticker": "O:AAPL260501C00260000", + "type": "call", + "strike": 260, + "expiry": "2026-05-01", + "iv": 0.25842800623177753, + "delta": 0.35348759424374515, + "gamma": 0.017263178401937508, + "theta": -0.10579076421847851, + "vega": 0.30238218838809894, + "open_interest": 262, + "volume": 480, + "bid": 4.8, + "ask": 5.2, + "mid": 5.0, + "last": 4.78, + "break_even": 265 + }, + { + "ticker": "O:AAPL260501P00235000", + "type": "put", + "strike": 235, + "expiry": "2026-05-01", + "iv": 0.35495669310113975, + "delta": -0.2764369959353671, + "gamma": 0.011329553324057457, + "theta": -0.11541537586381362, + "vega": 0.2777600141361194, + "open_interest": 185, + "volume": 49, + "bid": 5.25, + "ask": 5.7, + "mid": 5.47, + "last": 5.6, + "break_even": 229.525 + }, + { + "ticker": "O:AAPL260501P00240000", + "type": "put", + "strike": 240, + "expiry": "2026-05-01", + "iv": 0.34635904816857327, + "delta": -0.3343371591997618, + "gamma": 0.012802368826684623, + "theta": -0.12327123053692535, + "vega": 0.32229558856789, + "open_interest": 372, + "volume": 57, + "bid": 6.65, + "ask": 7.05, + "mid": 6.85, + "last": 6.99, + "break_even": 233.15 + }, + { + "ticker": "O:AAPL260501P00250000", + "type": "put", + "strike": 250, + "expiry": "2026-05-01", + "iv": 0.32420826921186136, + "delta": -0.4724374090014687, + "gamma": 0.01508306945841745, + "theta": -0.12410804813238259, + "vega": 0.33244673281848286, + "open_interest": 346, + "volume": 75, + "bid": 10.3, + "ask": 10.8, + "mid": 10.55, + "last": 11.11, + "break_even": 239.45 + }, + { + "ticker": "O:AAPL260501P00255000", + "type": "put", + "strike": 255, + "expiry": "2026-05-01", + "iv": 0.31083141502583206, + "delta": -0.548752257244611, + "gamma": 0.015496703818470018, + "theta": -0.11465305266675012, + "vega": 0.33750554981974595, + "open_interest": 71, + "volume": 14, + "bid": 12.75, + "ask": 13.25, + "mid": 13.0, + "last": 13.05, + "break_even": 242 + }, + { + "ticker": "O:AAPL260501P00260000", + "type": "put", + "strike": 260, + "expiry": "2026-05-01", + "iv": 0.29850638048562256, + "delta": -0.6306614722476618, + "gamma": 0.015695243278371362, + "theta": -0.10415889839689907, + "vega": 0.2998114652956202, + "open_interest": 122, + "volume": 1, + "bid": 15.05, + "ask": 16.2, + "mid": 15.62, + "last": 15.27, + "break_even": 244.375 + } + ] + }, + "2026-06-18": { + "dte": 89, + "contracts": [ + { + "ticker": "O:AAPL260618C00235000", + "type": "call", + "strike": 235, + "expiry": "2026-06-18", + "iv": 0.3126199774183469, + "delta": 0.6992285699880434, + "gamma": 0.009168720314835514, + "theta": -0.08950169650489226, + "vega": 0.4035765733799896, + "open_interest": 6602, + "volume": 6, + "bid": 24.15, + "ask": 25, + "mid": 24.57, + "last": 24.17, + "break_even": 259.575 + }, + { + "ticker": "O:AAPL260618C00240000", + "type": "call", + "strike": 240, + "expiry": "2026-06-18", + "iv": 0.303000652642941, + "delta": 0.652331343028191, + "gamma": 0.009951075218069804, + "theta": -0.09030428869230817, + "vega": 0.46897957184300343, + "open_interest": 6306, + "volume": 84, + "bid": 20.8, + "ask": 21.6, + "mid": 21.2, + "last": 20.77, + "break_even": 261.2 + }, + { + "ticker": "O:AAPL260618C00250000", + "type": "call", + "strike": 250, + "expiry": "2026-06-18", + "iv": 0.2849745350156595, + "delta": 0.5467437106813486, + "gamma": 0.011498376968581442, + "theta": -0.09034700856212083, + "vega": 0.4856263322747671, + "open_interest": 18291, + "volume": 555, + "bid": 14.7, + "ask": 14.85, + "mid": 14.77, + "last": 14.85, + "break_even": 264.775 + }, + { + "ticker": "O:AAPL260618C00255000", + "type": "call", + "strike": 255, + "expiry": "2026-06-18", + "iv": 0.2751809589945769, + "delta": 0.48900529234243106, + "gamma": 0.011801983478122917, + "theta": -0.0859068367682254, + "vega": 0.4946158687494169, + "open_interest": 5888, + "volume": 327, + "bid": 12.05, + "ask": 12.35, + "mid": 12.2, + "last": 11.95, + "break_even": 267.2 + }, + { + "ticker": "O:AAPL260618C00260000", + "type": "call", + "strike": 260, + "expiry": "2026-06-18", + "iv": 0.26921343273609316, + "delta": 0.42878520333285136, + "gamma": 0.011949569366368264, + "theta": -0.0824597926933067, + "vega": 0.5039644805054297, + "open_interest": 12312, + "volume": 1429, + "bid": 9.7, + "ask": 9.9, + "mid": 9.8, + "last": 9.67, + "break_even": 269.8 + }, + { + "ticker": "O:AAPL260618P00235000", + "type": "put", + "strike": 235, + "expiry": "2026-06-18", + "iv": 0.3404591251103616, + "delta": -0.31332112019929514, + "gamma": 0.008705219352617588, + "theta": -0.07848727801247753, + "vega": 0.46313324787654275, + "open_interest": 9223, + "volume": 145, + "bid": 9.15, + "ask": 9.3, + "mid": 9.23, + "last": 9.43, + "break_even": 225.775 + }, + { + "ticker": "O:AAPL260618P00240000", + "type": "put", + "strike": 240, + "expiry": "2026-06-18", + "iv": 0.3244749192909726, + "delta": -0.3569477781248529, + "gamma": 0.009503435087185706, + "theta": -0.07665642977187949, + "vega": 0.47031944917449586, + "open_interest": 16739, + "volume": 338, + "bid": 10.5, + "ask": 10.85, + "mid": 10.68, + "last": 11.05, + "break_even": 229.325 + }, + { + "ticker": "O:AAPL260618P00250000", + "type": "put", + "strike": 250, + "expiry": "2026-06-18", + "iv": 0.31261561943534816, + "delta": -0.45827311586406533, + "gamma": 0.010722345062750722, + "theta": -0.0780893773900271, + "vega": 0.4854071501413275, + "open_interest": 10562, + "volume": 231, + "bid": 14.4, + "ask": 14.75, + "mid": 14.57, + "last": 14.98, + "break_even": 235.425 + }, + { + "ticker": "O:AAPL260618P00255000", + "type": "put", + "strike": 255, + "expiry": "2026-06-18", + "iv": 0.2993085573609729, + "delta": -0.5127970853422293, + "gamma": 0.011167290837273091, + "theta": -0.07260991412177033, + "vega": 0.4926191442430827, + "open_interest": 6111, + "volume": 87, + "bid": 16.55, + "ask": 17.1, + "mid": 16.83, + "last": 17.4, + "break_even": 238.175 + }, + { + "ticker": "O:AAPL260618P00260000", + "type": "put", + "strike": 260, + "expiry": "2026-06-18", + "iv": 0.2914613915175142, + "delta": -0.5707621635925824, + "gamma": 0.011445570578041457, + "theta": -0.0686812506928372, + "vega": 0.4979372046578223, + "open_interest": 14627, + "volume": 715, + "bid": 18.95, + "ask": 19.75, + "mid": 19.35, + "last": 20.1, + "break_even": 240.65 + } + ] + }, + "2026-07-17": { + "dte": 118, + "contracts": [ + { + "ticker": "O:AAPL260717C00235000", + "type": "call", + "strike": 235, + "expiry": "2026-07-17", + "iv": 0.2985547369867146, + "delta": 0.6933831644628308, + "gamma": 0.008438590546929917, + "theta": -0.07691275277390694, + "vega": 0.5281434098644526, + "open_interest": 391, + "volume": 17, + "bid": 25.5, + "ask": 26.95, + "mid": 26.23, + "last": 26.86, + "break_even": 261.225 + }, + { + "ticker": "O:AAPL260717C00240000", + "type": "call", + "strike": 240, + "expiry": "2026-07-17", + "iv": 0.2912228043588679, + "delta": 0.6491960300196405, + "gamma": 0.009008700382594522, + "theta": -0.07727058945262338, + "vega": 0.5374145651592146, + "open_interest": 1676, + "volume": 7, + "bid": 22.75, + "ask": 23.35, + "mid": 23.05, + "last": 23, + "break_even": 263.05 + }, + { + "ticker": "O:AAPL260717C00250000", + "type": "call", + "strike": 250, + "expiry": "2026-07-17", + "iv": 0.2888549777549731, + "delta": 0.5544774030912119, + "gamma": 0.009832937187391522, + "theta": -0.08057730847185517, + "vega": 0.5570607012434339, + "open_interest": 1610, + "volume": 364, + "bid": 17.05, + "ask": 17.7, + "mid": 17.38, + "last": 17.05, + "break_even": 267.375 + }, + { + "ticker": "O:AAPL260717C00255000", + "type": "call", + "strike": 255, + "expiry": "2026-07-17", + "iv": 0.2743360515686456, + "delta": 0.5040932502376575, + "gamma": 0.010298418258067767, + "theta": -0.07587679481877115, + "vega": 0.5674409736671648, + "open_interest": 2442, + "volume": 94, + "bid": 14.4, + "ask": 14.55, + "mid": 14.48, + "last": 14.35, + "break_even": 269.475 + }, + { + "ticker": "O:AAPL260717C00260000", + "type": "call", + "strike": 260, + "expiry": "2026-07-17", + "iv": 0.2694243738117633, + "delta": 0.4521484880981624, + "gamma": 0.010426427806162096, + "theta": -0.07341484963631394, + "vega": 0.5781165009510107, + "open_interest": 3454, + "volume": 11536, + "bid": 12, + "ask": 12.2, + "mid": 12.1, + "last": 11.95, + "break_even": 272.1 + }, + { + "ticker": "O:AAPL260717P00235000", + "type": "put", + "strike": 235, + "expiry": "2026-07-17", + "iv": 0.32870090562364107, + "delta": -0.321141595131987, + "gamma": 0.00789734897770126, + "theta": -0.0648492834664562, + "vega": 0.5308899868245757, + "open_interest": 2462, + "volume": 636, + "bid": 10.7, + "ask": 10.85, + "mid": 10.77, + "last": 11.05, + "break_even": 224.225 + }, + { + "ticker": "O:AAPL260717P00240000", + "type": "put", + "strike": 240, + "expiry": "2026-07-17", + "iv": 0.3180242457698487, + "delta": -0.3616390220994055, + "gamma": 0.008487733040308684, + "theta": -0.06423920790324054, + "vega": 0.5395299453040139, + "open_interest": 5792, + "volume": 184, + "bid": 12.3, + "ask": 12.5, + "mid": 12.4, + "last": 12.34, + "break_even": 227.6 + }, + { + "ticker": "O:AAPL260717P00250000", + "type": "put", + "strike": 250, + "expiry": "2026-07-17", + "iv": 0.3068797061834372, + "delta": -0.4520727725240404, + "gamma": 0.009525362475636087, + "theta": -0.06516859516454684, + "vega": 0.5572463582109509, + "open_interest": 6919, + "volume": 146, + "bid": 16.1, + "ask": 16.45, + "mid": 16.27, + "last": 16.7, + "break_even": 233.725 + }, + { + "ticker": "O:AAPL260717P00255000", + "type": "put", + "strike": 255, + "expiry": "2026-07-17", + "iv": 0.2939544622168664, + "delta": -0.5005941718608375, + "gamma": 0.009952543099914465, + "theta": -0.060715588496467396, + "vega": 0.5651541135110842, + "open_interest": 7168, + "volume": 17, + "bid": 18.1, + "ask": 18.8, + "mid": 18.45, + "last": 19.05, + "break_even": 236.55 + }, + { + "ticker": "O:AAPL260717P00260000", + "type": "put", + "strike": 260, + "expiry": "2026-07-17", + "iv": 0.28980637456826225, + "delta": -0.5505506864864452, + "gamma": 0.010110639248672492, + "theta": -0.05842799653352195, + "vega": 0.5712547745910662, + "open_interest": 3967, + "volume": 29, + "bid": 20.85, + "ask": 21.4, + "mid": 21.12, + "last": 21.65, + "break_even": 238.875 + } + ] + }, + "2026-08-21": { + "dte": 153, + "contracts": [ + { + "ticker": "O:AAPL260821C00235000", + "type": "call", + "strike": 235, + "expiry": "2026-08-21", + "iv": 0.3043136554108412, + "delta": 0.6821607844960651, + "gamma": 0.0073256002008926, + "theta": -0.07007753082680963, + "vega": 0.5993180134221826, + "open_interest": 90, + "volume": 11, + "bid": 28.55, + "ask": 29.8, + "mid": 29.18, + "last": 29.84, + "break_even": 264.175 + }, + { + "ticker": "O:AAPL260821C00240000", + "type": "call", + "strike": 240, + "expiry": "2026-08-21", + "iv": 0.30076463431685707, + "delta": 0.6431934441809896, + "gamma": 0.007696775391543141, + "theta": -0.07104271416117114, + "vega": 0.6099029436107379, + "open_interest": 829, + "volume": 17, + "bid": 25.9, + "ask": 26.5, + "mid": 26.2, + "last": 25.75, + "break_even": 266.2 + }, + { + "ticker": "O:AAPL260821C00250000", + "type": "call", + "strike": 250, + "expiry": "2026-08-21", + "iv": 0.29786699489224816, + "delta": 0.5627251321172045, + "gamma": 0.008349659472529986, + "theta": -0.07365141897990332, + "vega": 0.6316250853320442, + "open_interest": 3826, + "volume": 99, + "bid": 20.4, + "ask": 20.65, + "mid": 20.52, + "last": 20.4, + "break_even": 270.525 + }, + { + "ticker": "O:AAPL260821C00255000", + "type": "call", + "strike": 255, + "expiry": "2026-08-21", + "iv": 0.2870429802615128, + "delta": 0.5205746408100246, + "gamma": 0.008645132613729265, + "theta": -0.0705574500576778, + "vega": 0.6429227292905422, + "open_interest": 783, + "volume": 84, + "bid": 17.7, + "ask": 17.85, + "mid": 17.77, + "last": 18.06, + "break_even": 272.775 + }, + { + "ticker": "O:AAPL260821C00260000", + "type": "call", + "strike": 260, + "expiry": "2026-08-21", + "iv": 0.28117865094204886, + "delta": 0.4768448531632328, + "gamma": 0.008803109135264319, + "theta": -0.06844415206839417, + "vega": 0.6545908836531829, + "open_interest": 1724, + "volume": 86, + "bid": 15.25, + "ask": 15.4, + "mid": 15.32, + "last": 15.23, + "break_even": 275.325 + }, + { + "ticker": "O:AAPL260821P00235000", + "type": "put", + "strike": 235, + "expiry": "2026-08-21", + "iv": 0.3330717874175558, + "delta": -0.33039696453336975, + "gamma": 0.006916128400551668, + "theta": -0.05706311120004093, + "vega": 0.6024929122388625, + "open_interest": 5180, + "volume": 168, + "bid": 13.1, + "ask": 13.3, + "mid": 13.2, + "last": 13.55, + "break_even": 221.8 + }, + { + "ticker": "O:AAPL260821P00240000", + "type": "put", + "strike": 240, + "expiry": "2026-08-21", + "iv": 0.32466120399736587, + "delta": -0.3662297171366738, + "gamma": 0.007362297058266476, + "theta": -0.056854993714039036, + "vega": 0.6122870370675275, + "open_interest": 6151, + "volume": 133, + "bid": 14.8, + "ask": 15, + "mid": 14.9, + "last": 14.95, + "break_even": 225.1 + }, + { + "ticker": "O:AAPL260821P00250000", + "type": "put", + "strike": 250, + "expiry": "2026-08-21", + "iv": 0.31411132778475787, + "delta": -0.44470701700374893, + "gamma": 0.008193496762905891, + "theta": -0.05751534912216733, + "vega": 0.6318165302847614, + "open_interest": 2067, + "volume": 65, + "bid": 18.55, + "ask": 19, + "mid": 18.77, + "last": 18.6, + "break_even": 231.225 + }, + { + "ticker": "O:AAPL260821P00255000", + "type": "put", + "strike": 255, + "expiry": "2026-08-21", + "iv": 0.3048880813999734, + "delta": -0.48574592649643483, + "gamma": 0.008468347317413057, + "theta": -0.05456536169457693, + "vega": 0.6408225554148901, + "open_interest": 2873, + "volume": 90, + "bid": 20.9, + "ask": 21.3, + "mid": 21.1, + "last": 20.87, + "break_even": 233.9 + }, + { + "ticker": "O:AAPL260821P00260000", + "type": "put", + "strike": 260, + "expiry": "2026-08-21", + "iv": 0.29647688314131887, + "delta": -0.5296346642756775, + "gamma": 0.008753624321376679, + "theta": -0.05184515121324428, + "vega": 0.6490102199478757, + "open_interest": 3276, + "volume": 27, + "bid": 23.15, + "ask": 23.8, + "mid": 23.48, + "last": 24.45, + "break_even": 236.525 + } + ] + } + }, + "underlying_price": 247.99, + "contracts": [ + { + "ticker": "O:AAPL260410C00235000", + "type": "call", + "strike": 235, + "expiry": "2026-04-10", + "iv": 0.27775339759817463, + "delta": 0.8370234497367673, + "gamma": 0.015016769244700665, + "theta": -0.11570685554644508, + "vega": 0.14827029350827556, + "open_interest": 408, + "volume": 37, + "bid": 16.1, + "ask": 17.25, + "mid": 16.68, + "last": 16.02, + "break_even": 251.675 + }, + { + "ticker": "O:AAPL260410C00240000", + "type": "call", + "strike": 240, + "expiry": "2026-04-10", + "iv": 0.26806975473499334, + "delta": 0.7539417338276039, + "gamma": 0.019988495028098262, + "theta": -0.1380368779928339, + "vega": 0.19742619295030425, + "open_interest": 337, + "volume": 78, + "bid": 12.15, + "ask": 13.1, + "mid": 12.62, + "last": 12.07, + "break_even": 252.625 + }, + { + "ticker": "O:AAPL260410C00250000", + "type": "call", + "strike": 250, + "expiry": "2026-04-10", + "iv": 0.2449996092392883, + "delta": 0.5174257348765249, + "gamma": 0.02803779425357078, + "theta": -0.15459043721842927, + "vega": 0.2345333461708929, + "open_interest": 1501, + "volume": 625, + "bid": 5.65, + "ask": 6.1, + "mid": 5.88, + "last": 5.6, + "break_even": 255.875 + }, + { + "ticker": "O:AAPL260410C00255000", + "type": "call", + "strike": 255, + "expiry": "2026-04-10", + "iv": 0.22862452224474397, + "delta": 0.37222674917414517, + "gamma": 0.028509562197256255, + "theta": -0.13517701692649303, + "vega": 0.2098012424652596, + "open_interest": 1003, + "volume": 628, + "bid": 3.3, + "ask": 3.45, + "mid": 3.38, + "last": 3.32, + "break_even": 258.375 + }, + { + "ticker": "O:AAPL260410C00260000", + "type": "call", + "strike": 260, + "expiry": "2026-04-10", + "iv": 0.22165292267331346, + "delta": 0.2388437227553148, + "gamma": 0.024021132854635856, + "theta": -0.10591398289233998, + "vega": 0.16419736686210715, + "open_interest": 1369, + "volume": 713, + "bid": 1.77, + "ask": 1.84, + "mid": 1.81, + "last": 1.75, + "break_even": 261.805 + }, + { + "ticker": "O:AAPL260410P00235000", + "type": "put", + "strike": 235, + "expiry": "2026-04-10", + "iv": 0.344123794655953, + "delta": -0.2075057975798738, + "gamma": 0.014358454676138914, + "theta": -0.14053097091438396, + "vega": 0.15083831093428687, + "open_interest": 538, + "volume": 100, + "bid": 2.34, + "ask": 2.62, + "mid": 2.48, + "last": 2.61, + "break_even": 232.52 + }, + { + "ticker": "O:AAPL260410P00240000", + "type": "put", + "strike": 240, + "expiry": "2026-04-10", + "iv": 0.3293761330043195, + "delta": -0.2851343145880544, + "gamma": 0.017585585243066866, + "theta": -0.1564449638680878, + "vega": 0.1987092957552004, + "open_interest": 836, + "volume": 531, + "bid": 3.6, + "ask": 3.7, + "mid": 3.65, + "last": 3.65, + "break_even": 236.35 + }, + { + "ticker": "O:AAPL260410P00250000", + "type": "put", + "strike": 250, + "expiry": "2026-04-10", + "iv": 0.292289013585907, + "delta": -0.4841504452579613, + "gamma": 0.02371559500178608, + "theta": -0.16187109442956502, + "vega": 0.23443691606136383, + "open_interest": 751, + "volume": 227, + "bid": 6.45, + "ask": 7.15, + "mid": 6.8, + "last": 7.2, + "break_even": 243.2 + }, + { + "ticker": "O:AAPL260410P00255000", + "type": "put", + "strike": 255, + "expiry": "2026-04-10", + "iv": 0.28568291904049153, + "delta": -0.6017375685594628, + "gamma": 0.023471724631921133, + "theta": -0.1495821335202105, + "vega": 0.23761364179492392, + "open_interest": 753, + "volume": 41, + "bid": 9.15, + "ask": 9.9, + "mid": 9.53, + "last": 9.97, + "break_even": 245.475 + }, + { + "ticker": "O:AAPL260410P00260000", + "type": "put", + "strike": 260, + "expiry": "2026-04-10", + "iv": 0.28904913424265993, + "delta": -0.7051670843500822, + "gamma": 0.020774154211725418, + "theta": -0.1315867131167768, + "vega": 0.21089089419248944, + "open_interest": 530, + "volume": 30, + "bid": 12.65, + "ask": 13.4, + "mid": 13.03, + "last": 13.45, + "break_even": 246.975 + }, + { + "ticker": "O:AAPL260501C00235000", + "type": "call", + "strike": 235, + "expiry": "2026-05-01", + "iv": 0.2964436147281378, + "delta": 0.7562437098743807, + "gamma": 0.012635935384060442, + "theta": -0.1095622484116962, + "vega": 0.27576773082035316, + "open_interest": 63, + "volume": 84, + "bid": 18.95, + "ask": 19.95, + "mid": 19.45, + "last": 19.32, + "break_even": 254.45 + }, + { + "ticker": "O:AAPL260501C00240000", + "type": "call", + "strike": 240, + "expiry": "2026-05-01", + "iv": 0.296454858905694, + "delta": 0.6867705643052079, + "gamma": 0.014450589233431675, + "theta": -0.12202458969333715, + "vega": 0.2808364846905159, + "open_interest": 15, + "volume": 62, + "bid": 15.7, + "ask": 16.2, + "mid": 15.95, + "last": 15.9, + "break_even": 255.95 + }, + { + "ticker": "O:AAPL260501C00250000", + "type": "call", + "strike": 250, + "expiry": "2026-05-01", + "iv": 0.2790556992920356, + "delta": 0.5298405805841789, + "gamma": 0.01730449767200043, + "theta": -0.12583303860268852, + "vega": 0.3325204531931766, + "open_interest": 167, + "volume": 145, + "bid": 9.45, + "ask": 9.85, + "mid": 9.65, + "last": 9, + "break_even": 259.65 + }, + { + "ticker": "O:AAPL260501C00255000", + "type": "call", + "strike": 255, + "expiry": "2026-05-01", + "iv": 0.2661718402907884, + "delta": 0.4418269423689861, + "gamma": 0.017738236074484177, + "theta": -0.11639097378325167, + "vega": 0.33887044407016526, + "open_interest": 198, + "volume": 104, + "bid": 6.9, + "ask": 7.3, + "mid": 7.1, + "last": 6.7, + "break_even": 262.1 + }, + { + "ticker": "O:AAPL260501C00260000", + "type": "call", + "strike": 260, + "expiry": "2026-05-01", + "iv": 0.25842800623177753, + "delta": 0.35348759424374515, + "gamma": 0.017263178401937508, + "theta": -0.10579076421847851, + "vega": 0.30238218838809894, + "open_interest": 262, + "volume": 480, + "bid": 4.8, + "ask": 5.2, + "mid": 5.0, + "last": 4.78, + "break_even": 265 + }, + { + "ticker": "O:AAPL260501P00235000", + "type": "put", + "strike": 235, + "expiry": "2026-05-01", + "iv": 0.35495669310113975, + "delta": -0.2764369959353671, + "gamma": 0.011329553324057457, + "theta": -0.11541537586381362, + "vega": 0.2777600141361194, + "open_interest": 185, + "volume": 49, + "bid": 5.25, + "ask": 5.7, + "mid": 5.47, + "last": 5.6, + "break_even": 229.525 + }, + { + "ticker": "O:AAPL260501P00240000", + "type": "put", + "strike": 240, + "expiry": "2026-05-01", + "iv": 0.34635904816857327, + "delta": -0.3343371591997618, + "gamma": 0.012802368826684623, + "theta": -0.12327123053692535, + "vega": 0.32229558856789, + "open_interest": 372, + "volume": 57, + "bid": 6.65, + "ask": 7.05, + "mid": 6.85, + "last": 6.99, + "break_even": 233.15 + }, + { + "ticker": "O:AAPL260501P00250000", + "type": "put", + "strike": 250, + "expiry": "2026-05-01", + "iv": 0.32420826921186136, + "delta": -0.4724374090014687, + "gamma": 0.01508306945841745, + "theta": -0.12410804813238259, + "vega": 0.33244673281848286, + "open_interest": 346, + "volume": 75, + "bid": 10.3, + "ask": 10.8, + "mid": 10.55, + "last": 11.11, + "break_even": 239.45 + }, + { + "ticker": "O:AAPL260501P00255000", + "type": "put", + "strike": 255, + "expiry": "2026-05-01", + "iv": 0.31083141502583206, + "delta": -0.548752257244611, + "gamma": 0.015496703818470018, + "theta": -0.11465305266675012, + "vega": 0.33750554981974595, + "open_interest": 71, + "volume": 14, + "bid": 12.75, + "ask": 13.25, + "mid": 13.0, + "last": 13.05, + "break_even": 242 + }, + { + "ticker": "O:AAPL260501P00260000", + "type": "put", + "strike": 260, + "expiry": "2026-05-01", + "iv": 0.29850638048562256, + "delta": -0.6306614722476618, + "gamma": 0.015695243278371362, + "theta": -0.10415889839689907, + "vega": 0.2998114652956202, + "open_interest": 122, + "volume": 1, + "bid": 15.05, + "ask": 16.2, + "mid": 15.62, + "last": 15.27, + "break_even": 244.375 + }, + { + "ticker": "O:AAPL260618C00235000", + "type": "call", + "strike": 235, + "expiry": "2026-06-18", + "iv": 0.3126199774183469, + "delta": 0.6992285699880434, + "gamma": 0.009168720314835514, + "theta": -0.08950169650489226, + "vega": 0.4035765733799896, + "open_interest": 6602, + "volume": 6, + "bid": 24.15, + "ask": 25, + "mid": 24.57, + "last": 24.17, + "break_even": 259.575 + }, + { + "ticker": "O:AAPL260618C00240000", + "type": "call", + "strike": 240, + "expiry": "2026-06-18", + "iv": 0.303000652642941, + "delta": 0.652331343028191, + "gamma": 0.009951075218069804, + "theta": -0.09030428869230817, + "vega": 0.46897957184300343, + "open_interest": 6306, + "volume": 84, + "bid": 20.8, + "ask": 21.6, + "mid": 21.2, + "last": 20.77, + "break_even": 261.2 + }, + { + "ticker": "O:AAPL260618C00250000", + "type": "call", + "strike": 250, + "expiry": "2026-06-18", + "iv": 0.2849745350156595, + "delta": 0.5467437106813486, + "gamma": 0.011498376968581442, + "theta": -0.09034700856212083, + "vega": 0.4856263322747671, + "open_interest": 18291, + "volume": 555, + "bid": 14.7, + "ask": 14.85, + "mid": 14.77, + "last": 14.85, + "break_even": 264.775 + }, + { + "ticker": "O:AAPL260618C00255000", + "type": "call", + "strike": 255, + "expiry": "2026-06-18", + "iv": 0.2751809589945769, + "delta": 0.48900529234243106, + "gamma": 0.011801983478122917, + "theta": -0.0859068367682254, + "vega": 0.4946158687494169, + "open_interest": 5888, + "volume": 327, + "bid": 12.05, + "ask": 12.35, + "mid": 12.2, + "last": 11.95, + "break_even": 267.2 + }, + { + "ticker": "O:AAPL260618C00260000", + "type": "call", + "strike": 260, + "expiry": "2026-06-18", + "iv": 0.26921343273609316, + "delta": 0.42878520333285136, + "gamma": 0.011949569366368264, + "theta": -0.0824597926933067, + "vega": 0.5039644805054297, + "open_interest": 12312, + "volume": 1429, + "bid": 9.7, + "ask": 9.9, + "mid": 9.8, + "last": 9.67, + "break_even": 269.8 + }, + { + "ticker": "O:AAPL260618P00235000", + "type": "put", + "strike": 235, + "expiry": "2026-06-18", + "iv": 0.3404591251103616, + "delta": -0.31332112019929514, + "gamma": 0.008705219352617588, + "theta": -0.07848727801247753, + "vega": 0.46313324787654275, + "open_interest": 9223, + "volume": 145, + "bid": 9.15, + "ask": 9.3, + "mid": 9.23, + "last": 9.43, + "break_even": 225.775 + }, + { + "ticker": "O:AAPL260618P00240000", + "type": "put", + "strike": 240, + "expiry": "2026-06-18", + "iv": 0.3244749192909726, + "delta": -0.3569477781248529, + "gamma": 0.009503435087185706, + "theta": -0.07665642977187949, + "vega": 0.47031944917449586, + "open_interest": 16739, + "volume": 338, + "bid": 10.5, + "ask": 10.85, + "mid": 10.68, + "last": 11.05, + "break_even": 229.325 + }, + { + "ticker": "O:AAPL260618P00250000", + "type": "put", + "strike": 250, + "expiry": "2026-06-18", + "iv": 0.31261561943534816, + "delta": -0.45827311586406533, + "gamma": 0.010722345062750722, + "theta": -0.0780893773900271, + "vega": 0.4854071501413275, + "open_interest": 10562, + "volume": 231, + "bid": 14.4, + "ask": 14.75, + "mid": 14.57, + "last": 14.98, + "break_even": 235.425 + }, + { + "ticker": "O:AAPL260618P00255000", + "type": "put", + "strike": 255, + "expiry": "2026-06-18", + "iv": 0.2993085573609729, + "delta": -0.5127970853422293, + "gamma": 0.011167290837273091, + "theta": -0.07260991412177033, + "vega": 0.4926191442430827, + "open_interest": 6111, + "volume": 87, + "bid": 16.55, + "ask": 17.1, + "mid": 16.83, + "last": 17.4, + "break_even": 238.175 + }, + { + "ticker": "O:AAPL260618P00260000", + "type": "put", + "strike": 260, + "expiry": "2026-06-18", + "iv": 0.2914613915175142, + "delta": -0.5707621635925824, + "gamma": 0.011445570578041457, + "theta": -0.0686812506928372, + "vega": 0.4979372046578223, + "open_interest": 14627, + "volume": 715, + "bid": 18.95, + "ask": 19.75, + "mid": 19.35, + "last": 20.1, + "break_even": 240.65 + }, + { + "ticker": "O:AAPL260717C00235000", + "type": "call", + "strike": 235, + "expiry": "2026-07-17", + "iv": 0.2985547369867146, + "delta": 0.6933831644628308, + "gamma": 0.008438590546929917, + "theta": -0.07691275277390694, + "vega": 0.5281434098644526, + "open_interest": 391, + "volume": 17, + "bid": 25.5, + "ask": 26.95, + "mid": 26.23, + "last": 26.86, + "break_even": 261.225 + }, + { + "ticker": "O:AAPL260717C00240000", + "type": "call", + "strike": 240, + "expiry": "2026-07-17", + "iv": 0.2912228043588679, + "delta": 0.6491960300196405, + "gamma": 0.009008700382594522, + "theta": -0.07727058945262338, + "vega": 0.5374145651592146, + "open_interest": 1676, + "volume": 7, + "bid": 22.75, + "ask": 23.35, + "mid": 23.05, + "last": 23, + "break_even": 263.05 + }, + { + "ticker": "O:AAPL260717C00250000", + "type": "call", + "strike": 250, + "expiry": "2026-07-17", + "iv": 0.2888549777549731, + "delta": 0.5544774030912119, + "gamma": 0.009832937187391522, + "theta": -0.08057730847185517, + "vega": 0.5570607012434339, + "open_interest": 1610, + "volume": 364, + "bid": 17.05, + "ask": 17.7, + "mid": 17.38, + "last": 17.05, + "break_even": 267.375 + }, + { + "ticker": "O:AAPL260717C00255000", + "type": "call", + "strike": 255, + "expiry": "2026-07-17", + "iv": 0.2743360515686456, + "delta": 0.5040932502376575, + "gamma": 0.010298418258067767, + "theta": -0.07587679481877115, + "vega": 0.5674409736671648, + "open_interest": 2442, + "volume": 94, + "bid": 14.4, + "ask": 14.55, + "mid": 14.48, + "last": 14.35, + "break_even": 269.475 + }, + { + "ticker": "O:AAPL260717C00260000", + "type": "call", + "strike": 260, + "expiry": "2026-07-17", + "iv": 0.2694243738117633, + "delta": 0.4521484880981624, + "gamma": 0.010426427806162096, + "theta": -0.07341484963631394, + "vega": 0.5781165009510107, + "open_interest": 3454, + "volume": 11536, + "bid": 12, + "ask": 12.2, + "mid": 12.1, + "last": 11.95, + "break_even": 272.1 + }, + { + "ticker": "O:AAPL260717P00235000", + "type": "put", + "strike": 235, + "expiry": "2026-07-17", + "iv": 0.32870090562364107, + "delta": -0.321141595131987, + "gamma": 0.00789734897770126, + "theta": -0.0648492834664562, + "vega": 0.5308899868245757, + "open_interest": 2462, + "volume": 636, + "bid": 10.7, + "ask": 10.85, + "mid": 10.77, + "last": 11.05, + "break_even": 224.225 + }, + { + "ticker": "O:AAPL260717P00240000", + "type": "put", + "strike": 240, + "expiry": "2026-07-17", + "iv": 0.3180242457698487, + "delta": -0.3616390220994055, + "gamma": 0.008487733040308684, + "theta": -0.06423920790324054, + "vega": 0.5395299453040139, + "open_interest": 5792, + "volume": 184, + "bid": 12.3, + "ask": 12.5, + "mid": 12.4, + "last": 12.34, + "break_even": 227.6 + }, + { + "ticker": "O:AAPL260717P00250000", + "type": "put", + "strike": 250, + "expiry": "2026-07-17", + "iv": 0.3068797061834372, + "delta": -0.4520727725240404, + "gamma": 0.009525362475636087, + "theta": -0.06516859516454684, + "vega": 0.5572463582109509, + "open_interest": 6919, + "volume": 146, + "bid": 16.1, + "ask": 16.45, + "mid": 16.27, + "last": 16.7, + "break_even": 233.725 + }, + { + "ticker": "O:AAPL260717P00255000", + "type": "put", + "strike": 255, + "expiry": "2026-07-17", + "iv": 0.2939544622168664, + "delta": -0.5005941718608375, + "gamma": 0.009952543099914465, + "theta": -0.060715588496467396, + "vega": 0.5651541135110842, + "open_interest": 7168, + "volume": 17, + "bid": 18.1, + "ask": 18.8, + "mid": 18.45, + "last": 19.05, + "break_even": 236.55 + }, + { + "ticker": "O:AAPL260717P00260000", + "type": "put", + "strike": 260, + "expiry": "2026-07-17", + "iv": 0.28980637456826225, + "delta": -0.5505506864864452, + "gamma": 0.010110639248672492, + "theta": -0.05842799653352195, + "vega": 0.5712547745910662, + "open_interest": 3967, + "volume": 29, + "bid": 20.85, + "ask": 21.4, + "mid": 21.12, + "last": 21.65, + "break_even": 238.875 + }, + { + "ticker": "O:AAPL260821C00235000", + "type": "call", + "strike": 235, + "expiry": "2026-08-21", + "iv": 0.3043136554108412, + "delta": 0.6821607844960651, + "gamma": 0.0073256002008926, + "theta": -0.07007753082680963, + "vega": 0.5993180134221826, + "open_interest": 90, + "volume": 11, + "bid": 28.55, + "ask": 29.8, + "mid": 29.18, + "last": 29.84, + "break_even": 264.175 + }, + { + "ticker": "O:AAPL260821C00240000", + "type": "call", + "strike": 240, + "expiry": "2026-08-21", + "iv": 0.30076463431685707, + "delta": 0.6431934441809896, + "gamma": 0.007696775391543141, + "theta": -0.07104271416117114, + "vega": 0.6099029436107379, + "open_interest": 829, + "volume": 17, + "bid": 25.9, + "ask": 26.5, + "mid": 26.2, + "last": 25.75, + "break_even": 266.2 + }, + { + "ticker": "O:AAPL260821C00250000", + "type": "call", + "strike": 250, + "expiry": "2026-08-21", + "iv": 0.29786699489224816, + "delta": 0.5627251321172045, + "gamma": 0.008349659472529986, + "theta": -0.07365141897990332, + "vega": 0.6316250853320442, + "open_interest": 3826, + "volume": 99, + "bid": 20.4, + "ask": 20.65, + "mid": 20.52, + "last": 20.4, + "break_even": 270.525 + }, + { + "ticker": "O:AAPL260821C00255000", + "type": "call", + "strike": 255, + "expiry": "2026-08-21", + "iv": 0.2870429802615128, + "delta": 0.5205746408100246, + "gamma": 0.008645132613729265, + "theta": -0.0705574500576778, + "vega": 0.6429227292905422, + "open_interest": 783, + "volume": 84, + "bid": 17.7, + "ask": 17.85, + "mid": 17.77, + "last": 18.06, + "break_even": 272.775 + }, + { + "ticker": "O:AAPL260821C00260000", + "type": "call", + "strike": 260, + "expiry": "2026-08-21", + "iv": 0.28117865094204886, + "delta": 0.4768448531632328, + "gamma": 0.008803109135264319, + "theta": -0.06844415206839417, + "vega": 0.6545908836531829, + "open_interest": 1724, + "volume": 86, + "bid": 15.25, + "ask": 15.4, + "mid": 15.32, + "last": 15.23, + "break_even": 275.325 + }, + { + "ticker": "O:AAPL260821P00235000", + "type": "put", + "strike": 235, + "expiry": "2026-08-21", + "iv": 0.3330717874175558, + "delta": -0.33039696453336975, + "gamma": 0.006916128400551668, + "theta": -0.05706311120004093, + "vega": 0.6024929122388625, + "open_interest": 5180, + "volume": 168, + "bid": 13.1, + "ask": 13.3, + "mid": 13.2, + "last": 13.55, + "break_even": 221.8 + }, + { + "ticker": "O:AAPL260821P00240000", + "type": "put", + "strike": 240, + "expiry": "2026-08-21", + "iv": 0.32466120399736587, + "delta": -0.3662297171366738, + "gamma": 0.007362297058266476, + "theta": -0.056854993714039036, + "vega": 0.6122870370675275, + "open_interest": 6151, + "volume": 133, + "bid": 14.8, + "ask": 15, + "mid": 14.9, + "last": 14.95, + "break_even": 225.1 + }, + { + "ticker": "O:AAPL260821P00250000", + "type": "put", + "strike": 250, + "expiry": "2026-08-21", + "iv": 0.31411132778475787, + "delta": -0.44470701700374893, + "gamma": 0.008193496762905891, + "theta": -0.05751534912216733, + "vega": 0.6318165302847614, + "open_interest": 2067, + "volume": 65, + "bid": 18.55, + "ask": 19, + "mid": 18.77, + "last": 18.6, + "break_even": 231.225 + }, + { + "ticker": "O:AAPL260821P00255000", + "type": "put", + "strike": 255, + "expiry": "2026-08-21", + "iv": 0.3048880813999734, + "delta": -0.48574592649643483, + "gamma": 0.008468347317413057, + "theta": -0.05456536169457693, + "vega": 0.6408225554148901, + "open_interest": 2873, + "volume": 90, + "bid": 20.9, + "ask": 21.3, + "mid": 21.1, + "last": 20.87, + "break_even": 233.9 + }, + { + "ticker": "O:AAPL260821P00260000", + "type": "put", + "strike": 260, + "expiry": "2026-08-21", + "iv": 0.29647688314131887, + "delta": -0.5296346642756775, + "gamma": 0.008753624321376679, + "theta": -0.05184515121324428, + "vega": 0.6490102199478757, + "open_interest": 3276, + "volume": 27, + "bid": 23.15, + "ask": 23.8, + "mid": 23.48, + "last": 24.45, + "break_even": 236.525 + } + ], + "atm_iv": 26.9, + "hv_20": 20.4, + "hv_60": 24.1, + "iv_premium": 6.5, + "iv_environment": "moderate", + "perception": { + "iv_term_structure": [ + { + "expiry": "2026-04-10", + "dte": 20, + "atm_iv": 24.5 + }, + { + "expiry": "2026-05-01", + "dte": 41, + "atm_iv": 27.9 + }, + { + "expiry": "2026-06-18", + "dte": 89, + "atm_iv": 27.5 + }, + { + "expiry": "2026-07-17", + "dte": 118, + "atm_iv": 27.4 + }, + { + "expiry": "2026-08-21", + "dte": 153, + "atm_iv": 28.7 + } + ], + "by_expiry": { + "2026-04-10": { + "dte": 20, + "atm_iv": 24.5, + "put_call_skew_25d": 1.49, + "max_oi_call": { + "strike": 250, + "oi": 1501 + }, + "max_oi_put": { + "strike": 240, + "oi": 836 + }, + "pcr_oi": 0.74, + "total_volume": 3010 + }, + "2026-05-01": { + "dte": 41, + "atm_iv": 27.9, + "put_call_skew_25d": 1.37, + "max_oi_call": { + "strike": 260, + "oi": 262 + }, + "max_oi_put": { + "strike": 240, + "oi": 372 + }, + "pcr_oi": 1.55, + "total_volume": 1071 + }, + "2026-06-18": { + "dte": 89, + "atm_iv": 27.5, + "put_call_skew_25d": 1.26, + "max_oi_call": { + "strike": 250, + "oi": 18291 + }, + "max_oi_put": { + "strike": 240, + "oi": 16739 + }, + "pcr_oi": 1.16, + "total_volume": 3917 + }, + "2026-07-17": { + "dte": 118, + "atm_iv": 27.4, + "put_call_skew_25d": 1.22, + "max_oi_call": { + "strike": 260, + "oi": 3454 + }, + "max_oi_put": { + "strike": 255, + "oi": 7168 + }, + "pcr_oi": 2.75, + "total_volume": 13030 + }, + "2026-08-21": { + "dte": 153, + "atm_iv": 28.7, + "put_call_skew_25d": 1.18, + "max_oi_call": { + "strike": 250, + "oi": 3826 + }, + "max_oi_put": { + "strike": 240, + "oi": 6151 + }, + "pcr_oi": 2.7, + "total_volume": 780 + } + } + } + }, + "treasury_10y": 4.85, + "treasury_source": "TLT_proxy", + "earnings": [ + { + "id": "2fcf421e3fc253b101c9b4279745795ef0bec067911283450b30a12c59e02327", + "publisher": { + "name": "Benzinga", + "homepage_url": "https://www.benzinga.com/", + "logo_url": "https://s3.polygon.io/public/assets/news/logos/benzinga.svg", + "favicon_url": "https://s3.polygon.io/public/assets/news/favicons/benzinga.ico" + }, + "title": "SEC Greenlights Nasdaq's Tokenized Settlement Pilot: What It Means For Investors", + "author": "Isaac Olaosegba", + "published_utc": "2026-03-20T21:29:01Z", + "article_url": "https://www.benzinga.com/Opinion/26/03/51392959/sec-greenlights-nasdaq-tokenized-settlement-pilot-what-it-means-for-investors?utm_source=benzinga_taxonomy&utm_medium=rss_feed_free&utm_content=taxonomy_rss&utm_campaign=channel", + "tickers": [ + "NDAQ", + "AAPL", + "SPY" + ], + "image_url": "https://cdn.benzinga.com/files/imagecache/bz2_opengraph_meta_image_400x300/sites/all/themes/bz2/images/bz-icon.png", + "description": "The SEC has approved Nasdaq's pilot program to tokenize and settle Russell 1000 stocks and major ETFs using blockchain technology. Tokenized shares will be identical to traditional shares but settle almost instantly via smart contracts instead of the current T+1 process. This approval lays groundwork for potential 24/7 trading and accelerates convergence between traditional finance and digital assets, with the pilot expected to launch in Q3 2026.", + "keywords": [ + "tokenized settlement", + "blockchain", + "SEC approval", + "Nasdaq", + "Russell 1000", + "instant settlement", + "24/7 trading", + "RWA tokenization", + "DeFi", + "distributed ledger technology" + ], + "insights": [ + { + "ticker": "NDAQ", + "sentiment": "positive", + "sentiment_reasoning": "Nasdaq received SEC approval for its tokenized settlement pilot program, positioning it as a leader in blockchain-based equity trading infrastructure and opening new revenue opportunities." + }, + { + "ticker": "AAPL", + "sentiment": "neutral", + "sentiment_reasoning": "Apple is mentioned only as an example of a Russell 1000 company whose shares will be tokenized; no specific impact on the company is discussed." + }, + { + "ticker": "SPY", + "sentiment": "positive", + "sentiment_reasoning": "ETF providers stand to benefit from increased demand for diversified, low-cost investment vehicles as fractional ownership becomes more accessible through tokenization." + } + ] + } + ], + "short_interest": [], + "news": [ + { + "title": "SEC Greenlights Nasdaq's Tokenized Settlement Pilot: What It Means For Investors", + "published": "2026-03-20T21:29:01Z", + "sentiment": "neutral", + "sentiment_reason": "Apple is mentioned only as an example of a Russell 1000 company whose shares will be tokenized; no specific impact on the company is discussed." + }, + { + "title": "Is Apple Stock Your Ticket to Becoming a Millionaire?", + "published": "2026-03-20T08:25:00Z", + "sentiment": "neutral", + "sentiment_reason": "Apple is recognized as an outstanding business with elite innovation, strong financials, pricing power, and an unmatched competitive ecosystem. Howeve" + }, + { + "title": "Warren Buffett's Berkshire Hathaway Is Doubling Its Money in Coca-Cola, American Express, and Moody's Every 21 to 30 Mon", + "published": "2026-03-20T08:06:00Z", + "sentiment": "neutral", + "sentiment_reason": "Mentioned as one of Buffett's largest nominal-dollar gains but not highlighted as a core long-term holding like Coca-Cola, American Express, and Moody" + }, + { + "title": "Could Investing $10,000 in VONG Make You a Millionaire?", + "published": "2026-03-20T04:30:00Z", + "sentiment": "neutral", + "sentiment_reason": "Listed as one of VONG's top four holdings. No specific sentiment is expressed about the company itself." + }, + { + "title": "Calydon Capital Dumps $9 Million of ZoomInfo Amid Stock's 92% Decline Since 2021", + "published": "2026-03-19T19:13:15Z", + "sentiment": "neutral", + "sentiment_reason": "Mentioned as a top holding of Calydon Capital (2.4% of AUM, $13.99 million) but no specific commentary provided. Neutral mention in context of fund's " + } + ] + }, + "market_context": { + "date": "2026-03-21", + "computed_at": "2026-03-21 01:34:46", + "regime": { + "level": "Lean-Bearish", + "composite": -0.325, + "assessment_date": "2026-03-13", + "stale": true, + "core_scores_summary": { + "geopolitics": { + "score": -5, + "direction": "\u2193" + }, + "trade_policy": { + "score": -2, + "direction": "\u2191" + }, + "ai_tech_cycle": { + "score": 3, + "direction": "\u2192" + }, + "inflation": { + "score": 1, + "direction": "\u2193" + }, + "employment": { + "score": -3, + "direction": "\u2193" + }, + "valuation": { + "score": -3, + "direction": "\u2193" + }, + "fed_liquidity": { + "score": 0, + "direction": "\u2192" + }, + "pricing_gaps": { + "score": -4, + "direction": "\u2193" + } + } + }, + "market_stage": { + "spy": { + "stage": 1, + "stage_name": "Base/\u76d8\u6574", + "description": "\u8fc7\u6e21(4\u21921) \u2014 \u4ef7\u683c\u4f4e\u4e8e\u5747\u7ebf\u4f46\u5747\u7ebf\u4e0a\u884c", + "confidence": "L", + "price": 652.17, + "ma_150": 673.83, + "price_vs_ma_pct": -3.21, + "ma_slope_20d_pct": 0.834, + "volume_trend_pct": 4.0, + "ma_period_used": 150 + }, + "qqq": { + "stage": 1, + "stage_name": "Base/\u76d8\u6574", + "description": "\u8fc7\u6e21(4\u21921) \u2014 \u4ef7\u683c\u4f4e\u4e8e\u5747\u7ebf\u4f46\u5747\u7ebf\u4e0a\u884c", + "confidence": "L", + "price": 585.19, + "ma_150": 606.27, + "price_vs_ma_pct": -3.48, + "ma_slope_20d_pct": 0.787, + "volume_trend_pct": 8.0, + "ma_period_used": 150 + }, + "tlt": { + "stage": 3, + "stage_name": "Top/\u7b51\u9876", + "description": "\u7b51\u9876\u9636\u6bb5 \u2014 \u5747\u7ebf\u8d70\u5e73(\u6b64\u524d\u4e0a\u5347)", + "confidence": "M", + "price": 86.09, + "ma_150": 88.69, + "price_vs_ma_pct": -2.93, + "ma_slope_20d_pct": 0.228, + "volume_trend_pct": 12.2, + "ma_period_used": 150 + }, + "bond_signal": "diverging", + "bond_signal_cn": "\u80a1\u503a\u80cc\u79bb \u2014 TLT S3 vs SPY S1\uff0c\u5173\u6ce8\u8f6c\u6298", + "effective_stage": 1, + "effective_stage_name": "Base/\u76d8\u6574" + }, + "sector_rotation": { + "cycle_position": "early_contraction", + "cycle_position_cn": "\u6536\u7f29\u65e9\u671f", + "rankings": [ + { + "etf": "XLE", + "name": "Energy", + "price": 59.98, + "return_20d_pct": 9.29, + "return_60d_pct": 35.67, + "rs_20d_pct": 14.7, + "rs_60d_pct": 40.44, + "rs_composite_pct": 30.14, + "weinstein_stage": 2, + "rank": 1 + }, + { + "etf": "XLU", + "name": "Utilities", + "price": 45.66, + "return_20d_pct": -1.46, + "return_60d_pct": 7.5, + "rs_20d_pct": 3.95, + "rs_60d_pct": 12.27, + "rs_composite_pct": 8.94, + "weinstein_stage": 2, + "rank": 2 + }, + { + "etf": "SMH", + "name": "Semiconductors", + "price": 386.7, + "return_20d_pct": -6.82, + "return_60d_pct": 7.51, + "rs_20d_pct": -1.42, + "rs_60d_pct": 12.28, + "rs_composite_pct": 6.8, + "weinstein_stage": 2, + "rank": 3 + }, + { + "etf": "XLP", + "name": "Consumer Staples", + "price": 81.78, + "return_20d_pct": -6.95, + "return_60d_pct": 5.01, + "rs_20d_pct": -1.55, + "rs_60d_pct": 9.78, + "rs_composite_pct": 5.25, + "weinstein_stage": 1, + "rank": 4 + }, + { + "etf": "XLRE", + "name": "Real Estate", + "price": 41.3, + "return_20d_pct": -5.2, + "return_60d_pct": 2.75, + "rs_20d_pct": 0.2, + "rs_60d_pct": 7.52, + "rs_composite_pct": 4.59, + "weinstein_stage": 1, + "rank": 5 + }, + { + "etf": "XLI", + "name": "Industrials", + "price": 162.34, + "return_20d_pct": -8.4, + "return_60d_pct": 3.37, + "rs_20d_pct": -3.0, + "rs_60d_pct": 8.14, + "rs_composite_pct": 3.68, + "weinstein_stage": 2, + "rank": 6 + }, + { + "etf": "XLB", + "name": "Materials", + "price": 47.27, + "return_20d_pct": -10.73, + "return_60d_pct": 3.51, + "rs_20d_pct": -5.33, + "rs_60d_pct": 8.28, + "rs_composite_pct": 2.84, + "weinstein_stage": 1, + "rank": 7 + }, + { + "etf": "XLC", + "name": "Communication Services", + "price": 112.58, + "return_20d_pct": -3.61, + "return_60d_pct": -3.5, + "rs_20d_pct": 1.79, + "rs_60d_pct": 1.27, + "rs_composite_pct": 1.48, + "weinstein_stage": 1, + "rank": 8 + }, + { + "etf": "XLK", + "name": "Technology", + "price": 136.12, + "return_20d_pct": -3.38, + "return_60d_pct": -6.22, + "rs_20d_pct": 2.03, + "rs_60d_pct": -1.46, + "rs_composite_pct": -0.06, + "weinstein_stage": 1, + "rank": 9 + }, + { + "etf": "XLV", + "name": "Healthcare", + "price": 146.26, + "return_20d_pct": -6.73, + "return_60d_pct": -5.82, + "rs_20d_pct": -1.33, + "rs_60d_pct": -1.05, + "rs_composite_pct": -1.16, + "weinstein_stage": 1, + "rank": 10 + }, + { + "etf": "XLF", + "name": "Financials", + "price": 49.25, + "return_20d_pct": -6.17, + "return_60d_pct": -10.97, + "rs_20d_pct": -0.77, + "rs_60d_pct": -6.2, + "rs_composite_pct": -4.03, + "weinstein_stage": 4, + "rank": 11 + }, + { + "etf": "XLY", + "name": "Consumer Discretionary", + "price": 108.46, + "return_20d_pct": -7.65, + "return_60d_pct": -11.35, + "rs_20d_pct": -2.25, + "rs_60d_pct": -6.58, + "rs_composite_pct": -4.85, + "weinstein_stage": 3, + "rank": 12 + } + ], + "favored_sectors": [ + "XLP", + "XLU" + ], + "avoided_sectors": [ + "SMH", + "XLF", + "XLK", + "XLV", + "XLY" + ] + }, + "strategy": { + "primary": "standby", + "description": "\u5f85\u547d \u2014 \u5b8f\u89c2\u504f\u7a7a+\u5e02\u573a\u76d8\u6574\uff0c\u4e0d\u5165\u573a", + "max_signal": "WAIT", + "allowed_signals": [ + "WAIT" + ], + "call_allowed": false, + "put_allowed": false, + "size_multiplier": 0.0, + "max_new_positions": 0, + "regime_category": "bearish", + "effective_stage": 1 + }, + "meta": { + "tickers_fetched": 15, + "tickers_failed": 0, + "computation_time_s": 1.5 + } + } +} \ No newline at end of file diff --git a/fixtures/charts_sample/assets/AAPL_daily_indicators_2026-03-21_17-27-46.png b/fixtures/charts_sample/assets/AAPL_daily_indicators_2026-03-21_17-27-46.png new file mode 100644 index 0000000..c33977b Binary files /dev/null and b/fixtures/charts_sample/assets/AAPL_daily_indicators_2026-03-21_17-27-46.png differ diff --git a/fixtures/charts_sample/assets/AAPL_daily_price_2026-03-21_17-27-46.png b/fixtures/charts_sample/assets/AAPL_daily_price_2026-03-21_17-27-46.png new file mode 100644 index 0000000..3d1fa9b Binary files /dev/null and b/fixtures/charts_sample/assets/AAPL_daily_price_2026-03-21_17-27-46.png differ diff --git a/fixtures/charts_sample/assets/AAPL_hourly_indicators_2026-03-21_17-27-46.png b/fixtures/charts_sample/assets/AAPL_hourly_indicators_2026-03-21_17-27-46.png new file mode 100644 index 0000000..0c56eba Binary files /dev/null and b/fixtures/charts_sample/assets/AAPL_hourly_indicators_2026-03-21_17-27-46.png differ diff --git a/fixtures/charts_sample/assets/AAPL_hourly_price_2026-03-21_17-27-46.png b/fixtures/charts_sample/assets/AAPL_hourly_price_2026-03-21_17-27-46.png new file mode 100644 index 0000000..d205291 Binary files /dev/null and b/fixtures/charts_sample/assets/AAPL_hourly_price_2026-03-21_17-27-46.png differ diff --git a/fixtures/charts_sample/assets/AAPL_monthly_indicators_2026-03-21_17-27-46.png b/fixtures/charts_sample/assets/AAPL_monthly_indicators_2026-03-21_17-27-46.png new file mode 100644 index 0000000..7f9c456 Binary files /dev/null and b/fixtures/charts_sample/assets/AAPL_monthly_indicators_2026-03-21_17-27-46.png differ diff --git a/fixtures/charts_sample/assets/AAPL_monthly_price_2026-03-21_17-27-46.png b/fixtures/charts_sample/assets/AAPL_monthly_price_2026-03-21_17-27-46.png new file mode 100644 index 0000000..ce6111f Binary files /dev/null and b/fixtures/charts_sample/assets/AAPL_monthly_price_2026-03-21_17-27-46.png differ diff --git a/fixtures/charts_sample/assets/AAPL_weekly_indicators_2026-03-21_17-27-46.png b/fixtures/charts_sample/assets/AAPL_weekly_indicators_2026-03-21_17-27-46.png new file mode 100644 index 0000000..2ebcec5 Binary files /dev/null and b/fixtures/charts_sample/assets/AAPL_weekly_indicators_2026-03-21_17-27-46.png differ diff --git a/fixtures/charts_sample/assets/AAPL_weekly_price_2026-03-21_17-27-46.png b/fixtures/charts_sample/assets/AAPL_weekly_price_2026-03-21_17-27-46.png new file mode 100644 index 0000000..55b4a47 Binary files /dev/null and b/fixtures/charts_sample/assets/AAPL_weekly_price_2026-03-21_17-27-46.png differ diff --git a/free_engine/fetch_all.py b/free_engine/fetch_all.py new file mode 100644 index 0000000..96078ab --- /dev/null +++ b/free_engine/fetch_all.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +"""QuantRadar free-data engine — drop-in replacement for charts fetch_all.py. + +Same CLI contract the quantradar facade expects: + + python fetch_all.py TICKER [SECTOR_ETF] [--output-dir DIR] + +JSON payload on stdout (ENGINE_CONTRACT-compatible), logs on stderr. +Zero paid APIs: Yahoo chart (x2 hosts) + Nasdaq.com + Stooq, aggregated by +per-day median voting; fundamentals fail-open via Yahoo quoteSummary with +Nasdaq summary fallback. VIX via Yahoo "^VIX" (free). + +No third-party imports — stdlib only, so it runs inside the shell's +interpreter without a venv. +""" + +from __future__ import annotations + +import json +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +from free_aggregate import aggregate_bars # noqa: E402 +from free_mechanical import ( # noqa: E402 + action_to_signal, + apply_post_gates, + core, + etf_for_sector, + grade_for, + is_near_earnings, + pct_change_over, +) +from free_sources import SOURCE_NAMES, fetch_all_sources, fetch_fundamentals # noqa: E402 +from market_calendar import completed_bars, completed_session # noqa: E402 + +MIN_BARS = 50 +CACHE_DIR = HERE / ".cache" +SPY = "SPY" + +# Same class-share aliases the shell normalizes (app/quality.py) — keep the +# request ticker and payload ticker in canonical form so the quality gate's +# mismatch check never fires. +_SYMBOL_ALIASES = { + "BRK.B": "BRK-B", + "BF.B": "BF-B", +} + + +def _canonical(symbol: str) -> str: + return _SYMBOL_ALIASES.get(symbol.upper(), symbol.upper()) + + +def _log(msg: str) -> None: + print(f"[free-engine] {msg}", file=sys.stderr, flush=True) + + +def _aggregate_symbol(symbol: str, now: datetime | None = None) -> tuple[dict, dict[str, str]]: + bars_by_source, errors = fetch_all_sources(symbol, days=365, cache_dir=CACHE_DIR, as_of=now) + if not bars_by_source: + raise RuntimeError( + "all free sources failed: " + + "; ".join(f"{k}: {v}" for k, v in sorted(errors.items())) + ) + agg = aggregate_bars(bars_by_source) + agg["bars"] = completed_bars(agg["bars"], now=now, minimum=MIN_BARS) + agg["days"] = len(agg["bars"]) + if set(agg["disagree_days"]) & {row[0] for row in agg["bars"][-50:]}: + raise RuntimeError(f"independent price feeds disagree for {symbol}; score withheld") + if agg["days"] < MIN_BARS: + raise RuntimeError(f"too few bars for {symbol} ({agg['days']} < {MIN_BARS})") + return agg, errors + + +def _reliability(agg: dict, errors: dict[str, str]) -> str: + latest = agg["per_day_sources"].get(agg["bars"][-1][0], []) + agreeing = len({"yahoo" if n.startswith("yahoo_") else n for n in latest if n not in errors}) + recent_disagree = set(agg["disagree_days"]) & {row[0] for row in agg["bars"][-5:]} + if agreeing >= 3 and not recent_disagree: + return "high" + if agreeing >= 2: + return "medium" + return "low" + + +def _state_for(action: str, reason: str) -> dict: + if action == "SETUP": + return {"code": "A", "name": "setup_zone", "reason": reason} + if action == "WAIT": + return {"code": "B", "name": "wait_watch", "reason": reason} + return {"code": "C", "name": "stand_aside", "reason": reason} + + +def _clamp(v: float, lo: float, hi: float) -> float: + return max(lo, min(hi, v)) + + +def build_payload(ticker: str, sector_arg: str | None) -> dict: + warnings: list[str] = [] + t0 = time.time() + batch_now = datetime.now(timezone.utc) + + agg, errors = _aggregate_symbol(ticker, batch_now) + bars = agg["bars"] + for name in SOURCE_NAMES: + if name in errors: + warnings.append(f"source {name} unavailable: {errors[name]}") + if agg["disagree_days"]: + warnings.append( + f"{len(agg['disagree_days'])} day(s) had >3% cross-source spread " + f"(e.g. {agg['disagree_days'][-1]}); median used" + ) + + # Market gate: SPY multi-source aggregate (free, same pipeline) + spy_pct = None + spy_bars_for_gate = None + try: + spy_agg = agg if ticker == SPY else _aggregate_symbol(SPY, batch_now)[0] + spy_bars_for_gate = spy_agg["bars"] + spy_pct = pct_change_over(spy_bars_for_gate, 5) + except Exception as exc: + warnings.append(f"SPY market gate unavailable ({exc}); gate shows unknown") + + # Fundamentals — fail-open (name/sector/earnings) + fund = fetch_fundamentals(ticker, cache_dir=CACHE_DIR) + sector_name = fund.get("sector") + company_name = fund.get("company_name") + earnings_date = fund.get("earnings_date") + + # Sector gate: resolve ETF (arg overrides detection), score it free + sector_etf = (sector_arg or "").strip().upper() or etf_for_sector(sector_name) + sector_action = None + sector_pct = None + if sector_etf: + try: + sec_agg, _ = _aggregate_symbol(sector_etf, batch_now) + sector_pct = pct_change_over(sec_agg["bars"], 5) + sec_core = core(sec_agg["bars"], spy_bars_for_gate) + sector_action = sec_core["action"] + except Exception as exc: + warnings.append(f"sector gate unavailable for {sector_etf}: {exc}") + sector_etf = None + + # VIX via Yahoo ^VIX (free; not on Nasdaq/stocks) + vix_current = None + vix_trend = None + try: + vix_bars, _ = fetch_all_sources("^VIX", days=30, cache_dir=CACHE_DIR, as_of=batch_now) + vix_agg = aggregate_bars(vix_bars) + vc = [b[1] for b in completed_bars(vix_agg["bars"], now=batch_now, minimum=5)] + if vc: + vix_current = vc[-1] + if len(vc) >= 5: + vix_trend = "rising" if vc[-1] > vc[-5] else "falling" + except Exception: + warnings.append("VIX unavailable; risk gauge omitted") + + # Core scoring — exact iOS formula, then earnings/sector gates + c = core(bars, spy_bars_for_gate) + today = bars[-1][0] + earnings_near = is_near_earnings(earnings_date, today) + c = apply_post_gates(c, earnings_near=earnings_near, sector_action=sector_action) + + signal = action_to_signal(c["action"]) + state = _state_for(c["action"], c["reason"]) + adj = c["adjustments"] + + score = float(c["score"]) + base_total = _clamp(50.0 + adj["trend_sma"] + adj["momentum_rsi"] + adj["volume_price"], 0.0, 100.0) + + reliability = _reliability(agg, errors) + if len([n for n in agg["source_coverage"] if n not in errors]) == 1: + warnings.append("single-source data — consensus voting unavailable this run") + + live_sources = sorted(n for n in agg["source_coverage"] if n not in errors) + fetch_ms = int((time.time() - t0) * 1000) + + payload: dict = { + "ticker": ticker.upper(), + "fetch_time": datetime.now(tz=timezone.utc).isoformat(timespec="seconds"), + "data_quality": { + "reliability": reliability, + "timeframes_ok": 1, + "bars": agg["days"], + "market_as_of": bars[-1][0], + "expected_session": completed_session(batch_now), + "market_gate": c["market_gate"], + "sources_live": live_sources, + "sources_failed": sorted(errors.keys()), + "disagree_days": len(agg["disagree_days"]), + # Free sources provide no options chain — omit option_chain_ok so + # the shell reports options as not-actionable without claiming a + # simulated feed (never invent options data). + "warnings": warnings, + }, + "mechanical_scores": { + "final_score": score, + "signal_mechanical": signal, + "state": state, + "base_score": { + "total": base_total, + "trend": { + "total": _clamp(25.0 + adj["trend_sma"], 0.0, 50.0), + "max": 50.0, + }, + "momentum": { + "total": _clamp(25.0 + adj["momentum_rsi"], 0.0, 50.0), + "max": 50.0, + }, + "volume_price": { + "total": _clamp(25.0 + adj["volume_price"], 0.0, 50.0), + "max": 50.0, + "volume_ratio": round(c["vol_ratio"], 3), + }, + }, + "entry_timing": { + "grade": grade_for(c["action"], score), + "total": score, + "max": 100.0, + }, + "detail": { + "rsi14": round(c["rsi"], 2) if c["rsi"] is not None else None, + "pullback_from_sma20_pct": round(c["pullback"], 2), + "sma20": round(c["sma20"], 4), + "sma50": round(c["sma50"], 4), + "last_close": c["last"], + "earnings_forced_wait": c.get("earnings_forced_wait", False), + }, + }, + "indicator_data": { + "generated_at": datetime.now(tz=timezone.utc).isoformat(timespec="seconds"), + "timeframe": "daily", + }, + "daily_bars": [list(row) for row in bars], + "spy_daily_bars": [list(row) for row in (spy_bars_for_gate or [])], + "market_env": { + "spy_change_pct": round(spy_pct, 3) if spy_pct is not None else None, + "market_state": ( + "risk_off" + if c["market_gate"] == "NO" + else ("watch" if c["market_gate"] == "WATCH" else "normal") + if spy_pct is not None + else None + ), + "sector_etf": sector_etf, + "sector_change_pct": round(sector_pct, 3) if sector_pct is not None else None, + "sector_action": sector_action, + "vix_current": round(vix_current, 2) if vix_current is not None else None, + "vix_trend": vix_trend, + }, + "fundamentals": { + "company_name": company_name, + "sector": sector_name, + "earnings_date": earnings_date, + }, + "market_context": { + "earnings_within_window": earnings_near, + "sources_aggregated": live_sources, + "options_note": "no options chain from free sources — options views omitted", + "fetch_ms": fetch_ms, + }, + } + return payload + + +def main(argv: list[str]) -> int: + # tolerate "--output-dir DIR" (charts facade compat; dir unused) + cleaned: list[str] = [] + skip_next = False + for a in argv: + if skip_next: + skip_next = False + continue + if a == "--output-dir": + skip_next = True + continue + cleaned.append(a) + args = cleaned + if not args: + print("usage: fetch_all.py TICKER [SECTOR_ETF]", file=sys.stderr) + return 2 + ticker = _canonical(args[0].strip()) + sector_arg = args[1].strip().upper() if len(args) > 1 else None + + try: + payload = build_payload(ticker, sector_arg) + except Exception as exc: + # Contract-shaped engine error — quality gate will fail this closed. + err = { + "ticker": ticker, + "ok": False, + "error": str(exc)[:400], + "fetch_time": datetime.now(tz=timezone.utc).isoformat(timespec="seconds"), + } + _log(f"engine error: {exc}") + print(json.dumps(err, ensure_ascii=False)) + return 0 + + _log( + f"{ticker} score={payload['mechanical_scores']['final_score']} " + f"signal={payload['mechanical_scores']['signal_mechanical']} " + f"sources={payload['market_context']['sources_aggregated']} " + f"{payload['market_context']['fetch_ms']}ms" + ) + print(json.dumps(payload, ensure_ascii=False, default=str)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/free_engine/free_aggregate.py b/free_engine/free_aggregate.py new file mode 100644 index 0000000..b0b5e3d --- /dev/null +++ b/free_engine/free_aggregate.py @@ -0,0 +1,82 @@ +"""Multi-source aggregation: per-day cross-source voting → consensus series. + +The product promise is "multiple free sources, aggregated into one reliable +series". Rules: + + * A trading day is kept if at least `MIN_AGREE` sources report it, OR if + only one source is alive (single-source fallback is honest, flagged). + * The consensus close is the MEDIAN of available closes — resistant to one + source glitching. Volume is the median too. + * Days where sources disagree beyond `DISAGREE_PCT` are flagged (still kept, + using median, counted toward reliability). +""" + +from __future__ import annotations + +import statistics +import math +from typing import Iterable + +MIN_AGREE = 1 +DISAGREE_PCT = 3.0 # >3% spread between source closes on one day = disagree + + +def _median(values: list[float]) -> float: + return float(statistics.median(values)) + + +def aggregate_bars( + bars_by_source: dict[str, list[tuple[str, float, float]]], +) -> dict: + """Merge raw per-source series into a consensus series + diagnostics. + + Returns dict with: + bars: list[(date, close, volume)] sorted ascending + per_day_sources: dict[date -> source list that reported it] + disagree_days: list[date] where spread exceeded threshold + source_coverage: dict[source -> days reported] + days: total consensus days + """ + by_day: dict[str, dict[str, tuple[float, float]]] = {} + coverage: dict[str, int] = {} + for source, rows in bars_by_source.items(): + coverage[source] = len(rows) + for day, close, volume in rows: + by_day.setdefault(day, {})[source] = (close, volume) + + bars: list[tuple[str, float, float]] = [] + per_day_sources: dict[str, list[str]] = {} + disagree_days: list[str] = [] + + for day in sorted(by_day): + entries = by_day[day] + if len(entries) < MIN_AGREE: + continue + # Yahoo's two hosts serve one underlying feed, so they get one vote. + providers = dict(entries) + if "yahoo_q1" in providers and "yahoo_q2" in providers: + providers.pop("yahoo_q2") + closes = [c for c, _ in providers.values() if math.isfinite(c) and c > 0] + volumes = [v for _, v in providers.values() if math.isfinite(v) and v > 0] + if not closes: + continue + close = _median(closes) + volume = _median(volumes) if volumes else 0.0 + if len(closes) >= 2: + spread = (max(closes) - min(closes)) / min(closes) * 100.0 + if spread > DISAGREE_PCT: + disagree_days.append(day) + bars.append((day, round(close, 6), round(volume, 2))) + per_day_sources[day] = sorted(entries.keys()) + + return { + "bars": bars, + "per_day_sources": per_day_sources, + "disagree_days": disagree_days, + "source_coverage": coverage, + "days": len(bars), + } + + +def consensus_dates(bars: Iterable[tuple[str, float, float]]) -> list[str]: + return [b[0] for b in bars] diff --git a/free_engine/free_mechanical.py b/free_engine/free_mechanical.py new file mode 100644 index 0000000..705ba41 --- /dev/null +++ b/free_engine/free_mechanical.py @@ -0,0 +1,272 @@ +"""Mechanical posture scoring — exact port of iOS FreeMechanicalScorer.core. + +The iOS app and the web product must agree tick-for-tick on the same formula. +Every constant, branch, and clamp below mirrors +ios/QuantRadar/Services/FreeDataRadar.swift (FreeMechanicalScorer) and +ios/QuantRadar/Services/PostureDepth.swift. No invented rules. + +Pure functions, no I/O — unit-testable against hand-computed fixtures. +""" + +from __future__ import annotations + +import math +from typing import Any, Sequence + +# Bars are (date, close, volume) tuples, ascending by date. +Bar = tuple[str, float, float] + +EARNINGS_WINDOW_DAYS = 3 + +# Sector keyword → SPDR ETF (iOS PostureDepth.etf(forSector:)) +_SECTOR_KEYWORDS: tuple[tuple[str, str], ...] = ( + ("technolog", "XLK"), + ("financ", "XLF"), + ("energy", "XLE"), + ("health", "XLV"), + ("cyclical", "XLY"), + ("discretion", "XLY"), + ("defensive", "XLP"), + ("staple", "XLP"), + ("industrial", "XLI"), + ("material", "XLB"), + ("basic", "XLB"), + ("real estate", "XLRE"), + ("utilit", "XLU"), + ("communicat", "XLC"), +) + + +def sma(values: Sequence[float], n: int) -> float: + if len(values) < n: + return 0.0 + return sum(values[-n:]) / float(n) + + +def rsi14(closes: Sequence[float]) -> float | None: + """Swift rsi14: last 15 closes → 14 deltas, simple (non-Wilder) average.""" + if len(closes) <= 15: + return None + window = list(closes[-15:]) + gains = 0.0 + losses = 0.0 + for i in range(1, len(window)): + d = window[i] - window[i - 1] + if d >= 0: + gains += d + else: + losses -= d + avg_gain = gains / 14.0 + avg_loss = losses / 14.0 + if avg_loss == 0: + return 100.0 + rs = avg_gain / avg_loss + return 100.0 - (100.0 / (1.0 + rs)) + + +def pct_change_over(bars: Sequence[Bar], lookback: int = 5) -> float | None: + """Percent change between bars[-lookback] and bars[-1]. + + Mirrors iOS exactly: ``spy[spy.count - 5]`` vs ``spy.last`` (a window of + `lookback` bars, i.e. lookback-1 intervals). + """ + if len(bars) < lookback: + return None + a = bars[-lookback][1] + b = bars[-1][1] + if a <= 0: + return None + return (b - a) / a * 100.0 + + +def _round_half_away(x: float) -> float: + # Swift .rounded() = half away from zero (not banker's rounding) + return math.floor(x + 0.5) if x >= 0 else math.ceil(x - 0.5) + + +def etf_for_sector(sector: str | None) -> str | None: + if not sector: + return None + s = sector.lower() + for keyword, etf in _SECTOR_KEYWORDS: + if keyword in s: + return etf + return None + + +def core(bars: Sequence[Bar], spy_bars: Sequence[Bar] | None) -> dict[str, Any]: + """Exact port of FreeMechanicalScorer.core(bars:spyBars:). + + Returns all intermediates (score/action/label/reason/pullback/rsi/gates) + so the caller can build contract payloads and apply earnings/sector gates. + """ + closes = [b[1] for b in bars] + volumes = [b[2] for b in bars] + last = closes[-1] if closes else 0.0 + sma20 = sma(closes, 20) + sma50 = sma(closes, 50) + rsi = rsi14(closes) + vol_sma = sma(volumes, 20) + vol_ratio = (volumes[-1] / vol_sma) if (vol_sma > 0 and volumes) else 1.0 + pullback = ((sma20 - last) / sma20 * 100.0) if sma20 > 0 else 0.0 + + score = 50.0 + sma_adj = 0.0 + if sma20 > 0 and sma50 > 0: + if last > sma20 and sma20 > sma50: + sma_adj = 18.0 + elif last > sma20: + sma_adj = 8.0 + elif last < sma50: + sma_adj = -18.0 + else: + sma_adj = -8.0 + score += sma_adj + + rsi_adj = 0.0 + if rsi is not None: + if 45.0 <= rsi <= 65.0: + rsi_adj = 12.0 + elif rsi > 70.0: + rsi_adj = -10.0 + elif rsi < 30.0: + rsi_adj = 4.0 + else: + rsi_adj = 2.0 + score += rsi_adj + + vol_adj = 0.0 + if vol_ratio >= 1.2: + vol_adj = 8.0 + elif vol_ratio < 0.7: + vol_adj = -4.0 + score += vol_adj + + market_gate = "UNKNOWN" + spy_pct: float | None = None + spy_adj = 0.0 + if (spy_bars is not None and len(spy_bars) >= 5 + and all(math.isfinite(row[1]) and row[1] > 0 for row in spy_bars[-5:])): + market_gate = "PASS" + a = spy_bars[-5][1] + b = spy_bars[-1][1] + if a > 0: + spy_pct = (b - a) / a * 100.0 + # Two independent checks (NOT elif) — both fire below -6% + if spy_pct is not None and spy_pct < -3.0: + market_gate = "WATCH" + spy_adj -= 10.0 + if spy_pct is not None and spy_pct < -6.0: + market_gate = "NO" + spy_adj -= 15.0 + score += spy_adj + + score = min(95.0, max(5.0, _round_half_away(score))) + + if market_gate == "NO" or score < 38.0: + action, label = "NO", "Avoid" + reason = "Posture is weak or the market gate is blocked — do not force a trade." + elif market_gate == "UNKNOWN": + action, label = "WAIT", "Wait & Watch" + reason = "SPY market data is unavailable — wait until the market gate can be checked." + elif score >= 68.0 and -2.0 <= pullback <= 8.0 and (rsi if rsi is not None else 50.0) < 68.0: + action, label = "SETUP", "Setup zone" + reason = ( + f"Trend supportive, RSI {(rsi if rsi is not None else 0.0):.0f}, " + f"pullback {max(0.0, pullback):.1f}% from SMA20." + ) + else: + action, label = "WAIT", "Wait & Watch" + reason = ( + f"Score {score:.0f} — timing not fully aligned " + f"(RSI {(rsi if rsi is not None else 0.0):.0f})." + ) + + stock_gate = "PASS" if action == "SETUP" else ("NO" if action == "NO" else "WATCH") + + return { + "score": score, + "action": action, + "label": label, + "reason": reason, + "market_gate": market_gate, + "stock_gate": stock_gate, + "spy_pct": spy_pct, + "pullback": pullback, + "rsi": rsi, + "vol_ratio": vol_ratio, + "sma20": sma20, + "sma50": sma50, + "last": last, + "adjustments": { + "trend_sma": sma_adj, + "momentum_rsi": rsi_adj, + "volume_price": vol_adj, + "market_gate": spy_adj, + }, + } + + +def is_near_earnings(earnings_date: str | None, today: str) -> bool: + """0..3 NY calendar days ahead — PostureDepth.isNearEarnings port. + + Dates are ISO strings; day math is calendar-date arithmetic (no tz math + needed for a 3-day window granularity). + """ + if not earnings_date: + return False + try: + from datetime import date + + e = date.fromisoformat(str(earnings_date)[:10]) + t = date.fromisoformat(str(today)[:10]) + delta = (e - t).days + return 0 <= delta <= EARNINGS_WINDOW_DAYS + except ValueError: + return False + + +def apply_post_gates( + c: dict[str, Any], + *, + earnings_near: bool, + sector_action: str | None, +) -> dict[str, Any]: + """iOS score() post-processing: earnings window + sector gate overrides.""" + out = dict(c) + earnings_forced = False + if earnings_near and out["action"] == "SETUP": + out["action"] = "WAIT" + out["label"] = "Wait & Watch" + out["reason"] = "Earnings within 3 days — radar stays on wait." + out["stock_gate"] = "WATCH" + earnings_forced = True + if sector_action == "NO" and out["action"] == "SETUP": + out["action"] = "WAIT" + out["label"] = "Wait & Watch" + out["reason"] = "Sector posture is blocked — wait even if the stock looks ready." + out["stock_gate"] = "WATCH" + out["earnings_forced_wait"] = earnings_forced + return out + + +def action_to_signal(action: str) -> str: + """Map iOS action vocabulary to ENGINE_CONTRACT signals. + + SETUP → PROBE (gates allow engagement — small/confirm, never "full size") + WAIT → WAIT + NO → NO + """ + if action == "SETUP": + return "PROBE" + if action == "WAIT": + return "WAIT" + return "NO" + + +def grade_for(action: str, score: float) -> str: + if action == "SETUP": + return "A" + if score >= 55.0: + return "B" + return "C" diff --git a/free_engine/free_sources.py b/free_engine/free_sources.py new file mode 100644 index 0000000..1b94fbe --- /dev/null +++ b/free_engine/free_sources.py @@ -0,0 +1,538 @@ +"""Free multi-source OHLCV acquisition layer — stdlib only. + +Sources (all free, no API keys): + 1. Yahoo Finance chart API — query1 host + 2. Yahoo Finance chart API — query2 host (independent edge) + 3. Nasdaq.com chart API (US-listed equities) + 4. Stooq daily CSV (US + international coverage) + +Every source runs in parallel with per-source timeout and retry. Results are +raw (date, close, volume) series; aggregation/voting happens in free_aggregate. + +A small disk cache (TTL minutes) absorbs upstream rate-limit bursts — the +quantradar facade may invoke this subprocess on every live analyze. +""" + +from __future__ import annotations + +import gzip +import io +import json +import os +import ssl +import threading +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +# Stooq is opt-out: it now serves a JS-challenge page to non-browser agents, +# so it cannot be a reliable free source. fetch_stooq stays for a future +# proxy; it is not in the default rotation. +SOURCE_NAMES = ("yahoo_q1", "yahoo_q2", "nasdaq") + +_UA_YAHOO = ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) QuantRadar/1.0" +) +_UA_NASDAQ = ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15" +) +_UA_PLAIN = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) QuantRadar/1.0" + +_SSL_CTX = ssl.create_default_context() +_LOCK = threading.Lock() + + +class SourceError(RuntimeError): + """One source failed; message carries the reason.""" + + +def _http_get(url: str, headers: dict[str, str], timeout: float = 8.0) -> bytes: + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req, timeout=timeout, context=_SSL_CTX) as resp: + raw = resp.read() + except urllib.error.HTTPError as exc: + raise SourceError(f"http {exc.code}") from exc + except Exception as exc: # timeout / DNS / reset / ssl + raise SourceError(f"net {type(exc).__name__}") from exc + if raw[:2] == b"\x1f\x8b": + try: + raw = gzip.GzipFile(fileobj=io.BytesIO(raw)).read() + except OSError: + pass + return raw + + +def _to_float(val: Any) -> float | None: + if val is None: + return None + if isinstance(val, bool): + return None + if isinstance(val, (int, float)): + return float(val) + if isinstance(val, str): + cleaned = val.replace(",", "").replace("$", "").strip() + if not cleaned or cleaned in {"null", "N/A", "None"}: + return None + try: + return float(cleaned) + except ValueError: + return None + return None + + +def yahoo_range(days: int) -> str: + if days <= 30: + return "1mo" + if days <= 100: + return "3mo" + if days <= 200: + return "6mo" + return "1y" + + +# --------------------------------------------------------------------------- +# Yahoo chart API +# --------------------------------------------------------------------------- + + +def _parse_yahoo(raw: bytes, source: str) -> list[tuple[str, float, float]]: + try: + root = json.loads(raw.decode("utf-8", errors="replace")) + except json.JSONDecodeError as exc: + raise SourceError(f"{source}: bad json") from exc + chart = root.get("chart") if isinstance(root, dict) else None + if not isinstance(chart, dict): + raise SourceError(f"{source}: no chart node") + if chart.get("error"): + err = chart["error"] + desc = err.get("description") if isinstance(err, dict) else str(err) + raise SourceError(f"{source}: api error {str(desc)[:120]}") + results = chart.get("result") + if not isinstance(results, list) or not results: + raise SourceError(f"{source}: empty result") + result = results[0] + timestamps = result.get("timestamp") + indicators = result.get("indicators") or {} + quotes = indicators.get("quote") if isinstance(indicators, dict) else None + if not isinstance(timestamps, list) or not isinstance(quotes, list) or not quotes: + raise SourceError(f"{source}: malformed payload") + quote = quotes[0] + closes = quote.get("close") if isinstance(quote, dict) else None + volumes = quote.get("volume") if isinstance(quote, dict) else None + if not isinstance(closes, list) or not isinstance(volumes, list): + raise SourceError(f"{source}: missing close/volume") + bars: list[tuple[str, float, float]] = [] + for i, ts in enumerate(timestamps): + c = _to_float(closes[i]) if i < len(closes) else None + v = _to_float(volumes[i]) if i < len(volumes) else 0.0 + if c is None or c <= 0: + continue + day = datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%Y-%m-%d") + bars.append((day, c, max(0.0, v or 0.0))) + return bars + + +def fetch_yahoo(symbol: str, host: str, days: int = 200) -> list[tuple[str, float, float]]: + source = "yahoo_q1" if "query1" in host else "yahoo_q2" + encoded = urllib.parse.quote(symbol, safe="") + url = ( + f"https://{host}/v8/finance/chart/{encoded}" + f"?range={yahoo_range(days)}&interval=1d&includePrePost=false" + ) + headers = {"User-Agent": _UA_YAHOO, "Accept": "application/json"} + data = _http_get(url, headers) + return _parse_yahoo(data, source) + + +# --------------------------------------------------------------------------- +# Nasdaq.com chart API +# --------------------------------------------------------------------------- + + +def _parse_nasdaq(raw: bytes) -> list[tuple[str, float, float]]: + try: + root = json.loads(raw.decode("utf-8", errors="replace")) + except json.JSONDecodeError as exc: + raise SourceError("nasdaq: bad json") from exc + data = root.get("data") if isinstance(root, dict) else None + chart = data.get("chart") if isinstance(data, dict) else None + if not isinstance(chart, list): + raise SourceError("nasdaq: no chart array") + bars: list[tuple[str, float, float]] = [] + for point in chart: + if not isinstance(point, dict): + continue + y = _to_float(point.get("y")) + x = _to_float(point.get("x")) + if y is None or y <= 0 or x is None: + continue + z = point.get("z") + volume = _to_float(z.get("volume")) if isinstance(z, dict) else None + day = datetime.fromtimestamp(x / 1000.0, tz=timezone.utc).strftime("%Y-%m-%d") + bars.append((day, y, max(0.0, volume or 0.0))) + bars.sort(key=lambda b: b[0]) + return bars + + +def fetch_nasdaq(symbol: str, days: int = 200) -> list[tuple[str, float, float]]: + # Nasdaq chart API uses dot class shares; skip symbols it cannot serve. + if "=" in symbol or "." in symbol: + raise SourceError("nasdaq: unsupported symbol form") + nasdaq_symbol = symbol.replace("-", ".") + end = datetime.now(tz=timezone.utc) + start = end - timedelta(days=max(60, days)) + fmt = "%Y-%m-%d" + url = ( + "https://api.nasdaq.com/api/quote/" + f"{urllib.parse.quote(nasdaq_symbol, safe='')}/chart" + f"?assetclass=stocks&fromdate={start.strftime(fmt)}&todate={end.strftime(fmt)}" + ) + headers = { + "User-Agent": _UA_NASDAQ, + "Accept": "application/json, text/plain, */*", + "Origin": "https://www.nasdaq.com", + "Referer": "https://www.nasdaq.com/", + } + data = _http_get(url, headers) + return _parse_nasdaq(data) + + +# --------------------------------------------------------------------------- +# Stooq daily CSV (US symbols need .US suffix) +# --------------------------------------------------------------------------- + + +def fetch_stooq(symbol: str, days: int = 200) -> list[tuple[str, float, float]]: + if symbol.startswith("^") or "." in symbol or "=" in symbol: + raise SourceError("stooq: unsupported symbol form") + stooq_symbol = symbol.lower().replace("-", ".") + if stooq_symbol not in {"spy", "qqq", "dia", "iwm"}: + stooq_symbol = f"{stooq_symbol}.us" + end = datetime.now(tz=timezone.utc) + start = end - timedelta(days=max(60, days)) + url = ( + "https://stooq.com/q/d/l/" + f"?s={urllib.parse.quote(stooq_symbol, safe='')}" + f"&d1={start.strftime('%Y%m%d')}&d2={end.strftime('%Y%m%d')}&i=d" + ) + data = _http_get(url, {"User-Agent": _UA_PLAIN, "Accept": "text/csv"}) + text = data.decode("utf-8", errors="replace") + lines = [ln.strip() for ln in text.splitlines() if ln.strip()] + if len(lines) < 2 or not lines[0].lower().startswith("date"): + raise SourceError("stooq: no csv data") + header = [h.strip().lower() for h in lines[0].split(",")] + try: + di, ci, vi = header.index("date"), header.index("close"), header.index("volume") + except ValueError as exc: + raise SourceError("stooq: unexpected header") from exc + bars: list[tuple[str, float, float]] = [] + for line in lines[1:]: + parts = line.split(",") + if len(parts) <= max(di, ci, vi): + continue + c = _to_float(parts[ci]) + v = _to_float(parts[vi]) + if c is None or c <= 0: + continue + bars.append((parts[di], c, max(0.0, v or 0.0))) + bars.sort(key=lambda b: b[0]) + return bars + + +# --------------------------------------------------------------------------- +# Fundamentals (fail-open): company name, sector, earnings date +# --------------------------------------------------------------------------- + + +def _parse_yahoo_fundamentals(raw: bytes) -> dict[str, Any]: + root = json.loads(raw.decode("utf-8", errors="replace")) + qs = root.get("quoteSummary") if isinstance(root, dict) else None + results = qs.get("result") if isinstance(qs, dict) else None + if not isinstance(results, list) or not results: + return {} + result = results[0] + out: dict[str, Any] = {} + profile = result.get("assetProfile") + if isinstance(profile, dict): + sector = profile.get("sector") + name = profile.get("longBusinessSummary") and None # never fabricate + if isinstance(sector, str) and sector.strip(): + out["sector"] = sector.strip() + _ = name + cal = result.get("calendarEvents") + if isinstance(cal, dict): + earn = cal.get("earnings") + if isinstance(earn, dict): + ed = earn.get("earningsDate") + if isinstance(ed, list) and ed: + first = ed[0] + raw_ts = first.get("raw") if isinstance(first, dict) else None + if isinstance(raw_ts, (int, float)) and raw_ts > 0: + out["earnings_date"] = datetime.fromtimestamp( + float(raw_ts), tz=timezone.utc + ).strftime("%Y-%m-%d") + return out + + +def fetch_yahoo_fundamentals(symbol: str) -> dict[str, Any]: + encoded = urllib.parse.quote(symbol, safe="") + url = ( + "https://query1.finance.yahoo.com/v10/finance/quoteSummary/" + f"{encoded}?modules=assetProfile,calendarEvents" + ) + try: + data = _http_get(url, {"User-Agent": _UA_YAHOO, "Accept": "application/json"}, timeout=6.0) + return _parse_yahoo_fundamentals(data) + except Exception: + return {} + + +def fetch_yahoo_chart_meta(symbol: str) -> dict[str, Any]: + """Cheap free metadata from the chart endpoint (name, exchange).""" + encoded = urllib.parse.quote(symbol, safe="") + url = ( + f"https://query1.finance.yahoo.com/v8/finance/chart/{encoded}" + "?range=5d&interval=1d" + ) + try: + data = _http_get(url, {"User-Agent": _UA_YAHOO, "Accept": "application/json"}, timeout=6.0) + root = json.loads(data.decode("utf-8", errors="replace")) + results = ((root.get("chart") or {}).get("result")) or [] + meta = results[0].get("meta") if results else None + if not isinstance(meta, dict): + return {} + out: dict[str, Any] = {} + name = meta.get("longName") or meta.get("shortName") + if isinstance(name, str) and name.strip(): + out["company_name"] = name.strip() + return out + except Exception: + return {} + + +def fetch_nasdaq_info(symbol: str) -> dict[str, Any]: + """Nasdaq info endpoint — companyName (free).""" + if "=" in symbol or "." in symbol: + return {} + nasdaq_symbol = symbol.replace("-", ".") + url = ( + "https://api.nasdaq.com/api/quote/" + f"{urllib.parse.quote(nasdaq_symbol, safe='')}/info?assetclass=stocks" + ) + headers = { + "User-Agent": _UA_NASDAQ, + "Accept": "application/json, text/plain, */*", + "Origin": "https://www.nasdaq.com", + "Referer": "https://www.nasdaq.com/", + } + try: + data = _http_get(url, headers, timeout=6.0) + root = json.loads(data.decode("utf-8", errors="replace")) + d = root.get("data") if isinstance(root, dict) else None + out: dict[str, Any] = {} + name = d.get("companyName") if isinstance(d, dict) else None + if isinstance(name, str) and name.strip(): + out["company_name"] = name.strip() + return out + except Exception: + return {} + + +def fetch_nasdaq_summary(symbol: str) -> dict[str, Any]: + if "=" in symbol: + return {} + nasdaq_symbol = symbol.replace("-", ".") + for asset in ("stocks", "etf"): + url = ( + "https://api.nasdaq.com/api/quote/" + f"{urllib.parse.quote(nasdaq_symbol, safe='')}/summary?assetclass={asset}" + ) + headers = { + "User-Agent": _UA_NASDAQ, + "Accept": "application/json, text/plain, */*", + "Origin": "https://www.nasdaq.com", + "Referer": "https://www.nasdaq.com/", + } + try: + data = _http_get(url, headers, timeout=6.0) + root = json.loads(data.decode("utf-8", errors="replace")) + d = root.get("data") if isinstance(root, dict) else None + summary = d.get("summaryData") if isinstance(d, dict) else None + if not isinstance(summary, dict): + continue + out: dict[str, Any] = {} + sector = summary.get("Sector") + if isinstance(sector, dict): + v = sector.get("value") + if isinstance(v, str) and v.strip(): + out["sector"] = v.strip() + company = summary.get("Company Name") + if isinstance(company, dict): + v = company.get("value") + if isinstance(v, str) and v.strip(): + out["company_name"] = v.strip() + if out: + return out + except Exception: + continue + return {} + + +# --------------------------------------------------------------------------- +# Orchestration: parallel fetch + disk cache +# --------------------------------------------------------------------------- + +_CACHE_LOCK = threading.Lock() +_CACHE_TTL_SEC = 600 # 10 min — absorbs rate-limit bursts on repeated scans + + +def _cache_path(cache_dir: Path, key: str) -> Path: + safe = "".join(ch if ch.isalnum() else "_" for ch in key) + return cache_dir / f"{safe}.json" + + +def _cache_read(cache_dir: Path | None, key: str, *, not_before: float = 0) -> Any | None: + if cache_dir is None: + return None + p = _cache_path(cache_dir, key) + try: + obj = json.loads(p.read_text(encoding="utf-8")) + except Exception: + return None + if (not isinstance(obj, dict) or time.time() - float(obj.get("t", 0)) > _CACHE_TTL_SEC + or float(obj.get("t", 0)) < not_before): + return None + return obj.get("v") + + +def _cache_write(cache_dir: Path | None, key: str, value: Any, *, fetched_at: float | None = None) -> None: + if cache_dir is None: + return + try: + cache_dir.mkdir(parents=True, exist_ok=True) + with _CACHE_LOCK: + with tempfile.NamedTemporaryFile(mode="w", dir=cache_dir, prefix=".cache-", suffix=".tmp", delete=False) as temporary: + temp_path = Path(temporary.name) + try: + json.dump({"t": time.time() if fetched_at is None else fetched_at, "v": value}, temporary, allow_nan=False) + temporary.flush() + os.replace(temp_path, _cache_path(cache_dir, key)) + finally: + temp_path.unlink(missing_ok=True) + _cache_prune(cache_dir) + except OSError: + pass + + +def _cache_prune(cache_dir: Path) -> None: + try: + files = sorted(cache_dir.glob("*.json"), key=lambda p: p.stat().st_mtime) + for stale in files[:-800]: + stale.unlink(missing_ok=True) + except OSError: + pass + + +def _fetch_source(name: str, symbol: str, days: int) -> list[tuple[str, float, float]]: + last_err = "unknown" + for attempt in range(2): + try: + if name == "yahoo_q1": + return fetch_yahoo(symbol, "query1.finance.yahoo.com", days) + if name == "yahoo_q2": + return fetch_yahoo(symbol, "query2.finance.yahoo.com", days) + if name == "nasdaq": + return fetch_nasdaq(symbol, days) + if name == "stooq": + return fetch_stooq(symbol, days) + raise SourceError(f"unknown source {name}") + except SourceError as exc: + last_err = str(exc) + if "http 429" in last_err or "http 403" in last_err or "net " in last_err: + time.sleep(0.9 * (attempt + 1)) + continue + raise + raise SourceError(last_err) + + +def fetch_all_sources( + symbol: str, + days: int = 200, + cache_dir: Path | None = None, + as_of: datetime | None = None, +) -> tuple[dict[str, list[tuple[str, float, float]]], dict[str, str]]: + """Fetch every source in parallel. + + Returns (bars_by_source, errors_by_source). Sources that produced fewer + than 10 rows are treated as failed (too thin to be useful). + """ + bars: dict[str, list[tuple[str, float, float]]] = {} + errors: dict[str, str] = {} + from market_calendar import completed_bars, completed_session, session_close + as_of = as_of or datetime.now(timezone.utc) + cutoff = completed_session(as_of) + if cutoff is None: + return {}, {name: "calendar coverage unavailable" for name in SOURCE_NAMES} + not_before = session_close(cutoff).timestamp() + + def worker(name: str) -> tuple[str, list[tuple[str, float, float]] | None, str]: + cached = _cache_read(cache_dir, f"{name}:{symbol}", not_before=not_before) + if cached is not None: + rows = [(str(r[0]), float(r[1]), float(r[2])) for r in cached] + try: + completed_bars(rows, now=as_of, minimum=min(139, max(5, days // 2))) + return name, rows, "" + except ValueError: + pass + try: + started = time.time() + rows = _fetch_source(name, symbol, days) + if len(rows) < 10: + return name, None, f"{name}: too few bars ({len(rows)})" + _cache_write(cache_dir, f"{name}:{symbol}", rows, fetched_at=started) + return name, rows, "" + except SourceError as exc: + return name, None, str(exc) + except Exception as exc: # never let one source sink the batch + return name, None, f"{name}: {type(exc).__name__}" + + with ThreadPoolExecutor(max_workers=len(SOURCE_NAMES)) as pool: + futures = [pool.submit(worker, n) for n in SOURCE_NAMES] + for fut in as_completed(futures): + name, rows, err = fut.result() + if rows is not None: + bars[name] = rows + else: + errors[name] = err + return bars, errors + + +def fetch_fundamentals(symbol: str, cache_dir: Path | None = None) -> dict[str, Any]: + """Company name / sector / earnings date. Fail-open: {} is acceptable.""" + cached = _cache_read(cache_dir, f"fund:{symbol}") + if cached is not None: + return cached if isinstance(cached, dict) else {} + out: dict[str, Any] = fetch_yahoo_fundamentals(symbol) + # Sector/name from Nasdaq summary (free) when Yahoo quoteSummary is dead + nasdaq_sum = fetch_nasdaq_summary(symbol) + for key, val in nasdaq_sum.items(): + out.setdefault(key, val) + # Company name fallbacks: Yahoo chart meta → Nasdaq info + if not out.get("company_name"): + meta = fetch_yahoo_chart_meta(symbol) + if meta.get("company_name"): + out["company_name"] = meta["company_name"] + if not out.get("company_name"): + info = fetch_nasdaq_info(symbol) + if info.get("company_name"): + out["company_name"] = info["company_name"] + _cache_write(cache_dir, f"fund:{symbol}", out) + return out diff --git a/free_engine/market_calendar.py b/free_engine/market_calendar.py new file mode 100644 index 0000000..bdfdca2 --- /dev/null +++ b/free_engine/market_calendar.py @@ -0,0 +1,57 @@ +"""Completed NYSE sessions shared with the on-device radar calendar.""" + +import json +import math +from datetime import date, datetime, timedelta, timezone +from pathlib import Path +from zoneinfo import ZoneInfo + +CALENDAR = json.loads(Path(__file__).with_name("nyse_calendar.json").read_text()) + + +def session_window(end: str, count: int) -> list[str] | None: + day = date.fromisoformat(end) + days = [] + while len(days) < count: + if not CALENDAR["first_year"] <= day.year <= CALENDAR["last_year"]: + return None + if day.weekday() < 5 and day.isoformat() not in CALENDAR["holidays"]: + days.append(day.isoformat()) + day -= timedelta(days=1) + return list(reversed(days)) + + +def session_close(day: str) -> datetime: + hour = 13 if day in CALENDAR["early_closes"] else 16 + return datetime.fromisoformat(day).replace(hour=hour, tzinfo=ZoneInfo("America/New_York")) + + +def completed_session(now: datetime | None = None) -> str | None: + local = (now or datetime.now(timezone.utc)).astimezone(ZoneInfo("America/New_York")) + if not CALENDAR["first_year"] <= local.year <= CALENDAR["last_year"]: + return None + day = local.date() + for _ in range(10): + key = day.isoformat() + close_hour = 13 if key in CALENDAR["early_closes"] else 16 + if (day.weekday() < 5 and key not in CALENDAR["holidays"] + and (day < local.date() or local.hour >= close_hour)): + return key + day -= timedelta(days=1) + return None + + +def completed_bars(bars, *, now: datetime | None = None, minimum: int = 50): + """Reject stale/invalid history and exclude the still-open daily candle.""" + expected = completed_session(now) + if expected is None: + raise ValueError("NYSE calendar coverage unavailable") + selected = [row for row in bars if row[0] <= expected] + if len(selected) < minimum or selected[-1][0] != expected: + raise ValueError(f"daily data does not cover completed session {expected}") + if any(not math.isfinite(row[1]) or row[1] <= 0 + or not math.isfinite(row[2]) or row[2] < 0 for row in selected): + raise ValueError("invalid daily prices or volumes") + if any(a[0] >= b[0] for a, b in zip(selected, selected[1:])): + raise ValueError("daily bars must have unique ascending dates") + return selected diff --git a/free_engine/nyse_calendar.json b/free_engine/nyse_calendar.json new file mode 100644 index 0000000..63de446 --- /dev/null +++ b/free_engine/nyse_calendar.json @@ -0,0 +1,12 @@ +{ + "source": "https://www.nyse.com/trade/hours-calendars", + "verified_on": "2026-09-05", + "first_year": 2026, + "last_year": 2028, + "holidays": [ + "2026-01-01", "2026-01-19", "2026-02-16", "2026-04-03", "2026-05-25", "2026-06-19", "2026-07-03", "2026-09-07", "2026-11-26", "2026-12-25", + "2027-01-01", "2027-01-18", "2027-02-15", "2027-03-26", "2027-05-31", "2027-06-18", "2027-07-05", "2027-09-06", "2027-11-25", "2027-12-24", + "2028-01-17", "2028-02-21", "2028-04-14", "2028-05-29", "2028-06-19", "2028-07-04", "2028-09-04", "2028-11-23", "2028-12-25" + ], + "early_closes": ["2026-11-27", "2026-12-24", "2027-11-26", "2028-07-03", "2028-11-24"] +} diff --git a/free_engine/replay.py b/free_engine/replay.py new file mode 100644 index 0000000..2d263c8 --- /dev/null +++ b/free_engine/replay.py @@ -0,0 +1,129 @@ +"""Immutable, dated mechanical replay and downloadable daily charts.""" + +from __future__ import annotations + +import csv +import hashlib +import html +import io +import json +import zipfile + +from free_engine.free_mechanical import core +from free_engine.market_calendar import session_window + +FORMULA_VERSION = "mechanical-2-market-required" +CSV_FIELDS = ("date", "close", "volume", "score", "action", "market_gate", "rsi14", "sma20", "sma50", "note") + + +def build_replay(payload: dict, *, sessions: int = 90, cutoff: str | None = None) -> list[dict]: + cutoff = cutoff or payload["data_quality"]["market_as_of"] + bars = [tuple(row) for row in payload.get("daily_bars", []) if row[0] <= cutoff] + spy = [tuple(row) for row in payload.get("spy_daily_bars", []) if row[0] <= cutoff] + dates = session_window(cutoff, sessions) + if dates is None: + raise ValueError("calendar coverage unavailable for requested replay") + out = [] + for day in dates: + window = session_window(day, 50) + stock_slice = [row for row in bars if row[0] <= day] + spy_slice = [row for row in spy if row[0] <= day] + row = {key: None for key in CSV_FIELDS} + row.update(date=day, action="UNKNOWN", market_gate="UNKNOWN", note="Missing or incomplete daily history") + if stock_slice and stock_slice[-1][0] == day: + row.update(close=stock_slice[-1][1], volume=stock_slice[-1][2]) + if window and [b[0] for b in stock_slice[-50:]] == window: + expected_spy = session_window(day, 5) + if [b[0] for b in spy_slice[-5:]] != expected_spy: + spy_slice = [] + result = core(stock_slice, spy_slice or None) + row.update(score=result["score"], action=result["action"], market_gate=result["market_gate"], + rsi14=result["rsi"], sma20=result["sma20"], sma50=result["sma50"], + note="Mechanical reconstruction; historical earnings and sector gates unavailable") + out.append(row) + return out + + +def _line_chart(title: str, rows: list[dict], series: list[tuple[str, str]], *, fixed_range=None) -> str: + width, height = 960, 340 + values = [r[key] for r in rows for key, _ in series if r.get(key) is not None] + low, high = fixed_range or (min(values, default=0), max(values, default=1)) + span = high - low or 1 + parts = [f'', + f'{html.escape(title)}', + '', + f'{html.escape(title)}', + ''] + for key, color in series: + segment = [] + for i, row in enumerate(rows): + value = row.get(key) + if value is None: + if segment: + parts.append(f'') + segment = [] + else: + x = 48 + 888 * i / max(1, len(rows) - 1) + y = 280 - 220 * (value - low) / span + segment.append(f"{x:.2f},{y:.2f}") + if segment: + parts.append(f'') + for y, value in ((65, high), (280, low)): + parts.append(f'{value:.1f}') + if rows: + for x, day in ((48, rows[0]["date"]), (850, rows[-1]["date"])): + parts.append(f'{day}') + legend = " · ".join(key for key, _ in series) + parts.append(f'{html.escape(legend)}') + return "".join(parts) + + +def build_report_bundle(payload: dict, *, include_csv: bool = False) -> dict: + from app.contract import map_charts_payload + from app.quality import assess_charts_payload + + ticker = payload["ticker"] + cutoff = payload["data_quality"]["market_as_of"] + replay = build_replay(payload, cutoff=cutoff) + if len(replay) != 90 or any(row["close"] is None or row["score"] is None for row in replay): + raise ValueError("A full report requires 139 consecutive completed trading sessions") + quality = assess_charts_payload(payload, ticker) + if not quality["usable"]: + raise ValueError("Snapshot did not pass the data quality gate") + canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), allow_nan=False) + report = { + "report_version": 2, "formula_version": FORMULA_VERSION, "ticker": ticker, "as_of": cutoff, + "input_sha256": hashlib.sha256(canonical.encode()).hexdigest(), + "snapshot": map_charts_payload(payload, mode="live", quality=quality), + "replay": replay, "inputs": payload, + "notes": ["Reconstructed daily mechanical posture, not previously published signals or trading performance.", + "Historical earnings and sector gates are unavailable; today's fundamentals are not applied to past sessions.", + "Daily close and volume only. No invented candles, intraday charts or options data.", + "Data vendors may revise historical prices; this purchased snapshot remains fixed."], + } + assets = {"report.json": json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False)} + assets["price.svg"] = _line_chart(f"{ticker} · daily close / SMA20 / SMA50", replay, + [("close", "#50e3ad"), ("sma20", "#7aa7ff"), ("sma50", "#f6bb60")]) + assets["rsi.svg"] = _line_chart(f"{ticker} · RSI14", replay, [("rsi14", "#7aa7ff")], fixed_range=(0, 100)) + assets["volume.svg"] = _line_chart(f"{ticker} · daily volume", replay, [("volume", "#50e3ad")]) + colors = {"SETUP": "#50e3ad", "WAIT": "#f6bb60", "NO": "#ee6e7d", "UNKNOWN": "#738397"} + cells = "".join(f'{r["date"]}: {r["action"]}' for i, r in enumerate(replay)) + assets["posture.svg"] = '90-session reconstructed postureSETUP green · WAIT amber · NO red · UNKNOWN gray' + cells + '' + if include_csv: + output = io.StringIO(newline="") + writer = csv.DictWriter(output, fieldnames=CSV_FIELDS) + writer.writeheader() + writer.writerows(replay) + assets["replay.csv"] = output.getvalue() + return {"ticker": ticker, "as_of": cutoff, "sessions": len(replay), "assets": assets, + "input_sha256": report["input_sha256"], "formula_version": FORMULA_VERSION} + + +def zip_bundle(bundle: dict) -> bytes: + output = io.BytesIO() + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for name, text in bundle["assets"].items(): + info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, text.encode("utf-8")) + return output.getvalue() diff --git a/ios/QuantRadar/App/QuantRadarApp.swift b/ios/QuantRadar/App/QuantRadarApp.swift index ab4a206..423fe49 100644 --- a/ios/QuantRadar/App/QuantRadarApp.swift +++ b/ios/QuantRadar/App/QuantRadarApp.swift @@ -5,7 +5,9 @@ struct QuantRadarApp: App { @StateObject private var radar = RadarService() @StateObject private var watchlist = WatchlistStore() @StateObject private var purchases = PurchaseStore() + @StateObject private var journal = DecisionJournal() @AppStorage("hasSeenOnboarding") private var hasSeenOnboarding = false + @Environment(\.scenePhase) private var scenePhase var body: some Scene { WindowGroup { @@ -19,8 +21,38 @@ struct QuantRadarApp: App { .environmentObject(radar) .environmentObject(watchlist) .environmentObject(purchases) + .environmentObject(journal) .preferredColorScheme(.dark) - .onAppear { ReviewPrompt.recordLaunch() } + .onAppear { + ReviewPrompt.recordLaunch() + #if DEBUG + if ProcessInfo.processInfo.arguments.contains("-qr-ui-test-reset") { + journal.clear() + AppAccess.resetPreviewTicker() + purchases.debugForceUnlocked = false + purchases.debugForceLivePlus = false + hasSeenOnboarding = true + } + if ScreenshotLaunch.showOnboarding { + hasSeenOnboarding = false + } else if ScreenshotLaunch.isEnabled { + hasSeenOnboarding = true + ScreenshotLaunch.seedJournalIfNeeded(journal) + } + if ProcessInfo.processInfo.arguments.contains("-qr-force-unlock") { + hasSeenOnboarding = true + purchases.debugForceUnlocked = true + } + if ProcessInfo.processInfo.arguments.contains("-qr-buy-unlock") { + hasSeenOnboarding = true + Task { _ = await purchases.purchaseUnlock() } + } + #endif + } + .onChange(of: scenePhase) { _, phase in + guard phase == .active, purchases.effectiveUnlocked, !watchlist.items.isEmpty else { return } + Task { await watchlist.refreshScores(using: radar) } + } } } } diff --git a/ios/QuantRadar/App/RootTabView.swift b/ios/QuantRadar/App/RootTabView.swift index b2d03b5..cf3b045 100644 --- a/ios/QuantRadar/App/RootTabView.swift +++ b/ios/QuantRadar/App/RootTabView.swift @@ -12,21 +12,30 @@ enum QRTheme { } struct RootTabView: View { - @EnvironmentObject private var purchases: PurchaseStore + @State private var selection = 2 var body: some View { - TabView { + TabView(selection: $selection) { + WatchlistView() + .tabItem { Label("Plan", systemImage: "checklist") } + .tag(2) TodayView() .tabItem { Label("Today", systemImage: "dot.radiowaves.left.and.right") } + .tag(0) SearchView() .tabItem { Label("Scan", systemImage: "magnifyingglass") } - if purchases.effectiveUnlocked { - WatchlistView() - .tabItem { Label("Watch", systemImage: "eye") } - } + .tag(1) SettingsView() .tabItem { Label("Settings", systemImage: "gearshape") } + .tag(3) } .tint(QRTheme.radar) + .onAppear { + #if DEBUG + if let tab = ScreenshotLaunch.tabIndex { + selection = tab + } + #endif + } } } diff --git a/ios/QuantRadar/Models/RadarVerdict.swift b/ios/QuantRadar/Models/RadarVerdict.swift index ed281c6..58fd468 100644 --- a/ios/QuantRadar/Models/RadarVerdict.swift +++ b/ios/QuantRadar/Models/RadarVerdict.swift @@ -86,11 +86,17 @@ struct RadarVerdict: Codable, Identifiable, Hashable { let fetchTime: String? let disclaimer: String? let dataPath: String? + var marketAsOf: String? = nil + var computedAt: String? = nil + var fromCache: Bool? = nil enum CodingKeys: String, CodingKey { case mode, disclaimer case fetchTime = "fetch_time" case dataPath = "data_path" + case marketAsOf = "market_as_of" + case computedAt = "computed_at" + case fromCache = "from_cache" } } diff --git a/ios/QuantRadar/PrivacyInfo.xcprivacy b/ios/QuantRadar/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..5704bed --- /dev/null +++ b/ios/QuantRadar/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/ios/QuantRadar/Resources/Products.storekit b/ios/QuantRadar/Resources/Products.storekit index df73499..fb429cb 100644 --- a/ios/QuantRadar/Resources/Products.storekit +++ b/ios/QuantRadar/Resources/Products.storekit @@ -1,98 +1,48 @@ { - "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" : [ - + "appPolicies": { + "eula": "", + "policies": [ + { + "locale": "en_US", + "policyText": "", + "policyURL": "" + } ] }, - "subscriptionGroups" : [ + "identifier": "9C9918AE", + "nonRenewingSubscriptions": [], + "products": [ { - "id" : "214E0A8E", - "localizations" : [ + "displayPrice": "9.99", + "familyShareable": false, + "internalID": "302D6C92", + "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" + "description": "One-time unlock for full ticker scan and watchlist. Not a subscription. Educational radar only.", + "displayName": "QuantRadar Unlock", + "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" - } - ] + "productID": "one.quantradar.app.unlock", + "referenceName": "QuantRadar Unlock", + "type": "NonConsumable" } ], - "version" : { - "major" : 3, - "minor" : 0 + "settings": { + "_askToBuyEnabled": false, + "_billingGracePeriodEnabled": false, + "_billingIssuesEnabled": false, + "_disableDialogs": false, + "_failTransactionsEnabled": false, + "_locale": "en_US", + "_renewalBillingIssuesEnabled": false, + "_storefront": "USA", + "_storeKitErrors": [], + "_timeRate": 0 + }, + "subscriptionGroups": [], + "version": { + "major": 5, + "minor": 0 } } diff --git a/ios/QuantRadar/Services/AppAccess.swift b/ios/QuantRadar/Services/AppAccess.swift index ef81b23..6ac6cb6 100644 --- a/ios/QuantRadar/Services/AppAccess.swift +++ b/ios/QuantRadar/Services/AppAccess.swift @@ -16,7 +16,7 @@ enum AppAccess { 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 founderPriceLine = "Launch unlock. One-time, not a subscription. Restore anytime." static let previewTickerKey = "qr.preview.personal_ticker" diff --git a/ios/QuantRadar/Services/BarsCache.swift b/ios/QuantRadar/Services/BarsCache.swift index 48541a2..fd24965 100644 --- a/ios/QuantRadar/Services/BarsCache.swift +++ b/ios/QuantRadar/Services/BarsCache.swift @@ -14,7 +14,7 @@ actor BarsCache { func asResult() -> FreeBarsResult? { guard let source else { return nil } - return FreeBarsResult(bars: bars, source: source) + return FreeBarsResult(bars: bars, source: source, fromCache: true, fetchedAt: fetchedAt) } } @@ -63,7 +63,7 @@ actor BarsCache { 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()) + let entry = Entry(bars: result.bars, sourceRaw: result.source.rawValue, fetchedAt: result.fetchedAt) memory[key] = entry if let data = try? encoder.encode(entry) { try? data.write(to: diskURL(for: key), options: .atomic) diff --git a/ios/QuantRadar/Services/ChaseCheck.swift b/ios/QuantRadar/Services/ChaseCheck.swift new file mode 100644 index 0000000..b477a02 --- /dev/null +++ b/ios/QuantRadar/Services/ChaseCheck.swift @@ -0,0 +1,36 @@ +import Foundation + +/// A user-owned process check. It never changes the mechanical market score. +/// Its job is to interrupt urgency before the user interprets a setup. +struct ChaseCheck: Equatable { + var entryWasPlanned = false + var invalidationIsDefined = false + var independentOfHype = false + + var completedCount: Int { + [entryWasPlanned, invalidationIsDefined, independentOfHype] + .filter { $0 } + .count + } + + var isClear: Bool { + completedCount == 3 + } + + var status: String { + isClear ? "PROCESS CLEAR" : "PAUSE" + } + + var guidance: String { + if isClear { + return "Your entry, invalidation, and reason existed before the urge. Now read the radar." + } + return "An incomplete process is a reason to slow down — never a reason to chase." + } + + mutating func reset() { + entryWasPlanned = false + invalidationIsDefined = false + independentOfHype = false + } +} diff --git a/ios/QuantRadar/Services/DecisionJournal.swift b/ios/QuantRadar/Services/DecisionJournal.swift new file mode 100644 index 0000000..97c77af --- /dev/null +++ b/ios/QuantRadar/Services/DecisionJournal.swift @@ -0,0 +1,185 @@ +import Foundation + +struct DecisionPlan: Codable, Hashable { + let reason: String + let trigger: String + let invalidation: String + let reviewOn: Date +} + +struct DecisionReview: Codable, Hashable { + enum Outcome: String, Codable, CaseIterable { + case followed = "Followed my plan" + case changed = "Changed my plan" + case didNotAct = "Did not act" + } + let outcome: Outcome + let lesson: String + let reviewedAt: Date +} + +struct DecisionEntry: Codable, Identifiable, Hashable { + let id: UUID + let ticker: String + let radarAction: String + let decision: String + let score: Double? + let processClear: Bool + let createdAt: Date + var plan: DecisionPlan? = nil + var review: DecisionReview? = nil + var marketAsOf: String? = nil + + var exportText: String { + var lines = ["QuantRadar · Decision record", ticker + " · " + decision, + "Recorded: " + createdAt.formatted(date: .abbreviated, time: .shortened)] + if let plan { + lines += ["Original reason: " + plan.reason, "Condition to observe: " + plan.trigger, + "What invalidates it: " + plan.invalidation, + "Review date: " + plan.reviewOn.formatted(date: .abbreviated, time: .omitted)] + } + if radarAction != "NOT SCANNED" { + lines += ["Radar at recording: " + radarAction, "Market session: " + (marketAsOf ?? "Not recorded")] + } + if let review { + lines += ["Self-review: " + review.outcome.rawValue, "Lesson: " + review.lesson, + "Reviewed: " + review.reviewedAt.formatted(date: .abbreviated, time: .shortened)] + } + lines.append("Personal process journal. No trades or investment returns are verified.") + return lines.joined(separator: "\n\n") + } +} + +/// Private, on-device record of decisions made before a trade. +@MainActor +final class DecisionJournal: ObservableObject { + @Published private(set) var entries: [DecisionEntry] = [] + + static let storageKey = "qr.decision.journal" + + var storage: UserDefaults + + init(storage: UserDefaults = .standard) { + self.storage = storage + load() + } + + var summaryLine: String { + switch entries.count { + case 0: + return "No decisions logged yet." + case 1: + return "1 process-first decision saved on this device." + default: + return "\(entries.count) process-first decisions saved on this device." + } + } + + @discardableResult + func record(verdict: RadarVerdict, chaseCheck: ChaseCheck, now: Date = Date()) -> DecisionEntry { + let action = verdict.isWithheld ? "UNKNOWN" : verdict.actionCode + let decision: String + if !chaseCheck.isClear || verdict.isWithheld { + decision = "PAUSE" + } else if ["NO", "AVOID"].contains(action) { + decision = "PASS" + } else if action == "WAIT" { + decision = "WAIT" + } else { + decision = "REVIEW" + } + + let entry = DecisionEntry( + id: UUID(), + ticker: AppAccess.normalizeTicker(verdict.ticker), + radarAction: action, + decision: decision, + score: verdict.isWithheld ? nil : verdict.primaryScore?.value, + processClear: chaseCheck.isClear, + createdAt: now, + marketAsOf: verdict.meta?.marketAsOf + ) + + entries.insert(entry, at: 0) + persist() + return entry + } + + enum JournalError: LocalizedError { + case invalidPlan, invalidReview + var errorDescription: String? { + switch self { + case .invalidPlan: return "Enter a valid ticker, all three plan details (up to 1,000 characters each), and a review date from today onward." + case .invalidReview: return "Add a lesson (up to 2,000 characters). A saved review cannot replace an earlier review." + } + } + } + + @discardableResult + func commitPlan(ticker: String, reason: String, trigger: String, invalidation: String, + reviewOn: Date, decision: String, verdict: RadarVerdict? = nil, + now: Date = Date()) throws -> DecisionEntry { + let symbol = AppAccess.normalizeTicker(ticker) + let details = [reason, trigger, invalidation].map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard symbol.range(of: "^[A-Z][A-Z0-9.\\-^]{0,11}$", options: .regularExpression) != nil, + details.allSatisfy({ !$0.isEmpty && $0.count <= 1_000 }), + ["PAUSE", "WAIT", "PASS", "REVIEW"].contains(decision), + Calendar.current.startOfDay(for: reviewOn) >= Calendar.current.startOfDay(for: now), + verdict == nil || AppAccess.normalizeTicker(verdict!.ticker) == symbol else { + throw JournalError.invalidPlan + } + let entry = DecisionEntry(id: UUID(), ticker: symbol, radarAction: verdict.map { $0.isWithheld ? "UNKNOWN" : $0.actionCode } ?? "NOT SCANNED", + decision: decision, score: verdict?.isWithheld == false ? verdict?.primaryScore?.value : nil, + processClear: false, createdAt: now, + plan: DecisionPlan(reason: details[0], trigger: details[1], invalidation: details[2], reviewOn: reviewOn), + marketAsOf: verdict?.meta?.marketAsOf) + entries.insert(entry, at: 0) + persist() + return entry + } + + func completeReview(id: UUID, outcome: DecisionReview.Outcome, lesson: String, now: Date = Date()) throws { + let text = lesson.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, text.count <= 2_000, + let index = entries.firstIndex(where: { $0.id == id }), + entries[index].review == nil, now >= entries[index].createdAt else { + throw JournalError.invalidReview + } + entries[index].review = DecisionReview(outcome: outcome, lesson: text, reviewedAt: now) + persist() + } + + func dueCount(now: Date = Date()) -> Int { + entries.filter { + guard $0.review == nil, let plan = $0.plan else { return false } + return Calendar.current.startOfDay(for: plan.reviewOn) <= Calendar.current.startOfDay(for: now) + }.count + } + + func clear() { + entries = [] + storage.removeObject(forKey: Self.storageKey) + } + + #if DEBUG + func replaceForScreenshot(_ seeded: [DecisionEntry]) { + entries = seeded + } + #endif + + private func load() { + guard + let data = storage.data(forKey: Self.storageKey), + let saved = try? JSONDecoder().decode([DecisionEntry].self, from: data) + else { + entries = [] + return + } + entries = saved + } + + private func persist() { + guard let data = try? JSONEncoder().encode(entries) else { return } + storage.set(data, forKey: Self.storageKey) + } +} diff --git a/ios/QuantRadar/Services/FreeDataRadar.swift b/ios/QuantRadar/Services/FreeDataRadar.swift index 83750bf..f6ea215 100644 --- a/ios/QuantRadar/Services/FreeDataRadar.swift +++ b/ios/QuantRadar/Services/FreeDataRadar.swift @@ -16,6 +16,7 @@ struct FreeBarsResult: Sendable { let bars: [FreeBar] let source: FreeDataSourceID var fromCache: Bool = false + var fetchedAt: Date = Date() } enum FreeMarketDataError: LocalizedError { @@ -50,25 +51,29 @@ enum FreeMarketDataClient { static func dailyBars( symbol: String, - rangeHintDays: Int = 180, + rangeHintDays: Int = 365, 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) + if cached.bars.count >= min(139, rangeHintDays / 2), + let completed = MarketCalendar.completedBars(cached.bars, fetchedAt: cached.fetchedAt) { + return FreeBarsResult(bars: completed, source: cached.source, fromCache: true, fetchedAt: cached.fetchedAt) + } } var errors: [String] = [] for source in sourceOrder { do { + let requestedAt = Date() 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))") + guard let completed = MarketCalendar.completedBars(bars, fetchedAt: requestedAt) else { + errors.append("\(source.rawValue): stale or insufficient completed daily bars") continue } - let result = FreeBarsResult(bars: bars, source: source, fromCache: false) + let result = FreeBarsResult(bars: completed, source: source, fromCache: false, fetchedAt: requestedAt) await BarsCache.shared.set(sym, result: result) return result } catch { @@ -149,7 +154,7 @@ enum FreeMarketDataClient { 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("=") { + if symbol.contains("=") || (symbol.contains("-") && !["BRK-B", "BF-B"].contains(symbol)) { throw URLError(.unsupportedURL) } let nasdaqSymbol = symbol.replacingOccurrences(of: "-", with: ".") @@ -246,41 +251,84 @@ enum FreeMarketDataClient { enum QuoteFundamentals: Sendable { case missing - case loaded(sector: String?, earningsDate: Date?) + case loaded(sector: String?, earningsDate: Date?, company: String?) var sector: String? { - if case .loaded(let s, _) = self { return s } + if case .loaded(let s, _, _) = self { return s } return nil } var earningsDate: Date? { - if case .loaded(_, let d) = self { return d } + if case .loaded(_, let d, _) = self { return d } + return nil + } + + var company: String? { + if case .loaded(_, _, let c) = self { return c } return nil } + + var isUseful: Bool { + sector != nil || earningsDate != nil || company != nil + } } extension FreeMarketDataClient { - /// Yahoo quoteSummary — fail-open. Never blocks a scan. + /// Sector / earnings / name. Yahoo first, Nasdaq summary fallback. Fail-open. static func fundamentals(symbol: String) async -> QuoteFundamentals { let sym = normalize(symbol) guard !sym.isEmpty else { return .missing } - let encoded = sym.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? sym + if let yahoo = await yahooFundamentals(symbol: sym), yahoo.isUseful { + return yahoo + } + if let nasdaq = await nasdaqSummary(symbol: sym), nasdaq.isUseful { + return nasdaq + } + return .missing + } + + private static func yahooFundamentals(symbol: String) async -> QuoteFundamentals? { + let encoded = symbol.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? symbol guard var components = URLComponents(string: "https://query1.finance.yahoo.com/v10/finance/quoteSummary/\(encoded)") else { - return .missing + return nil } components.queryItems = [ URLQueryItem(name: "modules", value: "assetProfile,calendarEvents"), ] - guard let url = components.url else { return .missing } + guard let url = components.url else { return nil } 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) + let parsed = parseFundamentals(data) + return parsed.isUseful ? parsed : nil } catch { - return .missing + return nil + } + } + + private static func nasdaqSummary(symbol: String) async -> QuoteFundamentals? { + let encoded = symbol.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? symbol + let classes = ["stocks", "etf"] + for asset in classes { + guard let url = URL(string: "https://api.nasdaq.com/api/quote/\(encoded)/summary?assetclass=\(asset)") else { + continue + } + do { + 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/", + ]) + let parsed = parseNasdaqSummary(data) + if parsed.isUseful { return parsed } + } catch { + continue + } } + return nil } static func parseFundamentals(_ data: Data) -> QuoteFundamentals { @@ -309,7 +357,19 @@ extension FreeMarketDataClient { } } if sector == nil && earnings == nil { return .missing } - return .loaded(sector: sector, earningsDate: earnings) + return .loaded(sector: sector, earningsDate: earnings, company: nil) + } + + static func parseNasdaqSummary(_ data: Data) -> QuoteFundamentals { + guard + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let dataObj = root["data"] as? [String: Any], + let summary = dataObj["summaryData"] as? [String: Any] + else { return .missing } + let sector = ((summary["Sector"] as? [String: Any])?["value"] as? String) + .flatMap { $0.isEmpty ? nil : $0 } + if sector == nil { return .missing } + return .loaded(sector: sector, earningsDate: nil, company: nil) } } @@ -355,9 +415,11 @@ enum FreeMechanicalScorer { if volRatio >= 1.2 { score += 8 } else if volRatio < 0.7 { score -= 4 } - var marketGate = "PASS" + var marketGate = "UNKNOWN" var spyPct: Double? - if let spy = spyBars, spy.count >= 5 { + if let spy = spyBars, spy.count >= 5, + spy.suffix(5).allSatisfy({ $0.close.isFinite && $0.close > 0 }) { + marketGate = "PASS" let a = spy[spy.count - 5].close let b = spy.last!.close spyPct = (b - a) / a * 100 @@ -374,6 +436,10 @@ enum FreeMechanicalScorer { action = "NO" label = "Avoid" reason = "Posture is weak or the market gate is blocked — do not force a trade." + } else if marketGate == "UNKNOWN" { + action = "WAIT" + label = "Wait & Watch" + reason = "SPY market data is unavailable — wait until the market gate can be checked." } else if score >= 68 && pullback >= -2 && pullback <= 8 && (rsi ?? 50) < 68 { action = "SETUP" label = "Setup zone" @@ -417,9 +483,22 @@ enum FreeMechanicalScorer { earningsDate: Date? = nil, sectorName: String? = nil, sectorAction: String? = nil, + fetchedAt: Date? = nil, + fromCache: Bool = false, now: Date = Date() ) -> RadarVerdict { - var c = core(bars: bars, spyBars: spyBars) + let completeBars = MarketCalendar.completedBars(bars, now: now, fetchedAt: fetchedAt) + let completeSPY = spyBars.flatMap { MarketCalendar.completedBars($0, now: now, minimum: 5) } + let usable = completeBars != nil + let currentBars = completeBars ?? [] + var c = core(bars: currentBars, spyBars: completeSPY) + if !usable { + c.action = "WAIT" + c.label = "Data unavailable" + c.reason = "Daily data is stale or incomplete. Wait for the latest completed market session." + c.summary = c.reason + c.stockGate = "UNKNOWN" + } var earningsForced = false if let earningsDate, PostureDepth.isNearEarnings(earningsDate, now: now), c.action == "SETUP" { c.action = "WAIT" @@ -442,8 +521,8 @@ enum FreeMechanicalScorer { } let etf = PostureDepth.etf(forSector: sectorName) let depth = PostureDepth.build( - bars: bars, - spyBars: spyBars, + bars: currentBars, + spyBars: completeSPY, earningsDate: earningsDate, now: now, earningsForcedWait: earningsForced, @@ -455,11 +534,11 @@ enum FreeMechanicalScorer { companyName: company ?? symbol, sector: sectorName ?? etf, primaryScore: .init( - value: c.score, + value: usable ? c.score : nil, scale: 100, label: "Mechanical posture score", - withheld: false, - note: nil + withheld: !usable, + note: usable ? nil : c.reason ), primary: .init(action: c.action, label: c.label, reason: c.reason), summary: c.summary, @@ -468,9 +547,9 @@ enum FreeMechanicalScorer { freezeLabel: "on-device", postureNote: "Mechanical posture ≠ trade direction." ), - dataQuality: .init(usable: true, reliability: "medium", optionsActionable: false), + dataQuality: .init(usable: usable, reliability: usable ? "medium" : "low", optionsActionable: false), market: .init( - marketState: c.marketGate == "PASS" ? "risk_on_cautious" : "risk_off", + marketState: c.marketGate == "UNKNOWN" ? "unknown" : (c.marketGate == "PASS" ? "risk_on_cautious" : "risk_off"), spyChangePct: c.spyPct, sectorEtf: etf, sectorChangePct: nil, @@ -479,13 +558,16 @@ enum FreeMechanicalScorer { ), meta: .init( mode: "free_multi_source", - fetchTime: ISO8601DateFormatter().string(from: Date()), + fetchTime: fetchedAt.map { ISO8601DateFormatter().string(from: $0) }, disclaimer: "Educational radar only — not investment advice.", - dataPath: source.rawValue + dataPath: source.rawValue, + marketAsOf: currentBars.last.map { MarketCalendar.barDay($0.date) }, + computedAt: ISO8601DateFormatter().string(from: now), + fromCache: fromCache ), gate: .init(market: c.marketGate, sector: sectorGate, stock: c.stockGate), - warnings: ["Options data is not part of this radar."], - depth: depth + warnings: ["Options data is not part of this radar."] + (completeSPY == nil ? ["SPY gate is unknown."] : []) + (usable ? [] : [c.reason]), + depth: usable ? depth : nil ) } diff --git a/ios/QuantRadar/Services/MarketCalendar.swift b/ios/QuantRadar/Services/MarketCalendar.swift new file mode 100644 index 0000000..34f6c52 --- /dev/null +++ b/ios/QuantRadar/Services/MarketCalendar.swift @@ -0,0 +1,76 @@ +import Foundation + +enum MarketCalendar { + private struct Schedule: Decodable { + let first_year: Int + let last_year: Int + let holidays: Set + let early_closes: Set + } + + private static let schedule: Schedule? = { + guard let url = Bundle.main.url(forResource: "nyse_calendar", withExtension: "json"), + let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(Schedule.self, from: data) + }() + + private static let barFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + static func barDay(_ date: Date) -> String { + barFormatter.string(from: date) + } + + static func completedSession(now: Date = Date()) -> String? { + guard let schedule else { return nil } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/New_York")! + let year = calendar.component(.year, from: now) + guard (schedule.first_year...schedule.last_year).contains(year) else { return nil } + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = calendar.timeZone + formatter.dateFormat = "yyyy-MM-dd" + let today = calendar.startOfDay(for: now) + var day = today + for _ in 0..<10 { + let key = formatter.string(from: day) + let weekday = calendar.component(.weekday, from: day) + let closeHour = schedule.early_closes.contains(key) ? 13 : 16 + if weekday != 1 && weekday != 7 && !schedule.holidays.contains(key), + day < today || calendar.component(.hour, from: now) >= closeHour { + return key + } + guard let previous = calendar.date(byAdding: .day, value: -1, to: day) else { return nil } + day = previous + } + return nil + } + + static func sessionClose(_ day: String) -> Date? { + guard let schedule else { return nil } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "America/New_York") + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter.date(from: day + (schedule.early_closes.contains(day) ? " 13:00" : " 16:00")) + } + + static func completedBars(_ bars: [FreeBar], now: Date = Date(), minimum: Int = 50, fetchedAt: Date? = nil) -> [FreeBar]? { + guard let expected = completedSession(now: now) else { return nil } + if let fetchedAt, let close = sessionClose(expected), fetchedAt < close { return nil } + let selected = bars.filter { barDay($0.date) <= expected } + guard selected.count >= minimum, let last = selected.last, barDay(last.date) == expected, + selected.allSatisfy({ $0.close.isFinite && $0.close > 0 && $0.volume.isFinite && $0.volume >= 0 }), + zip(selected, selected.dropFirst()).allSatisfy({ barDay($0.date) < barDay($1.date) }) + else { return nil } + return selected + } +} diff --git a/ios/QuantRadar/Services/PostureDepth.swift b/ios/QuantRadar/Services/PostureDepth.swift index 7750b4d..64f374e 100644 --- a/ios/QuantRadar/Services/PostureDepth.swift +++ b/ios/QuantRadar/Services/PostureDepth.swift @@ -74,11 +74,16 @@ enum PostureDepth { let start = max(minBarsForDay - 1, bars.count - historyDays) var out: [String] = [] out.reserveCapacity(bars.count - start) + let datedSPY = (spyBars ?? []).map { (MarketCalendar.barDay($0.date), $0) } for i in start..= min(slice.count, 5) { - spySlice = Array(spy.prefix(min(spy.count, slice.count))) + if spyBars != nil { + let day = MarketCalendar.barDay(bars[i].date) + let aligned = datedSPY.filter { $0.0 <= day }.map { $0.1 } + if let last = aligned.last, MarketCalendar.barDay(last.date) == day { + spySlice = aligned + } } let core = FreeMechanicalScorer.core( bars: slice, diff --git a/ios/QuantRadar/Services/PurchaseStore.swift b/ios/QuantRadar/Services/PurchaseStore.swift index 4555396..b81fdc8 100644 --- a/ios/QuantRadar/Services/PurchaseStore.swift +++ b/ios/QuantRadar/Services/PurchaseStore.swift @@ -136,7 +136,7 @@ final class PurchaseStore: ObservableObject { do { try await AppStore.sync() await refreshEntitlements() - lastError = nil + lastError = effectiveUnlocked ? nil : "No previous Unlock purchase was found." } catch { lastError = error.localizedDescription } diff --git a/ios/QuantRadar/Services/RadarService.swift b/ios/QuantRadar/Services/RadarService.swift index 6bad572..dcc9513 100644 --- a/ios/QuantRadar/Services/RadarService.swift +++ b/ios/QuantRadar/Services/RadarService.swift @@ -40,15 +40,22 @@ final class RadarService: ObservableObject { // Do not assign `latest` here — Today must not flash the INTC sample. } - /// Prefetch SPY into cache, then refresh Today with live SPY posture. + /// Prefetch SPY into cache, then refresh Today without touching Scan's `latest`. func warmUpToday() async { guard !warmUpStarted else { return } warmUpStarted = true + await refreshToday(bypassCache: false) + } + + /// Live SPY for the Today tab. Does not assign `latest` (Scan keeps its own result). + func refreshToday(bypassCache: Bool = false) async { isWarmingUp = true defer { isWarmingUp = false } - _ = try? await FreeMarketDataClient.dailyBars(symbol: "SPY", rangeHintDays: 90) - if await analyze(ticker: "SPY") { - todayVerdict = latest + if let scored = await scoreQuietly(ticker: "SPY", bypassCache: bypassCache) { + applyScored(scored, target: .today, sourceLabel: scored.meta?.dataPath) + if todayVerdict != nil { errorMessage = nil } + } else if todayVerdict == nil { + errorMessage = "Market posture unavailable." } } @@ -61,10 +68,9 @@ final class RadarService: ObservableObject { if forceDemo && symbol == "INTC" { if demo == nil { loadDemo() } - latest = demo - lastSource = "bundled_sample" + applyScored(demo, target: .scan, sourceLabel: "bundled_sample") errorMessage = nil - return true + return demo != nil } isLoading = true @@ -73,8 +79,8 @@ final class RadarService: ObservableObject { if let base = debugAnalyzeOverride { do { - latest = try await fetchWebAnalyze(base: base, ticker: symbol) - lastSource = "debug_web" + let remote = try await fetchWebAnalyze(base: base, ticker: symbol) + applyScored(remote, target: .scan, sourceLabel: "debug_web") errorMessage = "Debug web analyze override active." return true } catch { @@ -83,90 +89,91 @@ final class RadarService: ObservableObject { } 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 + let scored = try await scoreLive(symbol: symbol, bypassCache: bypassCache) + applyScored(scored, target: .scan, sourceLabel: scored.meta?.dataPath) return true } catch { if symbol == "INTC" { if demo == nil { loadDemo() } - latest = demo - lastSource = "bundled_sample" + applyScored(demo, target: .scan, sourceLabel: "bundled_sample") errorMessage = "All free sources failed — showing bundled INTC sample." return latest != nil } - latest = Self.synthetic(for: symbol, reason: error.localizedDescription) - lastSource = nil + applyScored(Self.synthetic(for: symbol, reason: error.localizedDescription), target: .scan, sourceLabel: 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? { + /// Score a ticker without mutating `latest` (watchlist batch refresh / Today). + func scoreQuietly(ticker: String, bypassCache: Bool = false) 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 try? await scoreLive(symbol: symbol, bypassCache: bypassCache) + } + + enum PublishTarget { + case scan + case today + } + + /// Scan writes `latest`. Today writes `todayVerdict` only. Scanning SPY also updates Today. + func applyScored(_ scored: RadarVerdict?, target: PublishTarget, sourceLabel: String?) { + guard let scored else { return } + switch target { + case .scan: + latest = scored + if scored.ticker == AppAccess.freeMarketTicker { + todayVerdict = scored } - 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 + case .today: + todayVerdict = scored } + lastSource = sourceLabel + } + + private func scoreLive(symbol: String, bypassCache: Bool) async throws -> RadarVerdict { + async let primary = FreeMarketDataClient.dailyBars( + symbol: symbol, + bypassCache: bypassCache + ) + async let spy: FreeBarsResult? = symbol == "SPY" ? nil : try? await FreeMarketDataClient.dailyBars( + symbol: "SPY", + rangeHintDays: 365, + bypassCache: bypassCache && symbol == "SPY" + ) + async let fund = FreeMarketDataClient.fundamentals(symbol: symbol) + let result = try await primary + let spyBars = symbol == "SPY" ? result : 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), + let completed = MarketCalendar.completedBars(sectorBars.bars) { + let marketBars = spyBars.flatMap { MarketCalendar.completedBars($0.bars, minimum: 5) } + sectorAction = FreeMechanicalScorer.core(bars: completed, spyBars: marketBars).action + } + } + return FreeMechanicalScorer.score( + symbol: symbol, + company: fundamentals.company, + bars: result.bars, + spyBars: spyBars?.bars, + source: result.source, + earningsDate: fundamentals.earningsDate, + sectorName: fundamentals.sector, + sectorAction: sectorAction, + fetchedAt: result.fetchedAt, + fromCache: result.fromCache + ) } /// Unlocked Today: sector ETF posture chips. Cached bars, limited concurrency. func refreshSectors() async { var rows: [SectorChip] = [] + let batchNow = Date() let spy = try? await FreeMarketDataClient.dailyBars(symbol: "SPY", rangeHintDays: 90) + let spyBars = spy.flatMap { MarketCalendar.completedBars($0.bars, now: batchNow, minimum: 5, fetchedAt: $0.fetchedAt) } let etfs = Array(PostureDepth.sectorETF.keys).sorted() await withTaskGroup(of: SectorChip?.self) { group in var i = 0 @@ -178,7 +185,8 @@ final class RadarService: ObservableObject { 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 + guard let completed = MarketCalendar.completedBars(bars.bars, now: batchNow, fetchedAt: bars.fetchedAt) else { return nil } + let action = FreeMechanicalScorer.core(bars: completed, spyBars: spyBars).action let label = PostureDepth.sectorETF[etf] ?? etf return SectorChip(etf: etf, label: label, action: action) } diff --git a/ios/QuantRadar/Services/ReviewPrompt.swift b/ios/QuantRadar/Services/ReviewPrompt.swift index 0b2f91c..ea3ca3e 100644 --- a/ios/QuantRadar/Services/ReviewPrompt.swift +++ b/ios/QuantRadar/Services/ReviewPrompt.swift @@ -10,11 +10,17 @@ enum ReviewPrompt { static var storage: UserDefaults = .standard static func recordLaunch() { + #if DEBUG + if ScreenshotLaunch.isEnabled { return } + #endif let n = storage.integer(forKey: launchKey) + 1 storage.set(n, forKey: launchKey) } static func recordVerdict(_ verdict: RadarVerdict) { + #if DEBUG + if ScreenshotLaunch.isEnabled { return } + #endif switch verdict.actionCode { case "WAIT", "NO", "AVOID": storage.set(true, forKey: waitKey) diff --git a/ios/QuantRadar/Services/ScreenshotLaunch.swift b/ios/QuantRadar/Services/ScreenshotLaunch.swift new file mode 100644 index 0000000..41200a6 --- /dev/null +++ b/ios/QuantRadar/Services/ScreenshotLaunch.swift @@ -0,0 +1,52 @@ +#if DEBUG +import Foundation + +/// Debug-only launch arguments so App Store screenshots can be captured +/// from the simulator framebuffer without clicking a visible window. +enum ScreenshotLaunch { + static var isEnabled: Bool { + ProcessInfo.processInfo.arguments.contains("-ui-screenshot") + } + + static var screen: String { + let args = ProcessInfo.processInfo.arguments + guard let idx = args.firstIndex(of: "-qr-screen"), idx + 1 < args.count else { + return "" + } + return args[idx + 1] + } + + static var tabIndex: Int? { + switch screen { + case "scan", "decision", "paywall": return 1 + case "plan": return 2 + case "today": return 0 + case "settings": return 3 + default: return nil + } + } + + static var showOnboarding: Bool { + isEnabled && screen == "onboarding" + } + + @MainActor + static func seedJournalIfNeeded(_ journal: DecisionJournal) { + guard isEnabled, screen == "plan" else { return } + journal.replaceForScreenshot( + [ + DecisionEntry( + id: UUID(), + ticker: "AAPL", + radarAction: "WAIT", + decision: "WAIT", + score: 66, + processClear: true, + createdAt: Date() + ) + ] + ) + AppAccess.storage.set("AAPL", forKey: AppAccess.previewTickerKey) + } +} +#endif diff --git a/ios/QuantRadar/Views/ChaseCheckView.swift b/ios/QuantRadar/Views/ChaseCheckView.swift new file mode 100644 index 0000000..b5e6460 --- /dev/null +++ b/ios/QuantRadar/Views/ChaseCheckView.swift @@ -0,0 +1,131 @@ +import SwiftUI + +struct ChaseCheckView: View { + @Binding var check: ChaseCheck + let ticker: String + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("Chase Check") + .font(.headline) + .foregroundStyle(QRTheme.text) + Text(ticker.isEmpty ? "Interrupt urgency before the score." : "Before reading \(ticker), check your process.") + .font(.caption) + .foregroundStyle(QRTheme.muted) + } + Spacer() + Text(check.status) + .font(.caption2.monospaced().weight(.bold)) + .foregroundStyle(check.isClear ? QRTheme.radar : QRTheme.warn) + } + + checkRow( + "My entry existed before this move", + systemImage: "scope", + isOn: $check.entryWasPlanned + ) + checkRow( + "I can name what invalidates the setup", + systemImage: "xmark.diamond", + isOn: $check.invalidationIsDefined + ) + checkRow( + "I would consider it without social hype", + systemImage: "person.2.slash", + isOn: $check.independentOfHype + ) + + Text(check.guidance) + .font(.caption) + .foregroundStyle(check.isClear ? QRTheme.radar : QRTheme.muted) + } + .padding(14) + .background(QRTheme.panel) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .accessibilityElement(children: .contain) + .accessibilityLabel("Chase Check, \(check.completedCount) of 3 process checks complete") + } + + private func checkRow( + _ title: String, + systemImage: String, + isOn: Binding + ) -> some View { + Button { + isOn.wrappedValue.toggle() + } label: { + HStack(spacing: 10) { + Image(systemName: isOn.wrappedValue ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isOn.wrappedValue ? QRTheme.radar : QRTheme.muted) + Image(systemName: systemImage) + .foregroundStyle(QRTheme.muted) + .frame(width: 18) + Text(title) + .font(.subheadline) + .foregroundStyle(QRTheme.text) + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityValue(isOn.wrappedValue ? "Checked" : "Not checked") + } +} + +struct DecisionCommitView: View { + let verdict: RadarVerdict + let chaseCheck: ChaseCheck + let isSaved: Bool + let onSave: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Your decision") + .font(.headline) + .foregroundStyle(QRTheme.text) + + Text(decisionExplanation) + .font(.footnote) + .foregroundStyle(QRTheme.muted) + + if isSaved { + Label("Saved privately on this device", systemImage: "checkmark.seal.fill") + .font(.subheadline.weight(.medium)) + .foregroundStyle(QRTheme.radar) + } else { + Button(action: onSave) { + Label(buttonTitle, systemImage: "checkmark.shield") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(chaseCheck.isClear ? QRTheme.radar : QRTheme.warn) + .foregroundStyle(.black) + } + } + .padding(14) + .background(QRTheme.panel) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + + private var buttonTitle: String { + if verdict.isWithheld || !chaseCheck.isClear { return "Log: pause" } + switch verdict.actionCode { + case "NO", "AVOID": return "Log: pass" + case "WAIT": return "Log: wait" + default: return "Log: review, don’t chase" + } + } + + private var decisionExplanation: String { + if verdict.isWithheld { + return "Market data is unavailable. Record a pause with an UNKNOWN radar snapshot." + } + if !chaseCheck.isClear { + return "The mechanical score stays unchanged. Your personal process gate says pause." + } + return "Record the decision before the outcome. Grade the process later, not the next candle." + } +} diff --git a/ios/QuantRadar/Views/DecisionPlanView.swift b/ios/QuantRadar/Views/DecisionPlanView.swift new file mode 100644 index 0000000..07d0929 --- /dev/null +++ b/ios/QuantRadar/Views/DecisionPlanView.swift @@ -0,0 +1,207 @@ +import SwiftUI + +struct DecisionPlanComposer: View { + @EnvironmentObject private var journal: DecisionJournal + @Environment(\.dismiss) private var dismiss + let verdict: RadarVerdict? + @State private var ticker: String + @State private var reason = "" + @State private var trigger = "" + @State private var invalidation = "" + @State private var reviewOn = Date() + @State private var decision = "WAIT" + @State private var error: String? + @FocusState private var fieldFocused: Bool + + init(verdict: RadarVerdict? = nil) { + self.verdict = verdict + _ticker = State(initialValue: verdict?.ticker ?? "") + } + + var body: some View { + Form { + Section { + Text("Write the conditions before you know the outcome. Saving keeps this original plan unchanged; your review will be added separately.") + .font(.subheadline) + .foregroundStyle(QRTheme.muted) + } + Section("Your decision") { + TextField("Ticker", text: $ticker) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .disabled(verdict != nil) + .accessibilityIdentifier("planTicker") + .focused($fieldFocused) + Picker("My decision", selection: $decision) { + ForEach(["PAUSE", "WAIT", "PASS", "REVIEW"], id: \.self) { Text($0).tag($0) } + } + Text("Your choice, not a recommendation to buy or sell.") + .font(.caption) + .foregroundStyle(QRTheme.muted) + } + Section("1 · Why am I considering it?") { + TextField("My reason, in my own words", text: $reason, axis: .vertical) + .lineLimit(2...5) + .accessibilityIdentifier("planReason") + .focused($fieldFocused) + } + Section("2 · What must happen first?") { + TextField("The condition I will wait for", text: $trigger, axis: .vertical) + .lineLimit(2...5) + .accessibilityIdentifier("planTrigger") + .focused($fieldFocused) + } + Section("3 · What would change my mind?") { + TextField("What invalidates this idea", text: $invalidation, axis: .vertical) + .lineLimit(2...5) + .accessibilityIdentifier("planInvalidation") + .focused($fieldFocused) + } + Section { + DatePicker("Review on", selection: $reviewOn, + in: Calendar.current.startOfDay(for: Date())..., displayedComponents: .date) + Text("The date appears in your review queue. It does not schedule a notification or place a trade.") + .font(.caption) + .foregroundStyle(QRTheme.muted) + if let verdict { + Text("Radar snapshot: \(verdict.isWithheld ? "UNKNOWN" : verdict.actionCode) · \(verdict.meta?.marketAsOf ?? "session not recorded")") + .font(.caption) + } else { + Text("No market data is needed. This is your own written plan.") + .font(.caption) + } + } + if let error { Section { Text(error).foregroundStyle(QRTheme.warn) } } + } + .scrollContentBackground(.hidden) + .scrollDismissesKeyboard(.interactively) + .background(QRTheme.bg) + .navigationTitle("Write a decision") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { fieldFocused = false } + .accessibilityIdentifier("dismissPlanKeyboard") + } + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Save plan") { save() } + .fontWeight(.semibold) + .disabled([ticker, reason, trigger, invalidation].contains { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) + .accessibilityIdentifier("saveDecisionPlan") + } + } + } + + private func save() { + do { + try journal.commitPlan(ticker: ticker, reason: reason, trigger: trigger, + invalidation: invalidation, reviewOn: reviewOn, + decision: decision, verdict: verdict) + dismiss() + } catch { self.error = error.localizedDescription } + } +} + +struct DecisionDetailView: View { + @EnvironmentObject private var journal: DecisionJournal + let entryID: UUID + @State private var outcome: DecisionReview.Outcome = .didNotAct + @State private var lesson = "" + @State private var error: String? + @FocusState private var reviewFocused: Bool + + private var entry: DecisionEntry? { journal.entries.first { $0.id == entryID } } + + var body: some View { + Form { + if let entry { + Section { + Label("Original record preserved", systemImage: "lock.doc") + .foregroundStyle(QRTheme.radar) + Text(entry.createdAt.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + LabeledContent("My decision", value: entry.decision) + if entry.radarAction != "NOT SCANNED" { + LabeledContent("Radar then", value: entry.radarAction) + Text("Market session: \(entry.marketAsOf ?? "Not recorded")") + .font(.caption) + } + } + if let plan = entry.plan { + Section("Before · My original plan") { + original("My reason", plan.reason) + original("Condition to observe", plan.trigger) + original("What invalidates it", plan.invalidation) + LabeledContent("Review date", value: plan.reviewOn.formatted(date: .abbreviated, time: .omitted)) + } + } else { + Section { Text("This quick log has no written conditions attached. Its original decision is preserved.").font(.caption) } + } + if let review = entry.review { + Section("After · My process review") { + Text(review.outcome.rawValue).font(.headline) + .accessibilityIdentifier("savedReviewOutcome") + Text(review.lesson) + Text(review.reviewedAt.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundStyle(QRTheme.muted) + } + } else { + Section("After · Review the process") { + Text("Compare what you did with what you wrote. A good price outcome does not prove a good process.") + .font(.caption) + .foregroundStyle(QRTheme.muted) + Picker("What did I do?", selection: $outcome) { + ForEach(DecisionReview.Outcome.allCases, id: \.self) { Text($0.rawValue).tag($0) } + } + TextField("What will I repeat or change next time?", text: $lesson, axis: .vertical) + .lineLimit(3...6) + .accessibilityIdentifier("reviewLesson") + .focused($reviewFocused) + Button("Save review") { + do { try journal.completeReview(id: entryID, outcome: outcome, lesson: lesson) } + catch { self.error = error.localizedDescription } + } + .disabled(lesson.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .accessibilityIdentifier("saveDecisionReview") + Text("Saving adds one dated review without rewriting the original plan.") + .font(.caption) + .foregroundStyle(QRTheme.muted) + } + } + if let error { Section { Text(error).foregroundStyle(QRTheme.warn) } } + Section { + ShareLink(item: entry.exportText) { Label("Share this record", systemImage: "square.and.arrow.up") } + .accessibilityIdentifier("shareDecisionRecord") + Text("Only share with people you choose. These are your notes, not verified trades or returns.") + .font(.caption) + .foregroundStyle(QRTheme.muted) + } + } else { + Text("This record is no longer in your journal.") + } + } + .scrollContentBackground(.hidden) + .scrollDismissesKeyboard(.interactively) + .background(QRTheme.bg) + .navigationTitle(entry?.ticker ?? "Decision") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { reviewFocused = false } + .accessibilityIdentifier("dismissPlanKeyboard") + } + } + } + + private func original(_ title: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 5) { + Text(title).font(.caption).foregroundStyle(QRTheme.muted) + Text(value).foregroundStyle(QRTheme.text).textSelection(.enabled) + } + .padding(.vertical, 4) + } +} diff --git a/ios/QuantRadar/Views/LockedVerdictView.swift b/ios/QuantRadar/Views/LockedVerdictView.swift index 8f4d4ad..04ec226 100644 --- a/ios/QuantRadar/Views/LockedVerdictView.swift +++ b/ios/QuantRadar/Views/LockedVerdictView.swift @@ -2,6 +2,7 @@ import SwiftUI /// Blurred real verdict + anxiety copy. Score is computed; details stay locked. struct LockedVerdictView: View { + @EnvironmentObject private var purchases: PurchaseStore let verdict: RadarVerdict let onUnlock: () -> Void @@ -21,7 +22,7 @@ struct LockedVerdictView: View { .multilineTextAlignment(.center) .foregroundStyle(QRTheme.muted) Button(action: onUnlock) { - Text("Unlock to see · $9.99") + Text("Unlock to see · \(unlockPrice)") .font(.subheadline.weight(.semibold)) .padding(.horizontal, 16) .padding(.vertical, 10) @@ -34,4 +35,8 @@ struct LockedVerdictView: View { } .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) } + + private var unlockPrice: String { + purchases.unlockProduct?.displayPrice ?? "$9.99" + } } diff --git a/ios/QuantRadar/Views/OnboardingView.swift b/ios/QuantRadar/Views/OnboardingView.swift index d22a5fa..9ab7034 100644 --- a/ios/QuantRadar/Views/OnboardingView.swift +++ b/ios/QuantRadar/Views/OnboardingView.swift @@ -6,45 +6,62 @@ struct OnboardingView: View { 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) + ScrollView { + VStack(spacing: 28) { + 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)) + Text("QuantRadar") + .font(.system(size: 36, weight: .bold, design: .rounded)) .foregroundStyle(QRTheme.text) - .multilineTextAlignment(.center) - Text("One mechanical posture score for US tickers. Most days the honest answer is wait.") - .font(.body) + + VStack(spacing: 10) { + Text("Before you chase, check your process.") + .font(.title3.weight(.semibold)) + .foregroundStyle(QRTheme.text) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + Text("Write your reason, the condition you will wait for, and what would change your mind. Keep that original plan beside a dated review of what you actually did.") + .font(.body) + .foregroundStyle(QRTheme.muted) + .multilineTextAlignment(.center) + .padding(.horizontal, 28) + Text("Your plan and review work offline, free. Market context includes SPY plus one ticker of yours; Unlock adds the full radar.") + .font(.footnote.weight(.medium)) + .foregroundStyle(QRTheme.text) + .multilineTextAlignment(.center) + .padding(.horizontal, 28) + Text("No account. Your decision journal stays on this device.") + .font(.caption) + .foregroundStyle(QRTheme.radar) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + } + + Button { + hasSeenOnboarding = true + } label: { + Text("Start my plan") + .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) .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) + .padding(.bottom, 24) } - .padding(.horizontal, 24) - - Text("Educational only — not investment advice.") - .font(.caption) - .foregroundStyle(QRTheme.muted) - .padding(.bottom, 24) + .frame(maxWidth: 620) + .padding(.vertical, 24) + .frame(maxWidth: .infinity) } } } diff --git a/ios/QuantRadar/Views/PaywallView.swift b/ios/QuantRadar/Views/PaywallView.swift index a65c9ac..726819f 100644 --- a/ios/QuantRadar/Views/PaywallView.swift +++ b/ios/QuantRadar/Views/PaywallView.swift @@ -10,6 +10,10 @@ struct PaywallView: View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 20) { + Text(AppAccess.differentiationLine) + .font(.headline) + .foregroundStyle(QRTheme.radar) + Text("Unlock any ticker.") .font(.title2.bold()) .foregroundStyle(QRTheme.text) @@ -19,10 +23,10 @@ struct PaywallView: View { .foregroundStyle(QRTheme.muted) VStack(alignment: .leading, spacing: 10) { - bullet("Any US ticker scan") - bullet("Watchlist with posture-change alerts") + bullet("Every supported US ticker scan") + bullet("Watchlist refreshed when you open the app") bullet("90-day posture strip and setup evidence") - bullet("One-time $9.99 — not a subscription") + bullet("One-time \(unlockPrice) — not a subscription") } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) @@ -66,6 +70,8 @@ struct PaywallView: View { .foregroundStyle(QRTheme.muted) } .padding(20) + .frame(maxWidth: 620) + .frame(maxWidth: .infinity) } .background(QRTheme.bg.ignoresSafeArea()) .navigationTitle("Unlock") @@ -80,9 +86,9 @@ struct PaywallView: View { 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 "\(AppAccess.anxietyCopy(ticker: t)) You already have today’s SPY and one personal scan. Unlock once for every supported 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." + return "You already have today’s SPY and one personal scan. Unlock once for every supported US ticker plus a watchlist. Most days the honest answer is still wait." } private var unlockButtonTitle: String { @@ -90,7 +96,11 @@ struct PaywallView: View { if let p = purchases.unlockProduct { return "Unlock · \(p.displayPrice)" } - return "Unlock · $9.99" + return "Check App Store price" + } + + private var unlockPrice: String { + purchases.unlockProduct?.displayPrice ?? "Price loading" } private func bullet(_ text: String) -> some View { diff --git a/ios/QuantRadar/Views/PostureStripView.swift b/ios/QuantRadar/Views/PostureStripView.swift index 950da1f..5ff3849 100644 --- a/ios/QuantRadar/Views/PostureStripView.swift +++ b/ios/QuantRadar/Views/PostureStripView.swift @@ -21,7 +21,10 @@ struct PostureStripView: View { .clipShape(RoundedRectangle(cornerRadius: 3, style: .continuous)) } .frame(height: 14) + .accessibilityHidden(true) } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilitySummary) } private func color(_ action: String) -> Color { @@ -31,6 +34,13 @@ struct PostureStripView: View { default: return QRTheme.warn.opacity(0.85) } } + + private var accessibilitySummary: String { + let setup = history.filter { $0.uppercased() == "SETUP" }.count + let wait = history.filter { $0.uppercased() == "WAIT" }.count + let no = history.count - setup - wait + return "Posture history: \(setup) setup, \(wait) wait, \(max(0, no)) avoid sessions" + } } struct DepthFactsView: View { @@ -44,14 +54,14 @@ struct DepthFactsView: View { .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)) + Text(String(format: "Reconstructed SETUP %d sessions ago · close change %+.1f%%. Not a trading return.", 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)) + Text(String(format: "Reconstructed SETUP days: 5-day median price change %+.1f%% (n=%d)%@. Historical earnings and sector gates unavailable.", m5, n, extra)) .font(.caption) .foregroundStyle(QRTheme.muted) } diff --git a/ios/QuantRadar/Views/SearchView.swift b/ios/QuantRadar/Views/SearchView.swift index c547f23..994cb64 100644 --- a/ios/QuantRadar/Views/SearchView.swift +++ b/ios/QuantRadar/Views/SearchView.swift @@ -4,13 +4,21 @@ struct SearchView: View { @EnvironmentObject private var radar: RadarService @EnvironmentObject private var watchlist: WatchlistStore @EnvironmentObject private var purchases: PurchaseStore + @EnvironmentObject private var journal: DecisionJournal @State private var ticker = "" @State private var showPaywall = false @State private var paywallTicker: String? @State private var gateMessage: String? - @State private var lockedPreview = false + @State private var chaseCheck = ChaseCheck() + @State private var decisionSaved = false + @State private var planVerdict: RadarVerdict? @FocusState private var focused: Bool + private var lockedPreview: Bool { + guard let verdict = radar.latest else { return false } + return AppAccess.isPreviewLocked(ticker: verdict.ticker, unlocked: purchases.effectiveUnlocked) + } + var body: some View { NavigationStack { ScrollView { @@ -24,6 +32,7 @@ struct SearchView: View { .textInputAutocapitalization(.characters) .autocorrectionDisabled() .focused($focused) + .accessibilityIdentifier("tickerField") .padding(14) .background(QRTheme.panel) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) @@ -34,6 +43,7 @@ struct SearchView: View { .tint(QRTheme.radar) .foregroundStyle(.black) .disabled(radar.isLoading || ticker.trimmingCharacters(in: .whitespaces).isEmpty) + .accessibilityIdentifier("runScanButton") } HStack(spacing: 8) { @@ -51,6 +61,11 @@ struct SearchView: View { } } + ChaseCheckView( + check: $chaseCheck, + ticker: AppAccess.normalizeTicker(ticker) + ) + if !purchases.effectiveUnlocked { Text(previewCaption) .font(.caption) @@ -83,6 +98,18 @@ struct SearchView: View { } } else { VerdictCardView(verdict: v) + DecisionCommitView( + verdict: v, + chaseCheck: chaseCheck, + isSaved: decisionSaved + ) { + journal.record(verdict: v, chaseCheck: chaseCheck) + decisionSaved = true + } + Button { planVerdict = v } label: { + Label("Write conditions & set a review date", systemImage: "square.and.pencil") + } + .buttonStyle(.bordered) Text("Educational only — not investment advice. Not a broker.") .font(.caption2) .foregroundStyle(QRTheme.muted) @@ -96,18 +123,64 @@ struct SearchView: View { } } .padding(20) + .frame(maxWidth: 760) + .frame(maxWidth: .infinity) } .background(QRTheme.bg.ignoresSafeArea()) .navigationTitle("Scan") + .onChange(of: ticker) { _, _ in + #if DEBUG + if ScreenshotLaunch.isEnabled { return } + #endif + chaseCheck.reset() + decisionSaved = false + } .onChange(of: radar.latest?.ticker) { _, _ in if let v = radar.latest, !lockedPreview { ReviewPrompt.recordVerdict(v) } } + .onChange(of: purchases.effectiveUnlocked) { _, unlocked in + guard unlocked else { gateMessage = nil; return } + gateMessage = "Unlocked. The \(paywallTicker ?? "requested") posture is now visible." + } .sheet(isPresented: $showPaywall) { PaywallView(focusTicker: paywallTicker) .environmentObject(purchases) } + .sheet(item: $planVerdict) { verdict in + NavigationStack { DecisionPlanComposer(verdict: verdict) }.tint(QRTheme.radar) + } + .task { + #if DEBUG + await prepareScreenshotScanIfNeeded() + #endif + } + } + } + + #if DEBUG + private func prepareScreenshotScanIfNeeded() async { + guard ScreenshotLaunch.isEnabled else { return } + switch ScreenshotLaunch.screen { + case "scan", "decision": + AppAccess.storage.set("AAPL", forKey: AppAccess.previewTickerKey) + ticker = "AAPL" + chaseCheck.entryWasPlanned = true + chaseCheck.invalidationIsDefined = true + chaseCheck.independentOfHype = true + await runScan() + if ScreenshotLaunch.screen == "decision", let v = radar.latest, !lockedPreview { + journal.record(verdict: v, chaseCheck: chaseCheck) + decisionSaved = true + } + case "paywall": + AppAccess.storage.set("AAPL", forKey: AppAccess.previewTickerKey) + ticker = "NVDA" + await runScan() + default: + break } } + #endif private var previewCaption: String { if let claimed = AppAccess.claimedPreviewTicker { @@ -119,12 +192,11 @@ struct SearchView: View { private func runScan() async { focused = false gateMessage = nil + decisionSaved = false 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 { + if AppAccess.isPreviewLocked(ticker: latest.ticker, unlocked: purchases.effectiveUnlocked) { paywallTicker = symbol showPaywall = true return diff --git a/ios/QuantRadar/Views/SettingsView.swift b/ios/QuantRadar/Views/SettingsView.swift index aeff6c4..3d5594d 100644 --- a/ios/QuantRadar/Views/SettingsView.swift +++ b/ios/QuantRadar/Views/SettingsView.swift @@ -2,7 +2,9 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject private var purchases: PurchaseStore + @EnvironmentObject private var journal: DecisionJournal @State private var showPaywall = false + @State private var showClearJournalConfirmation = false var body: some View { NavigationStack { @@ -10,9 +12,9 @@ struct SettingsView: View { Section("Purchase") { LabeledContent( "Core", - value: purchases.effectiveUnlocked ? "Unlocked" : "Locked · $9.99" + value: purchases.effectiveUnlocked ? "Unlocked" : "Locked · \(unlockPrice)" ) - Text("One-time unlock for any ticker, watchlist, and 90-day evidence.") + Text("One-time unlock for every supported ticker, watchlist, and 90-day evidence.") .font(.caption) .foregroundStyle(.secondary) @@ -42,9 +44,15 @@ struct SettingsView: View { Section("Discipline") { LabeledContent("Streak", value: "\(DisciplineLedger.streak) days") LabeledContent("Waits logged", value: "\(DisciplineLedger.waitCount)") + LabeledContent("Decisions saved", value: "\(journal.entries.count)") Text(DisciplineLedger.summaryLine) .font(.caption) .foregroundStyle(.secondary) + if !journal.entries.isEmpty { + Button("Clear decision history", role: .destructive) { + showClearJournalConfirmation = true + } + } } Section("Legal") { @@ -68,12 +76,29 @@ struct SettingsView: View { } } .scrollContentBackground(.hidden) + .frame(maxWidth: 760) + .frame(maxWidth: .infinity) .background(QRTheme.bg.ignoresSafeArea()) .navigationTitle("Settings") .sheet(isPresented: $showPaywall) { PaywallView() .environmentObject(purchases) } + .confirmationDialog( + "Clear decision history?", + isPresented: $showClearJournalConfirmation, + titleVisibility: .visible + ) { + Button("Clear history", role: .destructive) { + journal.clear() + } + } message: { + Text("This removes the private on-device journal. It cannot be undone.") + } } } + + private var unlockPrice: String { + purchases.unlockProduct?.displayPrice ?? "App Store price" + } } diff --git a/ios/QuantRadar/Views/TodayView.swift b/ios/QuantRadar/Views/TodayView.swift index 7b098bb..2e5ebee 100644 --- a/ios/QuantRadar/Views/TodayView.swift +++ b/ios/QuantRadar/Views/TodayView.swift @@ -76,7 +76,7 @@ struct TodayView: View { .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } else { Button { showPaywall = true } label: { - Label("Unlock any ticker · $9.99", systemImage: "lock.open") + Label("Unlock any ticker · \(unlockPrice)", systemImage: "lock.open") .font(.subheadline.weight(.medium)) .frame(maxWidth: .infinity, alignment: .leading) .padding(12) @@ -94,6 +94,8 @@ struct TodayView: View { .foregroundStyle(QRTheme.muted) } .padding(20) + .frame(maxWidth: 760) + .frame(maxWidth: .infinity) } .background(QRTheme.bg.ignoresSafeArea()) .navigationTitle("Today") @@ -101,7 +103,7 @@ struct TodayView: View { ToolbarItem(placement: .topBarTrailing) { Button { Task { - _ = await radar.analyze(ticker: "SPY", bypassCache: true) + await radar.refreshToday(bypassCache: true) if purchases.effectiveUnlocked { await radar.refreshSectors() } @@ -123,6 +125,9 @@ struct TodayView: View { } if !briefingAsked { briefingAsked = true + #if DEBUG + if ScreenshotLaunch.isEnabled { return } + #endif await DailyBriefing.requestAndSchedule() } } @@ -135,4 +140,8 @@ struct TodayView: View { } } } + + private var unlockPrice: String { + purchases.unlockProduct?.displayPrice ?? "App Store price" + } } diff --git a/ios/QuantRadar/Views/VerdictCardView.swift b/ios/QuantRadar/Views/VerdictCardView.swift index 9c61753..7ac2685 100644 --- a/ios/QuantRadar/Views/VerdictCardView.swift +++ b/ios/QuantRadar/Views/VerdictCardView.swift @@ -3,6 +3,8 @@ import SwiftUI struct VerdictCardView: View { let verdict: RadarVerdict var showsShare: Bool = true + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var revealStep = 3 var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -43,6 +45,11 @@ struct VerdictCardView: View { gatesRow marketRow + if let day = verdict.meta?.marketAsOf { + Text("Daily close · \(day)\(verdict.meta?.fromCache == true ? " · cached" : "")") + .font(.caption) + .foregroundStyle(QRTheme.muted) + } if let history = verdict.depth?.history, !history.isEmpty { PostureStripView(history: history) @@ -68,6 +75,19 @@ struct VerdictCardView: View { .stroke(QRTheme.radar.opacity(0.25), lineWidth: 1) ) ) + .task(id: "\(verdict.ticker)-\(verdict.scoreText)-\(verdict.actionCode)") { + guard !reduceMotion else { + revealStep = 3 + return + } + revealStep = 0 + for step in 1...3 { + try? await Task.sleep(nanoseconds: 140_000_000) + withAnimation(.easeOut(duration: 0.2)) { + revealStep = step + } + } + } } private var scoreBadge: some View { @@ -82,6 +102,8 @@ struct VerdictCardView: View { .frame(width: 72, height: 72) .background(QRTheme.radarDim) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Mechanical posture score \(verdict.scoreText) out of 100") } private var actionPill: some View { @@ -93,6 +115,7 @@ struct VerdictCardView: View { .background(actionColor.opacity(0.2)) .foregroundStyle(actionColor) .clipShape(Capsule()) + .accessibilityLabel("Radar action \(verdict.primary?.label ?? verdict.actionCode)") } private var actionColor: Color { @@ -107,14 +130,18 @@ struct VerdictCardView: View { 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) + gateChip("Market", g.market, step: 1) + gateChip("Sector", g.sector, step: 2) + gateChip("Stock", g.stock, step: 3) } + .accessibilityElement(children: .combine) + .accessibilityLabel( + "Radar lock. Market \(g.market ?? "unknown"), sector \(g.sector ?? "unknown"), stock \(g.stock ?? "unknown")" + ) } } - private func gateChip(_ title: String, _ value: String?) -> some View { + private func gateChip(_ title: String, _ value: String?, step: Int) -> some View { VStack(spacing: 2) { Text(title) .font(.caption2) @@ -127,6 +154,14 @@ struct VerdictCardView: View { .padding(.vertical, 8) .background(QRTheme.bg.opacity(0.6)) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay(alignment: .top) { + Capsule() + .fill(revealStep >= step ? QRTheme.radar : QRTheme.muted.opacity(0.25)) + .frame(width: revealStep >= step ? 28 : 8, height: 2) + .padding(.top, 3) + } + .opacity(revealStep >= step ? 1 : 0.42) + .scaleEffect(revealStep >= step ? 1 : 0.97) } @ViewBuilder diff --git a/ios/QuantRadar/Views/WatchlistView.swift b/ios/QuantRadar/Views/WatchlistView.swift index f9a8113..c8a3fda 100644 --- a/ios/QuantRadar/Views/WatchlistView.swift +++ b/ios/QuantRadar/Views/WatchlistView.swift @@ -4,18 +4,78 @@ struct WatchlistView: View { @EnvironmentObject private var watchlist: WatchlistStore @EnvironmentObject private var radar: RadarService @EnvironmentObject private var purchases: PurchaseStore + @EnvironmentObject private var journal: DecisionJournal + @State private var showPaywall = false + @State private var showComposer = false + @State private var journalFilter = "Open" + + private var visibleEntries: [DecisionEntry] { + journal.entries.filter { journalFilter == "All" || (journalFilter == "Reviewed" ? $0.review != nil : $0.review == nil) } + } 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 { + List { + Section { + VStack(alignment: .leading, spacing: 12) { + Text("Before the trade.").font(.title.bold()) + Text("Write your conditions now. Review your process later.") + .font(.subheadline).foregroundStyle(QRTheme.muted) + Button { showComposer = true } label: { + Label("Write a decision", systemImage: "square.and.pencil") + .font(.headline).frame(maxWidth: .infinity).padding(.vertical, 5) + } + .buttonStyle(.borderedProminent).tint(QRTheme.radar).foregroundStyle(.black) + .accessibilityIdentifier("newDecisionPlan") + Text("\(journal.dueCount()) due for review · Private on this device · No purchase needed") + .font(.caption).foregroundStyle(QRTheme.muted) + } + .padding(.vertical, 8) + } + Section { + Picker("Journal filter", selection: $journalFilter) { + ForEach(["Open", "Reviewed", "All"], id: \.self) { Text($0).tag($0) } + } + .pickerStyle(.segmented) + if journal.entries.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("No decisions logged yet") + .font(.headline) + Text("Name your reason, the condition you will wait for, and what would change your mind. The original stays beside your later review.") + .font(.caption) + .foregroundStyle(QRTheme.muted) + } + .padding(.vertical, 6) + } else { + if visibleEntries.isEmpty { Text("No \(journalFilter.lowercased()) decisions yet.").foregroundStyle(QRTheme.muted) } + ForEach(visibleEntries) { entry in + NavigationLink { DecisionDetailView(entryID: entry.id) } label: { decisionRow(entry) } + } + } + } header: { + Text("Decision journal") + } footer: { + Text(journal.summaryLine) + } + + Section("Watchlist") { + if !purchases.effectiveUnlocked { + VStack(alignment: .leading, spacing: 10) { + Label("Unlock Watch", systemImage: "lock") + .font(.headline) + Text("Keep tickers together and refresh their mechanical posture. Unlock once — not a subscription.") + .font(.caption) + .foregroundStyle(QRTheme.muted) + Button("Unlock full radar") { showPaywall = true } + .buttonStyle(.borderedProminent) + .tint(QRTheme.radar) + .foregroundStyle(.black) + } + .padding(.vertical, 6) + } else if watchlist.items.isEmpty { + Text("Scan a ticker and tap Add to Watch.") + .foregroundStyle(QRTheme.muted) + } else { ForEach(watchlist.items) { item in VStack(alignment: .leading, spacing: 8) { HStack { @@ -56,18 +116,20 @@ struct WatchlistView: View { idx.map { watchlist.items[$0].ticker }.forEach(watchlist.remove) } } - .scrollContentBackground(.hidden) - .overlay(alignment: .top) { - if watchlist.isRefreshing { - ProgressView() - .tint(QRTheme.radar) - .padding(8) - } - } + } + } + .scrollContentBackground(.hidden) + .frame(maxWidth: 760) + .frame(maxWidth: .infinity) + .overlay(alignment: .top) { + if watchlist.isRefreshing { + ProgressView() + .tint(QRTheme.radar) + .padding(8) } } .background(QRTheme.bg.ignoresSafeArea()) - .navigationTitle("Watch") + .navigationTitle("Plan") .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { @@ -75,13 +137,63 @@ struct WatchlistView: View { } label: { Image(systemName: "arrow.clockwise") } - .disabled(watchlist.items.isEmpty || watchlist.isRefreshing) + .disabled( + !purchases.effectiveUnlocked + || watchlist.items.isEmpty + || watchlist.isRefreshing + ) } } .task { - guard !watchlist.items.isEmpty else { return } + guard purchases.effectiveUnlocked, !watchlist.items.isEmpty else { return } await watchlist.refreshScores(using: radar) } + .sheet(isPresented: $showPaywall) { + PaywallView() + .environmentObject(purchases) + } + .sheet(isPresented: $showComposer) { + NavigationStack { DecisionPlanComposer() }.tint(QRTheme.radar) + } + } + } + + private func decisionRow(_ entry: DecisionEntry) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(entry.ticker) + .font(.headline) + .foregroundStyle(QRTheme.text) + Text(entry.decision) + .font(.caption2.monospaced().weight(.bold)) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background((entry.processClear ? QRTheme.radar : QRTheme.warn).opacity(0.18)) + .foregroundStyle(entry.processClear ? QRTheme.radar : QRTheme.warn) + .clipShape(Capsule()) + Spacer() + if let score = entry.score { + Text(String(format: "%.0f", score)) + .font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(QRTheme.muted) + } + } + HStack { + Text(entry.review == nil ? (entry.plan == nil ? "Quick log" : "Written plan") : "Reviewed") + Text("·") + Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted)) + } + .font(.caption) + .foregroundStyle(QRTheme.muted) + if let plan = entry.plan, entry.review == nil { + Text("Review \(plan.reviewOn.formatted(date: .abbreviated, time: .omitted))") + .font(.caption).foregroundStyle(QRTheme.radar) + } } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel( + "\(entry.ticker), decision \(entry.decision), radar \(entry.radarAction), \(entry.createdAt.formatted(date: .abbreviated, time: .omitted))" + ) } } diff --git a/ios/QuantRadarStoreKitTests/PurchaseStoreTests.swift b/ios/QuantRadarStoreKitTests/PurchaseStoreTests.swift new file mode 100644 index 0000000..1122179 --- /dev/null +++ b/ios/QuantRadarStoreKitTests/PurchaseStoreTests.swift @@ -0,0 +1,88 @@ +import XCTest +import StoreKit +import StoreKitTest +@testable import QuantRadar + +/// Apple's local StoreKit test environment, never a live charge or debug unlock. +@MainActor +final class PurchaseStoreTests: XCTestCase { + private func session() throws -> SKTestSession { + let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "Products", withExtension: "storekit")) + let test = try SKTestSession(contentsOf: url) + test.resetToDefaultState() + test.clearTransactions() + test.disableDialogs = true + guard test.disableDialogs else { + throw NSError(domain: "StoreKitTestConfiguration", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Local StoreKit configuration could not be activated."]) + } + return test + } + + private func store() async -> PurchaseStore { + let store = PurchaseStore() + store.debugForceUnlocked = false + store.debugForceLivePlus = false + await store.bootstrap() + return store + } + + private func waitForEntitlement(_ store: PurchaseStore, unlocked: Bool) async -> Bool { + for _ in 0..<30 { + if store.isUnlocked == unlocked { return true } + try? await Task.sleep(nanoseconds: 100_000_000) + } + return false + } + + func testPurchaseRestoreAndRefundThroughStoreKit() async throws { + let test = try session() + defer { test.clearTransactions() } + let first = await store() + XCTAssertFalse(first.effectiveUnlocked) + XCTAssertEqual(first.unlockProduct?.type, .nonConsumable) + let bought = await first.purchaseUnlock() + XCTAssertTrue(bought) + XCTAssertTrue(first.isUnlocked) + XCTAssertTrue(first.effectiveUnlocked) + + let restored = await store() + await restored.restore() + XCTAssertTrue(restored.isUnlocked) + XCTAssertNil(restored.lastError) + let transaction = try XCTUnwrap(test.allTransactions().first { $0.productIdentifier == AppAccess.unlockProductID }) + try test.refundTransaction(identifier: transaction.identifier) + let revoked = await waitForEntitlement(restored, unlocked: false) + XCTAssertTrue(revoked) + XCTAssertFalse(restored.effectiveUnlocked) + await restored.restore() + XCTAssertFalse(restored.isUnlocked) + } + + func testCancelledPurchaseDoesNotUnlock() async throws { + let test = try session() + defer { test.clearTransactions() } + let purchases = await store() + _ = try XCTUnwrap(purchases.unlockProduct) + try await test.setSimulatedError(.generic(.userCancelled), forAPI: .purchase) + let bought = await purchases.purchaseUnlock() + XCTAssertFalse(bought) + XCTAssertFalse(purchases.effectiveUnlocked) + XCTAssertFalse(purchases.isBusy) + } + + func testPendingPurchaseOnlyUnlocksAfterApproval() async throws { + let test = try session() + defer { test.clearTransactions() } + test.askToBuyEnabled = true + let purchases = await store() + let bought = await purchases.purchaseUnlock() + XCTAssertFalse(bought) + XCTAssertFalse(purchases.effectiveUnlocked) + XCTAssertEqual(purchases.lastError, "Purchase pending approval.") + let transaction = try XCTUnwrap(test.allTransactions().first { $0.productIdentifier == AppAccess.unlockProductID }) + try test.approveAskToBuyTransaction(identifier: transaction.identifier) + let granted = await waitForEntitlement(purchases, unlocked: true) + XCTAssertTrue(granted) + } +} diff --git a/ios/QuantRadarTests/BarsCacheTests.swift b/ios/QuantRadarTests/BarsCacheTests.swift index 16a8b04..ead4a60 100644 --- a/ios/QuantRadarTests/BarsCacheTests.swift +++ b/ios/QuantRadarTests/BarsCacheTests.swift @@ -23,11 +23,14 @@ final class BarsCacheTests: XCTestCase { XCTAssertNotNil(hit) XCTAssertEqual(hit?.bars.count, 40) XCTAssertEqual(hit?.source, .yahooQuery1) + XCTAssertEqual(hit?.fetchedAt, result.fetchedAt) + XCTAssertEqual(hit?.fromCache, true) // 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) + XCTAssertEqual(diskHit?.fetchedAt, result.fetchedAt) } func testExpiredEntryMisses() async throws { diff --git a/ios/QuantRadarTests/DecisionJournalTests.swift b/ios/QuantRadarTests/DecisionJournalTests.swift new file mode 100644 index 0000000..cecce7d --- /dev/null +++ b/ios/QuantRadarTests/DecisionJournalTests.swift @@ -0,0 +1,162 @@ +import XCTest +@testable import QuantRadar + +@MainActor +final class DecisionJournalTests: XCTestCase { + private var defaults: UserDefaults! + private var suiteName: String! + + override func setUp() { + super.setUp() + suiteName = "qr.journal.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testChaseCheckRequiresAllThreeCommitments() { + var check = ChaseCheck() + XCTAssertEqual(check.status, "PAUSE") + XCTAssertEqual(check.completedCount, 0) + + check.entryWasPlanned = true + check.invalidationIsDefined = true + XCTAssertFalse(check.isClear) + + check.independentOfHype = true + XCTAssertTrue(check.isClear) + XCTAssertEqual(check.status, "PROCESS CLEAR") + + check.reset() + XCTAssertEqual(check, ChaseCheck()) + } + + func testJournalRecordsPauseWhenProcessIncomplete() { + let journal = DecisionJournal(storage: defaults) + let verdict = RadarService.synthetic(for: "AAPL", reason: "test") + + let entry = journal.record(verdict: verdict, chaseCheck: ChaseCheck()) + + XCTAssertEqual(entry.ticker, "AAPL") + XCTAssertEqual(entry.decision, "PAUSE") + XCTAssertFalse(entry.processClear) + XCTAssertEqual(journal.entries.count, 1) + } + + func testJournalPreservesSeparateDecisionsForSameTickerAndDay() { + let now = Date(timeIntervalSince1970: 1_788_000_000) + let verdict = RadarService.synthetic(for: "MSFT", reason: "test") + var clear = ChaseCheck() + clear.entryWasPlanned = true + clear.invalidationIsDefined = true + clear.independentOfHype = true + + let journal = DecisionJournal(storage: defaults) + let first = journal.record(verdict: verdict, chaseCheck: clear, now: now) + let second = journal.record(verdict: verdict, chaseCheck: clear, now: now.addingTimeInterval(60)) + + XCTAssertEqual(first.decision, "PAUSE") + XCTAssertEqual(first.radarAction, "UNKNOWN") + XCTAssertEqual(second.decision, "PAUSE") + XCTAssertNotEqual(first.id, second.id) + XCTAssertEqual(journal.entries.count, 2) + XCTAssertEqual(journal.entries.last, first) + + let restored = DecisionJournal(storage: defaults) + XCTAssertEqual(restored.entries.count, 2) + XCTAssertEqual(restored.entries.first?.ticker, "MSFT") + } + + private func makePlan(_ journal: DecisionJournal, now: Date = Date()) throws -> DecisionEntry { + try journal.commitPlan(ticker: " aapl ", reason: " My original reason ", trigger: "Wait for my condition", + invalidation: "My invalidation", reviewOn: now, decision: "WAIT", now: now) + } + + func testPlanWorksWithoutMarketDataAndSurvivesRelaunch() throws { + let journal = DecisionJournal(storage: defaults) + let plan = try makePlan(journal) + XCTAssertEqual(plan.ticker, "AAPL") + XCTAssertEqual(plan.plan?.reason, "My original reason") + XCTAssertEqual(plan.radarAction, "NOT SCANNED") + XCTAssertNil(plan.score) + XCTAssertNil(plan.review) + XCTAssertEqual(DecisionJournal(storage: defaults).entries.first, plan) + } + + func testReviewKeepsOriginalAndCannotOverwritePreviousReview() throws { + let journal = DecisionJournal(storage: defaults) + let now = Date() + let before = try makePlan(journal, now: now) + try journal.completeReview(id: before.id, outcome: .didNotAct, lesson: "Condition never occurred", now: now.addingTimeInterval(60)) + let after = try XCTUnwrap(DecisionJournal(storage: defaults).entries.first) + XCTAssertEqual(after.id, before.id) + XCTAssertEqual(after.plan, before.plan) + XCTAssertEqual(after.createdAt, before.createdAt) + XCTAssertEqual(after.decision, before.decision) + XCTAssertEqual(after.review?.lesson, "Condition never occurred") + XCTAssertTrue(after.exportText.contains("Original reason: My original reason")) + XCTAssertTrue(after.exportText.contains("Condition never occurred")) + XCTAssertThrowsError(try journal.completeReview(id: before.id, outcome: .followed, lesson: "Rewrite")) + XCTAssertEqual(journal.entries.first, after) + } + + func testIncompleteOrMismatchedPlanDoesNotPersist() { + let journal = DecisionJournal(storage: defaults) + let now = Date() + for symbol in ["", " diff --git a/static/login.html b/static/login.html index cd4767f..ae18513 100644 --- a/static/login.html +++ b/static/login.html @@ -2,9 +2,13 @@ + + + + Sign in — QuantRadar - + @@ -25,11 +29,10 @@

Sign in

- Own account — email + password. No Manus. - Guest demo radar stays open without signing in. + Save your watchlist and keep your reports in one place. + You can also run free scans without an account.

-
@@ -48,8 +51,7 @@

Sign in

-
or
- +
diff --git a/static/privacy-ios.html b/static/privacy-ios.html index b30f37f..1fc4a7c 100644 --- a/static/privacy-ios.html +++ b/static/privacy-ios.html @@ -2,6 +2,8 @@ + + Privacy Policy (iOS) — QuantRadar @@ -25,6 +27,7 @@

What the iOS app collects

  • No account, email, or sign-in.
  • No analytics SDK, advertising ID, or tracking.
  • Tickers you scan or watch stay on device (UserDefaults / local cache).
  • +
  • Chase Check answers and Decision Journal entries stay on device and can be cleared in Settings.
  • Purchases are processed by Apple. We do not receive your card number.
  • Network

    diff --git a/static/privacy.html b/static/privacy.html index 306bfc7..fd06710 100644 --- a/static/privacy.html +++ b/static/privacy.html @@ -2,6 +2,8 @@ + + Privacy Policy — QuantRadar diff --git a/static/refund.html b/static/refund.html index f5c326d..1f4c0e8 100644 --- a/static/refund.html +++ b/static/refund.html @@ -2,6 +2,8 @@ + + Refund Policy — QuantRadar diff --git a/static/reports.html b/static/reports.html new file mode 100644 index 0000000..f6cb111 --- /dev/null +++ b/static/reports.html @@ -0,0 +1,129 @@ + + + + + Your reports · QuantRadar + + + +
    QuantRadar
    +
    +

    Your reports

    +

    Keep a dated snapshot you can return to. Purchased reports stay in your account after a subscription ends.

    + +
    +

    One stock, one complete report · $9

    +

    A fixed snapshot with full JSON, four daily charts and a reconstructed replay of 90 trading sessions. We check coverage before opening checkout.

    + + + +

    +

    Total $9 · one-time payment. No subscription.

    + + +

    The $9 report credit can be used toward Pro within 7 days of payment. Historical replay uses mechanical price rules; historical earnings and sector gates are unavailable. Educational research, not trading performance.

    +
    +

    Saved reports

    +

    Loading your reports…

    +

    Refund policy · Privacy

    +
    + + + diff --git a/static/site.css b/static/site.css index b1c5aa1..147a93b 100644 --- a/static/site.css +++ b/static/site.css @@ -1,5 +1,3 @@ -@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Syne:wght@600;700;800&display=swap"); - :root { /* Four-step elevation + material (craft FR-2) */ --bg-void: #060807; @@ -218,6 +216,24 @@ a:hover { color: #6ee4ad; } padding: 2rem 1.25rem 3rem; line-height: 1.6; } +.pricing-page { max-width: 1180px; } + +.report-page input:not([type="checkbox"]) { + display: block; + width: 100%; + padding: 0.8rem; + margin: 0.4rem 0 1rem; + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + background: var(--bg); + font: inherit; +} +.report-page input[type="checkbox"] { width: 1.1rem; height: 1.1rem; vertical-align: middle; accent-color: var(--accent); } +.report-page .card { margin-bottom: 1rem; } +.report-page .card h2 { margin-top: 0; } +.report-page .table-scroll { overflow-x: auto; } +.report-page .watch-tickers { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 1rem 0; } .prose h1 { font-family: var(--display); @@ -2174,3 +2190,86 @@ select:focus-visible { color: var(--muted); font-size: 0.88rem; } + +/* ---- engagement layer ("Your desk") ---- */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 80; + padding: 1rem; +} +.modal-card { + background: var(--panel, #101b16); + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 14px; + padding: 1.25rem; + max-width: 420px; + width: 100%; +} +.modal-card h3 { margin: 0 0 0.5rem; } +.bump-row { + display: flex; + align-items: flex-start; + gap: 0.55rem; + padding: 0.7rem; + margin: 0.75rem 0; + border: 1px dashed rgba(61, 214, 140, 0.35); + border-radius: 10px; + font-size: 0.85rem; + cursor: pointer; +} +.bump-row input { margin-top: 0.15rem; } + +.today-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + margin-top: 0.4rem; +} +.today-table th, +.today-table td { + text-align: left; + padding: 0.5rem 0.6rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); +} +.today-table th { + color: var(--muted); + font-weight: 500; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.today-table tbody tr { + transition: background 160ms var(--ease-out); +} +.today-table tbody tr:hover { + background: rgba(61, 214, 140, 0.055); +} +.today-row-in { + animation: fadeRise var(--motion-reveal) var(--ease-out) both; +} + +#engagePanel .engage-line.good { + color: var(--accent); + font-size: 0.9rem; + margin: 0.35rem 0 0.6rem; +} +#engagePanel .engage-note { + color: var(--muted); + font-size: 0.82rem; + margin: 0.35rem 0 0.5rem; +} +#engagePanel .spark { + display: block; + color: var(--accent); + opacity: 0.9; +} +.btn.small { + padding: 0.3rem 0.7rem; + font-size: 0.8rem; +} +#engageActions { align-items: center; } diff --git a/static/terms-ios.html b/static/terms-ios.html index 1c4547e..4701f00 100644 --- a/static/terms-ios.html +++ b/static/terms-ios.html @@ -2,6 +2,8 @@ + + Terms of Use (iOS) — QuantRadar diff --git a/static/terms.html b/static/terms.html index 8173a9f..11e1979 100644 --- a/static/terms.html +++ b/static/terms.html @@ -2,6 +2,8 @@ + + Terms of Use — QuantRadar diff --git a/static/today.html b/static/today.html new file mode 100644 index 0000000..cca5f97 --- /dev/null +++ b/static/today.html @@ -0,0 +1,201 @@ + + + + + + + + + + Today — QuantRadar + + + + + + + + + + +
    + QuantRadar + +
    +
    +

    Today’s gate screen

    +

    Mechanical screen only — educational, not investment advice.

    + + + + + +

    + Desk · Track notes · Pricing +

    +
    + + + + diff --git a/static/track.html b/static/track.html index dfb3e04..1203d38 100644 --- a/static/track.html +++ b/static/track.html @@ -2,6 +2,10 @@ + + + + Track notes — QuantRadar diff --git a/static/watchlist.html b/static/watchlist.html new file mode 100644 index 0000000..5be9d95 --- /dev/null +++ b/static/watchlist.html @@ -0,0 +1,112 @@ + + + + + Your watchlist · QuantRadar + + + +
    QuantRadar
    +
    +

    Your watchlist

    +

    Review your stocks together after each completed trading session. Saved snapshots show which postures changed and which stocks need better data.

    + +
    +

    Stocks you follow

    +

    +
    +
    +

    + +

    Pro: up to 10 stocks. Portfolio Pro: up to 50 stocks plus CSV export. One saved report per completed session. Changes to your watchlist apply to the next report. Reports appear here; email delivery is not included.

    +
    +

    Daily reports

    +

    Loading…

    +

    These are watchlist snapshots, not portfolio returns, buy recommendations or a record of trading performance.

    +
    + + + diff --git a/tests/stripe_fixtures.py b/tests/stripe_fixtures.py new file mode 100644 index 0000000..0d8d76d --- /dev/null +++ b/tests/stripe_fixtures.py @@ -0,0 +1,18 @@ +"""Stripe subscription response fixtures; never contact or charge a real account.""" +import os +import time +from unittest import mock + + +def mock_subscription(case, email, *, status="active", plan="pro", customer="cus_test"): + price = "price_portfolio_test" if plan == "portfolio_pro" else "price_monthly_test" + sub = {"id": "sub_test", "customer": customer, "status": status, + "metadata": {"email": email, "product": "quantradar_" + plan}, + "current_period_end": int(time.time()) + 86400, + "items": {"data": [{"price": {"id": price}}]}} + env = mock.patch.dict(os.environ, {"STRIPE_PRICE_ID_MONTHLY": "price_monthly_test", + "STRIPE_PRICE_ID_PORTFOLIO_PRO_MONTHLY": "price_portfolio_test"}) + env.start(); case.addCleanup(env.stop) + network = mock.patch("app.stripe_billing.stripe_get", return_value=sub) + network.start(); case.addCleanup(network.stop) + return sub diff --git a/tests/test_atomic.py b/tests/test_atomic.py new file mode 100644 index 0000000..33be078 --- /dev/null +++ b/tests/test_atomic.py @@ -0,0 +1,672 @@ +"""Atomic unit tests for the engagement / users / billing layer. + +Each test verifies exactly one behavior with hand-computed expectations — +no HTTP, no network, no engine subprocess. Pure functions and the users store +are isolated in temp dirs. +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +import time +import unittest +from pathlib import Path +from unittest import mock + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO)) + +import app.users as users_mod # noqa: E402 +from app import engagement # noqa: E402 +from app import stripe_billing # noqa: E402 + + +class EngagementStoreFixture(unittest.TestCase): + """Temp-dir isolation for engagement JSON stores.""" + + def setUp(self) -> None: + patcher = mock.patch("free_engine.market_calendar.completed_session", return_value="2026-09-04") + patcher.start() + self.addCleanup(patcher.stop) + self._td = tempfile.TemporaryDirectory() + d = Path(self._td.name) + self._orig = (engagement.LEDGER_PATH, engagement.PRICE_CACHE_PATH, engagement.DIGEST_DIR, engagement.TODAY_CACHE_PATH) + engagement.LEDGER_PATH = d / "ledger.json" + engagement.PRICE_CACHE_PATH = d / "price_cache.json" + engagement.DIGEST_DIR = d / "digest" + engagement.TODAY_CACHE_PATH = d / "today_cache.json" + + def tearDown(self) -> None: + (engagement.LEDGER_PATH, engagement.PRICE_CACHE_PATH, engagement.DIGEST_DIR, engagement.TODAY_CACHE_PATH) = self._orig + self._td.cleanup() + + def _seed(self, entries, cache) -> None: + for entry in entries: + entry.setdefault("as_of", "2026-09-03") + for hit in cache.values(): + hit.setdefault("as_of", "2026-09-04") + engagement.LEDGER_PATH.write_text(json.dumps({"version": 1, "entries": entries}), encoding="utf-8") + engagement.PRICE_CACHE_PATH.write_text(json.dumps(cache), encoding="utf-8") + + +class AvoidanceAtoms(EngagementStoreFixture): + def test_exact_threshold_5pct_counts(self) -> None: + # drop of exactly 5.0 is >= threshold → counts + self._seed( + [{"ticker": "T", "ts": time.time() - 10, "action": "NO", "close": 100.0, "email": "reader@example.com"}], + {"T": {"ts": time.time(), "close": 95.0}}, + ) + ev = engagement.avoidance_events("reader@example.com") + self.assertEqual(len(ev), 1) + self.assertEqual(ev[0]["drop_pct"], 5.0) + + def test_drop_4_9pct_excluded(self) -> None: + self._seed( + [{"ticker": "T", "ts": time.time() - 10, "action": "NO", "close": 100.0, "email": "reader@example.com"}], + {"T": {"ts": time.time(), "close": 95.1}}, + ) + self.assertEqual(engagement.avoidance_events("reader@example.com"), []) + + def test_price_rise_never_counts(self) -> None: + self._seed( + [{"ticker": "T", "ts": time.time() - 10, "action": "NO", "close": 100.0, "email": "reader@example.com"}], + {"T": {"ts": time.time(), "close": 130.0}}, + ) + self.assertEqual(engagement.avoidance_events("reader@example.com"), []) + + def test_wait_action_never_counts(self) -> None: + self._seed( + [{"ticker": "T", "ts": time.time() - 10, "action": "WAIT", "close": 100.0, "email": "reader@example.com"}], + {"T": {"ts": time.time(), "close": 50.0}}, + ) + self.assertEqual(engagement.avoidance_events("reader@example.com"), []) + + def test_zero_then_close_skipped(self) -> None: + self._seed( + [{"ticker": "T", "ts": time.time() - 10, "action": "NO", "close": 0.0, "email": "reader@example.com"}], + {"T": {"ts": time.time(), "close": 1.0}}, + ) + self.assertEqual(engagement.avoidance_events("reader@example.com"), []) + + def test_missing_cache_entry_skipped(self) -> None: + self._seed( + [{"ticker": "T", "ts": time.time() - 10, "action": "NO", "close": 100.0, "email": "reader@example.com"}], + {}, + ) + self.assertEqual(engagement.avoidance_events("reader@example.com"), []) + + def test_cache_older_than_ttl_excluded(self) -> None: + stale_ts = time.time() - engagement._PRICE_TTL_SEC - 10 + self._seed( + [{"ticker": "T", "ts": time.time() - 10, "action": "NO", "close": 100.0, "email": "reader@example.com"}], + {"T": {"ts": stale_ts, "close": 90.0}}, + ) + self.assertEqual(engagement.avoidance_events("reader@example.com"), []) + + def test_limit_parameter(self) -> None: + entries = [ + {"ticker": f"T{i}", "ts": time.time() - 10, "action": "NO", "close": 100.0, "email": "reader@example.com"} + for i in range(8) + ] + cache = {f"T{i}": {"ts": time.time(), "close": 90.0} for i in range(8)} + self._seed(entries, cache) + self.assertEqual(len(engagement.avoidance_events("reader@example.com", limit=3)), 3) + self.assertEqual(len(engagement.avoidance_events("reader@example.com", limit=100)), 8) + + def test_most_recent_scans_first(self) -> None: + self._seed( + [ + {"ticker": "OLD", "ts": time.time() - 5000, "action": "NO", "close": 100.0, "email": "reader@example.com"}, + {"ticker": "NEW", "ts": time.time() - 10, "action": "NO", "close": 100.0, "email": "reader@example.com"}, + ], + {"OLD": {"ts": time.time(), "close": 90.0}, "NEW": {"ts": time.time(), "close": 90.0}}, + ) + ev = engagement.avoidance_events("reader@example.com") + self.assertEqual(ev[0]["ticker"], "NEW") + + def test_corrupt_cache_file_yields_empty(self) -> None: + engagement.LEDGER_PATH.write_text("{not json", encoding="utf-8") + engagement.PRICE_CACHE_PATH.write_text("{not json", encoding="utf-8") + self.assertEqual(engagement.avoidance_events("reader@example.com"), []) + + def test_drop_pct_rounded_to_one_decimal(self) -> None: + self._seed( + [{"ticker": "T", "ts": time.time() - 10, "action": "NO", "close": 100.0, "email": "reader@example.com"}], + {"T": {"ts": time.time(), "close": 93.3333}}, + ) + ev = engagement.avoidance_events("reader@example.com") + self.assertEqual(ev[0]["drop_pct"], 6.7) + + +class RecordScanAtoms(EngagementStoreFixture): + def test_failed_result_not_recorded(self) -> None: + engagement.record_scan("T", {"ok": False}, None) + time.sleep(0.15) + self.assertFalse(engagement.LEDGER_PATH.is_file()) + + def test_demo_result_not_recorded_even_when_called_directly(self) -> None: + engagement.record_scan("T", {"ok": True, "meta": {"mode":"artifact"}}, "reader@example.com") + self.assertFalse(engagement.LEDGER_PATH.is_file()) + + def test_missing_snapshot_close_skips_entry(self) -> None: + engagement.record_scan("T", {"ok": True, "meta": {"mode":"live", "market_as_of":"2026-09-04"}}, "reader@example.com") + self.assertFalse(engagement.LEDGER_PATH.is_file()) + + def test_ledger_capped_at_2000(self) -> None: + big = [{"ticker": f"T{i}"} for i in range(2050)] + engagement.LEDGER_PATH.write_text(json.dumps({"version": 2, "entries": big}), encoding="utf-8") + engagement.record_scan("CAP", {"ok": True, "primary": {"action":"NO"}, + "meta":{"mode":"live", "market_as_of":"2026-09-04", "market_close":2}}, "reader@example.com") + data = json.loads(engagement.LEDGER_PATH.read_text()) + self.assertEqual(len(data["entries"]), 2000) + self.assertEqual(data["entries"][-1]["ticker"], "CAP") + + +class RecentClosesAtoms(EngagementStoreFixture): + def _write_cache(self, src: str, ticker: str, rows: list) -> None: + engine_dir = engagement._engine_dir() + cache_dir = engine_dir / ".cache" + cache_dir.mkdir(parents=True, exist_ok=True) + safe = "".join(ch if ch.isalnum() else "_" for ch in f"{src}:{ticker}") + (cache_dir / f"{safe}.json").write_text( + json.dumps({"t": time.time(), "v": rows}), encoding="utf-8" + ) + + def test_cold_cache_returns_empty(self) -> None: + self.assertEqual(engagement.recent_closes("NOSUCH"), []) + + def test_warm_cache_returns_last_n(self) -> None: + rows = [["2026-08-0%d" % (i + 1), 100.0 + i, 1000.0] for i in range(10)] + self._write_cache("yahoo_q1", "WARM", rows) + got = engagement.recent_closes("WARM", n=5) + self.assertEqual(got, [105.0, 106.0, 107.0, 108.0, 109.0]) + + def test_shorter_cache_than_n_returns_empty(self) -> None: + rows = [["2026-08-0%d" % (i + 1), 100.0 + i, 1000.0] for i in range(3)] + self._write_cache("yahoo_q1", "SHORT", rows) + self.assertEqual(engagement.recent_closes("SHORT", n=5), []) + + +class WatchlistAtoms(unittest.TestCase): + def setUp(self) -> None: + self._td = tempfile.TemporaryDirectory() + self._orig = users_mod.USERS_PATH + users_mod.USERS_PATH = Path(self._td.name) / "users.json" + os.environ["ALLOW_REGISTER"] = "1" + users_mod.register_user("w@test.local", "hunter2secret") + + def tearDown(self) -> None: + users_mod.USERS_PATH = self._orig + self._td.cleanup() + + def test_invalid_tickers_rejected(self) -> None: + for bad in ("", " ", "A B", "T;DROP", "!!!!"): + ok, why, _ = engagement.add_watch("w@test.local", bad) + self.assertFalse(ok, f"should reject {bad!r}") + self.assertEqual(why, "invalid ticker") + + def test_lowercase_normalized_to_upper(self) -> None: + ok, why, wl = engagement.add_watch("w@test.local", "nvda") + self.assertTrue(ok) + self.assertEqual(wl, ["NVDA"]) + + def test_duplicate_add_idempotent(self) -> None: + engagement.add_watch("w@test.local", "NVDA") + ok, why, wl = engagement.add_watch("w@test.local", "NVDA") + self.assertTrue(ok) + self.assertEqual(why, "already watching") + self.assertEqual(wl, ["NVDA"]) + + def test_free_limit_is_one(self) -> None: + self.assertEqual(engagement.watch_limit("w@test.local"), 1) + engagement.add_watch("w@test.local", "NVDA") + ok, why, wl = engagement.add_watch("w@test.local", "AMD") + self.assertFalse(ok) + self.assertEqual(why, "limit") + self.assertEqual(wl, ["NVDA"]) + + def test_pro_limit_is_ten(self) -> None: + users_mod.set_plan("w@test.local", "pro") + self.assertEqual(engagement.watch_limit("w@test.local"), 10) + tickers = ["A1", "B2", "C3", "D4", "E5", "F6", "G7", "H8", "I9", "J0"] + for t in tickers: + ok, why, _ = engagement.add_watch("w@test.local", t) + self.assertTrue(ok, (t, why)) + # 10 full — the 11th must hit the cap + ok, why, wl = engagement.add_watch("w@test.local", "K1") + self.assertFalse(ok) + self.assertEqual(why, "limit") + self.assertEqual(len(wl), 10) + + def test_remove_missing_ticker_no_error(self) -> None: + wl = engagement.remove_watch("w@test.local", "NOPE") + self.assertEqual(wl, []) + + def test_remove_then_readd_slot_freed(self) -> None: + engagement.add_watch("w@test.local", "NVDA") + engagement.remove_watch("w@test.local", "NVDA") + ok, why, wl = engagement.add_watch("w@test.local", "AMD") + self.assertTrue(ok) + self.assertEqual(wl, ["AMD"]) + + def test_unknown_account_returns_error(self) -> None: + ok, why, wl = engagement.add_watch("ghost@test.local", "NVDA") + self.assertFalse(ok) + self.assertEqual(why, "no account") + self.assertEqual(wl, []) + self.assertEqual(engagement.get_watchlist("ghost@test.local"), []) + + +class DigestAtoms(unittest.TestCase): + def setUp(self) -> None: + self._td = tempfile.TemporaryDirectory() + self._orig = users_mod.USERS_PATH + users_mod.USERS_PATH = Path(self._td.name) / "users.json" + os.environ["ALLOW_REGISTER"] = "1" + self._orig_smtp = {k: os.environ.get(k) for k in ("SMTP_HOST", "SMTP_USER")} + os.environ.pop("SMTP_HOST", None) + os.environ.pop("SMTP_USER", None) + + def tearDown(self) -> None: + users_mod.USERS_PATH = self._orig + for k, v in self._orig_smtp.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + self._td.cleanup() + + def test_optin_defaults_false(self) -> None: + users_mod.register_user("d@test.local", "hunter2secret") + self.assertFalse(users_mod.get_user("d@test.local").get("daily_digest")) + + def test_optin_roundtrip(self) -> None: + users_mod.register_user("d@test.local", "hunter2secret") + engagement.set_digest_optin("d@test.local", True) + self.assertTrue(users_mod.get_user("d@test.local").get("daily_digest")) + engagement.set_digest_optin("d@test.local", False) + self.assertFalse(users_mod.get_user("d@test.local").get("daily_digest")) + + def test_optins_only_returns_subscribed(self) -> None: + users_mod.register_user("a@test.local", "hunter2secret") + users_mod.register_user("b@test.local", "hunter2secret") + engagement.set_digest_optin("a@test.local", True) + rows = engagement.digest_optins() + self.assertEqual([r["email"] for r in rows], ["a@test.local"]) + + def test_smtp_configured_false_without_keys(self) -> None: + self.assertFalse(engagement.smtp_configured()) + + def test_smtp_configured_true_with_keys(self) -> None: + os.environ["SMTP_HOST"] = "smtp.example.com" + os.environ["SMTP_USER"] = "u" + self.assertTrue(engagement.smtp_configured()) + + +class UsersEntitlementAtoms(unittest.TestCase): + def setUp(self) -> None: + self._td = tempfile.TemporaryDirectory() + self._orig = users_mod.USERS_PATH + users_mod.USERS_PATH = Path(self._td.name) / "users.json" + os.environ["ALLOW_REGISTER"] = "1" + users_mod.register_user("e@test.local", "hunter2secret") + + def tearDown(self) -> None: + users_mod.USERS_PATH = self._orig + self._td.cleanup() + + def test_public_user_entitlement_defaults(self) -> None: + u = users_mod.public_user(users_mod.get_user("e@test.local")) + self.assertEqual(u["watchlist"], []) + self.assertFalse(u["report_granted"]) + self.assertFalse(u["bump_csv_priority"]) + self.assertFalse(u["daily_digest"]) + self.assertIsNone(u["report_coupon"]) + + def test_grant_report_sets_flags(self) -> None: + users_mod.grant_report("e@test.local") + u = users_mod.get_user("e@test.local") + self.assertTrue(u["report_granted"]) + self.assertIn("report_granted_at", u) + self.assertFalse(u.get("bump_csv_priority", False)) + + def test_grant_report_with_bump(self) -> None: + users_mod.grant_report("e@test.local", bump=True) + u = users_mod.get_user("e@test.local") + self.assertTrue(u["report_granted"]) + self.assertTrue(u["bump_csv_priority"]) + + def test_grant_report_unknown_account_raises(self) -> None: + with self.assertRaises(ValueError): + users_mod.grant_report("ghost@test.local") + + def test_set_report_coupon_stored(self) -> None: + users_mod.set_report_coupon("e@test.local", "coupon_abc") + u = users_mod.get_user("e@test.local") + self.assertEqual(u["report_coupon"], "coupon_abc") + + def test_set_report_coupon_clears(self) -> None: + users_mod.set_report_coupon("e@test.local", "coupon_abc") + users_mod.set_report_coupon("e@test.local", None) + u = users_mod.get_user("e@test.local") + self.assertIsNone(u["report_coupon"]) + + def test_grant_report_does_not_clobber_plan(self) -> None: + users_mod.set_plan("e@test.local", "pro") + users_mod.grant_report("e@test.local") + u = users_mod.get_user("e@test.local") + self.assertEqual(u["plan"], "pro") + + def test_public_engagement_shape(self) -> None: + pub = engagement.public_engagement({"email": "e@test.local"}) + self.assertEqual(pub["watchlist"], []) + self.assertEqual(pub["watchlist_limit"], 1) + self.assertFalse(pub["report_granted"]) + + def test_public_engagement_unknown_email_empty(self) -> None: + self.assertEqual(engagement.public_engagement({"email": "ghost@test.local"}), {}) + self.assertEqual(engagement.public_engagement({}), {}) + + +class StripeReportAtoms(unittest.TestCase): + """Unit-level atoms for the $9 report checkout path (no network).""" + + def setUp(self) -> None: + self._env_orig = { + k: os.environ.get(k) + for k in ( + "STRIPE_PRICE_ID_REPORT", + "QUANTRADAR_STRIPE_PRICE_ID_REPORT", + "STRIPE_PRICE_ID_BUMP", + "QUANTRADAR_STRIPE_PRICE_ID_BUMP", + "QUANTRADAR_STRIPE_SECRET_KEY", + ) + } + for k in self._env_orig: + os.environ.pop(k, None) + + def tearDown(self) -> None: + for k, v in self._env_orig.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def test_price_id_report_env_resolution(self) -> None: + self.assertEqual(stripe_billing.price_id_report(), "") + os.environ["STRIPE_PRICE_ID_REPORT"] = "price_r1" + self.assertEqual(stripe_billing.price_id_report(), "price_r1") + os.environ.pop("STRIPE_PRICE_ID_REPORT") + os.environ["QUANTRADAR_STRIPE_PRICE_ID_REPORT"] = "price_r2" + self.assertEqual(stripe_billing.price_id_report(), "price_r2") + + def test_price_id_bump_env_resolution(self) -> None: + self.assertEqual(stripe_billing.price_id_bump(), "") + os.environ["STRIPE_PRICE_ID_BUMP"] = "price_b1" + self.assertEqual(stripe_billing.price_id_bump(), "price_b1") + + def test_report_checkout_requires_stripe(self) -> None: + with self.assertRaises(RuntimeError): + stripe_billing.create_report_checkout(customer_email="x@y.com") + + def test_report_checkout_requires_report_price(self) -> None: + os.environ["QUANTRADAR_STRIPE_SECRET_KEY"] = "sk_test_fake" + with self.assertRaises(RuntimeError) as ctx: + stripe_billing.create_report_checkout(customer_email="x@y.com") + self.assertIn("STRIPE_PRICE_ID_REPORT", str(ctx.exception)) + + def test_credit_coupon_without_key_returns_none(self) -> None: + self.assertIsNone(stripe_billing.create_credit_coupon("x@y.com")) + + def test_pro_checkout_with_credit_requires_stripe(self) -> None: + with self.assertRaises(RuntimeError): + stripe_billing.pro_checkout_with_credit( + customer_email="x@y.com", interval="monthly", coupon_id=None + ) + + def test_webhook_report_requires_a_bound_order(self) -> None: + """A paid flag without an order/session binding cannot grant reports or Pro.""" + users_td = tempfile.TemporaryDirectory() + orig = users_mod.USERS_PATH + users_mod.USERS_PATH = Path(users_td.name) / "users.json" + os.environ["ALLOW_REGISTER"] = "1" + try: + users_mod.register_user("buyer@test.local", "hunter2secret") + event = { + "id": "evt_atomic_checkout", + "type": "checkout.session.completed", + "data": { + "object": { + "customer_email": "buyer@test.local", + "payment_status": "paid", + "metadata": {"product": "quantradar_report", "bump": "1", "email": "buyer@test.local"}, + } + }, + } + with mock.patch.object(stripe_billing, "create_credit_coupon") as mint: + with self.assertRaises(ValueError): + stripe_billing.apply_webhook_event(event) + mint.assert_not_called() + u = users_mod.get_user("buyer@test.local") + self.assertFalse(u.get("report_granted")) + self.assertEqual(u["plan"], "free") + finally: + users_mod.USERS_PATH = orig + users_td.cleanup() + + def test_webhook_pro_branch_still_sets_pro(self) -> None: + """A verified active subscription with an allowlisted price grants Pro.""" + from stripe_fixtures import mock_subscription + mock_subscription(self, "sub@test.local") + users_td = tempfile.TemporaryDirectory() + orig = users_mod.USERS_PATH + users_mod.USERS_PATH = Path(users_td.name) / "users.json" + os.environ["ALLOW_REGISTER"] = "1" + try: + event = { + "id": "evt_atomic_checkout", + "type": "checkout.session.completed", + "data": { + "object": { + "mode": "subscription", "subscription": "sub_test", + "customer_email": "sub@test.local", + "payment_status": "paid", + "metadata": {"product": "quantradar_pro", "email": "sub@test.local"}, + } + }, + } + res = stripe_billing.apply_webhook_event(event) + self.assertTrue(res["ok"], res) + self.assertEqual(res["action"], "plan_pro") + self.assertEqual(users_mod.resolve_plan("sub@test.local"), "pro") + finally: + users_mod.USERS_PATH = orig + users_td.cleanup() + + def test_webhook_unpaid_report_not_granted(self) -> None: + users_td = tempfile.TemporaryDirectory() + orig = users_mod.USERS_PATH + users_mod.USERS_PATH = Path(users_td.name) / "users.json" + os.environ["ALLOW_REGISTER"] = "1" + try: + users_mod.register_user("np@test.local", "hunter2secret") + event = { + "id": "evt_atomic_checkout", + "type": "checkout.session.completed", + "data": { + "object": { + "customer_email": "np@test.local", + "payment_status": "unpaid", + "metadata": {"product": "quantradar_report"}, + } + }, + } + res = stripe_billing.apply_webhook_event(event) + self.assertTrue(res["ok"]) + self.assertEqual(res["action"], "awaiting_payment") + self.assertFalse(users_mod.get_user("np@test.local").get("report_granted")) + finally: + users_mod.USERS_PATH = orig + users_td.cleanup() + + +class _FakeResp: + def __init__(self, obj: dict) -> None: + self._obj = obj + + def read(self) -> bytes: + return json.dumps(self._obj).encode() + + def __enter__(self) -> "_FakeResp": + return self + + def __exit__(self, *args: object) -> bool: + return False + + +class CouponDegradeAtoms(unittest.TestCase): + """The $9->Pro credit coupon can die (expire/redeemed/deleted) between mint + and checkout. pro_checkout_with_credit must degrade to allow_promotion_codes + instead of crashing the checkout session creation.""" + + def setUp(self) -> None: + self._env_orig = { + k: os.environ.get(k) + for k in ( + "QUANTRADAR_STRIPE_SECRET_KEY", + "STRIPE_SECRET_KEY", + "STRIPE_PRICE_ID_MONTHLY", + "QUANTRADAR_STRIPE_PRICE_ID_MONTHLY", + "STRIPE_PRICE_ID_YEARLY", + "STRIPE_PRICE_ID", + "PUBLIC_BASE_URL", + ) + } + for k in self._env_orig: + os.environ.pop(k, None) + os.environ["QUANTRADAR_STRIPE_SECRET_KEY"] = "sk_test_fake" + os.environ["STRIPE_PRICE_ID_MONTHLY"] = "price_m1" + os.environ["PUBLIC_BASE_URL"] = "http://127.0.0.1:8765" + + def tearDown(self) -> None: + for k, v in self._env_orig.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def _patch_urlopen(self, coupon_obj: dict | Exception | None): + captured: list = [] + + def fake(req, timeout=None): # noqa: ANN001 + captured.append(req) + url = req.full_url + if "/checkout/sessions" in url: + return _FakeResp({"id": "cs_test_1", "url": "https://checkout.stripe.com/x"}) + if "/v1/coupons/" in url: + if isinstance(coupon_obj, Exception): + raise coupon_obj + return _FakeResp(coupon_obj or {}) + raise AssertionError("unexpected url: " + url) + + return mock.patch.object(stripe_billing.urllib.request, "urlopen", side_effect=fake), captured + + def _post_body(self, captured: list) -> dict: + req = [r for r in captured if "/checkout/sessions" in r.full_url][-1] + from urllib.parse import parse_qs + + return {k: v[0] for k, v in parse_qs(req.data.decode()).items()} + + def test_coupon_redeemable_no_secret_false(self) -> None: + os.environ.pop("QUANTRADAR_STRIPE_SECRET_KEY") + os.environ.pop("STRIPE_SECRET_KEY", None) + self.assertFalse(stripe_billing.coupon_redeemable("cpn_x")) + + def test_coupon_redeemable_no_id_false(self) -> None: + self.assertFalse(stripe_billing.coupon_redeemable(None)) + self.assertFalse(stripe_billing.coupon_redeemable(" ")) + + def test_coupon_redeemable_expired_false(self) -> None: + patcher, _ = self._patch_urlopen( + {"id": "cpn_x", "valid": True, "redeem_by": int(time.time()) - 10, "max_redemptions": 1, "times_redeemed": 0} + ) + with patcher: + self.assertFalse(stripe_billing.coupon_redeemable("cpn_x")) + + def test_coupon_redeemable_invalid_flag_false(self) -> None: + patcher, _ = self._patch_urlopen({"id": "cpn_x", "valid": False}) + with patcher: + self.assertFalse(stripe_billing.coupon_redeemable("cpn_x")) + + def test_coupon_redeemable_exhausted_false(self) -> None: + patcher, _ = self._patch_urlopen( + {"id": "cpn_x", "valid": True, "redeem_by": int(time.time()) + 3600, "max_redemptions": 1, "times_redeemed": 1} + ) + with patcher: + self.assertFalse(stripe_billing.coupon_redeemable("cpn_x")) + + def test_coupon_redeemable_network_error_false(self) -> None: + patcher, _ = self._patch_urlopen(RuntimeError("boom")) + with patcher: + self.assertFalse(stripe_billing.coupon_redeemable("cpn_x")) + + def test_coupon_redeemable_healthy_true(self) -> None: + patcher, _ = self._patch_urlopen( + {"id": "cpn_x", "valid": True, "redeem_by": int(time.time()) + 3600, "max_redemptions": 1, "times_redeemed": 0} + ) + with patcher: + self.assertTrue(stripe_billing.coupon_redeemable("cpn_x")) + + def test_pro_checkout_applies_valid_coupon(self) -> None: + with mock.patch.object(stripe_billing, "coupon_redeemable", return_value=True): + patcher, captured = self._patch_urlopen(None) + with patcher: + res = stripe_billing.pro_checkout_with_credit( + customer_email="b@t.com", interval="monthly", coupon_id="cpn_live" + ) + body = self._post_body(captured) + self.assertEqual(res["id"], "cs_test_1") + self.assertEqual(body["discounts[0][coupon]"], "cpn_live") + self.assertNotIn("allow_promotion_codes", body) + self.assertEqual(body["metadata[product]"], "quantradar_pro") + + def test_pro_checkout_degrades_on_dead_coupon(self) -> None: + with mock.patch.object(stripe_billing, "coupon_redeemable", return_value=False): + patcher, captured = self._patch_urlopen(None) + with patcher: + res = stripe_billing.pro_checkout_with_credit( + customer_email="b@t.com", interval="monthly", coupon_id="cpn_dead" + ) + body = self._post_body(captured) + self.assertEqual(res["id"], "cs_test_1") + self.assertNotIn("discounts[0][coupon]", body) + self.assertEqual(body["allow_promotion_codes"], "true") + + def test_pro_checkout_none_coupon_allows_promo(self) -> None: + patcher, captured = self._patch_urlopen(None) + with patcher: + stripe_billing.pro_checkout_with_credit( + customer_email="b@t.com", interval="monthly", coupon_id=None + ) + body = self._post_body(captured) + self.assertEqual(body["allow_promotion_codes"], "true") + self.assertNotIn("discounts[0][coupon]", body) + + def test_pro_checkout_yearly_interval_metadata(self) -> None: + os.environ["STRIPE_PRICE_ID_YEARLY"] = "price_y1" + patcher, captured = self._patch_urlopen(None) + with patcher: + res = stripe_billing.pro_checkout_with_credit( + customer_email="b@t.com", interval="yearly", coupon_id=None + ) + body = self._post_body(captured) + self.assertEqual(res["interval"], "yearly") + self.assertEqual(body["metadata[interval]"], "yearly") + self.assertEqual(body["line_items[0][price]"], "price_y1") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_audit_fixes.py b/tests/test_audit_fixes.py index ea6f176..e51fcdc 100644 --- a/tests/test_audit_fixes.py +++ b/tests/test_audit_fixes.py @@ -299,13 +299,17 @@ def tearDown(self) -> None: self._td.cleanup() def test_downgrade_via_customer_id(self) -> None: + from stripe_fixtures import mock_subscription + sub = mock_subscription(self, "", status="canceled", customer="cus_abc") + sub["metadata"].pop("email") from app import stripe_billing self.users.set_plan("cust@test.local", "pro", stripe_customer_id="cus_abc") out = stripe_billing.apply_webhook_event( { + "id": "evt_customer_downgrade", "type": "customer.subscription.deleted", - "data": {"object": {"customer": "cus_abc", "metadata": {}}}, + "data": {"object": {"id": "sub_test"}}, } ) self.assertTrue(out.get("ok"), out) diff --git a/tests/test_auth.py b/tests/test_auth.py index 33e0262..9b8d38c 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -53,7 +53,9 @@ def test_health_auth_fields(self) -> None: h = health_payload() self.assertIs(h["manus_login"], False) self.assertTrue(h["guest_access"]) - self.assertTrue(h["live_requires_login"]) + # Live is open to everyone (free multi-source engine); Pro = higher limits. + self.assertFalse(h["live_requires_login"]) + self.assertFalse(h["live_requires_pro"]) self.assertEqual(h["login_path"], "/login") self.assertTrue( h["auth"] in { @@ -85,6 +87,10 @@ def setUp(self) -> None: os.environ["QUANTRADAR_DEV_LOGIN"] = "1" os.environ["QUANTRADAR_BOOTSTRAP_DEMO"] = "0" os.environ["SESSION_SECRET"] = "test-session-secret-for-unit" + # Pin a missing charts dir so default/live analyze fails fast (no real + # engine subprocess or network in unit tests). + self._orig_charts = os.environ.get("CHARTS_DIR") + os.environ["CHARTS_DIR"] = str(Path(self._td.name) / "no_charts") # Force re-read of secret path uses env each call — ok self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) self.port = self.server.server_address[1] @@ -98,25 +104,43 @@ def tearDown(self) -> None: import app.users as users users.USERS_PATH = self._orig_users + if self._orig_charts is None: + os.environ.pop("CHARTS_DIR", None) + else: + os.environ["CHARTS_DIR"] = self._orig_charts self._td.cleanup() def _url(self, path: str) -> str: return f"http://127.0.0.1:{self.port}{path}" - def test_guest_analyze_and_live_requires_login(self) -> None: - with urllib.request.urlopen(self._url("/api/analyze?ticker=INTC"), timeout=5) as r: + def test_guest_analyze_and_live_open_to_guests(self) -> None: + with urllib.request.urlopen( + self._url("/api/analyze?ticker=INTC&mode=artifact"), timeout=5 + ) as r: body = json.loads(r.read().decode()) self.assertTrue(body.get("ok"), body) + # Live is open to guests (free multi-source engine). With CHARTS_DIR + # pointing nowhere it must fail closed (never 401/403 gate). + prev_charts = os.environ.get("CHARTS_DIR") + os.environ["CHARTS_DIR"] = str(REPO / "fixtures" / "no_such_charts_dir") try: - urllib.request.urlopen( - self._url("/api/analyze?ticker=INTC&mode=live"), timeout=5 - ) - self.fail("live without session should 401") - except urllib.error.HTTPError as e: - self.assertEqual(e.code, 401) - body = json.loads(e.read().decode()) - self.assertEqual(body.get("error"), "login_required") + try: + with urllib.request.urlopen( + self._url("/api/analyze?ticker=INTC&mode=live"), timeout=30 + ) as r: + code = r.status + body = json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + code = e.code + body = json.loads(e.read().decode()) + finally: + if prev_charts is None: + os.environ.pop("CHARTS_DIR", None) + else: + os.environ["CHARTS_DIR"] = prev_charts + self.assertNotIn(code, (401, 403), body) + self.assertNotIn(body.get("error"), {"login_required", "plan_required"}) def test_dev_login_then_me(self) -> None: # Follow redirect and capture cookie @@ -144,19 +168,22 @@ def test_dev_login_then_me(self) -> None: else: self.assertTrue(body["authenticated"]) - # free session → live blocked with plan_required + # free session → live is open (no plan gate); with CHARTS_DIR missing it + # fails closed but never 401/403. token_free = authlib.mint_session(sub="dev", email="dev@test.local", plan="free") req = urllib.request.Request( self._url("/api/analyze?ticker=INTC&mode=live"), headers={"Cookie": f"{authlib.COOKIE_NAME}={token_free}"}, ) try: - urllib.request.urlopen(req, timeout=5) - self.fail("free plan live should 403") + with urllib.request.urlopen(req, timeout=30) as r: + code = r.status + body = json.loads(r.read().decode()) except urllib.error.HTTPError as e: - self.assertEqual(e.code, 403) + code = e.code body = json.loads(e.read().decode()) - self.assertEqual(body.get("error"), "plan_required") + self.assertNotIn(code, (401, 403), body) + self.assertNotEqual(body.get("error"), "plan_required") # pro requires a users-store row — cookie plan alone must not elevate prev_charts = os.environ.get("CHARTS_DIR") diff --git a/tests/test_billing.py b/tests/test_billing.py index 508e9d0..4276801 100644 --- a/tests/test_billing.py +++ b/tests/test_billing.py @@ -26,6 +26,7 @@ from app import stripe_billing # noqa: E402 from app.server import Handler # noqa: E402 from app import users as users_mod # noqa: E402 +from stripe_fixtures import mock_subscription class PriceIntervalTests(unittest.TestCase): @@ -123,12 +124,15 @@ def test_checkout_requires_login_and_stripe(self) -> None: self.assertEqual(body.get("error"), "stripe_not_configured") def test_webhook_sets_pro(self) -> None: + mock_subscription(self, "buyer@test.local") users_mod.register_user("buyer@test.local", "password12", name="Buyer") self.assertEqual(users_mod.resolve_plan("buyer@test.local"), "free") event = { + "id": "evt_checkout", "type": "checkout.session.completed", "data": { "object": { + "mode": "subscription", "subscription": "sub_test", "customer_email": "buyer@test.local", "payment_status": "paid", "status": "complete", @@ -143,12 +147,15 @@ def test_webhook_sets_pro(self) -> None: self.assertEqual(users_mod.resolve_plan("buyer@test.local"), "pro") def test_webhook_sets_portfolio_pro(self) -> None: + mock_subscription(self, "portfolio@test.local", plan="portfolio_pro") users_mod.register_user("portfolio@test.local", "password12", name="Portfolio") self.assertEqual(users_mod.resolve_plan("portfolio@test.local"), "free") event = { + "id": "evt_checkout", "type": "checkout.session.completed", "data": { "object": { + "mode": "subscription", "subscription": "sub_test", "customer_email": "portfolio@test.local", "payment_status": "paid", "status": "complete", @@ -175,21 +182,23 @@ def test_webhook_sets_portfolio_pro(self) -> None: status = json.loads(r.read().decode()) self.assertEqual(status["user"]["plan"], "portfolio_pro") - def test_webhook_unpaid_rejected(self) -> None: + def test_webhook_unpaid_waits_for_async_confirmation(self) -> None: event = { + "id": "evt_checkout", "type": "checkout.session.completed", "data": { "object": { + "mode": "subscription", "subscription": "sub_test", "customer_email": "unpaid@test.local", "payment_status": "unpaid", "status": "complete", - "metadata": {"email": "unpaid@test.local"}, + "metadata": {"email": "unpaid@test.local", "product": "quantradar_pro"}, } }, } code, body = self._post("/api/billing/webhook", event) - self.assertEqual(code, 422, body) - self.assertEqual(body.get("error"), "not_paid") + self.assertEqual(code, 200, body) + self.assertEqual(body.get("action"), "awaiting_payment") self.assertEqual(users_mod.resolve_plan("unpaid@test.local"), "free") def test_billing_status_shape(self) -> None: @@ -214,22 +223,27 @@ def tearDown(self) -> None: self._tmpdir.cleanup() def test_subscription_deleted_downgrades(self) -> None: + mock_subscription(self, "gone@test.local", status="canceled") users_mod.set_plan("gone@test.local", "pro") out = stripe_billing.apply_webhook_event( { + "id": "evt_deleted", "type": "customer.subscription.deleted", - "data": {"object": {"metadata": {"email": "gone@test.local"}}}, + "data": {"object": {"id": "sub_test"}}, } ) self.assertEqual(out.get("action"), "plan_free") self.assertEqual(users_mod.resolve_plan("gone@test.local"), "free") def test_subscription_deleted_without_email_fails(self) -> None: + sub = mock_subscription(self, "", status="canceled") + sub["metadata"].pop("email") users_mod.set_plan("stuck@test.local", "pro") out = stripe_billing.apply_webhook_event( { + "id": "evt_deleted", "type": "customer.subscription.deleted", - "data": {"object": {"metadata": {}}}, + "data": {"object": {"id": "sub_test"}}, } ) self.assertFalse(out.get("ok")) diff --git a/tests/test_cache_perf.py b/tests/test_cache_perf.py new file mode 100644 index 0000000..396c16a --- /dev/null +++ b/tests/test_cache_perf.py @@ -0,0 +1,124 @@ +"""Atomic tests: HTTP caching layer, favicon, health memoization, static 304s.""" + +from __future__ import annotations + +import os +import tempfile +import threading +import unittest +from http.client import HTTPConnection +from http.server import ThreadingHTTPServer +from pathlib import Path + +from app import server as server_mod +from app.server import Handler + + +class CacheAtoms(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + os.environ.setdefault("QUANTRADAR_MODE", "artifact") + cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + cls.port = cls.httpd.server_address[1] + cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True) + cls.thread.start() + + @classmethod + def tearDownClass(cls) -> None: + cls.httpd.shutdown() + + def _request(self, path: str, headers: dict | None = None) -> tuple[int, dict, bytes]: + conn = HTTPConnection("127.0.0.1", self.port, timeout=10) + try: + conn.request("GET", path, headers=headers or {}) + resp = conn.getresponse() + return resp.status, dict(resp.getheaders()), resp.read() + finally: + conn.close() + + def test_css_has_etag_and_public_cache(self) -> None: + code, h, body = self._request("/static/site.css") + self.assertEqual(code, 200) + self.assertIn("ETag", h) + self.assertTrue(h["Cache-Control"].startswith("public"), h["Cache-Control"]) + self.assertGreater(len(body), 0) + + def test_conditional_get_returns_304_without_body(self) -> None: + code, h, body = self._request("/static/site.css") + etag = h.get("ETag") + self.assertTrue(etag) + code2, h2, body2 = self._request("/static/site.css", {"If-None-Match": etag}) + self.assertEqual(code2, 304) + self.assertEqual(body2, b"") + self.assertEqual(h2.get("ETag"), etag) + + def test_weak_etag_also_matches(self) -> None: + code, h, _ = self._request("/static/site.css") + etag = h.get("ETag") + code2, _, body2 = self._request("/static/site.css", {"If-None-Match": "W/" + etag}) + self.assertEqual(code2, 304) + self.assertEqual(body2, b"") + + def test_wrong_etag_returns_full_200(self) -> None: + code, _, body = self._request("/static/site.css", {"If-None-Match": '"bogus"'}) + self.assertEqual(code, 200) + self.assertGreater(len(body), 0) + + def test_favicon_served_with_cache(self) -> None: + code, h, body = self._request("/favicon.ico") + self.assertEqual(code, 200) + self.assertIn("image/svg+xml", h.get("Content-Type", "")) + self.assertTrue(body.startswith(b" None: + """API responses must never be cached — plans/auth change underneath.""" + code, h, _ = self._request("/api/auth/status") + self.assertEqual(code, 200) + self.assertEqual(h.get("Cache-Control"), "no-store") + + def test_html_pages_stay_no_store(self) -> None: + for path in ("/", "/pricing", "/login"): + with self.subTest(path=path): + code, h, _ = self._request(path) + self.assertEqual(code, 200) + self.assertEqual(h.get("Cache-Control"), "no-store") + + def test_chart_asset_cached(self) -> None: + # fixture asset shipped in repo + code, h, _ = self._request("/api/charts/AAPL_daily_price_2026-03-21_17-27-46.png") + if code == 404: + self.skipTest("fixture chart asset not present") + self.assertEqual(code, 200) + self.assertIn("max-age", h.get("Cache-Control", "")) + + def test_git_sha_memoized(self) -> None: + """git_sha must not re-fork git on every health poll.""" + server_mod._GIT_SHA_RESOLVED = False + server_mod._GIT_SHA_CACHE = None + a = server_mod.git_sha() + server_mod._GIT_SHA_CACHE = "SENTINEL" # would be overwritten if re-forked + b = server_mod.git_sha() + self.assertEqual(b, "SENTINEL") + # restore clean state for other tests + server_mod._GIT_SHA_RESOLVED = False + server_mod._GIT_SHA_CACHE = None + + +class MosaicCapAtoms(unittest.TestCase): + def test_cell_cap_arithmetic(self) -> None: + """Mirror of mosaic.js cap loop: 1080p must yield <= 1400 cells.""" + w, h, gap = 1920, 1080, 3 + cell = 32 + cols = max(18, -(-w // (cell + gap))) + rows = max(14, -(-h // (cell + gap))) + while cols * rows > 1400: + cell += 6 + cols = max(18, -(-w // (cell + gap))) + rows = max(14, -(-h // (cell + gap))) + self.assertLessEqual(cols * rows, 1400) + self.assertGreaterEqual(cell, 32) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_engagement.py b/tests/test_engagement.py new file mode 100644 index 0000000..5b2a505 --- /dev/null +++ b/tests/test_engagement.py @@ -0,0 +1,335 @@ +"""Engagement layer: watchlist caps, real avoidance ledger, digest opt-in. + +All avoidance math is tested against hand-computed numbers — no invented events. +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +import threading +import time +import unittest +from unittest import mock +import urllib.error +import urllib.request +from http.server import ThreadingHTTPServer +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO)) + +from app import engagement # noqa: E402 +from app.server import Handler # noqa: E402 + + +class AvoidanceLedgerTests(unittest.TestCase): + def setUp(self) -> None: + patcher = mock.patch("free_engine.market_calendar.completed_session", return_value="2026-09-04") + patcher.start() + self.addCleanup(patcher.stop) + self._td = tempfile.TemporaryDirectory() + d = Path(self._td.name) + self._orig_ledger = engagement.LEDGER_PATH + self._orig_cache = engagement.PRICE_CACHE_PATH + engagement.LEDGER_PATH = d / "ledger.json" + engagement.PRICE_CACHE_PATH = d / "price_cache.json" + + def tearDown(self) -> None: + engagement.LEDGER_PATH = self._orig_ledger + engagement.PRICE_CACHE_PATH = self._orig_cache + self._td.cleanup() + + def _seed(self, entries: list[dict], cache: dict) -> None: + for entry in entries: + entry.setdefault("as_of", "2026-09-03") + for hit in cache.values(): + hit.setdefault("as_of", "2026-09-04") + engagement.LEDGER_PATH.write_text( + json.dumps({"version": 1, "entries": entries}), encoding="utf-8" + ) + engagement.PRICE_CACHE_PATH.write_text(json.dumps(cache), encoding="utf-8") + + def test_no_event_fall_10pct_counts(self) -> None: + self._seed( + [ + {"ticker": "XYZ", "ts": time.time() - 86400, "action": "NO", "close": 100.0, "email": "reader@example.com"}, + ], + {"XYZ": {"ts": time.time(), "close": 90.0}}, + ) + events = engagement.avoidance_events("reader@example.com") + self.assertEqual(len(events), 1) + self.assertEqual(events[0]["ticker"], "XYZ") + self.assertEqual(events[0]["drop_pct"], 10.0) + + def test_put_action_counts(self) -> None: + self._seed( + [{"ticker": "ABC", "ts": time.time() - 100, "action": "PUT", "close": 50.0, "email": "reader@example.com"}], + {"ABC": {"ts": time.time(), "close": 46.0}}, + ) + events = engagement.avoidance_events("reader@example.com") + self.assertEqual(len(events), 1) + self.assertEqual(events[0]["drop_pct"], 8.0) + + def test_small_fall_below_threshold_excluded(self) -> None: + self._seed( + [{"ticker": "XYZ", "ts": time.time() - 100, "action": "NO", "close": 100.0, "email": "reader@example.com"}], + {"XYZ": {"ts": time.time(), "close": 96.0}}, + ) + self.assertEqual(engagement.avoidance_events("reader@example.com"), []) + + def test_setup_action_never_counts(self) -> None: + self._seed( + [{"ticker": "XYZ", "ts": time.time() - 100, "action": "SETUP", "close": 100.0, "email": "reader@example.com"}], + {"XYZ": {"ts": time.time(), "close": 80.0}}, + ) + self.assertEqual(engagement.avoidance_events("reader@example.com"), []) + + def test_stale_cache_excluded(self) -> None: + self._seed( + [{"ticker": "XYZ", "ts": time.time() - 100, "action": "NO", "close": 100.0, "email": "reader@example.com"}], + {"XYZ": {"ts": time.time() - 24 * 3600, "close": 90.0}}, + ) + self.assertEqual(engagement.avoidance_events("reader@example.com"), []) + + def test_email_scoping(self) -> None: + self._seed( + [ + {"ticker": "AAA", "ts": time.time() - 100, "action": "NO", "close": 10.0, "email": "a@x.com"}, + {"ticker": "BBB", "ts": time.time() - 100, "action": "NO", "close": 10.0, "email": "b@x.com"}, + ], + {"AAA": {"ts": time.time(), "close": 9.0}, "BBB": {"ts": time.time(), "close": 9.0}}, + ) + self.assertEqual([e["ticker"] for e in engagement.avoidance_events("a@x.com")], ["AAA"]) + self.assertEqual(engagement.avoidance_events(None), []) + + def test_record_scan_uses_its_own_close_and_ignores_demo_or_guest(self) -> None: + result = {"ok": True, "primary": {"action": "NO"}, "ticker": "TESL", "score": {"final": 30}, + "meta": {"mode": "live", "market_as_of": "2026-09-04", "market_close": 42.0}} + engagement.record_scan("TESL", result, None) + engagement.record_scan("TESL", {**result, "meta": {**result["meta"], "mode": "artifact"}}, "reader@example.com") + self.assertFalse(engagement.LEDGER_PATH.is_file()) + engagement.record_scan("TESL", result, "reader@example.com") + data = json.loads(engagement.LEDGER_PATH.read_text()) + self.assertEqual(data["entries"][0]["close"], 42.0) + self.assertEqual(data["entries"][0]["as_of"], "2026-09-04") + self.assertEqual(engagement.avoidance_events("reader@example.com"), []) + + def test_undated_or_same_session_history_never_implies_an_avoided_loss(self) -> None: + self._seed([{"ticker":"XYZ", "as_of":"2026-09-04", "email":"reader@example.com", "action":"NO", "close":100}], + {"XYZ":{"ts":time.time(), "as_of":"2026-09-04", "close":80}}) + self.assertEqual(engagement.avoidance_events("reader@example.com"), []) + self.assertEqual(engagement.avoidance_events(None), []) + + def test_record_scan_ignores_failed_results(self) -> None: + engagement.record_scan("TESL", {"ok": False}, None) + time.sleep(0.2) + self.assertFalse(engagement.LEDGER_PATH.is_file()) + + +class HttpEngagementTests(unittest.TestCase): + def setUp(self) -> None: + import app.users as users + + self._td = tempfile.TemporaryDirectory() + self._orig_users = users.USERS_PATH + users.USERS_PATH = Path(self._td.name) / "users.json" + + d = Path(self._td.name) / "eng" + d.mkdir() + self._orig_ledger = engagement.LEDGER_PATH + self._orig_cache = engagement.PRICE_CACHE_PATH + self._orig_digest = engagement.DIGEST_DIR + engagement.LEDGER_PATH = d / "ledger.json" + engagement.PRICE_CACHE_PATH = d / "price_cache.json" + engagement.DIGEST_DIR = d / "digest" + + os.environ["PUBLIC_BASE_URL"] = "http://127.0.0.1:0" + os.environ["QUANTRADAR_DEV_LOGIN"] = "1" + os.environ["SESSION_SECRET"] = "test-session-secret-for-unit" + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.port = self.server.server_address[1] + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + def tearDown(self) -> None: + self.server.shutdown() + self.server.server_close() + import app.users as users + + users.USERS_PATH = self._orig_users + engagement.LEDGER_PATH = self._orig_ledger + engagement.PRICE_CACHE_PATH = self._orig_cache + engagement.DIGEST_DIR = self._orig_digest + self._td.cleanup() + + def _url(self, path: str) -> str: + return f"http://127.0.0.1:{self.port}{path}" + + def _register(self, email: str, password: str = "hunter2secret") -> str: + req = urllib.request.Request( + self._url("/api/auth/register"), + data=json.dumps({"email": email, "password": password}).encode(), + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=10) as r: + body = json.loads(r.read().decode()) + self.assertTrue(body.get("ok"), body) + cookie = None + for h, v in r.headers.items(): + if h.lower() == "set-cookie" and "qr_session=" in v: + cookie = v.split(";")[0] + assert cookie, "session cookie not set on register" + return cookie + + def _post(self, path: str, payload: dict, cookie: str | None = None): + req = urllib.request.Request( + self._url(path), + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json", **({"Cookie": cookie} if cookie else {})}, + ) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return r.status, json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read().decode()) + + def _get(self, path: str, cookie: str | None = None): + req = urllib.request.Request(self._url(path), headers={"Cookie": cookie} if cookie else {}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return r.status, json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read().decode()) + + def test_watchlist_free_limit_one_then_upgrade_unlocks(self) -> None: + cookie = self._register("wl@test.local") + code, body = self._post("/api/watchlist/add", {"ticker": "NVDA"}, cookie) + self.assertEqual(code, 200, body) + self.assertTrue(body.get("ok"), body) + self.assertEqual(body["watchlist"], ["NVDA"]) + self.assertEqual(body["limit"], 1) + + # Second ticker hits the free cap — honest limit message, not a fake gate. + code, body = self._post("/api/watchlist/add", {"ticker": "AMD"}, cookie) + self.assertEqual(code, 200, body) + self.assertFalse(body.get("ok")) + self.assertEqual(body.get("error"), "watchlist_limit") + self.assertIn("Pro", body.get("error_detail", "")) + + # Upgrade to pro in the store (SSOT) → ten slots. + from app.users import set_plan + + set_plan("wl@test.local", "pro") + cookie_pro = self._register("wl-pro@test.local") + from app.users import set_plan as sp + + sp("wl-pro@test.local", "pro") + code, body = self._post("/api/watchlist/add", {"ticker": "AMD"}, cookie_pro) + self.assertTrue(body.get("ok"), body) + self.assertEqual(body["limit"], 10) + + def test_watchlist_requires_login(self) -> None: + code, body = self._post("/api/watchlist/add", {"ticker": "NVDA"}, None) + self.assertEqual(code, 401) + self.assertEqual(body.get("error"), "login_required") + + def test_watchlist_remove(self) -> None: + cookie = self._register("wl2@test.local") + self._post("/api/watchlist/add", {"ticker": "MSFT"}, cookie) + code, body = self._post("/api/watchlist/remove", {"ticker": "MSFT"}, cookie) + self.assertEqual(code, 200) + self.assertEqual(body["watchlist"], []) + + def test_auth_status_exposes_watchlist_and_caps(self) -> None: + cookie = self._register("wl3@test.local") + self._post("/api/watchlist/add", {"ticker": "AAPL"}, cookie) + code, body = self._get("/api/auth/status", cookie) + self.assertEqual(code, 200) + self.assertTrue(body.get("authenticated")) + self.assertEqual(body["user"]["watchlist"], ["AAPL"]) + self.assertEqual(body["user"]["watchlist_limit"], 1) + self.assertFalse(body["user"]["daily_digest"]) + + def test_digest_opt_in_honest_note(self) -> None: + cookie = self._register("dg@test.local") + code, body = self._post("/api/digest/opt", {"on": True}, cookie) + self.assertEqual(code, 200, body) + self.assertTrue(body.get("ok")) + self.assertTrue(body.get("on")) + # No SMTP keys in tests → honest archive note. + self.assertFalse(body.get("smtp")) + self.assertIn("Email delivery is not included", body.get("note", "")) + + def test_guest_ledger_does_not_count_private_scans(self) -> None: + engagement.LEDGER_PATH.write_text( + json.dumps( + { + "version": 1, + "entries": [ + {"ticker": "XYZ", "ts": time.time() - 100, "action": "NO", "close": 100.0, "email": "a@x.com"} + ], + } + ), + encoding="utf-8", + ) + engagement.PRICE_CACHE_PATH.write_text( + json.dumps({"XYZ": {"ts": time.time(), "close": 90.0}}), encoding="utf-8" + ) + code, body = self._get("/api/ledger", None) + self.assertEqual(code, 200) + self.assertEqual(body.get("avoided"), []) + self.assertEqual(body.get("avoided_count"), 0) + + def test_today_page_served(self) -> None: + req = urllib.request.Request(self._url("/today")) + with urllib.request.urlopen(req, timeout=10) as r: + self.assertEqual(r.status, 200) + html = r.read().decode() + self.assertIn("Today", html) + self.assertIn("/api/today", html) + + def test_digest_cannot_read_another_accounts_watchlist(self) -> None: + import types + from app.users import set_plan + + cookie = self._register("owner@test.local") + set_plan("owner@test.local", "pro") + self._register("other@test.local") + engagement.add_watch("owner@test.local", "MSFT") + engagement.add_watch("other@test.local", "AAPL") + engagement.set_digest_optin("other@test.local", True) + source = types.ModuleType("free_sources") + from datetime import date, timedelta + from free_engine.market_calendar import completed_session + end = date.fromisoformat(completed_session()) + rows = [((end - timedelta(days=59-i)).isoformat(), 100, 1000) for i in range(60)] + source.fetch_all_sources = mock.Mock(return_value=({"test": rows}, {})) + with mock.patch.dict(sys.modules, {"free_sources": source}): + code, body = self._post("/api/digest/build", {"email": "other@test.local"}, cookie) + self.assertEqual(code, 200, body) + self.assertEqual([u["email"] for u in body["digest"]["users"]], ["owner@test.local"]) + self.assertEqual([i["ticker"] for i in body["digest"]["users"][0]["items"]], ["MSFT"]) + self.assertNotIn("other@test.local", json.dumps(body)) + self.assertNotIn("AAPL", json.dumps(body)) + self.assertFalse(engagement.DIGEST_DIR.exists()) + + def test_digest_missing_market_data_is_unknown(self) -> None: + import types + from app.users import set_plan + + cookie = self._register("unknown@test.local") + set_plan("unknown@test.local", "pro") + source = types.ModuleType("free_sources") + source.fetch_all_sources = mock.Mock(return_value=({}, {"test": "offline"})) + with mock.patch.dict(sys.modules, {"free_sources": source}): + code, body = self._post("/api/digest/build", {}, cookie) + self.assertEqual(code, 200, body) + self.assertIsNone(body["digest"]["market"]["gate_open"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_free_engine.py b/tests/test_free_engine.py new file mode 100644 index 0000000..1dd4fc9 --- /dev/null +++ b/tests/test_free_engine.py @@ -0,0 +1,245 @@ +"""Free engine tests — iOS formula parity, aggregation, contract fit.""" + +from __future__ import annotations + +import json +import sys +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO)) +sys.path.insert(0, str(REPO / "free_engine")) + +from app.contract import map_charts_payload, validate_response # noqa: E402 +from app.quality import assess_charts_payload # noqa: E402 +from free_aggregate import aggregate_bars # noqa: E402 +from free_mechanical import ( # noqa: E402 + action_to_signal, + apply_post_gates, + core, + etf_for_sector, + pct_change_over, + rsi14, + sma, +) + + +def bars_seq(closes, volumes=None, start="2026-01-01"): + from datetime import date, timedelta + + d = date.fromisoformat(start) + out = [] + for i, c in enumerate(closes): + v = volumes[i] if volumes else 1_000_000.0 + out.append(((d + timedelta(days=i)).isoformat(), float(c), float(v))) + return out + + +def uptrend_closes(): + """Searched fixture: 45-day gentle rise + shaped 14-delta tail. + + Hand-verified against the iOS formula: +18 trend (last>sma20>sma50), + +12 momentum (RSI 63.2 ∈ 45..65), +8 volume (ratio 1.5), pullback −0.16%, + RSI < 68 → final 88, action SETUP. + """ + closes = [100.0 + i * 0.3 for i in range(45)] + v = closes[-1] + for d in [0.9] * 8 + [-0.7] * 6: + v += d + closes.append(v) + return closes + + +def uptrend_vols(): + return [1e6] * 58 + [1.5e6] + + +class SMARSITests(unittest.TestCase): + def test_sma_needs_n(self) -> None: + self.assertEqual(sma([1.0, 2.0], 3), 0.0) + self.assertAlmostEqual(sma([1.0, 2.0, 3.0], 3), 2.0) + # suffix(n) semantics + self.assertAlmostEqual(sma([10.0, 1.0, 2.0, 3.0], 3), 2.0) + + def test_rsi14_slice_of_15(self) -> None: + # 16 closes → window is last 15 → 14 deltas, all +1 → RSI 100 + closes = [100.0 + i for i in range(16)] + self.assertAlmostEqual(rsi14(closes), 100.0) + # exactly 15 closes → None (Swift: closes.count > 15) + self.assertIsNone(rsi14(closes[:15])) + # all down → avg_loss>0, avg_gain=0 → rs=0 → RSI 0 + down = [100.0 - i for i in range(16)] + self.assertAlmostEqual(rsi14(down), 0.0) + + +class CoreParityTests(unittest.TestCase): + """Each case hand-computed from the iOS FreeMechanicalScorer formula.""" + + def test_missing_or_invalid_spy_never_passes(self) -> None: + bars = bars_seq(uptrend_closes(), uptrend_vols()) + for spy in (None, [], bars_seq([100.0] * 4), bars_seq([0, 100, 100, 100, 100]), + bars_seq([100, 100, float("nan"), 100, 100])): + with self.subTest(spy=spy): + result = core(bars, spy) + self.assertEqual(result["market_gate"], "UNKNOWN") + self.assertEqual(result["action"], "WAIT") + self.assertIsNone(result["spy_pct"]) + + def test_uptrend_setup(self) -> None: + closes = uptrend_closes() + c = core(bars_seq(closes, uptrend_vols()), bars_seq([100.0] * 5)) + # +18 trend, +12 momentum, +8 volume → 88 + self.assertEqual(c["score"], 88.0) + self.assertEqual(c["adjustments"]["trend_sma"], 18.0) + self.assertEqual(c["adjustments"]["momentum_rsi"], 12.0) + self.assertEqual(c["adjustments"]["volume_price"], 8.0) + self.assertEqual(c["market_gate"], "PASS") + # SETUP needs score>=68, pullback in [-2, 8], rsi<68 + self.assertEqual(c["action"], "SETUP") + self.assertEqual(c["stock_gate"], "PASS") + + def test_downtrend_no(self) -> None: + closes = [200.0 - i * 1.2 for i in range(60)] + c = core(bars_seq(closes), None) + # last < sma50 → -18; rsi low → +4; vol flat → 0 → 36 < 38 → NO + self.assertEqual(c["score"], 36.0) + self.assertEqual(c["action"], "NO") + self.assertEqual(c["stock_gate"], "NO") + + def test_spy_gate_thresholds(self) -> None: + flat = bars_seq(uptrend_closes(), uptrend_vols()) + # SPY down 4% over the 5-bar window → WATCH, -10 + spy_down4 = bars_seq([100.0, 100.0, 100.0, 100.0, 96.0]) + c = core(flat, spy_down4) + self.assertEqual(c["market_gate"], "WATCH") + self.assertEqual(c["adjustments"]["market_gate"], -10.0) + # SPY down 7% → NO gate (-10 and -15 stack = -25) + spy_down7 = bars_seq([100.0, 100.0, 100.0, 100.0, 93.0]) + c2 = core(flat, spy_down7) + self.assertEqual(c2["market_gate"], "NO") + self.assertEqual(c2["adjustments"]["market_gate"], -25.0) + self.assertEqual(c2["action"], "NO") + + def test_pct_change_over_ios_window(self) -> None: + # iOS: spy[count-5] vs spy.last → 5 bars, 4 intervals + spy = bars_seq([100.0, 101.0, 102.0, 103.0, 104.0]) + self.assertAlmostEqual(pct_change_over(spy, 5), (104.0 - 100.0)) + self.assertIsNone(pct_change_over(spy[:4], 5)) + + def test_earnings_gate_flips_setup_to_wait(self) -> None: + c = core(bars_seq(uptrend_closes(), uptrend_vols()), bars_seq([100.0] * 5)) + self.assertEqual(c["action"], "SETUP") + gated = apply_post_gates(c, earnings_near=True, sector_action=None) + self.assertEqual(gated["action"], "WAIT") + self.assertTrue(gated["earnings_forced_wait"]) + self.assertEqual(gated["stock_gate"], "WATCH") + + def test_sector_no_blocks_setup(self) -> None: + c = core(bars_seq(uptrend_closes(), uptrend_vols()), bars_seq([100.0] * 5)) + gated = apply_post_gates(c, earnings_near=False, sector_action="NO") + self.assertEqual(gated["action"], "WAIT") + + def test_clamp_bounds(self) -> None: + up = bars_seq(uptrend_closes(), uptrend_vols()) + self.assertLessEqual(core(up, None)["score"], 95.0) + self.assertGreaterEqual(core(up, None)["score"], 5.0) + + +class AggregationTests(unittest.TestCase): + def test_yahoo_hosts_share_one_vote(self) -> None: + agg = aggregate_bars({ + "yahoo_q1": [("2026-09-04", 100.0, 1000)], + "yahoo_q2": [("2026-09-04", 100.0, 1000)], + "nasdaq": [("2026-09-04", 110.0, 1000)], + }) + self.assertEqual(agg["bars"][0][1], 105.0) + self.assertEqual(agg["disagree_days"], ["2026-09-04"]) + + def test_median_voting(self) -> None: + a = [("2026-01-02", 100.0, 1e6), ("2026-01-03", 101.0, 1e6)] + b = [("2026-01-02", 100.0, 1e6), ("2026-01-03", 101.0, 1e6)] + glitch = [("2026-01-02", 55.0, 1e6)] # glitched close, 1 of 3 + agg = aggregate_bars({"s1": a, "s2": b, "s3": glitch}) + day1 = agg["bars"][0] + self.assertAlmostEqual(day1[1], 100.0) # median resists the glitch + self.assertEqual(agg["days"], 2) + + def test_disagree_flag(self) -> None: + a = [("2026-01-02", 100.0, 1e6)] + b = [("2026-01-02", 110.0, 1e6)] # 10% spread + agg = aggregate_bars({"s1": a, "s2": b}) + self.assertEqual(agg["disagree_days"], ["2026-01-02"]) + self.assertAlmostEqual(agg["bars"][0][1], 105.0) + + def test_single_source_kept(self) -> None: + agg = aggregate_bars({"s1": [("2026-01-02", 50.0, 1.0)]}) + self.assertEqual(agg["days"], 1) + + +class ContractFitTests(unittest.TestCase): + def test_engine_payload_shape(self) -> None: + from free_engine.fetch_all import MIN_BARS # noqa: F401 (import check) + + closes = uptrend_closes() + bars = bars_seq(closes, uptrend_vols()) + # Build the same structural payload the engine emits (no network) + c = core(bars, bars_seq([100.0] * 5)) + signal = action_to_signal(c["action"]) + state = {"code": "A", "name": "setup_zone", "reason": c["reason"]} + payload = { + "ticker": "TESTCO", + "fetch_time": "2026-09-01T00:00:00+00:00", + "data_quality": { + "reliability": "high", + "timeframes_ok": 1, + "option_chain_ok": False, + "warnings": [], + }, + "mechanical_scores": { + "final_score": c["score"], + "signal_mechanical": signal, + "state": state, + "base_score": { + "total": 88.0, + "trend": {"total": 43.0, "max": 50.0}, + "momentum": {"total": 37.0, "max": 50.0}, + "volume_price": { + "total": 33.0, + "max": 50.0, + "volume_ratio": c["vol_ratio"], + }, + }, + "entry_timing": {"grade": "A", "total": c["score"], "max": 100.0}, + }, + "indicator_data": {}, + "market_env": {"spy_change_pct": -0.2}, + "fundamentals": {}, + "market_context": {}, + } + self.assertEqual(signal, "PROBE") + q = assess_charts_payload(payload, "TESTCO") + self.assertTrue(q["usable"], q) + resp = map_charts_payload(payload, mode="live", quality=q) + self.assertTrue(resp["ok"]) + self.assertEqual(validate_response(resp), []) + self.assertEqual(resp["primary"]["action"], "PROBE") + self.assertFalse(resp["data_quality"]["options_actionable"]) + + def test_signal_mapping(self) -> None: + self.assertEqual(action_to_signal("SETUP"), "PROBE") + self.assertEqual(action_to_signal("WAIT"), "WAIT") + self.assertEqual(action_to_signal("NO"), "NO") + + def test_sector_etf_map_matches_ios(self) -> None: + self.assertEqual(etf_for_sector("Technology"), "XLK") + self.assertEqual(etf_for_sector("Consumer Discretionary"), "XLY") + self.assertEqual(etf_for_sector("Consumer Defensive"), "XLP") + self.assertEqual(etf_for_sector("Real Estate"), "XLRE") + self.assertEqual(etf_for_sector("Communication Services"), "XLC") + self.assertIsNone(etf_for_sector("Crypto Mining")) + self.assertIsNone(etf_for_sector(None)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_funnel.py b/tests/test_funnel.py index 5c7fb03..8039b3c 100644 --- a/tests/test_funnel.py +++ b/tests/test_funnel.py @@ -157,7 +157,8 @@ def test_share_card_unknown_ticker_404(self) -> None: except urllib.error.HTTPError as e: self.assertEqual(e.code, 404) body = e.read().decode("utf-8") - self.assertIn("not a published demo", body) + self.assertIn("no frozen demo", body) + self.assertIn("/?live=NOTADEMO", body) if __name__ == "__main__": diff --git a/tests/test_market_calendar.py b/tests/test_market_calendar.py new file mode 100644 index 0000000..7af9722 --- /dev/null +++ b/tests/test_market_calendar.py @@ -0,0 +1,57 @@ +import unittest +import tempfile +from pathlib import Path +from unittest import mock +from datetime import datetime, timedelta + +from free_engine.market_calendar import completed_bars, completed_session, session_window + + +class MarketCalendarTests(unittest.TestCase): + def test_completed_sessions_include_dst_holidays_and_early_closes(self): + cases = { + "2026-09-08T19:59:59+00:00": "2026-09-04", + "2026-09-08T20:00:00+00:00": "2026-09-08", + "2026-09-07T23:00:00+00:00": "2026-09-04", + "2026-11-27T17:59:59+00:00": "2026-11-25", + "2026-11-27T18:00:00+00:00": "2026-11-27", + "2026-03-06T20:30:00+00:00": "2026-03-05", + "2026-03-09T20:30:00+00:00": "2026-03-09", + "2026-01-01T22:00:00+00:00": "2025-12-31", + "2029-01-02T22:00:00+00:00": None, + } + for instant, expected in cases.items(): + with self.subTest(instant=instant): + self.assertEqual(completed_session(datetime.fromisoformat(instant)), expected) + + def test_partial_bar_is_excluded_and_stale_data_rejected(self): + now = datetime.fromisoformat("2026-09-08T18:00:00+00:00") + end = datetime.fromisoformat("2026-09-04") + bars = [((end - timedelta(days=59-i)).date().isoformat(), 100.0, 1000) for i in range(60)] + self.assertEqual(completed_bars(bars + [("2026-09-08", 999.0, 1000)], now=now), bars) + with self.assertRaises(ValueError): + completed_bars(bars[:-1], now=now) + with self.assertRaises(ValueError): + completed_bars(bars[-20:], now=now) + with self.assertRaises(ValueError): + completed_bars(bars + [bars[-1]], now=now) + with self.assertRaises(ValueError): + completed_bars(bars[:-1] + [("2026-09-04", float("inf"), 1000)], now=now) + + def test_preclose_cache_must_refresh_after_close(self): + import sys + sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "free_engine")) + import free_sources as sources + now = datetime.fromisoformat("2026-09-08T20:01:00+00:00") + rows = [(day, 100.0, 1000) for day in session_window("2026-09-08", 60)] + with tempfile.TemporaryDirectory() as temporary: + cache = Path(temporary) + sources._cache_write(cache, "yahoo_q1:AAPL", rows, fetched_at=now.timestamp() - 120) + with mock.patch.object(sources, "SOURCE_NAMES", ("yahoo_q1",)), mock.patch.object(sources.time, "time", return_value=now.timestamp()), mock.patch.object(sources, "_fetch_source", return_value=rows) as fetch: + result, errors = sources.fetch_all_sources("AAPL", days=90, cache_dir=cache, as_of=now) + fetch.assert_called_once() + self.assertFalse(errors) + self.assertEqual(result["yahoo_q1"], rows) + with mock.patch.object(sources, "SOURCE_NAMES", ("yahoo_q1",)), mock.patch.object(sources.time, "time", return_value=now.timestamp()), mock.patch.object(sources, "_fetch_source") as fetch: + sources.fetch_all_sources("AAPL", days=90, cache_dir=cache, as_of=now) + fetch.assert_not_called() diff --git a/tests/test_paid_delivery.py b/tests/test_paid_delivery.py new file mode 100644 index 0000000..577ba5a --- /dev/null +++ b/tests/test_paid_delivery.py @@ -0,0 +1,289 @@ +import os +import tempfile +import unittest +import threading +import time +import urllib.request +import urllib.error +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from unittest import mock + +from app import paid_delivery as delivery +from app import users, auth +from app import stripe_billing +from test_paid_replay import report_payload + + +class PaidDeliveryTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.env = mock.patch.dict(os.environ, {"QUANTRADAR_BILLING_DB": str(Path(self.tmp.name) / "billing.sqlite3")}) + self.env.start() + self.addCleanup(self.env.stop) + self.clock = mock.patch("free_engine.market_calendar.completed_session", return_value="2026-09-04") + self.clock.start() + self.addCleanup(self.clock.stop) + self.accounts = mock.patch.object(users, "USERS_PATH", Path(self.tmp.name) / "users.json") + self.accounts.start() + self.addCleanup(self.accounts.stop) + coupon = mock.patch("app.stripe_billing.create_credit_coupon", side_effect=lambda email, **kw: "coupon_" + kw["order_id"]) + coupon.start() + self.addCleanup(coupon.stop) + + def order(self, owner="buyer@example.com", key="request_key_000001", bump=False): + with mock.patch("app.charts_facade.run_fetch_all", return_value=report_payload()): + return delivery.prepare_report_order(owner, "TESTCO", request_key=key, bump=bump) + + def pay(self, order): + session = {"id": "cs_" + order["id"], "url": "https://checkout.stripe.com/test", + "mode": "payment", "payment_status": "paid", "amount_total": order["amount"], "currency": "usd", + "payment_intent": "pi_" + order["id"], + "metadata": {"order_id": order["id"], "email": order["owner"], "product": "quantradar_report"}} + delivery.attach_checkout(order["id"], session) + return delivery.event_once("evt_" + order["id"], "checkout.session.completed", lambda db: delivery.mark_report_paid(db, session)) + + def test_two_orders_keep_owned_bundles_and_survive_restart(self): + first = self.order() + second = self.order("second@example.com", bump=True) + self.pay(first) + self.pay(second) + self.assertIsNone(delivery.get_report("second@example.com", first["id"])) + self.assertNotIn("bundle", delivery.get_report(first["owner"], first["id"])) + self.assertTrue(delivery.fulfill_next()) + self.assertTrue(delivery.fulfill_next()) + self.assertFalse(delivery.fulfill_next()) + for order in (first, second): + result = delivery.get_report(order["owner"], order["id"]) + self.assertEqual(result["delivery_state"], "ready") + self.assertEqual(result["bundle"]["ticker"], "TESTCO") + self.assertEqual("replay.csv" in result["bundle"]["assets"], bool(order["bump"])) + self.assertEqual(len(delivery.list_reports(first["owner"])), 1) + + def test_expired_worker_cannot_hide_completed_download(self): + order = self.order() + self.pay(order) + build = delivery.build_report_bundle + calls = 0 + def resume(inputs, **kwargs): + nonlocal calls + calls += 1 + if calls == 1: + delivery.fulfill_next(now=1121) + raise ValueError("Old worker failed after a recovered delivery completed") + return build(inputs, **kwargs) + with mock.patch.object(delivery, "build_report_bundle", side_effect=resume): + delivery.fulfill_next(now=1000) + report = delivery.get_report(order["owner"], order["id"]) + self.assertEqual(report["delivery_state"], "ready") + self.assertIn("bundle", report) + + def test_refund_worker_waits_for_checkout_revocation(self): + order = self.order() + self.pay(order) + delivery.fulfill_next() + stripe_billing.apply_webhook_event({"id":"evt_refund_checkout", "type":"charge.refunded", "data":{"object":{ + "payment_intent":"pi_" + order["id"], "refunded":True}}}) + now = time.time() + 120 + with mock.patch.object(stripe_billing, "revoke_credit_coupon", return_value=True), mock.patch( + "app.subscription_checkout.revoke_coupon_checkouts", side_effect=[TimeoutError("Stripe unavailable"), None]): + delivery.fulfill_next(now=now) + self.assertEqual(delivery.get_report(order["owner"], order["id"])["credit_state"], "revoking") + delivery.fulfill_next(now=now + 61) + self.assertEqual(delivery.get_report(order["owner"], order["id"])["credit_state"], "revoked") + + def test_request_and_event_idempotency_under_concurrency(self): + first = self.order() + self.assertEqual(first["id"], self.order()["id"]) + calls = [] + def apply(db): + calls.append(True) + return {"ok": True, "action": "applied"} + with ThreadPoolExecutor(max_workers=5) as pool: + results = list(pool.map(lambda _: delivery.event_once("evt_repeated", "test", apply), range(10))) + self.assertEqual(len(calls), 1) + self.assertEqual(sum(bool(r.get("duplicate")) for r in results), 9) + self.pay(first) + self.assertTrue(self.pay(first)["duplicate"]) + + def test_conflicting_concurrent_request_keys_do_not_return_wrong_order(self): + barrier = threading.Barrier(2) + def payload(ticker, sector): + barrier.wait(timeout=5) + return report_payload() + def prepare(ticker): + try: + return delivery.prepare_report_order("buyer@example.com", ticker, request_key="same_request_key_001") + except ValueError: + return None + with mock.patch.object(delivery, "validated_report_payload", side_effect=payload), ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(prepare, ["AAPL", "MSFT"])) + self.assertEqual(sum(r is None for r in results), 1) + self.assertEqual(len(delivery.list_reports("buyer@example.com")), 1) + + def test_refund_before_payment_and_stale_partial_never_restore_access(self): + order = self.order() + refund = {"id": "evt_refund_first", "type": "charge.refunded", "data": {"object": { + "id": "ch_test", "payment_intent": "pi_" + order["id"], "refunded": True}}} + stripe_billing.apply_webhook_event(refund) + self.pay(order) + self.assertEqual(delivery.get_report(order["owner"], order["id"])["payment_state"], "refunded") + self.assertFalse(delivery.fulfill_next()) + refund["id"] = "evt_stale_partial" + refund["data"]["object"]["refunded"] = False + stripe_billing.apply_webhook_event(refund) + report = delivery.get_report(order["owner"], order["id"]) + self.assertEqual(report["payment_state"], "refunded") + self.assertNotIn("bundle", report) + + def test_refund_during_credit_issuance_revokes_credit_without_hiding_ready_bundle(self): + order = self.order() + self.pay(order) + def issue(*args, **kwargs): + stripe_billing.apply_webhook_event({"id": "evt_refund_during_credit", "type": "charge.refunded", "data": {"object": { + "payment_intent": "pi_" + order["id"], "refunded": False}}}) + return "coupon_inflight" + with mock.patch.object(stripe_billing, "create_credit_coupon", side_effect=issue): + delivery.fulfill_next() + report = delivery.get_report(order["owner"], order["id"]) + self.assertEqual(report["credit_state"], "revoking") + self.assertIn("bundle", report) + with mock.patch.object(stripe_billing, "revoke_credit_coupon", return_value=True) as revoke: + delivery.fulfill_next(now=9999999999) + revoke.assert_called_once_with("coupon_inflight") + self.assertEqual(delivery.get_report(order["owner"], order["id"])["credit_state"], "revoked") + + def test_credit_failure_keeps_download_ready_and_older_unused_credit_is_selected(self): + first = self.order() + self.pay(first) + delivery.fulfill_next() + second = self.order(key="second_request_key_01") + self.pay(second) + with mock.patch.object(stripe_billing, "create_credit_coupon", side_effect=RuntimeError("temporary Stripe failure")): + delivery.fulfill_next() + self.assertIn("bundle", delivery.get_report(second["owner"], second["id"])) + with delivery.database() as db: + db.execute("UPDATE report_orders SET credit_state='ready',credit_code='coupon_spent' WHERE id=?", (second["id"],)) + with mock.patch.object(stripe_billing, "coupon_redeemable", side_effect=lambda code: code != "coupon_spent"): + self.assertEqual(delivery.report_credit(first["owner"]), "coupon_" + first["id"]) + + def test_legacy_paid_checkout_binds_existing_claim_once(self): + owner = "legacy@example.com" + users.set_plan(owner, "free") + users.grant_report(owner, bump=False) + users.set_report_coupon(owner, "coupon_legacy") + claim = delivery.list_reports(owner)[0] + session = {"id": "cs_legacy", "mode": "payment", "payment_status": "paid", "amount_total": 900, + "currency": "usd", "payment_intent": "pi_legacy", "metadata": {"product": "quantradar_report", "email": owner}} + paid_at = int(time.time()) + event = {"id": "evt_legacy", "type": "checkout.session.completed", "created": paid_at, "data": {"object": session}} + result = stripe_billing.apply_webhook_event(event) + self.assertEqual(result["order_id"], claim["id"]) + self.assertTrue(stripe_billing.apply_webhook_event(event)["duplicate"]) + reports = delivery.list_reports(owner) + self.assertEqual(len(reports), 1) + self.assertEqual(reports[0]["paid_at"], paid_at) + self.assertEqual(reports[0]["delivery_state"], "awaiting_ticker") + with mock.patch.object(stripe_billing, "coupon_redeemable", return_value=True): + self.assertEqual(delivery.report_credit(owner), "coupon_legacy") + stripe_billing.apply_webhook_event({"id": "evt_legacy_refund", "type": "charge.refunded", "data": {"object": {"payment_intent": "pi_legacy", "refunded": True}}}) + self.assertIsNone(delivery.report_credit(owner)) + self.assertEqual(delivery.get_report(owner, claim["id"])["credit_state"], "revoking") + + def test_legacy_initialization_and_payment_are_atomic_on_disk(self): + owner = "race@example.com" + users.set_plan(owner, "free") + users.grant_report(owner, bump=False) + event = {"id": "evt_legacy_race", "type": "checkout.session.completed", "data": {"object": { + "id": "cs_legacy_race", "mode": "payment", "payment_status": "paid", "amount_total": 900, + "currency": "usd", "payment_intent": "pi_legacy_race", "metadata": {"product": "quantradar_report", "email": owner}}}} + barrier = threading.Barrier(2) + def initialize(): + barrier.wait(timeout=5) + delivery.ensure_legacy_claim(owner) + def webhook(): + barrier.wait(timeout=5) + stripe_billing.apply_webhook_event(event) + with ThreadPoolExecutor(max_workers=2) as pool: + futures = [pool.submit(initialize), pool.submit(webhook)] + for future in futures: + future.result(timeout=10) + self.assertEqual(len(delivery.list_reports(owner)), 1) + + def test_mismatched_payment_never_unlocks(self): + order = self.order() + delivery.attach_checkout(order["id"], {"id": "cs_bound", "url": "https://checkout.stripe.com/test"}) + for session in ({"id": "cs_wrong"}, {"id": "cs_bound", "payment_status": "paid", "amount_total": 1}): + session["metadata"] = {"order_id": order["id"]} + with self.assertRaises(ValueError): + delivery.event_once("evt_wrong", "test", lambda db: delivery.mark_report_paid(db, session)) + self.assertEqual(delivery.get_report(order["owner"], order["id"])["payment_state"], "unpaid") + + def test_priority_and_abandoned_lease_recovery(self): + normal = self.order(key="request_normal_001") + priority = self.order(key="request_priority_01", bump=True) + self.pay(normal) + self.pay(priority) + self.assertTrue(delivery.fulfill_next()) + self.assertEqual(delivery.get_report(priority["owner"], priority["id"])["delivery_state"], "ready") + with delivery.database() as db: + db.execute("UPDATE report_orders SET delivery_state='building',lease_until=0 WHERE id=?", (normal["id"],)) + self.assertTrue(delivery.fulfill_next()) + self.assertEqual(delivery.get_report(normal["owner"], normal["id"])["delivery_state"], "ready") + + def test_older_subscription_cancellation_cannot_remove_portfolio(self): + with delivery.database() as db: + db.executemany("INSERT INTO subscriptions VALUES(?,?,?,?,?,?,?,?)", [ + ("sub_old", "a@example.com", "cus_a", "pro", "price_pro", "canceled", 9999999999, 1), + ("sub_new", "a@example.com", "cus_a", "portfolio_pro", "price_portfolio", "active", 9999999999, 2), + ]) + self.assertEqual(delivery.subscription_plan("a@example.com"), "portfolio_pro") + self.assertEqual(delivery.subscription_plan("a@example.com", now=99999999999), "free") + self.assertIsNone(delivery.subscription_plan("other@example.com")) + + def test_legacy_claim_is_once_only_and_keeps_csv(self): + users.set_plan("legacy@example.com", "free") + users.grant_report("legacy@example.com", bump=False) + first = delivery.list_reports("legacy@example.com") + self.assertEqual(len(first), 1) + self.assertEqual(first[0]["delivery_state"], "awaiting_ticker") + self.assertEqual(delivery.list_reports("legacy@example.com")[0]["id"], first[0]["id"]) + with mock.patch("app.charts_facade.run_fetch_all", return_value=report_payload()): + delivery.claim_legacy_report("legacy@example.com", first[0]["id"], "TESTCO") + self.assertTrue(delivery.fulfill_next()) + report = delivery.get_report("legacy@example.com", first[0]["id"]) + self.assertIn("replay.csv", report["bundle"]["assets"]) + with self.assertRaises(ValueError): + delivery.claim_legacy_report("legacy@example.com", first[0]["id"], "AAPL") + self.assertEqual(len(delivery.list_reports("legacy@example.com")), 1) + + def test_http_downloads_enforce_ownership_and_private_cache(self): + from http.server import ThreadingHTTPServer + from app.server import Handler + order = self.order() + self.pay(order) + delivery.fulfill_next() + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + url = f"http://127.0.0.1:{server.server_port}/api/reports/{order['id']}/assets/report.zip" + for email, expected in ((None, 401), ("other@example.com", 404), (order["owner"], 200)): + headers = {} + if email: + headers["Cookie"] = auth.COOKIE_NAME + "=" + auth.mint_session(sub=email, email=email) + request = urllib.request.Request(url, headers=headers) + try: + response = urllib.request.urlopen(request) + except urllib.error.HTTPError as error: + response = error + with response: + self.assertEqual(response.status, expected) + if expected == 200: + self.assertEqual(response.headers["Cache-Control"], "private, no-store") + self.assertEqual(response.headers["Content-Type"], "application/zip") + self.assertTrue(response.read().startswith(b"PK")) + finally: + server.shutdown() + server.server_close() diff --git a/tests/test_paid_replay.py b/tests/test_paid_replay.py new file mode 100644 index 0000000..ca4554f --- /dev/null +++ b/tests/test_paid_replay.py @@ -0,0 +1,60 @@ +import csv +import io +import json +import unittest +import zipfile + +from free_engine.market_calendar import session_window +from free_engine.replay import build_replay, build_report_bundle, zip_bundle + + +def report_payload(): + dates = session_window("2026-09-04", 150) + bars = [[day, 100 + i * 0.1, 1000000] for i, day in enumerate(dates)] + return { + "ticker": "TESTCO", "fetch_time": "2026-09-04T21:00:00+00:00", + "data_quality": {"market_as_of": "2026-09-04", "reliability": "medium", "timeframes_ok": 1}, + "daily_bars": bars, "spy_daily_bars": [[day, 500, 1000000] for day in dates], + "mechanical_scores": {"final_score": 58, "signal_mechanical": "WAIT", "base_score": {"total": 58}, + "state": {"code": "B", "name": "wait_watch", "reason": "Wait"}}, + "market_env": {"spy_change_pct": 0}, "fundamentals": {}, "indicator_data": {}, + } + + +class PaidReplayTests(unittest.TestCase): + def test_future_prices_cannot_change_past_replay(self): + payload = report_payload() + cutoff = payload["daily_bars"][-10][0] + original = build_replay(payload, sessions=50, cutoff=cutoff) + for row in payload["daily_bars"] + payload["spy_daily_bars"]: + if row[0] > cutoff: + row[1] = 0.001 + self.assertEqual(build_replay(payload, sessions=50, cutoff=cutoff), original) + + def test_missing_market_and_stock_sessions_remain_explicit(self): + payload = report_payload() + payload["spy_daily_bars"].pop() + row = build_replay(payload)[-1] + self.assertEqual(row["market_gate"], "UNKNOWN") + self.assertNotEqual(row["action"], "SETUP") + payload["daily_bars"].pop(-10) + row = build_replay(payload)[-1] + self.assertEqual(row["action"], "UNKNOWN") + self.assertIsNone(row["score"]) + with self.assertRaisesRegex(ValueError, "139 consecutive"): + build_report_bundle(payload) + + def test_bundle_json_csv_charts_and_zip_are_consistent(self): + bundle = build_report_bundle(report_payload(), include_csv=True) + self.assertEqual(bundle["sessions"], 90) + report = json.loads(bundle["assets"]["report.json"]) + csv_rows = list(csv.DictReader(io.StringIO(bundle["assets"]["replay.csv"]))) + self.assertEqual([r["date"] for r in csv_rows], [r["date"] for r in report["replay"]]) + self.assertEqual(float(csv_rows[-1]["close"]), report["replay"][-1]["close"]) + self.assertIn(report["as_of"], bundle["assets"]["price.svg"]) + archive = zip_bundle(bundle) + self.assertEqual(archive, zip_bundle(bundle)) + with zipfile.ZipFile(io.BytesIO(archive)) as zipped: + self.assertEqual(set(zipped.namelist()), set(bundle["assets"])) + self.assertEqual(zipped.read("report.json").decode(), bundle["assets"]["report.json"]) + self.assertNotIn("replay.csv", build_report_bundle(report_payload())["assets"]) diff --git a/tests/test_password_auth.py b/tests/test_password_auth.py index eccc70a..505f9f9 100644 --- a/tests/test_password_auth.py +++ b/tests/test_password_auth.py @@ -103,20 +103,22 @@ def test_register_login_me(self) -> None: self.assertTrue(me.get("authenticated")) self.assertEqual(me["user"]["email"], "desk@test.local") - # free plan → live requires Pro + # free plan → live is open (no plan gate); with CHARTS_DIR missing it + # fails closed but never 401/403. req = urllib.request.Request( f"http://127.0.0.1:{self.port}/api/analyze?ticker=INTC&mode=live", headers={"Cookie": f"qr_session={token}"}, ) try: - with urllib.request.urlopen(req, timeout=5) as r: + with urllib.request.urlopen(req, timeout=30) as r: code = r.status j = json.loads(r.read().decode()) except urllib.error.HTTPError as e: code = e.code j = json.loads(e.read().decode()) - self.assertEqual(code, 403, j) - self.assertEqual(j.get("error"), "plan_required") + self.assertNotIn(code, (401, 403), j) + self.assertNotEqual(j.get("error"), "plan_required") + self.assertNotEqual(j.get("error"), "login_required") from app.users import set_plan diff --git a/tests/test_scan_runtime.py b/tests/test_scan_runtime.py new file mode 100644 index 0000000..6505037 --- /dev/null +++ b/tests/test_scan_runtime.py @@ -0,0 +1,85 @@ +import os +import threading +import unittest +from concurrent.futures import ThreadPoolExecutor +from unittest import mock + +from app import charts_facade as facade +from app.public_surface import harden_public_analyze +from app.server import Handler, check_rate_limit, _RATE_HITS + + +class ScanRuntimeTests(unittest.TestCase): + def setUp(self): + facade._SCAN_CACHE.clear() + _RATE_HITS.clear() + + def test_two_engine_slots_allow_shared_requests_and_reject_new_work(self): + started = {ticker: threading.Event() for ticker in ("AAPL", "MSFT")} + release = threading.Event() + calls = [] + def engine(ticker, sector, timeout): + calls.append(ticker) + started[ticker].set() + release.wait(timeout=5) + return {"ticker": ticker, "rows": [1]} + with mock.patch.object(facade, "_run_fetch_all", side_effect=engine), ThreadPoolExecutor(max_workers=6) as pool: + first = pool.submit(facade.run_fetch_all, "AAPL") + second = pool.submit(facade.run_fetch_all, "MSFT") + try: + self.assertTrue(started["AAPL"].wait(timeout=2)) + self.assertTrue(started["MSFT"].wait(timeout=2)) + copies = [pool.submit(facade.run_fetch_all, "AAPL") for _ in range(3)] + with self.assertRaisesRegex(RuntimeError, "capacity"): + facade.run_fetch_all("NVDA") + finally: + release.set() + a, b = first.result(), second.result() + for future in copies: + self.assertEqual(future.result(), a) + a["rows"].append(2) + self.assertEqual(facade.run_fetch_all("AAPL")["rows"], [1]) + self.assertCountEqual(calls, ["AAPL", "MSFT"]) + + def test_cache_expiry_session_switch_and_failure_release(self): + with mock.patch("free_engine.market_calendar.completed_session", return_value="2026-09-04") as session, mock.patch.object(facade.time, "monotonic", return_value=100) as clock, mock.patch.object(facade, "_run_fetch_all", return_value={"ok": True}) as engine: + facade.run_fetch_all("AAPL") + clock.return_value = 159 + facade.run_fetch_all("AAPL") + self.assertEqual(engine.call_count, 1) + clock.return_value = 160 + facade.run_fetch_all("AAPL") + session.return_value = "2026-09-08" + facade.run_fetch_all("AAPL") + self.assertEqual(engine.call_count, 3) + engine.side_effect = RuntimeError("upstream unavailable") + with self.assertRaisesRegex(RuntimeError, "upstream"): + facade.run_fetch_all("MSFT") + engine.side_effect = None + self.assertTrue(facade.run_fetch_all("MSFT")["ok"]) + + def test_replay_restricts_again_when_audience_changes(self): + raw = {"meta": {"mode": "live"}, "_replay": [{"date": str(i), "close": i, "score": 50} for i in range(100)]} + pro = harden_public_analyze(raw, user={"plan": "pro"}) + self.assertEqual(len(pro["engagement_replay"]["rows"]), 90) + free = harden_public_analyze(pro) + self.assertEqual(len(free["engagement_replay"]["rows"]), 5) + self.assertFalse(free["engagement_replay"]["unlocked"]) + self.assertNotIn("_replay", free) + self.assertEqual(len(raw["_replay"]), 100) + + def test_paid_scan_limit_exceeds_free_but_untrusted_plan_does_not(self): + with mock.patch.dict(os.environ, {"QUANTRADAR_RATE_LIMIT": "1", "QUANTRADAR_RATE_LIMIT_AUTH": "2", "QUANTRADAR_RATE_LIMIT_PRO": "3"}): + self.assertTrue(check_rate_limit("owner", authenticated=True)) + self.assertTrue(check_rate_limit("owner", authenticated=True)) + self.assertFalse(check_rate_limit("owner", authenticated=True, plan="unknown")) + self.assertTrue(check_rate_limit("owner", authenticated=True, plan="portfolio_pro")) + self.assertFalse(check_rate_limit("owner", authenticated=True, plan="pro")) + + def test_only_loopback_proxy_can_supply_client_ip(self): + handler = object.__new__(Handler) + handler.headers = {"X-Forwarded-For": "198.51.100.1, 203.0.113.2"} + handler.client_address = ("198.51.100.3", 1234) + self.assertEqual(handler._client_id(), "198.51.100.3") + handler.client_address = ("127.0.0.1", 1234) + self.assertEqual(handler._client_id(), "203.0.113.2") diff --git a/tests/test_subscription_checkout.py b/tests/test_subscription_checkout.py new file mode 100644 index 0000000..0db781e --- /dev/null +++ b/tests/test_subscription_checkout.py @@ -0,0 +1,220 @@ +import copy +import io +import json +import os +from pathlib import Path +import tempfile +import threading +import time +import unittest +import urllib.error +from concurrent.futures import ThreadPoolExecutor +from unittest import mock + +from app import paid_delivery, stripe_billing, subscription_checkout, users + + +class SubscriptionCheckoutTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.owner = "subscriber@example.com" + self.sessions = {} + self.keys = {} + self.customer_keys = {} + self.lock = threading.Lock() + self.lose_response = False + self.subscription_status = "active" + self.legacy_subscription = None + patches = [ + mock.patch.object(users, "USERS_PATH", Path(temporary.name) / "users.json"), + mock.patch.dict(os.environ, {"QUANTRADAR_BILLING_DB": str(Path(temporary.name) / "billing.sqlite3"), + "STRIPE_PRICE_ID_MONTHLY": "price_monthly", "STRIPE_PRICE_ID_YEARLY": "price_yearly", + "STRIPE_PRICE_ID_PORTFOLIO_PRO_MONTHLY": "price_portfolio"}), + mock.patch.object(stripe_billing, "stripe_post", side_effect=self.post), + mock.patch.object(stripe_billing, "stripe_get", side_effect=self.get), + mock.patch.object(stripe_billing, "create_checkout_session", side_effect=self.create), + mock.patch.object(stripe_billing, "create_billing_portal", return_value={"url":"https://billing.stripe.com/portal", "billing_portal":True}), + ] + for patch in patches: + patch.start() + self.addCleanup(patch.stop) + users.set_plan(self.owner, "free") + + def post(self, path, data, *, idempotency_key): + with self.lock: + if path == "customers": + return self.customer_keys.setdefault(idempotency_key, {"id":"cus_" + str(len(self.customer_keys))}) + session = self.sessions[path.split("/")[2]] + self.assertEqual(session["status"], "open") + session["status"] = "expired" + return copy.deepcopy(session) + + def create(self, **params): + with self.lock: + key = params["idempotency_key"] + if key not in self.keys: + self.keys[key] = copy.deepcopy(params) + session_id = "cs_" + str(len(self.keys)) + self.sessions[session_id] = {"id":session_id, "url":"https://checkout.stripe.com/"+session_id, + "status":"open", "mode":"subscription", "customer":params["customer_id"], + "metadata":{"checkout_attempt":key}, "subscription":None} + else: + self.assertEqual(self.keys[key], params) + session = next(s for s in self.sessions.values() if s["metadata"]["checkout_attempt"] == key) + if self.lose_response: + self.lose_response = False + raise TimeoutError("Stripe created the session but response was lost") + return copy.deepcopy(session) + + def get(self, path): + with self.lock: + if path.startswith("subscriptions?"): + return {"data":[copy.deepcopy(self.legacy_subscription)] if self.legacy_subscription else [], "has_more":False} + if path.startswith("checkout/sessions?"): + return {"data":copy.deepcopy(list(self.sessions.values())), "has_more":False} + if path.startswith("checkout/sessions/"): + return copy.deepcopy(self.sessions[path.rsplit("/",1)[-1]]) + if path.startswith("subscriptions/"): + if self.legacy_subscription and path.endswith(self.legacy_subscription["id"]): + return copy.deepcopy(self.legacy_subscription) + session = next(s for s in self.sessions.values() if s["subscription"] == path.rsplit("/",1)[-1]) + return {"id":session["subscription"], "customer":session["customer"], "status":self.subscription_status, + "current_period_end":time.time()+86400, "metadata":{"email":self.owner,"product":"quantradar_pro", + "checkout_attempt":session["metadata"]["checkout_attempt"]}, + "items":{"data":[{"id":"si_one", "price":{"id":"price_monthly"}}]}} + raise AssertionError(path) + + def start(self, interval="monthly"): + return subscription_checkout.start(self.owner, plan="pro", interval=interval) + + def test_concurrent_clicks_share_customer_and_only_payable_session(self): + with ThreadPoolExecutor(max_workers=6) as pool: + results = list(pool.map(lambda _: self.start(), range(6))) + self.assertEqual(len({r["id"] for r in results}), 1) + self.assertEqual(len(self.sessions), 1) + self.assertEqual(len(self.customer_keys), 1) + + def test_response_loss_recovers_same_attempt_after_new_connection(self): + self.lose_response = True + with self.assertRaises(TimeoutError): + self.start() + result = self.start() + self.assertEqual(result["id"], "cs_1") + self.assertEqual(len(self.sessions), 1) + + def test_changing_interval_expires_old_link_before_new_one(self): + first = self.start() + second = self.start("yearly") + self.assertNotEqual(first["id"], second["id"]) + self.assertEqual(self.sessions[first["id"]]["status"], "expired") + self.assertEqual(sum(s["status"] == "open" for s in self.sessions.values()), 1) + + def test_refunded_credit_expires_old_link_before_full_price_checkout(self): + with mock.patch.object(paid_delivery, "report_credit", return_value="report_credit") as credit: + first = subscription_checkout.start(self.owner, plan="pro", interval="monthly", coupon_id="report_credit") + credit.return_value = None + second = self.start() + self.assertNotEqual(first["id"], second["id"]) + self.assertEqual(self.sessions[first["id"]]["status"], "expired") + self.assertIsNone(list(self.keys.values())[-1]["coupon_id"]) + + def test_refund_during_creation_never_returns_discounted_link(self): + with mock.patch.object(paid_delivery, "report_credit", return_value=None): + result = subscription_checkout.start(self.owner, plan="pro", interval="monthly", coupon_id="refunded_credit") + self.assertEqual(result["id"], "cs_2") + self.assertEqual(self.sessions["cs_1"]["status"], "expired") + + def test_revoke_recovers_lost_response_without_creating_another_session(self): + self.lose_response = True + with self.assertRaises(TimeoutError): + subscription_checkout.start(self.owner, plan="pro", interval="monthly", coupon_id="report_credit") + subscription_checkout.revoke_coupon_checkouts("report_credit") + self.assertEqual(len(self.sessions), 1) + self.assertEqual(self.sessions["cs_1"]["status"], "expired") + with paid_delivery.database() as db: + self.assertEqual(db.execute("SELECT state FROM subscription_checkouts").fetchone()[0], "closed") + + def test_revoke_unknown_creation_remains_recoverable(self): + customer = subscription_checkout._customer(self.owner) + attempt = subscription_checkout._current_attempt(self.owner, "pro", "monthly", "price_monthly", "report_credit", customer) + with self.assertRaises(ValueError): + subscription_checkout.revoke_coupon_checkouts("report_credit") + self.assertEqual(len(self.sessions), 0) + with paid_delivery.database() as db: + row = db.execute("SELECT id,state FROM subscription_checkouts").fetchone() + self.assertEqual(tuple(row), (attempt["id"], "creating")) + + def test_revoke_unknown_expiration_preserves_attempt_for_retry(self): + with mock.patch.object(paid_delivery, "report_credit", return_value="report_credit"): + first = subscription_checkout.start(self.owner, plan="pro", interval="monthly", coupon_id="report_credit") + with mock.patch.object(stripe_billing, "stripe_post", side_effect=TimeoutError("expiration response lost")): + with self.assertRaises(TimeoutError): + subscription_checkout.revoke_coupon_checkouts("report_credit") + self.assertEqual(self.sessions[first["id"]]["status"], "open") + subscription_checkout.revoke_coupon_checkouts("report_credit") + self.assertEqual(self.sessions[first["id"]]["status"], "expired") + + def test_completed_payment_without_webhook_opens_portal(self): + first = self.start() + self.sessions[first["id"]].update(status="complete", subscription="sub_first") + result = self.start("yearly") + self.assertTrue(result["billing_portal"]) + self.assertEqual(users.resolve_plan(self.owner), "pro") + self.assertEqual(len(self.sessions), 1) + + def test_unknown_expiration_never_starts_replacement(self): + first = self.start() + with mock.patch.object(stripe_billing, "stripe_post", side_effect=TimeoutError("expiration result unknown")): + with self.assertRaises(TimeoutError): + self.start("yearly") + self.assertEqual(len(self.sessions), 1) + self.assertEqual(self.sessions[first["id"]]["status"], "open") + + def test_uncertain_old_attempt_recovers_by_metadata_without_recreating(self): + self.lose_response = True + with self.assertRaises(TimeoutError): + self.start() + with paid_delivery.database() as db: + db.execute("UPDATE subscription_checkouts SET created_at=1") + result = self.start() + self.assertEqual(result["id"], "cs_1") + self.assertEqual(len(self.sessions), 1) + + def test_current_cancellation_allows_a_new_subscription(self): + first = self.start() + self.sessions[first["id"]].update(status="complete", subscription="sub_first") + self.subscription_status = "canceled" + second = self.start() + self.assertNotEqual(first["id"], second["id"]) + self.assertEqual(len(self.customer_keys), 1) + + def test_legacy_remote_subscription_is_reconciled_before_new_checkout(self): + self.legacy_subscription = {"id":"sub_legacy", "customer":"cus_0", "status":"active", + "current_period_end":time.time()+86400, "metadata":{}, + "items":{"data":[{"id":"si_legacy", "price":{"id":"price_monthly"}}]}} + result = self.start() + self.assertTrue(result["billing_portal"]) + self.assertEqual(len(self.sessions), 0) + self.assertEqual(users.resolve_plan(self.owner), "pro") + + def test_definitively_rejected_coupon_can_retry_without_discount(self): + def create(**params): + if params["coupon_id"]: + raise stripe_billing.StripeRequestError(400, {"type":"invalid_request_error", "code":"resource_missing", "param":"discounts[0][coupon]"}) + return self.create(**params) + with mock.patch.object(stripe_billing, "create_checkout_session", side_effect=create), mock.patch.object(paid_delivery, "report_credit", return_value=None): + result = subscription_checkout.start(self.owner, plan="pro", interval="monthly", coupon_id="revoked_coupon") + self.assertEqual(result["id"], "cs_1") + self.assertEqual(len(self.sessions), 1) + self.assertIsNone(next(iter(self.keys.values()))["coupon_id"]) + + +class StripeCheckoutErrorTests(unittest.TestCase): + def test_real_http_helper_preserves_definitive_coupon_validation_error(self): + error = urllib.error.HTTPError("https://api.stripe.com/v1/checkout/sessions", 400, "Bad Request", {}, + io.BytesIO(json.dumps({"error":{"type":"invalid_request_error", "code":"resource_missing", "param":"discounts[0][coupon]"}}).encode())) + with mock.patch.dict(os.environ, {"STRIPE_SECRET_KEY":"sk_test_fixture", "STRIPE_PRICE_ID_MONTHLY":"price_fixture"}), mock.patch("urllib.request.urlopen", side_effect=error): + with self.assertRaises(stripe_billing.StripeRequestError) as caught: + stripe_billing.create_checkout_session(customer_email="test@example.com", coupon_id="missing", idempotency_key="test-key") + self.assertTrue(caught.exception.coupon_rejected) diff --git a/tests/test_trust_gate.py b/tests/test_trust_gate.py index 2d2fcc6..2ee4f10 100644 --- a/tests/test_trust_gate.py +++ b/tests/test_trust_gate.py @@ -50,17 +50,14 @@ def test_no_fake_stats_on_homepage_bytes(self) -> None: self.assertTrue((REPO / "docs" / "STOCK_AGENT_MAP.md").is_file()) def test_pro_value_copy_not_selling_live_air(self) -> None: - """QD1-0: pricing must not claim live desk is available now.""" + """Pricing describes delivered daily data and keeps purchase boundaries explicit.""" pricing = (REPO / "static" / "pricing.html").read_text(encoding="utf-8") self.assertNotIn("Pro for live desk", pricing) - self.assertIn("supporter", pricing.lower()) - self.assertIn("when charts are mounted", pricing.lower()) + self.assertIn("completed daily sessions", pricing.lower()) + self.assertIn("watchlist", pricing.lower()) self.assertIn("does website pro unlock the ios app", pricing.lower()) self.assertIn("independent products", pricing.lower()) self.assertTrue((REPO / "docs" / "PRO_VALUE.md").is_file()) - body = (REPO / "docs" / "PRO_VALUE.md").read_text(encoding="utf-8") - self.assertIn("Verdict: B", body) - self.assertIn("supporter_until_mount", body) def test_legal_pages_exist(self) -> None: for name in ( diff --git a/tests/test_watch_reports.py b/tests/test_watch_reports.py new file mode 100644 index 0000000..890b71b --- /dev/null +++ b/tests/test_watch_reports.py @@ -0,0 +1,189 @@ +import csv +import io +import json +import os +from pathlib import Path +import tempfile +import threading +import urllib.request +import urllib.error +import unittest +from unittest import mock + +from app import engagement, paid_delivery, users, watch_reports +from app import auth +from test_paid_replay import report_payload + + +class WatchReportTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + root = Path(temporary.name) + for patcher in (mock.patch.object(users, "USERS_PATH", root / "users.json"), + mock.patch.dict(os.environ, {"QUANTRADAR_BILLING_DB": str(root / "billing.sqlite3")}), + mock.patch.object(watch_reports, "completed_session", return_value="2026-09-04")): + patcher.start() + self.addCleanup(patcher.stop) + self.owner = "portfolio@example.com" + users.set_plan(self.owner, "portfolio_pro") + engagement.add_watch(self.owner, "TESTCO") + + def test_free_cannot_create_and_automatic_optin_respects_current_entitlement(self): + users.set_plan("free@example.com", "free") + engagement.add_watch("free@example.com", "TESTCO") + engagement.set_digest_optin("free@example.com", True) + engagement.set_digest_optin(self.owner, True) + with self.assertRaisesRegex(ValueError, "Pro is required"): + watch_reports.request_report("free@example.com") + watch_reports.schedule_optins() + watch_reports.schedule_optins() + self.assertEqual(len(watch_reports.list_reports(self.owner)), 1) + self.assertEqual(watch_reports.list_reports("free@example.com"), []) + + def test_roster_freezes_once_and_owned_results_survive_downgrade(self): + report_id = watch_reports.request_report(self.owner) + engagement.add_watch(self.owner, "AAPL") + self.assertEqual(watch_reports.request_report(self.owner), report_id) + self.assertEqual(watch_reports.get_report(self.owner, report_id)["tickers"], ["TESTCO"]) + self.assertIsNone(watch_reports.get_report("other@example.com", report_id)) + with mock.patch("app.charts_facade.run_fetch_all", return_value=report_payload()): + self.assertTrue(watch_reports.process_next()) + users.set_plan(self.owner, "free") + report = watch_reports.get_report(self.owner, report_id) + self.assertEqual(report["state"], "ready") + self.assertTrue(report["csv_enabled"]) + exported = list(csv.DictReader(io.StringIO(watch_reports.csv_export(report)))) + self.assertEqual(exported[0]["ticker"], "TESTCO") + self.assertEqual(float(exported[0]["close"]), report_payload()["daily_bars"][-1][1]) + self.assertNotIn(self.owner, json.dumps(report)) + self.assertFalse(watch_reports.process_next()) + + def test_failed_ticker_does_not_block_remaining_roster_and_partial_is_explicit(self): + engagement.add_watch(self.owner, "MISSING") + report_id = watch_reports.request_report(self.owner) + with mock.patch("app.charts_facade.run_fetch_all", side_effect=[report_payload(), ValueError("missing"), ValueError("missing"), ValueError("missing")]): + for now in (1000, 1000, 1061, 1122): + watch_reports.process_next(now=now) + report = watch_reports.get_report(self.owner, report_id) + self.assertEqual(report["state"], "partial") + self.assertEqual(report["ready"], 1) + self.assertEqual(report["rows"][1]["action"], "UNKNOWN") + self.assertIsNone(report["rows"][1]["score"]) + + def test_stale_session_rejected_and_expired_lease_recovers(self): + report_id = watch_reports.request_report(self.owner) + with paid_delivery.database() as db: + db.execute("UPDATE watch_reports SET state='building',lease_until=1000 WHERE id=?", (report_id,)) + with mock.patch("app.charts_facade.run_fetch_all", return_value=report_payload()): + self.assertFalse(watch_reports.process_next(now=999)) + self.assertTrue(watch_reports.process_next(now=1001)) + with mock.patch.object(watch_reports, "completed_session", return_value="2026-09-08"): + next_id = watch_reports.request_report(self.owner) + with mock.patch("app.charts_facade.run_fetch_all", return_value=report_payload()): + watch_reports.process_next(now=2000) + self.assertIsNone(watch_reports.get_report(self.owner, next_id)["rows"][0]["score"]) + + def test_previous_action_is_only_from_prior_saved_session(self): + previous_id = watch_reports.request_report(self.owner) + with mock.patch("app.charts_facade.run_fetch_all", return_value=report_payload()): + watch_reports.process_next() + with mock.patch.object(watch_reports, "completed_session", return_value="2026-09-08"): + current_id = watch_reports.request_report(self.owner) + payload = report_payload() + payload["data_quality"]["market_as_of"] = "2026-09-08" + payload["daily_bars"][-1][0] = "2026-09-08" + payload["mechanical_scores"]["signal_mechanical"] = "NO" + with mock.patch("app.charts_facade.run_fetch_all", return_value=payload): + watch_reports.process_next() + current = watch_reports.get_report(self.owner, current_id)["rows"][0] + self.assertEqual(current["previous_action"], "WAIT") + self.assertTrue(current["changed"]) + self.assertEqual(watch_reports.get_report(self.owner, previous_id)["rows"][0]["action"], "WAIT") + + def test_reconstructed_and_saved_setup_aliases_do_not_signal_a_change(self): + previous_id = watch_reports.request_report(self.owner) + prior = {"TESTCO": {"ticker": "TESTCO", "status": "ready", "action": "SETUP", "attempts": 1}} + with paid_delivery.database() as db: + db.execute("UPDATE watch_reports SET results=?,state='ready' WHERE id=?", (json.dumps(prior), previous_id)) + with mock.patch.object(watch_reports, "completed_session", return_value="2026-09-08"): + current_id = watch_reports.request_report(self.owner) + historical = {"close": 100, "score": 80, "action": "SETUP", "market_gate": "PASS"} + with mock.patch("app.charts_facade.run_fetch_all", return_value=report_payload()), mock.patch("free_engine.replay.build_replay", return_value=[historical]): + watch_reports.process_next() + row = watch_reports.get_report(self.owner, current_id)["rows"][0] + self.assertEqual(row["action"], "PROBE") + self.assertEqual(row["previous_action"], "PROBE") + self.assertFalse(row["changed"]) + + def test_http_exports_have_complete_bodies_and_enforce_ownership(self): + from http.server import ThreadingHTTPServer + from app.server import Handler + report_id = watch_reports.request_report(self.owner) + with mock.patch("app.charts_facade.run_fetch_all", return_value=report_payload()): + watch_reports.process_next() + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + try: + for extension in ("json", "csv"): + url = f"http://127.0.0.1:{server.server_port}/api/watch-reports/{report_id}/{extension}" + for email, expected in ((None, 401), ("other@example.com", 404), (self.owner, 200)): + headers = {"Cookie": auth.COOKIE_NAME + "=" + auth.mint_session(sub=email, email=email)} if email else {} + try: + response = urllib.request.urlopen(urllib.request.Request(url, headers=headers)) + except urllib.error.HTTPError as error: + response = error + with response: + self.assertEqual(response.status, expected) + data = response.read() + self.assertEqual(len(data), int(response.headers["Content-Length"])) + if expected == 200: + self.assertEqual(response.headers["Cache-Control"], "private, no-store") + self.assertGreater(len(data), 100) + if extension == "json": + self.assertEqual(json.loads(data)["rows"][0]["ticker"], "TESTCO") + else: + self.assertEqual(list(csv.DictReader(io.StringIO(data.decode())))[0]["ticker"], "TESTCO") + finally: + server.shutdown() + server.server_close() + + def test_expired_worker_cannot_overwrite_recovered_results(self): + report_id = watch_reports.request_report(self.owner) + calls = 0 + def engine(ticker): + nonlocal calls + calls += 1 + if calls == 1: + watch_reports.process_next(now=1121) + raise ValueError("Old worker resumed after its lease expired") + return report_payload() + with mock.patch("app.charts_facade.run_fetch_all", side_effect=engine): + watch_reports.process_next(now=1000) + self.assertEqual(watch_reports.get_report(self.owner, report_id)["state"], "ready") + + def test_process_interruptions_count_toward_attempt_limit(self): + report_id = watch_reports.request_report(self.owner) + with mock.patch("app.charts_facade.run_fetch_all", side_effect=SystemExit("process stopped")) as fetch: + for now in (1000, 1121, 1242): + with self.assertRaises(SystemExit): + watch_reports.process_next(now=now) + self.assertTrue(watch_reports.process_next(now=1363)) + self.assertEqual(fetch.call_count, 3) + self.assertEqual(watch_reports.get_report(self.owner, report_id)["state"], "partial") + + def test_later_recovery_reconstructs_frozen_session_without_current_fundamentals(self): + report_id = watch_reports.request_report(self.owner) + payload = report_payload() + payload["data_quality"]["market_as_of"] = "2026-09-08" + payload["daily_bars"].append(["2026-09-08", 1, 1000000]) + payload["spy_daily_bars"].append(["2026-09-08", 1, 1000000]) + with mock.patch("app.charts_facade.run_fetch_all", return_value=payload): + watch_reports.process_next() + report = watch_reports.get_report(self.owner, report_id) + self.assertEqual(report["state"], "ready") + row = report["rows"][0] + self.assertEqual(row["as_of"], "2026-09-04") + self.assertTrue(row["reconstructed"]) + self.assertEqual(row["earnings_gate"], "UNKNOWN") + self.assertGreater(row["close"], 100)