Portfolio optimization¶
PortfolioOptimizationTools is the LLM-facing, auditable surface over
LazyPortfolio's hierarchical V2 engine. It is for decision support: it returns
target weights and diagnostics, never submits trades. Five policy objectives
remain available: min_risk, max_return, max_ratio, max_utility, hrp.
The extraction from LazyFin changed the implementation and API, not the need
to document their mathematics and evaluation protocol.
The mathematical specification of the supported objectives is in Portfolio optimization methodology. This page documents the LLM tool boundary and operational usage. The flat provider and tree provider use the same engine; the tree surface additionally exposes hierarchy and per-node settings.
Data boundary¶
The agent supplies comma-separated ticker symbols (SPY,TLT) or canonical
instruments (ticker:SPY,ticker:TLT), a date window, an objective and constraints.
Canonical daily simple returns are loaded privately from market-data-hub
by MarketDataHubOptimizationBackend, or an injected OptimizationDataBackend.
The tool's frequency parameter selects the fitting grid (D/W/M/Q),
not the data extraction grid. The flat wrapper declares USD reference currency;
the full tree config declares its own currency.
Tool results are deliberately limited to weights, diagnostics, aggregate backtest metrics and bounded provenance. Prices, return rows and covariance matrices never enter LLM context through these tools. Flat results have no persistent run id: keep the request and result in the caller's audit record.
Setup¶
The flat connector imports lazyfin for its sibling finance providers and
lazyportfolio for the V2 engine. The tree module imports only lazyportfolio
lazily. Both packages are separate installations; there is no LazyTools fin
extra and no current lazyfin[optimizer] setup for this engine. Consult
ecosystem/compatibility.toml and requirements/verified.txt for tested pins.
That file's older LazyTools pin is an ecosystem CI baseline, distinct from
LazyTools 0.10.4; this docs change does not update the ecosystem combination.
from lazytools.connectors.fin import PortfolioOptimizationTools
flat = PortfolioOptimizationTools()
print([tool.name for tool in flat.as_tools()])
For an agent, pass this provider in Agent(..., tools=[flat]). The constructor
accepts only keyword backend=; it no longer accepts OptimizationStore.
The current menu is portfolio_optimizer_list_objectives,
portfolio_optimizer_run, portfolio_optimizer_backtest.
Policies and constraints¶
Call portfolio_optimizer_list_objectives for the authoritative vocabulary.
Names are objective=, not the former method=:
| Objective | Implemented target | Important limitation |
|---|---|---|
min_risk |
Minimize covariance-implied variance | Sensitive to covariance and bounds; does not need a mean forecast |
max_return |
Maximize expected excess return | Flat surface supplies bounds, not a benchmark-volatility cap |
max_ratio |
Maximize expected excess return / standard deviation | Fractional objective; no global-optimality promise |
max_utility |
Maximize excess mean minus risk_aversion / 2 times variance |
Uses variance, not standard deviation or CVaR |
hrp |
Hierarchical risk parity with Skfolio's default variance measure | Bounds supported; cash/leverage and non-auto mean estimators refused |
The flat run/backtest arguments are instruments, objective, start, end,
frequency, min_weight, max_weight, cash_enabled, max_leverage,
mean_estimator, risk_aversion, risk_free_rate, benchmark_weights.
Defaults: min_risk, weekly fitting, uniform minimum 0 and maximum 1,
cash disabled, leverage 1, mean estimator auto. Uniform per-asset minimum
weights are now supported through min_weight; they are not fixed to zero.
Mean estimators are auto, equilibrium, empirical, bayes_stein,
james_stein, bodnar_okhrin. The wrapper always constructs B0 weights,
using supplied canonical instrument weights or an equal-weight fallback.
The engine resolves auto from objective, reference availability and financing;
inspect resolved_mean_estimator in the run result. In the verified engine,
utility/financing auto uses Bayes-Stein rather than reconstructing the benchmark.
Explicit equilibrium requires a complete fully invested reference and rejects
active cash financing. Tool descriptions can be more conservative about asking
for explicit benchmark weights; supply them when that reference is meaningful.
The full tree surface exposes per-asset bounds, volatility targets/caps,
tracking error, covariance choice, borrowing spread and Black-Litterman views.
Those are tree constraints, not extra flat-tool keywords. HRP rejects unsupported
combinations; validation/solve errors must remain visible. No post-solve patch
should make a refused configuration look compliant.
What changed from the seven-method interface¶
| Former name or API | Current location or status |
|---|---|
min_variance_shrinkage |
Use min_risk; default covariance still uses fixed shrinkage |
max_sharpe_shrinkage |
Use max_ratio; risk_free_rate is now exposed |
max_utility_shrinkage |
Use max_utility; quadratic utility has factor one half |
hrp_cvar |
Use hrp only for variance-based HRP; this is a changed risk measure |
min_cvar, risk_budget_cvar |
No equivalent V2 objective in this tool menu; CVaR/RiskBudgeting remain lower-level Skfolio capabilities |
max_return_benchmark_vol |
Tree max_return with an explicit benchmark-referenced volatility cap; flat max_return alone is not equivalent |
OptimizationStore, ModelPortfolio, benchmark_id, run-id lifecycle |
Removed from the flat wrapper; B0 uses benchmark_weights, saved tree configs use the LazyPortfolio store |
chart_filename, artifacts_dir |
Not flat optimizer arguments; use a separate report/chart pipeline |
The deterministic score-ranked OptimizerTools / optimize_target_weights
still lives in lazyfin.kernel. It is a separate optimizer and was unaffected
by the hierarchical-engine extraction.
Benchmarks and backtests¶
Declare a static comparator with benchmark_weights, for example
{"ticker:SPY": 0.7, "ticker:TLT": 0.3}. There is no preliminary model-portfolio
creation or benchmark_id. The B0 comparator does not impose a risk budget
unless a tree configuration explicitly declares a benchmark risk constraint.
portfolio_optimizer_backtest fits the V2 estimator on the requested return
frequency and values the resulting holding periods on daily simple returns.
Its train_size (default 104) counts fitting observations;
rebalance_frequency (default M) independently controls renewed weights.
A fit ending at a rebalance endpoint applies to following daily observations,
not the training window. Annualized OOS metrics use daily factor 252, even
when fitting is weekly or monthly. See the methodology for compounding,
drifting weights and the cost convention.
The backtest takes all run arguments plus train_size, rebalance_frequency
and transaction_cost_bps (default 0). Pass costs deliberately: zero is a
configuration default, not an estimate of real execution cost.
The result contains n_folds, FINAL metrics, B0 benchmark_metrics,
transaction_cost_paid and bounded source/frequency provenance. It does not
persist specifications, weights at each fold or raw observations in a V1 store.
Here is a complete flat call; run it only with a configured data backend:
import asyncio
import json
from lazytools.connectors.fin import PortfolioOptimizationTools
async def evaluate():
flat = PortfolioOptimizationTools()
menu = {tool.name: tool for tool in flat.as_tools()}
vocabulary = json.loads(await menu["portfolio_optimizer_list_objectives"].run())
assert "max_utility" in vocabulary["objectives"]
result = await menu["portfolio_optimizer_backtest"].run(
instruments="SPY,TLT", objective="min_risk",
start="2018-01-01", end="2026-01-01", frequency="W",
max_weight=0.7, train_size=104, rebalance_frequency="M",
transaction_cost_bps=10.0,
benchmark_weights={"ticker:SPY": 0.7, "ticker:TLT": 0.3},
)
print(json.loads(result))
asyncio.run(evaluate())
Retain the exact inputs and compact outputs for reproduction. A self-contained
HTML report can still use ReportTools / ReportFiles and save_memo_html,
but a chart needs a separately authorized source; the optimizer returns no
OOS curve or sandboxed file: chart reference. Do not infer a missing curve
from aggregate metrics.
Live DeepSeek smoke test¶
examples/run_portfolio_optimization_deepseek.py remains the live agentic
smoke-test runner. It now imports MarketDataHubOptimizationBackend from
LazyPortfolio and the current flat provider. DeepSeek chooses a sequence of
DataHub, optimizer, report and Telegram tools for a SPY/GLD/TLT/BCI study against
a 70/30 SPY/TLT comparator. The task specifies a roughly three-year rolling
window, quarterly rebalancing and capped long-only weights.
The runner checks completion artifacts, not a predetermined chain or numerical
performance. It uses report chart: schemas for instrument price/return charts,
not the removed optimizer chart_filename interface. It reads DEEPSEEK_API_KEY,
TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID and a configured market-data-hub database,
with its documented local key-file fallback. This sends a Telegram summary and
HTML attachment: it is an explicitly invoked live example, not part of docs
verification. The docs tests substitute engines/data and send no messages.
Tree configurations¶
PortfolioTreeTools (src/lazytools/connectors/fin/tree_tools.py) exposes
the current full-tree contract alongside the flat tools above.
PortfolioOptimizationTools always wraps a single flat node. PortfolioTreeTools
exposes the full LazyPortfolio V2 node-tree: multiple nodes, each with its own
instruments, a proxy ticker (how its parent sees it), an objective and
constraints, composed into flat / forward / forward_backward estimates.
A tree config is one JSON object:
{
"root_id": "root",
"currency": "USD",
"nodes": [
{"id": "root", "name": "Global", "children": ["equity", "bonds"], "instruments": [], "proxy": "", "goal": {"objective": "min_risk"}, "constraints": {}},
{"id": "equity", "name": "Equity", "children": [], "instruments": ["SPY", "VGK", "EWJ"], "proxy": "ACWI", "goal": {"objective": "min_risk"}, "constraints": {}},
{"id": "bonds", "name": "Bonds", "children": [], "instruments": ["SHY", "IEF", "TLT"], "proxy": "AGG", "goal": {"objective": "min_risk"}, "constraints": {}}
],
"data": {"start": "2018-01-01", "end": ""},
"backtest": {
"train_size": 104, "rebalance_frequency": "M", "estimation_frequency": "W",
"transaction_cost_bps": 5, "forward_enabled": true, "hierarchy_mode": "proxy",
"benchmark": {"name": "B0", "weights": {"ACWI": 0.7, "AGG": 0.3}}
}
}
children is a flat list of other node ids in the same nodes list, not
nested objects — every node but the root needs exactly one parent and (if it
has one) a proxy ticker; constraints may be {} and LazyPortfolio fills in
its ~25 optional fields. backtest.forward_enabled/hierarchy_mode select the
estimate mode (forward_enabled=false → flat; hierarchy_mode="proxy" →
forward; "synthetic_reconstructed" → forward_backward) via
lazyportfolio.v2.mode.mode_from_config — never chosen ad hoc by a tool
argument, so a saved tree always estimates the same way wherever it runs.
This is the same format Tree Studio
(LazyPortfolio's local visual editor) edits and saves — both go through
lazyportfolio.v2.store, sharing one SQLite database via the
LAZYPORTFOLIO_TREE_DB env var (set it the same way for both
processes; every portfolio_tree_list/_save response reports the resolved
database path so a mismatch is visible rather than silent). A tree built via
portfolio_tree_save appears in Tree Studio's saved-model list immediately;
a tree built/edited in the GUI loads via portfolio_tree_load/_list.
from lazytools.connectors.fin.tree_tools import PortfolioTreeTools
tools = PortfolioTreeTools() # validate / list / load
tools = PortfolioTreeTools(allow_compute=True, allow_persist=True, allow_delete=True)
print([tool.name for tool in tools.as_tools()])
portfolio_tree_validate(config)— never touches market data or the store; returns the flattened instrument universe and per-sleeve breakdown when valid, or a clear structural error (never raises) when not.portfolio_tree_list()/portfolio_tree_load(name)— read the shared store.portfolio_tree_save(name, config)— validates (same check as_validate) before writing; refuses to write anything on a validation failure.portfolio_tree_delete(name).portfolio_tree_estimate(config=None, name="", estimation_frequency="", train_size=0)/portfolio_tree_backtest(config=None, name="", estimation_frequency="", train_size=0, rebalance_frequency="", transaction_cost_bps=None)— pass either an inlineconfigor a savedname(configwins if both are given); the optional frequency/window/cost arguments override the tree's ownbacktestvalues for that call only, without mutating the saved file. Responses carryterminal_weights, per-nodelocal_weights/terminal_weights/audit, and aggregate backtest metrics/provenance — neversynthetic_returnsor per-period curves.
Tree privileges and errors¶
allow_compute adds estimate/backtest, allow_persist adds save, and
allow_delete adds delete. There is no constructor allow_write parameter.
Compute can be granted without persistence or deletion. The MCP fin provider
maps its write-enabled selection (--allow-unsafe) to all three privileges;
read-only tree tools are emitted otherwise. Flat estimation/backtests are already
in the provider menu: this flag is not a universal compute-cost gate.
store_path overrides LAZYPORTFOLIO_TREE_DB. Inline config wins over name.
Save validates before writing, and frequency/window/cost overrides affect only
that call. portfolio_tree_validate verifies structure without fetching data;
it cannot prove coverage, feasibility or forecast quality. Tree views are
typed per-node settings; a single-node tree is sufficient for views on a flat
universe. The flat portfolio_optimizer_* calls have no view argument.
Missing optional dependencies, missing data, invalid structure and infeasible
settings are errors to resolve. They are not reasons to invent returns or
silently fall back to a removed policy. Regression coverage in
test_portfolio_optimization_tools.py and test_fin_tree_tools.py verifies this
API/data boundary, not profitability.