Portfolio Backtesting and Correlation Analytics
ta4j 0.26.0 provides a constructor-first portfolio workflow for strict time alignment, static target-weight execution, correlation and hierarchy analysis, and long-only minimum-variance allocation. The same PortfolioSeries drives accounting, the optional optimizer, charts, and reports, so one prepared data window has one asset order and one numeric domain.
Use this workflow for static allocation research with one or more risk assets and an optional cash sleeve, including a one-asset benchmark. Correlations and minimum-variance optimization require at least two risk assets. Continue to use BarSeriesManager or BacktestExecutor for strategy-driven trades over one BarSeries.
Release status: This API is available in ta4j 0.26.0. The runnable example links below are pinned to that release.
Quick Start
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import org.ta4j.core.BarSeries;
import org.ta4j.core.analysis.cost.LinearTransactionCostModel;
import org.ta4j.core.portfolio.PortfolioAllocation;
import org.ta4j.core.portfolio.PortfolioExecutionResult;
import org.ta4j.core.portfolio.PortfolioSeries;
import org.ta4j.core.portfolio.PortfolioSeriesManager;
import org.ta4j.core.portfolio.RebalancePolicy;
BarSeries equitySeries = ...;
BarSeries bondSeries = ...;
BarSeries commoditySeries = ...;
Map<String, BarSeries> assets = new LinkedHashMap<>();
assets.put("EQUITY", equitySeries);
assets.put("BONDS", bondSeries);
assets.put("COMMODITIES", commoditySeries);
PortfolioSeries portfolio = new PortfolioSeries(assets);
Map<String, Double> weights = new LinkedHashMap<>();
weights.put("EQUITY", 0.60);
weights.put("BONDS", 0.30);
weights.put("COMMODITIES", 0.05);
PortfolioAllocation allocation = new PortfolioAllocation(weights);
PortfolioSeriesManager manager = new PortfolioSeriesManager(
portfolio,
new LinearTransactionCostModel(0.001));
PortfolioExecutionResult result = manager.run(
allocation,
portfolio.numFactory().numOf("10000"),
RebalancePolicy.onIndexes(Set.of(0, 21, 42, 63)));
System.out.println("Final value: " + result.getFinalValue());
System.out.println("Total return: " + result.getTotalReturn());
System.out.println("Costs: " + result.getTotalTransactionCost());
The weights sum to 0.95, so the remaining 0.05 is held as cash. Rebalance trade sizes are solved against post-cost portfolio value and constrained so transaction costs cannot make cash negative. Holdings are fractional and long-only; trades and valuation use aligned closes in one common quote currency, with adjustments supplied upstream and no implicit forward fill. Returns are fractions, so 0.05 means 5%.
Canonical Flow
graph TD
BS["Named BarSeries inputs"] --> PS["new PortfolioSeries(...)"]
PS --> CORR["new PortfolioCorrelations(...)"]
PS --> OPT["new MinimumVarianceOptimizer(...)"]
W["new PortfolioAllocation(...)"] --> MGR["new PortfolioSeriesManager(...)"]
PS --> MGR
RP["RebalancePolicy"] --> MGR
MGR --> RESULT["PortfolioExecutionResult"]
RESULT --> SNAP["PortfolioSnapshot"]
RESULT --> VALUE["Portfolio value BarSeries"]
CORR --> MATRIX["CorrelationMatrix"]
MATRIX --> TREE["completeLinkage()"]
MATRIX --> CHARTS["Heatmaps and dendrograms"]
OPT --> W
Portfolio Series
PortfolioSeries snapshots its inputs once and aligns them by the intersection of bar end times present in every source. Missing bars are excluded rather than forward-filled.
Use source names when they are already the desired identifiers:
PortfolioSeries portfolio = new PortfolioSeries(equitySeries, bondSeries);
// Equivalent:
PortfolioSeries fromList = new PortfolioSeries(List.of(equitySeries, bondSeries));
Each BarSeries#getName() must be nonblank and unique. Use the map constructor for explicit aliases and deterministic encounter order:
Map<String, BarSeries> assets = new LinkedHashMap<>();
assets.put("US_EQUITY", equitySeries);
assets.put("TREASURY", bondSeries);
PortfolioSeries portfolio = new PortfolioSeries(assets);
Useful alignment methods:
int barCount = portfolio.getBarCount();
List<Instant> timeline = portfolio.getEndTimes();
Num equityClose = portfolio.getClosePrice("US_EQUITY", 0);
int originalIndex = portfolio.getSourceIndex("US_EQUITY", 0);
BarSeries defensiveCopy = portfolio.getBarSeries("US_EQUITY");
The original source index is retained even when an input has a nonzero begin index. Mutating an original input after construction does not change the portfolio snapshot.
Alignment rules:
- At least one nonempty series is required for execution; correlation and optimizer calculations require two.
- Asset names must be nonblank and unique.
- Every source must share at least one common bar end time.
- Duplicate end times inside one source are rejected.
- Portfolio indexes begin at zero;
getSourceIndex(...)maps them back to retained source indexes. - Asset and matrix order follows constructor encounter order.
- The first source supplies the portfolio
NumFactory; values from other factories are converted without routing through primitive doubles.
Prepare currency conversion, exchange-calendar joins, or intentional carry-forward bars before construction. Strict alignment will not invent those policies.
Allocations
Use explicit weights when a cash sleeve is intentional:
Map<String, Double> weights = new LinkedHashMap<>();
weights.put("US_EQUITY", 0.60);
weights.put("TREASURY", 0.30);
PortfolioAllocation allocation = new PortfolioAllocation(weights);
Num investedWeight = allocation.getTotalWeight(); // 0.90
Num cashWeight = allocation.getCashWeight(); // 0.10
Map weights are plain Number values, including BigDecimal; the manager converts them using the portfolio’s NumFactory. Weights must be finite and non-negative, and their sum must not exceed one. Relative WeightedValue inputs retain their Num weights, including finite DecimalNum values outside the primitive double range.
Use relative weighted values for a normalized, fully invested allocation:
PortfolioAllocation allocation = new PortfolioAllocation(
List.of(
new WeightedValue<>("US_EQUITY", numFactory.two()),
new WeightedValue<>("TREASURY", numFactory.one())));
This constructor normalizes the values to one and combines duplicate asset names. getTargetWeight(name) returns zero for an intentionally unallocated asset.
Execution and Rebalancing
PortfolioSeriesManager mirrors the role of BarSeriesManager: the constructor owns the series and transaction-cost configuration, while each run(...) supplies an allocation and starting cash.
PortfolioSeriesManager manager = new PortfolioSeriesManager(portfolio);
PortfolioExecutionResult initialOnly =
manager.run(allocation, numFactory.numOf("10000"));
PortfolioExecutionResult scheduled = manager.run(
allocation,
numFactory.numOf("10000"),
RebalancePolicy.onIndexes(Set.of(0, 63, 126, 189)));
The two-argument run(...) invests at the first aligned bar and then holds. A supplied policy schedules every trade, including the initial investment: onIndexes(Set.of(3)) stays in cash through bar 2; atStart().or(onIndexes(Set.of(3))) invests at bar 0 and rebalances at bar 3. Available policies are:
RebalancePolicy.atStart();
RebalancePolicy.everyBar();
RebalancePolicy.onIndexes(Set.of(0, 20, 40));
RebalancePolicy.everyNthBar(20);
RebalancePolicy.firstBarOf(ChronoUnit.MONTHS, ZoneOffset.UTC);
RebalancePolicy.whenDriftExceeds(0.03);
RebalancePolicy is a functional interface whose lambda receives a RebalancePolicy.Context, including the current aligned index and pre-trade weights. firstBarOf(...) uses aligned timestamps for calendar periods; everyNthBar(...) counts retained bars and does not imply a calendar month. Policies compose with or(...) and and(...).
Use any ta4j CostModel:
PortfolioSeriesManager manager = new PortfolioSeriesManager(
portfolio,
new LinearTransactionCostModel(0.001));
Execution is long-only and uses fractional units. Buys and sells are reduced or skipped when a fixed or step-like transaction cost would otherwise make cash negative. Invalid, negative, or non-finite costs are rejected.
Strategy-Driven Portfolio Runs
Since 0.27.1, one PortfolioStrategy can trade many assets: each aligned bar it decides, per asset, whether to enter or exit, and the manager keeps one trading record per asset. Use this for rotation, top-N, and “trade whichever market qualifies” research where the strategy, not a fixed weight vector, picks the assets.
Map<String, BarSeries> markets = new LinkedHashMap<>();
markets.put("BTC-USD", btc);
markets.put("ETH-USD", eth);
markets.put("SOL-USD", sol);
PortfolioSeriesView view = PortfolioSeries.union(markets);
PortfolioRule entry = new AssetRankRule(IndicatorAssetRanker.of(new VolumeIndicator(btc)), 1)
.and(new MaxOpenPositionsRule(2))
.and(AssetRule.of(new CrossedUpIndicatorRule(fastSma, slowSma)));
PortfolioRule exit = AssetRule.of(new StopLossRule(new ClosePriceIndicator(btc), 5));
PortfolioStrategy rotation = new BasePortfolioStrategy("volume-rotation", entry, exit);
PortfolioTradingRecord result = new PortfolioSeriesManager(view).run(rotation, view.numFactory().one());
TradingRecord solTrades = result.record("SOL-USD");
Indicators and rules passed to AssetRule.of(...) or IndicatorAssetRanker.of(...) are templates: their descriptors are rebuilt on each asset’s own series, so the sample series above only shapes the definition. Use the Function<BarSeries, ...> constructors when a component cannot be described by a descriptor; those strategies run but refuse to serialize.
To reuse an existing single-series strategy unchanged on every asset:
PortfolioStrategy perAsset = PerAssetStrategy.of(existingStrategy); // or PerAssetStrategy.ofJson(json)
String json = perAsset.toJson();
Run semantics:
PortfolioSeries.union(...)aligns on the union of end times, so late-listed or delisted assets stay in the view.hasBar(asset, i)is true only when the asset has a bar ending exactly at aligned timei; assets without a bar are skipped for that index. The ordinary constructors keep strict intersection alignment.- Rules receive the aligned index; asset-scoped rules evaluate at
getSourceIndex(asset, i), and unstable bars apply to each asset’s own source index. - Per asset, an open position is evaluated only for exit and a flat asset only for entry, so an asset never exits and re-enters on the same bar. With current-close execution,
MaxOpenPositionsRulecounts positions opened by earlier assets in the same bar. - At each aligned end time, execution callbacks (
TradeExecutionModel.onBar(...)) run for every asset with a bar before any strategy decision, so fills due at that time are visible to every asset’s decision. MaxOpenPositionsRulecounts open positions plus accepted entry orders that have not filled, expired, or been rejected (PortfolioTradingRecord.pendingEntryCount()). Asynchronous execution models such asStopLimitExecutionModelreport those orders throughTradeExecutionModel.hasPendingOrder(record); a custom model that retains orders must override it.- A trade directed at a later source bar, such as a next-open fill, becomes visible in the asset’s record when the aligned timeline reaches that bar’s end time, not at signal time. An asset with a gap in its bars therefore stays open (or flat) for other assets’ decisions until its fill bar.
IndicatorAssetRankerdrops assets with no bar, unstable values, orNaN; equal scores order by ascending asset name.run(strategy, amount)fills on each asset’s current close.run(strategy, positionSizer, executionModel)reuses ordinaryTradeExecutionModelandPositionSizerimplementations on source indexes and cannot see bars beyond the run window. Both close each asset’s remaining position at that asset’s last processed close, and do so when the timeline reaches that asset’s final bar, so later decisions for other assets see the closure.BasePortfolioTradingRecord(view, factory)lets callers supply their own per-assetTradingRecord, for example one backed by a persistent ledger.replaceRecord(asset, record)installs a restored record for an asset whose cached record is flat; replacing a record with an open position throws.
For live feeds, LivePortfolioSeries wraps caller-owned series without copying. Call advance() after appending bars to extend the aligned timeline, and addAsset/removeAsset as the tradable universe changes; a removed asset no longer resolves a source index. The caller must not mutate member series concurrently with strategy evaluation.
Snapshots and Results
Output types are immutable and created by the manager:
for (PortfolioSnapshot snapshot : result.getSnapshots()) {
System.out.printf(
"index=%d value=%s cash=%s turnover=%s%n",
snapshot.getIndex(),
snapshot.getPortfolioValue(),
snapshot.getCash(),
snapshot.getTurnover());
}
| Snapshot method | Meaning |
|---|---|
getIndex() / getEndTime() |
Aligned portfolio location. |
getPrices() |
Close prices used for valuation. |
getHoldings() |
Fractional units after any rebalance. |
getCash() |
Cash after trades and costs. |
getPortfolioValue() |
Cash plus marked-to-market holdings. |
getPeriodReturn() |
Return since the previous snapshot, net of costs. |
getTransactionCost() |
Costs paid at this snapshot. |
getTradedNotional() |
Gross traded notional in the quote currency, excluding costs. |
getTurnover() |
Traded notional divided by pre-trade portfolio value. |
getAssetValue(name) |
Marked-to-market asset value. |
getAssetWeights() / getCashWeight() |
Achieved asset and cash weights. |
getRebalanceStatus() |
NOT_SCHEDULED, COMPLETED, PARTIAL, or SKIPPED. |
Result summaries include getFinalValue(), getTotalReturn(), getTotalTransactionCost(), getTotalTurnover(), and getFinalWeights().
toString() gives a compact summary. Full snapshot history is explicit through getSnapshots(), including filtering by rebalance status.
Export the equity curve for normal ta4j indicators and criteria:
BarSeries equityCurve = result.toPortfolioValueSeries("portfolio-value");
ClosePriceIndicator value = new ClosePriceIndicator(equityCurve);
SMAIndicator smoothedValue = new SMAIndicator(value, 20);
The exported curve begins with the initial capital before the first aligned bar, so existing return and drawdown criteria include initial trading fees. StaticPortfolioBacktest demonstrates EnterAndHoldCriterion(NetReturnCriterion) agreeing with getTotalReturn().
Correlation Matrices
Construct analytics once for the aligned series:
PortfolioCorrelations correlations = new PortfolioCorrelations(portfolio);
CorrelationMatrix prices = correlations.getPriceMatrix();
CorrelationMatrix simpleReturns = correlations.getSimpleReturnMatrix();
CorrelationMatrix logReturns = correlations.getLogReturnMatrix();
Full-history methods end at the final aligned index. Rolling and historical forms make the decision index and observation count explicit:
CorrelationMatrix rolling = correlations.getSimpleReturnMatrix(60);
CorrelationMatrix historical =
correlations.getLogReturnMatrix(index, 120, SampleType.SAMPLE);
Num coefficient = historical.getCoefficient("US_EQUITY", "TREASURY");
List<CorrelationPair> pairs = historical.getPairs();
getPriceMatrix(...) counts prices. Simple- and log-return windows count one-bar returns, so they need one earlier price. isStable() reports whether the requested index contains the complete transform and correlation window; unavailable off-diagonal values are NaN.
Complete-Linkage Hierarchy
Every finite matrix can produce a deterministic hierarchy:
CorrelationHierarchy hierarchy = simpleReturns.completeLinkage();
for (ClusterMerge merge : hierarchy.getMerges()) {
System.out.printf(
"%d + %d distance=%s size=%d%n",
merge.getLeftClusterIndex(),
merge.getRightClusterIndex(),
merge.getDistance(),
merge.getSize());
}
System.out.println(hierarchy.getLeafOrder());
completeLinkage() matches scipy.cluster.hierarchy.linkage(matrix, method="complete"): each asset is represented by its complete correlation row, row distance is Euclidean, and cluster distance is the maximum pairwise row distance. Leaf cluster indexes are 0..assetCount-1; each merge receives the next index.
A matrix containing NaN or infinite coefficients is rejected rather than silently clustered.
Minimum-Variance Allocation
MinimumVarianceOptimizer is an optional core calculation that returns the same PortfolioAllocation used by static execution. It estimates covariance from aligned one-bar simple returns and minimizes portfolio variance subject to non-negative weights summing to one. Ledoit-Wolf shrinkage toward a scaled identity is the default; withCovarianceEstimator(CovarianceEstimator.SAMPLE) selects raw sample covariance. Static allocation does not require the optimizer or any network/reporting dependency.
PortfolioAllocation pure =
new MinimumVarianceOptimizer(portfolio).optimize();
PortfolioAllocation capped =
new MinimumVarianceOptimizer(
portfolio,
0.50)
.optimize();
The uncapped result is the mathematical long-only optimum for the estimated covariance matrix. A maximum weight can make the result more practical and diversified; it must be feasible for the number of assets. For example, a 25% cap requires at least four assets.
Historical windows are explicit and do not read future bars:
PortfolioAllocation walkForwardAllocation =
new MinimumVarianceOptimizer(
portfolio,
decisionIndex,
120,
0.50)
.optimize();
All covariance, projection, and convergence calculations use Num. The deterministic bounded-simplex projected-gradient solver supports singular covariance matrices without inversion. It rejects non-positive or non-finite prices, fewer than two return observations, infeasible caps, and numerical non-convergence.
Adjusted Yahoo Data
The examples module can load Yahoo data with yfinance-style auto_adjust=True behavior:
BarSeries adjusted =
YahooFinanceHttpBarSeriesDataSource.loadAdjustedSeries(
"QQQ",
YahooFinanceInterval.DAY_1,
start,
end);
Yahoo’s adjusted-close ratio is applied to open, high, low, and close; volume is retained. Existing loadSeries(...) and loadSeriesInstance(...) methods continue to return raw prices.
For daily multi-asset research, normalize end times to one calendar convention before constructing PortfolioSeries. The runnable portfolio examples show a UTC-date normalization step.
Heatmaps, Dendrograms, and Reports
PortfolioCorrelationChartFactory in ta4j-examples creates headless-safe JFreeChart output:
PortfolioCorrelationChartFactory charts =
new PortfolioCorrelationChartFactory();
JFreeChart heatmap =
charts.createHeatmap("Return correlations", simpleReturns);
JFreeChart dendrogram =
charts.createDendrogram(
"Correlation hierarchy",
simpleReturns.completeLinkage());
The anonymous DiversifiedPortfolioAnalysis example uses adjusted YTD data for:
QQQ, VWO, COIN, FBTC, IBIT, ETHW, RES, XOM, DOC, NKE, ARKK, TLT
It writes:
- adjusted-price heatmap and dendrogram
- simple-return heatmap and dendrogram
- equal-weight, pure minimum-variance, and 25%-capped allocations
report.htmlprice-correlations.csv,return-correlations.csv,allocations.csv, andreturn-linkage.csvai-analysis-prompt.md
Run it from the ta4j repository root:
./mvnw -pl ta4j-examples -am install
./mvnw -pl ta4j-examples exec:java \
-Dexec.mainClass=ta4jexamples.portfolio.DiversifiedPortfolioAnalysis
The example does not call an AI service. To embed a response from an external model, save the response to a file and rerun:
./mvnw -pl ta4j-examples exec:java \
-Dexec.mainClass=ta4jexamples.portfolio.DiversifiedPortfolioAnalysis \
-Dexec.args="--ai-analysis=/path/to/response.md"
The response is HTML-escaped before it is embedded. Use --output=/path/to/directory to select another output directory.
No-Look-Ahead Contract
Correlation methods and optimizer constructors with an explicit index read prices only through that index. A rolling rebalance loop should compute an allocation at decision index i, then apply it according to the execution timing in the research design.
Do not estimate one full-history allocation and present it as a historical strategy. That is an in-sample description, not a walk-forward backtest.
Common Pitfalls
- Mismatched calendars: Alignment is an intersection. Normalize intentional calendar differences first.
- Raw equity prices: Splits and distributions can dominate price correlations. Prefer adjusted prices for this analysis.
- Price correlations as diversification proof: Price levels can be jointly trending. Inspect return correlations too.
- Implicit cash: Explicit weights below one create a cash sleeve; relative
WeightedValueinputs normalize to full investment. - Infeasible caps: A 25% cap needs at least four assets.
- Short histories: Correlation and covariance estimates are fragile with few observations.
- Full-history optimization: It is descriptive unless the allocation is estimated independently at each historical decision point.
- Execution realism: Cost models are supported, but slippage, taxes, FX conversion, borrowing, and order-book fills are outside this static manager.
- Personalized advice: Minimum variance minimizes estimated variance only. It does not know an investor’s goals, liabilities, taxes, or risk capacity.
Public Type Map
| Type | Purpose |
|---|---|
PortfolioSeries |
Snapshot and strict common-end-time alignment of named asset series. |
PortfolioAllocation |
Long-only explicit or normalized target weights. |
PortfolioSeriesManager |
Cost-aware target-weight execution. |
RebalancePolicy |
Functional rebalance schedule. |
PortfolioSnapshot |
Immutable state at one aligned bar. |
PortfolioExecutionResult |
Immutable execution summary and equity-curve export. |
PortfolioCorrelations |
Price, simple-return, and log-return matrices. |
CorrelationMatrix |
Immutable matrix, pairs, stability metadata, and hierarchy entrypoint. |
CorrelationHierarchy / ClusterMerge |
Complete-linkage result. |
MinimumVarianceOptimizer |
Num-native long-only covariance optimizer. |
PortfolioSeriesView / LivePortfolioSeries |
Aligned multi-asset view; live, caller-owned variant with advance() and universe changes. |
PortfolioStrategy / BasePortfolioStrategy / PerAssetStrategy |
One strategy deciding entries and exits per asset. |
PortfolioRule, AssetRule, AssetRankRule, MaxOpenPositionsRule, EndTimeRule |
Composable portfolio-level and asset-scoped rules. |
AssetRanker / IndicatorAssetRanker |
Deterministic cross-asset ranking. |
PortfolioTradingRecord / BasePortfolioTradingRecord |
One trading record per asset plus open-position counts. |
Runnable Examples
For general strategy mechanics, see Backtesting. For promotion gates and realism checks, see Backtesting Realism Checklist.