The theme toggle's onclick attribute held a 140-character inline
IIFE that was hard to read amongst the other named-function
handlers in the same header. Promoted it to cassandraToggleTheme()
alongside cassandraSetTone / cassandraSetLang.
- chat.js: pending indicator class was wrong (.pending instead of
chat-msg--pending) so the … waiting message never got italic/dim
- settings.html + cassandra.css: three invented CSS vars (--panel-bg,
--ok, --surface-1) had hardcoded fallbacks that broke dark mode;
replaced with real tokens (--surface, --positive)
- cassandra.css: .pf-secondary was scoped to .pf-actions but used
standalone in 4 places (sync modal, disable-sync, import cancel,
forget-pie button) — hoisted to a top-level selector
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two issues addressed:
1. The /settings language <select> was unstyled — .settings-select and
.settings-status classes didn't exist, so the dropdown rendered
with full native browser chrome and clashed visually with the rest
of the panel. Added a terminal-aesthetic select: transparent
background, 1px var(--border), custom chevron via crossed
linear-gradients, accent border on focus/hover. Disabled options
(ES/FR/DE 'coming soon') render in --dim.
2. Added a compact EN | IT pill in the topbar next to the theme
toggle, mirroring the .tone-toggle visual rhythm. Shown only when
a user is signed in (admins skipped). Optimistic UI: clicking
flips the pill immediately, PATCHes /api/settings/language, and
reverts on failure. On /log specifically the page reloads so the
user sees the localized version of the strategic log right away.
The /settings dropdown still surfaces all five languages (with ES/FR/DE
disabled) for visibility; the topbar pill keeps to the two active
languages to stay compact.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Delete app/templates/upload.html. The /upload route redirected to
/settings#import (302) and never rendered this template; the file
was carrying stale Trading-212-only copy.
- Landing + pricing pages: replace "Trading 212 today, more brokers
planned" with "Trading 212 natively, other formats auto-detected"
to reflect the LLM-fallback parser that's been live for a few days.
The /upload redirect route in app/routers/pages.py stays — it remains
a useful bookmark-forwarder for users with old links.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A small italic muted line beneath the form explaining the controls:
"Type a symbol, then quantity and cost — or use the calendar to fill
cost from a buy date — then [+] to add. [×] next to an existing row
removes it."
Only renders while the composer itself is visible (i.e. in edit mode),
so it doesn't clutter the dashboard at rest.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two related polishes:
- The add form was auto-shown by the empty-state path so brand-new
users would see something to act on. That conflicts with the user's
preference for "Edit always toggles the form, no other path." The
empty state now shows guidance copy ("click edit to add one")
instead. exitEditMode always hides the form too.
- The submit "add" word-button is replaced by a square accent-bordered
+ glyph (26×26). Matches the visual weight of the calendar ghost
next to it but stays in the accent colour so it reads as primary.
Adds a tiny active-state scale tick for tactile feedback.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Subtitle was technical noise that didn't earn its space in the header.
Title alone reads cleaner. Kept the scoped panel-header layout override
in cassandra.css since it's harmless and future-proof against re-adding
header children.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous CSS used invented variable names (--neu-dim, --err, --ok)
that don't exist in the project's design system; the form fell back to
hardcoded hex values and looked disconnected from the rest of the site.
Rebuilt against the real tokens (--border, --dim, --muted, --positive,
--negative, --warning, --accent) and the mono-first 'geopolitical-
terminal aesthetic' the rest of the dashboard uses:
$ ticker ✓ 172.40 USD │ qty @ cost USD 📅 add
────
- No boxed-form chrome. A dashed bottom rule separates the composer
from the table below.
- Inputs lose their card-style boxes; they're underline-only with a
faint accent wash on focus — feels like editing a command line.
- '$' prompt marker, '│' divider, '@' between qty and cost give the
row a terminal grammar without being twee.
- Submit is a ghost pill in the accent colour; lights to solid only
when enabled.
- All controls now respond correctly to the light/dark theme toggle.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the multi-row wizard-style form (Ticker / Qty on row 1, mode
radios on row 2, Date+Cost on row 3) with a single horizontal strip
that sits unobtrusively above the portfolio table. The radio toggle is
gone; a small calendar icon next to the Cost input pops out a date
picker that auto-fills cost on selection and then hides itself.
Same input IDs, so the existing validate/Add/× handlers work unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- portfolio.js empty-state CTA: "Import a T212 CSV" → "Import a portfolio CSV"
- settings.html lede: lead with broker-agnostic copy; relegate the T212
export path to a smaller secondary line.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Section heading drops "Trading 212"; drop-zone label and hint mention
the auto-detect path; the help-paragraph opens conditionally with
"If you use Trading 212" so non-T212 users don't feel like outsiders.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The referral feature was half-built: codes captured, banner shown,
counts displayed — but no money flowed when a referred user paid.
The Settings page hard-coded "— (D.3)" for Active credits and the
marketing copy promised "50% off for 3 months" with nothing behind it.
Closing the loop:
- New `convert_referral(session, user)` in referral_service.py looks
up the user's Referral row, stamps `converted_at` + `credited_at`,
and extends `credit_until` by 45 days on BOTH the buyer and the
referrer. Idempotent — replayed webhooks and renewals are no-ops.
Stacks correctly when the user already has a credit window running
(anchors at max(now, current_credit_until) like cli.grant_credit).
- Stripe webhook wires this into `_grant_paid`. A captured
`first_paid_transition = user.tier != "paid"` gate avoids the DB
lookup on every renewal event; convert_referral's own idempotency
is the second line of defence.
- `_grant_paid` now takes `session` as its first positional arg so
the conversion runs inside the same transaction as the tier flip
and audit-row write. A mid-flight failure rolls everything back
together — no partial state.
- Settings page replaces the "— (D.3)" placeholder with the live
count of conversions still inside their 45-day credit window, plus
a "+N days on your account" hint when the user has any credit of
their own (referrer bonus, admin grant, or future refund-as-credit).
- Marketing copy on pricing.html + settings.html switches from "50%
off for 3 months" to "45 days of paid access" — same economic value,
honest about the actual mechanism (full free access rather than
discounted billing).
Credit-amount rationale: 50% × 3 months ≈ 1.5 months of free
service ≈ 45 days. Pure-credit delivery is processor-agnostic, needs
no Stripe coupon plumbing, and stacks cleanly across referrals.
7 new tests in test_referral_conversion.py cover the happy path,
idempotency, no-referral no-op, credit stacking, deleted-referrer
survival, end-to-end webhook → credit landing, and the renewal-event
no-double-credit guarantee.
Also bundled: the Restore-button class fix from earlier
(portfolio.js — the cloud-restore "Restore" submit was unstyled and
picked up browser defaults; now uses .settings-btn like the rest of
the action-button family).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Settings page tidy-up driven by user feedback that it had grown too busy:
- Each section (Import, Invite, Email digests, Cloud sync) is now a
native <details>/<summary> accordion. Import stays open by default
because /settings#import is the deep-link target from the dashboard
CTA; the others collapse so the page lands quiet.
- Manage subscription is a right-aligned gear-icon button instead of
a rectangular text button — the descriptive copy moves into the
tooltip. Frees up the Tier row of visual weight.
Auth + modal inputs were too small (verify code box, portfolio restore
PIN): the auth-card selector now covers text inputs as well, and a new
.modal-input class standardises 16px / 12px-padding fields used in the
cloud-sync enable modal and the portfolio restore prompt.
The verify page no longer carries the "Email me the digest" checkbox —
it was misleading on repeat logins (server-side it only applied on
first sign-up but rendered every time). Default-opt-in lives in the
User row at creation; per-user changes happen on /settings. First
successful verify now triggers a one-shot welcome email explaining the
digest cadence and pointing at /settings for opt-out; SMTP failure is
logged but does not block the login.
Tests rewritten to cover the new welcome-email path:
- first login sends exactly one welcome email
- returning user gets none
- SMTP failure does not break the redirect
- regression guard: returning user who opted out stays opted out
Also lands the paddle merchant-summary doc that was written earlier
during the Paddle → Polar → Stripe onboarding pivot.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bundles three related pieces that came out of the operator's first
end-to-end test of the paid flow:
1. Manage subscription button on /settings (paid users with a real
Stripe sub — i.e. not credit-granted access). POSTs to the existing
/api/stripe/portal endpoint; Stripe-hosted customer portal handles
card updates, cancellation, monthly↔annual switch, invoice history.
Replaces the stale "Paid features unlock with Paddle (D.3) or
invite credits" hint for free users with a live link to /pricing.
2. Per-cadence cooling-off treatment:
- **Annual £70**: 14-day free trial via
subscription_data.trial_period_days=14. No money moves during
the trial, so the CCR 2013 14-day refund question doesn't arise
(nothing paid = nothing to refund). Card is still required at
checkout so Stripe can charge on day 15.
- **Monthly £7**: bills immediately. A 14-day trial there would
give away ~50% of cycle one. Instead, /pricing now carries a
required tick-box above the Subscribe buttons (subscribe stays
disabled until checked) — by ticking, the user expressly
consents to begin performance immediately and acknowledges that
this extinguishes their statutory 14-day right under Reg 36
CCR 2013. Consent collected on our own page (not via Stripe's
account-wide consent_collection.terms_of_service) so each
product can keep its own Terms URL as we add more.
3. T&C §6 clause 1 split into 1a (annual / trial substitute) +
1b (monthly / Reg 36 waiver via on-page tick-box). Clause 2
(post-cooling-off cancellation) unchanged.
Settings page shows "Free trial — N days remaining" while the
sub is in `trialing` status, falling back to "Paid subscription
active." once it transitions to active. Countdown is computed
server-side from User.stripe_trial_end_at (new column, migration
0020) populated by the subscription.created/updated webhook from
the Stripe trial_end timestamp; cleared on the trialing→active
transition and on revoke.
Drive-by: fixed a structlog kwarg-name collision on
`log.warning(..., event=event_type, ...)` in both polar_webhook.py
and stripe_billing.py — `event` is structlog's positional event
name and "got multiple values" crashed the user-not-found log
path. Renamed to `event_type=` everywhere it appeared. Caught by
the new trialing-stores-trial-end test.
Tests
- 4 new in test_stripe_billing.py covering monthly (no trial, no
consent_collection), annual (trial, no consent), trialing stores
trial_end, trialing→active clears trial_end.
- 1 existing test renamed + reworked for the consent split.
- Full suite: 224 passed, 5 skipped.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug: the per-browser pie was stored under a single global key
(`cassandra.pie`) with no per-user scope. If User A uploaded a
portfolio and User B then signed in on the same browser, User B saw
User A's holdings — portfolio.js read straight from localStorage on
hydration with no check that the data belonged to the current session.
This was not a server-side leak: the session cookie was correct, no
API returned User A's data to User B. The stale browser state was the
sole vector. Reported by the operator while testing the paid-checkout
flow with a second account on the same browser.
Fix — defense in depth, two layers:
1. base.html now stamps cu.user.id into localStorage as
`cassandra.user_id` on every authenticated page load. If the
previous stamp doesn't match the current user, wipe localStorage
(preserving only `cassandra.theme`, which is cosmetic) and
sessionStorage before any other script runs. This catches:
- the reported scenario (User A logs out, User B logs in)
- any case where logout missed the wipe (JS disabled, browser
killed before the redirect ran)
- cookie-revocation / session-rotation edge cases where the
server-side identity changes without an explicit logout
2. /logout no longer returns a bare 303; it returns a small HTML
page that actively wipes per-user localStorage + sessionStorage
client-side (theme preserved), then redirects to /login. A
meta-refresh covers the no-JS case (the cookie deletion is
still server-side, so security is preserved either way).
Behaviour for the legitimate case (same user logs out + back in)
is unchanged: their localStorage data survives because the
mismatch check sees the same user_id and doesn't fire — the
logout wipe runs but they re-stamp + re-upload only the
cassandra.user_id and a fresh pie cycle if they choose to upload.
Suite: 221 passed, 5 skipped, 0 failed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Stripe is the merchant-on-record for read.markets after Polar/Paddle
both declined the financial-media category. This commit lands the
full subscription flow: an "Upgrade" button on /pricing now opens a
real Stripe-hosted Checkout, completes the subscription, and the
webhook flips user.tier to "paid" idempotently.
Endpoints
- POST /api/stripe/checkout (require_auth) — creates a hosted
Checkout Session in subscription mode, passes user.id as
client_reference_id + email as customer_email, returns the URL
for the page-side JS to redirect to. Reuses an existing
stripe_customer_id to avoid duplicate Stripe customers on repeat
checkouts. allow_promotion_codes=True so the referral-credit
redemption can attach a coupon at checkout once that flow ships.
- POST /api/stripe/portal (require_auth) — mints a Stripe Customer
Portal session. Used by /settings; returns 404 until the user has
a stripe_customer_id (i.e. completed at least one checkout).
- POST /api/stripe/webhook — signature-verified via
stripe.Webhook.construct_event. Idempotent via UNIQUE on
stripe_events.event_id. Event dispatch:
checkout.session.completed → grant paid, store IDs
customer.subscription.created → grant paid (active/trialing)
customer.subscription.updated → grant paid (active/trialing)
customer.subscription.deleted → drop to free, clear sub id
invoice.paid / failed → audit only
charge.refunded → audit only
Stripe-SDK objects don't expose dict.get(); we use the SDK for
signature verification then re-parse the JSON body for handler
dispatch — cleaner than reaching into StripeObject internals.
Schema (migration 0019)
- users.stripe_customer_id, users.stripe_subscription_id (nullable
String(64), UNIQUE on customer_id).
- stripe_events table mirroring polar_events: event_id (unique),
event_type, received_at, processed_at, error, raw payload
(truncated to 16 KiB).
Settings (.env)
- STRIPE_API_KEY (rk_test_… for dev, rk_live_… for GA)
- STRIPE_WEBHOOK_SECRET (whsec_… from the dashboard endpoint)
- STRIPE_PRICE_MONTHLY (price_xxx for £7/month)
- STRIPE_PRICE_ANNUAL (price_xxx for £70/year)
Pricing page
- Free tier CTA unchanged.
- Paid CTA branches three ways: paid → "Manage subscription" to
/settings; logged-in free → two buttons (£7/mo, £70/yr) that POST
to /api/stripe/checkout and redirect; anonymous → /login?next=/pricing.
- Inline JS intercepts the button click, calls the checkout
endpoint, redirects on success, surfaces errors via alert(). No
Stripe.js dep — we use the hosted-checkout URL directly.
Polar handler stays in place for berengar.io / flyroom.net which
still ship through Polar. polar_* and stripe_* columns coexist
independently on the User row.
Tests
- 9 in tests/test_stripe_billing.py covering: bad signature → 401,
missing signature → 400, checkout.session.completed flips tier +
stores IDs, subscription.updated active grants paid,
subscription.deleted drops to free with customer id preserved,
replayed event id is no-op (one row in stripe_events),
unknown event acked 200, checkout endpoint mocks the SDK and
returns the hosted URL, checkout requires login.
- Full suite: 221 passed, 5 skipped.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Marketing + behaviour pass to get the site ready for Paddle approval.
Pricing page
- £7/month, £70/year headline (was "Coming soon").
- Bigger tier names (was 11px uppercase mono — looked like chips).
- Real CTAs (button base styles were only scoped to .hero__ctas).
- "Best value" badge + drop-shadow on the Paid card; full-width
block CTAs that align across both cards.
- "Free vs Paid at a glance" comparison table beneath the cards.
- Compact "Invite a friend — both get 50% off for 3 months"
callout with the detail explanation behind a <dialog> popup.
Tier copy + behaviour now consistent
- Free strategic-log refresh is every 6 hours, not hourly. New
read-side filter on /api/log/{latest,by-date} restricts free
users to logs at boundary hours (00/06/12/18 UTC); paid users
still see the most recent.
- Follow-up chat is paid-only. /api/chat returns 402 for free;
the chat sidebar on /log is replaced with a locked aside and
chat.js no longer loads at all for free users.
- Dashboard meta lines + landing copy softened so they no longer
promise hourly to everyone.
Future-proofing copy on public pages
- Dropped "free forever" wording (we may close the free tier).
- "Trading 212 CSV" became "broker CSV (Trading 212 today; more
planned)" on pricing + landing; the actual import UIs stay
T212-specific.
Terms
- Renamed Terms of Service -> Terms and Conditions (Paddle
expectation), bumped last-updated to 2026-05-26.
- New §6 Refunds covering the 14-day cooling off, post-window
cancellation, termination-by-us refunds, statutory rights, and
how to request a refund.
- Renumbered §7-§14 and fixed the disclaimer link labels.
Tests
- 6 new tests in tests/test_chat_and_log_gates.py cover the
chat 402 + the boundary-hour filter on both log endpoints.
- Full suite: 205 passed, 5 skipped, 0 failed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Audit against the live feature set surfaced one missing entry and a few
soft phrasings:
- Free now lists "Ask follow-up questions on any past log" — the /api/chat
endpoint has no paid gate (router-level require_token only), so it's
available to every signed-in user. The landing-page screenshot already
showed it; the pricing page didn't mention it.
- "Per-group cross-asset summaries" → "Cross-asset indicator panels
(equities, rates, FX, …) with a one-paragraph AI read on each tab" —
more concrete about what the user actually sees.
- "Novice / Intermediate reading levels" → spelled out what each does
(Novice defines jargon; Intermediate is terse).
- Free's exclusion list explicitly includes "Daily email digest" so the
paid/free distinction reads cleanly without back-and-forth.
- Paid's daily-digest bullet now leads with the word count target
(~600 words) so the value is concrete, not abstract.
- Encrypted cloud sync bullet now names the actual security model
(PIN-derived in-browser + server-side outer wrap).
Added a small "Invite a friend" footnote — the credit ledger and invite
link both ship today; the rate kicks in with the payments rollout. Honest
phrasing keeps it from looking like vaporware.
Intro paragraph rewritten to lead with what's free (most of the editorial)
rather than what paid extends, since the free tier is the entry point.
Five PNGs at app/static/images/ (renamed from screenshot dumps):
- dashboard.png — full dashboard hero shot, sits below the hero CTAs
- news-feed.png — feature-card thumbnail: auto-tagged news feed
- indicators-read.png — feature-card thumbnail: per-group AI commentary
- strategic-log.png — feature-card thumbnail: hourly strategic log text
- chat-with-log.png — "More views" gallery: ask follow-ups against a log
Every screenshot is a <button class="shot"> with data-full + data-caption;
click opens an HTML5 <dialog>-based lightbox. <dialog> handles focus trap,
ESC-to-close, inert background; the backdrop click closes too. Images use
loading="lazy" so the lightbox-only ones don't block first paint.
CSS appended to cassandra.css: .shot, .shot-hero, .shots-grid, .shot__caption,
and .shot-modal (+ ::backdrop). All colours pull from the existing palette
vars so light and dark themes stay coherent.
Total image weight: ~950 KB across all five — acceptable for a marketing
landing page with lazy-loaded thumbnails.
Adds an 8-byte HKDF fingerprint of the current pepper to portfolio_sync
rows. On fetch, a mismatch surfaces as 410 Gone (distinct from genuine
GCM corruption → 500), and the UI silently cleans up the dead row and
shows a soft "please re-import" notice instead of a confusing PIN
re-prompt. Legacy rows (pepper_fp NULL) are probed optimistically and
backfilled on success.
Also fixes a latent bug in unwrap(): AESGCM.decrypt args were swapped
(ct, nonce instead of nonce, ct), so restore-from-cloud always failed
even when the pepper was correct.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- dashboard grid: explicit "header" area as the first row so the
aggregated read panel renders at the top instead of being
auto-placed after the named areas.
- indicators: hide rows flagged stale (older than the group's
freshness threshold). Server still computes stale_symbols;
rendering can be re-enabled by removing the
`{% if not is_stale %}` wrapper in indicators.html.
- /log: add tone-changed to #log-content's hx-trigger and include
it in cassandraSetTone's selector list — toggling Novice /
Intermediate on the Log page was previously a no-op.
- prompts: bump PROMPT_VERSION 7→8. Strengthen the rational-vs-
irrational framing in the strategic-log system prompt from
aspirational to mandatory ("a paragraph without both lenses must
be rewritten"). Require the same lens in the per-group summary,
cross-asset aggregate, and portfolio commentary overrides.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Swaps the role of `:root` (now light) and the data-theme attribute
(now `[data-theme="dark"]`) in cassandra.css, flips the localStorage
fallback from 'dark' to 'light' in base/login/verify templates, and
updates the theme-toggle label and the branding-consistency test
selectors to match.
Existing users with cassandra.theme=dark in localStorage still see
dark — their explicit preference wins. Only first-time visitors and
users with no stored preference shift to light.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The product is now "Read the Markets" served at https://read.markets,
with the app at https://app.read.markets. "Cassandra" survives only as
the in-product AI persona (system prompt + "Ask Cassandra" chat label).
Centralised the brand in app/branding.py: BRAND_NAME, BRAND_SHORT,
DOMAIN, SITE_URL, APP_URL, EMAIL_FROM_DEFAULT. Jinja templates pull
{{ BRAND_NAME }} via globals registered in templates_env.py; Python
code reads branding.BRAND_NAME directly. The future-rename surface
is now a one-liner.
Updated: FastAPI app title, every page title (dashboard, news, log,
settings, upload, login, verify), header brand div, auth-card brands,
OTP email subject + HTML + plain-text bodies (incl. uppercase header
tag), OpenRouter X-Title + HTTP-Referer attribution headers, README.
Email tests now assert against branding.BRAND_NAME rather than the
literal string.
Internal identifiers deliberately kept on the legacy "cassandra" name
to avoid invalidating live sessions / advisory locks / configs:
cookies (cassandra_session, cassandra_pending) + itsdangerous salts,
MariaDB GET_LOCK keys, CASSANDRA_TOKEN env var, cassandra.css filename,
pyproject package name, localStorage prefs, outbound User-Agent strings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lays the billing-prep spine before Paddle lands in D.3.
D.1 — referrals
- users.referral_code: unique 8-char URL-safe code (alphabet excludes the
ambiguous 0/O/1/I/L). Generated lazily on first /settings hit so existing
accounts pick one up without a backfill migration.
- users.referred_by_user_id + new referrals audit table (referrer,
referred, created_at, converted_at, credited_at). converted_at /
credited_at stay null until D.3 fills them via the Paddle webhook.
- POST /login accepts ?ref=<code>; the code rides on the signed
pending-verify cookie so it survives the GET → POST → /verify hop.
- /settings page: email, tier badge, referral code chip + invite link
with one-click copy, pending/converted/active-credits stats grid.
Settings nav link added to the top bar.
Reward shape: when the referred user makes their first paid Paddle
subscription, both they and the referrer get 50% off for 3 months.
(D.3 wires the actual credit application via the Paddle webhook.)
D.2 — paid-access gate
- users.credit_until: timestamp until which a free-tier account has
paid-tier access. Null = no credit. Populated by admin CLI now and the
D.3 webhook later.
- app.services.access exposes paid_status(user) → PaidStatus dataclass
(active / source / expires_at / days_remaining), is_paid_active() with
admin-bearer-token bypass, and a require_paid FastAPI dependency that
raises 402 Payment Required for free-tier callers.
- POST /api/analyze (portfolio AI commentary) gated behind require_paid.
- Settings page surfaces credit window when active ("free · credit · N
day(s) remaining (expires YYYY-MM-DD)") and the upgrade hint when not.
- Admin CLI: python -m app.cli {grant-credit,revoke-credit,show-status}.
grant-credit is idempotent — extends from max(now, current expiry) so
re-running the command never erodes an existing grant.
Migrations 0013 (referrals) and 0014 (credit_until). Tests cover the
paid-status truth table, code generation + normalisation, CLI argument
parsing, and the pending-cookie ref roundtrip (29 new tests).
- Move news_job from hourly to 3x/hour (cron 10,30,50), with a CadencePolicy
gate that throttles to active hours (07-21 UTC weekdays at 20 min), off-hours
(3 h), weekends (6 h). Keeps the daytime feed fresh without spamming RSS
sources overnight.
- Tag each headline on ingestion via DeepSeek (BATCH_SIZE=25, max_tokens=4000,
json.JSONDecoder().raw_decode + per-row regex recovery for resilient parsing).
Vocabulary: 16 tags including new EU / USA / AI / Conflict. NULL tags are
picked up automatically on the next news_job run, so back-tagging is implicit
rather than a separate migration step.
- Tag UI: pill bar above the feed with off → include → exclude cycle on click;
shift-click jumps straight to exclude. State persists in localStorage and is
injected into /api/news requests via htmx:configRequest. Per-row chips sit to
the right of the headline (new 5-column grid: age | source | title | tags |
UTC) so vertical density stays high.
- Strategic log header bug: model was hallucinating "(Updated 21:30 UTC)" in
future tense. Bumped PROMPT_VERSION 6→7, added explicit ban on time-of-day
clauses, and supply the actual current UTC time in the user prompt so the
model has no need to invent one.
Migration 0012 adds headlines.tags (JSON, nullable). Tests cover vocabulary
integrity, validation/normalisation, and the JSON-recovery parser (17 tests).
Server no longer holds portfolios. Holdings live in the browser
(localStorage); the server publishes an anonymous ticker_universe and a
gzipped /api/universe payload identical for every authenticated user, so
access patterns can't betray which tickers a user holds. AI commentary
is generated ephemerally from the browser-supplied pie and the cost
ledger row records no positions. Migrations 0009-0011 added the
universe table and dropped positions / portfolio_snapshots /
portfolios.
Authentication is now e-mail OTP only. Migration 0010 dropped
password_hash and email_verified (every active session is by
construction proof of email control). The /signup endpoint is gone;
signup and login share a single email-entry page. Email rendering is
HTML+plain-text multipart with a shared brand palette (app/branding.py)
asserted in sync with the CSS by a drift-detection test.
LLM provider defaults to DeepSeek-direct (cheaper, api.deepseek.com)
with OpenRouter as automatic fallback if DeepSeek fails. ai_log_job and
indicator_summary_job now iterate the two tones (NOVICE, INTERMEDIATE)
per cycle so the dashboard's tone toggle is instant; PROMPT_VERSION
bumped to 6 with an educational anti-TA / anti-gambling stance baked
into _CORE. NOVICE mode renders a curated glossary inline (CBOE VIX,
yield curve, HY OAS, etc.) with JS-positioned tooltips that survive
viewport edges and sticky bars. Model name and tokens hidden from the
user UI; still recorded in StrategicLog.model and AICall for admin.
Layout adds a sticky top nav, a sticky bottom markets bar (one chip per
exchange with status LED + headline index + 1d change), and
Phase H feedback reporting is queued in tasks/todo.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the static bearer-token gate with a real auth boundary. The
existing CASSANDRA_TOKEN path is retained as an admin / scripting escape
hatch — kept compatible by aliasing require_token to require_auth.
- New users table (migration 0007): email, argon2 password_hash, tier,
email_verified (declared but not enforced until phase E), settings_json
for the tone/analysis/anchor knobs we'll wire in phase D.
- app/services/auth_service.py: argon2-cffi password hashing with timing-
attack-resistant authenticate() (always runs a hash verify even on
unknown-email to deny a username-enumeration oracle).
- app/auth.py rewritten: require_auth returns a CurrentUser with either
is_admin=True (bearer path) or a User object (session path). Failing
requests get 303 → /login for HTML, 401 for API. Sessions signed with
itsdangerous against CASSANDRA_SESSION_SECRET; 14-day TTL.
- app/routers/auth.py: /login, /signup, /logout. Login form preserves the
?next=… param for redirect-after-login. Signup respects a new
CASSANDRA_SIGNUP_ENABLED flag.
- Standalone /login + /signup templates (no app chrome). base.html grows
a user chip + logout link in the header (reads request.state.current_user).
Phase A's main known limitations are documented in the plan: email
verification is declared but not enforced; session revocation is
best-effort (cookie-only, not DB-backed). Both land in phase E.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes Phase B. The full alternative-onboarding flow is now end-to-end:
drop a T212 pie CSV → parser → InstrumentMap resolver → PortfolioSnapshot
+ Position rows, all without ever asking the user for broker credentials.
- persist_pie() in app/services/csv_import.py: takes a ParsedPie, resolves
each Slice via InstrumentMap, writes Portfolio + Snapshot + Position
rows. Unmapped slices are still persisted using their CSV values and
surfaced in the response for the UI to warn about.
- POST /api/portfolios/upload: multipart endpoint accepting CSV file +
optional portfolio_name + currency. 2 MiB cap. Returns import summary.
- /upload page with drag-drop dropzone, file input fallback, and inline
result panel showing invested/value/result + unmapped-slice warnings.
- New "Import" link in the header nav.
Verified end-to-end against the real T212 export: all 13 positions land
with correct T212 tickers (incl. FPp_EQ for the Paris TotalEnergies
listing the heuristic resolver picks), zero unmapped slices, totals
reconcile to the penny.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new data sources hooked into the existing SOURCES registry. All
open APIs, no keys:
- EUROSTAT: prefix EUROSTAT:dataset?dim=val&... — current EU bond
yields (Bund/OAT/BTP/EZ) and Eurozone economic indicators that
FRED's OECD-mirror series stopped updating in 2022-2023.
- ONS: prefix ONS:topic/cdid/dataset — current UK CPI, unemployment,
GDP, industrial production. Replaces the 5+ month-stale FRED
LRHUTTTTGBM156S mirror.
New indicator groups in default.toml feed the strategic/fundamental
lens we converged on: valuation (CAPE/Buffett anchors), bubble_watch
(SKEW/VVIX/RSP vs SPY/HYG vs TLT/IPO/crypto), economy (multi-region,
ALL current-or-stale-flagged), bonds (UK/EU/US/JPN sovereign yields).
Indicator panel now opens with an AI "read" interpretation per group
(generated hourly at :07 UTC alongside an aggregate cross-group read
shown in the dashboard header). The aggregate is grounded by a markets
strip — NYSE/LSE/Frankfurt/Tokyo/HK/Shanghai with open/closed LEDs and
next-open countdown, computed locally from each exchange's tz.
Other UX bits: indicator-row tooltips populated from TOML notes;
rows whose last observation is >90 days old get a 'stale' chip;
ghost symbols (in DB but no longer in TOML) filtered out of the
panel; Eurostat/ONS symbols display as short codes rather than the
full API path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Containerised macro-strategy dashboard: 4-panel web UI (indicators,
portfolio, flash news, AI strategic log), MariaDB store, hourly
ingestion jobs, OpenRouter-backed AI analysis.
Ports the four prototype scripts in the parent dir (market_pulse,
flash_news, trading212, strategic_log) into async services backed by a
persistent DB and served via FastAPI + Jinja2 + HTMX. APScheduler runs
as a separate compose service for crash-safety and easier restarts.
Portfolio composition + position names come live from Trading 212;
news per-ticker headlines reuse those names. Tone (NOVICE/INTERMEDIATE/
PRO) and analysis style (DRY/SPECULATIVE) are env-configurable and
stored on each log row so historical entries show what produced them.
Default model is deepseek/deepseek-v4-flash (overridable via env).
Light/dark theme toggle, sans-serif for prose surfaces, monospace for
data. Bearer-token auth, OpenRouter monthly cost cap, RSS feeds auto-
disabled on consecutive failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>