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 AND state IN ('ready','partial') ORDER BY as_of DESC LIMIT 1", (job["owner"], job["as_of"])).fetchone()
+ prior = json.loads(previous[0]).get(ticker, {}) if previous else {}
+ result = {"ticker": ticker, "as_of": job["as_of"], "status": "unavailable", "close": None,
+ "score": None, "action": "UNKNOWN", "market_gate": "UNKNOWN", "previous_action": None,
+ "changed": None, "reliability": "unknown", "note": "Completed-session data is unavailable.",
+ "attempts": attempt, "reconstructed": False}
+ try:
+ payload = run_fetch_all(ticker)
+ quality = assess_charts_payload(payload, ticker)
+ if (payload.get("data_quality") or {}).get("market_as_of") != job["as_of"]:
+ from free_engine.replay import build_replay
+ historical = build_replay(payload, sessions=1, cutoff=job["as_of"])[0]
+ if historical["score"] is None:
+ raise ValueError("Historical coverage is unavailable")
+ result.update(close=historical["close"], score=historical["score"], market_gate=historical["market_gate"],
+ reconstructed=True, sector_gate="UNKNOWN", earnings_gate="UNKNOWN",
+ note="Recovered mechanical reconstruction; historical earnings and sector gates unavailable.")
+ action = historical["action"]
+ else:
+ if not quality["usable"]:
+ raise ValueError("Snapshot data is unavailable")
+ result.update(close=payload["daily_bars"][-1][1], score=payload["mechanical_scores"]["final_score"],
+ market_gate=payload["data_quality"].get("market_gate", "UNKNOWN"), note="Educational daily-close snapshot.")
+ action = payload["mechanical_scores"]["signal_mechanical"]
+ action = "PROBE" if action == "SETUP" else action
+ previous_action = prior.get("action") if prior.get("status") == "ready" else None
+ previous_action = "PROBE" if previous_action == "SETUP" else previous_action
+ result.update(status="ready", action=action,
+ previous_action=previous_action, changed=action != previous_action if previous_action else None,
+ reliability=quality.get("reliability", "unknown"))
+ except Exception:
+ pass
+ results[ticker] = result
+ missing = any(t not in results for t in roster)
+ retry = any(r["status"] != "ready" and r["attempts"] < 3 for r in results.values())
+ state = "building" if missing else "retrying" if retry else "ready" if all(r["status"] == "ready" for r in results.values()) else "partial"
+ with paid_delivery.database() as db:
+ db.execute("UPDATE watch_reports SET results=?,state=?,retry_at=?,lease_until=0,lease_token=NULL WHERE id=? AND lease_token=?",
+ (json.dumps(results, allow_nan=False), state, now + 60 if state == "retrying" else 0, job["id"], lease_token))
+ return True
diff --git a/docs/CORRECT_OPS.md b/docs/CORRECT_OPS.md
index 8c67f22..b98c32d 100644
--- a/docs/CORRECT_OPS.md
+++ b/docs/CORRECT_OPS.md
@@ -1,119 +1,51 @@
-# 最正确操作方法(锁定)
+# QuantRadar 发布与运行事实
-> 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'')
+ 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"] = ''
+ 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.