133 lines
4.5 KiB
Go
133 lines
4.5 KiB
Go
// 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
|
|
}
|