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