diff --git a/app/auth.py b/app/auth.py
index 06a31a4..27218ca 100644
--- a/app/auth.py
+++ b/app/auth.py
@@ -161,6 +161,37 @@ async def require_auth(
)
+async def maybe_current_user(
+ request: Request,
+ authorization: str | None = Header(default=None),
+) -> CurrentUser | None:
+ """Soft-auth: same resolution as `require_auth`, but returns None on
+ miss instead of raising. Used on dual-purpose routes (e.g. `/` where
+ a logged-in user sees the dashboard and a logged-out visitor sees the
+ landing page) and on the public marketing/legal pages, so their
+ templates can show a "Dashboard" link when a session is present."""
+ s = get_settings()
+
+ if s.CASSANDRA_TOKEN and authorization and authorization.lower().startswith("bearer "):
+ provided = authorization.split(" ", 1)[1].strip()
+ if secrets.compare_digest(provided.encode(), s.CASSANDRA_TOKEN.encode()):
+ principal = CurrentUser(is_admin=True, user=None)
+ request.state.current_user = principal
+ return principal
+
+ cookie = request.cookies.get(SESSION_COOKIE_NAME)
+ if cookie:
+ uid = verify_session(cookie)
+ if uid is not None:
+ async with get_session_factory()() as db_session:
+ user = await get_user(db_session, uid)
+ if user is not None:
+ principal = CurrentUser(is_admin=False, user=user)
+ request.state.current_user = principal
+ return principal
+ return None
+
+
def _raise_redirect_to_login(next_path: str = "/") -> None:
# Some pages (login itself) are paths a redirect loop would be silly
# to send back to. The auth router opts out of this dependency
diff --git a/app/branding.py b/app/branding.py
index 8f8bd17..1bd8f48 100644
--- a/app/branding.py
+++ b/app/branding.py
@@ -29,9 +29,26 @@ BRAND_NAME = "Read the Markets"
BRAND_SHORT = "Read"
DOMAIN = "read.markets"
SITE_URL = "https://read.markets"
-APP_URL = "https://app.read.markets"
+# The app lives at the apex too — same host serves landing/legal *and*
+# the dashboard. SITE_URL and APP_URL are kept as separate symbols so a
+# future split (marketing/apex, app/subdomain) is a two-line change.
+APP_URL = "https://read.markets"
EMAIL_FROM_DEFAULT = f"noreply@{DOMAIN}"
+# Marketing line — printed in the landing hero. Single source of truth so
+# OG cards, email subjects, and the landing template stay in sync. The
+# wording is a stance, not just a description: this is for people who
+# treat investing and gambling as different activities. The "news
+# aggregator / media service" framing still lives in the body copy and
+# the disclaimer, where it does the legal distancing work.
+TAGLINE = "Understand markets. Don't gamble on them."
+
+# Legal-page operator details. Placeholders until the user supplies real
+# ones; only the legal pages read them.
+LEGAL_OPERATOR = BRAND_NAME
+OPERATOR_EMAIL = f"hello@{DOMAIN}"
+OPERATOR_JURISDICTION = "United Kingdom"
+
DARK: dict[str, str] = {
"bg": "#0a0e14",
diff --git a/app/main.py b/app/main.py
index 20ed7f0..a9dcb50 100644
--- a/app/main.py
+++ b/app/main.py
@@ -20,6 +20,7 @@ from app.logging import configure_logging, get_logger
from app.routers import api as api_router
from app.routers import auth as auth_router
from app.routers import pages as pages_router
+from app.routers import public as public_router
from app.routers import sync as sync_router
from app.routers import universe as universe_router
from app.services.feeds_bootstrap import bootstrap_feeds
@@ -85,4 +86,7 @@ app.include_router(auth_router.router, tags=["auth"])
app.include_router(api_router.router, prefix="/api", tags=["api"])
app.include_router(universe_router.router, prefix="/api", tags=["universe"])
app.include_router(sync_router.router, tags=["portfolio-sync"])
+# Public router (no auth dep) before pages_router so the marketing/legal
+# paths can never collide with future authenticated routes.
+app.include_router(public_router.router)
app.include_router(pages_router.router, tags=["pages"])
diff --git a/app/routers/pages.py b/app/routers/pages.py
index 46f58e0..a00bf56 100644
--- a/app/routers/pages.py
+++ b/app/routers/pages.py
@@ -8,7 +8,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
-from app.auth import CurrentUser, require_auth, require_token
+from app.auth import CurrentUser, maybe_current_user, require_auth, require_token
from app.config import get_settings, load_groups
from app.db import get_session
from app.models import Referral, StrategicLog, User
@@ -16,26 +16,41 @@ from app.services.access import paid_status
from app.services.referral_service import assign_code_if_missing
from app.templates_env import templates
-router = APIRouter(dependencies=[Depends(require_token)])
+# Router-level auth removed in favour of per-route deps so that `/` can be
+# dual-purpose: logged-in users see the dashboard, logged-out visitors see
+# the landing page.
+router = APIRouter()
@router.get("/", response_class=HTMLResponse)
-async def dashboard(request: Request):
+async def root_page(
+ request: Request,
+ cu: CurrentUser | None = Depends(maybe_current_user),
+):
+ """Dual-purpose root: dashboard when authenticated, landing otherwise."""
+ if cu is None:
+ return templates.TemplateResponse(
+ request, "landing.html", {"cu": None},
+ )
s = get_settings()
groups = load_groups(s.BASELINE_TOML, s.PORTFOLIO_TOML)
return templates.TemplateResponse(
request,
"dashboard.html",
- {"groups": list(groups.keys()), "anchor": s.CASSANDRA_ANCHOR_DATE},
+ {"groups": list(groups.keys()), "anchor": s.CASSANDRA_ANCHOR_DATE, "cu": cu},
)
-@router.get("/news", response_class=HTMLResponse)
+@router.get(
+ "/news",
+ response_class=HTMLResponse,
+ dependencies=[Depends(require_token)],
+)
async def news_page(request: Request):
return templates.TemplateResponse(request, "news.html", {})
-@router.get("/upload")
+@router.get("/upload", dependencies=[Depends(require_token)])
async def upload_page(request: Request):
"""Legacy bookmark — the import widget now lives in /settings."""
return RedirectResponse(url="/settings#import", status_code=302)
@@ -69,7 +84,11 @@ def _log_page_context(target: date) -> dict:
}
-@router.get("/log", response_class=HTMLResponse)
+@router.get(
+ "/log",
+ response_class=HTMLResponse,
+ dependencies=[Depends(require_token)],
+)
async def log_page(
request: Request,
session: AsyncSession = Depends(get_session),
@@ -78,7 +97,11 @@ async def log_page(
return templates.TemplateResponse(request, "log.html", _log_page_context(target))
-@router.get("/log/{day}", response_class=HTMLResponse)
+@router.get(
+ "/log/{day}",
+ response_class=HTMLResponse,
+ dependencies=[Depends(require_token)],
+)
async def log_page_day(
request: Request,
day: str,
diff --git a/app/routers/public.py b/app/routers/public.py
new file mode 100644
index 0000000..a040ccd
--- /dev/null
+++ b/app/routers/public.py
@@ -0,0 +1,68 @@
+"""Unauthenticated marketing + legal pages.
+
+This router carries no auth dependency — every route is reachable to
+anonymous visitors and is also reachable to logged-in users (the
+templates branch off `cu` to flip the top-right CTA between
+"Sign in / sign up" and "Dashboard").
+
+The dual-purpose root (`/`) lives in `app/routers/pages.py` because it
+also has to render the dashboard when authenticated. Pure public-only
+pages live here.
+"""
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, Request
+from fastapi.responses import HTMLResponse
+
+from app.auth import CurrentUser, maybe_current_user
+from app.templates_env import templates
+
+
+router = APIRouter(tags=["public"])
+
+
+def _ctx(request: Request, cu: CurrentUser | None) -> dict:
+ """Minimal context every public template expects. `cu` is injected
+ into the template so the header CTA can flip between
+ 'Sign in / sign up' and 'Dashboard'."""
+ return {"cu": cu}
+
+
+@router.get("/pricing", response_class=HTMLResponse)
+async def pricing_page(
+ request: Request,
+ cu: CurrentUser | None = Depends(maybe_current_user),
+):
+ return templates.TemplateResponse(request, "pricing.html", _ctx(request, cu))
+
+
+@router.get("/about", response_class=HTMLResponse)
+async def about_page(
+ request: Request,
+ cu: CurrentUser | None = Depends(maybe_current_user),
+):
+ return templates.TemplateResponse(request, "about.html", _ctx(request, cu))
+
+
+@router.get("/terms", response_class=HTMLResponse)
+async def terms_page(
+ request: Request,
+ cu: CurrentUser | None = Depends(maybe_current_user),
+):
+ return templates.TemplateResponse(request, "terms.html", _ctx(request, cu))
+
+
+@router.get("/privacy", response_class=HTMLResponse)
+async def privacy_page(
+ request: Request,
+ cu: CurrentUser | None = Depends(maybe_current_user),
+):
+ return templates.TemplateResponse(request, "privacy.html", _ctx(request, cu))
+
+
+@router.get("/disclaimer", response_class=HTMLResponse)
+async def disclaimer_page(
+ request: Request,
+ cu: CurrentUser | None = Depends(maybe_current_user),
+):
+ return templates.TemplateResponse(request, "disclaimer.html", _ctx(request, cu))
diff --git a/app/services/openrouter.py b/app/services/openrouter.py
index f1402f4..759e9f5 100644
--- a/app/services/openrouter.py
+++ b/app/services/openrouter.py
@@ -549,6 +549,13 @@ def _endpoint_for(provider: str) -> tuple[str, str, str, dict[str, str]]:
# OpenRouter dashboard — keep aligned with the live brand.
"HTTP-Referer": branding.SITE_URL,
"X-Title": branding.BRAND_NAME,
+ # No-train opt-out. Tells OpenRouter (and any compatible
+ # upstream) that this request must not be used to train
+ # or improve models. The Privacy notice promises this; the
+ # header is what makes the promise truthful. If a future
+ # upstream ignores the header, fix the provider — not the
+ # header — so the contract stays auditable.
+ "X-OR-Allow-Training": "false",
},
)
raise RuntimeError(f"Unknown LLM provider: {provider!r}")
diff --git a/app/static/css/cassandra.css b/app/static/css/cassandra.css
index 9bb7ffc..e5f1d79 100644
--- a/app/static/css/cassandra.css
+++ b/app/static/css/cassandra.css
@@ -1421,3 +1421,290 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--dim); border-radius: 0; }
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
+
+/* ============================================================
+ * Public pages — landing, pricing, about, terms, privacy, disclaimer.
+ * Shared by all templates extending public_base.html. Visual language
+ * matches the app shell (same palette, monospace brand, restrained
+ * typography) but without dashboard chrome.
+ * ============================================================ */
+
+.public-page { background: var(--bg); }
+
+.public-shell {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ max-width: 1080px;
+ margin: 0 auto;
+ padding: 0 24px;
+}
+
+.public-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 22px 0 16px;
+ border-bottom: 1px solid var(--border);
+}
+.public-header__brand {
+ color: var(--accent);
+ font-weight: 700;
+ text-decoration: none;
+ font-family: var(--font-mono);
+ font-size: 15px;
+ letter-spacing: 0.01em;
+}
+.public-header__brand::before { content: "▰ "; opacity: 0.6; }
+.public-header__brand:hover { color: var(--text); }
+.public-header__nav { display: flex; align-items: center; gap: 22px; }
+.public-header__nav a {
+ color: var(--muted);
+ font-size: 13px;
+ text-decoration: none;
+}
+.public-header__nav a:hover,
+.public-header__nav a.active { color: var(--text); }
+.public-header__cta {
+ color: var(--accent) !important;
+ border: 1px solid var(--accent);
+ padding: 6px 14px;
+ border-radius: 3px;
+}
+.public-header__cta:hover { background: var(--accent); color: var(--bg) !important; }
+
+.public-main {
+ flex: 1;
+ padding: 48px 0 64px;
+}
+
+.public-footer {
+ border-top: 1px solid var(--border);
+ padding: 28px 0 36px;
+ margin-top: 24px;
+ font-size: 12px;
+ color: var(--muted);
+}
+.public-footer__inner {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+.public-footer__brand strong { color: var(--text); margin-right: 10px; }
+.public-footer__tagline { color: var(--muted); }
+.public-footer__links { display: flex; flex-wrap: wrap; gap: 16px; }
+.public-footer__links a { color: var(--muted); text-decoration: none; }
+.public-footer__links a:hover { color: var(--accent); }
+.public-footer__meta { color: var(--dim); font-size: 11px; }
+
+/* --- Hero (landing) -------------------------------------------------- */
+
+.hero {
+ padding: 32px 0 48px;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 48px;
+}
+.hero__brand {
+ color: var(--muted);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+.hero__headline {
+ font-size: clamp(28px, 5vw, 44px);
+ font-weight: 700;
+ line-height: 1.15;
+ color: var(--text);
+ margin: 12px 0 14px;
+ letter-spacing: -0.01em;
+}
+.hero__subhead {
+ font-size: 16px;
+ color: var(--muted);
+ max-width: 640px;
+ line-height: 1.55;
+ margin: 0 0 24px;
+}
+.hero__ctas { display: flex; flex-wrap: wrap; gap: 12px; }
+.hero__ctas .btn-primary,
+.hero__ctas .btn-secondary {
+ display: inline-block;
+ padding: 10px 22px;
+ border-radius: 3px;
+ font-size: 13.5px;
+ font-weight: 500;
+ text-decoration: none;
+}
+/* Qualify with `a` so we beat `a { color: var(--accent) }` and any
+ :link/:visited UA defaults. Without `a.btn-primary` the cascade can
+ resolve in favour of the visited-link color on some browsers and the
+ label disappears against the accent background. */
+a.btn-primary,
+a.btn-primary:link,
+a.btn-primary:visited {
+ background: var(--accent);
+ color: var(--bg);
+ border: 1px solid var(--accent);
+}
+a.btn-primary:hover { background: transparent; color: var(--accent); }
+a.btn-secondary,
+a.btn-secondary:link,
+a.btn-secondary:visited {
+ background: transparent;
+ color: var(--text);
+ border: 1px solid var(--border);
+}
+a.btn-secondary:hover { color: var(--accent); border-color: var(--accent); }
+
+/* --- Feature blocks (landing) --------------------------------------- */
+
+.feature-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 24px;
+ margin: 8px 0 56px;
+}
+.feature-card {
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 22px 22px 24px;
+ background: var(--surface);
+}
+.feature-card__tag {
+ font-family: var(--font-mono);
+ font-size: 10.5px;
+ color: var(--accent);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ margin-bottom: 10px;
+}
+.feature-card__title {
+ font-size: 17px;
+ font-weight: 600;
+ color: var(--text);
+ margin: 0 0 10px;
+}
+.feature-card__body {
+ font-size: 13.5px;
+ line-height: 1.6;
+ color: var(--muted);
+ margin: 0;
+}
+
+/* --- Section primitives reused across pricing/about/legal ---------- */
+
+.public-section {
+ margin: 0 0 56px;
+}
+.public-section__head {
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--text);
+ margin: 0 0 16px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--border);
+}
+.public-section h3 {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text);
+ margin: 24px 0 8px;
+}
+.public-section p,
+.public-section li {
+ font-size: 14px;
+ line-height: 1.65;
+ color: var(--text);
+}
+.public-section p { margin: 0 0 14px; }
+.public-section ul {
+ margin: 0 0 16px;
+ padding-left: 22px;
+}
+.public-section li { margin-bottom: 6px; }
+.public-section a { color: var(--accent); }
+
+.public-section--callout {
+ border-left: 3px solid var(--accent);
+ padding: 16px 22px;
+ background: var(--surface);
+ border-radius: 0 4px 4px 0;
+ margin: 0 0 32px;
+}
+.public-section--warning {
+ border-left-color: var(--negative);
+ background: color-mix(in srgb, var(--negative) 6%, var(--bg));
+}
+.public-section--warning a { color: var(--text); }
+
+/* --- "What this is not" strip on landing --------------------------- */
+
+.not-strip {
+ border: 1px dashed var(--border);
+ padding: 18px 22px;
+ border-radius: 4px;
+ margin: 0 0 56px;
+ background: var(--surface);
+}
+.not-strip strong { color: var(--text); }
+.not-strip ul { display: flex; flex-wrap: wrap; gap: 18px 28px; margin: 8px 0 0; padding: 0; list-style: none; }
+.not-strip li { color: var(--muted); font-size: 13px; }
+.not-strip li::before { content: "✕ "; color: var(--negative); font-weight: 700; margin-right: 4px; }
+
+/* --- Pricing comparison -------------------------------------------- */
+
+.tier-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 18px;
+ margin: 8px 0 24px;
+}
+.tier-card {
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 22px 22px 26px;
+ background: var(--surface);
+ display: flex;
+ flex-direction: column;
+}
+.tier-card--featured {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 1px var(--accent) inset;
+}
+.tier-card__name {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--muted);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ margin-bottom: 8px;
+}
+.tier-card__price {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--text);
+ margin-bottom: 4px;
+}
+.tier-card__price-hint {
+ font-size: 12px;
+ color: var(--muted);
+ margin-bottom: 18px;
+}
+.tier-card ul {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 22px;
+ flex: 1;
+}
+.tier-card li {
+ font-size: 13.5px;
+ color: var(--text);
+ padding: 6px 0;
+ border-bottom: 1px solid var(--border);
+}
+.tier-card li:last-child { border-bottom: 0; }
+.tier-card li::before { content: "✓ "; color: var(--positive); font-weight: 700; margin-right: 4px; }
+.tier-card li.tier-card__excluded { color: var(--muted); }
+.tier-card li.tier-card__excluded::before { content: "✕ "; color: var(--dim); }
+.tier-card__cta { margin-top: auto; }
diff --git a/app/templates/about.html b/app/templates/about.html
new file mode 100644
index 0000000..0f0d989
--- /dev/null
+++ b/app/templates/about.html
@@ -0,0 +1,76 @@
+{% extends "public_base.html" %}
+{% block title %}{{ BRAND_NAME }} · About{% endblock %}
+
+{% block main %}
+
+
+ {{ BRAND_NAME }} is a news aggregator with a macro brain.
+ We pull market headlines and a curated set of cross-asset signals,
+ auto-tag the news by theme, and use a large language model to write
+ a short interpretation every hour — in plain English, with a fixed
+ editorial discipline.
+
+ Editorially we’re a media service, not a financial one. We
+ don’t make buy/sell calls, we don’t do technical
+ analysis, and we don’t pretend to know which way the tape goes
+ next.
+
+ Investors who’d rather understand than trade. People
+ who want a coherent read of the underlying fundamentals — what the
+ real economy, policy, and valuation are doing — not the next 30
+ minutes of price action, not chart patterns, and not which ETF to
+ buy.
+
+ Particularly: investors new enough to markets that the gambling
+ framing of social media is doing real damage. Every read here is
+ deliberately calm, anti-technical-analysis, and rooted in
+ fundamentals. We treat trading and gambling as different activities,
+ and we’re built for the people who already see them that way.
+
+ Architecturally, the product is deliberately privacy-shaped:
+
+ Full details on the privacy page.
+
+ {{ BRAND_NAME }} is operated from {{ OPERATOR_JURISDICTION }} by an
+ individual operator. It is not a regulated firm, and
+ nothing here is investment advice. See the
+ disclaimer.
+
+ If you are in financial distress, please consider speaking to a
+ free service such as
+ MoneyHelper
+ before relying on anything you read here.
+
+ {{ BRAND_NAME }} is not investment advice.
+
+ This page is part of, and qualifies, the
+ Terms of Service.
+
+ The strategic log, indicator summaries, and portfolio analysis are
+ generated by large language models from publicly available market
+ data and news. They can be wrong, incomplete, or out of date. Numbers
+ can be misread. Models occasionally generate inaccurate or invented
+ information (often called “hallucinations”). Treat them
+ as a prompt to think, not as facts to act on.
+
+ The portfolio analysis is an interpretation of holdings you
+ supplied. It does not consider your overall wealth, debts, tax
+ position, or anything we don’t see. It is not personalised
+ advice.
+
+ Consult a properly qualified, regulated financial adviser who knows
+ your full situation. Read the source documents (issuer accounts,
+ fund prospectus, etc.). If you are not in a position to lose the
+ money you would put at risk, do not put it at risk.
+
+ {{ BRAND_NAME }} is operated from {{ OPERATOR_JURISDICTION }}. The
+ Service is not directed at, or intended for distribution to or use
+ by, any person in any jurisdiction where such distribution or use
+ would be contrary to local law or regulation.
+
+ No part of this Service constitutes an offer or solicitation of
+ securities to any US person within the meaning of US securities law.
+
+ The Service is not directed at retail or professional clients in any
+ EU/EEA member state, nor in any jurisdiction where its provision
+ would require local licensing or registration. Where any output of
+ the Service could be construed as an “investment
+ recommendation” under Regulation (EU) 596/2014 (Market Abuse
+ Regulation) or its UK equivalent, it is non-personalised, produced
+ by a non-regulated source for educational purposes only, and the
+ operator (a) has no position in, or remuneration linked to, the
+ specific instruments mentioned in any individual piece of commentary,
+ and (b) is not a “relevant person” within MAR Art.
+ 3(1)(34).
+
+ The service is provided “as is” without warranties of any
+ kind. To the maximum extent permitted by applicable law, the operator
+ excludes liability for any loss arising from use of, or reliance on,
+ the content. See the Terms of Service for the
+ full limitation of liability.
+
+ Questions about this disclaimer:
+ {{ OPERATOR_EMAIL }}.
+
+ Built for investors who want to act rationally and
+ tune out the high-frequency noise that comes from treating markets
+ like a casino. We aggregate cross-asset news and macro signals,
+ then write a plain-English read of what the underlying fundamentals
+ justify versus what the crowd is doing. Refreshed hourly. A media
+ service, not a financial one.
+
+ RSS and per-ticker feeds covering equities, rates, credit, FX,
+ commodities, and geopolitics. Every headline is auto-tagged by
+ theme so the noise stays as noise and the fundamentals-relevant
+ stuff is easy to find. Ingestion follows the trading calendar —
+ off-hours stay quiet.
+
+ A hand-picked set of indicators across every asset class, refreshed
+ hourly during market hours. Each group gets a short read that
+ explains what the move means, not what it was. Anchored
+ in earnings, policy, valuation — not chart patterns.
+
+ We tie the day’s headlines and the cross-asset signals into
+ a single short interpretation. Each paragraph separates
+ rational drivers (earnings, policy, valuation)
+ from irrational ones (positioning, narrative,
+ flows) and names the gap. Two reading levels: novice and
+ intermediate. This is editorial commentary on public data —
+ not a forecast and not advice on any investment decision.
+
+ Paid users can also drop a Trading 212 pie CSV for an AI
+ sense-check on concentration, regime fit, and currency exposure.
+ Holdings stay in your browser by default; opt in to encrypted cloud
+ sync to restore on another device.
+
+ By signing up you agree to our Terms and
+ Privacy notice, and confirm you’ve read
+ the financial disclaimer.
+ About {{ BRAND_NAME }}
+ Who it’s for
+ How it’s built
+
+
+
+ Financial disclaimer
+
+ In short
+
+
+About the AI output
+ Before you act on anything
+ Jurisdiction
+ No warranty
+ {{ TAGLINE }}
+ Headlines from across the macro universe
+ A curated cross-asset tape
+ Rational vs irrational, every paragraph
+
+
+
+ By signing in you agree to our + Terms and + Privacy notice, and confirm you’ve read + the financial disclaimer. +
+
diff --git a/app/templates/pricing.html b/app/templates/pricing.html
new file mode 100644
index 0000000..8d88352
--- /dev/null
+++ b/app/templates/pricing.html
@@ -0,0 +1,96 @@
+{% extends "public_base.html" %}
+{% block title %}{{ BRAND_NAME }} · Pricing{% endblock %}
+
+{% block main %}
+
+
+ Two tiers. The news aggregator and the hourly macro interpretation
+ are free for everyone — we want the read out where people can use
+ it. The paid tier extends the same editorial commentary to the
+ specific tickers in a portfolio you upload — an educational read
+ of public data, not advice on whether to hold them.
+
+ The portfolio feature does not produce buy, sell or hold
+ recommendations. It does not consider your wider finances, debts,
+ tax position or objectives. It is not regulated investment advice
+ or a personal recommendation under FSMA / FCA COBS.
+
+ Your portfolio holdings live in your browser’s local storage by
+ default. The server only learns which Yahoo tickers appear across the
+ user base — an anonymous union, with no link back to any specific
+ user.
+
+ If you opt in to encrypted cloud sync, your pie is
+ encrypted in your browser with a PIN you choose, then sent to the
+ server. We add a second layer of encryption with a key only the
+ server holds. We never see your holdings as plaintext, and forgetting
+ the PIN means we can’t recover it for you. Full details on the
+ privacy page.
+
+ Not investment advice. Every output here is an
+ interpretation of public data — not personalised advice, not a
+ recommendation, and not produced by a regulated entity. Read the full
+ disclaimer before relying on anything you see.
+
+ Last updated: 2026-05-24. The operator (data controller) is
+ {{ LEGAL_OPERATOR }}, {{ OPERATOR_JURISDICTION }}. Registered with
+ the UK Information Commissioner’s Office under reference
+ ZC098928. Questions:
+ {{ OPERATOR_EMAIL }}.
+
+ This page describes exactly what we collect, what we don’t,
+ where it lives, and how long we keep it. It is written from the code,
+ not from a template — every claim corresponds to an explicit
+ code path we’re happy to point a reviewer at.
+ We rely on the following lawful bases:
+ The Service does not make decisions about you that produce legal or
+ similarly significant effects in an automated way (UK-GDPR Art. 22).
+ The AI portfolio analysis is editorial commentary on the holdings
+ you upload; it does not approve, reject or rank you, and you remain
+ the sole decision-maker about anything in your account.
+
+ The server runs in {{ OPERATOR_JURISDICTION }}. Data is stored in a
+ MariaDB database on the same host, backed up locally.
+
+ Two flows can take personal data outside the UK:
+ You have the right to:
+ Email {{ OPERATOR_EMAIL }} to
+ exercise any of these.
+
+ The Service is not directed at, and is not intended for use by,
+ anyone under 18. Do not create an account if you are under 18. If
+ you believe a child has provided personal data to us, contact
+ {{ OPERATOR_EMAIL }} and we
+ will delete it.
+
+ If we discover a personal-data breach likely to result in a risk to
+ your rights and freedoms, we will notify the ICO within 72 hours of
+ becoming aware of it, as required by UK-GDPR Art. 33, and notify
+ affected users without undue delay where Art. 34 requires.
+
+ Material changes will be flagged in-app and dated above. Trivial
+ edits (grammar, restructuring) won’t.
+ Pricing
+
+
+
+
+ How the data is handled
+ Privacy notice
+ What we collect
+
+
+What we don’t collect
+
+
+localStorage. The server’s view is the anonymous
+ ticker universe described above.
+ Lawful basis (UK-GDPR Art. 6)
+
+
+sessionStorage).
+ You can withdraw consent at any time by disabling sync in
+ Settings; the cached key is cleared and the server-side blob is
+ removed.
+ Automated decisions and profiling
+ Cookies and local storage
+
+
+localStorage on your device. They never leave the
+ browser.
+ localStorage on your device. If you enable
+ cloud sync, the derived encryption key is cached in
+ sessionStorage so you don’t have to re-enter
+ your PIN on every navigation. This caching is performed only with
+ your consent (given when you enable sync); it is cleared when you
+ close the tab or disable sync.
+ Where the data lives, and international transfers
+
+
+X-OR-Allow-Training: false on OpenRouter); see the
+ Third parties section below for the caveats.
+ Retention
+
+
+Third parties
+
+
+
+ No-training opt-out. Every OpenRouter request
+ carries the X-OR-Allow-Training: false header, which
+ signals to OpenRouter and any compatible upstream that the prompt
+ must not be used to train or improve models. DeepSeek does not
+ currently expose a per-request opt-out; if you do not want your
+ holdings to leave our server at all, do not use the AI portfolio
+ analysis feature. We do not control retention or training policies
+ on the provider side beyond the headers we set — the provider’s
+ own published data policy is the binding statement on that point.
+ Your rights (UK-GDPR)
+
+
+ Children
+ Security incidents
+ Changes to this notice
+
+ + +
+ + {# Same flash-prevention theme bootstrap as the app shell. #} + + + +
+