"""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 = 4 # --- 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.""" # --- 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. {tone_block} {analysis_block} # Bad example — describes what happened "S&P +5.2% 1m and Nasdaq +8.8% 1m diverge from FTSE -3.4% and Euro Stoxx \ -2.6%. The US-vs-rest gap is widening." # Good example — interprets what it means "The US-vs-rest equity gap is funded by AI-capex concentration in 7 names; \ the breadth-weighted RSP barely keeps pace with SPY, which is the classic \ late-cycle marker — narrow leadership, not broad recovery. The 5% 1m gap \ between Nasdaq and FTSE is a narrative trade, not a fundamental one." """ 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. {tone_block} {analysis_block} # Bad example — describes "Equities are up, real yields are higher, HY OAS is tight, breadth is \ narrowing." # Good example — interprets "The tape is paying a rising real discount rate (US 10y real +15bp 1m) with \ conviction for AI growth, but credit refuses to confirm and breadth is \ narrowing — that combination is what late-cycle looks like, not pre-crash. \ The risk is not the level but the convergence: if any one of credit, \ breadth, or vol turns, the others will follow fast." """ 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, ) -> 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]