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:
parent
480fd311c5
commit
6e7f57c6b2
54 changed files with 5005 additions and 916 deletions
|
|
@ -34,9 +34,6 @@ from app.models import (
|
|||
Headline,
|
||||
IndicatorSummary,
|
||||
JobRun,
|
||||
Portfolio,
|
||||
PortfolioSnapshot,
|
||||
Position,
|
||||
Quote,
|
||||
StrategicLog,
|
||||
)
|
||||
|
|
@ -44,7 +41,6 @@ from app.schemas import (
|
|||
HealthOut,
|
||||
HeadlineOut,
|
||||
JobStatus,
|
||||
PortfolioSummary,
|
||||
QuoteOut,
|
||||
StrategicLogOut,
|
||||
)
|
||||
|
|
@ -52,7 +48,8 @@ from app.schemas import (
|
|||
|
||||
router = APIRouter(dependencies=[Depends(require_token)])
|
||||
|
||||
JOB_NAMES = ("market_job", "news_job", "portfolio_job", "ai_log_job", "rollup_job")
|
||||
JOB_NAMES = ("market_job", "news_job", "ai_log_job", "rollup_job",
|
||||
"indicator_summary_job", "universe_flush_job")
|
||||
JOB_STALE_HOURS = 2.0 # job is "warn" if its last success was >2h ago
|
||||
|
||||
# Per-group expected freshness — bonds and intraday tape want daily data,
|
||||
|
|
@ -133,6 +130,7 @@ async def indicators(
|
|||
group: str,
|
||||
request: Request,
|
||||
as_: str | None = Query(default=None, alias="as"),
|
||||
tone: str | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
sub = (
|
||||
|
|
@ -170,12 +168,22 @@ async def indicators(
|
|||
rows = [r for r in rows if r.symbol in configured]
|
||||
|
||||
has_anchor = any((r.changes or {}).get("anchor") is not None for r in rows)
|
||||
wanted_tone = _resolve_tone_param(tone)
|
||||
summary = (await session.execute(
|
||||
select(IndicatorSummary)
|
||||
.where(IndicatorSummary.group_name == group)
|
||||
.where(IndicatorSummary.tone == wanted_tone)
|
||||
.order_by(desc(IndicatorSummary.generated_at))
|
||||
.limit(1)
|
||||
)).scalar_one_or_none()
|
||||
if summary is None:
|
||||
# Fallback during rollout: any tone for this group.
|
||||
summary = (await session.execute(
|
||||
select(IndicatorSummary)
|
||||
.where(IndicatorSummary.group_name == group)
|
||||
.order_by(desc(IndicatorSummary.generated_at))
|
||||
.limit(1)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
# Mark rows whose `as_of` is older than the group-specific threshold.
|
||||
# Daily-tape groups (bonds, rates, equity, ...) flag stale earlier
|
||||
|
|
@ -195,7 +203,8 @@ async def indicators(
|
|||
request, "partials/indicators.html",
|
||||
{"quotes": rows, "has_anchor": has_anchor,
|
||||
"summary": summary, "notes": notes,
|
||||
"stale_symbols": stale_symbols},
|
||||
"stale_symbols": stale_symbols,
|
||||
"tone": wanted_tone},
|
||||
)
|
||||
return [QuoteOut.model_validate(r, from_attributes=True) for r in rows]
|
||||
|
||||
|
|
@ -257,19 +266,42 @@ def _log_partial_payload(row: StrategicLog | None) -> dict | None:
|
|||
}
|
||||
|
||||
|
||||
def _resolve_tone_param(tone: str | None) -> str:
|
||||
"""Normalise a query-param tone to one of the two valid values.
|
||||
PRO is silently mapped to INTERMEDIATE (see openrouter.PROMPT_VERSION 6)."""
|
||||
if not tone:
|
||||
return get_settings().CASSANDRA_TONE.upper()
|
||||
upper = tone.upper().strip()
|
||||
if upper in ("NOVICE", "INTERMEDIATE"):
|
||||
return upper
|
||||
return "INTERMEDIATE"
|
||||
|
||||
|
||||
@router.get("/log/latest")
|
||||
async def log_latest(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
as_: str | None = Query(default=None, alias="as"),
|
||||
tone: str | None = Query(default=None),
|
||||
):
|
||||
wanted_tone = _resolve_tone_param(tone)
|
||||
row = (await session.execute(
|
||||
select(StrategicLog).order_by(desc(StrategicLog.generated_at)).limit(1)
|
||||
select(StrategicLog)
|
||||
.where(StrategicLog.tone == wanted_tone)
|
||||
.order_by(desc(StrategicLog.generated_at))
|
||||
.limit(1)
|
||||
)).scalar_one_or_none()
|
||||
# Fallback during rollout: if the requested tone isn't produced yet,
|
||||
# serve whatever is latest rather than 404 the panel.
|
||||
if row is None:
|
||||
row = (await session.execute(
|
||||
select(StrategicLog).order_by(desc(StrategicLog.generated_at)).limit(1)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if as_ == "html":
|
||||
return templates.TemplateResponse(
|
||||
request, "partials/log.html", {"log": _log_partial_payload(row)},
|
||||
request, "partials/log.html",
|
||||
{"log": _log_partial_payload(row), "tone": wanted_tone},
|
||||
)
|
||||
|
||||
if row is None:
|
||||
|
|
@ -283,22 +315,35 @@ async def log_by_date(
|
|||
day: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
as_: str | None = Query(default=None, alias="as"),
|
||||
tone: str | None = Query(default=None),
|
||||
):
|
||||
"""Canonical log for a given day = MAX(generated_at) within that day."""
|
||||
"""Canonical log for a given day = MAX(generated_at) within that day,
|
||||
filtered by tone (NOVICE | INTERMEDIATE; default from settings)."""
|
||||
try:
|
||||
target = datetime.strptime(day, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="day must be YYYY-MM-DD")
|
||||
wanted_tone = _resolve_tone_param(tone)
|
||||
row = (await session.execute(
|
||||
select(StrategicLog)
|
||||
.where(func.date(StrategicLog.generated_at) == target)
|
||||
.where(StrategicLog.tone == wanted_tone)
|
||||
.order_by(desc(StrategicLog.generated_at))
|
||||
.limit(1)
|
||||
)).scalar_one_or_none()
|
||||
if row is None:
|
||||
# Fallback: any tone for that day.
|
||||
row = (await session.execute(
|
||||
select(StrategicLog)
|
||||
.where(func.date(StrategicLog.generated_at) == target)
|
||||
.order_by(desc(StrategicLog.generated_at))
|
||||
.limit(1)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if as_ == "html":
|
||||
return templates.TemplateResponse(
|
||||
request, "partials/log.html", {"log": _log_partial_payload(row)},
|
||||
request, "partials/log.html",
|
||||
{"log": _log_partial_payload(row), "tone": wanted_tone},
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="No log on this date")
|
||||
|
|
@ -380,119 +425,9 @@ async def log_days(
|
|||
return templates.TemplateResponse(request, "partials/calendar.html", payload)
|
||||
|
||||
|
||||
# --- Portfolios --------------------------------------------------------------
|
||||
|
||||
|
||||
# 2 MiB max for CSV uploads — T212 pies don't exceed a few KB in practice.
|
||||
# Keeps the abuse vector small without rejecting legitimate exports.
|
||||
_MAX_CSV_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
@router.post("/portfolios/upload")
|
||||
async def upload_portfolio_csv(
|
||||
file: UploadFile = File(...),
|
||||
portfolio_name: str | None = Form(default=None),
|
||||
currency: str = Form(default="GBP"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Import a Trading 212 pie-export CSV. Parses, resolves each Slice to a
|
||||
T212 ticker + Yahoo symbol via InstrumentMap, and persists a new
|
||||
PortfolioSnapshot + Position rows.
|
||||
|
||||
No user-id scoping yet — that lands in phase C. Until then, all uploads
|
||||
land in the single shared portfolio identified by name."""
|
||||
from app.services.csv_import import CSVImportError, parse_t212_csv, persist_pie
|
||||
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="No file uploaded")
|
||||
if not file.filename.lower().endswith(".csv"):
|
||||
raise HTTPException(status_code=400, detail="File must have .csv extension")
|
||||
|
||||
raw = await file.read(_MAX_CSV_BYTES + 1)
|
||||
if len(raw) > _MAX_CSV_BYTES:
|
||||
raise HTTPException(status_code=413, detail=f"File exceeds {_MAX_CSV_BYTES} bytes")
|
||||
if not raw:
|
||||
raise HTTPException(status_code=400, detail="File is empty")
|
||||
|
||||
try:
|
||||
pie = parse_t212_csv(raw)
|
||||
except CSVImportError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
try:
|
||||
result = await persist_pie(
|
||||
session, pie,
|
||||
portfolio_name=portfolio_name,
|
||||
currency=currency,
|
||||
)
|
||||
except Exception as e:
|
||||
# Roll back; surface a clean error
|
||||
await session.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Persist failed: {e}")
|
||||
|
||||
return {
|
||||
"portfolio_id": result.portfolio_id,
|
||||
"snapshot_id": result.snapshot_id,
|
||||
"portfolio_name": result.portfolio_name,
|
||||
"is_new_portfolio": result.is_new_portfolio,
|
||||
"positions": result.positions_written,
|
||||
"unmapped": result.unmapped_slices,
|
||||
"invested": pie.invested,
|
||||
"value": pie.value,
|
||||
"result": pie.result,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/portfolios")
|
||||
async def portfolios(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
as_: str | None = Query(default=None, alias="as"),
|
||||
):
|
||||
rows: list[PortfolioSummary] = []
|
||||
for p in (await session.execute(select(Portfolio))).scalars().all():
|
||||
snap = (await session.execute(
|
||||
select(PortfolioSnapshot)
|
||||
.where(PortfolioSnapshot.portfolio_id == p.id)
|
||||
.order_by(desc(PortfolioSnapshot.snapshot_at))
|
||||
.limit(1)
|
||||
)).scalar_one_or_none()
|
||||
positions: list = []
|
||||
if snap is not None:
|
||||
pos = (await session.execute(
|
||||
select(Position).where(Position.snapshot_id == snap.id)
|
||||
.order_by(desc(
|
||||
(Position.quantity * Position.current_price).label("v")
|
||||
))
|
||||
)).scalars().all()
|
||||
positions = [
|
||||
{"ticker": x.ticker, "name": x.name, "quantity": x.quantity,
|
||||
"average_price": x.average_price, "current_price": x.current_price,
|
||||
"ppl": x.ppl,
|
||||
"ppl_pct": (
|
||||
(x.current_price - x.average_price) / x.average_price * 100
|
||||
if x.average_price and x.current_price else None
|
||||
)}
|
||||
for x in pos
|
||||
]
|
||||
raw = (snap.raw_json or {}) if snap else {}
|
||||
inv = raw.get("investments") or {}
|
||||
rows.append(PortfolioSummary(
|
||||
name=p.name, currency=p.currency,
|
||||
snapshot_at=snap.snapshot_at if snap else None,
|
||||
total_value=snap.total_value if snap else None,
|
||||
cash=snap.cash if snap else None,
|
||||
invested=snap.invested if snap else None,
|
||||
total_cost=inv.get("totalCost"),
|
||||
unrealized_ppl=inv.get("unrealizedProfitLoss"),
|
||||
realized_ppl=inv.get("realizedProfitLoss"),
|
||||
positions=positions,
|
||||
))
|
||||
if as_ == "html":
|
||||
return templates.TemplateResponse(
|
||||
request, "partials/portfolio.html", {"portfolios": rows},
|
||||
)
|
||||
return rows
|
||||
# Portfolio endpoints moved to app/routers/universe.py (Phase G). The
|
||||
# server no longer persists per-user portfolio data; holdings live in
|
||||
# the browser's localStorage and prices come from /api/universe.
|
||||
|
||||
|
||||
# --- Health / ops footer -----------------------------------------------------
|
||||
|
|
@ -509,13 +444,23 @@ async def aggregate_summary(
|
|||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
as_: str | None = Query(default=None, alias="as"),
|
||||
tone: str | None = Query(default=None),
|
||||
):
|
||||
wanted_tone = _resolve_tone_param(tone)
|
||||
row = (await session.execute(
|
||||
select(IndicatorSummary)
|
||||
.where(IndicatorSummary.group_name == AGGREGATE_GROUP_NAME)
|
||||
.where(IndicatorSummary.tone == wanted_tone)
|
||||
.order_by(desc(IndicatorSummary.generated_at))
|
||||
.limit(1)
|
||||
)).scalar_one_or_none()
|
||||
if row is None:
|
||||
row = (await session.execute(
|
||||
select(IndicatorSummary)
|
||||
.where(IndicatorSummary.group_name == AGGREGATE_GROUP_NAME)
|
||||
.order_by(desc(IndicatorSummary.generated_at))
|
||||
.limit(1)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
from app.services.markets import all_statuses
|
||||
statuses = all_statuses()
|
||||
|
|
@ -523,7 +468,7 @@ async def aggregate_summary(
|
|||
if as_ == "html":
|
||||
return templates.TemplateResponse(
|
||||
request, "partials/dashboard_header.html",
|
||||
{"summary": row, "markets": statuses},
|
||||
{"summary": row, "markets": statuses, "tone": wanted_tone},
|
||||
)
|
||||
return {
|
||||
"summary": (
|
||||
|
|
@ -538,6 +483,86 @@ async def aggregate_summary(
|
|||
}
|
||||
|
||||
|
||||
# Market → headline index mapping for the sticky bottom bar. Symbols must
|
||||
# be present in config/default.toml so market_job populates `quotes`.
|
||||
_MARKET_INDEX = {
|
||||
"NYSE": ("^GSPC", "S&P 500"),
|
||||
"LSE": ("^FTSE", "FTSE 100"),
|
||||
# XETRA → Euro Stoxx 50 rather than ^GDAXI: Yahoo's DAX ticker is
|
||||
# patchy via the chart endpoint, and ^STOXX50E is already tracked in
|
||||
# config/default.toml's equity group.
|
||||
"XETRA": ("^STOXX50E", "STOXX 50"),
|
||||
"JPX": ("^N225", "Nikkei 225"),
|
||||
"HKEX": ("^HSI", "Hang Seng"),
|
||||
"SSE": ("000300.SS", "CSI 300"),
|
||||
}
|
||||
|
||||
|
||||
def _fmt_price(p: float | None) -> str:
|
||||
if p is None:
|
||||
return "—"
|
||||
if abs(p) >= 1000:
|
||||
return f"{p:,.0f}"
|
||||
if abs(p) >= 100:
|
||||
return f"{p:,.1f}"
|
||||
return f"{p:,.2f}"
|
||||
|
||||
|
||||
@router.get("/markets-bar", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def markets_bar(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
as_: str | None = Query(default=None, alias="as"),
|
||||
):
|
||||
"""The sticky bottom-bar payload: per-market open/close status with the
|
||||
market's headline index price + 1d change. Refreshed by HTMX every 60s.
|
||||
"""
|
||||
from app.services.markets import all_statuses
|
||||
|
||||
statuses = all_statuses()
|
||||
# Latest quote per headline-index symbol in one query.
|
||||
wanted_syms = [sym for sym, _ in _MARKET_INDEX.values()]
|
||||
sub = (
|
||||
select(Quote.symbol, func.max(Quote.fetched_at).label("mx"))
|
||||
.where(Quote.symbol.in_(wanted_syms))
|
||||
.group_by(Quote.symbol)
|
||||
.subquery()
|
||||
)
|
||||
rows = (await session.execute(
|
||||
select(Quote).join(
|
||||
sub,
|
||||
(Quote.symbol == sub.c.symbol) & (Quote.fetched_at == sub.c.mx),
|
||||
)
|
||||
)).scalars().all()
|
||||
by_sym = {q.symbol: q for q in rows}
|
||||
|
||||
markets: list[dict] = []
|
||||
for st in statuses:
|
||||
sym, label = _MARKET_INDEX.get(st["code"], (None, None))
|
||||
q = by_sym.get(sym) if sym else None
|
||||
idx = None
|
||||
if q is not None and q.price is not None:
|
||||
idx = {
|
||||
"symbol": q.symbol,
|
||||
"label": label,
|
||||
"price_fmt": _fmt_price(q.price),
|
||||
"change_1d_pct": (q.changes or {}).get("1d"),
|
||||
}
|
||||
markets.append({
|
||||
"code": st["code"],
|
||||
"label": st["label"],
|
||||
"open": st["open"],
|
||||
"until_iso": st["until"].isoformat(),
|
||||
"until_hhmm": st["until"].strftime("%H:%M"),
|
||||
"index": idx,
|
||||
})
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request, "partials/markets_bar.html",
|
||||
{"markets": markets},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def health_html(
|
||||
request: Request,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue