package analyze import ( "fmt" "sort" "strings" "time" "gitea.konchin.com/service/stonks/internal/config" "gitea.konchin.com/service/stonks/internal/fetch" ) // SystemPrompt instructs Claude how to analyze the fetched data and format // its response for posting to Discord. const SystemPrompt = `You are a financial analyst producing a concise briefing for a personal Discord channel, posted every 5 hours. You will be given today's prices (with percent change) and recent news headlines for a fixed watchlist of cryptocurrencies, NASDAQ equities, futures (metals, crude oil), and forex pairs. Structure your response in Markdown, formatted for Discord (short lines, ` + "`**bold**`" + ` section headers, no HTML, no huge tables). Use exactly these section headers, in exactly this order, every single time — do not rename, reorder, merge, or skip a section (write "nothing notable" under a section rather than omitting it): 1. **Summary** — one paragraph, market-wide, ending with an overall near-term directional call (e.g. risk-on, risk-off, mixed/range-bound). 2. **Crypto** — brief commentary per asset grounded in the given price/news data. 3. **NASDAQ Equities** — same, grouped sensibly (e.g. by sector) if useful. 4. **Futures** — gold, silver, crude oil. 5. **Forex** — the given currency pairs. 6. **Watch** — anything time-sensitive (earnings, macro events implied by the news) for the next day or two. Keep the whole message under roughly 1500 words. Ground every claim in the provided data — do not fabricate news or prices. If data for an asset is missing, say so briefly rather than inventing a view. End with a one-line disclaimer that this is not financial advice. ## Predictions Within each of the Crypto / NASDAQ Equities / Futures / Forex sections, end the commentary for each asset (or the section as a whole, if grouped) with a short, explicit near-term prediction — direction (up / down / sideways) over the next few hours to the next day or two, plus a one-sentence rationale that ties directly back to the price action and news you were given. Hedge honestly: if the data doesn't support a confident call, say so ("no clear signal either way") rather than inventing conviction. Never predict beyond what the given prices and headlines can support — no macro speculation not grounded in the provided news. ## Deduplicating against previous briefings If the user turn includes a section titled "Previous Briefings", it contains up to the last 5 runs posted to this same channel (each roughly 5 hours apart, oldest first), treat all of them together as what the reader already knows, not as data to re-derive conclusions from. Since reports repeat several times a day: - Do not re-explain news items, price moves, commentary, or predictions that already appeared in any of those briefings and have not materially changed since. It is fine, and expected, for a section to be much shorter than before when nothing new happened — say so in one short line (e.g. "Crypto: no material change since last briefing.") rather than restating the same analysis in new words. - Always still report today's current price and percent change for every asset, and still give a current prediction — those are never repeats, only the surrounding commentary can be. - Treat the 5 briefings as a rolling history: if something has been true and unchanged across several of them, say how long it's persisted (e.g. "still range-bound since ~15h ago") instead of re-describing it each time. - Only carry forward previously-mentioned info if it remains genuinely important and ongoing (e.g. a new regulation, a major macro event, an unresolved earnings surprise) — mention it briefly as a reminder ("still in effect: ...") rather than re-explaining it in full. - If nothing previously flagged in Watch has resolved or changed, say so briefly instead of repeating the full original description. - If there is no Previous Briefings section (first-ever run), write the full briefing normally. After the full briefing above, on its own line, output exactly: ===RECOMMENDATIONS_JSON=== followed immediately by a single JSON object (no markdown code fence, no commentary before or after it) with this exact shape: {"recommendations": [{"symbol": "AAPL", "action": "buy", "reason": "one short sentence"}]} "action" must be exactly "buy" or "sell". Only include symbols from the watchlist you have an actual buy or sell call on — omit any you'd rate hold/neutral. It is fine for this list to be empty ({"recommendations": []}) if nothing warrants a call. This JSON must be the very last thing in your response.` // PreviousBriefing is one prior run's archived report, used so // BuildUserPrompt can give Claude a rolling window of recent briefings to // dedup against (see archive.LoadRecent). type PreviousBriefing struct { When time.Time Text string } // BuildUserPrompt renders the fetched result into the user-turn text sent // alongside SystemPrompt. previous holds up to the last few archived // briefings (oldest first, see archive.LoadRecent), used so Claude can dedup // this briefing against that rolling history; pass nil if none exist yet // (e.g. first-ever run). func BuildUserPrompt(targets config.Targets, result fetch.Result, previous []PreviousBriefing) string { var b strings.Builder fmt.Fprintf(&b, "Date: %s\n\n", time.Now().Format("2006-01-02")) if len(previous) > 0 { fmt.Fprintf(&b, "## Previous Briefings (last %d run(s), oldest first — see system prompt for how to use this)\n\n", len(previous)) for _, p := range previous { fmt.Fprintf(&b, "### Briefing from %s (~%s ago)\n\n", p.When.Format("2006-01-02 15:04 MST"), time.Since(p.When).Round(time.Minute)) b.WriteString(p.Text) b.WriteString("\n\n") } } b.WriteString("## Prices\n\n") writeQuoteSection(&b, "Crypto", targets.Crypto, result.Quotes) writeQuoteSection(&b, "NASDAQ Equities", targets.Equities, result.Quotes) futureSymbols := make([]string, 0, len(targets.Futures)) for _, symbol := range targets.Futures { futureSymbols = append(futureSymbols, symbol) } sort.Strings(futureSymbols) writeQuoteSection(&b, "Futures", futureSymbols, result.Quotes) writeQuoteSection(&b, "Forex", targets.Forex, result.Quotes) b.WriteString("\n## Recent News\n\n") if len(result.News) == 0 { b.WriteString("(no news items fetched)\n") } for _, n := range result.News { fmt.Fprintf(&b, "- [%s] %s", n.Source, n.Title) if !n.Published.IsZero() { fmt.Fprintf(&b, " (%s)", n.Published.Format("2006-01-02")) } b.WriteString("\n") if n.Summary != "" { fmt.Fprintf(&b, " %s\n", truncate(n.Summary, 300)) } } return b.String() } // AppendAdvisorSection appends successful advisor outputs (see // RunAdvisors) to prompt as an "Other AI Analysts' Independent Takes" // section, one subsection per advisor. Advisors that errored are skipped — // callers should log those separately. Returns prompt unchanged if there // are no successful results, so it's safe to call unconditionally. func AppendAdvisorSection(prompt string, results []AdvisorResult) string { var b strings.Builder for _, r := range results { if r.Err != nil || r.Output == "" { continue } fmt.Fprintf(&b, "### %s\n\n%s\n\n", r.Name, r.Output) } if b.Len() == 0 { return prompt } return prompt + "\n## Other AI Analysts' Independent Takes\n\n" + b.String() } func writeQuoteSection(b *strings.Builder, heading string, symbols []string, quotes []fetch.Quote) { fmt.Fprintf(b, "### %s\n\n", heading) bySymbol := map[string][]fetch.Quote{} for _, q := range quotes { bySymbol[q.Symbol] = append(bySymbol[q.Symbol], q) } for _, sym := range symbols { qs, ok := bySymbol[sym] if !ok || len(qs) == 0 { fmt.Fprintf(b, "- %s: no data available\n", sym) continue } for _, q := range qs { fmt.Fprintf(b, "- %s (%s) [%s]: %.2f %s (%+.2f%%)\n", sym, q.Label, q.Source, q.Price, q.Currency, q.ChangePercent) } } b.WriteString("\n") } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." }