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
|
|
@ -138,65 +138,20 @@ class AICall(Base):
|
|||
error: Mapped[str | None] = mapped_column(String(512))
|
||||
|
||||
|
||||
class Portfolio(Base):
|
||||
__tablename__ = "portfolios"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
source: Mapped[str] = mapped_column(String(32), nullable=False) # e.g. "trading212"
|
||||
currency: Mapped[str] = mapped_column(String(8), default="GBP")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
snapshots: Mapped[list["PortfolioSnapshot"]] = relationship(
|
||||
back_populates="portfolio", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (UniqueConstraint("name", name="uq_portfolios_name"),)
|
||||
|
||||
|
||||
class PortfolioSnapshot(Base):
|
||||
__tablename__ = "portfolio_snapshots"
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
portfolio_id: Mapped[int] = mapped_column(ForeignKey("portfolios.id", ondelete="CASCADE"))
|
||||
snapshot_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
total_value: Mapped[float | None] = mapped_column(Float)
|
||||
cash: Mapped[float | None] = mapped_column(Float)
|
||||
invested: Mapped[float | None] = mapped_column(Float)
|
||||
raw_json: Mapped[dict | None] = mapped_column(JSON)
|
||||
|
||||
portfolio: Mapped[Portfolio] = relationship(back_populates="snapshots")
|
||||
positions: Mapped[list["Position"]] = relationship(
|
||||
back_populates="snapshot", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (Index("ix_snap_portfolio_at", "portfolio_id", "snapshot_at"),)
|
||||
|
||||
|
||||
class Position(Base):
|
||||
__tablename__ = "positions"
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
snapshot_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("portfolio_snapshots.id", ondelete="CASCADE")
|
||||
)
|
||||
ticker: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
name: Mapped[str | None] = mapped_column(String(128))
|
||||
quantity: Mapped[float | None] = mapped_column(Float)
|
||||
average_price: Mapped[float | None] = mapped_column(Float)
|
||||
current_price: Mapped[float | None] = mapped_column(Float)
|
||||
ppl: Mapped[float | None] = mapped_column(Float)
|
||||
|
||||
snapshot: Mapped[PortfolioSnapshot] = relationship(back_populates="positions")
|
||||
# Portfolio / PortfolioSnapshot / Position removed in Phase G —
|
||||
# holdings live in the browser, the server stores only the anonymous
|
||||
# ticker universe + public market data.
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""A multi-user account. Phase A wires login + session cookies; phase C
|
||||
adds owner_user_id FKs across portfolios/snapshots/positions so data
|
||||
becomes properly tenant-scoped."""
|
||||
"""A user account. Authentication is e-mail-only via one-time codes
|
||||
(see EmailOTP) — no passwords. Possessing an active session cookie
|
||||
means the user proved control of `email` at session creation time, so
|
||||
a separate `email_verified` flag would be redundant."""
|
||||
__tablename__ = "users"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
tier: Mapped[str] = mapped_column(String(16), default="free") # free | paid | enterprise
|
||||
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
settings_json: Mapped[dict | None] = mapped_column(JSON)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
|
@ -204,6 +159,23 @@ class User(Base):
|
|||
__table_args__ = (UniqueConstraint("email", name="uq_users_email"),)
|
||||
|
||||
|
||||
class EmailOTP(Base):
|
||||
"""One-time codes for email verification. The plaintext 6-digit code is
|
||||
sent in the email; we store an argon2 hash, expiry, attempt count, and
|
||||
a used_at timestamp so a single code can't be reused or brute-forced."""
|
||||
__tablename__ = "email_otps"
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
code_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
purpose: Mapped[str] = mapped_column(String(16), default="signup")
|
||||
|
||||
__table_args__ = (Index("ix_otps_email_created", "email", "created_at"),)
|
||||
|
||||
|
||||
class InstrumentMap(Base):
|
||||
"""Maps T212's tickers/shortnames to Yahoo Finance tickers so we can
|
||||
refresh prices via Yahoo after a user uploads a T212 pie CSV.
|
||||
|
|
@ -231,6 +203,27 @@ class InstrumentMap(Base):
|
|||
)
|
||||
|
||||
|
||||
class TickerUniverse(Base):
|
||||
"""The set of public tickers Cassandra is currently tracking. Populated
|
||||
as the union of all users' holdings, *without user attribution* — once
|
||||
a ticker is in the universe, the row carries no signal as to who put
|
||||
it there. The /api/universe endpoint returns the entire set (gzipped)
|
||||
to every authenticated client, so the request body itself doesn't leak
|
||||
which tickers belong to which user.
|
||||
|
||||
Eviction policy: passive aging. last_referenced_at is bumped whenever
|
||||
the ticker appears in /api/portfolio/parse or /api/analyze. A nightly
|
||||
cron prunes rows older than UNIVERSE_EVICTION_TTL (60 days).
|
||||
"""
|
||||
__tablename__ = "ticker_universe"
|
||||
yahoo_ticker: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
currency: Mapped[str | None] = mapped_column(String(8))
|
||||
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
last_referenced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
__table_args__ = (Index("ix_universe_last_ref", "last_referenced_at"),)
|
||||
|
||||
|
||||
class JobRun(Base):
|
||||
"""One row per scheduled-job invocation; powers /api/health + the ops footer."""
|
||||
__tablename__ = "job_runs"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue