From f1903e1e6130c269083b8661a67801e27c6045f3 Mon Sep 17 00:00:00 2001
From: Giorgio Gilestro
Date: Sun, 24 May 2026 00:08:02 +0200
Subject: [PATCH] public: landing + pricing + legal pages, apex-ready,
lawyer-reviewed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds the unauthenticated surface that's needed to invite outsiders:
- Landing (/) — dual-purpose root: dashboard for logged-in users,
landing for everyone else. New maybe_current_user soft-auth helper
in app/auth.py supports it without disturbing the per-route
require_token deps on /news, /log, /upload, /settings.
- About, Pricing, Disclaimer, Terms, Privacy — own router
(app/routers/public.py), no auth dep, shared public_base layout
(brand link, thin nav, footer with legal links + ICO ref + date).
- Editorial positioning: news aggregator with a macro brain; tagline
"Understand markets. Don't gamble on them."; anti-trading-as-gambling
stance carried through About and Landing.
Legal pass following an independent lawyer-style review:
- Privacy: explicit UK-GDPR Art. 6 lawful-basis section; Art. 22
automated-decision line; explicit consent for sessionStorage sync
key (PECR); 30-day IP-log retention; Art. 21 objection right;
Children clause; Art. 33/34 breach-notification clause;
international-transfer mechanism (IDTA + UK Addendum). ICO
registration ZC098928 surfaced at the top.
- Pricing: paid-card AI-portfolio-analysis bullet rewritten to remove
advice-shaped wording ("what would invalidate the posture" gone);
added italic carve-out citing FSMA / FCA COBS.
- Disclaimer: separate EU/EEA carve-out + MAR 596/2014 Art. 3(1)(34)
commentator safe-harbour; "qualifies the Terms" line; hallucination
wording fixed.
- Terms: cl.4 explicit AI-training prohibition + harassment line;
cl.5 CCR 2013 14-day cancellation; cl.7 softened AI copyright
claim under CDPA s.9(3) ambiguity; cl.8 proportionate suspension +
pro-rata refund for paid users; cl.10 CRA 2015 Pt 1 statutory-rights
carve-out from the liability cap; cl.11 right to close account on
material change; cl.12 non-exclusive jurisdiction + UK consumer
local courts.
Code-side enforcement of the Privacy claim:
- openrouter.py: outbound OpenRouter calls now carry
X-OR-Allow-Training: false. DeepSeek doesn't expose a per-request
flag; the Privacy page discloses this caveat verbatim.
Apex domain prep:
- branding.APP_URL flipped to https://read.markets (was app.). DNS for
the apex already resolves; pending operator NPM step is a cert that
covers the bare apex + a 301 from app.read.markets. No hard-coded
subdomain references remain in code (verified with grep).
Nav + chrome:
- app dropdown gains Pricing / Terms / Privacy / Disclaimer links.
- login.html gains a small legal-links footer for the
highest-leverage moment to surface them.
Co-Authored-By: Claude Opus 4.7
---
app/auth.py | 31 ++++
app/branding.py | 19 +-
app/main.py | 4 +
app/routers/pages.py | 39 ++++-
app/routers/public.py | 68 +++++++
app/services/openrouter.py | 7 +
app/static/css/cassandra.css | 287 ++++++++++++++++++++++++++++++
app/templates/about.html | 76 ++++++++
app/templates/base.html | 6 +-
app/templates/disclaimer.html | 110 ++++++++++++
app/templates/landing.html | 103 +++++++++++
app/templates/login.html | 7 +
app/templates/pricing.html | 96 ++++++++++
app/templates/privacy.html | 311 +++++++++++++++++++++++++++++++++
app/templates/public_base.html | 64 +++++++
app/templates/terms.html | 214 +++++++++++++++++++++++
app/templates_env.py | 4 +
17 files changed, 1436 insertions(+), 10 deletions(-)
create mode 100644 app/routers/public.py
create mode 100644 app/templates/about.html
create mode 100644 app/templates/disclaimer.html
create mode 100644 app/templates/landing.html
create mode 100644 app/templates/pricing.html
create mode 100644 app/templates/privacy.html
create mode 100644 app/templates/public_base.html
create mode 100644 app/templates/terms.html
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 %}
+
+
+
About {{ BRAND_NAME }}
+
+ {{ 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.
+
+
+
+
+
Who it’s for
+
+ 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.
+
+
+
+
+
How it’s built
+
+ Architecturally, the product is deliberately privacy-shaped:
+
+
+
Your portfolio lives in your browser. The server’s view is
+ an aggregate set of tickers held across the whole user base,
+ which on its own does not identify any individual user — see
+ the Privacy notice for the exact data
+ structures.
+
Cloud sync of your portfolio is opt-in and end-to-end encrypted
+ with a PIN only you know.
+
No third-party tracking, no analytics SDKs, no ad cookies.
+ {{ 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.
+
+ This page is part of, and qualifies, the
+ Terms of Service.
+
+
+
+
+
In short
+
+
Everything published here is for educational and
+ informational purposes only.
+
Nothing on this site is a buy or sell recommendation, a personal
+ recommendation, or an inducement to deal in any financial
+ instrument.
+
{{ BRAND_NAME }} is not a regulated financial firm. It is not
+ authorised by the Financial Conduct Authority and is not a
+ registered investment adviser in any jurisdiction.
+
The output is not tailored to your circumstances, objectives,
+ tax position, or risk tolerance.
+
Past performance does not predict future returns. Investing
+ carries the risk of losing money, including the principal.
+
+
+
+
+
About the AI output
+
+ 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.
+
+
+
+
+
Before you act on anything
+
+ 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.
+
+
+
+
+
Jurisdiction
+
+ {{ 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).
+
+
+
+
+
No warranty
+
+ 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.
+
+ 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.
+
+
+
+
+
Macro signals
+
A curated cross-asset tape
+
+ 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.
+
+
+
+
+
The hourly read
+
Rational vs irrational, every paragraph
+
+ 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.
+
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 %}
+
+
+
Pricing
+
+ 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.
+
+
+
+
+
+
+
Free
+
£0
+
Forever. No card needed.
+
+
News aggregator — auto-tagged by theme
+
Cross-asset macro signals across every asset class
+ 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.
+
+
+
+
+
+
+
How the data is handled
+
+ 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.
+
+
+
+
+
What we collect
+
+
+ Your email address, when you sign in. We use it
+ only to send one-time login codes.
+
+
+ An argon2 hash of each login code, plus expiry
+ and attempt counts. The plaintext code is sent to your inbox and
+ never written to disk on our side.
+
+
+ A signed session cookie after you verify a code.
+ It contains your user id only and is signed so we can detect
+ tampering. Cookie is marked Secure and HttpOnly.
+
+
+ Anonymous ticker universe: when you upload a
+ portfolio CSV we record which Yahoo tickers appear, with
+ no link to your account. The same row would exist whether
+ any specific user holds the ticker or not — once a ticker is in
+ the universe, the row carries no signal as to whose import added it.
+
+
+ If you opt in to encrypted cloud sync: an opaque
+ blob of bytes per user. The blob is your portfolio, encrypted in
+ your browser with a PIN you choose, then wrapped a second time on
+ the server with a key only the server holds. We can’t decrypt
+ the blob to plaintext without your PIN, and we can’t recover
+ your PIN if you forget it. By enabling cloud sync you give your
+ consent (UK-GDPR Art. 6(1)(a)) to this processing; you can
+ withdraw consent at any time by disabling sync in Settings, which
+ also removes the server-side blob.
+
+
+ Anonymised cost ledger of AI calls (model, tokens,
+ cost). No portfolio or personal data is attached to ledger rows.
+
+
+ Referral linkage: if you signed up via an invite
+ link, we record which existing user’s code you used so we can
+ apply the agreed referral credit later.
+
+
+ Job-run telemetry: success/failure timestamps for
+ the scheduled jobs that fetch market data and generate AI reads.
+ No user identifiers are attached.
+
+
+
+
+
+
What we don’t collect
+
+
+ Your portfolio holdings as plaintext on the server.
+ Parsed pies are returned to your browser and kept in
+ localStorage. The server’s view is the anonymous
+ ticker universe described above.
+
+
+ Third-party analytics or ad cookies. No Google
+ Analytics, no Hotjar, no Segment, no Facebook pixel, no LinkedIn
+ tag. (You can verify by viewing-source on any page.)
+
+
+ Browser fingerprints.
+
+
+ IP-address joins to your user identity. IP
+ addresses are processed transiently by the reverse proxy for
+ security and access logging, retained for up to 30 days, and not
+ linked to your account record.
+
+
+
+
+
+
Lawful basis (UK-GDPR Art. 6)
+
We rely on the following lawful bases:
+
+
+ Performance of a contract (Art. 6(1)(b)) — for
+ operating your account, the sign-in flow, paid features, and the
+ mechanics of encrypted cloud sync.
+
+
+ Legitimate interests (Art. 6(1)(f)) — for the
+ anonymous ticker universe, the anonymised cost ledger, job-run
+ telemetry, and reverse-proxy access logs. Our interest is the
+ secure, abuse-resistant, cost-controlled operation of a free
+ public service, balanced against the minimal and de-identified
+ nature of the data.
+
+
+ Consent (Art. 6(1)(a)) — where you opt in to
+ encrypted cloud sync (and the related caching of a derived
+ encryption key in your browser’s 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
+
+ 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.
+
+
+
+
+
Cookies and local storage
+
+
+ Session cookie — strictly necessary for keeping
+ you signed in (PECR reg. 6(4)). No prior consent required.
+
+
+ Local preferences — your chosen theme (light /
+ dark) and reading level (Novice / Intermediate) are stored in
+ localStorage on your device. They never leave the
+ browser.
+
+
+ Local portfolio + cached sync key — parsed pies
+ live in 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
+
+ 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:
+
+
+
+ SMTP for sending one-time login codes. Operator-hosted,
+ currently inside the UK; if that changes we will update this notice.
+
+
+ AI provider calls for the strategic log, indicator
+ summaries, and (paid) portfolio analysis. Where the provider sits
+ outside the UK, we rely on the UK International Data Transfer
+ Agreement (IDTA) / the UK Addendum to the EU Standard Contractual
+ Clauses where no adequacy decision applies. Each outbound request
+ carries an explicit no-training opt-out header
+ (X-OR-Allow-Training: false on OpenRouter); see the
+ Third parties section below for the caveats.
+
+
+
+
+
+
Retention
+
+
+ Login codes: expire after a few minutes; row
+ remains briefly to enforce single-use, then is purged.
+
+
+ Session cookies: expire automatically; you can
+ sign out at any time to revoke.
+
+
+ Ticker universe: rows untouched for 60 days are
+ evicted by a nightly job. Active tickers remain.
+
+
+ Encrypted portfolio blob: kept until you disable
+ cloud sync (one click in Settings) or delete your account. We hold
+ one row per user; new uploads overwrite the previous blob.
+
+ Cost ledger and job telemetry: retained for
+ operational accounting; no personal data attached.
+
+
+
+
+
+
Third parties
+
+
+ SMTP provider: an operator-hosted Mailu server
+ sends the one-time login codes. The provider sees your email
+ address and the code body (the code itself).
+
+
+ AI provider(s): DeepSeek (primary) with OpenRouter
+ as a fallback. They see the prompt for the strategic log, the
+ indicator summaries, and the portfolio analysis call — which
+ contains your holdings only when you press
+ “Generate AI analysis” on a paid plan, and only for the
+ duration of that single call. The portfolio analysis output is not
+ persisted on the server.
+
+ 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.
+
+
+ Market-data sources: Yahoo Finance and a small set
+ of public RSS feeds. We request prices and headlines; we don’t
+ send them any user identifier.
+
+
+
+
+
+
Your rights (UK-GDPR)
+
You have the right to:
+
+
Ask what personal data we hold about you (Art. 15, right of access).
+
Have inaccurate data corrected (Art. 16, rectification).
+
Have your account and associated data deleted (Art. 17, erasure).
+
Export the data you can recognise (Art. 20, portability): your
+ email, any active encrypted blob, your referral linkage.
+
Restrict processing (Art. 18).
+
Object specifically to processing carried out on the basis of
+ legitimate interests (Art. 21), including any direct marketing.
+
Withdraw consent at any time where processing is based on
+ consent (Art. 7(3)), e.g. by disabling cloud sync.
+ 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.
+
+
+
+
+
Security incidents
+
+ 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.
+
+
+
+
+
Changes to this notice
+
+ Material changes will be flagged in-app and dated above. Trivial
+ edits (grammar, restructuring) won’t.
+