-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture and Design Decisions
Auris follows a ports & adapters (hexagonal) architecture. The two core domains — market data and AI inference — are defined as small interfaces ("ports") in pkg/market and pkg/llm; each concrete provider is an isolated driver package ("adapter") in pkg/drivers/, wired up through a static registry (pkg/registry/). Adding a new provider means implementing one interface in one new package and adding one registry entry — the agent, TUI, and config layers never need to change.
flowchart TB
subgraph UI["Presentation"]
TUI["pkg/tui — Bubble Tea<br/>setup wizard · agent chat · portfolios · charts"]
end
subgraph CORE["Application core"]
AGENT["pkg/agent<br/>ReAct loop · 58 tools · market chain"]
FIN["pkg/finance<br/>deterministic calculation engine"]
PORT["pkg/portfolio<br/>FIFO lots · metrics · export"]
end
subgraph PORTS["Ports (interfaces)"]
LLMIF["pkg/llm<br/><code>AIProvider</code>"]
MKTIF["pkg/market<br/><code>ProviderAPI</code>"]
end
subgraph LLMDRV["LLM drivers"]
OLLAMA["Ollama"] ~~~ GEMINI["Gemini"] ~~~ CLAUDE["Claude"] ~~~ OPENAI["OpenAI¹"] ~~~ MINIMAX["MiniMax"]
end
subgraph MKTDRV["Market drivers"]
FMP["FMP<br/>(primary)"] -->|fallback| EODHD["EODHD<br/>(secondary)"]
SIM["Simulation"]
end
TUI --> AGENT
AGENT --> FIN
AGENT --> PORT
AGENT --> LLMIF
AGENT --> MKTIF
LLMIF --> LLMDRV
MKTIF --> MKTDRV
¹ The OpenAI driver also serves any OpenAI-compatible endpoint (DeepSeek, Groq, OpenRouter, self-hosted proxies…), registered as a separate provider entry.
This page walks through why the codebase is shaped this way, distilled from the project's internal Architecture Decision Records (docs/adr.md, ten ADRs, all accepted, none pending). That file is the canonical source, written in Spanish with full context/decision/consequences detail — this page is an English summary of the decisions most relevant to understanding the system from the outside. Each section below links back to the corresponding DD-N.
A recurring design question throughout the project: when a tool needs data, should the tool itself fetch it from a provider, or should the LLM be the one chaining separate tool calls together? Auris consistently chose the second option:
-
Technical indicators (
calculate_sma,calculate_rsi,calculate_macd, …) take aprices[]array directly rather than calling the market provider themselves. The LLM is expected to callmarket_get_candlesfirst, then feed the result into the indicator. This keeps the indicator functions pure and their tests fast and deterministic, with no market-provider dependency — at the cost of one extra ReAct loop iteration when the user hasn't already supplied prices. (DD-1) -
portfolio_calculate_metricsdefaults to auto-fetching each holding's current quote, but accepts an optionalquotessnapshot — symbols included in it skip the auto-fetch entirely. This makes the common case ("just give me my portfolio's metrics") work with zero preparation, while letting a caller that already has fresh quotes in context avoid redundant calls and rate-limit exposure. (DD-2) -
Monte Carlo simulation takes
drift_annual/volatility_annualas parameters rather than computing them internally — the LLM derives them (typically by chainingcalculate_volatilityover historical returns) and passes them in. The simulation tool itself only runs the closed-form GBM solution. This decouples the simulation engine from parameter estimation, keeping both independently testable and reusable. (DD-3)
The common thread: pushing orchestration into the LLM's tool-chaining ability keeps individual tools simple, pure, and fast to test — the tradeoff is a small number of extra ReAct loop iterations per question, which the 10-iteration budget comfortably absorbs.
Auris ships two market data drivers — Financial Modeling Prep (FMP) as primary, EODHD as secondary — wrapped into a single market.ProviderAPI by agent.NewMarketChain. Each call tries providers in order and falls through to the next only on errors that mean "this provider genuinely can't answer this" (ErrNotFound, ErrNotSupported, ErrRateLimit, ErrSubscriptionRequired); anything else, like ErrUnauthorized, surfaces immediately rather than being silently masked by a fallback.
Which provider goes primary was a real, evidence-driven decision, not an arbitrary choice: EODHD's advertised free-tier rate limit (x-ratelimit-limit: 1200/day, from the HTTP header) turned out to be misleading — a support email confirmed the real sustained limit is 20 calls/day once the one-time 500 "welcome calls" allowance is exhausted. That single fact flips EODHD from "could plausibly be primary" to "must be a scarce, secondary resource" — so FMP (free tier ~250 calls/day, broader US coverage) is primary, and EODHD only gets invoked for the gaps FMP's free tier leaves (notably BME/Madrid and other non-US symbols).
Drivers can statically declare tools they can never fulfil (e.g. order-book/tick data on REST-only providers) by implementing the optional market.CapabilityReporter interface. agent.New filters those out of the LLM's tool list up front, so the model never burns a ReAct iteration attempting a call that's guaranteed to fail. When chaining providers, a tool is excluded from the chain only if every provider in it lacks the capability — and if any provider in the chain doesn't implement CapabilityReporter at all, nothing is filtered for it (capability-unknown is treated conservatively, never over-filtering).
Extending AES-256-GCM encryption from just API keys to portfolios and chat sessions raised three non-obvious questions, covered in depth on the Security & Privacy page:
-
Why a separate, stable
StorageSaltinstead of reusing the credential salt (which regenerates on everySave) — reusing it would have silently changed the derived storage key on every unrelated config save, breaking already-encrypted files. -
Why the active key lives in package-level state (
config.SetStorageKey/StorageKey) rather than threaded through every call site — dozens of call sites across the TUI and agent tools callSavePortfolio/LoadPortfolio/SaveSession/LoadSession; adding a key parameter to all of them would have been a large, invasive diff for a single-process, single-user TUI application. -
Why toggling encryption re-encrypts everything synchronously, immediately, rather than lazily on next save — and why that's safe to retry:
ReencryptAllPortfolios/ReencryptAllSessionsare idempotent (each file is tried with the old key first, then the new key, so a batch interrupted halfway can simply be re-run) and every write goes throughWriteFileAtomic(temp file + atomic rename), so a crash mid-write never corrupts the target file — it just leaves an orphaned.tmp. "Retrying is safe" is a deliberate design property here, not a happy accident.
Simulation mode — synthetic market data and news requiring zero API keys — is deliberately not registered as just another entry in registry.AllMarket() alongside FMP/EODHD. It's implemented as an orthogonal AurisConfig.SimulationMode boolean that short-circuits provider construction entirely before the real provider chain is ever built. This was a conscious deviation from the project's own "adding a new market driver" checklist: simulation isn't a provider competing in the cascade, it's a mode that replaces the cascade wholesale, so it gets its own setup-wizard step and its own post-setup toggle rather than living in the provider-selection screens. A nice side effect: switching simulation off restores whatever real providers were already configured, without asking for credentials again — they were never removed, just unused while the flag was on.
Releases are built by GoReleaser rather than a hand-rolled Makefile, triggered only on git tag pushes — deliberately not on pull requests, since goreleaser release fails outright without a real tag, which would have left every PR red. cmd/auris/version.go declares version/commit/date/builtBy using the exact variable names GoReleaser's default ldflags template already targets, avoiding a custom ldflags: block entirely. When a binary is built without GoReleaser (a plain go build/go install, i.e. the normal path from Building from Source), those variables stay at their zero-value defaults — so -version instead falls back to Go's automatic VCS stamping (runtime/debug.ReadBuildInfo()) to still report the real commit hash it was built from (with a -dirty suffix for an uncommitted tree). The binary never has to lie about its own provenance.
Money formatting surfaced a question the portfolio model hadn't answered yet: does currency belong to the whole portfolio, to each holding, or to each individual FIFO lot? Per-lot was rejected as unnecessarily fine-grained (it complicates P&L aggregation for no real benefit); per-holding would require the LLM to manually convert and sum mixed currencies every time it's asked for a portfolio's total value. Currency lives on the portfolio as a single ISO 4217 field, from a curated list of 10 currencies in the creation wizard — deliberately not free-text, since validating an arbitrary ISO 4217 code well enough to show a readable name would require pulling in the full ISO registry for marginal benefit. Money formatting itself is also deliberately not locale-aware (no golang.org/x/text/currency dependency): the symbol always goes in front, and thousands/decimal separators stay consistent across both UI locales — predictable, testable, and legible in English and Spanish alike, rather than "more correct" but harder to reason about and to keep consistent in colored terminal output.
The full ADR log — with complete context, all consequences, and the two-way discussions behind each decision (including a couple of decisions this page compresses, like the exact idempotency algorithm in ReencryptAll* or the synthetic-market-data generator's correlated-noise model) — lives in docs/adr.md in the repository, in Spanish.
For new users
For contributors
Under the hood