Files
2026-09-11 09:54:49 +08:00

201 lines
12 KiB
Go

package analyze
import (
"fmt"
"sort"
"strings"
"time"
"gitea.konchin.com/service/stonks/internal/config"
"gitea.konchin.com/service/stonks/internal/fetch"
"gitea.konchin.com/service/stonks/internal/report"
)
// 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.
## Other AI analysts
The user turn may include an "Other AI Analysts' Independent Takes" section from other AI CLIs consulted alongside you. Treat it purely as extra opinions to weigh when forming your own view — never as ground truth, and never as something to defer your own judgment to. Only your own recommendations JSON below executes any trades; the other analysts' takes never do, no matter what they say.
## Paper-trading portfolio
The user turn includes a "Portfolio" section: current cash, any open leveraged positions (with entry price, current price, and unrealized P&L), and this run's max leverage. This is a real, persistent paper-trading account that started at a fixed budget — your buy/sell calls in the JSON block below are not just commentary, they actually execute against it at today's fetched prices, and the resulting cash/positions/profit carry forward to the next briefing. Size your requests accordingly:
- Never request more amount_usd than the cash shown as available — requests above it get clamped down to whatever cash remains, so an oversized request just wastes headroom you could have split across other calls.
- Prefer sizing positions sensibly relative to total portfolio value (e.g. don't put most of the account into one call) unless conviction and the data genuinely warrant concentration.
- You can only close a symbol's existing position by issuing the opposite action on it (sell to close a long, buy to close a short) — you cannot add to an existing position or hold two positions in the same symbol at once. If you want to stay in a position, simply don't issue a call for that symbol this run.
- Leverage above 1 amplifies both gains and losses on that position; only request it when conviction is genuinely high, and never above the stated max.
## Memory
The user turn may include a "Your Memory" section — freeform notes you wrote in a previous run to help future-you make better calls (theses on open positions, why past calls were made, patterns you're tracking). After the full briefing, output an updated version of these notes: fold in anything new worth remembering, and drop anything resolved or no longer useful, so it stays a living summary rather than an ever-growing log. Keep it under roughly 300 words. It is fine to leave it unchanged, or to write a fresh one if none existed yet.
After the full briefing above, on its own line, output exactly:
===MEMORY===
followed by your updated memory notes as plain text (no JSON, no code fence).
Then, 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", "amount_usd": 500, "leverage": 1}]}
"action" must be exactly "buy" or "sell". "amount_usd" is the cash you want to commit as margin (before leverage), grounded in the cash actually shown as available in the Portfolio section. "leverage" is the multiplier to apply (1 = no leverage); omit or use 1 unless you have real conviction. Only include symbols from the watchlist you have an actual buy or sell call on — omit any you'd rate hold/neutral, and omit any symbol whose existing position you don't want to close. 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, after the ===MEMORY=== section.`
// 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). portfolio is the paper-trading account's state
// before this run's trades (see internal/portfolio); memory is Claude's
// freeform notes from a previous run, or "" if none exist yet.
func BuildUserPrompt(targets config.Targets, result fetch.Result, previous []PreviousBriefing, portfolio report.PortfolioSnapshot, maxLeverage float64, memory string) string {
var b strings.Builder
fmt.Fprintf(&b, "Date: %s\n\n", time.Now().Format("2006-01-02"))
writePortfolioSection(&b, portfolio, maxLeverage)
if memory != "" {
fmt.Fprintf(&b, "## Your Memory (notes from a previous run — see system prompt for how to use this)\n\n%s\n\n", memory)
}
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 writePortfolioSection(b *strings.Builder, p report.PortfolioSnapshot, maxLeverage float64) {
fmt.Fprintf(b, "## Portfolio\n\n")
fmt.Fprintf(b, "- Cash available: $%.2f\n", p.CashUSD)
fmt.Fprintf(b, "- Total equity (cash + open positions): $%.2f\n", p.EquityUSD)
fmt.Fprintf(b, "- Total profit since inception: $%.2f (%.2f%%)\n", p.TotalProfitUSD, p.TotalProfitPct)
fmt.Fprintf(b, "- Max leverage allowed per call: %.2gx\n", maxLeverage)
if len(p.Positions) == 0 {
b.WriteString("- No open positions.\n")
} else {
b.WriteString("- Open positions:\n")
for _, pos := range p.Positions {
fmt.Fprintf(b, " - %s: %s, entry %.4g, current %.4g, %.2fx leverage, $%.2f margin, unrealized P&L $%.2f\n",
pos.Symbol, pos.Side, pos.EntryPrice, pos.CurrentPrice, pos.Leverage, pos.Margin, pos.UnrealizedPnL)
}
}
b.WriteString("\n")
}
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] + "..."
}