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>
272 lines
11 KiB
Python
272 lines
11 KiB
Python
"""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]
|