feat: done vibing
This commit is contained in:
27 files changed
+1922
-13
No files matched your search
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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] + "..."
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user