Skip to content

v1.10.0 — Mistral provider & held-only holdings

Choose a tag to compare

@furic furic released this 07 Aug 11:45
· 33 commits to main since this release

What's New

Two features: a third AI provider with a permanent free tier, and holdings you keep on purpose stop being treated as rebalancing errors.

Mistral joins as a third AI provider

Claude has no free tier, so a lapsed balance left multi-AI mode permanently degraded — every brief capped at BUY with a ⚠ 1/2 AI badge. Mistral replaces it as the second opinion:

  • Free Experiment tier is permanent — roughly 1B tokens/month against this workload's ~7M.
  • Independent model lineage from Gemini, which is the actual requirement. Under the unanimity rule a second model only adds information if its disagreement reflects the data rather than the model being weaker — which is why a 70B open-weight model on Groq or Cerebras would have been a downgrade dressed as an upgrade.
  • Strict structured output. src/providers/mistral.ts uses La Plateforme's OpenAI-compatible endpoint with response_format: json_schema and strict: true, so decoding is constrained to the schema rather than merely asked for JSON.
  • No new dependency — native fetch, the same approach social.ts takes for four platforms.

Setup:

# free key at https://console.mistral.ai
gh secret set MISTRAL_API_KEY
npx tsx smoke/smoke-mistral.ts   # verify the schema is accepted before trusting a cron

Optional MISTRAL_MODEL variable overrides the default mistral-large-latest.

Two failure modes handled deliberately, both learned from earlier releases:

  • 429/5xx retry with 5s/10s backoff. A rate-limited free tier produces these in normal operation, not only when broken.
  • finish_reason === "length" throws. A truncated response would parse into a short array and the provider would quietly contribute less than it should — the same silent-degradation shape as the v1.8 Claude Stage 1 truncation. Failing loudly makes the orchestrator drop the provider and mark the run degraded instead.

Mistral also generates the STRONG BUY detailed analysis page. AI_DETAILED_PROVIDER now accepts gemini, claude or mistral.

Held-only holdings stop generating noise

Implements a design approved back in June.

A held-only ticker is in currentHoldings with no targetPortfolio entry and not in watching — held deliberately, to keep portfolio totals honest and to inform ETF overlap. Its gap is always negative against an implied 0% target, so it produced:

  • permanent "N% overweight vs. a 0% target" filler in the daily brief, and
  • a standing TRIM/SELL in the weekly report and Telegram, every week, forever.

Neither is actionable. Richfolio is buy-only by design and has no sell logic behind those suggestions.

They now route to report.untrackedItems, which removes them from the AI prompt (zero tokens spent on them), the daily recommendations and the daily allocation table for free — all of those iterate report.items. The weekly email and Telegram list them neutrally under "Not in target portfolio": value and current %, no action verb.

What deliberately does not change:

Still counts held-only holdings Why
portfolioBeta real exposure — excluding it would understate risk
estimatedAnnualDividend they pay real dividends
totalCurrentValue sums currentHoldings directly
ETF overlap discount reads currentHoldings directly

npm run refresh -- MSFT on a held-only ticker now explains itself ("add it to watching for analysis") instead of printing the generic "AI did not return a recommendation".

Under the hood

Two modules extracted to be testable. config.ts reads config.json at import time and throws when it's absent — and CI runs without one, so anything importing it (directly or transitively) cannot be unit-tested at all. Two pieces of logic moved out from behind that wall:

Same split as socialContent.ts (pure) / social.ts (credentialled). The design doc had specified test/analyze.test.ts, which was impossible as written; both deviations are recorded in the spec rather than left as folklore.

One shared schema module. src/providers/schemas.ts holds the two-stage and detailed-page JSON Schemas, previously duplicated inside claude.ts and detailedAnalysis.ts. With a third provider, drift between provider output contracts was a matter of time. strictify() derives the strict-mode variant (adds additionalProperties: false, makes every property required) instead of maintaining a second hand-written copy — and is tested for not mutating the source, since claude.ts hands those same objects to Anthropic.

Fixes

AI_DETAILED_PROVIDER could pin an unconfigured provider

pickDetailedProvider() validated the override and fell back correctly — then the per-ticker loop overrode that decision with the raw env value, no key check:

explicitOverride === "gemini" || explicitOverride === "claude"
  ? (explicitOverride as DetailedProviderId)   // ← never verified it was configured

So AI_DETAILED_PROVIDER=claude without ANTHROPIC_API_KEY would have failed every ticker despite a working fallback sitting right above it. The override is now resolved once, and an ignored one is logged with the reason — a pin that silently doesn't apply is the same class of bug as a guard that silently doesn't run.

Provider dispatch also moved to a switch on the id union, so adding a provider id without a call path fails to compile instead of silently falling back to Gemini.

Upgrading

Nothing required, and no config changes.

  • Held-only behaviour applies automatically. If you hold tickers with no target weight, expect them to vanish from the daily brief and move to a neutral line in the weekly one. Fewer recommendations, and slightly cheaper AI runs.
  • Mistral is opt-in — set MISTRAL_API_KEY to enable it. With Gemini alone, nothing changes.

144 tests, up from 113 at v1.9.0.

Full changelog: v1.9.0...v1.10.0