initial commit — cassandra v0.1
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>
This commit is contained in:
commit
a10409c02b
61 changed files with 4890 additions and 0 deletions
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
32
app/services/feeds_bootstrap.py
Normal file
32
app/services/feeds_bootstrap.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""On startup, ensure every feed in default.toml/portfolio.toml has a row in
|
||||
the `feeds` table. Existing rows are left untouched so admin overrides
|
||||
(enabled=0 to mute a noisy source) survive restarts."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import load_feeds, get_settings
|
||||
from app.models import Feed
|
||||
|
||||
|
||||
async def bootstrap_feeds(session: AsyncSession) -> int:
|
||||
s = get_settings()
|
||||
declared = load_feeds(s.BASELINE_TOML, s.PORTFOLIO_TOML)
|
||||
existing = {
|
||||
(f.category, f.name): f
|
||||
for f in (await session.execute(select(Feed))).scalars().all()
|
||||
}
|
||||
inserted = 0
|
||||
for category, items in declared.items():
|
||||
for name, url in items:
|
||||
key = (category, name)
|
||||
if key in existing:
|
||||
# Refresh URL if it changed in TOML; preserve enabled/failures.
|
||||
if existing[key].url != url:
|
||||
existing[key].url = url
|
||||
continue
|
||||
session.add(Feed(category=category, name=name, url=url))
|
||||
inserted += 1
|
||||
await session.commit()
|
||||
return inserted
|
||||
285
app/services/market.py
Normal file
285
app/services/market.py
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
"""Market data fetchers — Yahoo Finance (no auth) and FRED (key required).
|
||||
|
||||
Ported from /home/gg/ownCloud/Family/Finances/Wealth/market_pulse.py.
|
||||
Logic preserved verbatim where possible; HTTP switched to httpx.AsyncClient.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
YAHOO_CHART = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
|
||||
FRED_API = "https://api.stlouisfed.org/fred/series/observations"
|
||||
UA = {"User-Agent": "Mozilla/5.0 (cassandra) Python/httpx"}
|
||||
|
||||
|
||||
# --- In-flight data shape (services return these; jobs persist to DB) --------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Quote:
|
||||
symbol: str
|
||||
source: str
|
||||
label: str
|
||||
note: str
|
||||
price: float | None
|
||||
currency: str | None
|
||||
as_of: str | None
|
||||
changes: dict[str, float | None] = field(default_factory=dict)
|
||||
price_base: float | None = None
|
||||
currency_base: str | None = None
|
||||
anchor_date: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def _pct(old: float | None, new: float | None) -> float | None:
|
||||
if old is None or new is None or old == 0:
|
||||
return None
|
||||
return (new - old) / old * 100.0
|
||||
|
||||
|
||||
def _parse_date(s: str) -> datetime:
|
||||
return datetime.strptime(s, "%Y-%m-%d")
|
||||
|
||||
|
||||
def _yahoo_range_covering(anchor: str | None) -> str:
|
||||
if not anchor:
|
||||
return "1y"
|
||||
days = (datetime.now(timezone.utc).date() - _parse_date(anchor).date()).days
|
||||
if days <= 360:
|
||||
return "1y"
|
||||
if days <= 1800:
|
||||
return "5y"
|
||||
if days <= 3600:
|
||||
return "10y"
|
||||
return "max"
|
||||
|
||||
|
||||
# --- Fetchers -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def fetch_yahoo(
|
||||
client: httpx.AsyncClient,
|
||||
symbol: str,
|
||||
label: str,
|
||||
note: str,
|
||||
anchor: str | None = None,
|
||||
) -> Quote:
|
||||
"""Latest close + %1d / %1m / %1y / (optional) %anchor from Yahoo's chart endpoint."""
|
||||
try:
|
||||
r = await client.get(
|
||||
YAHOO_CHART.format(symbol=symbol),
|
||||
params={"interval": "1d", "range": _yahoo_range_covering(anchor),
|
||||
"includePrePost": "false"},
|
||||
headers=UA,
|
||||
timeout=15,
|
||||
)
|
||||
r.raise_for_status()
|
||||
result = r.json()["chart"]["result"]
|
||||
if not result:
|
||||
raise ValueError("empty result")
|
||||
res = result[0]
|
||||
meta = res["meta"]
|
||||
price = meta.get("regularMarketPrice")
|
||||
prev_session = meta.get("previousClose")
|
||||
timestamps = res.get("timestamp") or []
|
||||
closes = (res.get("indicators", {}).get("quote") or [{}])[0].get("close") or []
|
||||
series = [(t, c) for t, c in zip(timestamps, closes) if c is not None]
|
||||
if not series:
|
||||
raise ValueError("no closes in series")
|
||||
last_ts = series[-1][0]
|
||||
if prev_session is None and len(series) >= 2:
|
||||
prev_session = series[-2][1]
|
||||
chg_1m = _pct(series[-22][1], price) if len(series) >= 22 else None
|
||||
chg_1y = _pct(series[0][1], price) if len(series) >= 2 else None
|
||||
changes: dict[str, float | None] = {
|
||||
"1d": _pct(prev_session, price),
|
||||
"1m": chg_1m,
|
||||
"1y": chg_1y,
|
||||
}
|
||||
anchor_used: str | None = None
|
||||
if anchor:
|
||||
anchor_ts = int(_parse_date(anchor).replace(tzinfo=timezone.utc).timestamp())
|
||||
anchor_close = next((c for t, c in series if t >= anchor_ts), None)
|
||||
anchor_actual_ts = next((t for t, c in series if t >= anchor_ts), None)
|
||||
changes["anchor"] = _pct(anchor_close, price)
|
||||
if anchor_actual_ts:
|
||||
anchor_used = datetime.fromtimestamp(
|
||||
anchor_actual_ts, timezone.utc
|
||||
).strftime("%Y-%m-%d")
|
||||
return Quote(
|
||||
symbol=symbol,
|
||||
source="yahoo",
|
||||
label=label,
|
||||
note=note,
|
||||
price=price,
|
||||
currency=meta.get("currency"),
|
||||
as_of=datetime.fromtimestamp(last_ts, timezone.utc).strftime("%Y-%m-%d"),
|
||||
changes=changes,
|
||||
anchor_date=anchor_used,
|
||||
)
|
||||
except Exception as e:
|
||||
return Quote(symbol, "yahoo", label, note, None, None, None, error=str(e))
|
||||
|
||||
|
||||
async def fetch_fred(
|
||||
client: httpx.AsyncClient,
|
||||
symbol: str,
|
||||
label: str,
|
||||
note: str,
|
||||
anchor: str | None = None,
|
||||
) -> Quote:
|
||||
"""Latest value + 1d/1m/1y deltas from a FRED series. Requires FRED_API_KEY."""
|
||||
api_key = get_settings().FRED_API_KEY
|
||||
if not api_key:
|
||||
return Quote(symbol, "fred", label, note, None, None, None,
|
||||
error="FRED_API_KEY not set")
|
||||
try:
|
||||
r = await client.get(
|
||||
FRED_API,
|
||||
params={
|
||||
"series_id": symbol,
|
||||
"api_key": api_key,
|
||||
"file_type": "json",
|
||||
"sort_order": "desc",
|
||||
"limit": 5000 if anchor else 800,
|
||||
},
|
||||
headers=UA,
|
||||
timeout=20,
|
||||
)
|
||||
r.raise_for_status()
|
||||
obs = r.json().get("observations", [])
|
||||
data = [
|
||||
(o["date"], float(o["value"]))
|
||||
for o in obs if o.get("value") not in (".", "", None)
|
||||
]
|
||||
if not data:
|
||||
raise ValueError("no observations")
|
||||
last_date, last_val = data[0]
|
||||
# CPI levels reported as YoY%.
|
||||
if symbol in ("CPIAUCSL", "CPILFESL") and len(data) >= 13:
|
||||
yoy = _pct(data[12][1], last_val)
|
||||
changes: dict[str, float | None] = {"1d": None, "1m": None, "1y": None}
|
||||
anchor_used: str | None = None
|
||||
if anchor:
|
||||
anchor_dt = _parse_date(anchor)
|
||||
anchor_idx = next(
|
||||
(i for i, (d, _) in enumerate(data) if _parse_date(d) <= anchor_dt),
|
||||
None,
|
||||
)
|
||||
if anchor_idx is not None and anchor_idx + 12 < len(data):
|
||||
yoy_then = _pct(data[anchor_idx + 12][1], data[anchor_idx][1])
|
||||
changes["anchor"] = (yoy or 0) - (yoy_then or 0)
|
||||
anchor_used = anchor
|
||||
return Quote(symbol, "fred", label, note, yoy, "%", last_date,
|
||||
changes=changes, anchor_date=anchor_used)
|
||||
|
||||
last_dt = _parse_date(last_date)
|
||||
|
||||
def find_back(min_days: int) -> float | None:
|
||||
for d, v in data[1:]:
|
||||
if (last_dt - _parse_date(d)).days >= min_days:
|
||||
return v
|
||||
return None
|
||||
|
||||
prev_val = data[1][1] if len(data) >= 2 else None
|
||||
changes = {
|
||||
"1d": _pct(prev_val, last_val),
|
||||
"1m": _pct(find_back(28), last_val),
|
||||
"1y": _pct(find_back(360), last_val),
|
||||
}
|
||||
anchor_used = None
|
||||
if anchor:
|
||||
anchor_dt = _parse_date(anchor)
|
||||
anchor_obs = next(
|
||||
((d, v) for d, v in data if _parse_date(d) <= anchor_dt), None
|
||||
)
|
||||
if anchor_obs:
|
||||
changes["anchor"] = _pct(anchor_obs[1], last_val)
|
||||
anchor_used = anchor_obs[0]
|
||||
return Quote(
|
||||
symbol=symbol, source="fred", label=label, note=note,
|
||||
price=last_val, currency=None, as_of=last_date,
|
||||
changes=changes, anchor_date=anchor_used,
|
||||
)
|
||||
except Exception as e:
|
||||
return Quote(symbol, "fred", label, note, None, None, None, error=str(e))
|
||||
|
||||
|
||||
# --- Source registry ----------------------------------------------------------
|
||||
|
||||
FetcherFn = Callable[..., "Quote"]
|
||||
SOURCES: dict[str, FetcherFn] = {"yahoo": fetch_yahoo, "FRED": fetch_fred}
|
||||
|
||||
|
||||
def parse_symbol(symbol: str) -> tuple[FetcherFn, str]:
|
||||
if ":" in symbol:
|
||||
prefix, _, ident = symbol.partition(":")
|
||||
if prefix in SOURCES:
|
||||
return SOURCES[prefix], ident
|
||||
return SOURCES["yahoo"], symbol
|
||||
|
||||
|
||||
async def fetch(
|
||||
client: httpx.AsyncClient,
|
||||
symbol: str,
|
||||
label: str,
|
||||
note: str,
|
||||
anchor: str | None = None,
|
||||
) -> Quote:
|
||||
fn, ident = parse_symbol(symbol)
|
||||
return await fn(client, ident, label, note, anchor)
|
||||
|
||||
|
||||
# --- Currency normalisation ---------------------------------------------------
|
||||
|
||||
|
||||
async def _get_fx_rate(
|
||||
client: httpx.AsyncClient,
|
||||
from_ccy: str,
|
||||
to_ccy: str,
|
||||
cache: dict[tuple[str, str], float | None],
|
||||
) -> float | None:
|
||||
if from_ccy == to_ccy:
|
||||
return 1.0
|
||||
if from_ccy == "GBp": # LSE pence
|
||||
gbp = await _get_fx_rate(client, "GBP", to_ccy, cache)
|
||||
return None if gbp is None else 0.01 * gbp
|
||||
key = (from_ccy, to_ccy)
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
pair = f"{from_ccy}{to_ccy}=X"
|
||||
try:
|
||||
r = await client.get(
|
||||
YAHOO_CHART.format(symbol=pair),
|
||||
params={"interval": "1d", "range": "5d"},
|
||||
headers=UA, timeout=10,
|
||||
)
|
||||
r.raise_for_status()
|
||||
rate = r.json()["chart"]["result"][0]["meta"].get("regularMarketPrice")
|
||||
cache[key] = rate
|
||||
return rate
|
||||
except Exception:
|
||||
cache[key] = None
|
||||
return None
|
||||
|
||||
|
||||
async def normalise_to_base(
|
||||
client: httpx.AsyncClient, quotes: list[Quote], base: str
|
||||
) -> None:
|
||||
cache: dict[tuple[str, str], float | None] = {}
|
||||
base = base.upper()
|
||||
for q in quotes:
|
||||
if q.price is None or not q.currency:
|
||||
continue
|
||||
rate = await _get_fx_rate(client, q.currency, base, cache)
|
||||
if rate is None:
|
||||
continue
|
||||
q.price_base = q.price * rate
|
||||
q.currency_base = base
|
||||
167
app/services/news.py
Normal file
167
app/services/news.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""RSS feed aggregator + Yahoo per-ticker news.
|
||||
|
||||
Ported from /home/gg/ownCloud/Family/Finances/Wealth/flash_news.py — same
|
||||
parsing, dedupe, and ticker-name resolution logic, async HTTP via httpx.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from email.utils import parsedate_to_datetime
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
UA = {"User-Agent": "Mozilla/5.0 (cassandra) Python/httpx"}
|
||||
ATOM_NS = "{http://www.w3.org/2005/Atom}"
|
||||
DC_NS = "{http://purl.org/dc/elements/1.1/}"
|
||||
YAHOO_NEWS = "https://query1.finance.yahoo.com/v1/finance/search"
|
||||
YAHOO_CHART = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
|
||||
|
||||
_NAME_STOPWORDS = {"plc", "corp", "inc", "ltd", "fund", "etf", "ucits",
|
||||
"class", "shares", "trust", "the", "and", "of"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Headline:
|
||||
when: datetime # tz-aware UTC
|
||||
source: str
|
||||
category: str
|
||||
title: str
|
||||
url: str
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
"""sha1 of normalised title — used as DB UNIQUE."""
|
||||
norm = " ".join(self.title.lower().split())
|
||||
return hashlib.sha1(norm.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _parse_date(s: str | None) -> datetime | None:
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return parsedate_to_datetime(s).astimezone(timezone.utc)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_feed(name: str, category: str, xml_bytes: bytes) -> list[Headline]:
|
||||
try:
|
||||
root = ET.fromstring(xml_bytes)
|
||||
except ET.ParseError:
|
||||
return []
|
||||
out: list[Headline] = []
|
||||
rss_items = root.findall(".//item")
|
||||
if rss_items:
|
||||
for it in rss_items:
|
||||
title = (it.findtext("title") or "").strip()
|
||||
link = (it.findtext("link") or "").strip()
|
||||
pub = it.findtext("pubDate") or it.findtext(f"{DC_NS}date")
|
||||
when = _parse_date(pub) or datetime.now(timezone.utc)
|
||||
if title and link:
|
||||
out.append(Headline(when, name, category, title, link))
|
||||
else:
|
||||
for entry in root.findall(f".//{ATOM_NS}entry"):
|
||||
title = (entry.findtext(f"{ATOM_NS}title") or "").strip()
|
||||
link_el = entry.find(f"{ATOM_NS}link")
|
||||
link = (link_el.get("href") if link_el is not None else "") or ""
|
||||
pub = entry.findtext(f"{ATOM_NS}published") or entry.findtext(f"{ATOM_NS}updated")
|
||||
when = _parse_date(pub) or datetime.now(timezone.utc)
|
||||
if title and link:
|
||||
out.append(Headline(when, name, category, title, link.strip()))
|
||||
return out
|
||||
|
||||
|
||||
async def fetch_feed(
|
||||
client: httpx.AsyncClient, name: str, category: str, url: str
|
||||
) -> list[Headline]:
|
||||
"""Returns headlines on success, empty list on any failure (caller logs)."""
|
||||
r = await client.get(url, headers=UA, timeout=12)
|
||||
r.raise_for_status()
|
||||
return parse_feed(name, category, r.content)
|
||||
|
||||
|
||||
async def _resolve_ticker_name(client: httpx.AsyncClient, ticker: str) -> str:
|
||||
"""Look up the company longName so news search hits headlines that actually
|
||||
mention the company rather than matching the literal ticker string."""
|
||||
try:
|
||||
r = await client.get(
|
||||
YAHOO_CHART.format(symbol=ticker),
|
||||
params={"interval": "1d", "range": "5d"},
|
||||
headers=UA, timeout=8,
|
||||
)
|
||||
r.raise_for_status()
|
||||
meta = r.json()["chart"]["result"][0]["meta"]
|
||||
return meta.get("longName") or meta.get("shortName") or ticker
|
||||
except Exception:
|
||||
return ticker
|
||||
|
||||
|
||||
async def fetch_yahoo_news(
|
||||
client: httpx.AsyncClient,
|
||||
ticker: str,
|
||||
count: int = 10,
|
||||
query_override: str | None = None,
|
||||
) -> list[Headline]:
|
||||
"""Filtered Yahoo per-ticker headlines. Niche UCITS ETFs return empty
|
||||
rather than the generic firehose because of the token-overlap guard.
|
||||
|
||||
If `query_override` is provided (e.g. a name already fetched from
|
||||
Trading 212 instruments), it skips the Yahoo chart-meta round-trip."""
|
||||
query = query_override or await _resolve_ticker_name(client, ticker)
|
||||
tokens = [
|
||||
t.lower() for t in re.split(r"[\s.]+", query)
|
||||
if len(t) >= 3 and t.lower() not in _NAME_STOPWORDS
|
||||
]
|
||||
try:
|
||||
r = await client.get(
|
||||
YAHOO_NEWS,
|
||||
params={"q": query, "newsCount": count, "quotesCount": 0},
|
||||
headers=UA, timeout=10,
|
||||
)
|
||||
r.raise_for_status()
|
||||
items = r.json().get("news", [])
|
||||
out: list[Headline] = []
|
||||
for it in items:
|
||||
title = (it.get("title") or "").strip()
|
||||
link = (it.get("link") or "").strip()
|
||||
if not (title and link):
|
||||
continue
|
||||
if tokens and not any(t in title.lower() for t in tokens):
|
||||
continue
|
||||
ts = it.get("providerPublishTime")
|
||||
when = (
|
||||
datetime.fromtimestamp(ts, timezone.utc) if ts
|
||||
else datetime.now(timezone.utc)
|
||||
)
|
||||
out.append(Headline(when, f"Yahoo:{ticker}", "ticker", title, link))
|
||||
return out
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def dedupe(headlines: list[Headline]) -> list[Headline]:
|
||||
"""URL first, then normalised title — same logic as the prototype."""
|
||||
seen_url: set[str] = set()
|
||||
seen_fp: set[str] = set()
|
||||
out: list[Headline] = []
|
||||
for h in headlines:
|
||||
if h.url in seen_url or h.fingerprint in seen_fp:
|
||||
continue
|
||||
seen_url.add(h.url)
|
||||
seen_fp.add(h.fingerprint)
|
||||
out.append(h)
|
||||
return out
|
||||
|
||||
|
||||
def matches_any(text: str, keywords: list[str]) -> bool:
|
||||
t = text.lower()
|
||||
return any(kw in t for kw in keywords)
|
||||
272
app/services/openrouter.py
Normal file
272
app/services/openrouter.py
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
"""Strategic-log generator — DB-fed, OpenRouter-backed.
|
||||
|
||||
Ported from /home/gg/ownCloud/Family/Finances/Wealth/strategic_log.py. The
|
||||
system prompt is preserved verbatim (the voice we converged on). The user
|
||||
prompt is now built from DB rows, not from subprocess JSON dumps.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
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 = 3
|
||||
|
||||
|
||||
# --- Core: invariant across tone/analysis settings ----------------------------
|
||||
|
||||
_CORE = """You are Cassandra, writing a single daily strategic markets log \
|
||||
for one specific investor. Synthesis, not exposition.
|
||||
|
||||
# Lens
|
||||
- Geopolitics → markets is the primary causal chain. For each sector move, \
|
||||
ask: geopolitical, cyclical, or idiosyncratic. Label it.
|
||||
- Divergences and contradictions are where the information is. Hunt for them.
|
||||
- Absence of expected moves is signal. If the thesis predicted a reaction \
|
||||
that didn't happen, that's more interesting than the reactions that did.
|
||||
- Compare live readings against any reference snapshots provided.
|
||||
|
||||
# Multi-source news
|
||||
- When state-aligned outlets (Xinhua, China Daily, RT) and Western outlets \
|
||||
cover the same event, read the gap in framing — that's the data.
|
||||
- News matters only insofar as it changes a market read. Color without \
|
||||
implications is filler.
|
||||
|
||||
# Structure
|
||||
- One-line date header + any anchor framing (e.g. "Week 11 since Hormuz").
|
||||
- Immediately after the date header — with **nothing** in between — write a \
|
||||
TL;DR. Format it as:
|
||||
|
||||
## TL;DR
|
||||
|
||||
One concise paragraph of 2-3 sentences, **≤60 words total**, naming the \
|
||||
single most important read or divergence of the day with concrete numbers. \
|
||||
This is what a reader who only has 10 seconds sees. Don't waste it on the \
|
||||
weather or generic context.
|
||||
|
||||
- Then 4-6 paragraphs, each anchored on a sleeve, sector, or theme. Concrete \
|
||||
numbers in every paragraph. No section over ~150 words.
|
||||
- One paragraph synthesising the news flow into a market read.
|
||||
- End with a watch list: 3-5 specific items to track in the next week, \
|
||||
each one sentence.
|
||||
|
||||
# Discipline
|
||||
- No emojis, no marketing language, no "concerning" or "unprecedented" \
|
||||
without a specific number behind it.
|
||||
- Concrete > vague. "AMD +113% since the anchor" beats "AI stocks up sharply".
|
||||
- Distinguish "the thesis predicted X and X happened" from "the thesis \
|
||||
predicted X and X did not happen". Both are useful; conflating them is not.
|
||||
- Don't repeat the same point in different words across paragraphs.
|
||||
- No buy/sell recommendations. Triggers are pre-set elsewhere; your job is \
|
||||
to report whether reality is confirming, modifying, or refuting the thesis."""
|
||||
|
||||
|
||||
# --- 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.""",
|
||||
|
||||
"INTERMEDIATE": """# Audience: intermediate
|
||||
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.""",
|
||||
|
||||
"PRO": """# Audience: professional
|
||||
Assume institutional vocabulary. Use dense market shorthand freely. Don't \
|
||||
define standard terms. Target ~800 words. Density of insight > readability.""",
|
||||
}
|
||||
|
||||
|
||||
# --- Analysis: forward-vs-backward focus -------------------------------------
|
||||
|
||||
_ANALYSIS: dict[str, str] = {
|
||||
"DRY": """# Analysis style: dry
|
||||
Report what happened. Identify divergences and contradictions. Compare to \
|
||||
references. Do not speculate on what comes next. Forward-looking statements \
|
||||
are limited to "what would invalidate the read" — never "we expect X to \
|
||||
happen". The watch list contains items to monitor, not predictions.""",
|
||||
|
||||
"SPECULATIVE": """# Analysis style: speculative
|
||||
Report what happened, then explicitly explore forward scenarios. For each \
|
||||
significant sector or theme, sketch a 1-4 week scenario set: the base case \
|
||||
(what the data suggests), a contrarian case (what would invalidate it), and \
|
||||
what tape signal would tip you from one to the other. Be explicit about \
|
||||
uncertainty — say "the base case is" not "X will happen". The watch list is \
|
||||
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"])
|
||||
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
|
||||
return "\n\n".join([_CORE, tone_block, analysis_block])
|
||||
|
||||
|
||||
# Backwards-compat: a default-composed SYSTEM_PROMPT for tests / callers that
|
||||
# don't yet pass tone/analysis. New callers should call build_system_prompt().
|
||||
SYSTEM_PROMPT = build_system_prompt("INTERMEDIATE", "SPECULATIVE")
|
||||
|
||||
|
||||
# --- Chat-mode overrides (sidebar on /log) -----------------------------------
|
||||
|
||||
_CHAT_OVERRIDES = """# Chat mode (overrides the log-structure rules above)
|
||||
You are NOT writing a daily log right now. The user is asking a specific
|
||||
question via the chat sidebar.
|
||||
- Forget the date header, TL;DR, sectional structure, and watch list. Just answer.
|
||||
- Typical response: 200-400 words. Longer only if the question genuinely
|
||||
warrants it.
|
||||
- Cite specific numbers and named headlines from the reference materials
|
||||
below whenever relevant. If a number isn't in the context, don't invent it.
|
||||
- If a question is outside the provided context (e.g. asking about a stock or
|
||||
event not in the data), say so plainly rather than speculating from prior
|
||||
knowledge.
|
||||
- No buy/sell recommendations. If asked, redirect to thesis and scenarios.
|
||||
- Keep the same audience and analysis discipline established above."""
|
||||
|
||||
|
||||
def build_chat_system_prompt(
|
||||
tone: str,
|
||||
analysis: str,
|
||||
*,
|
||||
log_content: str | None,
|
||||
log_generated_at: datetime | None,
|
||||
quotes_by_group: dict[str, list[dict]],
|
||||
headlines: list[dict],
|
||||
reference_line: str | None = None,
|
||||
) -> str:
|
||||
"""Composed system prompt for the /log chat sidebar. Carries the user's
|
||||
chosen tone + analysis style and inlines the latest log + market data +
|
||||
headlines as reference material the model can cite from."""
|
||||
parts = [build_system_prompt(tone, analysis), "", _CHAT_OVERRIDES, ""]
|
||||
if reference_line:
|
||||
parts.append(f"# Doc reference snapshot\n{reference_line}\n")
|
||||
if log_content:
|
||||
ts = log_generated_at.strftime("%Y-%m-%d %H:%M UTC") if log_generated_at else "n/a"
|
||||
parts.append(f"# Latest strategic log (generated {ts})\n\n{log_content}\n")
|
||||
parts.append("# Live market data")
|
||||
parts.append(
|
||||
"```json\n" + json.dumps(quotes_by_group, indent=2, default=str)[:25000] + "\n```"
|
||||
)
|
||||
parts.append("# Recent headlines (last 24h, thesis-filtered top 50)")
|
||||
for h in headlines[:50]:
|
||||
parts.append(f"- [{h['source']}] {h['title']}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogResult:
|
||||
content: str
|
||||
model: str
|
||||
prompt_tokens: int | None
|
||||
completion_tokens: int | None
|
||||
cost_usd: float | None
|
||||
|
||||
|
||||
def build_user_prompt(
|
||||
*,
|
||||
today: datetime,
|
||||
anchor: str | None,
|
||||
quotes_by_group: dict[str, list[dict]],
|
||||
headlines_by_bucket: dict[str, list[dict]],
|
||||
reference_line: str | None = None,
|
||||
) -> str:
|
||||
"""Assemble the user message from already-fetched-and-persisted data."""
|
||||
parts = [f"# Strategic log request — {today.strftime('%Y-%m-%d')}"]
|
||||
if anchor:
|
||||
parts.append(f"Anchor reference date: {anchor}")
|
||||
if reference_line:
|
||||
parts.append(
|
||||
"\n## Reference snapshot (when the macro thesis was authored)"
|
||||
f"\n{reference_line}\nCompare live readings against it."
|
||||
)
|
||||
parts.append("\n## Live market data (per group)")
|
||||
parts.append("```json\n" + json.dumps(quotes_by_group, indent=2, default=str) + "\n```")
|
||||
parts.append("\n## News flow (last 24h, filtered by bucket)")
|
||||
for label, items in headlines_by_bucket.items():
|
||||
if not items:
|
||||
continue
|
||||
parts.append(f"\n### {label.upper()}")
|
||||
for h in items[:30]:
|
||||
parts.append(f"- [{h['when'][:16].replace('T',' ')}] [{h['source']}] {h['title']}")
|
||||
parts.append(
|
||||
"\n## Task\nWrite the daily strategic log in ~800 words, following "
|
||||
"the discipline in the system prompt. No preamble; begin directly "
|
||||
"with the date header."
|
||||
)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@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(
|
||||
client: httpx.AsyncClient,
|
||||
messages: list[dict],
|
||||
model: str,
|
||||
max_tokens: int = 4000,
|
||||
) -> LogResult:
|
||||
s = get_settings()
|
||||
if not s.OPENROUTER_API_KEY:
|
||||
raise RuntimeError("OPENROUTER_API_KEY not set")
|
||||
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},
|
||||
timeout=180,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
msg = data["choices"][0]["message"]
|
||||
# Some providers return null content + populated `reasoning` for thinking
|
||||
# models, or null content when finish_reason=length cut off the response.
|
||||
content = msg.get("content") or msg.get("reasoning")
|
||||
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})"
|
||||
)
|
||||
usage = data.get("usage") or {}
|
||||
return LogResult(
|
||||
content=content,
|
||||
model=model,
|
||||
prompt_tokens=usage.get("prompt_tokens"),
|
||||
completion_tokens=usage.get("completion_tokens"),
|
||||
cost_usd=usage.get("cost") or usage.get("total_cost"),
|
||||
)
|
||||
|
||||
|
||||
def month_window() -> tuple[datetime, datetime]:
|
||||
"""[start, now] in UTC for the current calendar month."""
|
||||
now = datetime.now(timezone.utc)
|
||||
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
return start, now
|
||||
|
||||
|
||||
def month_start() -> datetime:
|
||||
return month_window()[0]
|
||||
69
app/services/trading212.py
Normal file
69
app/services/trading212.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Trading 212 read-only client.
|
||||
|
||||
Ported from /home/gg/ownCloud/Family/Finances/Wealth/trading212.py — same Basic
|
||||
auth scheme, same endpoints, async via httpx. Live endpoint only (demo would
|
||||
need a separate key pair).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
LIVE_BASE = "https://live.trading212.com/api/v0"
|
||||
|
||||
|
||||
class Trading212:
|
||||
def __init__(self, api_key: str | None = None, secret_key: str | None = None):
|
||||
s = get_settings()
|
||||
api_key = api_key or s.API_KEY
|
||||
secret_key = secret_key or s.SECRET_KEY
|
||||
if not api_key or not secret_key:
|
||||
raise RuntimeError("Trading 212 API_KEY/SECRET_KEY missing in env")
|
||||
token = base64.b64encode(f"{api_key}:{secret_key}".encode()).decode()
|
||||
self.headers = {
|
||||
"Authorization": f"Basic {token}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "cassandra/0.1",
|
||||
}
|
||||
|
||||
async def _request(
|
||||
self, client: httpx.AsyncClient, method: str, path: str, **kwargs
|
||||
):
|
||||
url = f"{LIVE_BASE}{path}"
|
||||
r = await client.request(method, url, headers=self.headers, timeout=30, **kwargs)
|
||||
if r.status_code == 429:
|
||||
reset = float(r.headers.get("x-ratelimit-reset", "1"))
|
||||
wait = max(1.0, reset - time.time())
|
||||
await asyncio.sleep(wait)
|
||||
r = await client.request(method, url, headers=self.headers, timeout=30, **kwargs)
|
||||
r.raise_for_status()
|
||||
if not r.content:
|
||||
return None
|
||||
ctype = r.headers.get("content-type", "")
|
||||
return r.json() if "json" in ctype else r.text
|
||||
|
||||
async def summary(self, client: httpx.AsyncClient):
|
||||
return await self._request(client, "GET", "/equity/account/summary")
|
||||
|
||||
async def cash(self, client: httpx.AsyncClient):
|
||||
return await self._request(client, "GET", "/equity/account/cash")
|
||||
|
||||
async def positions(self, client: httpx.AsyncClient):
|
||||
return await self._request(client, "GET", "/equity/portfolio")
|
||||
|
||||
async def position(self, client: httpx.AsyncClient, ticker: str):
|
||||
return await self._request(client, "GET", f"/equity/portfolio/{ticker}")
|
||||
|
||||
async def orders(self, client: httpx.AsyncClient):
|
||||
return await self._request(client, "GET", "/equity/orders")
|
||||
|
||||
async def instruments(self, client: httpx.AsyncClient):
|
||||
"""Full catalogue of tradable instruments. Used to enrich position
|
||||
rows with human-readable names."""
|
||||
return await self._request(client, "GET", "/equity/metadata/instruments")
|
||||
Loading…
Add table
Add a link
Reference in a new issue