phase G: data minimisation + passwordless auth + DeepSeek-first LLM

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>
This commit is contained in:
Giorgio Gilestro 2026-05-18 14:16:57 +01:00
parent 480fd311c5
commit 6e7f57c6b2
54 changed files with 5005 additions and 916 deletions

View file

@ -20,7 +20,13 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
# Bump when the composed prompt changes meaningfully. Stored on every
# StrategicLog row so historical logs can be linked to the prompt that produced
# them.
PROMPT_VERSION = 5
#
# v6 (2026-05-17): TONE shrinks to NOVICE | INTERMEDIATE (PRO dropped). New
# educational stance baked into _CORE — explicit anti-TA, anti-gambling-mindset
# framing aimed at young investors entering the trading world. NOVICE retuned
# to be pedagogical (defining terms, anti-pattern teach-backs); INTERMEDIATE
# kept terse but with light-touch educational nudges. See tasks/todo.md.
PROMPT_VERSION = 6
# --- Core: invariant across tone/analysis settings ----------------------------
@ -92,6 +98,23 @@ predicted X and X did not happen". Both are useful; conflating them is not.
- No buy/sell recommendations. Triggers are pre-set elsewhere; your job is \
to report whether reality is confirming, modifying, or refuting the thesis.
# Stance (educational, anti-TA, anti-gambling)
The target reader is most likely young, new to investing, and at risk of \
treating markets like a horse race they need to "read" via chart patterns. \
Cassandra is the corrective.
- **No technical analysis.** Head-and-shoulders, RSI thresholds, Fibonacci \
levels, Elliott waves, "support/resistance" these are descriptions of past \
crowd behaviour, not predictions. Don't use them; don't legitimise them. If \
you mention a price level, frame it as a positioning fact (e.g. "the level \
where the latest tranche of buyers entered"), not a signal.
- **No gambling framing.** Markets are not a coin flip and not a horse race. \
Never present a position as a single decisive moment, a "now or never", or a \
bet to be won. Every read should follow the shape: *regime implication \
what would change the regime*.
- **Macro causality, every time.** Price moves get explained through \
fundamentals, geopolitics, monetary policy, and structural shifts not \
chart shapes. Even short paragraphs need the cause, not just the effect.
# System temperature (closing line, mandatory)
Close the log with a single sentence on a line of its own, formatted exactly:
@ -121,25 +144,92 @@ read shifts to Z")."""
# --- Tone: audience-shaping block --------------------------------------------
_TONE: dict[str, str] = {
"NOVICE": """# Audience: novice
The reader is new to markets. Define jargon the first time it appears (a \
short clause in parentheses is fine). Avoid ticker shorthand without context. \
Prefer everyday phrasing: "the price of US government debt fell, pushing \
yields higher" rather than "the long end backed up". Keep paragraphs short. \
Target ~600 words instead of ~800 so density stays digestible.""",
"NOVICE": """# Audience: novice — likely a young investor new to markets
This reader probably arrived from social media, treats charts as predictions, \
and is one bad week away from quitting. Your job is to **educate them out of \
the gambling mindset** without ever being preachy. Calm, patient, slightly \
teacherly. Never condescending.
"INTERMEDIATE": """# Audience: intermediate
- **Define jargon the first time it appears.** A short clause in parentheses \
is fine: "yield curve (the chart of borrowing costs across different \
maturities)", "ERP (equity risk premium the extra return investors demand \
for owning stocks instead of safe bonds)", "basis point (one hundredth of a \
percent 25bp = 0.25%)".
- **Avoid ticker shorthand without context.** Use "Apple (AAPL)" on first \
mention, then "Apple" or the ticker after.
- **Everyday phrasing over jargon** where the meaning survives: "the price \
of US government debt fell, pushing yields up" rather than "the long end \
backed up"; "investors are paying more for the same earnings" rather than \
"multiple expansion".
- **One analogy per concept, used sparingly.** Use them to bridge to \
something concrete the reader already understands not to entertain.
# Educational teach-backs (NOVICE-specific, when warranted)
When the day's data makes a common misconception concrete, drop in ONE \
teach-back of one to two sentences. Don't force it. Don't moralise. Examples \
of moments to do this:
- Anyone treating chart patterns as predictions: \
"Patterns like head-and-shoulders describe what crowds did, not what they \
will do they're stories told after the fact, not edges."
- Anyone fixated on day-to-day moves: \
"A 1% one-day move in a stock is roughly what you'd expect by chance. The \
multi-week trend is where the information lives."
- Anyone treating one ticker as a coin flip: \
"A single name's monthly move is mostly noise. The regime — what bonds, the \
dollar, and credit are doing together tells you whether ANY stock is \
likely to drift up or down."
- Anyone trying to "time the bottom" or "buy the dip": \
"Catching the bottom is a different game from owning the next cycle. The \
first needs you to be right within days; the second needs you to be roughly \
right within years."
Limit yourself to one teach-back per log. Skip them entirely if the day's \
data doesn't naturally invite one.
# Length
Target ~700 words. Slightly more than INTERMEDIATE because explanations \
need breathing room.""",
"INTERMEDIATE": """# Audience: intermediate — reads the news, learning to \
connect macro to markets
Assume the reader knows market basics (yield curves, breakevens, HY OAS, \
sector ETFs). Use common terms without defining them, but stay clear of \
deep institutional shorthand ("the belly", "duration trade", "carry pickup"). \
Target ~700 words lean and clear, no padding.""",
sector ETFs, the difference between cyclical and defensive, what a basis \
point is). Use common terms without defining them, but stay clear of deep \
institutional shorthand ("the belly", "duration trade", "carry pickup", \
"the RV book", "off-the-run").
"PRO": """# Audience: professional
Assume institutional vocabulary. Use dense market shorthand freely. Don't \
define standard terms. Target ~800 words. Density of insight > readability.""",
Light-touch educational nudges are welcome when the day's data warrants — \
e.g. "with rates this volatile, technical levels in equities are mostly \
distraction" — but keep them to a passing clause, not a paragraph. Don't \
moralise.
# Length
Target ~600 words. Lean and clear, no padding.""",
}
# Legacy values map to the closest current value. Logs a warning so we can
# notice if some caller's config didn't get updated.
_TONE_ALIASES = {
"PRO": "INTERMEDIATE",
"PROFESSIONAL": "INTERMEDIATE",
}
def _resolve_tone(tone: str) -> str:
"""Map a caller-supplied tone string to one of {NOVICE, INTERMEDIATE}.
Unknown tones fall back to INTERMEDIATE. The legacy PRO value is mapped
to INTERMEDIATE (audience pivot, see PROMPT_VERSION v6 notes)."""
upper = (tone or "").upper().strip()
if upper in _TONE:
return upper
if upper in _TONE_ALIASES:
return _TONE_ALIASES[upper]
return "INTERMEDIATE"
# --- Analysis: forward-vs-backward focus -------------------------------------
_ANALYSIS: dict[str, str] = {
@ -161,7 +251,7 @@ the trip-wires that decide between scenarios.""",
def build_system_prompt(tone: str, analysis: str) -> str:
"""Compose the system prompt from the chosen audience and analysis style."""
tone_block = _TONE.get(tone.upper(), _TONE["INTERMEDIATE"])
tone_block = _TONE[_resolve_tone(tone)]
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
return "\n\n".join([_CORE, tone_block, analysis_block])
@ -192,7 +282,7 @@ def build_summary_system_prompt(tone: str, analysis: str) -> str:
"""A lean, focused system prompt for the per-indicator-group hourly
summary. INTERPRETATION not description the reader has the table
next to this paragraph; they don't need numbers recited at them."""
tone_block = _TONE.get(tone.upper(), _TONE["INTERMEDIATE"])
tone_block = _TONE[_resolve_tone(tone)]
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
return f"""You write a TINY interpretation (≤60 words, 2-3 sentences) \
of ONE indicator group for a strategic markets dashboard.
@ -239,7 +329,7 @@ def build_summary_user_prompt(group_name: str, quotes: list[dict]) -> str:
def build_aggregate_summary_system_prompt(tone: str, analysis: str) -> str:
"""System prompt for the cross-group aggregate read shown on the dashboard.
Wider lens than a per-group summary synthesise across all groups."""
tone_block = _TONE.get(tone.upper(), _TONE["INTERMEDIATE"])
tone_block = _TONE[_resolve_tone(tone)]
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
return f"""You write a single SHORT cross-asset INTERPRETATION (≤80 \
words, 2-4 sentences) for the dashboard header. The reader is glancing \
@ -381,30 +471,95 @@ def build_user_prompt(
return "\n".join(parts)
def _provider_chain() -> list[str]:
"""Ordered list of providers to try: primary, then fallback (unless
the fallback is unset, the same as primary, or has no API key)."""
s = get_settings()
primary = (s.LLM_PROVIDER or "deepseek").lower()
fallback = (s.LLM_FALLBACK or "").lower()
chain = [primary]
if fallback and fallback != primary:
chain.append(fallback)
# Drop providers with no API key configured.
return [p for p in chain if _provider_has_key(p)]
def _provider_has_key(provider: str) -> bool:
s = get_settings()
if provider == "deepseek":
return bool(s.DEEPSEEK_API_KEY)
if provider == "openrouter":
return bool(s.OPENROUTER_API_KEY)
return False
def _endpoint_for(provider: str) -> tuple[str, str, str, dict[str, str]]:
"""Resolve (url, api_key, default_model, extra_headers) for a specific
provider. Raises if its API key isn't set."""
s = get_settings()
if provider == "deepseek":
if not s.DEEPSEEK_API_KEY:
raise RuntimeError("DEEPSEEK_API_KEY not set")
return s.DEEPSEEK_URL, s.DEEPSEEK_API_KEY, s.DEEPSEEK_MODEL, {}
if provider == "openrouter":
if not s.OPENROUTER_API_KEY:
raise RuntimeError("OPENROUTER_API_KEY not set")
return (
OPENROUTER_URL,
s.OPENROUTER_API_KEY,
s.OPENROUTER_MODEL,
{
# OpenRouter-specific attribution headers.
"HTTP-Referer": "https://github.com/local/cassandra",
"X-Title": "Cassandra",
},
)
raise RuntimeError(f"Unknown LLM provider: {provider!r}")
def llm_configured() -> bool:
"""At least one provider in the configured chain has an API key."""
return bool(_provider_chain())
def active_model() -> str:
"""Return the model name of the *first* provider in the configured
chain (the one that would be tried first). Used to label AICall ledger
rows when no actual call result is available yet."""
chain = _provider_chain()
if not chain:
return "unknown"
s = get_settings()
return s.DEEPSEEK_MODEL if chain[0] == "deepseek" else s.OPENROUTER_MODEL
@retry(
reraise=True,
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=2, min=2, max=30),
retry=retry_if_exception_type((httpx.HTTPStatusError, httpx.TransportError)),
)
async def call_openrouter(
async def _call_provider(
client: httpx.AsyncClient,
provider: str,
messages: list[dict],
model: str,
max_tokens: int = 4000,
model: str | None,
max_tokens: int,
) -> LogResult:
s = get_settings()
if not s.OPENROUTER_API_KEY:
raise RuntimeError("OPENROUTER_API_KEY not set")
"""One provider call with tenacity retries on transport/HTTP errors.
Lives inside the retry decorator so retries happen within a provider,
not across the fallback chain."""
url, api_key, default_model, extra_headers = _endpoint_for(provider)
used_model = model or default_model
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
**extra_headers,
}
r = await client.post(
OPENROUTER_URL,
headers={
"Authorization": f"Bearer {s.OPENROUTER_API_KEY}",
"Content-Type": "application/json",
"HTTP-Referer": "https://github.com/local/cassandra",
"X-Title": "Cassandra",
},
json={"model": model, "messages": messages, "max_tokens": max_tokens},
url,
headers=headers,
json={"model": used_model, "messages": messages, "max_tokens": max_tokens},
timeout=180,
)
r.raise_for_status()
@ -416,19 +571,68 @@ async def call_openrouter(
if not content:
finish = data["choices"][0].get("finish_reason")
raise RuntimeError(
f"OpenRouter returned empty content (finish_reason={finish}, "
f"model={model}, max_tokens={max_tokens})"
f"LLM returned empty content (finish_reason={finish}, "
f"provider={provider}, model={used_model}, max_tokens={max_tokens})"
)
usage = data.get("usage") or {}
return LogResult(
content=content,
model=model,
# Record provider+model so admin can see which path produced this row.
model=f"{provider}/{used_model}",
prompt_tokens=usage.get("prompt_tokens"),
completion_tokens=usage.get("completion_tokens"),
cost_usd=usage.get("cost") or usage.get("total_cost"),
)
async def call_llm(
client: httpx.AsyncClient,
messages: list[dict],
model: str | None = None,
max_tokens: int = 4000,
) -> LogResult:
"""Provider-aware chat completion with fallback. Tries primary
(LLM_PROVIDER) first; if it raises after retries, falls through to
LLM_FALLBACK. Raises only if every provider in the chain fails.
The returned LogResult.model is prefixed with the provider that
actually answered (e.g. ``deepseek/deepseek-v4-flash`` or
``openrouter/deepseek/deepseek-v4-flash``) useful admin metadata
even though we hide it from the user-facing UI."""
chain = _provider_chain()
if not chain:
raise RuntimeError("No LLM provider configured (no API key set)")
last_exc: Exception | None = None
for i, provider in enumerate(chain):
try:
result = await _call_provider(
client, provider, messages, model, max_tokens,
)
if i > 0:
from app.logging import get_logger
get_logger("llm").info(
"llm.fallback_succeeded", provider=provider, attempt=i + 1,
)
return result
except Exception as e:
last_exc = e
if i + 1 < len(chain):
from app.logging import get_logger
get_logger("llm").warning(
"llm.primary_failed_trying_fallback",
provider=provider, error=str(e)[:200],
)
continue
# Re-raise the last exception so callers see the failure mode.
assert last_exc is not None
raise last_exc
# Back-compat alias for any straggling import sites.
call_openrouter = call_llm
def month_window() -> tuple[datetime, datetime]:
"""[start, now] in UTC for the current calendar month."""
now = datetime.now(timezone.utc)