Skip to content

LLM

Local-model view generation routed through Black–Litterman. See the LLM strategies guide.

Strategies

strategies

LLM-driven portfolio strategies (local models).

Three strategies, all with the standard method(returns, ...) -> PortfolioResult shape so they drop into the backtester and plots like any built-in. Each elicits per-asset views from a local LLM and routes them through the existing :func:jaxfolio.optimizers.classical.black_litterman, so they inherit its equilibrium prior, constraints, and diagnostics for free.

By default they construct an :class:~jaxfolio.llm.client.OllamaClient; pass client=FakeLLM(...) (or any :class:~jaxfolio.llm.client.LLMClient) to run offline or in tests. To use one inside :func:jaxfolio.backtest.compare, bind the non-returns arguments with :func:functools.partial.

llm_black_litterman

llm_black_litterman(
    returns,
    client: LLMClient | None = None,
    *,
    samples: int = 5,
    extra_context: str | None = None,
    tau: float = 0.05,
    risk_aversion: float = 2.5,
    config: OptimizerConfig | None = None,
) -> PortfolioResult

LLM-enhanced Black-Litterman (ICLR 2025).

Samples the local LLM samples times for per-asset return views, calibrates the view confidence from the sampling dispersion, and feeds both into Black-Litterman. Metadata records the raw views and confidence.

Source code in src/jaxfolio/llm/strategies.py
def llm_black_litterman(
    returns,
    client: LLMClient | None = None,
    *,
    samples: int = 5,
    extra_context: str | None = None,
    tau: float = 0.05,
    risk_aversion: float = 2.5,
    config: OptimizerConfig | None = None,
) -> PortfolioResult:
    """LLM-enhanced Black-Litterman (ICLR 2025).

    Samples the local LLM ``samples`` times for per-asset return views, calibrates
    the view confidence from the sampling dispersion, and feeds both into
    Black-Litterman. Metadata records the raw views and confidence.
    """
    client = _resolve_client(client)
    viewset = llm_views(returns, client, samples=samples, extra_context=extra_context)
    result = black_litterman(
        returns,
        views=viewset.views,
        view_confidence=viewset.confidence,
        tau=tau,
        risk_aversion=risk_aversion,
        config=config,
    )
    result.method = "LLM Black-Litterman"
    result.metadata.update(
        {
            "llm_views": viewset.views,
            "llm_confidence": viewset.confidence,
            "llm_view_std": viewset.per_asset_std,
            "samples": samples,
        }
    )
    return result

llm_sentiment_portfolio

llm_sentiment_portfolio(
    returns,
    news: dict,
    client: LLMClient | None = None,
    *,
    strength: float = 0.03,
    view_confidence: float = 0.5,
    tau: float = 0.05,
    risk_aversion: float = 2.5,
    config: OptimizerConfig | None = None,
) -> PortfolioResult

News-sentiment-tilted portfolio via a local LLM.

The LLM scores per-asset sentiment from news ({asset: text | [headlines]}), which is converted to return-view tilts and combined with the market equilibrium through Black-Litterman.

Source code in src/jaxfolio/llm/strategies.py
def llm_sentiment_portfolio(
    returns,
    news: dict,
    client: LLMClient | None = None,
    *,
    strength: float = 0.03,
    view_confidence: float = 0.5,
    tau: float = 0.05,
    risk_aversion: float = 2.5,
    config: OptimizerConfig | None = None,
) -> PortfolioResult:
    """News-sentiment-tilted portfolio via a local LLM.

    The LLM scores per-asset sentiment from ``news`` (``{asset: text | [headlines]}``),
    which is converted to return-view tilts and combined with the market
    equilibrium through Black-Litterman.
    """
    client = _resolve_client(client)
    scores = llm_sentiment(news, client)
    views = sentiment_to_views(scores, strength=strength)
    result = black_litterman(
        returns,
        views=views,
        view_confidence=view_confidence,
        tau=tau,
        risk_aversion=risk_aversion,
        config=config,
    )
    result.method = "LLM Sentiment Tilt"
    result.metadata.update({"sentiment_scores": scores, "sentiment_views": views})
    return result

llm_agent_portfolio

