122 lines
4.9 KiB
Go
122 lines
4.9 KiB
Go
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 daily briefing for a personal Discord channel.
|
|
|
|
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):
|
|
|
|
1. A one-paragraph market-wide summary.
|
|
2. A "Crypto" section: brief commentary per asset grounded in the given price/news data.
|
|
3. A "NASDAQ Equities" section: same, grouped sensibly (e.g. by sector) if useful.
|
|
4. A "Futures" section (gold, silver, crude oil).
|
|
5. A "Forex" section covering the given currency pairs.
|
|
6. A short "Watch" section flagging 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.
|
|
|
|
If the user turn includes a section titled "Other AI Analysts' Independent Takes", those are additional perspectives from other AI models given the same data — not ground truth and not your own reasoning. Weigh them alongside the price/news data, note explicitly where you agree or disagree and why, but reach your own independent conclusion. Do not simply restate their conclusions.
|
|
|
|
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.`
|
|
|
|
// BuildUserPrompt renders the fetched result into the user-turn text sent
|
|
// alongside SystemPrompt.
|
|
func BuildUserPrompt(targets config.Targets, result fetch.Result) string {
|
|
var b strings.Builder
|
|
|
|
fmt.Fprintf(&b, "Date: %s\n\n", time.Now().Format("2006-01-02"))
|
|
|
|
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] + "..."
|
|
}
|