feat: done vibing

This commit is contained in:
ytshih committed 2026-09-05 09:55:24 +08:00
1 parent 3de31a57c3
commit 4352fd1ad1
27 files changed
+1922 -13

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
package analyze
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"sync"
"github.com/ytshih/stonks/internal/config"
)
// AdvisorPreamble is prepended to the shared data prompt when consulting an
// advisor CLI (multi-agent mode) — advisors get a simpler ask than Claude's
// SystemPrompt: an independent take, no recommendations-JSON contract.
const AdvisorPreamble = `You are a financial analyst. Given the following market prices and recent news for a fixed watchlist (crypto, NASDAQ equities, futures, forex), give a concise independent investment analysis: what stands out, and any assets you'd flag as a buy or sell with a one-sentence reason each. Plain text, under 400 words.`
// AdvisorResult is one advisor CLI's outcome. Err is set (and Output empty)
// on failure — callers should log it and simply omit this advisor from the
// final prompt, not fail the run.
type AdvisorResult struct {
Name string
Output string
Err error
}
// RunAdvisors runs every configured advisor CLI concurrently, piping
// AdvisorPreamble + dataPrompt to each one's stdin, and returns one result
// per advisor in the same order as cfg.
func RunAdvisors(ctx context.Context, advisors []config.AdvisorConfig, dataPrompt string) []AdvisorResult {
prompt := AdvisorPreamble + "\n\n" + dataPrompt
results := make([]AdvisorResult, len(advisors))
var wg sync.WaitGroup
for i, adv := range advisors {
wg.Add(1)
go func(i int, adv config.AdvisorConfig) {
defer wg.Done()
out, err := runAdvisorCLI(ctx, adv, prompt)
results[i] = AdvisorResult{Name: adv.Name, Output: out, Err: err}
}(i, adv)
}
wg.Wait()
return results
}
func runAdvisorCLI(ctx context.Context, adv config.AdvisorConfig, prompt string) (string, error) {
if _, err := exec.LookPath(adv.Command); err != nil {
return "", fmt.Errorf("command %q not found on PATH: %w", adv.Command, err)
}
cmd := exec.CommandContext(ctx, adv.Command, adv.Args...)
cmd.Stdin = strings.NewReader(prompt)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("%s failed: %w (stderr: %s)", adv.Command, err, lastLine(stderr.String()))
}
out := strings.TrimSpace(stdout.String())
if out == "" {
return "", fmt.Errorf("%s produced no output (stderr: %s)", adv.Command, lastLine(stderr.String()))
}
return out, nil
}
// lastLine returns the last non-empty line of s, for a compact one-line
// error summary out of what can be many lines of CLI log noise.
func lastLine(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
for i := len(lines) - 1; i >= 0; i-- {
if line := strings.TrimSpace(lines[i]); line != "" {
return line
}
}
return ""
}
+40
View File
@@ -0,0 +1,40 @@
// Package analyze turns fetched market data + news into an investment-advice
// report via Claude, using one of two interchangeable modes: "subscription"
// (shells out to the `claude` CLI, billed against a Claude Pro/Max
// subscription) or "api" (calls the Messages API directly, billed per token).
package analyze
import (
"context"
"fmt"
"github.com/ytshih/stonks/internal/config"
)
// Analyzer sends a prompt to Claude and returns the response text.
type Analyzer interface {
Analyze(ctx context.Context, prompt string) (string, error)
}
// Constructor builds an Analyzer from the analyzer config block.
type Constructor func(cfg config.AnalyzerConfig) (Analyzer, error)
var registry = map[string]Constructor{}
// Register makes an analyzer constructor available under mode. Called from
// each mode implementation's init().
func Register(mode string, ctor Constructor) {
if _, exists := registry[mode]; exists {
panic(fmt.Sprintf("analyze: constructor already registered for mode %q", mode))
}
registry[mode] = ctor
}
// Build looks up the constructor for cfg.Mode and invokes it.
func Build(cfg config.AnalyzerConfig) (Analyzer, error) {
ctor, ok := registry[cfg.Mode]
if !ok {
return nil, fmt.Errorf("analyze: no analyzer registered for mode %q", cfg.Mode)
}
return ctor(cfg)
}
+77
View File
@@ -0,0 +1,77 @@
package analyze
import (
"context"
"fmt"
"strings"
"github.com/anthropics/anthropic-sdk-go"
"github.com/ytshih/stonks/internal/config"
)
func init() {
Register("api", newAPIAnalyzer)
}
const defaultAPIModel = anthropic.ModelClaudeOpus5
// apiAnalyzer calls the Claude Messages API directly, billed per token.
// Reads credentials the standard way (ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN
// / an `ant auth login` profile) via the zero-arg client constructor.
type apiAnalyzer struct {
client anthropic.Client
model anthropic.Model
effort anthropic.OutputConfigEffort
}
func newAPIAnalyzer(cfg config.AnalyzerConfig) (Analyzer, error) {
model := anthropic.Model(cfg.Model)
if cfg.Model == "" {
model = defaultAPIModel
}
effort := anthropic.OutputConfigEffortMedium
if cfg.Effort != "" {
effort = anthropic.OutputConfigEffort(cfg.Effort)
}
return &apiAnalyzer{
client: anthropic.NewClient(),
model: model,
effort: effort,
}, nil
}
func (a *apiAnalyzer) Analyze(ctx context.Context, prompt string) (string, error) {
resp, err := a.client.Messages.New(ctx, anthropic.MessageNewParams{
Model: a.model,
MaxTokens: 4096,
System: []anthropic.TextBlockParam{{Text: SystemPrompt}},
OutputConfig: anthropic.OutputConfigParam{
Effort: a.effort,
},
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
},
})
if err != nil {
return "", fmt.Errorf("analyze (api mode): %w", err)
}
if resp.StopReason == anthropic.StopReasonRefusal {
return "", fmt.Errorf("analyze (api mode): request refused by safety classifiers")
}
var out strings.Builder
for _, block := range resp.Content {
if text, ok := block.AsAny().(anthropic.TextBlock); ok {
out.WriteString(text.Text)
}
}
if out.Len() == 0 {
return "", fmt.Errorf("analyze (api mode): response contained no text content (stop_reason=%s)", resp.StopReason)
}
return out.String(), nil
}
+121
View File
@@ -0,0 +1,121 @@
package analyze
import (
"fmt"
"sort"
"strings"
"time"
"github.com/ytshih/stonks/internal/config"
"github.com/ytshih/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] + "..."
}
+65
View File
@@ -0,0 +1,65 @@
package analyze
import (
"encoding/json"
"fmt"
"strings"
"github.com/ytshih/stonks/internal/report"
)
// recommendationsMarker must match the marker text SystemPrompt instructs
// Claude to emit before the trailing recommendations JSON block.
const recommendationsMarker = "===RECOMMENDATIONS_JSON==="
type recommendationsPayload struct {
Recommendations []report.Recommendation `json:"recommendations"`
}
// SplitReport separates Claude's raw response into the long-form narrative
// text and the short buy/sell recommendations list appended after
// recommendationsMarker (see SystemPrompt).
//
// If the marker is missing or the trailing JSON fails to parse, the full raw
// text is returned as-is with a nil recommendations slice, plus a non-nil
// error — callers should log it as a warning and still send the text-only
// report rather than treat this as fatal.
func SplitReport(raw string) (report.Report, error) {
idx := strings.Index(raw, recommendationsMarker)
if idx == -1 {
return report.Report{Text: strings.TrimSpace(raw)},
fmt.Errorf("recommendations marker not found in response")
}
text := strings.TrimSpace(raw[:idx])
jsonPart := extractJSONObject(raw[idx+len(recommendationsMarker):])
var payload recommendationsPayload
if err := json.Unmarshal([]byte(jsonPart), &payload); err != nil {
return report.Report{Text: text}, fmt.Errorf("parse recommendations json: %w", err)
}
for _, r := range payload.Recommendations {
if r.Action != "buy" && r.Action != "sell" {
return report.Report{Text: text}, fmt.Errorf("recommendation for %q has invalid action %q", r.Symbol, r.Action)
}
}
return report.Report{Text: text, Recommendations: payload.Recommendations}, nil
}
// extractJSONObject pulls out the substring from the first "{" to the last
// "}" in s. This tolerates any decoration Claude adds around the JSON
// despite being asked not to — a ```json fence, a stray leading backtick, a
// trailing sentence — since none of that can contain a brace that comes
// before the real opening one or after the real closing one. Returns s
// trimmed, unchanged, if no braces are found (json.Unmarshal will then fail
// with a clear error).
func extractJSONObject(s string) string {
start := strings.IndexByte(s, '{')
end := strings.LastIndexByte(s, '}')
if start == -1 || end == -1 || end < start {
return strings.TrimSpace(s)
}
return s[start : end+1]
}
+53
View File
@@ -0,0 +1,53 @@
package analyze
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"github.com/ytshih/stonks/internal/config"
)
func init() {
Register("subscription", newSubscriptionAnalyzer)
}
// subscriptionAnalyzer shells out to the `claude` CLI in headless/print mode
// (`claude -p`), using whatever session `claude login` established on this
// host. Usage is billed against that Claude Pro/Max subscription's
// rate limits rather than a separate per-token API bill.
//
// The CLI has no separate "system prompt" flag documented for scripting, so
// the system and user prompts are concatenated and piped in over stdin —
// the most robust interface regardless of CLI version.
type subscriptionAnalyzer struct{}
func newSubscriptionAnalyzer(cfg config.AnalyzerConfig) (Analyzer, error) {
if _, err := exec.LookPath("claude"); err != nil {
return nil, fmt.Errorf("analyze (subscription mode): `claude` CLI not found on PATH — install Claude Code and run `claude login`: %w", err)
}
return &subscriptionAnalyzer{}, nil
}
func (a *subscriptionAnalyzer) Analyze(ctx context.Context, prompt string) (string, error) {
fullPrompt := SystemPrompt + "\n\n---\n\n" + prompt
cmd := exec.CommandContext(ctx, "claude", "-p")
cmd.Stdin = strings.NewReader(fullPrompt)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("analyze (subscription mode): claude CLI failed: %w (stderr: %s)", err, strings.TrimSpace(stderr.String()))
}
out := strings.TrimSpace(stdout.String())
if out == "" {
return "", fmt.Errorf("analyze (subscription mode): claude CLI produced no output (stderr: %s)", strings.TrimSpace(stderr.String()))
}
return out, nil
}