llm_agent_portfolio(
    returns,
    client: LLMClient | None = None,
    *,
    news: dict | None = None,
    roles=DEFAULT_ROLES,
    tau: float = 0.05,
    risk_aversion: float = 2.5,
    config: OptimizerConfig | None = None,
) -> PortfolioResult

Multi-agent-debate portfolio (bull / bear / risk agents).

Role-specialized LLM agents each argue per-asset views; the moderator aggregates them (with agreement-based confidence) and Black-Litterman turns the consensus into weights.

Source code in src/jaxfolio/llm/strategies.py
def llm_agent_portfolio(
    returns,
    client: LLMClient | None = None,
    *,
    news: dict | None = None,
    roles=DEFAULT_ROLES,
    tau: float = 0.05,
    risk_aversion: float = 2.5,
    config: OptimizerConfig | None = None,
) -> PortfolioResult:
    """Multi-agent-debate portfolio (bull / bear / risk agents).

    Role-specialized LLM agents each argue per-asset views; the moderator
    aggregates them (with agreement-based confidence) and Black-Litterman turns
    the consensus into weights.
    """
    client = _resolve_client(client)
    result_debate = debate(returns, client, roles=roles, news=news)
    result = black_litterman(
        returns,
        views=result_debate.views,
        view_confidence=result_debate.confidence,
        tau=tau,
        risk_aversion=risk_aversion,
        config=config,
    )
    result.method = "LLM Multi-Agent Debate"
    result.metadata.update(
        {
            "agent_views": result_debate.agent_views,
            "consensus_views": result_debate.views,
            "consensus_confidence": result_debate.confidence,
        }
    )
    return result

Client

client

Local-LLM client abstraction.

The strategies in this package never talk to a cloud API — they drive a local model. :class:OllamaClient speaks to a local Ollama <https://ollama.com>_ server (http://localhost:11434); because Ollama also exposes an OpenAI-compatible endpoint, the same host serves llama.cpp / LM Studio / vLLM.

Everything is built around the small :class:LLMClient protocol, so strategies accept an injected client. Tests and fully-offline runs pass :class:FakeLLM, which returns canned responses with no network at all.

LLMClient

Bases: Protocol

The minimal interface a strategy needs from a local LLM.

complete
complete(
    prompt: str,
    *,
    system: str | None = None,
    temperature: float = 0.7,
) -> str

Return a single completion for prompt.

Source code in src/jaxfolio/llm/client.py
def complete(self, prompt: str, *, system: str | None = None, temperature: float = 0.7) -> str:
    """Return a single completion for ``prompt``."""
    ...
sample
sample(
    prompt: str,
    k: int,
    *,
    system: str | None = None,
    temperature: float = 0.7,
) -> list[str]

Return k independent completions (for uncertainty estimation).

Source code in src/jaxfolio/llm/client.py
def sample(
    self, prompt: str, k: int, *, system: str | None = None, temperature: float = 0.7
) -> list[str]:
    """Return ``k`` independent completions (for uncertainty estimation)."""
    ...

OllamaClient

OllamaClient(
    model: str = DEFAULT_MODEL,
    *,
    host: str = DEFAULT_HOST,
    timeout: float = 120.0,
    options: dict | None = None,
)

Client for a local Ollama server (native /api/generate endpoint).

Parameters:

Name Type Description Default
model str

Ollama model tag, e.g. "llama3.1", "mistral", "qwen2.5". Pull it first with ollama pull <model>.

DEFAULT_MODEL
host str

Base URL of the local server.

DEFAULT_HOST
timeout float

Per-request timeout in seconds.

120.0
Source code in src/jaxfolio/llm/client.py
def __init__(
    self,
    model: str = DEFAULT_MODEL,
    *,
    host: str = DEFAULT_HOST,
    timeout: float = 120.0,
    options: dict | None = None,
):
    self.model = model
    self.host = host.rstrip("/")
    self.timeout = timeout
    self.options = options or {}

FakeLLM

FakeLLM(
    responses: Sequence[str] | Callable[[str, int], str],
)

A deterministic, offline stand-in for a local LLM.

Pass either a fixed list of responses (cycled), or a callable responder(prompt, index) -> str for prompt-dependent behavior. Used in the test suite and for running the examples without a model installed.

