// 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. Archiving is always on: besides being a debugging aid, the last // few runs' report.md files are read back on every run to let the prompt // dedup against that rolling history (see archive.LoadRecent, // analyze.BuildUserPrompt). type ArchiveConfig struct { // 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 left empty. Dir string `yaml:"dir"` } // PortfolioConfig controls the paper-trading portfolio state that Claude's // buy/sell recommendations are executed against. State is always on // (Claude's recommendations are meaningless without something tracking // them): a fresh portfolio starts at InitialBudgetUSD (default $10,000) and // its state persists across runs in a JSON file under Dir. type PortfolioConfig struct { // Dir is the directory portfolio.json and memory.md are stored in. // Supports a leading "~". Defaults to "~/.local/state/stonks" if empty. Dir string `yaml:"dir"` // InitialBudgetUSD seeds a brand-new portfolio's starting cash. Defaults // to 10000 if zero. Ignored once a portfolio.json already exists. InitialBudgetUSD float64 `yaml:"initial_budget_usd"` // MaxLeverage caps the leverage multiplier any single recommendation can // request; requests above it are clamped down by internal/portfolio, not // trusted from Claude. Defaults to 3 if zero. MaxLeverage float64 `yaml:"max_leverage"` } // 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"` Portfolio PortfolioConfig `yaml:"portfolio"` } // 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 }