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
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Package archive optionally saves a local copy of each run's fetched data
|
||||
// and analysis, for debugging prompt/parsing issues without reproducing a
|
||||
// live run.
|
||||
package archive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ytshih/stonks/internal/config"
|
||||
"github.com/ytshih/stonks/internal/fetch"
|
||||
"github.com/ytshih/stonks/internal/report"
|
||||
)
|
||||
|
||||
const defaultDir = "~/.local/state/stonks/runs"
|
||||
|
||||
// Run holds everything one pipeline run produced, for archiving.
|
||||
type Run struct {
|
||||
Result fetch.Result
|
||||
Prompt string // final prompt sent to the analyzer, including any advisor section
|
||||
RawResponse string // analyzer's raw, unparsed response
|
||||
Report report.Report
|
||||
}
|
||||
|
||||
// Save writes Run's artifacts to a new timestamped subdirectory under
|
||||
// cfg.Dir (or defaultDir if cfg.Dir is empty) and returns that directory's
|
||||
// path. Callers should treat a non-nil error as non-fatal — archiving is a
|
||||
// debugging aid, not part of the report pipeline.
|
||||
func Save(cfg config.ArchiveConfig, run Run, now time.Time) (string, error) {
|
||||
base := cfg.Dir
|
||||
if base == "" {
|
||||
base = defaultDir
|
||||
}
|
||||
base, err := expandHome(base)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("archive: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Join(base, now.UTC().Format("20060102T150405Z"))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("archive: create %q: %w", dir, err)
|
||||
}
|
||||
|
||||
fetchedJSON, err := json.MarshalIndent(run.Result, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("archive: marshal fetched data: %w", err)
|
||||
}
|
||||
recsJSON, err := json.MarshalIndent(run.Report.Recommendations, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("archive: marshal recommendations: %w", err)
|
||||
}
|
||||
|
||||
files := map[string][]byte{
|
||||
"fetched.json": fetchedJSON,
|
||||
"prompt.txt": []byte(run.Prompt),
|
||||
"analysis_raw.md": []byte(run.RawResponse),
|
||||
"report.md": []byte(run.Report.Text),
|
||||
"recommendations.json": recsJSON,
|
||||
}
|
||||
for name, data := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil {
|
||||
return dir, fmt.Errorf("archive: write %q: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// expandHome replaces a leading "~" with the current user's home directory.
|
||||
func expandHome(path string) (string, error) {
|
||||
if path != "~" && !strings.HasPrefix(path, "~/") {
|
||||
return path, nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
if path == "~" {
|
||||
return home, nil
|
||||
}
|
||||
return filepath.Join(home, path[2:]), nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Package config loads and represents stonks' YAML configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Targets lists the assets to fetch data for, grouped by asset class.
|
||||
type Targets struct {
|
||||
Crypto []string `yaml:"crypto"`
|
||||
Equities []string `yaml:"equities"`
|
||||
Futures map[string]string `yaml:"futures"`
|
||||
// Forex holds currency pairs as bare base+quote codes (e.g. "USDTWD"),
|
||||
// without any provider-specific suffix — fetchers append whatever
|
||||
// suffix their API needs (Yahoo Finance uses "=X").
|
||||
Forex []string `yaml:"forex"`
|
||||
}
|
||||
|
||||
// PluginConfig is the config for one named, registered fetcher or output.
|
||||
// Params is decoded lazily via DecodeParams so each plugin owns its own
|
||||
// params shape without the top-level config needing to know it.
|
||||
type PluginConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Params map[string]any `yaml:"params"`
|
||||
}
|
||||
|
||||
// DecodeParams re-marshals Params into out, which should be a pointer to a
|
||||
// plugin-specific params struct with `yaml` tags.
|
||||
func (p PluginConfig) DecodeParams(out any) error {
|
||||
if p.Params == nil {
|
||||
return nil
|
||||
}
|
||||
raw, err := yaml.Marshal(p.Params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal params for %q: %w", p.Name, err)
|
||||
}
|
||||
if err := yaml.Unmarshal(raw, out); err != nil {
|
||||
return fmt.Errorf("decode params for %q: %w", p.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AnalyzerConfig configures which Claude invocation mode to use.
|
||||
type AnalyzerConfig struct {
|
||||
// Mode is "subscription" (shells out to the `claude` CLI, billed
|
||||
// against a Claude Pro/Max subscription) or "api" (uses ANTHROPIC_API_KEY
|
||||
// via the Messages API, billed per token).
|
||||
Mode string `yaml:"mode"`
|
||||
// Model is only used in "api" mode.
|
||||
Model string `yaml:"model"`
|
||||
// Effort is only used in "api" mode: low|medium|high|xhigh|max.
|
||||
Effort string `yaml:"effort"`
|
||||
// Advisors are additional CLI-based AI analysts (e.g. gemini, codex)
|
||||
// consulted before the final Claude call — multi-agent mode. Their
|
||||
// output is appended to Claude's prompt as extra context; Claude still
|
||||
// produces the one final report and recommendations. Empty/omitted
|
||||
// disables multi-agent mode entirely.
|
||||
Advisors []AdvisorConfig `yaml:"advisors"`
|
||||
}
|
||||
|
||||
// AdvisorConfig defines one additional CLI-based AI analyst consulted in
|
||||
// multi-agent mode. The same fetched-data prompt is piped to its stdin;
|
||||
// Command must be a non-interactive/scriptable invocation (e.g. `gemini`
|
||||
// reads a piped prompt from stdin one-shot; `codex exec -` explicitly reads
|
||||
// from stdin).
|
||||
type AdvisorConfig struct {
|
||||
// Name labels this advisor's section in Claude's final prompt (e.g. "Gemini").
|
||||
Name string `yaml:"name"`
|
||||
// Command is the CLI binary to invoke (looked up on PATH).
|
||||
Command string `yaml:"command"`
|
||||
// Args are extra arguments passed before the prompt is piped via stdin.
|
||||
Args []string `yaml:"args"`
|
||||
}
|
||||
|
||||
// ArchiveConfig controls saving a local copy of each run's fetched data and
|
||||
// analysis — useful for debugging prompt/parsing issues without needing to
|
||||
// reproduce a live run.
|
||||
type ArchiveConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
// Dir is the directory each run's files are written under, one
|
||||
// timestamped subdirectory per run. Supports a leading "~". Defaults to
|
||||
// "~/.local/state/stonks/runs" if enabled with Dir left empty.
|
||||
Dir string `yaml:"dir"`
|
||||
}
|
||||
|
||||
// Config is the full stonks configuration.
|
||||
type Config struct {
|
||||
Targets Targets `yaml:"targets"`
|
||||
Fetchers []PluginConfig `yaml:"fetchers"`
|
||||
Analyzer AnalyzerConfig `yaml:"analyzer"`
|
||||
Outputs []PluginConfig `yaml:"outputs"`
|
||||
Archive ArchiveConfig `yaml:"archive"`
|
||||
}
|
||||
|
||||
// Load reads and parses the config file at path.
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config %q: %w", path, err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config %q: %w", path, err)
|
||||
}
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid config %q: %w", path, err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) validate() error {
|
||||
switch c.Analyzer.Mode {
|
||||
case "subscription", "api":
|
||||
case "":
|
||||
return fmt.Errorf("analyzer.mode is required (\"subscription\" or \"api\")")
|
||||
default:
|
||||
return fmt.Errorf("analyzer.mode %q must be \"subscription\" or \"api\"", c.Analyzer.Mode)
|
||||
}
|
||||
if len(c.Fetchers) == 0 {
|
||||
return fmt.Errorf("at least one fetcher must be configured")
|
||||
}
|
||||
if len(c.Outputs) == 0 {
|
||||
return fmt.Errorf("at least one output must be configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Package fetch defines the pluggable data-source interface and its
|
||||
// implementations (yfinance-equivalent prices, Finnhub, RSS news).
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ytshih/stonks/internal/config"
|
||||
)
|
||||
|
||||
// Quote is a single point-in-time price observation for one symbol.
|
||||
type Quote struct {
|
||||
Symbol string // ticker as passed to the fetcher, e.g. "AAPL", "BTC-USD", "GC=F"
|
||||
Label string // human-friendly name, e.g. "Apple Inc."
|
||||
Price float64
|
||||
ChangePercent float64 // percent change over the source's default window (usually 1 day)
|
||||
Currency string
|
||||
Source string // which fetcher produced this, e.g. "yfinance", "finnhub"
|
||||
}
|
||||
|
||||
// NewsItem is a single headline/article.
|
||||
type NewsItem struct {
|
||||
Title string
|
||||
Source string // publication or fetcher name
|
||||
URL string
|
||||
Published time.Time
|
||||
Summary string
|
||||
}
|
||||
|
||||
// Result is what one fetcher call returns. The orchestrator merges Results
|
||||
// from every configured fetcher before handing them to the analyzer.
|
||||
type Result struct {
|
||||
Quotes []Quote
|
||||
News []NewsItem
|
||||
}
|
||||
|
||||
// Merge appends other's quotes and news onto r.
|
||||
func (r *Result) Merge(other Result) {
|
||||
r.Quotes = append(r.Quotes, other.Quotes...)
|
||||
r.News = append(r.News, other.News...)
|
||||
}
|
||||
|
||||
// Fetcher fetches market data and/or news for the given targets.
|
||||
// Implementations should return a partial Result plus an error when only
|
||||
// some data could be retrieved, rather than nothing at all — the caller
|
||||
// decides whether a partial result is still worth reporting.
|
||||
type Fetcher interface {
|
||||
Name() string
|
||||
Fetch(ctx context.Context, targets config.Targets) (Result, error)
|
||||
}
|
||||
|
||||
// Constructor builds a Fetcher from its PluginConfig (name + params).
|
||||
type Constructor func(cfg config.PluginConfig) (Fetcher, error)
|
||||
|
||||
var registry = map[string]Constructor{}
|
||||
|
||||
// Register makes a fetcher constructor available under name. Called from
|
||||
// each fetcher implementation's init().
|
||||
func Register(name string, ctor Constructor) {
|
||||
if _, exists := registry[name]; exists {
|
||||
panic(fmt.Sprintf("fetch: constructor already registered for %q", name))
|
||||
}
|
||||
registry[name] = ctor
|
||||
}
|
||||
|
||||
// Build looks up the constructor for cfg.Name and invokes it.
|
||||
func Build(cfg config.PluginConfig) (Fetcher, error) {
|
||||
ctor, ok := registry[cfg.Name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("fetch: no fetcher registered for %q", cfg.Name)
|
||||
}
|
||||
return ctor(cfg)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/ytshih/stonks/internal/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("finnhub", newFinnhubFetcher)
|
||||
}
|
||||
|
||||
const finnhubBaseURL = "https://finnhub.io/api/v1"
|
||||
|
||||
// finnhubParams is the plugin-specific config for the finnhub fetcher.
|
||||
type finnhubParams struct {
|
||||
// APIKeyEnvVar names the environment variable holding the Finnhub API
|
||||
// key. Defaults to FINNHUB_API_KEY.
|
||||
APIKeyEnvVar string `yaml:"api_key_env_var"`
|
||||
// NewsLookbackDays controls how far back company-news headlines are
|
||||
// pulled. Defaults to 1 (yesterday through today).
|
||||
NewsLookbackDays int `yaml:"news_lookback_days"`
|
||||
}
|
||||
|
||||
// finnhubFetcher fetches equity quotes and per-ticker company news from
|
||||
// Finnhub's free tier (documented, key-based — a more stable second price
|
||||
// source than the unofficial yfinance-equivalent fetcher). Finnhub's free
|
||||
// tier does not cover crypto or futures symbols, so this fetcher only
|
||||
// covers targets.Equities.
|
||||
type finnhubFetcher struct {
|
||||
apiKey string
|
||||
newsLookbackDays int
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newFinnhubFetcher(cfg config.PluginConfig) (Fetcher, error) {
|
||||
params := finnhubParams{
|
||||
APIKeyEnvVar: "FINNHUB_API_KEY",
|
||||
NewsLookbackDays: 1,
|
||||
}
|
||||
if err := cfg.DecodeParams(¶ms); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
apiKey := os.Getenv(params.APIKeyEnvVar)
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("finnhub: environment variable %q is not set", params.APIKeyEnvVar)
|
||||
}
|
||||
|
||||
return &finnhubFetcher{
|
||||
apiKey: apiKey,
|
||||
newsLookbackDays: params.NewsLookbackDays,
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *finnhubFetcher) Name() string { return "finnhub" }
|
||||
|
||||
func (f *finnhubFetcher) Fetch(ctx context.Context, targets config.Targets) (Result, error) {
|
||||
var result Result
|
||||
var errs []error
|
||||
|
||||
for _, symbol := range targets.Equities {
|
||||
q, err := f.fetchQuote(ctx, symbol)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("finnhub quote %s: %w", symbol, err))
|
||||
continue
|
||||
}
|
||||
result.Quotes = append(result.Quotes, q)
|
||||
|
||||
news, err := f.fetchCompanyNews(ctx, symbol)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("finnhub news %s: %w", symbol, err))
|
||||
continue
|
||||
}
|
||||
result.News = append(result.News, news...)
|
||||
}
|
||||
|
||||
return result, joinErrs(errs)
|
||||
}
|
||||
|
||||
type finnhubQuoteResponse struct {
|
||||
Current float64 `json:"c"`
|
||||
Change float64 `json:"d"`
|
||||
PercentChange float64 `json:"dp"`
|
||||
}
|
||||
|
||||
func (f *finnhubFetcher) fetchQuote(ctx context.Context, symbol string) (Quote, error) {
|
||||
var resp finnhubQuoteResponse
|
||||
if err := f.get(ctx, "/quote", url.Values{"symbol": {symbol}}, &resp); err != nil {
|
||||
return Quote{}, err
|
||||
}
|
||||
if resp.Current == 0 {
|
||||
return Quote{}, fmt.Errorf("no data returned (symbol may be invalid or unsupported on this tier)")
|
||||
}
|
||||
return Quote{
|
||||
Symbol: symbol,
|
||||
Label: symbol,
|
||||
Price: resp.Current,
|
||||
ChangePercent: resp.PercentChange,
|
||||
Currency: "USD",
|
||||
Source: "finnhub",
|
||||
}, nil
|
||||
}
|
||||
|
||||
type finnhubNewsItem struct {
|
||||
Datetime int64 `json:"datetime"`
|
||||
Headline string `json:"headline"`
|
||||
Source string `json:"source"`
|
||||
Summary string `json:"summary"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func (f *finnhubFetcher) fetchCompanyNews(ctx context.Context, symbol string) ([]NewsItem, error) {
|
||||
to := time.Now().UTC()
|
||||
from := to.AddDate(0, 0, -f.newsLookbackDays)
|
||||
|
||||
var resp []finnhubNewsItem
|
||||
params := url.Values{
|
||||
"symbol": {symbol},
|
||||
"from": {from.Format("2006-01-02")},
|
||||
"to": {to.Format("2006-01-02")},
|
||||
}
|
||||
if err := f.get(ctx, "/company-news", params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]NewsItem, 0, len(resp))
|
||||
for _, n := range resp {
|
||||
items = append(items, NewsItem{
|
||||
Title: fmt.Sprintf("[%s] %s", symbol, n.Headline),
|
||||
Source: n.Source,
|
||||
URL: n.URL,
|
||||
Published: time.Unix(n.Datetime, 0).UTC(),
|
||||
Summary: n.Summary,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (f *finnhubFetcher) get(ctx context.Context, path string, params url.Values, out any) error {
|
||||
params.Set("token", f.apiKey)
|
||||
reqURL := finnhubBaseURL + path + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := f.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// joinErrs combines multiple errors into one, or returns nil if errs is empty.
|
||||
func joinErrs(errs []error) error {
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
msg := fmt.Sprintf("%d error(s) occurred:", len(errs))
|
||||
for _, e := range errs {
|
||||
msg += "\n - " + e.Error()
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mmcdole/gofeed"
|
||||
|
||||
"github.com/ytshih/stonks/internal/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("rss", newRSSFetcher)
|
||||
}
|
||||
|
||||
// rssParams is the plugin-specific config for the rss fetcher.
|
||||
type rssParams struct {
|
||||
Feeds []string `yaml:"feeds"`
|
||||
// MaxItemsPerFeed caps how many of the newest entries are kept per feed.
|
||||
// Defaults to 10.
|
||||
MaxItemsPerFeed int `yaml:"max_items_per_feed"`
|
||||
// MaxAgeHours drops entries older than this many hours. Defaults to 48.
|
||||
// Feeds with entries lacking a published date are kept regardless.
|
||||
MaxAgeHours int `yaml:"max_age_hours"`
|
||||
}
|
||||
|
||||
// rssFetcher pulls general market news headlines from a configured list of
|
||||
// RSS feeds. Free, no API key.
|
||||
type rssFetcher struct {
|
||||
feeds []string
|
||||
maxItemsPerFeed int
|
||||
maxAge time.Duration
|
||||
parser *gofeed.Parser
|
||||
}
|
||||
|
||||
func newRSSFetcher(cfg config.PluginConfig) (Fetcher, error) {
|
||||
params := rssParams{
|
||||
MaxItemsPerFeed: 10,
|
||||
MaxAgeHours: 48,
|
||||
}
|
||||
if err := cfg.DecodeParams(¶ms); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(params.Feeds) == 0 {
|
||||
return nil, fmt.Errorf("rss: at least one feed URL must be configured")
|
||||
}
|
||||
|
||||
return &rssFetcher{
|
||||
feeds: params.Feeds,
|
||||
maxItemsPerFeed: params.MaxItemsPerFeed,
|
||||
maxAge: time.Duration(params.MaxAgeHours) * time.Hour,
|
||||
parser: gofeed.NewParser(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *rssFetcher) Name() string { return "rss" }
|
||||
|
||||
func (f *rssFetcher) Fetch(ctx context.Context, _ config.Targets) (Result, error) {
|
||||
var result Result
|
||||
var errs []error
|
||||
cutoff := time.Now().Add(-f.maxAge)
|
||||
|
||||
for _, feedURL := range f.feeds {
|
||||
feed, err := f.parser.ParseURLWithContext(feedURL, ctx)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("rss %s: %w", feedURL, err))
|
||||
continue
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, item := range feed.Items {
|
||||
if count >= f.maxItemsPerFeed {
|
||||
break
|
||||
}
|
||||
if item.PublishedParsed != nil && item.PublishedParsed.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
|
||||
var published time.Time
|
||||
if item.PublishedParsed != nil {
|
||||
published = *item.PublishedParsed
|
||||
}
|
||||
|
||||
result.News = append(result.News, NewsItem{
|
||||
Title: item.Title,
|
||||
Source: feed.Title,
|
||||
URL: item.Link,
|
||||
Published: published,
|
||||
Summary: item.Description,
|
||||
})
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return result, joinErrs(errs)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ytshih/stonks/internal/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("yfinance", newYFinanceFetcher)
|
||||
}
|
||||
|
||||
// Yahoo's older batch quote endpoint (/v6/finance/quote, used by the
|
||||
// unmaintained piquette/finance-go library) has since been locked behind
|
||||
// auth (401/404 without a session crumb). The per-symbol chart endpoint
|
||||
// below is still open and unauthenticated as of writing, and covers
|
||||
// crypto, equities, and futures alike via the same shape — so this fetcher
|
||||
// talks to it directly instead of depending on a library hitting a dead
|
||||
// endpoint.
|
||||
const yfinanceChartURLTemplate = "https://query1.finance.yahoo.com/v8/finance/chart/%s"
|
||||
|
||||
// yfinanceFetcher fetches prices from Yahoo Finance's public chart API —
|
||||
// the same unofficial endpoint Python's yfinance scrapes. No API key
|
||||
// required, but it does require a browser-like User-Agent or Yahoo
|
||||
// returns 429.
|
||||
type yfinanceFetcher struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newYFinanceFetcher(cfg config.PluginConfig) (Fetcher, error) {
|
||||
return &yfinanceFetcher{
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *yfinanceFetcher) Name() string { return "yfinance" }
|
||||
|
||||
// yfinanceTask pairs the symbol used to query Yahoo (query) with the plain
|
||||
// symbol shown in the report and matched against config.Targets (display).
|
||||
// These differ for forex, where Yahoo requires a "=X" suffix (e.g.
|
||||
// "USDTWD=X") that config and the rest of the pipeline never see.
|
||||
type yfinanceTask struct {
|
||||
query string
|
||||
display string
|
||||
}
|
||||
|
||||
func (f *yfinanceFetcher) Fetch(ctx context.Context, targets config.Targets) (Result, error) {
|
||||
tasks := make([]yfinanceTask, 0, len(targets.Crypto)+len(targets.Equities)+len(targets.Futures)+len(targets.Forex))
|
||||
for _, s := range targets.Crypto {
|
||||
tasks = append(tasks, yfinanceTask{query: s, display: s})
|
||||
}
|
||||
for _, s := range targets.Equities {
|
||||
tasks = append(tasks, yfinanceTask{query: s, display: s})
|
||||
}
|
||||
for _, ticker := range targets.Futures {
|
||||
tasks = append(tasks, yfinanceTask{query: ticker, display: ticker})
|
||||
}
|
||||
for _, s := range targets.Forex {
|
||||
tasks = append(tasks, yfinanceTask{query: s + "=X", display: s})
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return Result{}, nil
|
||||
}
|
||||
|
||||
type outcome struct {
|
||||
quote Quote
|
||||
err error
|
||||
}
|
||||
outcomes := make([]outcome, len(tasks))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i, task := range tasks {
|
||||
wg.Add(1)
|
||||
go func(i int, task yfinanceTask) {
|
||||
defer wg.Done()
|
||||
q, err := f.fetchOne(ctx, task)
|
||||
outcomes[i] = outcome{quote: q, err: err}
|
||||
}(i, task)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var result Result
|
||||
var errs []error
|
||||
for i, o := range outcomes {
|
||||
if o.err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", tasks[i].display, o.err))
|
||||
continue
|
||||
}
|
||||
result.Quotes = append(result.Quotes, o.quote)
|
||||
}
|
||||
|
||||
return result, joinErrs(errs)
|
||||
}
|
||||
|
||||
type yfinanceChartResponse struct {
|
||||
Chart struct {
|
||||
Result []struct {
|
||||
Meta struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Currency string `json:"currency"`
|
||||
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
||||
RegularMarketChangePercent float64 `json:"regularMarketChangePercent"`
|
||||
} `json:"meta"`
|
||||
} `json:"result"`
|
||||
Error *struct {
|
||||
Description string `json:"description"`
|
||||
} `json:"error"`
|
||||
} `json:"chart"`
|
||||
}
|
||||
|
||||
func (f *yfinanceFetcher) fetchOne(ctx context.Context, task yfinanceTask) (Quote, error) {
|
||||
reqURL := fmt.Sprintf(yfinanceChartURLTemplate, url.PathEscape(task.query))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return Quote{}, err
|
||||
}
|
||||
// Yahoo returns 429 for requests with no browser-like User-Agent.
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; stonks/1.0)")
|
||||
|
||||
resp, err := f.client.Do(req)
|
||||
if err != nil {
|
||||
return Quote{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return Quote{}, fmt.Errorf("unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var parsed yfinanceChartResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||
return Quote{}, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
if parsed.Chart.Error != nil {
|
||||
return Quote{}, fmt.Errorf("yahoo error: %s", parsed.Chart.Error.Description)
|
||||
}
|
||||
if len(parsed.Chart.Result) == 0 {
|
||||
return Quote{}, fmt.Errorf("no data returned")
|
||||
}
|
||||
|
||||
meta := parsed.Chart.Result[0].Meta
|
||||
return Quote{
|
||||
Symbol: task.display,
|
||||
Label: task.display,
|
||||
Price: meta.RegularMarketPrice,
|
||||
ChangePercent: meta.RegularMarketChangePercent,
|
||||
Currency: meta.Currency,
|
||||
Source: "yfinance",
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ytshih/stonks/internal/config"
|
||||
"github.com/ytshih/stonks/internal/report"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("discord", newDiscordOutput)
|
||||
}
|
||||
|
||||
// discordMessageLimit is Discord's max character count for a single
|
||||
// webhook message's `content` field.
|
||||
const discordMessageLimit = 2000
|
||||
|
||||
// discordParams is the plugin-specific config for the discord output.
|
||||
type discordParams struct {
|
||||
// WebhookEnvVar names the environment variable holding the Discord
|
||||
// webhook URL. Defaults to DISCORD_WEBHOOK_URL.
|
||||
WebhookEnvVar string `yaml:"webhook_env_var"`
|
||||
}
|
||||
|
||||
// discordOutput posts the report to a Discord incoming webhook, splitting it
|
||||
// into multiple messages if it exceeds Discord's 2000-character limit.
|
||||
type discordOutput struct {
|
||||
webhookURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newDiscordOutput(cfg config.PluginConfig) (Output, error) {
|
||||
params := discordParams{WebhookEnvVar: "DISCORD_WEBHOOK_URL"}
|
||||
if err := cfg.DecodeParams(¶ms); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := os.Getenv(params.WebhookEnvVar)
|
||||
if url == "" {
|
||||
return nil, fmt.Errorf("discord: environment variable %q is not set", params.WebhookEnvVar)
|
||||
}
|
||||
|
||||
return &discordOutput{
|
||||
webhookURL: url,
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *discordOutput) Name() string { return "discord" }
|
||||
|
||||
func (d *discordOutput) Send(ctx context.Context, rep report.Report) error {
|
||||
for i, chunk := range splitForDiscord(rep.Text, discordMessageLimit) {
|
||||
if err := d.post(ctx, chunk); err != nil {
|
||||
return fmt.Errorf("discord: send chunk %d: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
embeds := buildRecommendationEmbeds(rep.Recommendations)
|
||||
if err := d.postEmbeds(ctx, embeds); err != nil {
|
||||
return fmt.Errorf("discord: send recommendation embeds: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *discordOutput) post(ctx context.Context, content string) error {
|
||||
return d.postJSON(ctx, map[string]any{"content": content})
|
||||
}
|
||||
|
||||
func (d *discordOutput) postEmbeds(ctx context.Context, embeds []map[string]any) error {
|
||||
return d.postJSON(ctx, map[string]any{"embeds": embeds})
|
||||
}
|
||||
|
||||
func (d *discordOutput) postJSON(ctx context.Context, payload map[string]any) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.webhookURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Discord embed colors (decimal RGB).
|
||||
const (
|
||||
colorBuy = 0x57F287 // green
|
||||
colorSell = 0xED4245 // red
|
||||
)
|
||||
|
||||
// discordDescriptionLimit is Discord's max character count for a single
|
||||
// embed's `description`.
|
||||
const discordDescriptionLimit = 4096
|
||||
|
||||
// buildRecommendationEmbeds renders the buy and sell recommendations as two
|
||||
// separate Discord embeds (always both, even when one or both lists are
|
||||
// empty, so "nothing to report" is stated explicitly rather than omitted).
|
||||
func buildRecommendationEmbeds(recs []report.Recommendation) []map[string]any {
|
||||
var buyLines, sellLines []string
|
||||
for _, r := range recs {
|
||||
line := fmt.Sprintf("**%s** — %s", r.Symbol, r.Reason)
|
||||
if r.Action == "sell" {
|
||||
sellLines = append(sellLines, line)
|
||||
} else {
|
||||
buyLines = append(buyLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
return []map[string]any{
|
||||
recommendationEmbed("🟢 Buy", colorBuy, buyLines, "No buy recommendations today."),
|
||||
recommendationEmbed("🔴 Sell", colorSell, sellLines, "No sell recommendations today."),
|
||||
}
|
||||
}
|
||||
|
||||
func recommendationEmbed(title string, color int, lines []string, emptyText string) map[string]any {
|
||||
description := emptyText
|
||||
if len(lines) > 0 {
|
||||
description = truncateForDescription(strings.Join(lines, "\n"))
|
||||
}
|
||||
return map[string]any{
|
||||
"title": title,
|
||||
"color": color,
|
||||
"description": description,
|
||||
}
|
||||
}
|
||||
|
||||
func truncateForDescription(s string) string {
|
||||
if len(s) <= discordDescriptionLimit {
|
||||
return s
|
||||
}
|
||||
return s[:discordDescriptionLimit-3] + "..."
|
||||
}
|
||||
|
||||
// splitForDiscord breaks text into chunks of at most limit characters,
|
||||
// preferring to split on paragraph/line boundaries so Markdown formatting
|
||||
// stays intact across messages.
|
||||
func splitForDiscord(text string, limit int) []string {
|
||||
if len(text) <= limit {
|
||||
return []string{text}
|
||||
}
|
||||
|
||||
var chunks []string
|
||||
var current strings.Builder
|
||||
|
||||
flush := func() {
|
||||
if current.Len() > 0 {
|
||||
chunks = append(chunks, strings.TrimSpace(current.String()))
|
||||
current.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
// A single line longer than the limit must be hard-split.
|
||||
for len(line) > limit {
|
||||
if current.Len() > 0 {
|
||||
flush()
|
||||
}
|
||||
chunks = append(chunks, line[:limit])
|
||||
line = line[limit:]
|
||||
}
|
||||
if current.Len()+len(line)+1 > limit {
|
||||
flush()
|
||||
}
|
||||
current.WriteString(line)
|
||||
current.WriteString("\n")
|
||||
}
|
||||
flush()
|
||||
|
||||
return chunks
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Package output defines the pluggable destination interface and its
|
||||
// implementations (currently Discord webhook).
|
||||
package output
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/ytshih/stonks/internal/config"
|
||||
"github.com/ytshih/stonks/internal/report"
|
||||
)
|
||||
|
||||
// Output delivers the final report somewhere (Discord, Slack, email, ...).
|
||||
// Implementations decide how to render report.Text and the optional
|
||||
// report.Recommendations (e.g. Discord sends the text as chunked messages
|
||||
// followed by a short recommendations embed).
|
||||
type Output interface {
|
||||
Name() string
|
||||
Send(ctx context.Context, rep report.Report) error
|
||||
}
|
||||
|
||||
// Constructor builds an Output from its PluginConfig (name + params).
|
||||
type Constructor func(cfg config.PluginConfig) (Output, error)
|
||||
|
||||
var registry = map[string]Constructor{}
|
||||
|
||||
// Register makes an output constructor available under name. Called from
|
||||
// each output implementation's init().
|
||||
func Register(name string, ctor Constructor) {
|
||||
if _, exists := registry[name]; exists {
|
||||
panic(fmt.Sprintf("output: constructor already registered for %q", name))
|
||||
}
|
||||
registry[name] = ctor
|
||||
}
|
||||
|
||||
// Build looks up the constructor for cfg.Name and invokes it.
|
||||
func Build(cfg config.PluginConfig) (Output, error) {
|
||||
ctor, ok := registry[cfg.Name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("output: no output registered for %q", cfg.Name)
|
||||
}
|
||||
return ctor(cfg)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Package report defines the shared shape produced by internal/analyze and
|
||||
// consumed by internal/output, decoupling the two from each other.
|
||||
package report
|
||||
|
||||
// Recommendation is a single buy/sell call with a short reason, surfaced
|
||||
// separately from the main narrative report (e.g. as a Discord embed).
|
||||
type Recommendation struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Action string `json:"action"` // "buy" or "sell"
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Report is the full output of one analysis run: the long-form narrative
|
||||
// text plus an optional short list of buy/sell recommendations.
|
||||
type Report struct {
|
||||
Text string
|
||||
Recommendations []Recommendation
|
||||
}
|
||||
Reference in new issue
Block a user