LLM strategies¶
jaxfolio ships three LLM-driven allocation strategies that run entirely on a
local model via Ollama — no API keys, and no data
leaves your machine. Each elicits per-asset views from the model and routes
them through black_litterman, so they inherit
its equilibrium prior, constraints, and diagnostics for free.
The design in one line
An LLM is only used to produce views and a confidence; the actual allocation is still Black–Litterman. This keeps the output well-behaved and grounded in market equilibrium, whatever the model says.
The client abstraction¶
Strategies never talk to a cloud API. They depend on a small
LLMClient protocol with
complete and sample methods, and accept an injected client:
OllamaClient— drives a local Ollama server (http://localhost:11434). Because Ollama also exposes an OpenAI-compatible endpoint, the same host serves llama.cpp / LM Studio / vLLM.FakeLLM— a deterministic, offline stand-in that returns canned responses with no network. This is how the tests and examples run without a model installed.
from jaxfolio.llm import OllamaClient, FakeLLM
client = OllamaClient("llama3.1") # any local model: mistral, qwen2.5, gemma3…
# or, fully offline:
client = FakeLLM(['{"ASSET_00": 0.01, "ASSET_01": -0.005}'])
Get a model running first:
1 — LLM-enhanced Black–Litterman¶
llm_black_litterman
implements the core of the ICLR-2025 method Integrating LLM-Generated Views into
Mean–Variance Optimization. It shows the model each asset's recent return
statistics, samples it k times, parses per-asset expected-return views, and
turns the dispersion across samples into a confidence — so uncertain views are
automatically down-weighted before entering Black–Litterman.
import jaxfolio as jf
from jaxfolio.llm import OllamaClient
returns = jf.generate_returns(n_assets=8, seed=7)
client = OllamaClient("llama3.1")
bl = jf.llm_black_litterman(returns, client=client, samples=5)
bl.metadata["llm_views"] # {asset: mean sampled view}
bl.metadata["llm_confidence"] # (0, 1] — high when samples agreed
bl.metadata["llm_view_std"] # per-asset dispersion
Confidence is calibrated from agreement across samples: tight agreement (low std relative to the spread of views) maps to high confidence.
2 — News-sentiment tilt¶
llm_sentiment_portfolio
scores per-asset sentiment in \([-1, 1]\) from headlines/notes, converts each
score to a small return tilt (score × strength), and combines it with the
market equilibrium through Black–Litterman.
news = {
"AAPL": "record revenue, raised guidance",
"TSLA": "recall concerns",
}
sent = jf.llm_sentiment_portfolio(returns, news, client=client, strength=0.03)
sent.metadata["sentiment_scores"] # {asset: score in [-1, 1]}
sent.metadata["sentiment_views"] # {asset: return tilt}
Each asset's news value may be a single string or a list of headlines.
3 — Multi-agent debate¶
llm_agent_portfolio
runs an AlphaAgents / HARLF-style debate. Role-specialized agents — a bull, a
bear, and a risk manager by default — each argue per-asset views from the
same data. A moderator averages them per asset, with confidence reflecting
cross-agent agreement, and Black–Litterman turns the consensus into weights.
agent = jf.llm_agent_portfolio(returns, client=client)
agent.metadata["agent_views"] # each role's individual stance
agent.metadata["consensus_views"] # the aggregated views
agent.metadata["consensus_confidence"]
The value over a single prompt is structured disagreement: the bull looks for upside, the bear for downside, the risk agent penalizes volatility, and the aggregation reflects where they converge.
Running offline¶
Every strategy accepts an injected client, so the entire flow runs with no model
installed by passing a FakeLLM. A callable responder makes the offline data
realistic enough to exercise the confidence calibration:
import json, numpy as np
from jaxfolio.llm import FakeLLM
assets = list(returns.columns)
def responder(prompt: str, i: int) -> str:
rng = np.random.default_rng(i + 1)
return json.dumps({a: round(float(rng.normal(0.01, 0.006)), 4) for a in assets})
bl = jf.llm_black_litterman(returns, client=FakeLLM(responder), samples=5)
Using inside compare¶
Because these strategies take a client argument, bind it with functools.partial
before handing them to the backtester:
from functools import partial
from jaxfolio.backtest import compare
compare(returns, {
"LLM-BL": partial(jf.llm_black_litterman, client=client, samples=5),
"1/N": jf.equal_weight,
})
References¶
- LLM-BL (ICLR 2025) — Integrating LLM-Generated Views into Mean-Variance Optimization
- AlphaAgents — arXiv:2508.11152
- HARLF — arXiv:2507.18560