read.markets/app/services/openrouter.py
Giorgio Gilestro 40cfb50e37 market-aware AI cadence + incremental log updates
Two changes that together cut OpenRouter spend ~50% and give the daily
log temporal awareness.

1. CadencePolicy (app/services/cadence.py): expensive AI jobs only
   fire hourly during the EU/US active window (Mon-Fri 07-21 UTC).
   Off-hours weekdays throttle to every 4h; weekends to every 12h.
   ai_log_job and indicator_summary_job both consult the policy before
   doing real work; market/news/portfolio ingest jobs stay hourly
   (cheap, no API cost). Skipped runs land in job_runs with status
   'skipped' and the throttle reason in error.

2. Update mode for ai_log_job: when an earlier log exists for the
   current UTC day, it's passed to the model as 'Earlier log from
   today (generated HH:MM UTC)'. The system prompt grows an Update
   mode section instructing the model to revise — not restart — and
   anchor on what has CHANGED since the earlier draft. The TL;DR
   leads with intra-day change when meaningful, the watch list evolves
   rather than restarts. PROMPT_VERSION bumped to 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:17:39 +01:00

440 lines
19 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 = 5
# --- 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.
# Time-horizon discipline
- This is a STRATEGIC log, not a day-trader's read. Treat 1-day moves under \
2% as background noise; mention them only when they break or confirm a \
multi-week trend or are extreme outliers.
- Anchor every claim to multi-week (1m), multi-month (since-anchor), or \
multi-year (1y) changes — not 1d. If the only thing happening is a 1d move, \
omit the paragraph.
- The watch list is for "structural tripwires over the next 1-3 months", not \
"things to watch tomorrow". Each watch item should name a level/threshold \
whose breach would change the regime, not a calendar-date event.
# Rational vs irrational framing
The reader's primary goal is to disconnect rational decisions from market \
irrationality. In every sector or theme paragraph, separately identify:
- The RATIONAL drivers: earnings, real-economy data, monetary policy, \
structural geopolitical shifts, valuation vs fundamentals.
- The IRRATIONAL drivers: positioning, narrative momentum, sentiment \
extremes, concentration, flow-driven moves, options gamma, credit complacency.
When the two diverge — price moving on irrational drivers while fundamentals \
say otherwise, or vice versa — flag the divergence explicitly. Those gaps \
are where the next regime change starts.
# 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.
# System temperature (closing line, mandatory)
Close the log with a single sentence on a line of its own, formatted exactly:
System temperature: [cool|neutral|elevated|hot|extreme] — [one clause naming the 2-3 specific divergences or readings that justify the label]
This is the line a reader who only sees the watch list scrolls down to. Make \
it earn its place: cite real signals (HY OAS, breadth, VIX, valuation, real \
yields), not vibes.
# Update mode (when an earlier log from today is provided)
If the user message includes a section labelled "Earlier log from today \
(generated HH:MM UTC)", treat that as YOUR OWN earlier draft. You are \
UPDATING it for the current data, not starting from scratch.
- Don't restate context that hasn't changed. Anchor on what's moved SINCE \
that timestamp: confirmations, refutations, new emergent patterns.
- The TL;DR should lead with the move since the earlier read when there \
was a meaningful intra-day change ("Since this morning's read, …") — \
otherwise stay regime-level.
- The watch list should evolve: drop items that triggered or settled, add \
items that emerged. Keep items still load-bearing.
- Preserve any insights from the earlier draft that remain valid; sharpen \
or revise the ones that don't. Avoid contradicting yourself silently — if \
you change a stance, name it briefly ("Earlier I read X; with Y now, the \
read shifts to Z")."""
# --- 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_summary_system_prompt(tone: str, analysis: str) -> str:
"""A lean, focused system prompt for the per-indicator-group hourly
summary. INTERPRETATION not description — the reader has the table
next to this paragraph; they don't need numbers recited at them."""
tone_block = _TONE.get(tone.upper(), _TONE["INTERMEDIATE"])
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
return f"""You write a TINY interpretation (≤60 words, 2-3 sentences) \
of ONE indicator group for a strategic markets dashboard.
# What this is for
The reader is looking at the table of numbers right next to your text. \
They can see the values. They CANNOT see the meaning. Your job is to \
**explain what the data means**, not to recite it. Each sentence should be \
a regime-level interpretation, a fundamental driver identification, or a \
cross-indicator implication — not a description of moves.
# Hard constraints
- Plain prose, ONE paragraph. No markdown, no headers, no lists, no labels.
- Open IMMEDIATELY with substance. NEVER start with: "I need to", "I'll", \
"We need to", "We are asked", "Here's", "Let me", "Let's", "Sure", "Looking \
at", "Based on", "Summary:", "The data shows", "First", "To address". No \
meta-commentary at all.
- Cite at most 2-3 specific numbers and ONLY when they anchor an \
interpretation. Don't list moves; explain them.
- Multi-week / multi-month horizon. 1-day moves under 2% are noise — skip.
- No buy/sell language. No predictions. No watch list. No TL;DR. No date \
header. No "system temperature" line — that belongs to the full daily log.
- Output the read directly. Do NOT include phrases like "Example", "Good \
example", "Bad example", "Reference", or any meta-framing of your output.
{tone_block}
{analysis_block}
"""
def build_summary_user_prompt(group_name: str, quotes: list[dict]) -> str:
parts = [
f"# Group: {group_name}",
"Indicators (latest reading + 1d/1m/1y/since-anchor change):",
"```json",
json.dumps(quotes, indent=2, default=str)[:12000],
"```",
"\nWrite the 2-3 sentence read for this group now.",
]
return "\n".join(parts)
def build_aggregate_summary_system_prompt(tone: str, analysis: str) -> str:
"""System prompt for the cross-group aggregate read shown on the dashboard.
Wider lens than a per-group summary — synthesise across all groups."""
tone_block = _TONE.get(tone.upper(), _TONE["INTERMEDIATE"])
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
return f"""You write a single SHORT cross-asset INTERPRETATION (≤80 \
words, 2-4 sentences) for the dashboard header. The reader is glancing — \
give them the meaning of the whole tape, not a recap.
# What this is for
The reader can see every indicator on the dashboard below this paragraph. \
Your job is NOT to summarise the moves. It is to explain what the moves, \
**taken together as a system**, mean: which regime is being signalled, \
which divergences are load-bearing, what fundamental story the cross-asset \
behaviour tells.
# Hard constraints
- Plain prose, ONE paragraph. No markdown, headers, lists, or labels.
- Open IMMEDIATELY with substance. NEVER start with: "I need to", "I'll", \
"We need to", "Here's", "Let me", "Looking at", "Based on", "Sure", "Summary:", \
"The data shows", "Across the board". No meta-commentary.
- Identify the single most important **cross-asset implication**: e.g. \
"rates and credit disagree", "equities outrun fundamentals", "geopolitical \
risk premium is in commodities but not vol". Cite no more than 3 specific \
numbers, and only as anchors for the interpretation.
- Multi-week / multi-month horizon. 1-day moves under 2% are noise.
- No buy/sell language. No predictions of specific levels.
- Output the read directly. Do NOT include phrases like "Example", "Good \
example", "Bad example", "Reference", or any meta-framing of your output.
{tone_block}
{analysis_block}
"""
def build_aggregate_summary_user_prompt(quotes_by_group: dict[str, list[dict]]) -> str:
parts = [
"# All indicator groups (latest readings + change windows)",
"```json",
json.dumps(quotes_by_group, indent=2, default=str)[:20000],
"```",
"\nWrite the cross-asset aggregate read now.",
]
return "\n".join(parts)
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,
previous_log: object | None = None,
) -> str:
"""Assemble the user message from already-fetched-and-persisted data.
If `previous_log` is a StrategicLog from earlier today, it's included
as 'Update mode' context — the model will revise rather than restart."""
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."
)
if previous_log is not None:
gen = getattr(previous_log, "generated_at", None)
ts = gen.strftime("%H:%M UTC") if gen else "earlier today"
parts.append(
f"\n## Earlier log from today (generated {ts})\n"
"Treat this as YOUR OWN earlier draft for today. Update it for\n"
"the current data — don't restate unchanged context. See the\n"
"'Update mode' section of the system prompt for how to handle it.\n"
"```markdown\n"
f"{previous_log.content}\n"
"```"
)
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']}")
task_line = (
"\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."
)
if previous_log is not None:
task_line = (
"\n## Task\nUpdate the earlier log above for the current data. "
"Keep the same structure (date header, TL;DR, sections, watch "
"list, system temperature) but anchor on what has CHANGED since "
"the earlier draft's timestamp. ~800 words. No preamble."
)
parts.append(task_line)
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]