Source code in src/jaxfolio/llm/client.py
def __init__(self, responses: Sequence[str] | Callable[[str, int], str]):
    self._responses = responses
    self._calls = 0

parse_json

parse_json(text: str) -> dict

Extract the first JSON object from an LLM response, tolerating prose.

Handles ```json fenced blocks and leading/trailing commentary. Returns an empty dict if nothing parseable is found (callers treat that as "no view").

Source code in src/jaxfolio/llm/client.py
def parse_json(text: str) -> dict:
    """Extract the first JSON object from an LLM response, tolerating prose.

    Handles ```json fenced blocks and leading/trailing commentary. Returns an
    empty dict if nothing parseable is found (callers treat that as "no view").
    """
    if not text:
        return {}

    # Try candidates in order of preference; the first that parses to a dict wins.
    candidates: list[str] = []
    fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
    if fence:
        candidates.append(fence.group(1))
    # Fall back to every brace-balanced object in the text (skipping braces inside
    # strings so a `}` in a value doesn't close early), so a failed fence or an
    # invalid earlier object doesn't hide a valid one later in the response.
    candidates.extend(_json_objects(text))

    for candidate in candidates:
        try:
            obj = json.loads(candidate)
        except json.JSONDecodeError:
            continue
        if isinstance(obj, dict):
            return obj
    return {}

default_client

default_client(
    model: str = DEFAULT_MODEL, **kwargs
) -> OllamaClient

Construct the default local client (Ollama).

Source code in src/jaxfolio/llm/client.py
def default_client(model: str = DEFAULT_MODEL, **kwargs) -> OllamaClient:
    """Construct the default local client (Ollama)."""
    return OllamaClient(model, **kwargs)

Views

views

LLM-generated return views for the Black-Litterman model.

Implements the core of the ICLR-2025 "Integrating LLM-Generated Views into Mean-Variance Optimization" method: prompt a local LLM with each asset's recent return statistics, sample it k times, parse per-asset expected-return views, and turn the dispersion across samples into a confidence — uncertain views are automatically down-weighted. The output plugs straight into :func:jaxfolio.optimizers.classical.black_litterman.

ViewSet dataclass

ViewSet(
    views: dict[str, float],
    confidence: float,
    per_asset_std: dict[str, float] = dict(),
    raw_samples: list[dict] = list(),
)

LLM-generated views and their calibrated confidences.

Attributes:

Name Type Description
views dict[str, float]

{asset: mean expected return} across the LLM samples.

confidence float

Scalar in (0, 1] summarizing overall view confidence (higher = the samples agreed more closely). Suitable for black_litterman's view_confidence argument.

per_asset_std dict[str, float]

{asset: std of the sampled views} — the raw dispersion.

raw_samples list[dict]

The parsed per-sample view dicts (for inspection / debugging).

calibrate_confidence

calibrate_confidence(
    views: dict[str, float], per_asset_std: dict[str, float]
) -> float

Map sampling dispersion to a Black-Litterman view confidence in (0, 1].

Confidence is high when the samples agree tightly relative to the typical view magnitude — normalizing by magnitude (not by cross-asset spread) keeps it well-behaved for single-asset universes and when all views agree, where a cross-asset-spread denominator would collapse to the floor.

Source code in src/jaxfolio/llm/views.py
def calibrate_confidence(views: dict[str, float], per_asset_std: dict[str, float]) -> float:
    """Map sampling dispersion to a Black-Litterman view confidence in ``(0, 1]``.

    Confidence is high when the samples agree tightly *relative to the typical
    view magnitude* — normalizing by magnitude (not by cross-asset spread) keeps
    it well-behaved for single-asset universes and when all views agree, where a
    cross-asset-spread denominator would collapse to the floor.
    """
    if not per_asset_std:
        return 0.5  # single sample or no dispersion signal
    avg_std = float(np.mean(list(per_asset_std.values())))
    ref_scale = float(np.mean([abs(v) for v in views.values()])) + 1e-4
    return float(np.clip(1.0 / (1.0 + avg_std / ref_scale), 0.05, 1.0))

build_prompt

build_prompt(
    returns, extra_context: str | None = None
) -> tuple[str, list[str]]

Construct the view-elicitation prompt and return (prompt, asset_names).

Source code in src/jaxfolio/llm/views.py
def build_prompt(returns, extra_context: str | None = None) -> tuple[str, list[str]]:
    """Construct the view-elicitation prompt and return ``(prompt, asset_names)``."""
    stats, names = _format_stats(returns)
    ctx = f"\n\nAdditional context:\n{extra_context}" if extra_context else ""
    prompt = (
        "Recent statistics for the investable universe:\n"
        f"{stats}{ctx}\n\n"
        "Return a JSON object mapping each ticker to your expected next-period "
        "return (decimal). Example: {"
        f'"{names[0]}": 0.01' + "}."
    )
    return prompt, names

llm_views

llm_views(
    returns,
    client: LLMClient,
    *,
    samples: int = 5,
    temperature: float = 0.8,
    extra_context: str | None = None,
    clip: tuple[float, float] = (-0.2, 0.2),
) -> ViewSet

Elicit per-asset return views from a local LLM and calibrate confidence.

Parameters:

Name Type Description Default
client LLMClient

A local LLM client (OllamaClient in production, FakeLLM in tests).

required
samples int

Number of independent samples k. More samples give a better estimate of both the mean view and its uncertainty.

5
clip tuple[float, float]

Per-period return views are clipped to this range to reject outliers / hallucinated magnitudes.

(-0.2, 0.2)

Returns:

Type Description
ViewSet

Mean views, a variance-derived confidence, and diagnostics.

Source code in src/jaxfolio/llm/views.py
def llm_views(
    returns,
    client: LLMClient,
    *,
    samples: int = 5,
    temperature: float = 0.8,
    extra_context: str | None = None,
    clip: tuple[float, float] = (-0.2, 0.2),
) -> ViewSet:
    """Elicit per-asset return views from a local LLM and calibrate confidence.

    Parameters
    ----------
    client:
        A local LLM client (``OllamaClient`` in production, ``FakeLLM`` in tests).
    samples:
        Number of independent samples ``k``. More samples give a better estimate
        of both the mean view and its uncertainty.
    clip:
        Per-period return views are clipped to this range to reject outliers /
        hallucinated magnitudes.

    Returns
    -------
    ViewSet
        Mean views, a variance-derived confidence, and diagnostics.
    """
    prompt, names = build_prompt(returns, extra_context)
    responses = client.sample(prompt, samples, system=_SYSTEM, temperature=temperature)

    lo, hi = clip
    parsed: list[dict[str, float]] = []
    stacked: dict[str, list[float]] = {n: [] for n in names}
    for resp in responses:
        obj = parse_json(resp)
        clean: dict[str, float] = {}
        for name in names:
            if name in obj:
                try:
                    val = float(np.clip(float(obj[name]), lo, hi))
                except (TypeError, ValueError):
                    continue
                clean[name] = val
                stacked[name].append(val)
        parsed.append(clean)

    views = {n: float(np.mean(v)) for n, v in stacked.items() if v}
    per_asset_std = {n: float(np.std(v)) for n, v in stacked.items() if len(v) > 1}
    confidence = calibrate_confidence(views, per_asset_std)

    return ViewSet(
        views=views,
        confidence=confidence,
        per_asset_std=per_asset_std,
        raw_samples=parsed,
    )

Sentiment

sentiment

Local-LLM news-sentiment scoring and its conversion to return views.

A local model reads per-asset news/context text and emits a sentiment score in [-1, 1]; :func:sentiment_to_views turns those scores into small expected-return tilts suitable for the Black-Litterman views argument.

llm_sentiment

llm_sentiment(
    news: dict,
    client: LLMClient,
    *,
    temperature: float = 0.3,
) -> dict[str, float]

Score per-asset news sentiment in [-1, 1] with a local LLM.

news maps asset -> a string or list of headlines. Assets whose score the model omits or malforms are skipped (treated as neutral downstream).

Source code in src/jaxfolio/llm/sentiment.py
def llm_sentiment(
    news: dict,
    client: LLMClient,
    *,
    temperature: float = 0.3,
) -> dict[str, float]:
    """Score per-asset news sentiment in ``[-1, 1]`` with a local LLM.

    ``news`` maps asset -> a string or list of headlines. Assets whose score the
    model omits or malforms are skipped (treated as neutral downstream).
    """
    if not news:
        return {}
    prompt = (
        "Score sentiment for each asset based on its news:\n"
        f"{_format_news(news)}\n\n"
        "Return a JSON object mapping each ticker to a score in [-1, 1]."
    )
    resp = client.complete(prompt, system=_SYSTEM, temperature=temperature)
    obj = parse_json(resp)
    scores: dict[str, float] = {}
    for asset in news:
        if asset in obj:
            try:
                scores[asset] = float(np.clip(float(obj[asset]), -1.0, 1.0))
            except (TypeError, ValueError):
                continue
    return scores

sentiment_to_views

sentiment_to_views(
    scores: dict[str, float], *, strength: float = 0.03
) -> dict[str, float]

Convert sentiment scores to expected-return views.

Each score in [-1, 1] becomes a view of score * strength (so strength is the per-period return magnitude a maximally bullish call implies, e.g. 0.03 = +3%).

Source code in src/jaxfolio/llm/sentiment.py
def sentiment_to_views(
    scores: dict[str, float],
    *,
    strength: float = 0.03,
) -> dict[str, float]:
    """Convert sentiment scores to expected-return views.

    Each score in ``[-1, 1]`` becomes a view of ``score * strength`` (so
    ``strength`` is the per-period return magnitude a maximally bullish call
    implies, e.g. ``0.03`` = +3%).
    """
    return {asset: float(score * strength) for asset, score in scores.items()}

Multi-agent debate

agents

Multi-agent LLM debate for portfolio views (AlphaAgents / HARLF style).

Several role-specialized agents — a bull, a bear, and a risk manager by default — each argue a per-asset stance from the same data using a local LLM. A moderator step aggregates their (confidence-weighted) stances into a single set of expected-return views, which then flow into Black-Litterman.

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.

DebateResult dataclass

DebateResult(
    views: dict[str, float],
    confidence: float,
    agent_views: dict[str, dict[str, float]] = dict(),
)

Aggregated views plus each agent's individual stance.

debate

debate(
    returns,
    client: LLMClient,
    *,
    roles=DEFAULT_ROLES,
    news: dict | None = None,
    temperature: float = 0.7,
    clip: tuple[float, float] = (-0.2, 0.2),
) -> DebateResult

Run a one-round multi-agent debate and aggregate into views.

Each role produces per-asset return estimates; the moderator averages them per asset (equal weight across agents), and confidence reflects cross-agent agreement (assets the agents agree on carry more weight).

Source code in src/jaxfolio/llm/agents.py
def debate(
    returns,
    client: LLMClient,
    *,
    roles=DEFAULT_ROLES,
    news: dict | None = None,
    temperature: float = 0.7,
    clip: tuple[float, float] = (-0.2, 0.2),
) -> DebateResult:
    """Run a one-round multi-agent debate and aggregate into views.

    Each role produces per-asset return estimates; the moderator averages them
    per asset (equal weight across agents), and confidence reflects cross-agent
    agreement (assets the agents agree on carry more weight).
    """
    stats, names = _format_stats(returns)
    news_block = None
    if news:
        news_block = "\n".join(
            f"- {a}: {v if isinstance(v, str) else '; '.join(map(str, v))}" for a, v in news.items()
        )

    lo, hi = clip
    agent_views: dict[str, dict[str, float]] = {}
    stacked: dict[str, list[float]] = {n: [] for n in names}

    for role_name, persona, _pref in roles:
        resp = client.complete(
            _agent_prompt(stats, persona, news_block),
            system=persona,
            temperature=temperature,
        )
        obj = parse_json(resp)
        view: dict[str, float] = {}
        for name in names:
            if name in obj:
                try:
                    val = float(np.clip(float(obj[name]), lo, hi))
                except (TypeError, ValueError):
                    continue
                view[name] = val
                stacked[name].append(val)
        agent_views[role_name] = view

    views = {n: float(np.mean(v)) for n, v in stacked.items() if v}
    # Confidence: high when the agents agree, relative to the view magnitude.
    per_asset_std = {n: float(np.std(v)) for n, v in stacked.items() if len(v) > 1}
    confidence = calibrate_confidence(views, per_asset_std)

    return DebateResult(views=views, confidence=confidence, agent_views=agent_views)