read.markets/app/jobs/market_job.py
Giorgio Gilestro 6e7f57c6b2 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>
2026-05-18 14:16:57 +01:00

82 lines
2.9 KiB
Python

"""Hourly market ingestion: fetch every (symbol, group) defined in TOML
*plus* every ticker in the Phase G shared ticker_universe, inserting one
Quote row per fetch."""
from __future__ import annotations
import asyncio
import httpx
from app.config import get_settings, load_groups
from app.db import utcnow
from app.jobs._helpers import job_lifecycle, log
from app.models import Quote
from app.services.market import fetch
from app.services.ticker_universe import get_all_tickers
async def run() -> None:
async with job_lifecycle("market_job") as (session, run):
if run.status == "skipped":
return
s = get_settings()
groups = load_groups(s.BASELINE_TOML, s.PORTFOLIO_TOML)
anchor = s.CASSANDRA_ANCHOR_DATE or None
# Build the (group, symbol, label, note) work list from config TOML.
items_flat: list[tuple[str, str, str, str]] = [
(group, sym, lab, note)
for group, items in groups.items()
for sym, lab, note in items
]
configured_syms = {sym for _, sym, _, _ in items_flat}
# Phase G: extend with anything in ticker_universe that isn't
# already covered by config. These land under group_name="universe"
# — the /api/universe endpoint reads the latest quote per symbol
# regardless of group.
universe_tickers = await get_all_tickers(session)
for t in universe_tickers:
if t not in configured_syms:
items_flat.append(("universe", t, t, ""))
async with httpx.AsyncClient(follow_redirects=True) as client:
tasks = [
fetch(client, sym, lab, note, anchor)
for _, sym, lab, note in items_flat
]
# Run in parallel but bounded — Yahoo can throttle if we hammer.
sem = asyncio.Semaphore(16)
async def bounded(t):
async with sem:
return await t
quotes = await asyncio.gather(*(bounded(t) for t in tasks))
now = utcnow()
for (group, _sym, _lab, _note), q in zip(items_flat, quotes):
session.add(Quote(
symbol=q.symbol,
source=q.source,
label=q.label,
group_name=group,
price=q.price,
currency=q.currency,
as_of=q.as_of,
changes=q.changes or None,
# Truncate to the column's 255-char limit. Some providers
# return verbose redirect chains that blow the limit.
error=(q.error[:250] if q.error else None),
fetched_at=now,
))
await session.commit()
run.items_written = len(quotes)
log.info(
"market_job.done",
count=len(quotes),
configured=len(configured_syms),
universe=len(universe_tickers),
)
if __name__ == "__main__":
asyncio.run(run())