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

+162
View File
@@ -0,0 +1,162 @@
// Command stonks fetches market data and news, asks Claude for a daily
// investment-advice briefing, and posts it to the configured outputs.
// Intended to be run once a day by a systemd user timer.
package main
import (
"context"
"flag"
"fmt"
"log"
"time"
"github.com/ytshih/stonks/internal/analyze"
"github.com/ytshih/stonks/internal/archive"
"github.com/ytshih/stonks/internal/config"
"github.com/ytshih/stonks/internal/fetch"
"github.com/ytshih/stonks/internal/output"
"github.com/ytshih/stonks/internal/report"
)
func main() {
configPath := flag.String("config", "config/config.yaml", "path to config.yaml")
timeout := flag.Duration("timeout", 5*time.Minute, "overall run timeout")
flag.Parse()
if err := run(*configPath, *timeout); err != nil {
log.Fatalf("stonks: %v", err)
}
}
func run(configPath string, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cfg, err := config.Load(configPath)
if err != nil {
return err
}
result, err := runFetchers(ctx, cfg)
if err != nil {
// Fetch errors are logged but non-fatal — a degraded report (some
// asset classes or news missing) is better than no report at all.
log.Printf("warning: one or more fetchers had errors: %v", err)
}
if len(result.Quotes) == 0 && len(result.News) == 0 {
return fmt.Errorf("no data fetched from any source; aborting rather than sending an empty report")
}
analyzer, err := analyze.Build(cfg.Analyzer)
if err != nil {
return fmt.Errorf("build analyzer: %w", err)
}
prompt := analyze.BuildUserPrompt(cfg.Targets, result)
if len(cfg.Analyzer.Advisors) > 0 {
advisorResults := analyze.RunAdvisors(ctx, cfg.Analyzer.Advisors, prompt)
for _, r := range advisorResults {
if r.Err != nil {
log.Printf("warning: advisor %q failed, excluding it from Claude's prompt: %v", r.Name, r.Err)
}
}
prompt = analyze.AppendAdvisorSection(prompt, advisorResults)
}
raw, err := analyzer.Analyze(ctx, prompt)
if err != nil {
return fmt.Errorf("analyze: %w", err)
}
rep, err := analyze.SplitReport(raw)
if err != nil {
// Missing/malformed recommendations block degrades to a text-only
// report rather than failing the whole run.
log.Printf("warning: could not parse recommendations block: %v", err)
}
if cfg.Archive.Enabled {
dir, archErr := archive.Save(cfg.Archive, archive.Run{
Result: result,
Prompt: prompt,
RawResponse: raw,
Report: rep,
}, time.Now())
if archErr != nil {
log.Printf("warning: could not save run archive: %v", archErr)
} else {
log.Printf("stonks: run archived to %s", dir)
}
}
if err := runOutputs(ctx, cfg, rep); err != nil {
return fmt.Errorf("send outputs: %w", err)
}
log.Printf("stonks: report sent (%d quotes, %d news items, %d recommendations)",
len(result.Quotes), len(result.News), len(rep.Recommendations))
return nil
}
func runFetchers(ctx context.Context, cfg *config.Config) (fetch.Result, error) {
var result fetch.Result
var errs []error
for _, fc := range cfg.Fetchers {
f, err := fetch.Build(fc)
if err != nil {
errs = append(errs, err)
continue
}
fetched, err := f.Fetch(ctx, cfg.Targets)
if err != nil {
errs = append(errs, fmt.Errorf("fetcher %q: %w", f.Name(), err))
// fall through — a partial result from this fetcher is still
// worth merging in.
}
result.Merge(fetched)
}
return result, joinErrs(errs)
}
func runOutputs(ctx context.Context, cfg *config.Config, rep report.Report) error {
var errs []error
sent := 0
for _, oc := range cfg.Outputs {
o, err := output.Build(oc)
if err != nil {
errs = append(errs, err)
continue
}
if err := o.Send(ctx, rep); err != nil {
errs = append(errs, fmt.Errorf("output %q: %w", o.Name(), err))
continue
}
sent++
}
if sent == 0 {
return joinErrs(errs)
}
if len(errs) > 0 {
// At least one output succeeded — log the rest as warnings rather
// than failing the whole run.
log.Printf("warning: some outputs failed: %v", joinErrs(errs))
}
return nil
}
func joinErrs(errs []error) error {
if len(errs) == 0 {
return nil
}
msg := fmt.Sprintf("%d error(s):", len(errs))
for _, e := range errs {
msg += "\n - " + e.Error()
}
return fmt.Errorf("%s", msg)
}