goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
@@ -0,0 +1,37 @@
# AGENTS instructions for `org.ta4j.core`
Applies to this package unless a deeper `AGENTS.md` overrides it.
## API and implementation conventions
- Mirror existing `BarSeries` builder patterns. New series types should include a dedicated builder in the same package.
- Extend `BaseBarSeries` for new series implementations to reuse validation and index/removal behavior.
- Keep concurrent access safe: guard writes with `ReentrantReadWriteLock`, guard read-mostly paths with read locks, and return immutable snapshots from `getBarData()`.
- Add `@since <current-version-without-SNAPSHOT>` to each new public class or method in this scope.
- Treat Javadoc as part of the API: new public APIs and behavior changes must include clear intent/usage documentation.
## Coding style and model rules
- Use loggers (`LogManager` / `Logger`) instead of `System.out` / `System.err`.
- Prefer imports over fully qualified class names in implementation code.
- Prefer explicit local variable types. Use `var` only when the inferred type is immediately obvious and meaningfully reduces noise.
- When a valid reference `Num` or `BarSeries` already exists, derive the `NumFactory` from that reference instead of creating a parallel default factory.
- For numerical domain code, keep calculations in `Num` and derive constants from the active `NumFactory`; convert to primitive `double` only when the required operation is unavailable in `Num`, and document why at the call site.
- Do not keep generic null/NaN fallback helpers when the call site already guarantees a usable reference; encode the stronger invariant directly at the call site.
- For DTO/model carrier types, prefer immutable shapes: `record` first, then public final fields, then mutable getter/setter models only when required.
- For component metadata JSON, use helpers from `org.ta4j.core.serialization` (`ComponentDescriptor`, `ComponentSerialization`) instead of hand-rolled structures.
## Core surface-area gate (MUST)
- New top-level classes in `org.ta4j.core` are a last resort.
- Prefer package-private nested helpers or existing extension points before adding new top-level core types.
- Reuse existing shared APIs first (for example shared enums) rather than introducing parallel concepts.
- If adding a new top-level core type is unavoidable, include a short rationale in the active PRD/checklist or PR notes.
## Scoped guides
- Read deeper package guides before editing these areas:
- `indicators/AGENTS.md`
- `serialization/AGENTS.md`
- `strategy/named/AGENTS.md`
- `bars/AGENTS.md`
@@ -0,0 +1,407 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import org.ta4j.core.Trade.TradeType;
import org.ta4j.core.analysis.AnalysisContext;
import org.ta4j.core.analysis.AnalysisWindow;
import org.ta4j.core.analysis.AnalysisContext.MissingHistoryPolicy;
import org.ta4j.core.analysis.AnalysisContext.PositionInclusionPolicy;
import org.ta4j.core.analysis.OpenPositionHandling;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.analysis.cost.ZeroCostModel;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.num.Num;
/**
* An analysis criterion. It can be used to:
*
* <ul>
* <li>analyze the performance of a {@link Strategy strategy}
* <li>compare several {@link Strategy strategies} together
* </ul>
*/
public interface AnalysisCriterion {
/** Filter to differentiate between winning or losing positions. */
enum PositionFilter {
/** Consider only winning positions. */
PROFIT,
/** Consider only losing positions. */
LOSS;
}
/**
* @param series the bar series, not null
* @param position the position, not null
* @return the criterion value for the position
*/
Num calculate(BarSeries series, Position position);
/**
* @param series the bar series, not null
* @param tradingRecord the trading record, not null
* @return the criterion value for the positions
*/
Num calculate(BarSeries series, TradingRecord tradingRecord);
/**
* Calculates this criterion over a specific analysis window using
* {@link AnalysisContext#defaults() default context options}.
*
* <p>
* Examples:
* </p>
* <ul>
* <li>Past 7 days:
* {@code criterion.calculate(series, record, AnalysisWindow.lookbackDuration(Duration.ofDays(7)))}</li>
* <li>Past 30 days:
* {@code criterion.calculate(series, record, AnalysisWindow.lookbackDuration(Duration.ofDays(30)))}</li>
* <li>Explicit date range:
* {@code criterion.calculate(series, record, AnalysisWindow.timeRange(Instant.parse("2026-02-10T00:00:00Z"), Instant.parse("2026-02-14T00:00:00Z")))}</li>
* </ul>
*
* @param series the bar series, not null
* @param tradingRecord the trading record, not null
* @param window the requested analysis window, not null
* @return the criterion value for the window
* @since 0.22.4
*/
default Num calculate(BarSeries series, TradingRecord tradingRecord, AnalysisWindow window) {
return calculate(series, tradingRecord, window, AnalysisContext.defaults());
}
/**
* Calculates this criterion over a specific analysis window.
*
* <p>
* Window boundaries follow:
* </p>
* <ul>
* <li>bar indices: start inclusive, end inclusive</li>
* <li>time windows: start inclusive, end exclusive (bar membership is based on
* bar end time)</li>
* </ul>
*
* <p>
* On constrained or moving series (for example when
* {@link BarSeries#setMaximumBarCount(int)} removed historical bars), missing
* history is handled according to
* {@link AnalysisContext#missingHistoryPolicy()}:
* </p>
* <ul>
* <li>{@link AnalysisContext.MissingHistoryPolicy#STRICT}: fails when requested
* history is unavailable</li>
* <li>{@link AnalysisContext.MissingHistoryPolicy#CLAMP}: intersects requested
* range with available logical indices</li>
* </ul>
*
* @param series the bar series, not null
* @param tradingRecord the trading record, not null
* @param window the requested analysis window, not null
* @param context window resolution and projection options, not null
* @return the criterion value for the window
* @since 0.22.4
*/
default Num calculate(BarSeries series, TradingRecord tradingRecord, AnalysisWindow window,
AnalysisContext context) {
Objects.requireNonNull(series, "series");
Objects.requireNonNull(tradingRecord, "tradingRecord");
Objects.requireNonNull(window, "window");
Objects.requireNonNull(context, "context");
if (series.isEmpty()) {
return calculate(series, tradingRecord);
}
int[] resolvedWindow = resolveWindow(series, window, context);
int windowStartIndex = resolvedWindow[0];
int windowEndIndex = resolvedWindow[1];
boolean hasBars = resolvedWindow[2] == 1;
TradingRecord projectedRecord = projectTradingRecord(series, tradingRecord, windowStartIndex, windowEndIndex,
hasBars, context);
return calculate(series, projectedRecord);
}
private static int[] resolveWindow(BarSeries series, AnalysisWindow window, AnalysisContext context) {
int availableStart = series.getBeginIndex();
int availableEnd = series.getEndIndex();
int requestedStart;
int requestedEnd;
boolean requestedEmpty;
boolean lowerBoundBeforeAvailable;
boolean upperBoundAfterAvailable;
switch (window) {
case AnalysisWindow.BarRange barRange:
requestedStart = barRange.startIndexInclusive();
requestedEnd = barRange.endIndexInclusive();
requestedEmpty = false;
lowerBoundBeforeAvailable = requestedStart < availableStart;
upperBoundAfterAvailable = requestedEnd > availableEnd;
break;
case AnalysisWindow.LookbackBars lookbackBars:
Instant lookbackBarsAsOf = context.asOf();
Instant availableStartTimeForBars = series.getBar(availableStart).getEndTime();
Instant availableEndExclusiveForBars = series.getBar(availableEnd).getEndTime().plusNanos(1);
lowerBoundBeforeAvailable = lookbackBarsAsOf != null
&& lookbackBarsAsOf.isBefore(availableStartTimeForBars);
upperBoundAfterAvailable = lookbackBarsAsOf != null
&& lookbackBarsAsOf.isAfter(availableEndExclusiveForBars);
int lookbackBarsAnchor = lookbackBarsAsOf == null ? availableEnd
: findLastIndexAtOrBefore(series, lookbackBarsAsOf, availableStart, availableEnd);
if (lookbackBarsAnchor < 0) {
requestedStart = availableStart;
requestedEnd = availableStart - 1;
requestedEmpty = true;
lowerBoundBeforeAvailable = true;
break;
}
requestedStart = lookbackBarsAnchor - lookbackBars.barCount() + 1;
requestedEnd = lookbackBarsAnchor;
requestedEmpty = false;
lowerBoundBeforeAvailable = lowerBoundBeforeAvailable || requestedStart < availableStart;
upperBoundAfterAvailable = upperBoundAfterAvailable || requestedEnd > availableEnd;
break;
case AnalysisWindow.TimeRange timeRange:
requestedStart = findFirstIndexAtOrAfter(series, timeRange.startInclusive(), availableStart, availableEnd);
requestedEnd = findLastIndexBefore(series, timeRange.endExclusive(), availableStart, availableEnd);
requestedEmpty = requestedStart < 0 || requestedEnd < 0 || requestedStart > requestedEnd;
lowerBoundBeforeAvailable = timeRange.startInclusive().isBefore(series.getBar(availableStart).getEndTime());
upperBoundAfterAvailable = timeRange.endExclusive()
.isAfter(series.getBar(availableEnd).getEndTime().plusNanos(1));
if (requestedEmpty) {
requestedStart = availableStart;
requestedEnd = availableStart - 1;
}
break;
case AnalysisWindow.LookbackDuration lookbackDuration:
Instant endExclusive = context.asOf() != null ? context.asOf()
: series.getBar(availableEnd).getEndTime().plusNanos(1);
Instant startInclusive = endExclusive.minus(lookbackDuration.duration());
requestedStart = findFirstIndexAtOrAfter(series, startInclusive, availableStart, availableEnd);
requestedEnd = findLastIndexBefore(series, endExclusive, availableStart, availableEnd);
requestedEmpty = requestedStart < 0 || requestedEnd < 0 || requestedStart > requestedEnd;
lowerBoundBeforeAvailable = startInclusive.isBefore(series.getBar(availableStart).getEndTime());
upperBoundAfterAvailable = endExclusive.isAfter(series.getBar(availableEnd).getEndTime().plusNanos(1));
if (requestedEmpty) {
requestedStart = availableStart;
requestedEnd = availableStart - 1;
}
break;
}
if (context.missingHistoryPolicy() == MissingHistoryPolicy.STRICT
&& (lowerBoundBeforeAvailable || upperBoundAfterAvailable)) {
throw unavailableHistoryException(requestedStart, requestedEnd, availableStart, availableEnd);
}
int resolvedStart = requestedStart;
int resolvedEnd = requestedEnd;
if (context.missingHistoryPolicy() == MissingHistoryPolicy.CLAMP) {
resolvedStart = Math.max(resolvedStart, availableStart);
resolvedEnd = Math.min(resolvedEnd, availableEnd);
}
if (context.missingHistoryPolicy() == MissingHistoryPolicy.STRICT
&& (resolvedStart < availableStart || resolvedEnd > availableEnd)) {
throw unavailableHistoryException(requestedStart, requestedEnd, availableStart, availableEnd);
}
if (requestedEmpty || resolvedStart > resolvedEnd) {
int anchor = Math.min(Math.max(resolvedStart, availableStart), availableEnd);
return new int[] { anchor, anchor, 0 };
}
return new int[] { resolvedStart, resolvedEnd, 1 };
}
private static IllegalArgumentException unavailableHistoryException(int requestedStart, int requestedEnd,
int availableStart, int availableEnd) {
String message = String.format("Requested window [%d, %d] is outside available series range [%d, %d]",
requestedStart, requestedEnd, availableStart, availableEnd);
return new IllegalArgumentException(message);
}
private static int findLastIndexAtOrBefore(BarSeries series, Instant asOf, int availableStart, int availableEnd) {
for (int i = availableEnd; i >= availableStart; i--) {
if (!series.getBar(i).getEndTime().isAfter(asOf)) {
return i;
}
}
return -1;
}
private static int findFirstIndexAtOrAfter(BarSeries series, Instant startInclusive, int availableStart,
int availableEnd) {
for (int i = availableStart; i <= availableEnd; i++) {
if (!series.getBar(i).getEndTime().isBefore(startInclusive)) {
return i;
}
}
return -1;
}
private static int findLastIndexBefore(BarSeries series, Instant endExclusive, int availableStart,
int availableEnd) {
for (int i = availableEnd; i >= availableStart; i--) {
if (series.getBar(i).getEndTime().isBefore(endExclusive)) {
return i;
}
}
return -1;
}
private static TradingRecord projectTradingRecord(BarSeries series, TradingRecord source, int start, int end,
boolean hasBars, AnalysisContext context) {
CostModel transactionCostModel = Objects.requireNonNullElseGet(source.getTransactionCostModel(),
ZeroCostModel::new);
CostModel holdingCostModel = Objects.requireNonNullElseGet(source.getHoldingCostModel(), ZeroCostModel::new);
BaseTradingRecord projectedRecord = new BaseTradingRecord(source.getStartingType(), start, end,
transactionCostModel, holdingCostModel);
if (!hasBars) {
return projectedRecord;
}
PositionInclusionPolicy inclusionPolicy = context.positionInclusionPolicy();
List<Position> includedPositions = new ArrayList<>();
for (Position position : source.getPositions()) {
if (includeClosedPosition(position, start, end, inclusionPolicy)) {
includedPositions.add(position);
}
}
if (context.openPositionHandling() == OpenPositionHandling.MARK_TO_MARKET) {
List<Position> openPositions = openPositionsForMarkToMarket(source, end, transactionCostModel,
holdingCostModel);
for (Position openPosition : openPositions) {
Position syntheticPosition = createMarkToMarketPosition(series, openPosition, end, holdingCostModel);
if (syntheticPosition != null
&& includeClosedPosition(syntheticPosition, start, end, inclusionPolicy)) {
includedPositions.add(syntheticPosition);
}
}
}
includedPositions.sort(Comparator.comparingInt(position -> position.getExit().getIndex()));
for (Position position : includedPositions) {
Trade entry = position.getEntry();
Trade exit = position.getExit();
projectedRecord.operate(entry);
projectedRecord.operate(exit);
}
return projectedRecord;
}
private static Position createMarkToMarketPosition(BarSeries series, Position currentPosition, int windowEndIndex,
CostModel holdingCostModel) {
if (currentPosition == null || !currentPosition.isOpened()) {
return null;
}
Trade entryTrade = currentPosition.getEntry();
if (entryTrade == null || entryTrade.getIndex() > windowEndIndex) {
return null;
}
Num amount = entryTrade.getAmount();
Num closePrice = series.getBar(windowEndIndex).getClosePrice();
CostModel transactionCostModel = entryTrade.getCostModel();
Trade syntheticExit = entryTrade.isBuy()
? Trade.sellAt(windowEndIndex, closePrice, amount, transactionCostModel)
: Trade.buyAt(windowEndIndex, closePrice, amount, transactionCostModel);
return new Position(entryTrade, syntheticExit, transactionCostModel, holdingCostModel);
}
private static List<Position> openPositionsForMarkToMarket(TradingRecord source, int windowEndIndex,
CostModel transactionCostModel, CostModel holdingCostModel) {
List<Position> openPositions = source.getOpenPositions();
if (!openPositions.isEmpty()) {
return openPositionsWithinWindow(openPositions, windowEndIndex);
}
Position currentPosition = source.getCurrentPosition();
if (currentPosition == null || !currentPosition.isOpened()) {
return List.of();
}
return List.of(currentPosition);
}
private static List<Position> openPositionsWithinWindow(List<Position> openPositions, int windowEndIndex) {
List<Position> positions = new ArrayList<>();
for (Position openPosition : openPositions) {
if (openPosition == null || !openPosition.isOpened()) {
continue;
}
if (openPosition.getEntry().getIndex() > windowEndIndex) {
continue;
}
positions.add(openPosition);
}
return positions;
}
private static boolean includeClosedPosition(Position position, int start, int end,
PositionInclusionPolicy positionInclusionPolicy) {
if (position == null || !position.isClosed()) {
return false;
}
int entry = position.getEntry().getIndex();
int exit = position.getExit().getIndex();
return switch (positionInclusionPolicy) {
case EXIT_IN_WINDOW -> exit >= start && exit <= end;
case FULLY_CONTAINED -> entry >= start && exit <= end;
};
}
/**
* @param manager the bar series manager with entry type of BUY
* @param strategies a list of strategies
* @return the best strategy (among the provided ones) according to the
* criterion
*/
default Strategy chooseBest(BarSeriesManager manager, List<Strategy> strategies) {
return chooseBest(manager, TradeType.BUY, strategies);
}
/**
* @param manager the bar series manager
* @param tradeType the entry type (BUY or SELL) of the first trade in the
* trading session
* @param strategies a list of strategies
* @return the best strategy (among the provided ones) according to the
* criterion
*/
default Strategy chooseBest(BarSeriesManager manager, TradeType tradeType, List<Strategy> strategies) {
Strategy bestStrategy = strategies.getFirst();
Num bestCriterionValue = calculate(manager.getBarSeries(), manager.run(bestStrategy));
for (int i = 1; i < strategies.size(); i++) {
Strategy currentStrategy = strategies.get(i);
Num currentCriterionValue = calculate(manager.getBarSeries(), manager.run(currentStrategy, tradeType));
if (betterThan(currentCriterionValue, bestCriterionValue)) {
bestStrategy = currentStrategy;
bestCriterionValue = currentCriterionValue;
}
}
return bestStrategy;
}
/**
* @param criterionValue1 the first value
* @param criterionValue2 the second value
* @return true if the first value is better than (according to the criterion)
* the second one, false otherwise
*/
boolean betterThan(Num criterionValue1, Num criterionValue2);
}
@@ -0,0 +1,240 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.function.Function;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* A {@code Bar} is aggregated open/high/low/close/volume/etc. data over a time
* period. It represents the "end bar" of a time period.
*/
public interface Bar extends Serializable {
/**
* @return the time period of the bar
*/
Duration getTimePeriod();
/**
* @return the begin timestamp of the bar period (in UTC).
*/
Instant getBeginTime();
/**
* @return the end timestamp of the bar period (in UTC).
*/
Instant getEndTime();
/**
* @return the open price of the bar period
*/
Num getOpenPrice();
/**
* @return the high price of the bar period
*/
Num getHighPrice();
/**
* @return the low price of the bar period
*/
Num getLowPrice();
/**
* @return the close price of the bar period
*/
Num getClosePrice();
/**
* @return the total traded volume of the bar period
*/
Num getVolume();
/**
* @return the total traded amount (tradePrice x tradeVolume) of the bar period
*/
Num getAmount();
/**
* @return the number of trades of the bar period
*/
long getTrades();
/**
* @param timestamp a timestamp
* @return true if the provided timestamp is between the begin time and the end
* time of the current period, false otherwise
*/
default boolean inPeriod(Instant timestamp) {
return timestamp != null && !timestamp.isBefore(getBeginTime()) && timestamp.isBefore(getEndTime());
}
/**
* @return the bar's begin time in UTC as {@link ZonedDateTime}
*/
default ZonedDateTime getZonedBeginTime() {
return getBeginTime().atZone(ZoneOffset.UTC);
}
/**
* @return the bar's end time in UTC as {@link ZonedDateTime}
*/
default ZonedDateTime getZonedEndTime() {
return getEndTime().atZone(ZoneOffset.UTC);
}
/**
* Converts the begin time of the bar to a time in the system's time zone.
*
* <p>
* <b>Warning:</b> The use of {@link ZoneId#systemDefault()} may introduce
* variability based on the system's default time zone settings. This can result
* in inconsistencies in time calculations and comparisons, particularly due to
* daylight saving time (DST). It is recommended to always utilize either
* {@link #getBeginTime()} or {@link #getZonedBeginTime()} for accurate results.
*
* @return the bar's begin time converted to system time zone
*/
default ZonedDateTime getSystemZonedBeginTime() {
return getBeginTime().atZone(ZoneId.systemDefault());
}
/**
* Converts the end time of the bar to a time in the system's time zone.
*
* <p>
* <b>Warning:</b> The use of {@link ZoneId#systemDefault()} may introduce
* variability based on the system's default time zone settings. This can result
* in inconsistencies in time calculations and comparisons, particularly due to
* daylight saving time (DST). It is recommended to always utilize either
* {@link #getEndTime()} or {@link #getZonedEndTime()} for accurate results.
*
* @return the bar's end time converted to system time zone
*/
default ZonedDateTime getSystemZonedEndTime() {
return getEndTime().atZone(ZoneId.systemDefault());
}
/**
* @return a user-friendly representation of the end timestamp in the system's
* time zone
*/
default String getDateName() {
return getSystemZonedEndTime().format(DateTimeFormatter.ISO_DATE_TIME);
}
/**
* @return an even more user-friendly representation of the end timestamp in the
* system's time zone
*/
default String getSimpleDateName() {
return getSystemZonedEndTime().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
}
/**
* @return true if this is a bearish bar, false otherwise
*/
default boolean isBearish() {
Num openPrice = getOpenPrice();
Num closePrice = getClosePrice();
return (openPrice != null) && (closePrice != null) && closePrice.isLessThan(openPrice);
}
/**
* @return true if this is a bullish bar, false otherwise
*/
default boolean isBullish() {
Num openPrice = getOpenPrice();
Num closePrice = getClosePrice();
return (openPrice != null) && (closePrice != null) && openPrice.isLessThan(closePrice);
}
/**
* Adds a trade and updates the close price at the end of the bar period.
*
* @param tradeVolume the traded volume
* @param tradePrice the actual price per asset
*/
void addTrade(Num tradeVolume, Num tradePrice);
/**
* Updates the close price at the end of the bar period. The open, high and low
* prices are also updated as needed.
*
* @param price the actual price per asset
* @param numFunction the numbers precision
*/
default void addPrice(String price, Function<Number, Num> numFunction) {
addPrice(numFunction.apply(new BigDecimal(price)));
}
/**
* Updates the close price at the end of the bar period. The open, high and low
* prices are also updated as needed.
*
* @param price the actual price per asset
* @param numFunction the numbers precision
*/
default void addPrice(Number price, Function<Number, Num> numFunction) {
addPrice(numFunction.apply(price));
}
/**
* Updates the close price at the end of the bar period. The open, high and low
* prices are also updated as needed.
*
* @param price the actual price per asset
*/
void addPrice(Num price);
/**
* Returns the {@link NumFactory} associated with the first available price
* field on the bar.
*
* @return the {@link NumFactory} derived from the bar's numeric values
* @throws IllegalArgumentException if no price fields are available to
* determine a factory
*
* @since 0.22.1
*/
default NumFactory numFactory() {
var open = getOpenPrice();
if (open != null) {
return open.getNumFactory();
}
var close = getClosePrice();
if (close != null) {
return close.getNumFactory();
}
var high = getHighPrice();
if (high != null) {
return high.getNumFactory();
}
var low = getLowPrice();
if (low != null) {
return low.getNumFactory();
}
var volume = getVolume();
if (volume != null) {
return volume.getNumFactory();
}
var amount = getAmount();
if (amount != null) {
return amount.getNumFactory();
}
throw new IllegalArgumentException("Cannot select a NumFactory: no price fields are available on the bar.");
}
}
@@ -0,0 +1,211 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.time.Duration;
import java.time.Instant;
import org.ta4j.core.num.Num;
/**
* Builder for one OHLCV bar.
*
* <p>
* Typical usage in backtests is to set period/time plus OHLCV fields and call
* {@link #add()}. For real-time trade ingestion, prefer series-level ingestion
* APIs such as
* {@link ConcurrentBarSeries#ingestTrade(java.time.Instant, Number, Number)} so
* rollover logic stays consistent.
* </p>
*/
public interface BarBuilder {
/**
* @param timePeriod the time period (optional if {@link #beginTime(Instant)}
* and {@link #endTime(Instant)} are given)
* @return {@code this}
*/
BarBuilder timePeriod(Duration timePeriod);
/**
* @param beginTime the begin time of the bar period (optional if
* {@link #endTime(Instant)} is given)
* @return {@code this}
*/
BarBuilder beginTime(Instant beginTime);
/**
* @param endTime the end time of the bar period (optional if
* {@link #beginTime(Instant)} is given)
* @return {@code this}
*/
BarBuilder endTime(Instant endTime);
/**
* @param openPrice the open price of the bar period
* @return {@code this}
*/
BarBuilder openPrice(Num openPrice);
/**
* @param openPrice the open price of the bar period
* @return {@code this}
*/
BarBuilder openPrice(Number openPrice);
/**
* @param openPrice the open price of the bar period
* @return {@code this}
*/
BarBuilder openPrice(String openPrice);
/**
* @param highPrice the highest price of the bar period
* @return {@code this}
*/
BarBuilder highPrice(Number highPrice);
/**
* @param highPrice the highest price of the bar period
* @return {@code this}
*/
BarBuilder highPrice(String highPrice);
/**
* @param highPrice the highest price of the bar period
* @return {@code this}
*/
BarBuilder highPrice(Num highPrice);
/**
* @param lowPrice the lowest price of the bar period
* @return {@code this}
*/
BarBuilder lowPrice(Num lowPrice);
/**
* @param lowPrice the lowest price of the bar period
* @return {@code this}
*/
BarBuilder lowPrice(Number lowPrice);
/**
* @param lowPrice the lowest price of the bar period
* @return {@code this}
*/
BarBuilder lowPrice(String lowPrice);
/**
* @param closePrice the close price of the bar period
* @return {@code this}
*/
BarBuilder closePrice(Num closePrice);
/**
* @param closePrice the close price of the bar period
* @return {@code this}
*/
BarBuilder closePrice(Number closePrice);
/**
* @param closePrice the close price of the bar period
* @return {@code this}
*/
BarBuilder closePrice(String closePrice);
/**
* @param volume the total traded volume of the bar period
* @return {@code this}
*/
BarBuilder volume(Num volume);
/**
* @param volume the total traded volume of the bar period
* @return {@code this}
*/
BarBuilder volume(Number volume);
/**
* @param volume the total traded volume of the bar period
* @return {@code this}
*/
BarBuilder volume(String volume);
/**
* @param amount the total traded amount of the bar period (if {@code null},
* then it is calculated by {@code closePrice * volume})
* @return {@code this}
*/
BarBuilder amount(Num amount);
/**
* @param amount the total traded amount of the bar period (if {@code null},
* then it is calculated by {@code closePrice * volume})
* @return {@code this}
*/
BarBuilder amount(Number amount);
/**
* @param amount the total traded amount of the bar period (if {@code null},
* then it is calculated by {@code closePrice * volume})
* @return {@code this}
*/
BarBuilder amount(String amount);
/**
* @param trades the number of trades of the bar period
* @return {@code this}
*/
BarBuilder trades(long trades);
/**
* @param trades the number of trades of the bar period
* @return {@code this}
*/
BarBuilder trades(String trades);
/**
* Updates the builder with a trade event and adds or updates bars as needed.
*
* @param time the trade timestamp (UTC)
* @param tradeVolume the traded volume
* @param tradePrice the traded price
*
* @since 0.22.2
*/
default void addTrade(Instant time, Num tradeVolume, Num tradePrice) {
throw new UnsupportedOperationException("Trade ingestion not supported by " + getClass().getSimpleName());
}
/**
* Updates the builder with a trade event and adds or updates bars as needed.
*
* @param time the trade timestamp (UTC)
* @param tradeVolume the traded volume
* @param tradePrice the traded price
* @param side aggressor side (optional)
* @param liquidity liquidity classification (optional)
*
* @since 0.22.2
*/
default void addTrade(Instant time, Num tradeVolume, Num tradePrice, RealtimeBar.Side side,
RealtimeBar.Liquidity liquidity) {
addTrade(time, tradeVolume, tradePrice);
}
/**
* @param barSeries the series used for bar addition
* @return {@code this}
*/
BarBuilder bindTo(BarSeries barSeries);
/**
* @return bar created from obtained data
*/
Bar build();
/**
* Builds bar with {@link #build()} and adds it to series
*/
void add();
}
@@ -0,0 +1,26 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.io.Serializable;
/**
* A factory that provides a builder for a bar.
*
* <p>
* Pair the factory with the bar semantics you need: time bars for clock-aligned
* candles, or alternative factories for volume/amount/tick driven aggregation.
* In most workflows, this is configured once at series construction time.
* </p>
*/
public interface BarBuilderFactory extends Serializable {
/**
* Constructor.
*
* @param series the bar series to which the created bar should be added
* @return the bar builder
*/
BarBuilder createBarBuilder(BarSeries series);
}
@@ -0,0 +1,263 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.io.Serializable;
import java.time.format.DateTimeFormatter;
import java.util.List;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* A {@code BarSeries} is a sequence of {@link Bar bars} separated by a
* predefined period (e.g. 15 minutes, 1 day, etc.).
*
* Notably, it can be:
*
* <ul>
* <li>the base of {@link Indicator indicator} calculations
* <li>constrained between beginning and ending indices (e.g. for some
* backtesting cases)
* <li>limited to a fixed number of bars (e.g. for actual trading)
* </ul>
*
* <p>
* The bar series is the core underlying dataset in ta4j. It represents a
* timeline of financial data (OHLCV) and acts as the source of truth for
* indicators, backtesting runs, and live trading operations.
* </p>
*/
public interface BarSeries extends Serializable {
/**
* @return factory that generates numbers usable in this BarSeries
*/
NumFactory numFactory();
/**
* @return builder that generates compatible bars
*/
BarBuilder barBuilder();
/**
* @return the name of the series
*/
String getName();
/**
* Gets the bar from {@link #getBarData()} with index {@code i}.
*
* <p>
* The given {@code i} can return the same bar within the first range of indices
* due to {@link #setMaximumBarCount(int)}, for example: If you fill a BarSeries
* with 30 bars and then apply a {@code maximumBarCount} of 10 bars, the first
* 20 bars will be removed from the BarSeries. The indices going further from 0
* to 29 remain but return the same bar from 0 to 20. The remaining 9 bars are
* returned from index 21.
*
* @param i the index
* @return the bar at the i-th position
*/
Bar getBar(int i);
/**
* @return the first bar of the series
*/
default Bar getFirstBar() {
return getBar(getBeginIndex());
}
/**
* @return the last bar of the series
*/
default Bar getLastBar() {
return getBar(getEndIndex());
}
/**
* @return the number of bars in the series
*/
int getBarCount();
/**
* @return true if the series is empty, false otherwise
*/
default boolean isEmpty() {
return getBarCount() == 0;
}
/**
* Returns the raw bar data, i.e. it returns the current list object, which is
* used internally to store the {@link Bar bars}. It may be:
*
* <ul>
* <li>a shortened bar list if a {@code maximumBarCount} has been set.
* <li>an extended bar list if it is a constrained bar series.
* </ul>
*
* <p>
* <b>Warning:</b> This method should be used carefully!
*
* @return the raw bar data
*/
List<Bar> getBarData();
/**
* @return the begin index of the series
*/
int getBeginIndex();
/**
* @return the end index of the series
*/
int getEndIndex();
/**
* @return the description of the series period (e.g. "from 2014-01-21T12:00:00Z
* to 2014-01-21T12:15:00Z"); times are in UTC.
*/
default String getSeriesPeriodDescription() {
StringBuilder sb = new StringBuilder();
if (!getBarData().isEmpty()) {
var endTimeFirstBar = getFirstBar().getEndTime();
var endTimeLastBar = getLastBar().getEndTime();
DateTimeFormatter formatter = DateTimeFormatter.ISO_INSTANT;
sb.append(formatter.format(endTimeFirstBar)).append(" - ").append(formatter.format(endTimeLastBar));
}
return sb.toString();
}
/**
* @return the description of the series period (e.g. "from 12:00 21/01/2014 to
* 12:15 21/01/2014"); times are in system's default time zone.
*/
default String getSeriesPeriodDescriptionInSystemTimeZone() {
StringBuilder sb = new StringBuilder();
if (!getBarData().isEmpty()) {
var endTimeFirstBar = getFirstBar().getSystemZonedEndTime();
var endTimeLastBar = getLastBar().getSystemZonedEndTime();
DateTimeFormatter formatter = DateTimeFormatter.ISO_DATE_TIME;
sb.append(formatter.format(endTimeFirstBar)).append(" - ").append(formatter.format(endTimeLastBar));
}
return sb.toString();
}
/**
* @return the maximum number of bars
*/
int getMaximumBarCount();
/**
* Sets the maximum number of bars that will be retained in the series.
* <p>
* If a new bar is added to the series such that the number of bars will exceed
* the maximum bar count, then the FIRST bar in the series is automatically
* removed, ensuring that the maximum bar count is not exceeded. The indices of
* the bar series do not change.
*
* @param maximumBarCount the maximum bar count
*/
void setMaximumBarCount(int maximumBarCount);
/**
* @return the number of removed bars
*/
int getRemovedBarsCount();
/**
* Adds the {@code bar} at the end of the series.
*
* <p>
* The {@code beginIndex} is set to {@code 0} if not already initialized.<br>
* The {@code endIndex} is set to {@code 0} if not already initialized, or
* incremented if it matches the end of the series.<br>
* Exceeding bars are removed.
*
* @param bar the bar to be added
* @see BarSeries#setMaximumBarCount(int)
*/
default void addBar(Bar bar) {
addBar(bar, false);
}
/**
* Adds the {@code bar} at the end of the series.
*
* <p>
* The {@code beginIndex} is set to {@code 0} if not already initialized.<br>
* The {@code endIndex} is set to {@code 0} if not already initialized, or
* incremented if it matches the end of the series.<br>
* Exceeding bars are removed.
*
* @param bar the bar to be added
* @param replace true to replace the latest bar. Some exchanges continuously
* provide new bar data in the respective period, e.g. 1 second
* in 1 minute duration. Strategy checks run after a replace
* therefore evaluate an in-progress bar ("live candle"), not a
* closed candle.
* @see BarSeries#setMaximumBarCount(int)
*/
void addBar(Bar bar, boolean replace);
/**
* Adds a trade and updates the close price of the last bar.
*
* @param tradeVolume the traded volume
* @param tradePrice the price
* @see Bar#addTrade(Num, Num)
*/
default void addTrade(Number tradeVolume, Number tradePrice) {
addTrade(numFactory().numOf(tradeVolume), numFactory().numOf(tradePrice));
}
/**
* Adds a trade and updates the close price of the last bar.
*
* @param tradeVolume the traded volume
* @param tradePrice the price
* @see Bar#addTrade(Num, Num)
*/
void addTrade(Num tradeVolume, Num tradePrice);
/**
* Updates the close price of the last bar. The open, high and low prices are
* also updated as needed.
*
* @param price the price for the bar
* @see Bar#addPrice(Num)
*/
void addPrice(Num price);
/**
* Updates the close price of the last bar. The open, high and low prices are
* also updated as needed.
*
* @param price the price for the bar
* @see Bar#addPrice(Num)
*/
default void addPrice(Number price) {
addPrice(numFactory().numOf(price));
}
/**
* Returns a new {@link BarSeries} instance (= "subseries") that is a subset of
* {@code this} BarSeries instance. It contains a copy of all {@link Bar bars}
* between {@code startIndex} (inclusive) and {@code endIndex} (exclusive) of
* {@code this} instance. The indices of {@code this} and its subseries can be
* different, i. e. index 0 of the subseries will be the {@code startIndex} of
* {@code this}. If {@code startIndex} {@literal <} this.seriesBeginIndex, then
* the subseries will start with the first available bar of {@code this}. If
* {@code endIndex} {@literal >} this.seriesEndIndex, then the subseries will
* end at the last available bar of {@code this}.
*
* @param startIndex the startIndex (inclusive)
* @param endIndex the endIndex (exclusive)
* @return a new BarSeries with Bars from startIndex to endIndex-1
* @throws IllegalArgumentException if endIndex {@literal <=} startIndex or
* startIndex {@literal <} 0
*/
BarSeries getSubSeries(int startIndex, int endIndex);
}
@@ -0,0 +1,24 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
/**
* Interface to build a {@link BarSeries}.
*
* <p>
* Use {@link org.ta4j.core.BaseBarSeriesBuilder} for deterministic
* single-threaded workflows, and
* {@link org.ta4j.core.ConcurrentBarSeriesBuilder} when ingestion and
* evaluation may happen concurrently.
* </p>
*/
public interface BarSeriesBuilder {
/**
* Builds the bar series with corresponding parameters.
*
* @return bar series
*/
BarSeries build();
}
@@ -0,0 +1,222 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import org.ta4j.core.num.Num;
/**
* Base implementation of a {@link Bar}.
*/
public class BaseBar implements Bar {
private static final long serialVersionUID = 8038383777467488147L;
/** The time period (e.g. 1 day, 15 min, etc.) of the bar. */
private final Duration timePeriod;
/** The begin time of the bar period (in UTC). */
private final Instant beginTime;
/** The end time of the bar period (in UTC). */
private final Instant endTime;
/** The open price of the bar period. */
private Num openPrice;
/** The high price of the bar period. */
private Num highPrice;
/** The low price of the bar period. */
private Num lowPrice;
/** The close price of the bar period. */
private Num closePrice;
/** The total traded volume of the bar period. */
private Num volume;
/** The total traded amount of the bar period. */
private Num amount;
/** The number of trades of the bar period. */
private long trades;
/**
* Constructor.
*
* <ul>
* <li>If {@link #timePeriod} is not provided, it will be calculated as
* {@link #endTime} - {@link #beginTime}.
* <li>If {@link #beginTime} is not provided, it will be calculated as
* {@link #endTime} - {@link #timePeriod}.
* <li>If {@link #endTime} is not provided, it will be calculated as
* {@link #beginTime} + {@link #timePeriod}.
* </ul>
*
* @param timePeriod the time period (optional if beginTime and endTime is
* given)
* @param beginTime the begin time of the bar period (in UTC) (optional if
* endTime is given)
* @param endTime the end time of the bar period (in UTC) (optional if
* beginTime is given)
* @param openPrice the open price of the bar period
* @param highPrice the highest price of the bar period
* @param lowPrice the lowest price of the bar period
* @param closePrice the close price of the bar period
* @param volume the total traded volume of the bar period
* @param amount the total traded amount of the bar period
* @param trades the number of trades of the bar period
* @throws NullPointerException if given or calculated {@link #timePeriod},
* {@link #beginTime} or {@link #endTime}
* values are {@code null}
* @throws IllegalArgumentException If the calculated timePeriod between the
* provided beginTime and endTime does not
* match the provided timePeriod
*/
public BaseBar(Duration timePeriod, Instant beginTime, Instant endTime, Num openPrice, Num highPrice, Num lowPrice,
Num closePrice, Num volume, Num amount, long trades) {
// set timePeriod
if (timePeriod != null) {
if (beginTime != null && endTime != null
&& timePeriod.compareTo(Duration.between(beginTime, endTime)) != 0) {
throw new IllegalArgumentException(
"The calculated timePeriod between beginTime and endTime does not match the given timePeriod.");
}
this.timePeriod = timePeriod;
} else {
this.timePeriod = beginTime != null && endTime != null ? Duration.between(beginTime, endTime)
: Objects.requireNonNull(timePeriod, "Time period cannot be null");
}
// set beginTime
if (beginTime == null && endTime != null) {
this.beginTime = endTime.minus(timePeriod);
} else {
this.beginTime = Objects.requireNonNull(beginTime, "Begin time cannot be null");
}
// set endTime
if (beginTime != null && endTime == null) {
this.endTime = beginTime.plus(timePeriod);
} else {
this.endTime = Objects.requireNonNull(endTime, "End time cannot be null");
}
this.openPrice = openPrice;
this.highPrice = highPrice;
this.lowPrice = lowPrice;
this.closePrice = closePrice;
this.volume = volume;
this.amount = amount;
this.trades = trades;
}
@Override
public Duration getTimePeriod() {
return timePeriod;
}
@Override
public Instant getBeginTime() {
return beginTime;
}
@Override
public Instant getEndTime() {
return endTime;
}
@Override
public Num getOpenPrice() {
return openPrice;
}
@Override
public Num getHighPrice() {
return highPrice;
}
@Override
public Num getLowPrice() {
return lowPrice;
}
@Override
public Num getClosePrice() {
return closePrice;
}
@Override
public Num getVolume() {
return volume;
}
@Override
public Num getAmount() {
return amount;
}
@Override
public long getTrades() {
return trades;
}
@Override
public void addTrade(Num tradeVolume, Num tradePrice) {
addPrice(tradePrice);
volume = volume.plus(tradeVolume);
amount = amount.plus(tradeVolume.multipliedBy(tradePrice));
trades++;
}
@Override
public void addPrice(Num price) {
if (openPrice == null) {
openPrice = price;
}
closePrice = price;
if (highPrice == null || highPrice.isLessThan(price)) {
highPrice = price;
}
if (lowPrice == null || lowPrice.isGreaterThan(price)) {
lowPrice = price;
}
}
/**
* @return {end time, close price, open price, low price, high price, volume}
*/
@Override
public String toString() {
return String.format(
"{end time: %1s, close price: %2s, open price: %3s, low price: %4s high price: %5s, volume: %6s}",
endTime, closePrice, openPrice, lowPrice, highPrice, volume);
}
@Override
public int hashCode() {
return Objects.hash(beginTime, endTime, timePeriod, openPrice, highPrice, lowPrice, closePrice, volume, amount,
trades);
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (!(obj instanceof BaseBar))
return false;
final BaseBar other = (BaseBar) obj;
return Objects.equals(beginTime, other.beginTime) && Objects.equals(endTime, other.endTime)
&& Objects.equals(timePeriod, other.timePeriod) && Objects.equals(openPrice, other.openPrice)
&& Objects.equals(highPrice, other.highPrice) && Objects.equals(lowPrice, other.lowPrice)
&& Objects.equals(closePrice, other.closePrice) && Objects.equals(volume, other.volume)
&& Objects.equals(amount, other.amount) && trades == other.trades;
}
}
@@ -0,0 +1,355 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.ta4j.core.bars.TimeBarBuilderFactory;
import org.ta4j.core.num.DecimalNumFactory;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
import java.io.Serial;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* Base implementation of a {@link BarSeries}.
*
* <p>
* This is the default choice for single-threaded backtests and deterministic
* replay workflows. If your pipeline ingests bars/trades concurrently with
* strategy evaluation, prefer {@link ConcurrentBarSeries}.
* </p>
*/
public class BaseBarSeries implements BarSeries {
@Serial
private static final long serialVersionUID = -1878027009398790126L;
/**
* The logger.
*/
private final transient Logger log = LoggerFactory.getLogger(getClass());
/**
* The name of the bar series.
*/
private final String name;
/**
* The list of bars of the bar series.
*/
private final List<Bar> bars;
private final BarBuilderFactory barBuilderFactory;
private final NumFactory numFactory;
/**
* True if the current bar series is constrained (i.e. its indexes cannot
* change), false otherwise.
*/
private final boolean constrained;
/**
* The begin index of the bar series
*/
private int seriesBeginIndex = -1;
/**
* The end index of the bar series.
*/
private int seriesEndIndex = -1;
/**
* The maximum number of bars for the bar series.
*/
private int maximumBarCount = Integer.MAX_VALUE;
/**
* The number of removed bars.
*/
private int removedBarsCount = 0;
/**
* Convenience constructor for BaseBarSeries minimizing upfront parameter
* setting. Defaults to Time-based bars, and DecimalNum values
*
* @param name the name of the bar series
* @param bars the list of bars of the bar series
*/
public BaseBarSeries(final String name, final List<Bar> bars) {
this(name, bars, 0, bars.size() - 1, false, DecimalNumFactory.getInstance(), new TimeBarBuilderFactory());
}
/**
* Constructor.
*
* @param name the name of the bar series
* @param bars the list of bars of the bar series
* @param seriesBeginIndex the begin index (inclusive) of the bar series
* @param seriesEndIndex the end index (inclusive) of the bar series
* @param constrained true to constrain the bar series (i.e. indexes
* cannot change), false otherwise
* @param numFactory the factory of numbers used in series {@link Num Num
* implementation}
* @param barBuilderFactory factory for creating bars of this series
*/
BaseBarSeries(final String name, final List<Bar> bars, final int seriesBeginIndex, final int seriesEndIndex,
final boolean constrained, final NumFactory numFactory, final BarBuilderFactory barBuilderFactory) {
this.name = name;
this.numFactory = numFactory;
this.bars = new ArrayList<>(bars);
this.barBuilderFactory = Objects.requireNonNull(barBuilderFactory);
if (bars.isEmpty()) {
// Bar list empty
this.constrained = false;
return;
}
// Bar list not empty: checking indexes
if (seriesEndIndex < seriesBeginIndex - 1) {
throw new IllegalArgumentException("End index must be >= to begin index - 1");
}
if (seriesEndIndex >= bars.size()) {
throw new IllegalArgumentException("End index must be < to the bar list size");
}
this.seriesBeginIndex = seriesBeginIndex;
this.seriesEndIndex = seriesEndIndex;
this.constrained = constrained;
}
/**
* Cuts a list of bars into a new list of bars that is a subset of it.
*
* @param bars the list of {@link Bar bars}
* @param startIndex start index of the subset
* @param endIndex end index of the subset
* @return a new list of bars with tick from startIndex (inclusive) to endIndex
* (exclusive)
*/
private static List<Bar> cut(final List<Bar> bars, final int startIndex, final int endIndex) {
return new ArrayList<>(bars.subList(startIndex, endIndex));
}
/**
* @param series a bar series
* @param index an out-of-bounds bar index
* @return a message for an OutOfBoundsException
*/
private static String buildOutOfBoundsMessage(final BaseBarSeries series, final int index) {
return String.format("Size of series: %s bars, %s bars removed, index = %s", series.bars.size(),
series.removedBarsCount, index);
}
@Override
public BaseBarSeries getSubSeries(final int startIndex, final int endIndex) {
if (startIndex < 0) {
throw new IllegalArgumentException(String.format("the startIndex: %s must not be negative", startIndex));
}
if (startIndex >= endIndex) {
throw new IllegalArgumentException(
String.format("the endIndex: %s must be greater than startIndex: %s", endIndex, startIndex));
}
var builder = new BaseBarSeriesBuilder().withName(getName())
.withNumFactory(this.numFactory)
.withMaxBarCount(this.maximumBarCount);
if (!this.bars.isEmpty()) {
var removedBarsCount = getRemovedBarsCount();
var start = startIndex - removedBarsCount;
var end = Math.min(endIndex - removedBarsCount, this.getEndIndex() + 1);
return builder.withBars(cut(this.bars, start, end)).build();
}
return builder.build();
}
@Override
public NumFactory numFactory() {
return this.numFactory;
}
@Override
public BarBuilder barBuilder() {
return barBuilderFactory.createBarBuilder(this);
}
protected BarBuilderFactory barBuilderFactory() {
return barBuilderFactory;
}
@Override
public String getName() {
return this.name;
}
@Override
public Bar getBar(final int i) {
int innerIndex = i - this.removedBarsCount;
if (innerIndex < 0) {
if (i < 0) {
// Cannot return the i-th bar if i < 0
throw new IndexOutOfBoundsException(buildOutOfBoundsMessage(this, i));
}
if (this.log.isTraceEnabled()) {
this.log.trace("Bar series `{}` ({} bars): bar {} already removed, use {}-th instead", this.name,
this.bars.size(), i, this.removedBarsCount);
}
if (this.bars.isEmpty()) {
throw new IndexOutOfBoundsException(buildOutOfBoundsMessage(this, this.removedBarsCount));
}
innerIndex = 0;
} else if (innerIndex >= this.bars.size()) {
// Cannot return the n-th bar if n >= bars.size()
throw new IndexOutOfBoundsException(buildOutOfBoundsMessage(this, i));
}
return this.bars.get(innerIndex);
}
@Override
public int getBarCount() {
if (this.seriesEndIndex < 0) {
return 0;
}
final int startIndex = Math.max(this.removedBarsCount, this.seriesBeginIndex);
return this.seriesEndIndex - startIndex + 1;
}
@Override
public List<Bar> getBarData() {
return this.bars;
}
@Override
public int getBeginIndex() {
return this.seriesBeginIndex;
}
@Override
public int getEndIndex() {
return this.seriesEndIndex;
}
@Override
public int getMaximumBarCount() {
return this.maximumBarCount;
}
boolean isConstrained() {
return this.constrained;
}
@Override
public void setMaximumBarCount(final int maximumBarCount) {
if (this.constrained) {
throw new IllegalStateException("Cannot set a maximum bar count on a constrained bar series");
}
if (maximumBarCount <= 0) {
throw new IllegalArgumentException("Maximum bar count must be strictly positive");
}
this.maximumBarCount = maximumBarCount;
removeExceedingBars();
}
@Override
public int getRemovedBarsCount() {
return this.removedBarsCount;
}
/**
* @throws NullPointerException if {@code bar} is {@code null}
*/
@Override
public void addBar(final Bar bar, final boolean replace) {
Objects.requireNonNull(bar, "bar must not be null");
if (!numFactory.produces(bar.getClosePrice())) {
throw new IllegalArgumentException(
String.format("Cannot add Bar with data type: %s to series with datatype: %s",
bar.getClosePrice().getClass(), this.numFactory.one().getClass()));
}
if (!this.bars.isEmpty()) {
if (replace) {
this.bars.set(this.bars.size() - 1, bar);
return;
}
final int lastBarIndex = this.bars.size() - 1;
final Instant seriesEndTime = this.bars.get(lastBarIndex).getEndTime();
if (!bar.getEndTime().isAfter(seriesEndTime)) {
throw new IllegalArgumentException(
String.format("Cannot add a bar with end time:%s that is <= to series end time: %s",
bar.getEndTime(), seriesEndTime));
}
}
this.bars.add(bar);
if (this.seriesBeginIndex == -1) {
// The begin index is set to 0 if not already initialized:
this.seriesBeginIndex = 0;
}
this.seriesEndIndex++;
removeExceedingBars();
}
/**
* Replaces a bar at the provided series index without changing bar count or
* indices.
*
* @param index the series index to replace
* @param bar the replacement bar
*
* @throws NullPointerException if {@code bar} is {@code null}
* @throws IllegalArgumentException if the bar does not match the series
* numFactory
* @throws IndexOutOfBoundsException if the index is outside the current series
* window
*/
protected void replaceBar(final int index, final Bar bar) {
Objects.requireNonNull(bar, "bar must not be null");
if (!numFactory.produces(bar.getClosePrice())) {
throw new IllegalArgumentException(
String.format("Cannot add Bar with data type: %s to series with datatype: %s",
bar.getClosePrice().getClass(), this.numFactory.one().getClass()));
}
if (index < this.seriesBeginIndex || index > this.seriesEndIndex || this.bars.isEmpty()) {
throw new IndexOutOfBoundsException(buildOutOfBoundsMessage(this, index));
}
final int innerIndex = index - this.removedBarsCount;
if (innerIndex < 0 || innerIndex >= this.bars.size()) {
throw new IndexOutOfBoundsException(buildOutOfBoundsMessage(this, index));
}
this.bars.set(innerIndex, bar);
}
@Override
public void addTrade(final Number tradeVolume, final Number tradePrice) {
addTrade(numFactory().numOf(tradeVolume), numFactory().numOf(tradePrice));
}
@Override
public void addTrade(final Num tradeVolume, final Num tradePrice) {
getLastBar().addTrade(tradeVolume, tradePrice);
}
@Override
public void addPrice(final Num price) {
getLastBar().addPrice(price);
}
/**
* Removes the first N bars that exceed the {@link #maximumBarCount}.
*/
protected void removeExceedingBars() {
final int barCount = this.bars.size();
if (barCount > this.maximumBarCount) {
// Removing old bars
final int nbBarsToRemove = barCount - this.maximumBarCount;
if (nbBarsToRemove == 1) {
this.bars.removeFirst();
} else {
this.bars.subList(0, nbBarsToRemove).clear();
}
// Updating removed bars count
this.removedBarsCount += nbBarsToRemove;
this.seriesBeginIndex = Math.max(this.seriesBeginIndex, this.removedBarsCount);
}
}
}
@@ -0,0 +1,145 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.util.ArrayList;
import java.util.List;
import org.ta4j.core.bars.TimeBarBuilderFactory;
import org.ta4j.core.num.DecimalNumFactory;
import org.ta4j.core.num.NumFactory;
/**
* A builder to build a new {@link BaseBarSeries}.
*/
public class BaseBarSeriesBuilder implements BarSeriesBuilder {
/** The {@link #name} for an unnamed bar series. */
private static final String UNNAMED_SERIES_NAME = "unnamed_series";
private List<Bar> bars;
private String name;
private boolean constrained;
private int maxBarCount;
private boolean isNumFactoryAssigned = false;
private NumFactory numFactory = DecimalNumFactory.getInstance();
private BarBuilderFactory barBuilderFactory = new TimeBarBuilderFactory();
/** Constructor to build a {@code BaseBarSeries}. */
public BaseBarSeriesBuilder() {
initValues();
}
private void initValues() {
this.bars = new ArrayList<>();
this.name = "unnamed_series";
this.constrained = false;
this.maxBarCount = Integer.MAX_VALUE;
}
@Override
public BaseBarSeries build() {
int beginIndex = -1;
int endIndex = -1;
if (!bars.isEmpty()) {
beginIndex = 0;
endIndex = bars.size() - 1;
if (!isNumFactoryAssigned) {
// use numFactory derived from bars instead of default numFactory
numFactory = bars.getFirst().numFactory();
}
// check if each bar has the same numFactory as the series numFactory
for (var bar : bars) {
if (bar.getClosePrice() != null) {
if (!numFactory.produces(bar.getClosePrice())) {
throw new IllegalArgumentException(
String.format("Cannot add Bar with data type: %s to series with datatype: %s",
bar.getClosePrice().getClass(), this.numFactory.one().getClass()));
}
}
}
}
var series = new BaseBarSeries(name == null ? UNNAMED_SERIES_NAME : name, bars, beginIndex, endIndex,
constrained, numFactory, barBuilderFactory);
series.setMaximumBarCount(maxBarCount);
initValues(); // reinitialize values for next series
return series;
}
/**
* @param constrained to set
* @return {@code this}
*
* @deprecated Constrained mode is being derived from max-bar-count
* configuration instead of being set directly. Prefer configuring
* retention via {@link #withMaxBarCount(int)} (or omit it for the
* default constrained behavior).
*/
@Deprecated(since = "0.22.2")
public BaseBarSeriesBuilder setConstrained(boolean constrained) {
this.constrained = constrained;
return this;
}
/**
* @param numFactory to set {@link BaseBarSeries#numFactory()} (by default, uses
* either {@link DecimalNumFactory} or {@code numFactory}
* derived from {@link #bars})
* @return {@code this}
*/
public BaseBarSeriesBuilder withNumFactory(NumFactory numFactory) {
if (numFactory != null) {
// user has explicitly assigned a numFactory
isNumFactoryAssigned = true;
}
this.numFactory = numFactory;
return this;
}
/**
* @param name to set {@link BaseBarSeries#getName()}
* @return {@code this}
*/
public BaseBarSeriesBuilder withName(String name) {
this.name = name;
return this;
}
/**
* @param bars to set {@link BaseBarSeries#getBarData()}; If {@link #numFactory}
* is not assigned by {@link #withNumFactory(NumFactory)},
* {@link #numFactory} defaults to the {@code numFactory} of the
* {@code bars}.
* @return {@code this}
*/
public BaseBarSeriesBuilder withBars(List<Bar> bars) {
this.bars = bars;
return this;
}
/**
* @param maxBarCount to set {@link BaseBarSeries#getMaximumBarCount()}
* @return {@code this}
*/
public BaseBarSeriesBuilder withMaxBarCount(int maxBarCount) {
this.maxBarCount = maxBarCount;
return this;
}
/**
* @param barBuilderFactory to build bars with the same datatype as series (by
* default, uses {@link TimeBarBuilderFactory})
*
* @return {@code this}
*/
public BaseBarSeriesBuilder withBarBuilderFactory(final BarBuilderFactory barBuilderFactory) {
this.barBuilderFactory = barBuilderFactory;
return this;
}
}
@@ -0,0 +1,210 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* {@link Bar} implementation that tracks realtime side and liquidity
* breakdowns.
*
* @since 0.22.2
*/
public class BaseRealtimeBar extends BaseBar implements RealtimeBar {
private static final long serialVersionUID = -5572746191534014724L;
private final NumFactory numFactory;
private Num buyVolume;
private Num sellVolume;
private Num buyAmount;
private Num sellAmount;
private long buyTrades;
private long sellTrades;
private boolean hasSideData;
private Num makerVolume;
private Num takerVolume;
private Num makerAmount;
private Num takerAmount;
private long makerTrades;
private long takerTrades;
private boolean hasLiquidityData;
/**
* Constructor.
*
* @param timePeriod the time period (optional if beginTime and endTime is
* given)
* @param beginTime the begin time of the bar period (in UTC) (optional if
* endTime is given)
* @param endTime the end time of the bar period (in UTC) (optional if
* beginTime is given)
* @param openPrice the open price of the bar period
* @param highPrice the highest price of the bar period
* @param lowPrice the lowest price of the bar period
* @param closePrice the close price of the bar period
* @param volume the total traded volume of the bar period
* @param amount the total traded amount of the bar period
* @param trades the number of trades of the bar period
* @param buyVolume buy-side volume
* @param sellVolume sell-side volume
* @param buyAmount buy-side amount
* @param sellAmount sell-side amount
* @param buyTrades buy-side trades
* @param sellTrades sell-side trades
* @param makerVolume maker-side volume
* @param takerVolume taker-side volume
* @param makerAmount maker-side amount
* @param takerAmount taker-side amount
* @param makerTrades maker-side trades
* @param takerTrades taker-side trades
* @param hasSideData {@code true} if side data was provided
* @param hasLiquidity {@code true} if liquidity data was provided
* @param numFactory the number factory backing this bar
*
* @since 0.22.2
*/
public BaseRealtimeBar(final Duration timePeriod, final Instant beginTime, final Instant endTime,
final Num openPrice, final Num highPrice, final Num lowPrice, final Num closePrice, final Num volume,
final Num amount, final long trades, final Num buyVolume, final Num sellVolume, final Num buyAmount,
final Num sellAmount, final long buyTrades, final long sellTrades, final Num makerVolume,
final Num takerVolume, final Num makerAmount, final Num takerAmount, final long makerTrades,
final long takerTrades, final boolean hasSideData, final boolean hasLiquidity,
final NumFactory numFactory) {
super(timePeriod, beginTime, endTime, openPrice, highPrice, lowPrice, closePrice, volume, amount, trades);
this.numFactory = Objects.requireNonNull(numFactory, "numFactory cannot be null");
this.buyVolume = buyVolume;
this.sellVolume = sellVolume;
this.buyAmount = buyAmount;
this.sellAmount = sellAmount;
this.buyTrades = buyTrades;
this.sellTrades = sellTrades;
this.hasSideData = hasSideData;
this.makerVolume = makerVolume;
this.takerVolume = takerVolume;
this.makerAmount = makerAmount;
this.takerAmount = takerAmount;
this.makerTrades = makerTrades;
this.takerTrades = takerTrades;
this.hasLiquidityData = hasLiquidity;
}
@Override
public boolean hasSideData() {
return hasSideData;
}
@Override
public boolean hasLiquidityData() {
return hasLiquidityData;
}
@Override
public Num getBuyVolume() {
return buyVolume == null ? numFactory.zero() : buyVolume;
}
@Override
public Num getSellVolume() {
return sellVolume == null ? numFactory.zero() : sellVolume;
}
@Override
public Num getBuyAmount() {
return buyAmount == null ? numFactory.zero() : buyAmount;
}
@Override
public Num getSellAmount() {
return sellAmount == null ? numFactory.zero() : sellAmount;
}
@Override
public long getBuyTrades() {
return buyTrades;
}
@Override
public long getSellTrades() {
return sellTrades;
}
@Override
public Num getMakerVolume() {
return makerVolume == null ? numFactory.zero() : makerVolume;
}
@Override
public Num getTakerVolume() {
return takerVolume == null ? numFactory.zero() : takerVolume;
}
@Override
public Num getMakerAmount() {
return makerAmount == null ? numFactory.zero() : makerAmount;
}
@Override
public Num getTakerAmount() {
return takerAmount == null ? numFactory.zero() : takerAmount;
}
@Override
public long getMakerTrades() {
return makerTrades;
}
@Override
public long getTakerTrades() {
return takerTrades;
}
@Override
public void addTrade(final Num tradeVolume, final Num tradePrice, final Side side, final Liquidity liquidity) {
super.addTrade(tradeVolume, tradePrice);
addSideData(tradeVolume, tradePrice, side);
addLiquidityData(tradeVolume, tradePrice, liquidity);
}
private void addSideData(final Num tradeVolume, final Num tradePrice, final Side side) {
if (side == null) {
return;
}
hasSideData = true;
final Num tradeAmount = tradePrice.multipliedBy(tradeVolume);
if (side == Side.BUY) {
buyVolume = buyVolume == null ? tradeVolume : buyVolume.plus(tradeVolume);
buyAmount = buyAmount == null ? tradeAmount : buyAmount.plus(tradeAmount);
buyTrades++;
} else {
sellVolume = sellVolume == null ? tradeVolume : sellVolume.plus(tradeVolume);
sellAmount = sellAmount == null ? tradeAmount : sellAmount.plus(tradeAmount);
sellTrades++;
}
}
private void addLiquidityData(final Num tradeVolume, final Num tradePrice, final Liquidity liquidity) {
if (liquidity == null) {
return;
}
hasLiquidityData = true;
final Num tradeAmount = tradePrice.multipliedBy(tradeVolume);
if (liquidity == Liquidity.MAKER) {
makerVolume = makerVolume == null ? tradeVolume : makerVolume.plus(tradeVolume);
makerAmount = makerAmount == null ? tradeAmount : makerAmount.plus(tradeAmount);
makerTrades++;
} else {
takerVolume = takerVolume == null ? tradeVolume : takerVolume.plus(tradeVolume);
takerAmount = takerAmount == null ? tradeAmount : takerAmount.plus(tradeAmount);
takerTrades++;
}
}
}
@@ -0,0 +1,334 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.ta4j.core.Trade.TradeType;
/**
* Base implementation of a {@link Strategy}.
*/
public class BaseStrategy implements Strategy {
/** The logger. */
protected final Logger log = LoggerFactory.getLogger(getClass());
/** The class name. */
private final String className = getClass().getSimpleName();
/** The name of the strategy. */
private final String name;
/** The entry rule. */
private final Rule entryRule;
/** The exit rule. */
private final Rule exitRule;
/** The entry trade type for this strategy. */
private final TradeType startingType;
/**
* The number of first bars in a bar series that this strategy ignores. During
* the unstable bars of the strategy, any trade placement will be canceled i.e.
* no entry/exit signal will be triggered before {@code index == unstableBars}.
*/
private int unstableBars;
/**
* Constructor.
*
* @param entryRule the entry rule
* @param exitRule the exit rule
*/
public BaseStrategy(Rule entryRule, Rule exitRule) {
this(null, entryRule, exitRule, 0, TradeType.BUY);
}
/**
* Constructor.
*
* @param entryRule the entry rule
* @param exitRule the exit rule
* @param unstableBars strategy will ignore possible signals at
* {@code index < unstableBars}
*/
public BaseStrategy(Rule entryRule, Rule exitRule, int unstableBars) {
this(null, entryRule, exitRule, unstableBars, TradeType.BUY);
}
/**
* Constructor.
*
* @param entryRule the entry rule
* @param exitRule the exit rule
* @param startingType the entry trade type
* @since 0.22.2
*/
public BaseStrategy(Rule entryRule, Rule exitRule, TradeType startingType) {
this(null, entryRule, exitRule, 0, startingType);
}
/**
* Constructor.
*
* @param entryRule the entry rule
* @param exitRule the exit rule
* @param unstableBars strategy will ignore possible signals at
* {@code index < unstableBars}
* @param startingType the entry trade type
* @since 0.22.2
*/
public BaseStrategy(Rule entryRule, Rule exitRule, int unstableBars, TradeType startingType) {
this(null, entryRule, exitRule, unstableBars, startingType);
}
/**
* Constructor.
*
* @param name the name of the strategy
* @param entryRule the entry rule
* @param exitRule the exit rule
*/
public BaseStrategy(String name, Rule entryRule, Rule exitRule) {
this(name, entryRule, exitRule, 0, TradeType.BUY);
}
/**
* Constructor.
*
* @param name the name of the strategy
* @param entryRule the entry rule
* @param exitRule the exit rule
* @param startingType the entry trade type
* @since 0.22.2
*/
public BaseStrategy(String name, Rule entryRule, Rule exitRule, TradeType startingType) {
this(name, entryRule, exitRule, 0, startingType);
}
/**
* Constructor.
*
* @param name the name of the strategy
* @param entryRule the entry rule
* @param exitRule the exit rule
* @param unstableBars strategy will ignore possible signals at
* {@code index < unstableBars}
* @throws IllegalArgumentException if entryRule or exitRule is null
*/
public BaseStrategy(String name, Rule entryRule, Rule exitRule, int unstableBars) {
this(name, entryRule, exitRule, unstableBars, TradeType.BUY);
}
/**
* Constructor.
*
* @param name the name of the strategy
* @param entryRule the entry rule
* @param exitRule the exit rule
* @param unstableBars strategy will ignore possible signals at
* {@code index < unstableBars}
* @param startingType the entry trade type
* @throws IllegalArgumentException if entryRule or exitRule is null
* @since 0.22.2
*/
public BaseStrategy(String name, Rule entryRule, Rule exitRule, int unstableBars, TradeType startingType) {
if (entryRule == null || exitRule == null) {
throw new IllegalArgumentException("Rules cannot be null");
}
if (unstableBars < 0) {
throw new IllegalArgumentException("Unstable bars must be >= 0");
}
if (startingType == null) {
throw new IllegalArgumentException("Starting type cannot be null");
}
this.name = name;
this.entryRule = entryRule;
this.exitRule = exitRule;
this.unstableBars = unstableBars;
this.startingType = startingType;
}
@Override
public String getName() {
return name;
}
@Override
public Rule getEntryRule() {
return entryRule;
}
@Override
public Rule getExitRule() {
return exitRule;
}
@Override
public TradeType getStartingType() {
return startingType;
}
@Override
public int getUnstableBars() {
return unstableBars;
}
@Override
public void setUnstableBars(int unstableBars) {
this.unstableBars = unstableBars;
}
@Override
public boolean isUnstableAt(int index) {
return index < unstableBars;
}
@Override
public boolean shouldEnter(int index, TradingRecord tradingRecord) {
return evaluateShouldEnter(index, tradingRecord, Rule.TraceMode.VERBOSE);
}
/**
* {@inheritDoc}
*
* @since 0.22.7
*/
@Override
public boolean shouldEnterWithTraceMode(int index, TradingRecord tradingRecord, Rule.TraceMode traceMode) {
return evaluateShouldEnter(index, tradingRecord, traceMode);
}
private boolean evaluateShouldEnter(int index, TradingRecord tradingRecord, Rule.TraceMode requestedTraceMode) {
Rule.TraceMode activeTraceMode = requestedTraceMode == null ? Rule.TraceMode.VERBOSE : requestedTraceMode;
boolean traceLoggingEnabled = log.isTraceEnabled();
if (isUnstableAt(index)) {
traceShouldEnter(index, false, traceLoggingEnabled, activeTraceMode, "unstable");
return false;
}
boolean enter = traceLoggingEnabled
? getEntryRule().isSatisfiedWithTraceMode(index, tradingRecord, activeTraceMode)
: getEntryRule().isSatisfied(index, tradingRecord);
traceShouldEnter(index, enter, traceLoggingEnabled, activeTraceMode, enter ? null : "entryRule");
return enter;
}
@Override
public boolean shouldExit(int index, TradingRecord tradingRecord) {
return evaluateShouldExit(index, tradingRecord, Rule.TraceMode.VERBOSE);
}
/**
* {@inheritDoc}
*
* @since 0.22.7
*/
@Override
public boolean shouldExitWithTraceMode(int index, TradingRecord tradingRecord, Rule.TraceMode traceMode) {
return evaluateShouldExit(index, tradingRecord, traceMode);
}
private boolean evaluateShouldExit(int index, TradingRecord tradingRecord, Rule.TraceMode requestedTraceMode) {
Rule.TraceMode activeTraceMode = requestedTraceMode == null ? Rule.TraceMode.VERBOSE : requestedTraceMode;
boolean traceLoggingEnabled = log.isTraceEnabled();
if (isUnstableAt(index)) {
traceShouldExit(index, false, traceLoggingEnabled, activeTraceMode, "unstable");
return false;
}
boolean exit = traceLoggingEnabled
? getExitRule().isSatisfiedWithTraceMode(index, tradingRecord, activeTraceMode)
: getExitRule().isSatisfied(index, tradingRecord);
traceShouldExit(index, exit, traceLoggingEnabled, activeTraceMode, exit ? null : "exitRule");
return exit;
}
@Override
public Strategy and(Strategy strategy) {
String andName = "and(" + name + "," + strategy.getName() + ")";
int unstable = Math.max(unstableBars, strategy.getUnstableBars());
return and(andName, strategy, unstable);
}
@Override
public Strategy or(Strategy strategy) {
String orName = "or(" + name + "," + strategy.getName() + ")";
int unstable = Math.max(unstableBars, strategy.getUnstableBars());
return or(orName, strategy, unstable);
}
@Override
public Strategy opposite() {
return new BaseStrategy("opposite(" + name + ")", exitRule, entryRule, unstableBars, startingType);
}
@Override
public Strategy and(String name, Strategy strategy, int unstableBars) {
return new BaseStrategy(name, entryRule.and(strategy.getEntryRule()), exitRule.and(strategy.getExitRule()),
unstableBars, getStartingType());
}
@Override
public Strategy or(String name, Strategy strategy, int unstableBars) {
return new BaseStrategy(name, entryRule.or(strategy.getEntryRule()), exitRule.or(strategy.getExitRule()),
unstableBars, getStartingType());
}
/**
* Returns the display name to use in trace logs. Uses the configured name if
* set, otherwise falls back to the class name.
*
* @return display name for tracing
*/
protected String getTraceDisplayName() {
return name != null ? name : className;
}
/**
* Traces the {@code shouldEnter()} method calls.
*
* @param index the bar index
* @param enter true if the strategy should enter, false otherwise
*/
protected void traceShouldEnter(int index, boolean enter) {
traceShouldEnter(index, enter, log.isTraceEnabled(), Rule.TraceMode.VERBOSE, enter ? null : "entryRule");
}
private void traceShouldEnter(int index, boolean enter, boolean traceLoggingEnabled, Rule.TraceMode activeTraceMode,
String reason) {
if (traceLoggingEnabled) {
log.trace(">>> {}#shouldEnter({}): {} mode={}{}", getTraceDisplayName(), index, enter, activeTraceMode,
strategyTraceContext(reason));
}
}
/**
* Traces the {@code shouldExit()} method calls.
*
* @param index the bar index
* @param exit true if the strategy should exit, false otherwise
*/
protected void traceShouldExit(int index, boolean exit) {
traceShouldExit(index, exit, log.isTraceEnabled(), Rule.TraceMode.VERBOSE, exit ? null : "exitRule");
}
private void traceShouldExit(int index, boolean exit, boolean traceLoggingEnabled, Rule.TraceMode activeTraceMode,
String reason) {
if (traceLoggingEnabled) {
log.trace(">>> {}#shouldExit({}): {} mode={}{}", getTraceDisplayName(), index, exit, activeTraceMode,
strategyTraceContext(reason));
}
}
private String strategyTraceContext(String reason) {
if (reason == null) {
return "";
}
if ("unstable".equals(reason)) {
return " reason=unstable unstableBars=" + unstableBars;
}
return " reason=" + reason;
}
}
@@ -0,0 +1,620 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import java.io.Serial;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.analysis.cost.RecordedTradeCostModel;
import org.ta4j.core.analysis.cost.ZeroCostModel;
import org.ta4j.core.num.Num;
/**
* Unified {@link Trade} implementation for backtest and live flows.
*
* <ul>
* <li>the index (in the {@link BarSeries bar series}) on which the trade is
* executed
* <li>a {@link Trade.TradeType type} (BUY or SELL)
* <li>a pricePerAsset (optional)
* <li>a trade amount (optional)
* </ul>
*
* A {@link Position position} is a pair of complementary trades.
*
* <p>
* Trades are backed by one or more {@link TradeFill} entries. Scalar
* constructors create a single fill; aggregated constructors preserve full fill
* progression.
* </p>
*
* @since 0.22.4
*/
public class BaseTrade implements Trade {
@Serial
private static final long serialVersionUID = -905474949010114150L;
private static final Gson GSON = new Gson();
private static final CostModel DEFAULT_COST_MODEL = new ZeroCostModel();
/** The type of the trade. */
private final Trade.TradeType type;
/** The index the trade was executed. */
private final int index;
/** The trade price per asset. */
private Num pricePerAsset;
/**
* The net price per asset for the trade (i.e. {@link #pricePerAsset} with
* {@link #cost}).
*/
private Num netPrice;
/** The trade amount. */
private final Num amount;
/** Execution fills for this trade (single fill for scalar trades). */
private final List<TradeFill> fills;
/** Execution timestamp. */
private final Instant time;
/** Execution side. */
private final ExecutionSide side;
/** Optional order id. */
private final String orderId;
/** Optional correlation id. */
private final String correlationId;
/**
* The simulated execution cost for this trade, derived from the configured
* {@link CostModel}.
*/
private Num cost;
/** The cost model for trade execution. */
private transient CostModel costModel;
/**
* Constructor.
*
* @param index the index the trade is executed
* @param series the bar series
* @param type the trade type
*/
protected BaseTrade(int index, BarSeries series, Trade.TradeType type) {
this(index, series, type, series.numFactory().one());
}
/**
* Constructor.
*
* @param index the index the trade is executed
* @param series the bar series
* @param type the trade type
* @param amount the trade amount
*/
protected BaseTrade(int index, BarSeries series, Trade.TradeType type, Num amount) {
this(index, series, type, amount, new ZeroCostModel());
}
/**
* Constructor.
*
* @param index the index the trade is executed
* @param series the bar series
* @param type the trade type
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution cost
*/
protected BaseTrade(int index, BarSeries series, Trade.TradeType type, Num amount, CostModel transactionCostModel) {
Num executionPrice = series.getBar(index).getClosePrice();
Instant executionTime = series.getBar(index).getEndTime();
this.type = type;
this.index = index;
this.amount = amount;
this.time = executionTime;
this.side = executionSide(type);
this.orderId = null;
this.correlationId = null;
this.fills = List.of(new TradeFill(index, executionTime, executionPrice, amount, side));
setPricesAndCost(executionPrice, amount, transactionCostModel, this.fills);
}
/**
* Constructor.
*
* @param index the index the trade is executed
* @param type the trade type
* @param pricePerAsset the trade price per asset
*/
protected BaseTrade(int index, Trade.TradeType type, Num pricePerAsset) {
this(index, type, pricePerAsset, pricePerAsset.getNumFactory().one());
}
/**
* Constructor.
*
* @param index the index the trade is executed
* @param type the trade type
* @param pricePerAsset the trade price per asset
* @param amount the trade amount
*/
protected BaseTrade(int index, Trade.TradeType type, Num pricePerAsset, Num amount) {
this(index, type, pricePerAsset, amount, new ZeroCostModel());
}
/**
* Constructor.
*
* @param index the index the trade is executed
* @param type the trade type
* @param pricePerAsset the trade price per asset
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution
*/
protected BaseTrade(int index, Trade.TradeType type, Num pricePerAsset, Num amount,
CostModel transactionCostModel) {
this.type = type;
this.index = index;
this.amount = amount;
this.time = null;
this.side = executionSide(type);
this.orderId = null;
this.correlationId = null;
this.fills = List.of(new TradeFill(index, null, pricePerAsset, amount, side));
setPricesAndCost(pricePerAsset, amount, transactionCostModel, this.fills);
}
/**
* Constructor for multi-fill trades.
*
* @param type trade type
* @param fills execution fills (must not be empty)
* @param transactionCostModel the cost model for trade execution
* @since 0.22.4
*/
protected BaseTrade(Trade.TradeType type, List<TradeFill> fills, CostModel transactionCostModel) {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(transactionCostModel, "transactionCostModel");
FillSummary fillSummary = summarizeFills(type, fills);
FillMetadata metadata = summarizeMetadata(type, fillSummary.firstFill());
this.type = type;
this.index = fillSummary.firstFill().index();
this.amount = fillSummary.totalAmount();
this.time = metadata.time();
this.side = metadata.side();
this.orderId = metadata.orderId();
this.correlationId = metadata.correlationId();
this.fills = fillSummary.fills();
setPricesAndCost(fillSummary.weightedAveragePrice(), fillSummary.totalAmount(), transactionCostModel,
this.fills);
}
/**
* Constructor for live execution trades.
*
* @param index trade index
* @param time execution timestamp
* @param pricePerAsset execution price per asset
* @param amount execution amount
* @param fee recorded execution fee (nullable, defaults to zero)
* @param side execution side
* @param orderId optional order id
* @param correlationId optional correlation id
* @since 0.22.4
*/
public BaseTrade(int index, Instant time, Num pricePerAsset, Num amount, Num fee, ExecutionSide side,
String orderId, String correlationId) {
if (index < 0) {
throw new IllegalArgumentException("index must be >= 0");
}
Objects.requireNonNull(time, "time");
Objects.requireNonNull(pricePerAsset, "pricePerAsset");
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(side, "side");
Num normalizedFee = fee == null ? pricePerAsset.getNumFactory().zero() : fee;
this.type = side.toTradeType();
this.index = index;
this.amount = amount;
this.time = time;
this.side = side;
this.orderId = orderId;
this.correlationId = correlationId;
this.fills = List
.of(new TradeFill(index, time, pricePerAsset, amount, normalizedFee, side, orderId, correlationId));
setPricesAndCost(pricePerAsset, amount, RecordedTradeCostModel.INSTANCE, this.fills);
}
@Override
public Trade.TradeType getType() {
return type;
}
@Override
public Num getCost() {
return cost;
}
@Override
public int getIndex() {
return index;
}
@Override
public Num getPricePerAsset() {
return pricePerAsset;
}
@Override
public Num getPricePerAsset(BarSeries barSeries) {
if (pricePerAsset.isNaN()) {
return barSeries.getBar(index).getClosePrice();
}
return pricePerAsset;
}
@Override
public Num getNetPrice() {
return netPrice;
}
@Override
public Num getAmount() {
return amount;
}
@Override
public Instant getTime() {
return time;
}
@Override
public String getOrderId() {
return orderId;
}
@Override
public String getCorrelationId() {
return correlationId;
}
@Override
public List<TradeFill> getFills() {
return exportedFills();
}
/**
* @return execution timestamp
* @since 0.22.4
*/
public Instant time() {
return time;
}
/**
* @return execution price per asset
* @since 0.22.4
*/
public Num price() {
return pricePerAsset;
}
/**
* @return execution amount
* @since 0.22.4
*/
public Num amount() {
return amount;
}
/**
* @return recorded fee/cost
* @since 0.22.4
*/
public Num fee() {
return cost;
}
/**
* @return execution side
* @since 0.22.4
*/
public ExecutionSide side() {
return side;
}
/**
* @return optional order id
* @since 0.22.4
*/
public String orderId() {
return orderId;
}
/**
* @return optional correlation id
* @since 0.22.4
*/
public String correlationId() {
return correlationId;
}
/**
* @return the configured cost model, or a zero-cost model after deserialization
* when the transient model is unset
*
* @since 0.22.4
*/
@Override
public CostModel getCostModel() {
return costModel == null ? DEFAULT_COST_MODEL : costModel;
}
/**
* Sets the raw and net prices of the trade.
*
* @param pricePerAsset the raw price of the asset
* @param amount the amount of assets ordered
* @param transactionCostModel the cost model for trade execution
*/
private void setPricesAndCost(Num pricePerAsset, Num amount, CostModel transactionCostModel,
List<TradeFill> fills) {
Objects.requireNonNull(transactionCostModel, "transactionCostModel");
this.costModel = transactionCostModel;
this.pricePerAsset = pricePerAsset;
this.cost = resolveCost(transactionCostModel, this.pricePerAsset, amount, fills);
if (amount.isZero()) {
this.netPrice = this.pricePerAsset;
return;
}
Num costPerAsset = cost.dividedBy(amount);
// add transaction costs to the pricePerAsset at the trade
if (type.equals(Trade.TradeType.BUY)) {
this.netPrice = this.pricePerAsset.plus(costPerAsset);
} else {
this.netPrice = this.pricePerAsset.minus(costPerAsset);
}
}
private static Num resolveCost(CostModel transactionCostModel, Num pricePerAsset, Num amount,
List<TradeFill> fills) {
if (transactionCostModel instanceof RecordedTradeCostModel) {
return sumFillFees(pricePerAsset.getNumFactory().zero(), fills);
}
return transactionCostModel.calculate(pricePerAsset, amount);
}
private static Num sumFillFees(Num zero, List<TradeFill> fills) {
Num totalFee = zero;
for (TradeFill fill : fills) {
totalFee = totalFee.plus(fill.fee());
}
return totalFee;
}
private static FillSummary summarizeFills(Trade.TradeType tradeType, List<TradeFill> fills) {
Objects.requireNonNull(fills, "fills");
if (fills.isEmpty()) {
throw new IllegalArgumentException("fills must not be empty");
}
Num totalAmount = fills.getFirst().amount().getNumFactory().zero();
Num weightedPrice = fills.getFirst().price().getNumFactory().zero();
TradeFill earliestFill = fills.getFirst();
ExecutionSide expectedSide = executionSide(tradeType);
for (TradeFill fill : fills) {
if (fill.side() != null && fill.side() != expectedSide) {
throw new IllegalArgumentException("fill side must match trade type at index " + fill.index());
}
if (fill.price().isNaN()) {
throw new IllegalArgumentException("fill price must be set");
}
if (fill.amount().isNaN() || fill.amount().isZero() || fill.amount().isNegative()) {
throw new IllegalArgumentException("fill amount must be positive");
}
if (fill.index() < earliestFill.index()) {
earliestFill = fill;
}
totalAmount = totalAmount.plus(fill.amount());
weightedPrice = weightedPrice.plus(fill.price().multipliedBy(fill.amount()));
}
return new FillSummary(List.copyOf(fills), earliestFill, totalAmount, weightedPrice.dividedBy(totalAmount));
}
private static FillMetadata summarizeMetadata(Trade.TradeType tradeType, TradeFill firstFill) {
Instant firstTime = firstFill.time();
String firstOrderId = firstFill.orderId();
String firstCorrelationId = firstFill.correlationId();
ExecutionSide resolvedSide = firstFill.side() == null ? executionSide(tradeType) : firstFill.side();
return new FillMetadata(firstTime, resolvedSide, firstOrderId, firstCorrelationId);
}
/**
* Exports fills with trade-level modeled costs apportioned back onto the fills
* when no explicit per-fill fees were recorded.
*/
private List<TradeFill> exportedFills() {
if (fills.isEmpty() || cost == null || cost.isNaN()) {
return fills;
}
CostModel effectiveCostModel = getCostModel();
if (effectiveCostModel instanceof RecordedTradeCostModel) {
return fills;
}
Num zero = fills.getFirst().price().getNumFactory().zero();
Num recordedFeeTotal = sumFillFees(zero, fills);
Num residualFee = cost.minus(recordedFeeTotal);
if (!residualFee.isPositive()) {
return fills;
}
Num totalWeight = totalFillWeight(zero);
if (totalWeight.isZero()) {
return fills;
}
Num remainingFee = residualFee;
List<TradeFill> adjustedFills = new ArrayList<>(fills.size());
for (int i = 0; i < fills.size(); i++) {
TradeFill fill = fills.get(i);
Num feeShare = i == fills.size() - 1 ? remainingFee
: residualFee.multipliedBy(fillWeight(fill)).dividedBy(totalWeight);
remainingFee = remainingFee.minus(feeShare);
adjustedFills.add(copyWithFee(fill, fill.fee().plus(feeShare)));
}
return List.copyOf(adjustedFills);
}
private Num totalFillWeight(Num zero) {
Num totalWeight = zero;
for (TradeFill fill : fills) {
totalWeight = totalWeight.plus(fillWeight(fill));
}
return totalWeight;
}
private Num fillWeight(TradeFill fill) {
return fill.price().multipliedBy(fill.amount());
}
private TradeFill copyWithFee(TradeFill fill, Num fee) {
return new TradeFill(fill.index(), fill.time(), fill.price(), fill.amount(), fee, fill.side(), fill.orderId(),
fill.correlationId());
}
private static ExecutionSide executionSide(Trade.TradeType tradeType) {
if (tradeType == Trade.TradeType.BUY) {
return ExecutionSide.BUY;
}
return ExecutionSide.SELL;
}
@Override
public boolean isBuy() {
return type == Trade.TradeType.BUY;
}
@Override
public boolean isSell() {
return type == Trade.TradeType.SELL;
}
@Override
public int hashCode() {
return Objects.hash(type, index, time, pricePerAsset, amount, cost, side, orderId, correlationId);
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof BaseTrade other)) {
return false;
}
return Objects.equals(type, other.type) && Objects.equals(index, other.index)
&& Objects.equals(time, other.time) && Objects.equals(pricePerAsset, other.pricePerAsset)
&& Objects.equals(amount, other.amount) && Objects.equals(cost, other.cost)
&& Objects.equals(side, other.side) && Objects.equals(orderId, other.orderId)
&& Objects.equals(correlationId, other.correlationId);
}
@Override
public String toString() {
JsonObject json = new JsonObject();
json.addProperty("type", type == null ? null : type.name());
json.addProperty("index", index);
json.addProperty("time", time == null ? null : time.toString());
json.addProperty("pricePerAsset", pricePerAsset == null ? null : pricePerAsset.toString());
json.addProperty("netPrice", netPrice == null ? null : netPrice.toString());
json.addProperty("amount", amount == null ? null : amount.toString());
json.addProperty("cost", cost == null ? null : cost.toString());
json.addProperty("side", side == null ? null : side.name());
json.addProperty("orderId", orderId);
json.addProperty("correlationId", correlationId);
return GSON.toJson(json);
}
/**
* Returns a copy of this trade with a new index.
*
* @param index trade index
* @return trade with the provided index
* @since 0.22.4
*/
public BaseTrade withIndex(int index) {
if (index < 0) {
throw new IllegalArgumentException("index must be >= 0");
}
int delta = index - this.index;
List<TradeFill> indexedFills = fills.stream()
.map(fill -> new TradeFill(fill.index() + delta, fill.time(), fill.price(), fill.amount(), fill.fee(),
fill.side(), fill.orderId(), fill.correlationId()))
.toList();
return new BaseTrade(type, indexedFills, resolveCopyCostModel(indexedFills));
}
private CostModel resolveCopyCostModel(List<TradeFill> indexedFills) {
if (costModel != null) {
return costModel;
}
Num fillFeeTotal = sumFillFees(cost.getNumFactory().zero(), indexedFills);
if (cost.equals(fillFeeTotal)) {
return RecordedTradeCostModel.INSTANCE;
}
return new PreservedTradeCostModel(cost);
}
private record FillSummary(List<TradeFill> fills, TradeFill firstFill, Num totalAmount, Num weightedAveragePrice) {
}
private record FillMetadata(Instant time, ExecutionSide side, String orderId, String correlationId) {
}
private static final class PreservedTradeCostModel implements CostModel {
private final Num preservedCost;
private PreservedTradeCostModel(Num preservedCost) {
this.preservedCost = Objects.requireNonNull(preservedCost, "preservedCost");
}
@Override
public Num calculate(Position position, int finalIndex) {
return preservedCost;
}
@Override
public Num calculate(Position position) {
return preservedCost;
}
@Override
public Num calculate(Num price, Num amount) {
return preservedCost;
}
@Override
public boolean equals(CostModel otherModel) {
if (!(otherModel instanceof PreservedTradeCostModel other)) {
return false;
}
return preservedCost.equals(other.preservedCost);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,635 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import org.ta4j.core.bars.TimeBarBuilderFactory;
import org.ta4j.core.num.DecimalNumFactory;
import org.ta4j.core.num.NumFactory;
import org.ta4j.core.num.Num;
import java.io.ObjectInputStream;
import java.io.IOException;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.Lock;
import java.util.function.Supplier;
import java.util.Collection;
import java.util.Comparator;
import java.util.ArrayList;
import java.util.Objects;
import java.time.Instant;
import java.util.List;
/**
* Thread-safe {@link BarSeries} implementation for concurrent read/write use
* cases.
*
* <p>
* Choose this type only when ingestion and evaluation can overlap on different
* threads. For single-threaded backtests and deterministic replay pipelines,
* {@link BaseBarSeries} is usually simpler.
* </p>
*
* <p>
* For real-time data feeds, prefer {@link #ingestTrade(Instant, Num, Num)} and
* {@link #ingestTrade(Instant, Number, Number)} to let the configured
* {@link BarBuilder} handle bar rollovers. Direct bar mutations remain
* available for reconciliation and data correction workflows.
*
* <p>
* Java serialization preserves bar data, the {@link NumFactory}, and the
* {@link BarBuilderFactory} configuration. Transient locks are reinitialized on
* deserialization, and the trade bar builder is recreated lazily on the next
* ingestion call.
*
* @since 0.22.2
*/
public class ConcurrentBarSeries extends BaseBarSeries {
private static final long serialVersionUID = -1868546230609071876L;
private transient Lock readLock;
private transient Lock writeLock;
private transient BarBuilder tradeBarBuilder;
/**
* Indicates how a streaming bar was applied to the series.
*
* @since 0.22.2
*/
public enum StreamingBarIngestAction {
APPENDED, REPLACED_LAST, REPLACED_HISTORICAL
}
/**
* Describes the outcome of ingesting a streaming bar.
*
* @param action indicates how the bar was applied
* @param index the affected series index
*
* @since 0.22.2
*/
public record StreamingBarIngestResult(StreamingBarIngestAction action, int index) {
public StreamingBarIngestResult {
Objects.requireNonNull(action, "action cannot be null");
if (index < 0) {
throw new IllegalArgumentException("index cannot be negative");
}
}
}
ConcurrentBarSeries(final String name, final List<Bar> bars) {
this(name, bars, 0, bars.size() - 1, false, DecimalNumFactory.getInstance(), new TimeBarBuilderFactory(true),
new ReentrantReadWriteLock());
}
ConcurrentBarSeries(final String name, final List<Bar> bars, final int seriesBeginIndex, final int seriesEndIndex,
final boolean constrained, final NumFactory numFactory, final BarBuilderFactory barBuilderFactory) {
this(name, bars, seriesBeginIndex, seriesEndIndex, constrained, numFactory, barBuilderFactory,
new ReentrantReadWriteLock());
}
ConcurrentBarSeries(final String name, final List<Bar> bars, final int seriesBeginIndex, final int seriesEndIndex,
final boolean constrained, final NumFactory numFactory, final BarBuilderFactory barBuilderFactory,
final ReadWriteLock readWriteLock) {
super(name, bars, seriesBeginIndex, seriesEndIndex, constrained, numFactory, barBuilderFactory);
initLocks(readWriteLock);
this.tradeBarBuilder = Objects.requireNonNull(super.barBuilder(), "barBuilder cannot be null");
}
private void initLocks(final ReadWriteLock readWriteLock) {
ReadWriteLock rwLock = Objects.requireNonNull(readWriteLock, "readWriteLock cannot be null");
this.readLock = rwLock.readLock();
this.writeLock = rwLock.writeLock();
}
private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
initLocks(new ReentrantReadWriteLock());
tradeBarBuilder = null;
}
private static List<Bar> cut(final List<Bar> bars, final int startIndex, final int endIndex) {
return new ArrayList<>(bars.subList(startIndex, endIndex));
}
@Override
public ConcurrentBarSeries getSubSeries(final int startIndex, final int endIndex) {
this.readLock.lock();
try {
if (startIndex < 0) {
throw new IllegalArgumentException(String.format("startIndex: %s cannot be negative", startIndex));
}
if (startIndex >= endIndex) {
throw new IllegalArgumentException(
String.format("endIndex: %s must be greater than startIndex: %s", endIndex, startIndex));
}
final List<Bar> bars = super.getBarData();
if (!bars.isEmpty()) {
final int start = startIndex - super.getRemovedBarsCount();
final int end = Math.min(endIndex - super.getRemovedBarsCount(), super.getEndIndex() + 1);
final var builder = new ConcurrentBarSeriesBuilder().withName(getName())
.withBars(cut(bars, start, end))
.withNumFactory(super.numFactory())
.withBarBuilderFactory(super.barBuilderFactory());
if (!isConstrained()) {
builder.withMaxBarCount(super.getMaximumBarCount());
}
return builder.build();
}
final var builder = new ConcurrentBarSeriesBuilder().withNumFactory(super.numFactory())
.withBarBuilderFactory(super.barBuilderFactory())
.withName(getName());
if (!isConstrained()) {
builder.withMaxBarCount(super.getMaximumBarCount());
}
return builder.build();
} finally {
this.readLock.unlock();
}
}
@Override
public BarBuilder barBuilder() {
this.readLock.lock();
try {
return super.barBuilder();
} finally {
this.readLock.unlock();
}
}
@Override
public String getName() {
this.readLock.lock();
try {
return super.getName();
} finally {
this.readLock.unlock();
}
}
@Override
public NumFactory numFactory() {
this.readLock.lock();
try {
return super.numFactory();
} finally {
this.readLock.unlock();
}
}
@Override
public Bar getBar(final int i) {
this.readLock.lock();
try {
return super.getBar(i);
} finally {
this.readLock.unlock();
}
}
@Override
public Bar getFirstBar() {
this.readLock.lock();
try {
return super.getBar(super.getBeginIndex());
} finally {
this.readLock.unlock();
}
}
@Override
public Bar getLastBar() {
this.readLock.lock();
try {
return super.getBar(super.getEndIndex());
} finally {
this.readLock.unlock();
}
}
@Override
public int getBarCount() {
this.readLock.lock();
try {
return super.getBarCount();
} finally {
this.readLock.unlock();
}
}
@Override
public List<Bar> getBarData() {
this.readLock.lock();
try {
return List.copyOf(super.getBarData());
} finally {
this.readLock.unlock();
}
}
@Override
public int getBeginIndex() {
this.readLock.lock();
try {
return super.getBeginIndex();
} finally {
this.readLock.unlock();
}
}
@Override
public int getEndIndex() {
this.readLock.lock();
try {
return super.getEndIndex();
} finally {
this.readLock.unlock();
}
}
@Override
public int getMaximumBarCount() {
this.readLock.lock();
try {
return super.getMaximumBarCount();
} finally {
this.readLock.unlock();
}
}
@Override
public void setMaximumBarCount(final int maximumBarCount) {
this.writeLock.lock();
try {
super.setMaximumBarCount(maximumBarCount);
} finally {
this.writeLock.unlock();
}
}
@Override
public int getRemovedBarsCount() {
this.readLock.lock();
try {
return super.getRemovedBarsCount();
} finally {
this.readLock.unlock();
}
}
/**
* Returns the builder used for streaming trade ingestion. Configure it (for
* example, set the time period) before calling
* {@link #ingestTrade(Instant, Num, Num)}.
*
* @return the trade bar builder
*
* @since 0.22.2
*/
public BarBuilder tradeBarBuilder() {
this.readLock.lock();
try {
if (tradeBarBuilder != null) {
return tradeBarBuilder;
}
} finally {
this.readLock.unlock();
}
this.writeLock.lock();
try {
if (tradeBarBuilder == null) {
tradeBarBuilder = Objects.requireNonNull(super.barBuilder(), "barBuilder cannot be null");
}
return tradeBarBuilder;
} finally {
this.writeLock.unlock();
}
}
/**
* Runs the supplied action while holding the read lock.
*
* @param action read-only action to execute
*
* @since 0.22.2
*/
public void withReadLock(final Runnable action) {
Objects.requireNonNull(action, "action cannot be null");
this.readLock.lock();
try {
action.run();
} finally {
this.readLock.unlock();
}
}
/**
* Runs the supplied action while holding the read lock.
*
* @param action read-only action to execute
* @param <T> return type
* @return the action result
*
* @since 0.22.2
*/
public <T> T withReadLock(final Supplier<T> action) {
Objects.requireNonNull(action, "action cannot be null");
this.readLock.lock();
try {
return action.get();
} finally {
this.readLock.unlock();
}
}
/**
* Runs the supplied action while holding the write lock.
*
* @param action mutating action to execute
*
* @since 0.22.2
*/
public void withWriteLock(final Runnable action) {
Objects.requireNonNull(action, "action cannot be null");
this.writeLock.lock();
try {
action.run();
} finally {
this.writeLock.unlock();
}
}
/**
* Runs the supplied action while holding the write lock.
*
* @param action mutating action to execute
* @param <T> return type
* @return the action result
*
* @since 0.22.2
*/
public <T> T withWriteLock(final Supplier<T> action) {
Objects.requireNonNull(action, "action cannot be null");
this.writeLock.lock();
try {
return action.get();
} finally {
this.writeLock.unlock();
}
}
@Override
public void addBar(final Bar bar, final boolean replace) {
this.writeLock.lock();
try {
super.addBar(bar, replace);
} finally {
this.writeLock.unlock();
}
}
@Override
public void addTrade(final Number tradeVolume, final Number tradePrice) {
this.writeLock.lock();
try {
super.addTrade(tradeVolume, tradePrice);
} finally {
this.writeLock.unlock();
}
}
@Override
public void addTrade(final Num tradeVolume, final Num tradePrice) {
this.writeLock.lock();
try {
super.addTrade(tradeVolume, tradePrice);
} finally {
this.writeLock.unlock();
}
}
@Override
public void addPrice(final Num price) {
this.writeLock.lock();
try {
super.addPrice(price);
} finally {
this.writeLock.unlock();
}
}
/**
* Ingests a trade event into the series using the configured bar builder.
*
* @param tradeTime the trade timestamp (UTC)
* @param tradeVolume the traded volume
* @param tradePrice the traded price
*
* @since 0.22.2
*/
public void ingestTrade(final Instant tradeTime, final Number tradeVolume, final Number tradePrice) {
ingestTrade(tradeTime, tradeVolume, tradePrice, null, null);
}
/**
* Ingests a trade event into the series using the configured bar builder.
*
* @param tradeTime the trade timestamp (UTC)
* @param tradeVolume the traded volume
* @param tradePrice the traded price
*
* @since 0.22.2
*/
public void ingestTrade(final Instant tradeTime, final Num tradeVolume, final Num tradePrice) {
ingestTrade(tradeTime, tradeVolume, tradePrice, null, null);
}
/**
* Ingests a trade event into the series using the configured bar builder.
*
* @param tradeTime the trade timestamp (UTC)
* @param tradeVolume the traded volume
* @param tradePrice the traded price
* @param side aggressor side (optional)
* @param liquidity liquidity classification (optional)
*
* @since 0.22.2
*/
public void ingestTrade(final Instant tradeTime, final Number tradeVolume, final Number tradePrice,
final RealtimeBar.Side side, final RealtimeBar.Liquidity liquidity) {
Objects.requireNonNull(tradeTime, "tradeTime cannot be null");
Objects.requireNonNull(tradeVolume, "tradeVolume cannot be null");
Objects.requireNonNull(tradePrice, "tradePrice cannot be null");
final NumFactory factory = super.numFactory();
ingestTrade(tradeTime, factory.numOf(tradeVolume), factory.numOf(tradePrice), side, liquidity);
}
/**
* Ingests a trade event into the series using the configured bar builder.
*
* @param tradeTime the trade timestamp (UTC)
* @param tradeVolume the traded volume
* @param tradePrice the traded price
* @param side aggressor side (optional)
* @param liquidity liquidity classification (optional)
*
* @since 0.22.2
*/
public void ingestTrade(final Instant tradeTime, final Num tradeVolume, final Num tradePrice,
final RealtimeBar.Side side, final RealtimeBar.Liquidity liquidity) {
Objects.requireNonNull(tradeTime, "tradeTime cannot be null");
Objects.requireNonNull(tradeVolume, "tradeVolume cannot be null");
Objects.requireNonNull(tradePrice, "tradePrice cannot be null");
if (!super.numFactory().produces(tradeVolume) || !super.numFactory().produces(tradePrice)) {
throw new IllegalArgumentException(
String.format("Cannot ingest trade with data types: %s/%s into series with datatype: %s",
tradeVolume.getClass(), tradePrice.getClass(), super.numFactory().one().getClass()));
}
this.writeLock.lock();
try {
if (tradeBarBuilder == null) {
tradeBarBuilder = Objects.requireNonNull(super.barBuilder(), "barBuilder");
}
tradeBarBuilder.addTrade(tradeTime, tradeVolume, tradePrice, side, liquidity);
} finally {
this.writeLock.unlock();
}
}
/**
* Ingests a single streaming bar (e.g., one emitted from an exchange WebSocket
* candles) and appends or replaces the matching interval.
*
* <p>
* Unlike {@link #addBar(Bar, boolean)}, this method can replace historical bars
* when exchanges replay snapshots that include prior intervals.
*
* @param bar streaming bar payload
* @return the applied action and affected series index
*
* @since 0.22.2
*/
public StreamingBarIngestResult ingestStreamingBar(final Bar bar) {
Objects.requireNonNull(bar, "bar cannot be null");
this.writeLock.lock();
try {
return addStreamingBarUnsafe(bar);
} finally {
this.writeLock.unlock();
}
}
/**
* Bulk-ingests streaming bars. Incoming payloads are sorted by their end time
* to gracefully handle candle snapshots that are emitted with the most recent
* intervals first.
*
* @param bars streaming bars to ingest
* @return applied actions in ascending end-time order
*
* @since 0.22.2
*/
public List<StreamingBarIngestResult> ingestStreamingBars(final Collection<Bar> bars) {
if (bars == null || bars.isEmpty()) {
return List.of();
}
final List<Bar> ordered = new ArrayList<>(bars);
ordered.removeIf(Objects::isNull);
if (ordered.isEmpty()) {
return List.of();
}
ordered.sort(Comparator.comparing(Bar::getEndTime));
this.writeLock.lock();
try {
final List<StreamingBarIngestResult> results = new ArrayList<>(ordered.size());
for (Bar bar : ordered) {
results.add(addStreamingBarUnsafe(bar));
}
return List.copyOf(results);
} finally {
this.writeLock.unlock();
}
}
private StreamingBarIngestResult addStreamingBarUnsafe(final Bar newBar) {
validateBarMatchesSeries(newBar);
final List<Bar> internal = super.getBarData();
if (internal.isEmpty()) {
super.addBar(newBar, false);
return new StreamingBarIngestResult(StreamingBarIngestAction.APPENDED, super.getEndIndex());
}
final Instant newEndTime = Objects.requireNonNull(newBar.getEndTime(), "Bar endTime cannot be null");
final Bar lastBar = internal.get(internal.size() - 1);
final Instant lastEndTime = Objects.requireNonNull(lastBar.getEndTime(), "Last bar endTime cannot be null");
int endTimeComparison = newEndTime.compareTo(lastEndTime);
if (endTimeComparison == 0) {
super.addBar(newBar, true);
return new StreamingBarIngestResult(StreamingBarIngestAction.REPLACED_LAST, super.getEndIndex());
}
if (endTimeComparison > 0) {
super.addBar(newBar, false);
return new StreamingBarIngestResult(StreamingBarIngestAction.APPENDED, super.getEndIndex());
}
final int internalIndex = findBarIndexByEndTime(internal, newEndTime);
if (internalIndex >= 0) {
final int seriesIndex = internalIndex + super.getRemovedBarsCount();
// Replacing a historical bar doesn't change bar count or indices, so we bypass
// addBar().
super.replaceBar(seriesIndex, newBar);
return new StreamingBarIngestResult(StreamingBarIngestAction.REPLACED_HISTORICAL, seriesIndex);
}
throw new IllegalArgumentException(
String.format("Cannot insert streaming bar ending at %s because series end time is %s",
newBar.getEndTime(), lastBar.getEndTime()));
}
private void validateBarMatchesSeries(final Bar bar) {
if (!super.numFactory().produces(bar.getClosePrice())) {
throw new IllegalArgumentException(
String.format("Cannot add Bar with data type: %s to series with datatype: %s",
bar.getClosePrice().getClass(), super.numFactory().one().getClass()));
}
}
private static int findBarIndexByEndTime(final List<Bar> bars, final Instant endTime) {
int low = 0;
int high = bars.size() - 1;
while (low <= high) {
int mid = (low + high) >>> 1;
final Instant midTime = bars.get(mid).getEndTime();
int comparison = midTime.compareTo(endTime);
if (comparison < 0) {
low = mid + 1;
} else if (comparison > 0) {
high = mid - 1;
} else {
return mid;
}
}
return -1;
}
@Override
public String getSeriesPeriodDescription() {
this.readLock.lock();
try {
return super.getSeriesPeriodDescription();
} finally {
this.readLock.unlock();
}
}
@Override
public String getSeriesPeriodDescriptionInSystemTimeZone() {
this.readLock.lock();
try {
return super.getSeriesPeriodDescriptionInSystemTimeZone();
} finally {
this.readLock.unlock();
}
}
}
@@ -0,0 +1,126 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.util.ArrayList;
import java.util.List;
import org.ta4j.core.bars.TimeBarBuilderFactory;
import org.ta4j.core.num.DecimalNumFactory;
import org.ta4j.core.num.NumFactory;
/**
* Builder for {@link ConcurrentBarSeries} instances.
*
* @since 0.22.2
*/
public class ConcurrentBarSeriesBuilder implements BarSeriesBuilder {
private static final String UNNAMED_SERIES_NAME = "unnamed_series";
private List<Bar> bars;
private String name;
private boolean maxBarCountConfigured;
private int maxBarCount;
private NumFactory numFactory = DecimalNumFactory.getInstance();
private BarBuilderFactory barBuilderFactory = new TimeBarBuilderFactory(true);
/**
* Creates a builder for {@link ConcurrentBarSeries}.
*
* @since 0.22.2
*/
public ConcurrentBarSeriesBuilder() {
initValues();
}
private void initValues() {
this.bars = new ArrayList<>();
this.name = UNNAMED_SERIES_NAME;
this.maxBarCountConfigured = false;
this.maxBarCount = Integer.MAX_VALUE;
}
/**
* {@inheritDoc}
*
* @since 0.22.2
*/
@Override
public ConcurrentBarSeries build() {
int beginIndex = -1;
int endIndex = -1;
if (!bars.isEmpty()) {
beginIndex = 0;
endIndex = bars.size() - 1;
}
// If maxBarCount is configured, the series must be unconstrained to allow
// removals.
boolean effectiveConstrained = !maxBarCountConfigured;
var series = new ConcurrentBarSeries(name == null ? UNNAMED_SERIES_NAME : name, bars, beginIndex, endIndex,
effectiveConstrained, numFactory, barBuilderFactory);
if (maxBarCountConfigured) {
series.setMaximumBarCount(maxBarCount);
}
initValues();
return series;
}
/**
* @param numFactory {@link NumFactory} to back the series
* @return {@code this}
*
* @since 0.22.2
*/
public ConcurrentBarSeriesBuilder withNumFactory(NumFactory numFactory) {
this.numFactory = numFactory;
return this;
}
/**
* @param name name of the series
* @return {@code this}
*
* @since 0.22.2
*/
public ConcurrentBarSeriesBuilder withName(String name) {
this.name = name;
return this;
}
/**
* @param bars initial bars for the series
* @return {@code this}
*
* @since 0.22.2
*/
public ConcurrentBarSeriesBuilder withBars(List<Bar> bars) {
this.bars = new ArrayList<>(bars);
return this;
}
/**
* @param maxBarCount maximum retained bars (also opts the series into
* pruning/unconstrained mode)
* @return {@code this}
*
* @since 0.22.2
*/
public ConcurrentBarSeriesBuilder withMaxBarCount(int maxBarCount) {
this.maxBarCount = maxBarCount;
this.maxBarCountConfigured = true;
return this;
}
/**
* @param barBuilderFactory builder factory for bars
* @return {@code this}
*
* @since 0.22.2
*/
public ConcurrentBarSeriesBuilder withBarBuilderFactory(BarBuilderFactory barBuilderFactory) {
this.barBuilderFactory = barBuilderFactory;
return this;
}
}
@@ -0,0 +1,90 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.io.Serializable;
import java.time.Instant;
import org.ta4j.core.num.Num;
/**
* Deprecated live-fill compatibility contract.
*
* <p>
* Use {@link TradeFill} for new code. This interface remains available in the
* 0.22.x line so existing live adapters can migrate without a hard compile
* break.
* </p>
*
* @since 0.22.2
*/
@Deprecated(since = "0.22.4")
public interface ExecutionFill extends Serializable {
/**
* @return the execution timestamp (UTC)
* @since 0.22.2
*/
Instant time();
/**
* @return the execution price per asset
* @since 0.22.2
*/
Num price();
/**
* @return the execution amount
* @since 0.22.2
*/
Num amount();
/**
* @return the execution fee (nullable, zero when unknown)
* @since 0.22.2
*/
Num fee();
/**
* @return the execution side
* @since 0.22.2
*/
ExecutionSide side();
/**
* @return the exchange order id if available
* @since 0.22.2
*/
String orderId();
/**
* @return the correlation id if available
* @since 0.22.2
*/
String correlationId();
/**
* @return the associated intent id, defaulting to {@link #correlationId()}
* @since 0.22.2
*/
default String intentId() {
return correlationId();
}
/**
* @return the bar index for the fill, or {@code -1} when not specified
* @since 0.22.2
*/
default int index() {
return -1;
}
/**
* @return true when the fill has a non-zero fee
* @since 0.22.2
*/
default boolean hasFee() {
Num fee = fee();
return fee != null && !fee.isZero();
}
}
@@ -0,0 +1,41 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.io.Serial;
import java.io.Serializable;
import java.time.Instant;
import java.util.Objects;
/**
* Generic execution intent metadata for live trading systems.
*
* <p>
* This is intentionally minimal so adapters can map their decision objects
* without leaking platform-specific details into ta4j core.
* </p>
*
* @param intentId unique intent identifier
* @param side execution side
* @param createdAt intent creation timestamp (UTC)
* @param correlationId optional correlation id for external systems
* @since 0.22.2
*/
public record ExecutionIntent(String intentId, ExecutionSide side, Instant createdAt,
String correlationId) implements Serializable {
@Serial
private static final long serialVersionUID = 8030047863134194008L;
public ExecutionIntent {
if (intentId == null || intentId.isBlank()) {
throw new IllegalArgumentException("intentId must be non-blank");
}
Objects.requireNonNull(side, "side");
Objects.requireNonNull(createdAt, "createdAt");
if (correlationId != null && correlationId.isBlank()) {
throw new IllegalArgumentException("correlationId must be non-blank when provided");
}
}
}
@@ -0,0 +1,42 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
/**
* Matching policy for pairing exits against open position lots.
*
* @since 0.22.2
*/
public enum ExecutionMatchPolicy {
/**
* First-in, first-out matching; exits close the earliest open lots.
*
* @since 0.22.2
*/
FIFO,
/**
* Last-in, first-out matching; exits close the most recent open lots.
*
* @since 0.22.2
*/
LIFO,
/**
* Average-cost matching; entries are merged into a single lot and exits use the
* weighted average cost basis.
*
* @since 0.22.2
*/
AVG_COST,
/**
* Match exits to a specific lot using correlationId or orderId; exit amounts
* must not exceed the matched lot.
*
* @since 0.22.2
*/
SPECIFIC_ID
}
@@ -0,0 +1,34 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
/**
* Execution side for live fills.
*
* @since 0.22.2
*/
public enum ExecutionSide {
/**
* Buy-side execution.
*
* @since 0.22.2
*/
BUY,
/**
* Sell-side execution.
*
* @since 0.22.2
*/
SELL;
/**
* @return the corresponding {@link Trade.TradeType}
* @since 0.22.2
*/
public Trade.TradeType toTradeType() {
return this == BUY ? Trade.TradeType.BUY : Trade.TradeType.SELL;
}
}
@@ -0,0 +1,206 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import org.ta4j.core.num.Num;
import org.ta4j.core.serialization.ComponentDescriptor;
import org.ta4j.core.serialization.IndicatorSerialization;
import org.ta4j.core.serialization.IndicatorSerializationException;
import java.util.stream.IntStream;
import java.util.stream.Stream;
/**
* Indicator over a {@link BarSeries bar series}.
*
* <p>
* Returns a value of type <b>T</b> for each index of the bar series.
*
* <p>
* Indicators transform price/volume data (or other indicators) into new data
* series (e.g., Moving Averages, RSI). They calculate values lazily or eagerly
* and are the fundamental building blocks for {@link Rule rules}. Values are
* retrieved per-index via {@link #getValue(int)}.
*
* @param <T> the type of the returned value (Double, Boolean, etc.)
*/
public interface Indicator<T> {
/**
* @param index the bar index
* @return the value of the indicator
*/
T getValue(int index);
/**
* Returns {@code true} once {@code this} indicator has enough bars to
* accurately calculate its value. Otherwise, {@code false} will be returned,
* which means the indicator will give incorrect values due to insufficient
* data. This method determines stability using the formula:
*
* <pre>
* isStable = {@link BarSeries#getBarCount()} >= {@link #getCountOfUnstableBars()}
* </pre>
*
* @return true if the calculated indicator value is correct
*/
default boolean isStable() {
return getBarSeries().getBarCount() >= getCountOfUnstableBars();
}
/**
* Returns the number of bars up to which {@code this} Indicator calculates
* wrong values.
*
* @return unstable bars
*/
int getCountOfUnstableBars();
/**
* @return the related bar series
*/
BarSeries getBarSeries();
/**
* @return all values from {@code this} Indicator over {@link #getBarSeries()}
* as a Stream
*/
default Stream<T> stream() {
return IntStream.range(getBarSeries().getBeginIndex(), getBarSeries().getEndIndex() + 1)
.mapToObj(this::getValue);
}
/**
* Returns all values of an {@link Indicator} within the given {@code index} and
* {@code barCount} as an array of Doubles. The returned doubles could have a
* minor loss of precision, if {@link Indicator} was based on {@link Num Num}.
*
* @param ref the indicator
* @param index the index
* @param barCount the barCount
* @return array of Doubles within {@code index} and {@code barCount}
*/
static Double[] toDouble(Indicator<Num> ref, int index, int barCount) {
int startIndex = Math.max(0, index - barCount + 1);
return IntStream.range(startIndex, startIndex + barCount)
.mapToObj(ref::getValue)
.map(Num::doubleValue)
.toArray(Double[]::new);
}
/**
* Serializes {@code this} indicator into a JSON payload that captures its type,
* numeric parameters, and child indicators.
*
* <p>
* The serialization process uses reflection to introspect the indicator's
* structure, extracting numeric constructor parameters and recursively
* serializing child indicators. The resulting JSON can be used to reconstruct
* an equivalent indicator instance using {@link #fromJson(BarSeries, String)}.
*
* <p>
* The JSON format includes:
* <ul>
* <li>The indicator's simple class name (type)</li>
* <li>Numeric parameters extracted from constructor arguments</li>
* <li>Child indicators as nested component descriptors</li>
* </ul>
*
* @return JSON description of the indicator
* @throws IndicatorSerializationException if serialization fails due to
* reflection errors, class loading
* issues, or JSON generation problems.
* This exception wraps all underlying
* serialization failures, providing a
* consistent exception type for error
* handling.
* @since 0.19
*/
default String toJson() {
return IndicatorSerialization.toJson(this);
}
/**
* Converts {@code this} indicator into a structured descriptor that can be
* embedded inside other component metadata.
*
* <p>
* This method creates a {@link ComponentDescriptor} that represents the
* indicator's structure, including its type, numeric parameters, and child
* indicators. The descriptor can be used for serialization, comparison, or
* embedding within larger component hierarchies (such as rules or strategies).
*
* <p>
* The descriptor extraction process:
* <ul>
* <li>Uses reflection to introspect the indicator's fields</li>
* <li>Extracts numeric constructor parameters</li>
* <li>Recursively processes child indicators, handling circular references</li>
* <li>Builds a tree structure representing the indicator's composition</li>
* </ul>
*
* @return component descriptor for the indicator
* @throws IndicatorSerializationException if descriptor creation fails due to
* reflection errors, class loading
* issues, or problems processing
* circular references. This exception
* wraps all underlying failures,
* providing a consistent exception type
* for error handling.
* @since 0.19
*/
default ComponentDescriptor toDescriptor() {
return IndicatorSerialization.describe(this);
}
/**
* Reconstructs an indicator instance from its serialized representation.
*
* <p>
* This method parses a JSON payload (typically generated by {@link #toJson()})
* and reconstructs an equivalent indicator instance. The deserialization
* process:
* <ul>
* <li>Parses the JSON into a component descriptor structure</li>
* <li>Resolves indicator types by simple name from the classpath</li>
* <li>Matches constructor parameters to descriptor values</li>
* <li>Recursively instantiates child indicators</li>
* <li>Constructs the indicator using reflection-based constructor matching</li>
* </ul>
*
* <p>
* <strong>Important:</strong> The indicator type must be resolvable from the
* classpath. Custom indicator classes must be in the
* {@code org.ta4j.core.indicators} package or registered appropriately for
* successful deserialization.
*
* @param series backing series to attach to the reconstructed indicator
* @param json serialized indicator payload generated by {@link #toJson()}
* @return indicator instance
* @throws IndicatorSerializationException if deserialization fails due to:
* <ul>
* <li>Invalid or malformed JSON
* syntax</li>
* <li>Unknown indicator type (class not
* found or not in expected
* package)</li>
* <li>Missing or incompatible
* constructor parameters</li>
* <li>Constructor instantiation
* failures</li>
* <li>Class loading or reflection
* errors</li>
* </ul>
* This exception wraps all underlying
* deserialization failures, providing a
* consistent exception type for error
* handling. The original cause is
* preserved and can be accessed via
* {@link Throwable#getCause()}.
* @since 0.19
*/
static Indicator<?> fromJson(BarSeries series, String json) {
return IndicatorSerialization.fromJson(series, json);
}
}
@@ -0,0 +1,138 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import java.io.Serial;
import java.time.Instant;
import java.util.Objects;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.analysis.cost.RecordedTradeCostModel;
import org.ta4j.core.num.Num;
import org.ta4j.core.utils.DeprecationNotifier;
/**
* Deprecated live-trade compatibility record.
*
* <p>
* Use {@link BaseTrade} and {@link TradeFill} for new code.
* </p>
*
* @param index trade index
* @param time execution timestamp (UTC)
* @param price execution price per asset
* @param amount execution amount
* @param fee execution fee (nullable, defaults to zero)
* @param side execution side (BUY/SELL)
* @param orderId optional order identifier
* @param correlationId optional correlation identifier
* @since 0.22.2
*/
@Deprecated(since = "0.22.4")
public record LiveTrade(int index, Instant time, Num price, Num amount, Num fee, ExecutionSide side, String orderId,
String correlationId) implements Trade, ExecutionFill {
@Serial
private static final long serialVersionUID = 3196554864123769210L;
private static final Gson GSON = new Gson();
private static final CostModel RECORDED_COST_MODEL = RecordedTradeCostModel.INSTANCE;
public LiveTrade {
DeprecationNotifier.warnOnce(LiveTrade.class, "org.ta4j.core.BaseTrade");
if (index < 0) {
throw new IllegalArgumentException("index must be >= 0");
}
Objects.requireNonNull(time, "time");
Objects.requireNonNull(price, "price");
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(side, "side");
if (fee == null) {
fee = price.getNumFactory().zero();
}
}
@Override
public Trade.TradeType getType() {
return side.toTradeType();
}
@Override
public int getIndex() {
return index;
}
@Override
public Num getPricePerAsset() {
return price;
}
@Override
public Num getNetPrice() {
if (amount.isZero()) {
return price;
}
Num costPerAsset = fee.dividedBy(amount);
if (side == ExecutionSide.BUY) {
return price.plus(costPerAsset);
}
return price.minus(costPerAsset);
}
@Override
public Num getAmount() {
return amount;
}
@Override
public Num getCost() {
return fee;
}
@Override
public CostModel getCostModel() {
return RECORDED_COST_MODEL;
}
@Override
public Instant getTime() {
return time;
}
@Override
public String getOrderId() {
return orderId;
}
@Override
public String getCorrelationId() {
return correlationId;
}
/**
* Returns a copy of this trade with a new index.
*
* @param index the trade index
* @return a trade with the provided index
* @since 0.22.2
*/
public LiveTrade withIndex(int index) {
return new LiveTrade(index, time, price, amount, fee, side, orderId, correlationId);
}
@Override
public String toString() {
JsonObject json = new JsonObject();
json.addProperty("index", index);
json.addProperty("time", time == null ? null : time.toString());
json.addProperty("price", price == null ? null : price.toString());
json.addProperty("amount", amount == null ? null : amount.toString());
json.addProperty("fee", fee == null ? null : fee.toString());
json.addProperty("side", side == null ? null : side.name());
json.addProperty("orderId", orderId);
json.addProperty("correlationId", correlationId);
return GSON.toJson(json);
}
}
@@ -0,0 +1,161 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.io.Serial;
import java.time.Instant;
import java.util.Objects;
import org.ta4j.core.Trade.TradeType;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.analysis.cost.RecordedTradeCostModel;
import org.ta4j.core.analysis.cost.ZeroCostModel;
import org.ta4j.core.num.Num;
import org.ta4j.core.utils.DeprecationNotifier;
/**
* Deprecated live-trading compatibility facade.
*
* <p>
* Use {@link BaseTradingRecord} for new code. This type remains available in
* the 0.22.x line so existing adapters can migrate without a hard patch-line
* break.
* </p>
*
* @since 0.22.2
*/
@Deprecated(since = "0.22.4")
public class LiveTradingRecord extends BaseTradingRecord implements PositionLedger {
@Serial
private static final long serialVersionUID = 7960596064337713648L;
/**
* Creates a live trading record with BUY entries and FIFO matching.
*
* @since 0.22.2
*/
public LiveTradingRecord() {
this(TradeType.BUY);
}
/**
* Creates a live trading record.
*
* @param startingType entry trade type
* @since 0.22.2
*/
public LiveTradingRecord(TradeType startingType) {
this(startingType, ExecutionMatchPolicy.FIFO, RecordedTradeCostModel.INSTANCE, new ZeroCostModel(), null, null);
}
/**
* Creates a live trading record.
*
* @param startingType entry trade type
* @param matchPolicy matching policy
* @param transactionCostModel ignored in favor of recorded fees
* @param holdingCostModel holding cost model
* @param startIndex optional start index
* @param endIndex optional end index
* @since 0.22.2
*/
public LiveTradingRecord(TradeType startingType, ExecutionMatchPolicy matchPolicy, CostModel transactionCostModel,
CostModel holdingCostModel, Integer startIndex, Integer endIndex) {
super(startingType, matchPolicy, RecordedTradeCostModel.INSTANCE,
holdingCostModel == null ? new ZeroCostModel() : holdingCostModel, startIndex, endIndex);
warnDeprecated();
}
/**
* Records a live trade using an auto-incremented trade index.
*
* @param trade live trade
* @since 0.22.2
*/
public void recordFill(LiveTrade trade) {
Objects.requireNonNull(trade, "trade");
TradeFill fill = new TradeFill(-1, trade.time(), trade.price(), trade.amount(), trade.fee(), trade.side(),
trade.orderId(), trade.correlationId());
super.operate(Trade.fromFill(fill));
}
/**
* Records a live trade using the provided trade index.
*
* @param index trade index
* @param trade live trade
* @since 0.22.2
*/
public void recordFill(int index, LiveTrade trade) {
Objects.requireNonNull(trade, "trade");
TradeFill fill = new TradeFill(index, trade.time(), trade.price(), trade.amount(), trade.fee(), trade.side(),
trade.orderId(), trade.correlationId());
super.operate(Trade.fromFill(fill));
}
/**
* Records a live fill using the deprecated {@link ExecutionFill} contract.
*
* @param fill execution fill
* @since 0.22.2
*/
public void recordExecutionFill(ExecutionFill fill) {
Objects.requireNonNull(fill, "fill");
ExecutionSide side = resolveExecutionSide(fill.side());
Instant time = fill.time() == null ? Instant.EPOCH : fill.time();
TradeFill tradeFill = new TradeFill(fill.index(), time, fill.price(), fill.amount(), fill.fee(), side,
fill.orderId(), fill.correlationId());
super.operate(Trade.fromFill(tradeFill));
}
/**
* Rehydrates transient cost models after deserialization.
*
* <p>
* Live trading records always use {@link RecordedTradeCostModel} for
* transaction costs.
* </p>
*
* @param holdingCostModel holding cost model, null defaults to
* {@link ZeroCostModel}
* @since 0.22.2
*/
@Override
public void rehydrate(CostModel holdingCostModel) {
rehydrate(RecordedTradeCostModel.INSTANCE, holdingCostModel);
}
/**
* Rehydrates transient cost models after deserialization.
*
* <p>
* Live trading records always use {@link RecordedTradeCostModel} for
* transaction costs. The supplied transaction cost model is ignored.
* </p>
*
* @param transactionCostModel ignored in favor of recorded fees
* @param holdingCostModel holding cost model, null defaults to
* {@link ZeroCostModel}
* @since 0.22.2
*/
@Override
public void rehydrate(CostModel transactionCostModel, CostModel holdingCostModel) {
super.rehydrate(RecordedTradeCostModel.INSTANCE, holdingCostModel);
}
private ExecutionSide resolveExecutionSide(ExecutionSide side) {
if (side != null) {
return side;
}
Position currentPosition = getCurrentPosition();
if (currentPosition == null || !currentPosition.isOpened() || currentPosition.getEntry() == null) {
return getStartingType() == TradeType.BUY ? ExecutionSide.BUY : ExecutionSide.SELL;
}
return currentPosition.getEntry().isBuy() ? ExecutionSide.SELL : ExecutionSide.BUY;
}
private static void warnDeprecated() {
DeprecationNotifier.warnOnce(LiveTradingRecord.class, "org.ta4j.core.BaseTradingRecord");
}
}
@@ -0,0 +1,559 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import static org.ta4j.core.num.NaN.NaN;
import java.io.Serial;
import java.io.Serializable;
import java.util.Objects;
import org.ta4j.core.Trade.TradeType;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.analysis.cost.ZeroCostModel;
import org.ta4j.core.num.Num;
/**
* A {@code Position} models either a closed entry/exit pair or an open position
* snapshot with only an entry trade.
*
* <p>
* The exit trade has the complement type of the entry trade, i.e.:
* <ul>
* <li>entry == BUY --> exit == SELL
* <li>entry == SELL --> exit == BUY
* </ul>
*
* <p>
* Open-position inspection APIs on {@link TradingRecord} also use this type, so
* callers can query per-lot and net exposure through one consistent contract.
* </p>
*/
public class Position implements Serializable {
@Serial
private static final long serialVersionUID = -5484709075767220358L;
/** The entry trade */
private Trade entry;
/** The exit trade */
private Trade exit;
/** The type of the entry trade */
private final TradeType startingType;
/** The cost model for transactions of the asset */
private final transient CostModel transactionCostModel;
/** The cost model for holding the asset */
private final transient CostModel holdingCostModel;
/** Constructor with {@link #startingType} = BUY. */
public Position() {
this(TradeType.BUY);
}
/**
* Constructor.
*
* @param startingType the starting {@link TradeType trade type} of the position
* (i.e. type of the entry trade)
*/
public Position(TradeType startingType) {
this(startingType, new ZeroCostModel(), new ZeroCostModel());
}
/**
* Constructor.
*
* @param startingType the starting {@link TradeType trade type} of the
* position (i.e. type of the entry trade)
* @param transactionCostModel the cost model for transactions of the asset
* @param holdingCostModel the cost model for holding asset (e.g. borrowing)
*/
public Position(TradeType startingType, CostModel transactionCostModel, CostModel holdingCostModel) {
if (startingType == null) {
throw new IllegalArgumentException("Starting type must not be null");
}
this.startingType = startingType;
this.transactionCostModel = transactionCostModel;
this.holdingCostModel = holdingCostModel;
}
/**
* Constructor.
*
* @param entry the entry {@link Trade trade}
* @param exit the exit {@link Trade trade}
*/
public Position(Trade entry, Trade exit) {
this(entry, exit, entry.getCostModel(), new ZeroCostModel());
}
/**
* Constructor.
*
* @param entry the entry {@link Trade trade}
* @param exit the exit {@link Trade trade}
* @param transactionCostModel the cost model for transactions of the asset
* @param holdingCostModel the cost model for holding asset (e.g. borrowing)
*/
public Position(Trade entry, Trade exit, CostModel transactionCostModel, CostModel holdingCostModel) {
if (entry.getType().equals(exit.getType())) {
throw new IllegalArgumentException("Both trades must have different types");
}
if (!(entry.getCostModel().equals(transactionCostModel))
|| !(exit.getCostModel().equals(transactionCostModel))) {
throw new IllegalArgumentException("Trades and the position must incorporate the same trading cost model");
}
this.startingType = entry.getType();
this.entry = entry;
this.exit = exit;
this.transactionCostModel = transactionCostModel;
this.holdingCostModel = holdingCostModel;
}
/**
* Constructor for an open position.
*
* @param entry the entry {@link Trade trade}
* @param transactionCostModel the cost model for transactions of the asset
* @param holdingCostModel the cost model for holding asset (e.g. borrowing)
* @since 0.22.2
*/
public Position(Trade entry, CostModel transactionCostModel, CostModel holdingCostModel) {
Objects.requireNonNull(entry, "entry");
if (!(entry.getCostModel().equals(transactionCostModel))) {
throw new IllegalArgumentException("Trades and the position must incorporate the same trading cost model");
}
this.startingType = entry.getType();
this.entry = entry;
this.exit = null;
this.transactionCostModel = transactionCostModel;
this.holdingCostModel = holdingCostModel;
}
/**
* @return the entry {@link Trade trade} of the position
*/
public Trade getEntry() {
return entry;
}
/**
* @return the exit {@link Trade trade} of the position
*/
public Trade getExit() {
return exit;
}
/**
* Returns the entry-side direction of this position.
*
* @return the entry side, or {@code null} when the position has no entry yet
* @since 0.22.4
*/
public ExecutionSide side() {
if (entry == null) {
return null;
}
return entry.isBuy() ? ExecutionSide.BUY : ExecutionSide.SELL;
}
/**
* Returns the entry amount of this position.
*
* <p>
* For aggregated open positions this is the net open amount.
* </p>
*
* @return the entry amount, or {@code null} when the position has no entry yet
* @since 0.22.4
*/
public Num amount() {
return entry == null ? null : entry.getAmount();
}
/**
* Returns the average entry price of this position.
*
* <p>
* For standard positions this is the entry trade price. For aggregated open
* positions this is the weighted average entry price of the net exposure.
* </p>
*
* @return the average entry price, or {@code null} when the position has no
* entry yet
* @since 0.22.4
*/
public Num averageEntryPrice() {
return entry == null ? null : entry.getPricePerAsset();
}
/**
* Returns the total entry cost of this position.
*
* @return the total entry cost, or {@code null} when the position has no entry
* yet
* @since 0.22.4
*/
public Num totalEntryCost() {
return entry == null ? null : entry.getValue();
}
/**
* Returns the entry fees currently carried by this position.
*
* <p>
* For aggregated open positions this reflects the summed remaining entry fees.
* </p>
*
* @return the entry fees, or {@code null} when the position has no entry yet
* @since 0.22.4
*/
public Num totalFees() {
return entry == null ? null : entry.getCost();
}
@Override
public boolean equals(Object obj) {
if (obj instanceof Position p) {
return (entry == null ? p.getEntry() == null : entry.equals(p.getEntry()))
&& (exit == null ? p.getExit() == null : exit.equals(p.getExit()));
}
return false;
}
@Override
public int hashCode() {
return Objects.hash(entry, exit);
}
/**
* Operates the position at the index-th position.
*
* @param index the bar index
* @return the trade
* @see #operate(int, Num, Num)
*/
public Trade operate(int index) {
return operate(index, NaN, NaN);
}
/**
* Operates the position at the index-th position.
*
* @param index the bar index
* @param price the price
* @param amount the amount
* @return the trade
* @throws IllegalStateException if {@link #isOpened()} and index {@literal <}
* entry.index
*/
public Trade operate(int index, Num price, Num amount) {
CostModel effectiveTransactionCostModel = getTransactionCostModel();
Trade trade = null;
if (isNew()) {
trade = operate(new BaseTrade(index, startingType, price, amount, effectiveTransactionCostModel));
} else if (isOpened()) {
if (index < entry.getIndex()) {
throw new IllegalStateException("The index i is less than the entryTrade index");
}
trade = operate(
new BaseTrade(index, startingType.complementType(), price, amount, effectiveTransactionCostModel));
}
return trade;
}
/**
* Operates the position with a pre-built trade.
*
* @param trade the trade to apply
* @return the trade
* @since 0.22.4
*/
public Trade operate(Trade trade) {
Objects.requireNonNull(trade, "trade");
CostModel effectiveTransactionCostModel = getTransactionCostModel();
if (!trade.getCostModel().equals(effectiveTransactionCostModel)) {
throw new IllegalArgumentException("Trades and the position must incorporate the same trading cost model");
}
if (isNew()) {
if (trade.getType() != startingType) {
throw new IllegalArgumentException("The first trade type must match the starting type");
}
entry = trade;
return trade;
}
if (isOpened()) {
if (trade.getType() != startingType.complementType()) {
throw new IllegalArgumentException("The exit trade type must complement the entry trade type");
}
if (trade.getIndex() < entry.getIndex()) {
throw new IllegalStateException("The index i is less than the entryTrade index");
}
exit = trade;
return trade;
}
return null;
}
/**
* @return true if the position is closed, false otherwise
*/
public boolean isClosed() {
return (entry != null) && (exit != null);
}
/**
* @return true if the position is opened, false otherwise
*/
public boolean isOpened() {
return (entry != null) && (exit == null);
}
/**
* @return true if the position is new, false otherwise
*/
public boolean isNew() {
return (entry == null) && (exit == null);
}
/**
* @return true if position is closed and {@link #getProfit()} > 0
*/
public boolean hasProfit() {
return getProfit().isPositive();
}
/**
* @return true if position is closed and {@link #getProfit()} {@literal <} 0
*/
public boolean hasLoss() {
return getProfit().isNegative();
}
/**
* Calculates the net profit of the position if it is closed. The net profit
* includes any trading costs.
*
* @return the profit or loss of the position
*/
public Num getProfit() {
if (isOpened()) {
return zero();
} else {
return getGrossProfit(exit.getPricePerAsset()).minus(getPositionCost());
}
}
/**
* Calculates the net profit of the position. If it is open, calculates the
* profit until the final bar. The net profit includes any trading costs.
*
* @param finalIndex the index of the final bar to be considered (if position is
* open)
* @param finalPrice the price of the final bar to be considered (if position is
* open)
* @return the profit or loss of the position
*/
public Num getProfit(int finalIndex, Num finalPrice) {
Num grossProfit = getGrossProfit(finalPrice);
Num tradingCost = getPositionCost(finalIndex);
return grossProfit.minus(tradingCost);
}
/**
* Calculates the gross profit of the position if it is closed. The gross profit
* excludes any trading costs.
*
* @return the gross profit of the position
*/
public Num getGrossProfit() {
if (isOpened()) {
return zero();
} else {
return getGrossProfit(exit.getPricePerAsset());
}
}
/**
* Calculates the gross profit of the position. The gross profit excludes any
* trading costs.
*
* @param finalPrice the price of the final bar to be considered (if position is
* open)
* @return the profit or loss of the position
*/
public Num getGrossProfit(Num finalPrice) {
Num grossProfit;
if (isOpened()) {
grossProfit = entry.getAmount().multipliedBy(finalPrice).minus(entry.getValue());
} else {
grossProfit = exit.getValue().minus(entry.getValue());
}
// Profits of long position are losses of short
if (entry.isSell()) {
grossProfit = grossProfit.negate();
}
return grossProfit;
}
/**
* Calculates the gross return of the position if it is closed. The gross return
* excludes any trading costs (and includes the base).
*
* @return the gross return of the position in percent
* @see #getGrossReturn(Num)
*/
public Num getGrossReturn() {
if (isOpened()) {
return zero();
} else {
return getGrossReturn(exit.getPricePerAsset());
}
}
/**
* Calculates the gross return of the position, if it exited at the provided
* price. The gross return excludes any trading costs (and includes the base).
*
* @param finalPrice the price of the final bar to be considered (if position is
* open)
* @return the gross return of the position in percent
* @see #getGrossReturn(Num, Num)
*/
public Num getGrossReturn(Num finalPrice) {
return getGrossReturn(getEntry().getPricePerAsset(), finalPrice);
}
/**
* Calculates the gross return of the position. If either the entry or exit
* price is {@code NaN}, the close price from given {@code barSeries} is used.
* The gross return excludes any trading costs (and includes the base).
*
* @param barSeries
* @return the gross return in percent with entry and exit prices from the
* barSeries
* @see #getGrossReturn(Num, Num)
*/
public Num getGrossReturn(BarSeries barSeries) {
Num entryPrice = getEntry().getPricePerAsset(barSeries);
Num exitPrice = getExit().getPricePerAsset(barSeries);
return getGrossReturn(entryPrice, exitPrice);
}
/**
* Calculates the gross return between entry and exit price in percent. Includes
* the base.
*
* <p>
* For example:
* <ul>
* <li>For buy position with a profit of 4%, it returns 1.04 (includes the base)
* <li>For sell position with a loss of 4%, it returns 0.96 (includes the base)
* </ul>
*
* @param entryPrice the entry price
* @param exitPrice the exit price
* @return the gross return in percent between entryPrice and exitPrice
* (includes the base)
*/
public Num getGrossReturn(Num entryPrice, Num exitPrice) {
if (getEntry().isBuy()) {
return exitPrice.dividedBy(entryPrice);
} else {
Num one = entryPrice.getNumFactory().one();
return ((exitPrice.dividedBy(entryPrice).minus(one)).negate()).plus(one);
}
}
/**
* Calculates the total cost of the position.
*
* @param finalIndex the index of the final bar to be considered (if position is
* open)
* @return the cost of the position
*/
public Num getPositionCost(int finalIndex) {
Num transactionCost = transactionCostModel.calculate(this, finalIndex);
Num borrowingCost = getHoldingCost(finalIndex);
return transactionCost.plus(borrowingCost);
}
/**
* Calculates the total cost of the closed position.
*
* @return the cost of the position
*/
public Num getPositionCost() {
Num transactionCost = transactionCostModel.calculate(this);
Num borrowingCost = getHoldingCost();
return transactionCost.plus(borrowingCost);
}
/**
* Calculates the holding cost of the closed position.
*
* @return the cost of the position
*/
public Num getHoldingCost() {
return holdingCostModel.calculate(this);
}
/**
* Calculates the holding cost of the position.
*
* @param finalIndex the index of the final bar to be considered (if position is
* open)
* @return the cost of the position
*/
public Num getHoldingCost(int finalIndex) {
return holdingCostModel.calculate(this, finalIndex);
}
/**
* @return the transaction cost model, or a zero-cost model after
* deserialization when the model is unset
*
* @since 0.22.2
*/
public CostModel getTransactionCostModel() {
return transactionCostModel == null ? new ZeroCostModel() : transactionCostModel;
}
/**
* @return the holding cost model, or a zero-cost model after deserialization
* when the model is unset
*
* @since 0.22.2
*/
public CostModel getHoldingCostModel() {
return holdingCostModel == null ? new ZeroCostModel() : holdingCostModel;
}
/**
* @return the {@link #startingType}
*/
public TradeType getStartingType() {
return startingType;
}
/**
* @return the Num of 0
*/
private Num zero() {
return entry.getNetPrice().getNumFactory().zero();
}
@Override
public String toString() {
return "Entry: " + entry + " exit: " + exit;
}
}
@@ -0,0 +1,39 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.util.List;
/**
* Deprecated compatibility view of closed and open positions.
*
* <p>
* Use {@link TradingRecord#getPositions()},
* {@link TradingRecord#getOpenPositions()}, and
* {@link TradingRecord#getCurrentPosition()} directly in new code.
* </p>
*
* @since 0.22.2
*/
@Deprecated(since = "0.22.4")
public interface PositionLedger {
/**
* @return the recorded closed positions
* @since 0.22.2
*/
List<Position> getPositions();
/**
* @return open positions
* @since 0.22.2
*/
List<Position> getOpenPositions();
/**
* @return the aggregated net open position
* @since 0.22.2
*/
Position getNetOpenPosition();
}
@@ -0,0 +1,151 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import org.ta4j.core.num.Num;
/**
* Extension of {@link Bar} for realtime analytics that track trade side and
* liquidity breakdowns.
*
* <p>
* Side- and liquidity-aware values are optional because exchanges may not
* provide them for every trade. When {@link #hasSideData()} or
* {@link #hasLiquidityData()} is {@code false}, the corresponding getters
* return zero values.
*
* @since 0.22.2
*/
public interface RealtimeBar extends Bar {
/**
* The aggressor side of a trade.
*
* @since 0.22.2
*/
enum Side {
BUY, SELL
}
/**
* The liquidity classification of a trade.
*
* @since 0.22.2
*/
enum Liquidity {
MAKER, TAKER
}
/**
* @return {@code true} if at least one trade with side information was
* aggregated into this bar
*
* @since 0.22.2
*/
boolean hasSideData();
/**
* @return {@code true} if at least one trade with liquidity information was
* aggregated into this bar
*
* @since 0.22.2
*/
boolean hasLiquidityData();
/**
* @return buy-side traded volume
*
* @since 0.22.2
*/
Num getBuyVolume();
/**
* @return sell-side traded volume
*
* @since 0.22.2
*/
Num getSellVolume();
/**
* @return buy-side traded amount
*
* @since 0.22.2
*/
Num getBuyAmount();
/**
* @return sell-side traded amount
*
* @since 0.22.2
*/
Num getSellAmount();
/**
* @return number of buy-side trades
*
* @since 0.22.2
*/
long getBuyTrades();
/**
* @return number of sell-side trades
*
* @since 0.22.2
*/
long getSellTrades();
/**
* @return maker-side traded volume
*
* @since 0.22.2
*/
Num getMakerVolume();
/**
* @return taker-side traded volume
*
* @since 0.22.2
*/
Num getTakerVolume();
/**
* @return maker-side traded amount
*
* @since 0.22.2
*/
Num getMakerAmount();
/**
* @return taker-side traded amount
*
* @since 0.22.2
*/
Num getTakerAmount();
/**
* @return number of maker-side trades
*
* @since 0.22.2
*/
long getMakerTrades();
/**
* @return number of taker-side trades
*
* @since 0.22.2
*/
long getTakerTrades();
/**
* Adds a trade with optional side and liquidity classification.
*
* @param tradeVolume traded volume
* @param tradePrice traded price
* @param side aggressor side (optional)
* @param liquidity liquidity classification (optional)
*
* @since 0.22.2
*/
void addTrade(Num tradeVolume, Num tradePrice, Side side, Liquidity liquidity);
}
@@ -0,0 +1,180 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import org.ta4j.core.rules.AndRule;
import org.ta4j.core.rules.NotRule;
import org.ta4j.core.rules.OrRule;
import org.ta4j.core.rules.XorRule;
import org.ta4j.core.serialization.ComponentDescriptor;
import org.ta4j.core.serialization.ComponentSerialization;
import org.ta4j.core.serialization.RuleSerialization;
/**
* A rule (also called "trading rule") used to build a {@link Strategy trading
* strategy}. A trading rule can consist of a combination of other rules.
*
* <p>
* Rules encapsulate the logic for trading decisions (e.g., crossing indicators,
* stop-loss thresholds, or boolean conditions). They are evaluated per bar
* index using {@link #isSatisfied(int, TradingRecord)}. Complex logic is built
* by composing primitive rules with logical operators like {@link #and(Rule)},
* {@link #or(Rule)}, or {@link #negation()}.
* </p>
*/
public interface Rule {
/**
* Controls trace logging behavior for rule evaluation.
*
* <p>
* SLF4J TRACE logging is the off switch. This selector only changes the amount
* of detail emitted during an evaluation where the relevant logger is already
* TRACE-enabled.
*
* @since 0.22.7
*/
enum TraceMode {
/**
* Emit one trace event for the evaluated rule while suppressing child-rule
* trace events inside the same scoped evaluation.
*/
SUMMARY,
/** Emit trace logs for this rule and all children in an evaluation scope. */
VERBOSE
}
/**
* Serializes this rule to JSON.
*
* @return JSON representation
* @since 0.22
*/
default String toJson() {
ComponentDescriptor descriptor = RuleSerialization.describe(this);
return ComponentSerialization.toJson(descriptor);
}
/**
* Builds a rule from JSON.
*
* @param series bar series context
* @param json payload
* @return reconstructed rule
* @since 0.22
*/
static Rule fromJson(BarSeries series, String json) {
ComponentDescriptor descriptor = ComponentSerialization.parse(json);
return RuleSerialization.fromDescriptor(series, descriptor);
}
/**
* @param rule another trading rule
* @return a rule which is the AND combination of this rule with the provided
* one
*/
default Rule and(Rule rule) {
return new AndRule(this, rule);
}
/**
* @param rule another trading rule
* @return a rule which is the OR combination of this rule with the provided one
*/
default Rule or(Rule rule) {
return new OrRule(this, rule);
}
/**
* @param rule another trading rule
* @return a rule which is the XOR combination of this rule with the provided
* one
*/
default Rule xor(Rule rule) {
return new XorRule(this, rule);
}
/**
* @return a rule which is the logical negation of this rule
*/
default Rule negation() {
return new NotRule(this);
}
/**
* @param index the bar index
* @return true if this rule is satisfied for the provided index, false
* otherwise
*/
default boolean isSatisfied(int index) {
return isSatisfied(index, (TradingRecord) null);
}
/**
* @param index the bar index
* @param tradingRecord the potentially needed trading history
* @return true if this rule is satisfied for the provided index, false
* otherwise
*/
boolean isSatisfied(int index, TradingRecord tradingRecord);
/**
* Evaluates this rule once with the supplied trace detail. Implementations that
* do not support scoped tracing may ignore {@code traceMode} and delegate to
* {@link #isSatisfied(int, TradingRecord)}.
*
* @param index the bar index
* @param traceMode trace detail for this evaluation only; {@code null} uses
* {@link TraceMode#VERBOSE}
* @return true if this rule is satisfied for the provided index, false
* otherwise
* @since 0.22.7
*/
default boolean isSatisfiedWithTraceMode(int index, TraceMode traceMode) {
return isSatisfiedWithTraceMode(index, null, traceMode);
}
/**
* Evaluates this rule once with the supplied trace detail. Implementations that
* do not support scoped tracing may ignore {@code traceMode} and delegate to
* {@link #isSatisfied(int, TradingRecord)}.
*
* @param index the bar index
* @param tradingRecord the potentially needed trading history
* @param traceMode trace detail for this evaluation only; {@code null} uses
* {@link TraceMode#VERBOSE}
* @return true if this rule is satisfied for the provided index, false
* otherwise
* @since 0.22.7
*/
default boolean isSatisfiedWithTraceMode(int index, TradingRecord tradingRecord, TraceMode traceMode) {
return isSatisfied(index, tradingRecord);
}
/**
* Sets a human friendly name for this rule. Implementations that support naming
* should override this method.
*
* <p>
* The default implementation is a no-op and does not store the name.
*
* @param name desired name; {@code null} or blank should reset the rule name
* back to the implementation specific default
* @since 0.19
*/
default void setName(String name) {
// no-op by default to preserve backwards compatibility for custom Rule
// implementations that do not support naming yet
}
/**
* Returns the configured name for this rule.
*
* @return a descriptive name or a lightweight default
* @since 0.19
*/
default String getName() {
return toString();
}
}
@@ -0,0 +1,226 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.io.Serial;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.analysis.cost.ZeroCostModel;
import org.ta4j.core.num.Num;
import org.ta4j.core.utils.DeprecationNotifier;
/**
* Deprecated simulated-trade compatibility facade.
*
* <p>
* Use {@link BaseTrade} or the static factory methods on {@link Trade} for new
* code. This type remains available in the 0.22.x line so existing backtest
* integrations can migrate without a hard patch-line break.
* </p>
*
* @since 0.22.2
*/
@Deprecated(since = "0.22.4")
public class SimulatedTrade extends BaseTrade {
@Serial
private static final long serialVersionUID = -905474949010114150L;
/**
* Constructor.
*
* @param index the index the trade is executed
* @param series the bar series
* @param type the trade type
* @since 0.22.2
*/
protected SimulatedTrade(int index, BarSeries series, Trade.TradeType type) {
this(index, series, type, series.numFactory().one());
}
/**
* Constructor.
*
* @param index the index the trade is executed
* @param series the bar series
* @param type the trade type
* @param amount the trade amount
* @since 0.22.2
*/
protected SimulatedTrade(int index, BarSeries series, Trade.TradeType type, Num amount) {
this(index, series, type, amount, new ZeroCostModel());
}
/**
* Constructor.
*
* @param index the index the trade is executed
* @param series the bar series
* @param type the trade type
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution cost
* @since 0.22.2
*/
protected SimulatedTrade(int index, BarSeries series, Trade.TradeType type, Num amount,
CostModel transactionCostModel) {
super(index, series, type, amount, transactionCostModel);
warnDeprecated();
}
/**
* Constructor.
*
* @param index the index the trade is executed
* @param type the trade type
* @param pricePerAsset the trade price per asset
* @since 0.22.2
*/
protected SimulatedTrade(int index, Trade.TradeType type, Num pricePerAsset) {
this(index, type, pricePerAsset, pricePerAsset.getNumFactory().one());
}
/**
* Constructor.
*
* @param index the index the trade is executed
* @param type the trade type
* @param pricePerAsset the trade price per asset
* @param amount the trade amount
* @since 0.22.2
*/
protected SimulatedTrade(int index, Trade.TradeType type, Num pricePerAsset, Num amount) {
this(index, type, pricePerAsset, amount, new ZeroCostModel());
}
/**
* Constructor.
*
* @param index the index the trade is executed
* @param type the trade type
* @param pricePerAsset the trade price per asset
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution
* @since 0.22.2
*/
protected SimulatedTrade(int index, Trade.TradeType type, Num pricePerAsset, Num amount,
CostModel transactionCostModel) {
super(index, type, pricePerAsset, amount, transactionCostModel);
warnDeprecated();
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @return a BUY trade
* @since 0.22.2
*/
public static SimulatedTrade buyAt(int index, BarSeries series) {
return new SimulatedTrade(index, series, Trade.TradeType.BUY);
}
/**
* @param index the index the trade is executed
* @param price the trade price per asset
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution
* @return a BUY trade
* @since 0.22.2
*/
public static SimulatedTrade buyAt(int index, Num price, Num amount, CostModel transactionCostModel) {
return new SimulatedTrade(index, Trade.TradeType.BUY, price, amount, transactionCostModel);
}
/**
* @param index the index the trade is executed
* @param price the trade price per asset
* @param amount the trade amount
* @return a BUY trade
* @since 0.22.2
*/
public static SimulatedTrade buyAt(int index, Num price, Num amount) {
return new SimulatedTrade(index, Trade.TradeType.BUY, price, amount);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @param amount the trade amount
* @return a BUY trade
* @since 0.22.2
*/
public static SimulatedTrade buyAt(int index, BarSeries series, Num amount) {
return new SimulatedTrade(index, series, Trade.TradeType.BUY, amount);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution
* @return a BUY trade
* @since 0.22.2
*/
public static SimulatedTrade buyAt(int index, BarSeries series, Num amount, CostModel transactionCostModel) {
return new SimulatedTrade(index, series, Trade.TradeType.BUY, amount, transactionCostModel);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @return a SELL trade
* @since 0.22.2
*/
public static SimulatedTrade sellAt(int index, BarSeries series) {
return new SimulatedTrade(index, series, Trade.TradeType.SELL);
}
/**
* @param index the index the trade is executed
* @param price the trade price per asset
* @param amount the trade amount
* @return a SELL trade
* @since 0.22.2
*/
public static SimulatedTrade sellAt(int index, Num price, Num amount) {
return new SimulatedTrade(index, Trade.TradeType.SELL, price, amount);
}
/**
* @param index the index the trade is executed
* @param price the trade price per asset
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution
* @return a SELL trade
* @since 0.22.2
*/
public static SimulatedTrade sellAt(int index, Num price, Num amount, CostModel transactionCostModel) {
return new SimulatedTrade(index, Trade.TradeType.SELL, price, amount, transactionCostModel);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @param amount the trade amount
* @return a SELL trade
* @since 0.22.2
*/
public static SimulatedTrade sellAt(int index, BarSeries series, Num amount) {
return new SimulatedTrade(index, series, Trade.TradeType.SELL, amount);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution
* @return a SELL trade
* @since 0.22.2
*/
public static SimulatedTrade sellAt(int index, BarSeries series, Num amount, CostModel transactionCostModel) {
return new SimulatedTrade(index, series, Trade.TradeType.SELL, amount, transactionCostModel);
}
private static void warnDeprecated() {
DeprecationNotifier.warnOnce(SimulatedTrade.class, "org.ta4j.core.BaseTrade");
}
}
@@ -0,0 +1,300 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import org.ta4j.core.Trade.TradeType;
import org.ta4j.core.serialization.ComponentDescriptor;
import org.ta4j.core.serialization.StrategySerialization;
/**
* A {@code Strategy} (also called "trading strategy") is a pair of
* complementary (entry and exit) {@link Rule rules}. It may recommend to enter
* or to exit. Recommendations are based respectively on the entry rule or on
* the exit rule.
*
* <p>
* In ta4j, a strategy is evaluated bar by bar during a backtest (via
* {@code BarSeriesManager} or {@code BacktestExecutor}) or in real-time. It
* signals {@code shouldEnter} or {@code shouldExit} based on the current index
* and trading record. Strategies can be composed using logical operators like
* {@link #and(Strategy)} or {@link #or(Strategy)}.
* </p>
*
* <p>
* Live-evaluation reminder: ta4j evaluates whatever bar state you provide at
* {@code index}. If your integration replaces the last bar as new ticks arrive,
* strategy decisions are evaluated against that still-forming bar (live
* candle). If your integration evaluates only after appending a finished bar,
* decisions are based on closed candles.
* </p>
*/
public interface Strategy {
/**
* @return the name of the strategy
*/
String getName();
/**
* @return the entry rule
*/
Rule getEntryRule();
/**
* Returns the starting trade type for this strategy.
*
* <p>
* Defaults to {@link TradeType#BUY} (long-only). Override when the strategy is
* meant to run in short-only mode.
* </p>
*
* @return starting trade type
* @since 0.22.2
*/
default TradeType getStartingType() {
return TradeType.BUY;
}
/**
* @return the exit rule
*/
Rule getExitRule();
/**
* @param strategy the other strategy
* @return the AND combination of two {@link Strategy strategies}
*/
Strategy and(Strategy strategy);
/**
* @param strategy the other strategy
* @return the OR combination of two {@link Strategy strategies}
*/
Strategy or(Strategy strategy);
/**
* @param name the name of the strategy
* @param strategy the other strategy
* @param unstableBars the number of first bars in a bar series that this
* strategy ignores
* @return the AND combination of two {@link Strategy strategies}
*/
Strategy and(String name, Strategy strategy, int unstableBars);
/**
* @param name the name of the strategy
* @param strategy the other strategy
* @param unstableBars the number of first bars in a bar series that this
* strategy ignores
* @return the OR combination of two {@link Strategy strategies}
*/
Strategy or(String name, Strategy strategy, int unstableBars);
/**
* @return the opposite of the {@link Strategy strategy}
*/
Strategy opposite();
/**
* @param unstableBars the number of first bars in a bar series that this
* strategy ignores
*/
void setUnstableBars(int unstableBars);
/**
* @return unstableBars the number of first bars in a bar series that this
* strategy ignores
*/
int getUnstableBars();
/**
* @param index a bar index
* @return true if this strategy is unstable at the provided index, false
* otherwise (stable)
*/
boolean isUnstableAt(int index);
/**
* @param index the bar index
* @param tradingRecord the potentially needed trading history
* @return true to recommend a trade, false otherwise (no recommendation)
*/
default boolean shouldOperate(int index, TradingRecord tradingRecord) {
Position position = tradingRecord.getCurrentPosition();
if (position.isNew()) {
return shouldEnter(index, tradingRecord);
} else if (position.isOpened()) {
return shouldExit(index, tradingRecord);
}
return false;
}
/**
* <p>
* <b>Implementation note:</b> This overload ignores trading state. In live
* execution, prefer {@link #shouldEnter(int, TradingRecord)} so rules can see
* open positions and avoid repeated entry signals while a position is already
* open.
* </p>
*
* @param index the bar index
* @return true to recommend to enter, false otherwise
*/
default boolean shouldEnter(int index) {
return shouldEnter(index, (TradingRecord) null);
}
/**
* <p>
* <b>Implementation note:</b> Use this overload for live systems so entry
* decisions include the current position state. After broker-confirmed fills,
* keep {@code tradingRecord} synchronized with executed fills.
* </p>
*
* @param index the bar index
* @param tradingRecord the potentially needed trading history
* @return true to recommend to enter, false otherwise
*/
default boolean shouldEnter(int index, TradingRecord tradingRecord) {
return !isUnstableAt(index) && getEntryRule().isSatisfied(index, tradingRecord);
}
/**
* Evaluates the entry rule once with the supplied trace detail. Implementations
* that do not support scoped tracing may ignore {@code traceMode} and delegate
* to {@link #shouldEnter(int, TradingRecord)}.
*
* @param index the bar index
* @param traceMode trace detail for this evaluation only; {@code null} uses
* {@link Rule.TraceMode#VERBOSE}
* @return true to recommend to enter, false otherwise
* @since 0.22.7
*/
default boolean shouldEnterWithTraceMode(int index, Rule.TraceMode traceMode) {
return shouldEnterWithTraceMode(index, null, traceMode);
}
/**
* Evaluates the entry rule once with the supplied trace detail. Implementations
* that do not support scoped tracing may ignore {@code traceMode} and delegate
* to {@link #shouldEnter(int, TradingRecord)}.
*
* @param index the bar index
* @param tradingRecord the potentially needed trading history
* @param traceMode trace detail for this evaluation only; {@code null} uses
* {@link Rule.TraceMode#VERBOSE}
* @return true to recommend to enter, false otherwise
* @since 0.22.7
*/
default boolean shouldEnterWithTraceMode(int index, TradingRecord tradingRecord, Rule.TraceMode traceMode) {
return shouldEnter(index, tradingRecord);
}
/**
* @param index the bar index
* @return true to recommend to exit, false otherwise
*/
default boolean shouldExit(int index) {
return shouldExit(index, (TradingRecord) null);
}
/**
* @param index the bar index
* @param tradingRecord the potentially needed trading history
* @return true to recommend to exit, false otherwise
*/
default boolean shouldExit(int index, TradingRecord tradingRecord) {
return !isUnstableAt(index) && getExitRule().isSatisfied(index, tradingRecord);
}
/**
* Evaluates the exit rule once with the supplied trace detail. Implementations
* that do not support scoped tracing may ignore {@code traceMode} and delegate
* to {@link #shouldExit(int, TradingRecord)}.
*
* @param index the bar index
* @param traceMode trace detail for this evaluation only; {@code null} uses
* {@link Rule.TraceMode#VERBOSE}
* @return true to recommend to exit, false otherwise
* @since 0.22.7
*/
default boolean shouldExitWithTraceMode(int index, Rule.TraceMode traceMode) {
return shouldExitWithTraceMode(index, null, traceMode);
}
/**
* Evaluates the exit rule once with the supplied trace detail. Implementations
* that do not support scoped tracing may ignore {@code traceMode} and delegate
* to {@link #shouldExit(int, TradingRecord)}.
*
* @param index the bar index
* @param tradingRecord the potentially needed trading history
* @param traceMode trace detail for this evaluation only; {@code null} uses
* {@link Rule.TraceMode#VERBOSE}
* @return true to recommend to exit, false otherwise
* @since 0.22.7
*/
default boolean shouldExitWithTraceMode(int index, TradingRecord tradingRecord, Rule.TraceMode traceMode) {
return shouldExit(index, tradingRecord);
}
/**
* Serializes {@code this} strategy into a JSON payload that captures its
* metadata and rule descriptors.
*
* @return JSON description of the strategy
* @throws NullPointerException if the strategy or any of its components are
* {@code null}
* @throws IllegalStateException if serialization fails due to an internal error
* during JSON generation
* @throws RuntimeException if serialization fails due to an I/O error or
* other runtime exception during JSON processing
* @since 0.19
*/
default String toJson() {
return StrategySerialization.toJson(this);
}
/**
* Converts {@code this} strategy into a structured descriptor that can be
* embedded inside other component metadata.
*
* @return component descriptor for the strategy
* @throws NullPointerException if the strategy or any of its rules are
* {@code null}
* @throws IllegalArgumentException if rule serialization fails due to
* unsupported constructor signatures or
* invalid rule configurations
* @since 0.19
*/
default ComponentDescriptor toDescriptor() {
return StrategySerialization.describe(this);
}
/**
* Reconstructs a strategy instance from its serialized representation.
*
* @param series backing series to attach to the reconstructed strategy; must
* not be {@code null}
* @param json serialized strategy payload generated by {@link #toJson()};
* must not be {@code null} and must be a valid JSON
* representation of a strategy descriptor
* @return reconstructed strategy instance with entry and exit rules restored
* from the descriptor
* @throws NullPointerException if {@code series} or {@code json} is
* {@code null}
* @throws IllegalArgumentException if the JSON payload is malformed, missing
* required component descriptors (entry/exit
* rules), contains incompatible component
* types, or fails validation during
* deserialization
* @throws IllegalStateException if strategy construction fails due to
* missing constructors, instantiation errors,
* or unresolved component dependencies
* @since 0.19
*/
static Strategy fromJson(BarSeries series, String json) {
return StrategySerialization.fromJson(series, json);
}
}
@@ -0,0 +1,425 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.io.Serializable;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.analysis.cost.RecordedTradeCostModel;
import org.ta4j.core.analysis.cost.ZeroCostModel;
import org.ta4j.core.num.Num;
/**
* Read-only trade contract shared by simulated and live executions.
*
* <ul>
* <li>the index (in the {@link BarSeries bar series}) on which the trade is
* executed</li>
* <li>a {@link TradeType type} (BUY or SELL)</li>
* <li>a price per asset (optional)</li>
* <li>a trade amount (optional)</li>
* </ul>
*
* <p>
* Metadata fields (timestamp, instrument, ids) are optional and may return
* {@code null}. They loosely mirror the attributes in XChange's trade DTO so
* adapters can preserve exchange-provided identifiers when available.
* </p>
*
* <p>
* Use the static factory methods on {@link Trade} for new code. The concrete
* implementation type is an internal detail.
* </p>
*
* @since 0.22.2
*/
public interface Trade extends Serializable {
/** The type of a trade. */
enum TradeType {
/** A BUY corresponds to a <i>BID</i> trade. */
BUY {
@Override
public TradeType complementType() {
return SELL;
}
},
/** A SELL corresponds to an <i>ASK</i> trade. */
SELL {
@Override
public TradeType complementType() {
return BUY;
}
};
/**
* @return the complementary trade type
*/
public abstract TradeType complementType();
}
/**
* @return the trade type (BUY or SELL)
*/
TradeType getType();
/**
* @return the index the trade is executed
*/
int getIndex();
/**
* @return the trade price per asset
*/
Num getPricePerAsset();
/**
* @param barSeries the bar series
* @return the trade price per asset, or, if {@code NaN}, the close price from
* the supplied {@link BarSeries}
*/
default Num getPricePerAsset(BarSeries barSeries) {
Num price = getPricePerAsset();
if (price.isNaN()) {
return barSeries.getBar(getIndex()).getClosePrice();
}
return price;
}
/**
* @return the net price per asset for the trade (i.e.
* {@link #getPricePerAsset()} with trading costs)
*/
Num getNetPrice();
/**
* @return the trade amount
*/
Num getAmount();
/**
* @return the simulated costs of the trade as calculated by the configured
* {@link CostModel}
*/
Num getCost();
/**
* @return the cost model for trade execution
*/
CostModel getCostModel();
/**
* @return execution timestamp if available, otherwise {@code null}
* @since 0.22.2
*/
default Instant getTime() {
return null;
}
/**
* @return exchange-provided trade id if available, otherwise {@code null}
* @since 0.22.2
*/
default String getId() {
return null;
}
/**
* @return instrument identifier (symbol/pair) if available, otherwise
* {@code null}
* @since 0.22.2
*/
default String getInstrument() {
return null;
}
/**
* @return originating order id if available, otherwise {@code null}
* @since 0.22.2
*/
default String getOrderId() {
return null;
}
/**
* @return correlation id if available, otherwise {@code null}
* @since 0.22.2
*/
default String getCorrelationId() {
return null;
}
/**
* @return true if this is a BUY trade, false otherwise
*/
default boolean isBuy() {
return getType() == TradeType.BUY;
}
/**
* @return true if this is a SELL trade, false otherwise
*/
default boolean isSell() {
return getType() == TradeType.SELL;
}
/**
* @return the value of a trade (without transaction cost)
*/
default Num getValue() {
return getPricePerAsset().multipliedBy(getAmount());
}
/**
* Returns execution fills for this trade.
*
* <p>
* Default simulated trades expose a single fill. Aggregated/partial trades may
* return multiple fills. The default single fill mirrors trade-level metadata
* (time, fee, order/correlation ids) when available.
* </p>
*
* @return execution fills of this trade
* @since 0.22.4
*/
default List<TradeFill> getFills() {
ExecutionSide side = getType() == TradeType.BUY ? ExecutionSide.BUY : ExecutionSide.SELL;
return List.of(new TradeFill(getIndex(), getTime(), getPricePerAsset(), getAmount(), getCost(), side,
getOrderId(), getCorrelationId()));
}
/**
* Resolves execution fills for the provided trade.
*
* <p>
* Trades should expose fills via {@link #getFills()}. When an implementation
* returns an empty list, this method falls back to index/price/amount to
* preserve compatibility with legacy scalar trade semantics.
* </p>
*
* @param trade trade to inspect
* @return immutable execution fills for the trade
* @since 0.22.4
*/
static List<TradeFill> executionFillsOf(Trade trade) {
Objects.requireNonNull(trade, "trade");
List<TradeFill> fills = List.copyOf(trade.getFills());
if (!fills.isEmpty()) {
return fills;
}
ExecutionSide side = trade.getType() == TradeType.BUY ? ExecutionSide.BUY : ExecutionSide.SELL;
return List.of(new TradeFill(trade.getIndex(), trade.getTime(), trade.getPricePerAsset(), trade.getAmount(),
trade.getCost(), side, trade.getOrderId(), trade.getCorrelationId()));
}
/**
* Creates a trade from one execution fill using recorded-fee semantics.
*
* <p>
* The fill must expose {@link TradeFill#side()} so the trade direction is
* explicit at construction time.
* </p>
*
* @param fill execution fill
* @return a trade representing the provided fill
* @throws IllegalArgumentException when {@code fill.side()} is missing
* @since 0.22.4
*/
static Trade fromFill(TradeFill fill) {
return fromFill(fill, RecordedTradeCostModel.INSTANCE);
}
/**
* Creates a trade from one execution fill using an explicit cost model.
*
* <p>
* The fill must expose {@link TradeFill#side()} so the trade direction is
* explicit at construction time.
* </p>
*
* @param fill execution fill
* @param transactionCostModel transaction cost model
* @return a trade representing the provided fill
* @throws IllegalArgumentException when {@code fill.side()} is missing
* @since 0.22.4
*/
static Trade fromFill(TradeFill fill, CostModel transactionCostModel) {
Objects.requireNonNull(fill, "fill");
if (fill.side() == null) {
throw new IllegalArgumentException("fill.side must be set when trade type is not provided");
}
return fromFill(fill.side().toTradeType(), fill, transactionCostModel);
}
/**
* Creates a trade from one execution fill using recorded-fee semantics.
*
* @param type trade type
* @param fill execution fill
* @return a trade representing the provided fill
* @since 0.22.4
*/
static Trade fromFill(TradeType type, TradeFill fill) {
return fromFill(type, fill, RecordedTradeCostModel.INSTANCE);
}
/**
* Creates a trade from one execution fill.
*
* @param type trade type
* @param fill execution fill
* @param transactionCostModel transaction cost model
* @return a trade representing the provided fill
* @since 0.22.4
*/
static Trade fromFill(TradeType type, TradeFill fill, CostModel transactionCostModel) {
Objects.requireNonNull(fill, "fill");
return fromFills(type, List.of(fill), transactionCostModel);
}
/**
* Creates a trade from one or more execution fills using recorded-fee
* semantics.
*
* @param type trade type
* @param fills execution fills (must not be empty)
* @return a trade representing the provided fills
* @since 0.22.4
*/
static Trade fromFills(TradeType type, List<TradeFill> fills) {
return fromFills(type, fills, RecordedTradeCostModel.INSTANCE);
}
/**
* Creates a trade from one or more execution fills.
*
* <p>
* The returned trade is a {@link BaseTrade}. Single-fill inputs keep scalar
* semantics; multi-fill inputs preserve full fill progression while exposing
* aggregated price/amount views.
* </p>
*
* @param type trade type
* @param fills execution fills (must not be empty)
* @param transactionCostModel transaction cost model
* @return a trade representing the provided fills
* @throws IllegalArgumentException when fills are empty or invalid
* @since 0.22.4
*/
static Trade fromFills(TradeType type, List<TradeFill> fills, CostModel transactionCostModel) {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(fills, "fills");
Objects.requireNonNull(transactionCostModel, "transactionCostModel");
if (fills.isEmpty()) {
throw new IllegalArgumentException("fills must not be empty");
}
return new BaseTrade(type, fills, transactionCostModel);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @return a BUY trade
*/
static Trade buyAt(int index, BarSeries series) {
return new BaseTrade(index, series, TradeType.BUY);
}
/**
* @param index the index the trade is executed
* @param price the trade price per asset
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution
* @return a BUY trade
*/
static Trade buyAt(int index, Num price, Num amount, CostModel transactionCostModel) {
return new BaseTrade(index, TradeType.BUY, price, amount, transactionCostModel);
}
/**
* @param index the index the trade is executed
* @param price the trade price per asset
* @param amount the trade amount
* @return a BUY trade
*/
static Trade buyAt(int index, Num price, Num amount) {
return new BaseTrade(index, TradeType.BUY, price, amount);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @param amount the trade amount
* @return a BUY trade
*/
static Trade buyAt(int index, BarSeries series, Num amount) {
return new BaseTrade(index, series, TradeType.BUY, amount);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution
* @return a BUY trade
*/
static Trade buyAt(int index, BarSeries series, Num amount, CostModel transactionCostModel) {
return new BaseTrade(index, series, TradeType.BUY, amount, transactionCostModel);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @return a SELL trade
*/
static Trade sellAt(int index, BarSeries series) {
return new BaseTrade(index, series, TradeType.SELL);
}
/**
* @param index the index the trade is executed
* @param price the trade price per asset
* @param amount the trade amount
* @return a SELL trade
*/
static Trade sellAt(int index, Num price, Num amount) {
return new BaseTrade(index, TradeType.SELL, price, amount);
}
/**
* @param index the index the trade is executed
* @param price the trade price per asset
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution
* @return a SELL trade
*/
static Trade sellAt(int index, Num price, Num amount, CostModel transactionCostModel) {
return new BaseTrade(index, TradeType.SELL, price, amount, transactionCostModel);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @param amount the trade amount
* @return a SELL trade
*/
static Trade sellAt(int index, BarSeries series, Num amount) {
return new BaseTrade(index, series, TradeType.SELL, amount);
}
/**
* @param index the index the trade is executed
* @param series the bar series
* @param amount the trade amount
* @param transactionCostModel the cost model for trade execution
* @return a SELL trade
*/
static Trade sellAt(int index, BarSeries series, Num amount, CostModel transactionCostModel) {
return new BaseTrade(index, series, TradeType.SELL, amount, transactionCostModel);
}
}
@@ -0,0 +1,86 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import java.io.Serial;
import java.io.Serializable;
import java.time.Instant;
import java.util.Objects;
import org.ta4j.core.num.Num;
/**
* Immutable execution fill used to represent partial trade executions.
*
* <p>
* This is the preferred fill primitive for new integrations that need
* broker-confirmed execution recording through
* {@link TradingRecord#operate(TradeFill)}.
* </p>
*
* <p>
* Metadata fields ({@code time}, {@code side}, IDs) are optional and may be
* {@code null}. {@code fee} defaults to zero when omitted.
* </p>
*
* @param index bar index where the fill happened; {@code -1} is
* reserved for fills awaiting recorder-assigned indices
* @param time optional execution timestamp (UTC)
* @param price execution price per asset
* @param amount executed amount
* @param fee optional execution fee (defaults to zero when null)
* @param side optional execution side
* @param orderId optional order id
* @param correlationId optional correlation id
*
* @since 0.22.4
*/
public record TradeFill(int index, Instant time, Num price, Num amount, Num fee, ExecutionSide side, String orderId,
String correlationId) implements Serializable {
@Serial
private static final long serialVersionUID = -258216480640174496L;
/**
* Creates a trade fill with scalar fields only.
*
* @param index bar index where the fill happened
* @param price execution price per asset
* @param amount executed amount
* @since 0.22.4
*/
public TradeFill(int index, Num price, Num amount) {
this(index, null, price, amount, null, null, null, null);
}
/**
* Creates a trade fill with execution side/time metadata.
*
* @param index bar index where the fill happened
* @param time execution timestamp (UTC), nullable
* @param price execution price per asset
* @param amount executed amount
* @param side execution side, nullable
* @since 0.22.4
*/
public TradeFill(int index, Instant time, Num price, Num amount, ExecutionSide side) {
this(index, time, price, amount, null, side, null, null);
}
/**
* Creates a trade fill.
*
* @throws NullPointerException if price or amount is null
* @since 0.22.4
*/
public TradeFill {
if (index < -1) {
throw new IllegalArgumentException("index must be >= -1");
}
Objects.requireNonNull(price, "price");
Objects.requireNonNull(amount, "amount");
if (fee == null) {
fee = price.getNumFactory().zero();
}
}
}
@@ -0,0 +1,385 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core;
import static org.ta4j.core.num.NaN.NaN;
import java.io.Serializable;
import java.util.List;
import java.util.Objects;
import org.ta4j.core.Trade.TradeType;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.num.Num;
/**
* A {@code TradingRecord} holds the full history/record of a trading session
* when running a {@link Strategy strategy}. It can be used to:
*
* <ul>
* <li>analyze the performance of a {@link Strategy strategy}
* <li>check whether some {@link Rule rules} are satisfied (while running a
* strategy)
* </ul>
*
* <p>
* {@link Trade} is the public trade contract. Concrete trade implementations
* are internal details and should not be required by strategy or backtest code.
* </p>
*
* <p>
* Execution metadata on trades/fills ({@code time}, {@code side},
* {@code orderId}, {@code correlationId}) may be missing in simulated
* environments. Implementations should preserve this metadata when provided and
* apply deterministic fallbacks when it is absent.
* </p>
*/
public interface TradingRecord extends Serializable {
/**
* @return the entry type (BUY or SELL) of the first trade in the trading
* session
*/
TradeType getStartingType();
/**
* @return the name of the TradingRecord
*/
String getName();
/**
* Places a trade in the trading record.
*
* @param index the index to place the trade
*/
default void operate(int index) {
operate(index, NaN, NaN);
}
/**
* Places a trade in the trading record.
*
* @param index the index to place the trade
* @param price the trade price per asset
* @param amount the trade amount
*/
void operate(int index, Num price, Num amount);
/**
* Places one execution fill in the trading record.
*
* <p>
* This is a convenience overload for streaming partial fills directly into a
* fill-aware record. It is equivalent to {@code operate(Trade.fromFill(fill))}.
* </p>
*
* <p>
* The fill must expose {@link TradeFill#side()} so the trade direction is
* explicit at ingestion time.
* </p>
*
* @param fill the execution fill to place
* @throws IllegalArgumentException when {@code fill.side()} is missing
* @since 0.22.4
*/
default void operate(TradeFill fill) {
operate(Trade.fromFill(fill));
}
/**
* Places a pre-built trade in the trading record.
*
* <p>
* This is useful for execution models that aggregate partial fills into a
* single entry or exit trade.
* </p>
*
* <p>
* The default implementation delegates to {@link #operate(int, Num, Num)} and
* therefore supports only index/price/amount semantics. Implementations that
* store additional execution metadata should override this method.
* </p>
*
* @param trade the trade to place
* @throws UnsupportedOperationException if {@code trade} contains multiple
* fills and this implementation has not
* overridden this method
* @since 0.22.4
*/
default void operate(Trade trade) {
Objects.requireNonNull(trade, "trade");
List<TradeFill> fills = Trade.executionFillsOf(trade);
if (fills.size() > 1) {
throw new UnsupportedOperationException(
"This TradingRecord implementation must override operate(Trade) to preserve multi-fill trades");
}
TradeFill fill = fills.getFirst();
operate(fill.index(), fill.price(), fill.amount());
}
/**
* Places an entry trade in the trading record.
*
* @param index the index to place the entry
* @return true if the entry has been placed, false otherwise
*/
default boolean enter(int index) {
return enter(index, NaN, NaN);
}
/**
* Places an entry trade in the trading record.
*
* @param index the index to place the entry
* @param price the trade price per asset
* @param amount the trade amount
* @return true if the entry has been placed, false otherwise
*/
boolean enter(int index, Num price, Num amount);
/**
* Places an entry trade in the trading record.
*
* @param trade the entry trade to place
* @return true if the entry has been placed, false otherwise
* @throws IllegalArgumentException when trade type is not the configured entry
* type
* @since 0.22.4
*/
default boolean enter(Trade trade) {
Objects.requireNonNull(trade, "trade");
TradeType expectedEntryType = getStartingType();
if (trade.getType() != expectedEntryType) {
throw new IllegalArgumentException("Entry trade type must be " + expectedEntryType);
}
if (isClosed()) {
operate(trade);
return true;
}
return false;
}
/**
* Places an exit trade in the trading record.
*
* @param index the index to place the exit
* @return true if the exit has been placed, false otherwise
*/
default boolean exit(int index) {
return exit(index, NaN, NaN);
}
/**
* Places an exit trade in the trading record.
*
* @param index the index to place the exit
* @param price the trade price per asset
* @param amount the trade amount
* @return true if the exit has been placed, false otherwise
*/
boolean exit(int index, Num price, Num amount);
/**
* Places an exit trade in the trading record.
*
* @param trade the exit trade to place
* @return true if the exit has been placed, false otherwise
* @throws IllegalArgumentException when trade type is not the configured exit
* type
* @since 0.22.4
*/
default boolean exit(Trade trade) {
Objects.requireNonNull(trade, "trade");
TradeType expectedExitType = getStartingType().complementType();
if (trade.getType() != expectedExitType) {
throw new IllegalArgumentException("Exit trade type must be " + expectedExitType);
}
if (!isClosed()) {
operate(trade);
return true;
}
return false;
}
/**
* @return true if no position is open, false otherwise
*/
default boolean isClosed() {
return !getCurrentPosition().isOpened();
}
/**
* @return the transaction cost model
*/
CostModel getTransactionCostModel();
/**
* @return holding cost model
*/
CostModel getHoldingCostModel();
/**
* @return the recorded closed positions
*/
List<Position> getPositions();
/**
* @return the number of recorded closed positions
*/
default int getPositionCount() {
return getPositions().size();
}
/**
* Returns the canonical current-position view for this record.
*
* <p>
* When the record has open exposure, this is the aggregated net-open
* {@link Position}. When the record is flat, this returns a new/empty
* {@link Position} snapshot instead of {@code null}.
* </p>
*
* @return the canonical current-position view
*/
Position getCurrentPosition();
/**
* Returns recorded execution fees when the implementation maintains a fee
* ledger separate from modeled transaction costs.
*
* <p>
* Legacy trading-record implementations can rely on this default and return
* {@code null} when they only support model-derived transaction costs.
* </p>
*
* @return recorded execution fees, or {@code null} when unavailable
* @since 0.22.4
*/
default Num getRecordedTotalFees() {
return null;
}
/**
* Returns open positions when supported by the implementation.
*
* <p>
* Lot-aware implementations should return one open {@link Position} snapshot
* per remaining open lot. Legacy trading-record implementations that only model
* a single synthetic current position can rely on this default and return an
* empty list.
* </p>
*
* @return open per-lot position snapshots
* @since 0.22.4
*/
default List<Position> getOpenPositions() {
return List.of();
}
/**
* Returns the aggregated net open position when supported by the
* implementation.
*
* <p>
* New code should prefer {@link #getCurrentPosition()}. This method remains as
* a compatibility alias for callers that previously requested a dedicated open
* position view. Legacy implementations that do not expose a distinct net-open
* snapshot can rely on the default and return {@code null}.
* </p>
*
* @return aggregated net open position, or {@code null} when no position is
* open
* @since 0.22.4
*/
@Deprecated(since = "0.22.4")
default Position getNetOpenPosition() {
return null;
}
/**
* @return the last closed position recorded
*/
default Position getLastPosition() {
List<Position> positions = getPositions();
if (!positions.isEmpty()) {
return positions.getLast();
}
return null;
}
/**
* @return the trades recorded
*/
List<Trade> getTrades();
/**
* @return the last trade recorded
*/
default Trade getLastTrade() {
List<Trade> trades = getTrades();
if (!trades.isEmpty()) {
return trades.getLast();
}
return null;
}
/**
* @param tradeType the type of the trade to get the last of
* @return the last trade (of the provided type) recorded
*/
default Trade getLastTrade(TradeType tradeType) {
List<Trade> trades = getTrades();
for (int i = trades.size() - 1; i >= 0; i--) {
Trade trade = trades.get(i);
if (trade.getType() == tradeType) {
return trade;
}
}
return null;
}
/**
* @return the last entry trade recorded
*/
default Trade getLastEntry() {
return getLastTrade(getStartingType());
}
/**
* @return the last exit trade recorded
*/
default Trade getLastExit() {
return getLastTrade(getStartingType().complementType());
}
/**
* @return the start of the recording (included)
*/
Integer getStartIndex();
/**
* @return the end of the recording (included)
*/
Integer getEndIndex();
/**
* @param series the bar series, not null
* @return the {@link #getStartIndex()} if not null and greater than
* {@link BarSeries#getBeginIndex()}, otherwise
* {@link BarSeries#getBeginIndex()}
*/
default int getStartIndex(BarSeries series) {
return getStartIndex() == null ? series.getBeginIndex() : Math.max(getStartIndex(), series.getBeginIndex());
}
/**
* @param series the bar series, not null
* @return the {@link #getEndIndex()} if not null and less than
* {@link BarSeries#getEndIndex()}, otherwise
* {@link BarSeries#getEndIndex()}
*/
default int getEndIndex(BarSeries series) {
return getEndIndex() == null ? series.getEndIndex() : Math.min(getEndIndex(), series.getEndIndex());
}
}
@@ -0,0 +1,138 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.aggregator;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import org.ta4j.core.Bar;
/**
* Aggregates a list of {@link Bar bars} into another one.
*/
public interface BarAggregator {
/**
* Aggregates the {@code bars} into another one.
*
* @param bars the bars to be aggregated
* @return aggregated bars
*/
List<Bar> aggregate(List<Bar> bars);
/**
* Validates that source bars are contiguous and have a consistent positive
* interval.
*
* @param bars source bars in chronological order
* @return the common source interval
* @throws NullPointerException if {@code bars} is {@code null}
* @throws IllegalArgumentException if any bar is null, has an invalid period, a
* measured-period mismatch, an uneven
* interval, or a gap between intervals
*
* @since 0.22.4
*/
default Duration requireEvenIntervals(List<Bar> bars) {
Objects.requireNonNull(bars, "bars");
if (bars.isEmpty()) {
return Duration.ZERO;
}
String aggregatorName = getClass().getSimpleName();
Bar firstBar = bars.getFirst();
Duration expectedTimePeriod = requireConsistentPeriod(firstBar, aggregatorName, 0);
Bar previousBar = firstBar;
for (int i = 1; i < bars.size(); i++) {
Bar currentBar = bars.get(i);
Duration currentTimePeriod = requireConsistentPeriod(currentBar, aggregatorName, i);
if (!expectedTimePeriod.equals(currentTimePeriod)) {
throw new IllegalArgumentException(
String.format("%s requires even source intervals: bar %d has period %s but expected %s.",
aggregatorName, i, currentTimePeriod, expectedTimePeriod));
}
if (!currentBar.getBeginTime().equals(previousBar.getEndTime())) {
throw new IllegalArgumentException(String.format(
"%s requires contiguous source intervals: bar %d begins at %s but previous bar ended at %s.",
aggregatorName, i, currentBar.getBeginTime(), previousBar.getEndTime()));
}
previousBar = currentBar;
}
return expectedTimePeriod;
}
/**
* Validates that a numeric threshold/configuration value is finite and strictly
* positive.
*
* @param value the candidate numeric value
* @param parameterName the constructor/argument name
* @return {@code value} when validation succeeds
* @throws NullPointerException if {@code value} is {@code null}
* @throws IllegalArgumentException if {@code value} is non-finite, not
* decimal-representable, or not greater than
* zero
*
* @since 0.22.4
*/
static Number requirePositiveFiniteNumber(Number value, String parameterName) {
Objects.requireNonNull(value, parameterName);
ensureFiniteNumber(value, parameterName);
BigDecimal decimal = parseDecimal(value, parameterName);
if (decimal.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException(parameterName + " must be greater than zero.");
}
return value;
}
private static Duration requireConsistentPeriod(Bar bar, String aggregatorName, int index) {
if (bar == null) {
throw new IllegalArgumentException(
String.format("%s requires non-null source bars: bar %d is null.", aggregatorName, index));
}
Duration timePeriod = bar.getTimePeriod();
if (timePeriod == null || timePeriod.isNegative() || timePeriod.isZero()) {
throw new IllegalArgumentException(
String.format("%s requires positive source intervals: bar %d has invalid period %s.",
aggregatorName, index, timePeriod));
}
Instant beginTime = bar.getBeginTime();
Instant endTime = bar.getEndTime();
if (beginTime == null || endTime == null) {
throw new IllegalArgumentException(
String.format("%s requires non-null source timestamps: bar %d has begin=%s, end=%s.",
aggregatorName, index, beginTime, endTime));
}
Duration measuredPeriod = Duration.between(beginTime, endTime);
if (!measuredPeriod.equals(timePeriod)) {
throw new IllegalArgumentException(
String.format("%s requires consistent source intervals: bar %d spans %s but period is %s.",
aggregatorName, index, measuredPeriod, timePeriod));
}
return timePeriod;
}
private static BigDecimal parseDecimal(Number value, String parameterName) {
try {
return new BigDecimal(value.toString());
} catch (NumberFormatException ex) {
throw new IllegalArgumentException(
parameterName + " must be a finite numeric value representable as decimal.", ex);
}
}
private static void ensureFiniteNumber(Number value, String parameterName) {
if (value instanceof Double d && !Double.isFinite(d)) {
throw new IllegalArgumentException(parameterName + " must be finite.");
}
if (value instanceof Float f && !Float.isFinite(f)) {
throw new IllegalArgumentException(parameterName + " must be finite.");
}
}
}
@@ -0,0 +1,31 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.aggregator;
import org.ta4j.core.BarSeries;
/**
* Aggregates a {@link BarSeries} into another one.
*/
public interface BarSeriesAggregator {
/**
* Aggregates the {@code series} into another one.
*
* @param series the series to be aggregated
* @return aggregated series
*/
default BarSeries aggregate(BarSeries series) {
return aggregate(series, series.getName());
}
/**
* Aggregates the {@code series} into another one.
*
* @param series the series to be aggregated
* @param aggregatedSeriesName the name for the aggregated series
* @return aggregated series with specified name
*/
BarSeries aggregate(BarSeries series, String aggregatedSeriesName);
}
@@ -0,0 +1,35 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.aggregator;
import java.util.List;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBarSeries;
import org.ta4j.core.BaseBarSeriesBuilder;
/**
* Aggregates a {@link BaseBarSeries} into another one using a
* {@link BarAggregator}.
*/
public class BaseBarSeriesAggregator implements BarSeriesAggregator {
private final BarAggregator barAggregator;
/**
* Constructor.
*
* @param barAggregator the {@link BarAggregator}
*/
public BaseBarSeriesAggregator(BarAggregator barAggregator) {
this.barAggregator = barAggregator;
}
@Override
public BarSeries aggregate(BarSeries series, String aggregatedSeriesName) {
final List<Bar> aggregatedBars = barAggregator.aggregate(series.getBarData());
return new BaseBarSeriesBuilder().withName(aggregatedSeriesName).withBars(aggregatedBars).build();
}
}
@@ -0,0 +1,142 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.aggregator;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import org.ta4j.core.Bar;
import org.ta4j.core.BaseBar;
import org.ta4j.core.bars.TimeBarBuilder;
import org.ta4j.core.num.Num;
/**
* Aggregates a list of {@link BaseBar bars} into another one by
* {@link BaseBar#timePeriod duration}.
*/
public class DurationBarAggregator implements BarAggregator {
/** The target time period that aggregated bars should have. */
private final Duration timePeriod;
private final boolean onlyFinalBars;
/**
* Duration based bar aggregator. Only bars with elapsed time (final bars) will
* be created.
*
* @param timePeriod the target time period that aggregated bars should have
*/
public DurationBarAggregator(Duration timePeriod) {
this(timePeriod, true);
}
/**
* Duration based bar aggregator.
*
* @param timePeriod the target time period that aggregated bars should have
* @param onlyFinalBars if true, only bars with elapsed time (final bars) will
* be created, otherwise also pending bars
*/
public DurationBarAggregator(Duration timePeriod, boolean onlyFinalBars) {
this.timePeriod = timePeriod;
this.onlyFinalBars = onlyFinalBars;
}
/**
* Aggregates the {@code bars} into another one by {@link #timePeriod}.
*
* @param bars the actual bars with actual {@code timePeriod}
* @return the aggregated bars with new {@link #timePeriod}
* @throws IllegalArgumentException if {@link #timePeriod} is not a
* multiplication of actual {@code timePeriod}
*/
@Override
public List<Bar> aggregate(List<Bar> bars) {
final List<Bar> aggregated = new ArrayList<>();
if (bars.isEmpty()) {
return aggregated;
}
final Bar firstBar = bars.getFirst();
// get the actual time period
final Duration actualDur = firstBar.getTimePeriod();
// check if new timePeriod is a multiplication of actual time period
final boolean isMultiplication = timePeriod.getSeconds() % actualDur.getSeconds() == 0;
if (!isMultiplication) {
throw new IllegalArgumentException(
"Cannot aggregate bars: the new timePeriod must be a multiplication of the actual timePeriod.");
}
int i = 0;
final Num zero = firstBar.numFactory().zero();
while (i < bars.size()) {
Bar bar = bars.get(i);
final Instant beginTime = bar.getBeginTime();
final Num open = bar.getOpenPrice();
Num high = bar.getHighPrice();
Num low = bar.getLowPrice();
Num close = null;
Num volume = zero;
Num amount = zero;
long trades = 0;
Duration sumDur = Duration.ZERO;
while (isInDuration(sumDur)) {
if (i < bars.size()) {
if (!beginTimesInDuration(beginTime, bars.get(i).getBeginTime())) {
break;
}
bar = bars.get(i);
if (high == null || bar.getHighPrice().isGreaterThan(high)) {
high = bar.getHighPrice();
}
if (low == null || bar.getLowPrice().isLessThan(low)) {
low = bar.getLowPrice();
}
close = bar.getClosePrice();
if (bar.getVolume() != null) {
volume = volume.plus(bar.getVolume());
}
if (bar.getAmount() != null) {
amount = amount.plus(bar.getAmount());
}
if (bar.getTrades() != 0) {
trades = trades + bar.getTrades();
}
}
sumDur = sumDur.plus(actualDur);
i++;
}
if (!onlyFinalBars || i <= bars.size()) {
final Bar aggregatedBar = new TimeBarBuilder().timePeriod(timePeriod)
.endTime(beginTime.plus(timePeriod))
.openPrice(open)
.highPrice(high)
.lowPrice(low)
.closePrice(close)
.volume(volume)
.amount(amount)
.trades(trades)
.build();
aggregated.add(aggregatedBar);
}
}
return aggregated;
}
private boolean beginTimesInDuration(Instant startTime, Instant endTime) {
return Duration.between(startTime, endTime).compareTo(timePeriod) < 0;
}
private boolean isInDuration(Duration duration) {
return duration.compareTo(timePeriod) < 0;
}
}
@@ -0,0 +1,53 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.aggregator;
import org.ta4j.core.Bar;
import org.ta4j.core.BaseBar;
import org.ta4j.core.bars.HeikinAshiBarBuilder;
import org.ta4j.core.num.Num;
import java.util.ArrayList;
import java.util.List;
/**
* Aggregates a list of {@link BaseBar bars} into another one by following
* Heikin-Ashi logic
*/
public class HeikinAshiBarAggregator implements BarAggregator {
@Override
public List<Bar> aggregate(List<Bar> ohlcBars) {
var heikinAshiBars = new ArrayList<Bar>();
var haBuilder = new HeikinAshiBarBuilder();
Num previousOpen = null;
Num previousClose = null;
for (Bar ohlcBar : ohlcBars) {
haBuilder.timePeriod(ohlcBar.getTimePeriod())
.endTime(ohlcBar.getEndTime())
.openPrice(ohlcBar.getOpenPrice())
.highPrice(ohlcBar.getHighPrice())
.lowPrice(ohlcBar.getLowPrice())
.closePrice(ohlcBar.getClosePrice())
.volume(ohlcBar.getVolume())
.amount(ohlcBar.getAmount())
.trades(ohlcBar.getTrades());
if (previousOpen != null && previousClose != null) {
haBuilder.previousHeikinAshiOpenPrice(previousOpen).previousHeikinAshiClosePrice(previousClose);
} else {
haBuilder.previousHeikinAshiOpenPrice(null).previousHeikinAshiClosePrice(null);
}
var haBar = haBuilder.build();
heikinAshiBars.add(haBar);
previousOpen = haBar.getOpenPrice();
previousClose = haBar.getClosePrice();
}
return heikinAshiBars;
}
}
@@ -0,0 +1,100 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.aggregator;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import org.ta4j.core.Bar;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* Aggregates source bars into range bars.
*
* <p>
* A range bar is closed once the aggregated high-low range reaches the
* configured range size. Source bars must be contiguous and evenly spaced.
*
* <p>
* Usage:
*
* <pre>{@code
* BarAggregator rangeAggregator = new RangeBarAggregator(2.5);
* List<Bar> rangeBars = rangeAggregator.aggregate(sourceBars);
* }</pre>
*
* <p>
* Source bars that do not complete the configured range are emitted only when
* {@code onlyFinalBars} is {@code false}.
*
* @since 0.22.4
*/
public class RangeBarAggregator implements BarAggregator {
private final Number rangeSize;
private final boolean onlyFinalBars;
/**
* Creates a range-bar aggregator that emits only completed range bars.
*
* @param rangeSize the minimum high-low range required to close a bar
* @throws NullPointerException if {@code rangeSize} is {@code null}
* @throws IllegalArgumentException if {@code rangeSize} is not a finite,
* positive value
*
* @since 0.22.4
*/
public RangeBarAggregator(Number rangeSize) {
this(rangeSize, true);
}
/**
* Creates a range-bar aggregator.
*
* @param rangeSize the minimum high-low range required to close a bar
* @param onlyFinalBars if {@code true}, incomplete trailing bars are omitted
* @throws NullPointerException if {@code rangeSize} is {@code null}
* @throws IllegalArgumentException if {@code rangeSize} is not a finite,
* positive value
*
* @since 0.22.4
*/
public RangeBarAggregator(Number rangeSize, boolean onlyFinalBars) {
this.rangeSize = BarAggregator.requirePositiveFiniteNumber(rangeSize, "rangeSize");
this.onlyFinalBars = onlyFinalBars;
}
/**
* Aggregates bars into range bars.
*
* @param bars source bars in chronological order
* @return range bars
* @throws NullPointerException if {@code bars} is {@code null}
* @throws IllegalArgumentException if source intervals are uneven or
* non-contiguous
*/
@Override
public List<Bar> aggregate(List<Bar> bars) {
Objects.requireNonNull(bars, "bars");
if (bars.isEmpty()) {
return new ArrayList<>();
}
requireEvenIntervals(bars);
NumFactory numFactory = bars.getFirst().numFactory();
Num resolvedRangeSize = numFactory.numOf(rangeSize);
Num zero = numFactory.zero();
return ThresholdBarAggregationSupport.aggregate(bars, numFactory, onlyFinalBars, snapshot -> {
Num currentRange = zero;
if (snapshot.highPrice() != null && snapshot.lowPrice() != null) {
currentRange = snapshot.highPrice().minus(snapshot.lowPrice());
}
return currentRange.isGreaterThanOrEqual(resolvedRangeSize);
}, snapshot -> ThresholdBarAggregationSupport.buildTimeBar(numFactory, snapshot));
}
}
@@ -0,0 +1,263 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.aggregator;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import org.ta4j.core.Bar;
import org.ta4j.core.bars.TimeBarBuilder;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* Aggregates source bars into Renko bricks.
*
* <p>
* Bricks are generated from close-price movement using a configurable
* {@code boxSize}. Reversals require a move of
* {@code reversalAmount * boxSize}. Source bars must be contiguous and evenly
* spaced. When multiple bricks are emitted from a single source bar, aggregated
* volume/amount/trades are attached to the first emitted brick and set to zero
* for additional bricks from the same source bar.
*
* <p>
* Usage:
*
* <pre>{@code
* BarAggregator renkoAggregator = new RenkoBarAggregator(2.0, 2);
* List<Bar> renkoBars = renkoAggregator.aggregate(sourceBars);
* }</pre>
*
* @since 0.22.4
*/
public class RenkoBarAggregator implements BarAggregator {
private enum Direction {
NONE, UP, DOWN
}
private final Number boxSize;
private final int reversalAmount;
/**
* Creates a Renko aggregator with a two-brick reversal.
*
* @param boxSize the price movement represented by one brick
* @throws NullPointerException if {@code boxSize} is {@code null}
* @throws IllegalArgumentException if {@code boxSize} is not a finite, positive
* value
*
* @since 0.22.4
*/
public RenkoBarAggregator(Number boxSize) {
this(boxSize, 2);
}
/**
* Creates a Renko aggregator.
*
* @param boxSize the price movement represented by one brick
* @param reversalAmount the number of boxes required for reversal
* @throws NullPointerException if {@code boxSize} is {@code null}
* @throws IllegalArgumentException if {@code boxSize} is not a finite, positive
* value, or if {@code reversalAmount <= 0}
*
* @since 0.22.4
*/
public RenkoBarAggregator(Number boxSize, int reversalAmount) {
this.boxSize = BarAggregator.requirePositiveFiniteNumber(boxSize, "boxSize");
if (reversalAmount <= 0) {
throw new IllegalArgumentException("reversalAmount must be greater than zero.");
}
this.reversalAmount = reversalAmount;
}
/**
* Aggregates bars into Renko bricks.
*
* @param bars source bars in chronological order
* @return Renko bricks
* @throws NullPointerException if {@code bars} is {@code null}
* @throws IllegalArgumentException if source intervals are uneven or
* non-contiguous, or if any source bar has a
* null close price
*/
@Override
public List<Bar> aggregate(List<Bar> bars) {
Objects.requireNonNull(bars, "bars");
List<Bar> renkoBars = new ArrayList<>();
if (bars.isEmpty()) {
return renkoBars;
}
Duration sourcePeriod = requireEvenIntervals(bars);
NumFactory numFactory = bars.getFirst().numFactory();
Num resolvedBoxSize = numFactory.numOf(boxSize);
Num reversalDistance = resolvedBoxSize.multipliedBy(numFactory.numOf(reversalAmount));
Num zero = numFactory.zero();
Num lastBrickClose = requireClosePrice(bars.getFirst(), 0);
Direction direction = Direction.NONE;
Num pendingVolume = zero;
Num pendingAmount = zero;
long pendingTrades = 0L;
Instant nextBrickEndTime = bars.getFirst().getEndTime();
for (int i = 0; i < bars.size(); i++) {
Bar sourceBar = bars.get(i);
Num closePrice = requireClosePrice(sourceBar, i);
if (sourceBar.getVolume() != null) {
pendingVolume = pendingVolume.plus(sourceBar.getVolume());
}
if (sourceBar.getAmount() != null) {
pendingAmount = pendingAmount.plus(sourceBar.getAmount());
}
pendingTrades += sourceBar.getTrades();
boolean emittedFromCurrentSourceBar = false;
if (direction == Direction.UP || direction == Direction.NONE) {
while (closePrice.isGreaterThanOrEqual(lastBrickClose.plus(resolvedBoxSize))) {
Num openPrice = lastBrickClose;
Num close = lastBrickClose.plus(resolvedBoxSize);
Instant brickEndTime = resolveBrickEndTime(sourceBar.getEndTime(), nextBrickEndTime);
Num brickVolume = emittedFromCurrentSourceBar ? zero : pendingVolume;
Num brickAmount = emittedFromCurrentSourceBar ? zero : pendingAmount;
long brickTrades = emittedFromCurrentSourceBar ? 0L : pendingTrades;
renkoBars.add(buildBrick(numFactory, sourcePeriod, brickEndTime, openPrice, close, brickVolume,
brickAmount, brickTrades));
lastBrickClose = close;
direction = Direction.UP;
nextBrickEndTime = brickEndTime.plus(sourcePeriod);
if (!emittedFromCurrentSourceBar) {
pendingVolume = zero;
pendingAmount = zero;
pendingTrades = 0L;
emittedFromCurrentSourceBar = true;
}
}
}
if (direction == Direction.NONE) {
while (closePrice.isLessThanOrEqual(lastBrickClose.minus(resolvedBoxSize))) {
Num openPrice = lastBrickClose;
Num close = lastBrickClose.minus(resolvedBoxSize);
Instant brickEndTime = resolveBrickEndTime(sourceBar.getEndTime(), nextBrickEndTime);
Num brickVolume = emittedFromCurrentSourceBar ? zero : pendingVolume;
Num brickAmount = emittedFromCurrentSourceBar ? zero : pendingAmount;
long brickTrades = emittedFromCurrentSourceBar ? 0L : pendingTrades;
renkoBars.add(buildBrick(numFactory, sourcePeriod, brickEndTime, openPrice, close, brickVolume,
brickAmount, brickTrades));
lastBrickClose = close;
direction = Direction.DOWN;
nextBrickEndTime = brickEndTime.plus(sourcePeriod);
if (!emittedFromCurrentSourceBar) {
pendingVolume = zero;
pendingAmount = zero;
pendingTrades = 0L;
emittedFromCurrentSourceBar = true;
}
}
}
if (direction == Direction.UP && closePrice.isLessThanOrEqual(lastBrickClose.minus(reversalDistance))) {
while (closePrice.isLessThanOrEqual(lastBrickClose.minus(resolvedBoxSize))) {
Num openPrice = lastBrickClose;
Num close = lastBrickClose.minus(resolvedBoxSize);
Instant brickEndTime = resolveBrickEndTime(sourceBar.getEndTime(), nextBrickEndTime);
Num brickVolume = emittedFromCurrentSourceBar ? zero : pendingVolume;
Num brickAmount = emittedFromCurrentSourceBar ? zero : pendingAmount;
long brickTrades = emittedFromCurrentSourceBar ? 0L : pendingTrades;
renkoBars.add(buildBrick(numFactory, sourcePeriod, brickEndTime, openPrice, close, brickVolume,
brickAmount, brickTrades));
lastBrickClose = close;
direction = Direction.DOWN;
nextBrickEndTime = brickEndTime.plus(sourcePeriod);
if (!emittedFromCurrentSourceBar) {
pendingVolume = zero;
pendingAmount = zero;
pendingTrades = 0L;
emittedFromCurrentSourceBar = true;
}
}
}
if (direction == Direction.DOWN) {
while (closePrice.isLessThanOrEqual(lastBrickClose.minus(resolvedBoxSize))) {
Num openPrice = lastBrickClose;
Num close = lastBrickClose.minus(resolvedBoxSize);
Instant brickEndTime = resolveBrickEndTime(sourceBar.getEndTime(), nextBrickEndTime);
Num brickVolume = emittedFromCurrentSourceBar ? zero : pendingVolume;
Num brickAmount = emittedFromCurrentSourceBar ? zero : pendingAmount;
long brickTrades = emittedFromCurrentSourceBar ? 0L : pendingTrades;
renkoBars.add(buildBrick(numFactory, sourcePeriod, brickEndTime, openPrice, close, brickVolume,
brickAmount, brickTrades));
lastBrickClose = close;
nextBrickEndTime = brickEndTime.plus(sourcePeriod);
if (!emittedFromCurrentSourceBar) {
pendingVolume = zero;
pendingAmount = zero;
pendingTrades = 0L;
emittedFromCurrentSourceBar = true;
}
}
if (closePrice.isGreaterThanOrEqual(lastBrickClose.plus(reversalDistance))) {
while (closePrice.isGreaterThanOrEqual(lastBrickClose.plus(resolvedBoxSize))) {
Num openPrice = lastBrickClose;
Num close = lastBrickClose.plus(resolvedBoxSize);
Instant brickEndTime = resolveBrickEndTime(sourceBar.getEndTime(), nextBrickEndTime);
Num brickVolume = emittedFromCurrentSourceBar ? zero : pendingVolume;
Num brickAmount = emittedFromCurrentSourceBar ? zero : pendingAmount;
long brickTrades = emittedFromCurrentSourceBar ? 0L : pendingTrades;
renkoBars.add(buildBrick(numFactory, sourcePeriod, brickEndTime, openPrice, close, brickVolume,
brickAmount, brickTrades));
lastBrickClose = close;
direction = Direction.UP;
nextBrickEndTime = brickEndTime.plus(sourcePeriod);
if (!emittedFromCurrentSourceBar) {
pendingVolume = zero;
pendingAmount = zero;
pendingTrades = 0L;
emittedFromCurrentSourceBar = true;
}
}
}
}
}
return renkoBars;
}
private static Num requireClosePrice(Bar bar, int index) {
if (bar.getClosePrice() == null) {
throw new IllegalArgumentException(String.format(
"RenkoBarAggregator requires close prices on all source bars. Missing at index %d.", index));
}
return bar.getClosePrice();
}
private static Bar buildBrick(NumFactory numFactory, Duration sourcePeriod, Instant endTime, Num openPrice,
Num closePrice, Num volume, Num amount, long trades) {
Num highPrice = openPrice.max(closePrice);
Num lowPrice = openPrice.min(closePrice);
return new TimeBarBuilder(numFactory).timePeriod(sourcePeriod)
.endTime(endTime)
.openPrice(openPrice)
.highPrice(highPrice)
.lowPrice(lowPrice)
.closePrice(closePrice)
.volume(volume)
.amount(amount)
.trades(trades)
.build();
}
private static Instant resolveBrickEndTime(Instant sourceBarEndTime, Instant nextBrickEndTime) {
return sourceBarEndTime.isAfter(nextBrickEndTime) ? sourceBarEndTime : nextBrickEndTime;
}
}
@@ -0,0 +1,165 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.aggregator;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.Predicate;
import org.ta4j.core.Bar;
import org.ta4j.core.bars.TimeBarBuilder;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* Shared accumulation workflow for threshold-based bar aggregators.
*/
final class ThresholdBarAggregationSupport {
private ThresholdBarAggregationSupport() {
}
static List<Bar> aggregate(List<Bar> bars, NumFactory numFactory, boolean onlyFinalBars,
Predicate<MutableWindow> completionPredicate, Function<MutableWindow, Bar> barBuilder) {
Objects.requireNonNull(bars, "bars");
Objects.requireNonNull(numFactory, "numFactory");
Objects.requireNonNull(completionPredicate, "completionPredicate");
Objects.requireNonNull(barBuilder, "barBuilder");
List<Bar> aggregated = new ArrayList<>();
if (bars.isEmpty()) {
return aggregated;
}
MutableWindow mutableWindow = new MutableWindow(numFactory);
for (Bar bar : bars) {
mutableWindow.add(bar);
if (completionPredicate.test(mutableWindow)) {
aggregated.add(barBuilder.apply(mutableWindow));
mutableWindow.reset();
}
}
if (!onlyFinalBars && !mutableWindow.isEmpty()) {
aggregated.add(barBuilder.apply(mutableWindow));
}
return aggregated;
}
static Bar buildTimeBar(NumFactory numFactory, MutableWindow window) {
Duration aggregatedPeriod = Duration.between(window.beginTime(), window.endTime());
return new TimeBarBuilder(numFactory).timePeriod(aggregatedPeriod)
.endTime(window.endTime())
.openPrice(window.openPrice())
.highPrice(window.highPrice())
.lowPrice(window.lowPrice())
.closePrice(window.closePrice())
.volume(window.volume())
.amount(window.amount())
.trades(window.trades())
.build();
}
static final class MutableWindow {
private final Num zero;
private Instant beginTime;
private Instant endTime;
private Num openPrice;
private Num highPrice;
private Num lowPrice;
private Num closePrice;
private Num volume;
private Num amount;
private long trades;
private MutableWindow(NumFactory numFactory) {
this.zero = numFactory.zero();
reset();
}
private void add(Bar bar) {
if (beginTime == null) {
beginTime = bar.getBeginTime();
openPrice = bar.getOpenPrice();
highPrice = bar.getHighPrice();
lowPrice = bar.getLowPrice();
} else {
if (highPrice == null || (bar.getHighPrice() != null && bar.getHighPrice().isGreaterThan(highPrice))) {
highPrice = bar.getHighPrice();
}
if (lowPrice == null || (bar.getLowPrice() != null && bar.getLowPrice().isLessThan(lowPrice))) {
lowPrice = bar.getLowPrice();
}
}
endTime = bar.getEndTime();
closePrice = bar.getClosePrice();
if (bar.getVolume() != null) {
volume = volume.plus(bar.getVolume());
}
if (bar.getAmount() != null) {
amount = amount.plus(bar.getAmount());
}
trades += bar.getTrades();
}
private boolean isEmpty() {
return beginTime == null;
}
private void reset() {
beginTime = null;
endTime = null;
openPrice = null;
highPrice = null;
lowPrice = null;
closePrice = null;
volume = zero;
amount = zero;
trades = 0L;
}
public Instant beginTime() {
return beginTime;
}
public Instant endTime() {
return endTime;
}
public Num openPrice() {
return openPrice;
}
public Num highPrice() {
return highPrice;
}
public Num lowPrice() {
return lowPrice;
}
public Num closePrice() {
return closePrice;
}
public Num volume() {
return volume;
}
public Num amount() {
return amount;
}
public long trades() {
return trades;
}
}
}
@@ -0,0 +1,94 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.aggregator;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import org.ta4j.core.Bar;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* Aggregates source bars into volume-threshold bars.
*
* <p>
* A volume bar is closed once the aggregated volume reaches the configured
* threshold. Source bars must be contiguous and evenly spaced.
*
* <p>
* Usage:
*
* <pre>{@code
* BarAggregator volumeAggregator = new VolumeBarAggregator(10_000);
* List<Bar> volumeBars = volumeAggregator.aggregate(sourceBars);
* }</pre>
*
* <p>
* Source bars that do not complete the configured volume threshold are emitted
* only when {@code onlyFinalBars} is {@code false}.
*
* @since 0.22.4
*/
public class VolumeBarAggregator implements BarAggregator {
private final Number volumeThreshold;
private final boolean onlyFinalBars;
/**
* Creates a volume-bar aggregator that emits only completed volume bars.
*
* @param volumeThreshold the minimum aggregated volume required to close a bar
* @throws NullPointerException if {@code volumeThreshold} is {@code null}
* @throws IllegalArgumentException if {@code volumeThreshold} is not a finite,
* positive value
*
* @since 0.22.4
*/
public VolumeBarAggregator(Number volumeThreshold) {
this(volumeThreshold, true);
}
/**
* Creates a volume-bar aggregator.
*
* @param volumeThreshold the minimum aggregated volume required to close a bar
* @param onlyFinalBars if {@code true}, incomplete trailing bars are omitted
* @throws NullPointerException if {@code volumeThreshold} is {@code null}
* @throws IllegalArgumentException if {@code volumeThreshold} is not a finite,
* positive value
*
* @since 0.22.4
*/
public VolumeBarAggregator(Number volumeThreshold, boolean onlyFinalBars) {
this.volumeThreshold = BarAggregator.requirePositiveFiniteNumber(volumeThreshold, "volumeThreshold");
this.onlyFinalBars = onlyFinalBars;
}
/**
* Aggregates bars into volume bars.
*
* @param bars source bars in chronological order
* @return volume bars
* @throws NullPointerException if {@code bars} is {@code null}
* @throws IllegalArgumentException if source intervals are uneven or
* non-contiguous
*/
@Override
public List<Bar> aggregate(List<Bar> bars) {
Objects.requireNonNull(bars, "bars");
if (bars.isEmpty()) {
return new ArrayList<>();
}
requireEvenIntervals(bars);
NumFactory numFactory = bars.getFirst().numFactory();
Num resolvedVolumeThreshold = numFactory.numOf(volumeThreshold);
return ThresholdBarAggregationSupport.aggregate(bars, numFactory, onlyFinalBars,
snapshot -> snapshot.volume().isGreaterThanOrEqual(resolvedVolumeThreshold),
snapshot -> ThresholdBarAggregationSupport.buildTimeBar(numFactory, snapshot));
}
}
@@ -0,0 +1,15 @@
/*
* SPDX-License-Identifier: MIT
*/
/**
* Aggregator.
*
* <p>
* This package can be used to aggregate {@link org.ta4j.core.Bar bars} by
* various conditions, for example:
* {@link org.ta4j.core.aggregator.DurationBarAggregator duration},
* {@link org.ta4j.core.aggregator.RangeBarAggregator range},
* {@link org.ta4j.core.aggregator.VolumeBarAggregator volume}, and
* {@link org.ta4j.core.aggregator.RenkoBarAggregator Renko bricks}.
*/
package org.ta4j.core.aggregator;
@@ -0,0 +1,146 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis;
import java.time.Instant;
import java.util.Objects;
/**
* Options controlling how windowed criterion analysis is resolved.
*
* <p>
* The default context is conservative and deterministic:
* </p>
* <ul>
* <li>{@link MissingHistoryPolicy#STRICT}</li>
* <li>{@link PositionInclusionPolicy#EXIT_IN_WINDOW}</li>
* <li>{@link OpenPositionHandling#IGNORE}</li>
* <li>no explicit anchor/as-of instant</li>
* </ul>
*
* @param missingHistoryPolicy behavior when requested history is unavailable
* @param positionInclusionPolicy policy used to include closed positions in the
* window
* @param openPositionHandling handling strategy for open position at window
* end; reuses the existing shared enum
* {@link OpenPositionHandling} where
* {@link OpenPositionHandling#IGNORE} excludes
* open positions and
* {@link OpenPositionHandling#MARK_TO_MARKET}
* synthesizes window-end valuation
* @param asOf optional anchor/as-of instant; when null, the
* series end is used for lookback windows
* @since 0.22.4
*/
public record AnalysisContext(MissingHistoryPolicy missingHistoryPolicy,
PositionInclusionPolicy positionInclusionPolicy, OpenPositionHandling openPositionHandling, Instant asOf) {
/**
* Policy for unavailable historical bars.
*
* @since 0.22.4
*/
public enum MissingHistoryPolicy {
/**
* Fail fast when requested window references unavailable series history.
*/
STRICT,
/**
* Intersect the requested window with available series history.
*/
CLAMP
}
/**
* Policy controlling which closed positions are included in a window.
*
* @since 0.22.4
*/
public enum PositionInclusionPolicy {
/**
* Include a closed position when its exit index is inside the window.
*/
EXIT_IN_WINDOW,
/**
* Include a closed position only when both entry and exit are in the window.
*/
FULLY_CONTAINED
}
/**
* Creates a default context.
*
* @since 0.22.4
*/
public AnalysisContext() {
this(MissingHistoryPolicy.STRICT, PositionInclusionPolicy.EXIT_IN_WINDOW, OpenPositionHandling.IGNORE, null);
}
/**
* Validates the context.
*/
public AnalysisContext {
Objects.requireNonNull(missingHistoryPolicy, "missingHistoryPolicy");
Objects.requireNonNull(positionInclusionPolicy, "positionInclusionPolicy");
Objects.requireNonNull(openPositionHandling, "openPositionHandling");
}
/**
* Creates a default context.
*
* @return default window analysis context
* @since 0.22.4
*/
public static AnalysisContext defaults() {
return new AnalysisContext();
}
/**
* Returns a copy with a different missing-history policy.
*
* @param policy the policy to use
* @return updated context
* @since 0.22.4
*/
public AnalysisContext withMissingHistoryPolicy(MissingHistoryPolicy policy) {
return new AnalysisContext(Objects.requireNonNull(policy, "policy"), positionInclusionPolicy,
openPositionHandling, asOf);
}
/**
* Returns a copy with a different position-inclusion policy.
*
* @param policy the policy to use
* @return updated context
* @since 0.22.4
*/
public AnalysisContext withPositionInclusionPolicy(PositionInclusionPolicy policy) {
return new AnalysisContext(missingHistoryPolicy, Objects.requireNonNull(policy, "policy"), openPositionHandling,
asOf);
}
/**
* Returns a copy with a different open-position handling.
*
* @param handling the handling to use
* @return updated context
* @since 0.22.4
*/
public AnalysisContext withOpenPositionHandling(OpenPositionHandling handling) {
return new AnalysisContext(missingHistoryPolicy, positionInclusionPolicy,
Objects.requireNonNull(handling, "handling"), asOf);
}
/**
* Returns a copy with a different anchor/as-of instant.
*
* @param asOf the anchor instant used by lookback windows, or null to use
* series end
* @return updated context
* @since 0.22.4
*/
public AnalysisContext withAsOf(Instant asOf) {
return new AnalysisContext(missingHistoryPolicy, positionInclusionPolicy, openPositionHandling, asOf);
}
}
@@ -0,0 +1,85 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import org.ta4j.core.*;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.analysis.cost.ZeroCostModel;
final class AnalysisPositionSupport {
private AnalysisPositionSupport() {
}
static List<Position> positionsForAnalysis(TradingRecord record, int finalIndex,
OpenPositionHandling openPositionHandling, EquityCurveMode equityCurveMode) {
Objects.requireNonNull(record, "record");
Objects.requireNonNull(openPositionHandling, "openPositionHandling");
Objects.requireNonNull(equityCurveMode, "equityCurveMode");
List<Position> positions = new ArrayList<>();
for (Position position : record.getPositions()) {
if (shouldIncludePosition(position, finalIndex, openPositionHandling, equityCurveMode)) {
positions.add(position);
}
}
if (shouldIncludeOpenPositions(openPositionHandling, equityCurveMode)) {
positions.addAll(openPositions(record, finalIndex));
}
return positions;
}
static boolean shouldIncludePosition(Position position, int finalIndex, OpenPositionHandling openPositionHandling,
EquityCurveMode equityCurveMode) {
if (position == null || position.getEntry() == null) {
return false;
}
int entryIndex = position.getEntry().getIndex();
if (entryIndex > finalIndex) {
return false;
}
if (!shouldIncludeOpenPositions(openPositionHandling, equityCurveMode)) {
Trade exit = position.getExit();
boolean isOpenAtFinalIndex = exit == null || exit.getIndex() > finalIndex;
return !isOpenAtFinalIndex;
}
return true;
}
static boolean shouldIncludeOpenPositions(OpenPositionHandling openPositionHandling,
EquityCurveMode equityCurveMode) {
return equityCurveMode != EquityCurveMode.REALIZED
&& openPositionHandling == OpenPositionHandling.MARK_TO_MARKET;
}
static List<Position> openPositions(TradingRecord record, int finalIndex) {
List<Position> openPositions = record.getOpenPositions();
if (!openPositions.isEmpty()) {
return openPositionsWithinRange(openPositions, finalIndex);
}
List<Position> positions = new ArrayList<>();
Position current = record.getCurrentPosition();
if (current != null && current.isOpened() && current.getEntry() != null
&& current.getEntry().getIndex() <= finalIndex) {
positions.add(current);
}
return positions;
}
private static List<Position> openPositionsWithinRange(List<Position> openPositions, int finalIndex) {
List<Position> positions = new ArrayList<>();
for (Position openPosition : openPositions) {
if (openPosition == null || !openPosition.isOpened()) {
continue;
}
if (openPosition.getEntry().getIndex() > finalIndex) {
continue;
}
positions.add(openPosition);
}
return positions;
}
}
@@ -0,0 +1,28 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis;
import org.ta4j.core.BarSeries;
/**
* Executes a one-shot analysis for a series and caller-provided context.
*
* @param <C> analysis context type (for example degree, configuration, or
* request object)
* @param <R> analysis result type
* @since 0.22.4
*/
@FunctionalInterface
public interface AnalysisRunner<C, R> {
/**
* Runs analysis for the supplied series and context.
*
* @param series series to analyze
* @param context caller-provided analysis context
* @return analysis result
* @since 0.22.4
*/
R analyze(BarSeries series, C context);
}
@@ -0,0 +1,181 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
/**
* Describes a requested analysis window.
*
* <p>
* A window can be expressed by:
* </p>
* <ul>
* <li>explicit bar index range (inclusive/inclusive)</li>
* <li>lookback bars (count of bars ending at an anchor)</li>
* <li>explicit time range (inclusive/exclusive)</li>
* <li>lookback duration (duration ending at an anchor)</li>
* </ul>
*
* <p>
* Use one of the static factory methods to create an immutable window.
* </p>
*
* @since 0.22.4
*/
public sealed interface AnalysisWindow permits AnalysisWindow.BarRange, AnalysisWindow.LookbackBars,
AnalysisWindow.TimeRange, AnalysisWindow.LookbackDuration {
/**
* Creates a window from explicit bar indices.
*
* @param startIndexInclusive the first bar index to include
* @param endIndexInclusive the last bar index to include
* @return the requested bar-range window
* @since 0.22.4
*/
static AnalysisWindow barRange(int startIndexInclusive, int endIndexInclusive) {
return new BarRange(startIndexInclusive, endIndexInclusive);
}
/**
* Creates a window from a lookback bar count.
*
* <p>
* The anchor is resolved at calculation time from {@link AnalysisContext} (or
* defaults).
* </p>
*
* @param barCount the number of bars to include
* @return the requested lookback-bars window
* @since 0.22.4
*/
static AnalysisWindow lookbackBars(int barCount) {
return new LookbackBars(barCount);
}
/**
* Creates a window from an explicit time range.
*
* <p>
* Time windows use start-inclusive/end-exclusive semantics.
* </p>
*
* @param startInclusive the start instant (inclusive)
* @param endExclusive the end instant (exclusive)
* @return the requested time-range window
* @since 0.22.4
*/
static AnalysisWindow timeRange(Instant startInclusive, Instant endExclusive) {
return new TimeRange(startInclusive, endExclusive);
}
/**
* Creates a window from a lookback duration.
*
* <p>
* The anchor is resolved at calculation time from {@link AnalysisContext} (or
* defaults).
* </p>
*
* @param duration the lookback duration
* @return the requested lookback-duration window
* @since 0.22.4
*/
static AnalysisWindow lookbackDuration(Duration duration) {
return new LookbackDuration(duration);
}
/**
* Explicit bar-index range window with inclusive boundaries.
*
* @param startIndexInclusive the start index (inclusive)
* @param endIndexInclusive the end index (inclusive)
* @since 0.22.4
*/
record BarRange(int startIndexInclusive, int endIndexInclusive) implements AnalysisWindow {
/**
* Creates a bar-index range.
*
* @param startIndexInclusive the start index (inclusive)
* @param endIndexInclusive the end index (inclusive)
*/
public BarRange {
if (startIndexInclusive < 0) {
throw new IllegalArgumentException("startIndexInclusive must be >= 0");
}
if (endIndexInclusive < startIndexInclusive) {
throw new IllegalArgumentException("endIndexInclusive must be >= startIndexInclusive");
}
}
}
/**
* Lookback-bar-count window.
*
* @param barCount number of bars to include
* @since 0.22.4
*/
record LookbackBars(int barCount) implements AnalysisWindow {
/**
* Creates a lookback-bar-count window.
*
* @param barCount number of bars to include
*/
public LookbackBars {
if (barCount <= 0) {
throw new IllegalArgumentException("barCount must be > 0");
}
}
}
/**
* Explicit time-range window with start-inclusive/end-exclusive boundaries.
*
* @param startInclusive the start instant (inclusive)
* @param endExclusive the end instant (exclusive)
* @since 0.22.4
*/
record TimeRange(Instant startInclusive, Instant endExclusive) implements AnalysisWindow {
/**
* Creates a time-range window.
*
* @param startInclusive the start instant (inclusive)
* @param endExclusive the end instant (exclusive)
*/
public TimeRange {
Objects.requireNonNull(startInclusive, "startInclusive");
Objects.requireNonNull(endExclusive, "endExclusive");
if (!startInclusive.isBefore(endExclusive)) {
throw new IllegalArgumentException("startInclusive must be before endExclusive");
}
}
}
/**
* Lookback-duration window.
*
* @param duration the lookback duration
* @since 0.22.4
*/
record LookbackDuration(Duration duration) implements AnalysisWindow {
/**
* Creates a lookback-duration window.
*
* @param duration the lookback duration
*/
public LookbackDuration {
Objects.requireNonNull(duration, "duration");
if (duration.isZero() || duration.isNegative()) {
throw new IllegalArgumentException("duration must be positive");
}
}
}
}
@@ -0,0 +1,349 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseTradingRecord;
import org.ta4j.core.Position;
import org.ta4j.core.Trade;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* Allows to follow the money cash flow involved by a list of positions over a
* bar series, either marked to market or using realized values only.
*/
public class CashFlow implements PerformanceIndicator {
/**
* The bar series.
*/
private final BarSeries barSeries;
/**
* The (accrued) cash flow sequence (without trading costs).
*/
private final List<Num> values;
/**
* The first logical bar index materialized in {@link #values}.
*/
private final int valueStartIndex;
/**
* The last logical bar index materialized in {@link #values}.
*/
private final int valueEndIndex;
/**
* The equity curve calculation mode.
*/
private final EquityCurveMode equityCurveMode;
/**
* Constructor.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
* @param finalIndex index up until cash flows of open positions are
* considered
* @param equityCurveMode the calculation mode
* @param openPositionHandling how to handle open positions
* @since 0.22.2
*/
public CashFlow(BarSeries barSeries, TradingRecord tradingRecord, int finalIndex, EquityCurveMode equityCurveMode,
OpenPositionHandling openPositionHandling) {
this(barSeries, tradingRecord, 0, barSeries.getEndIndex(), finalIndex, equityCurveMode, openPositionHandling);
}
/**
* Constructor materializing only a bounded logical window on the original
* series.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
* @param startIndex first logical bar index to materialize
* @param finalIndex last logical bar index to materialize and to
* consider for open positions
* @param equityCurveMode the calculation mode
* @param openPositionHandling how to handle open positions
* @since 0.22.5
*/
public CashFlow(BarSeries barSeries, TradingRecord tradingRecord, int startIndex, int finalIndex,
EquityCurveMode equityCurveMode, OpenPositionHandling openPositionHandling) {
this(barSeries, tradingRecord, startIndex, finalIndex, finalIndex, equityCurveMode, openPositionHandling);
}
/**
* Constructor for cash flows of a closed position.
*
* @param barSeries the bar series
* @param position a single position
* @param equityCurveMode the calculation mode
* @since 0.22.2
*/
public CashFlow(BarSeries barSeries, Position position, EquityCurveMode equityCurveMode) {
this(barSeries, new BaseTradingRecord(position), barSeries.getEndIndex(), equityCurveMode);
}
/**
* Constructor.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
* @param finalIndex index up until cash flows of open positions are
* considered
* @param equityCurveMode the calculation mode
* @since 0.22.2
*/
public CashFlow(BarSeries barSeries, TradingRecord tradingRecord, int finalIndex, EquityCurveMode equityCurveMode) {
this(barSeries, tradingRecord, finalIndex, equityCurveMode, OpenPositionHandling.MARK_TO_MARKET);
}
/**
* Constructor for cash flows of a closed position.
*
* @param barSeries the bar series
* @param position a single position
*/
public CashFlow(BarSeries barSeries, Position position) {
this(barSeries, position, EquityCurveMode.MARK_TO_MARKET);
}
/**
* Constructor for cash flows of closed positions of a trading record.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
*/
public CashFlow(BarSeries barSeries, TradingRecord tradingRecord) {
this(barSeries, tradingRecord, tradingRecord.getEndIndex(barSeries), EquityCurveMode.MARK_TO_MARKET,
OpenPositionHandling.MARK_TO_MARKET);
}
/**
* Constructor.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
* @param equityCurveMode the calculation mode
* @since 0.22.2
*/
public CashFlow(BarSeries barSeries, TradingRecord tradingRecord, EquityCurveMode equityCurveMode) {
this(barSeries, tradingRecord, tradingRecord.getEndIndex(barSeries), equityCurveMode,
OpenPositionHandling.MARK_TO_MARKET);
}
/**
* Constructor.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
* @param equityCurveMode the calculation mode
* @param openPositionHandling how to handle open positions
* @since 0.22.2
*/
public CashFlow(BarSeries barSeries, TradingRecord tradingRecord, EquityCurveMode equityCurveMode,
OpenPositionHandling openPositionHandling) {
this(barSeries, tradingRecord, tradingRecord.getEndIndex(barSeries), equityCurveMode, openPositionHandling);
}
/**
* Constructor.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
* @param finalIndex index up until cash flows of open positions are
* considered
*/
public CashFlow(BarSeries barSeries, TradingRecord tradingRecord, int finalIndex) {
this(barSeries, tradingRecord, finalIndex, EquityCurveMode.MARK_TO_MARKET, OpenPositionHandling.MARK_TO_MARKET);
}
/**
* Constructor.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
* @param openPositionHandling how to handle open positions
* @since 0.22.2
*/
public CashFlow(BarSeries barSeries, TradingRecord tradingRecord, OpenPositionHandling openPositionHandling) {
this(barSeries, tradingRecord, tradingRecord.getEndIndex(barSeries), EquityCurveMode.MARK_TO_MARKET,
openPositionHandling);
}
private CashFlow(BarSeries barSeries, TradingRecord tradingRecord, int startIndex, int endIndex, int finalIndex,
EquityCurveMode equityCurveMode, OpenPositionHandling openPositionHandling) {
this.barSeries = Objects.requireNonNull(barSeries);
this.equityCurveMode = Objects.requireNonNull(equityCurveMode);
int seriesEnd = barSeries.getEndIndex();
this.valueStartIndex = Math.max(0, startIndex);
this.valueEndIndex = seriesEnd < 0 ? -1 : Math.min(Math.max(endIndex, this.valueStartIndex), seriesEnd);
int size = this.valueEndIndex < this.valueStartIndex ? 0 : this.valueEndIndex - this.valueStartIndex + 1;
this.values = new ArrayList<>(Collections.nCopies(size, barSeries.numFactory().one()));
calculate(Objects.requireNonNull(tradingRecord), finalIndex, Objects.requireNonNull(openPositionHandling));
}
/**
* Calculates the cash flow for a single position (including accrued cashflow
* for open positions).
*
* @param position a single position
* @param finalIndex index up until cash flow of open positions is considered
* @since 0.22.2
*/
@Override
public void calculatePosition(Position position, int finalIndex) {
Trade entry = position.getEntry();
if (entry == null) {
return;
}
int seriesEnd = barSeries.getEndIndex();
int entryIndex = entry.getIndex();
if (entryIndex > finalIndex || entryIndex > seriesEnd) {
return;
}
int endIndex = determineEndIndex(position, finalIndex, seriesEnd);
int seriesBegin = barSeries.getBeginIndex();
if (endIndex < seriesBegin) {
return;
}
int windowStartIndex = Math.max(valueStartIndex, seriesBegin);
int windowEndIndex = Math.min(valueEndIndex, seriesEnd);
if (windowStartIndex > windowEndIndex || endIndex < windowStartIndex) {
return;
}
NumFactory numFactory = barSeries.numFactory();
boolean isLongTrade = entry.isBuy();
Num netEntryPrice = entry.getNetPrice();
Num entryEquity = getStoredValue(Math.max(entryIndex, windowStartIndex));
if (!entryEquity.isGreaterThan(numFactory.zero())) {
return;
}
int ratioIndex = endIndex;
if (ratioIndex == entryIndex && entryIndex < seriesEnd) {
ratioIndex = entryIndex + 1;
}
if (equityCurveMode == EquityCurveMode.MARK_TO_MARKET) {
Num averageHoldingCostPerPeriod = averageHoldingCostPerPeriod(position, endIndex, numFactory);
boolean windowStartSeeded = false;
if (entryIndex < windowStartIndex) {
Num windowStartPrice = windowStartIndex == endIndex ? resolveExitPrice(position, endIndex, barSeries)
: barSeries.getBar(windowStartIndex).getClosePrice();
Num windowStartNetPrice = addCost(windowStartPrice, averageHoldingCostPerPeriod, isLongTrade);
Num windowStartRatio = getIntermediateRatio(isLongTrade, netEntryPrice, windowStartNetPrice);
multiplyValue(windowStartIndex, windowStartRatio);
windowStartSeeded = true;
}
int start = Math.max(Math.max(entryIndex + 1, seriesBegin + 1), windowStartIndex + 1);
for (int barIndex = start; barIndex < endIndex && barIndex <= windowEndIndex; barIndex++) {
Num closePrice = barSeries.getBar(barIndex).getClosePrice();
Num intermediateNetPrice = addCost(closePrice, averageHoldingCostPerPeriod, isLongTrade);
Num ratio = getIntermediateRatio(isLongTrade, netEntryPrice, intermediateNetPrice);
multiplyValue(barIndex, ratio);
}
Num exitPrice = resolveExitPrice(position, endIndex, barSeries);
Num netExitPrice = addCost(exitPrice, averageHoldingCostPerPeriod, isLongTrade);
Num ratio = getIntermediateRatio(isLongTrade, netEntryPrice, netExitPrice);
if (ratioIndex <= windowEndIndex && !(windowStartSeeded && ratioIndex == windowStartIndex)) {
multiplyValue(ratioIndex, ratio);
}
multiplyRange(ratioIndex + 1, windowEndIndex, ratio);
return;
}
Trade exit = position.getExit();
if (exit != null && endIndex >= exit.getIndex()) {
Num holdingCost = position.getHoldingCost(endIndex);
Num netExitPrice = addCost(exit.getNetPrice(), holdingCost, isLongTrade);
Num ratio = getIntermediateRatio(isLongTrade, netEntryPrice, netExitPrice);
multiplyRange(Math.max(ratioIndex, windowStartIndex), windowEndIndex, ratio);
}
}
/**
* @param index the bar index
* @return the cash flow value at the index-th position
*/
@Override
public Num getValue(int index) {
return getStoredValue(index);
}
@Override
public int getCountOfUnstableBars() {
return 0;
}
@Override
public BarSeries getBarSeries() {
return barSeries;
}
/**
* @return the size of the bar series
*/
public int getSize() {
return barSeries.getBarCount();
}
/**
* @return the equity curve mode used for this cash flow
* @since 0.22.2
*/
@Override
public EquityCurveMode getEquityCurveMode() {
return equityCurveMode;
}
private void multiplyValue(int index, Num ratio) {
if (!containsIndex(index)) {
return;
}
int valueIndex = toValueIndex(index);
values.set(valueIndex, values.get(valueIndex).multipliedBy(ratio));
}
private void multiplyRange(int startIndex, int endIndex, Num ratio) {
if (values.isEmpty()) {
return;
}
int start = Math.max(valueStartIndex, startIndex);
int end = Math.min(endIndex, valueEndIndex);
if (start > end) {
return;
}
for (int i = start; i <= end; i++) {
int valueIndex = toValueIndex(i);
values.set(valueIndex, values.get(valueIndex).multipliedBy(ratio));
}
}
private boolean containsIndex(int index) {
return index >= valueStartIndex && index <= valueEndIndex;
}
private Num getStoredValue(int index) {
return values.get(toValueIndex(index));
}
private int toValueIndex(int index) {
return index - valueStartIndex;
}
private static Num getIntermediateRatio(boolean isLongTrade, Num entryPrice, Num exitPrice) {
if (isLongTrade) {
return exitPrice.dividedBy(entryPrice);
}
return entryPrice.getNumFactory().numOf(2).minus(exitPrice.dividedBy(entryPrice));
}
}
@@ -0,0 +1,279 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import org.ta4j.core.*;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* A {@link PerformanceIndicator} implementation that computes the cumulative
* profit and loss (PnL) series of one or more trading positions over a given
* {@link BarSeries}.
* <p>
* The cumulative PnL is calculated incrementally from the start of the
* {@code BarSeries}, taking into account realized and unrealized gains/losses,
* trading costs, and position direction (long or short). Each index in the
* series represents the total PnL up to that bar. The calculation mode can be
* configured to mark open positions to market or to only realize PnL at exits.
* </p>
*
* @since 0.19
*/
public final class CumulativePnL implements PerformanceIndicator {
private final BarSeries barSeries;
private final List<Num> values;
private final EquityCurveMode equityCurveMode;
/**
* Constructor for a trading record with a specified final index.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
* @param finalIndex the final index to calculate up to
* @param equityCurveMode the calculation mode
* @param openPositionHandling how to handle open positions
* @since 0.22.2
*/
public CumulativePnL(BarSeries barSeries, TradingRecord tradingRecord, int finalIndex,
EquityCurveMode equityCurveMode, OpenPositionHandling openPositionHandling) {
this.barSeries = Objects.requireNonNull(barSeries);
this.equityCurveMode = Objects.requireNonNull(equityCurveMode);
int seriesEnd = barSeries.getEndIndex();
int size = Math.max(seriesEnd + 1, 0);
this.values = new ArrayList<>(Collections.nCopies(size, barSeries.numFactory().zero()));
calculate(Objects.requireNonNull(tradingRecord), finalIndex, Objects.requireNonNull(openPositionHandling));
}
/**
* Constructor for a single closed position.
*
* @param barSeries the bar series
* @param position the closed position
* @param equityCurveMode the calculation mode
* @since 0.22.2
*/
public CumulativePnL(BarSeries barSeries, Position position, EquityCurveMode equityCurveMode) {
this(barSeries, new BaseTradingRecord(position), barSeries.getEndIndex(), equityCurveMode);
}
/**
* Constructor for a trading record with a specified final index.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
* @param finalIndex the final index to calculate up to
* @param equityCurveMode the calculation mode
* @since 0.22.2
*/
public CumulativePnL(BarSeries barSeries, TradingRecord tradingRecord, int finalIndex,
EquityCurveMode equityCurveMode) {
this(barSeries, tradingRecord, finalIndex, equityCurveMode, OpenPositionHandling.MARK_TO_MARKET);
}
/**
* Constructor for a single closed position.
*
* @param barSeries the bar series
* @param position the closed position
* @since 0.19
*/
public CumulativePnL(BarSeries barSeries, Position position) {
this(barSeries, position, EquityCurveMode.MARK_TO_MARKET);
}
/**
* Constructor for a trading record.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
* @since 0.19
*/
public CumulativePnL(BarSeries barSeries, TradingRecord tradingRecord) {
this(barSeries, tradingRecord, tradingRecord.getEndIndex(barSeries), EquityCurveMode.MARK_TO_MARKET,
OpenPositionHandling.MARK_TO_MARKET);
}
/**
* Constructor for a trading record.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
* @param equityCurveMode the calculation mode
* @since 0.22.2
*/
public CumulativePnL(BarSeries barSeries, TradingRecord tradingRecord, EquityCurveMode equityCurveMode) {
this(barSeries, tradingRecord, tradingRecord.getEndIndex(barSeries), equityCurveMode,
OpenPositionHandling.MARK_TO_MARKET);
}
/**
* Constructor for a trading record.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
* @param equityCurveMode the calculation mode
* @param openPositionHandling how to handle open positions
* @since 0.22.2
*/
public CumulativePnL(BarSeries barSeries, TradingRecord tradingRecord, EquityCurveMode equityCurveMode,
OpenPositionHandling openPositionHandling) {
this(barSeries, tradingRecord, tradingRecord.getEndIndex(barSeries), equityCurveMode, openPositionHandling);
}
/**
* Constructor for a trading record.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
* @param finalIndex the final index to calculate up to
* @since 0.19
*/
public CumulativePnL(BarSeries barSeries, TradingRecord tradingRecord, int finalIndex) {
this(barSeries, tradingRecord, finalIndex, EquityCurveMode.MARK_TO_MARKET, OpenPositionHandling.MARK_TO_MARKET);
}
/**
* Constructor for a trading record.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
* @param openPositionHandling how to handle open positions
* @since 0.22.2
*/
public CumulativePnL(BarSeries barSeries, TradingRecord tradingRecord, OpenPositionHandling openPositionHandling) {
this(barSeries, tradingRecord, tradingRecord.getEndIndex(barSeries), EquityCurveMode.MARK_TO_MARKET,
openPositionHandling);
}
/**
* Calculates the cumulative PnL for a single position.
*
* @param position the position
* @param finalIndex the final index to calculate up to
* @since 0.22.2
*/
@Override
public void calculatePosition(Position position, int finalIndex) {
Trade entry = position.getEntry();
if (entry == null) {
return;
}
int seriesEnd = barSeries.getEndIndex();
int entryIndex = entry.getIndex();
if (entryIndex > finalIndex || entryIndex > seriesEnd) {
return;
}
int endIndex = determineEndIndex(position, finalIndex, seriesEnd);
int seriesBegin = barSeries.getBeginIndex();
if (endIndex < seriesBegin) {
return;
}
NumFactory numFactory = barSeries.numFactory();
boolean isLong = entry.isBuy();
Num netEntryPrice = entry.getNetPrice();
if (equityCurveMode == EquityCurveMode.MARK_TO_MARKET) {
Num averageCostPerPeriod = averageHoldingCostPerPeriod(position, endIndex, numFactory);
int start = Math.max(entryIndex + 1, seriesBegin + 1);
for (int i = start; i < endIndex; i++) {
Num close = barSeries.getBar(i).getClosePrice();
Num netIntermediate = addCost(close, averageCostPerPeriod, isLong);
Num delta = isLong ? netIntermediate.minus(netEntryPrice) : netEntryPrice.minus(netIntermediate);
addValue(i, delta);
}
Num exitRaw = resolveExitPrice(position, endIndex, barSeries);
Num netExit = addCost(exitRaw, averageCostPerPeriod, isLong);
Num deltaExit = isLong ? netExit.minus(netEntryPrice) : netEntryPrice.minus(netExit);
addToRange(endIndex, seriesEnd, deltaExit);
return;
}
Trade exit = position.getExit();
if (exit != null && endIndex >= exit.getIndex()) {
Num holdingCost = position.getHoldingCost(endIndex);
Num netExit = addCost(exit.getNetPrice(), holdingCost, isLong);
Num deltaExit = isLong ? netExit.minus(netEntryPrice) : netEntryPrice.minus(netExit);
addToRange(exit.getIndex(), seriesEnd, deltaExit);
}
}
/**
* {@inheritDoc}
*
* @since 0.19
*/
@Override
public Num getValue(int index) {
return values.get(index);
}
/**
* {@inheritDoc}
*
* @since 0.19
*/
@Override
public int getCountOfUnstableBars() {
return 0;
}
/**
* {@inheritDoc}
*
* @since 0.19
*/
@Override
public BarSeries getBarSeries() {
return barSeries;
}
/**
* Returns the number of bars in the underlying series.
*
* @return the bar count
* @since 0.19
*/
public int getSize() {
return barSeries.getBarCount();
}
/**
* @return the equity curve mode used for this cumulative PnL
* @since 0.22.2
*/
@Override
public EquityCurveMode getEquityCurveMode() {
return equityCurveMode;
}
private void addValue(int index, Num delta) {
if (index < 0 || index >= values.size()) {
return;
}
values.set(index, values.get(index).plus(delta));
}
private void addToRange(int startIndex, int endIndex, Num delta) {
if (values.isEmpty()) {
return;
}
int start = Math.max(0, startIndex);
int end = Math.min(endIndex, values.size() - 1);
if (start > end) {
return;
}
for (int i = start; i <= end; i++) {
values.set(i, values.get(i).plus(delta));
}
}
}
@@ -0,0 +1,28 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis;
/**
* Defines how equity curves are computed.
* <p>
* {@link #MARK_TO_MARKET} reflects unrealized profit and loss on each bar using
* intermediate prices, while {@link #REALIZED} updates the curve only when a
* position is closed, keeping interim bars flat.
* </p>
*
* @since 0.22.2
*/
public enum EquityCurveMode {
/**
* Updates the equity curve on every bar, including unrealized gains/losses.
*/
MARK_TO_MARKET,
/**
* Updates the equity curve only when positions close, reflecting realized
* gains/losses.
*/
REALIZED
}
@@ -0,0 +1,183 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis;
import java.util.Objects;
import org.ta4j.core.utils.BarSeriesUtils;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.BarSeries;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* Computes compounded excess returns between sampled index pairs.
*
* <p>
* For each sampled pair, the excess return is formed by compounding the per-bar
* excess growth factors between the indices. This ensures mixed
* in/out-of-market segments within the sampling window contribute
* proportionally.
*
* <p>
* The {@link CashReturnPolicy} defines how flat equity intervals are treated
* relative to the risk-free benchmark, allowing flat segments to be neutral or
* to incur underperformance against cash.
*
* @since 0.22.2
*/
public final class ExcessReturns {
/**
* Describes how flat equity intervals are treated when computing excess
* returns.
*
* @since 0.22.2
*/
public enum CashReturnPolicy {
/**
* Treats flat equity while out of the market as earning the risk-free rate, so
* those intervals do not contribute to excess return underperformance.
*/
CASH_EARNS_RISK_FREE,
/**
* Treats flat equity while out of the market as earning zero return, so those
* intervals underperform the risk-free benchmark.
*/
CASH_EARNS_ZERO
}
private final Num annualRiskFreeRate;
private final CashReturnPolicy cashReturnPolicy;
private final BarSeries series;
private final InvestedInterval investedInterval;
private final CashFlow cashFlow;
/**
* Creates an excess return calculator with invested interval detection from a
* trading record.
*
* @param series the bar series providing time deltas and num
* factory
* @param annualRiskFreeRate the annual risk-free rate (e.g. 0.05 for 5%)
* @param cashReturnPolicy the policy for flat equity intervals
* @param tradingRecord the trading record used to detect invested
* intervals
* @since 0.22.2
*/
public ExcessReturns(BarSeries series, Num annualRiskFreeRate, CashReturnPolicy cashReturnPolicy,
TradingRecord tradingRecord) {
this(series, annualRiskFreeRate, cashReturnPolicy, tradingRecord, OpenPositionHandling.MARK_TO_MARKET);
}
/**
* Creates an excess return calculator with invested interval detection from a
* trading record.
*
* @param series the bar series providing time deltas and num
* factory
* @param annualRiskFreeRate the annual risk-free rate (e.g. 0.05 for 5%)
* @param cashReturnPolicy the policy for flat equity intervals
* @param tradingRecord the trading record used to detect invested
* intervals
* @param openPositionHandling how open positions should be handled
* @since 0.22.2
*/
public ExcessReturns(BarSeries series, Num annualRiskFreeRate, CashReturnPolicy cashReturnPolicy,
TradingRecord tradingRecord, OpenPositionHandling openPositionHandling) {
this(series, annualRiskFreeRate, cashReturnPolicy, tradingRecord, EquityCurveMode.MARK_TO_MARKET,
openPositionHandling);
}
/**
* Creates an excess return calculator with invested interval detection from a
* trading record.
*
* @param series the bar series providing time deltas and num
* factory
* @param annualRiskFreeRate the annual risk-free rate (e.g. 0.05 for 5%)
* @param cashReturnPolicy the policy for flat equity intervals
* @param tradingRecord the trading record used to detect invested
* intervals
* @param equityCurveMode the cash flow calculation mode
* @param openPositionHandling how open positions should be handled
* @since 0.22.2
*/
public ExcessReturns(BarSeries series, Num annualRiskFreeRate, CashReturnPolicy cashReturnPolicy,
TradingRecord tradingRecord, EquityCurveMode equityCurveMode, OpenPositionHandling openPositionHandling) {
this.series = Objects.requireNonNull(series, "series cannot be null");
this.annualRiskFreeRate = Objects.requireNonNull(annualRiskFreeRate, "annualRiskFreeRate cannot be null");
this.cashReturnPolicy = Objects.requireNonNull(cashReturnPolicy, "cashReturnPolicy cannot be null");
Objects.requireNonNull(tradingRecord, "tradingRecord cannot be null");
Objects.requireNonNull(equityCurveMode, "equityCurveMode cannot be null");
Objects.requireNonNull(openPositionHandling, "openPositionHandling cannot be null");
OpenPositionHandling effectiveOpenPositionHandling = equityCurveMode == EquityCurveMode.REALIZED
? OpenPositionHandling.IGNORE
: openPositionHandling;
this.investedInterval = new InvestedInterval(series, tradingRecord, effectiveOpenPositionHandling);
this.cashFlow = new CashFlow(series, tradingRecord, equityCurveMode, effectiveOpenPositionHandling);
}
/**
* Computes the compounded excess return using the configured cash flow.
*
* @param previousIndex the start index
* @param currentIndex the end index
* @return the compounded excess return
* @since 0.22.2
*/
public Num excessReturn(int previousIndex, int currentIndex) {
NumFactory numFactory = series.numFactory();
Num zero = numFactory.zero();
Num one = numFactory.one();
if (currentIndex <= previousIndex) {
return zero;
}
Num excessGrowth = one;
for (int i = previousIndex + 1; i <= currentIndex; i++) {
Num previousEquity = cashFlow.getValue(i - 1);
Num currentEquity = cashFlow.getValue(i);
Num riskFreeGrowth = riskFreeGrowth(i - 1, i, one);
boolean isFlat = currentEquity.isEqual(previousEquity);
boolean isInvested = isInvested(i);
if (cashReturnPolicy == CashReturnPolicy.CASH_EARNS_RISK_FREE && isFlat && !isInvested) {
continue;
}
if (previousEquity.isZero()) {
if (!currentEquity.isZero()) {
excessGrowth = zero;
}
continue;
}
if (riskFreeGrowth.isZero()) {
excessGrowth = excessGrowth.multipliedBy(currentEquity.dividedBy(previousEquity));
continue;
}
Num growth = currentEquity.dividedBy(previousEquity).dividedBy(riskFreeGrowth);
excessGrowth = excessGrowth.multipliedBy(growth);
}
return excessGrowth.minus(one);
}
private Num riskFreeGrowth(int previousIndex, int currentIndex, Num one) {
NumFactory numFactory = series.numFactory();
Num zero = numFactory.zero();
Num deltaYears = BarSeriesUtils.deltaYears(series, previousIndex, currentIndex);
if (deltaYears.isLessThanOrEqual(zero)) {
return one;
}
return one.plus(annualRiskFreeRate).pow(deltaYears);
}
private boolean isInvested(int index) {
return investedInterval.getValue(index);
}
}
@@ -0,0 +1,94 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis;
import java.util.List;
import java.util.Objects;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Position;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.indicators.CachedIndicator;
/**
* Indicates whether each bar interval is part of an invested position.
*
* <p>
* The indicator marks index {@code i} as invested when the interval between
* {@code i - 1} and {@code i} belongs to a position in the provided trading
* record.
*
* @since 0.22.2
*/
public class InvestedInterval extends CachedIndicator<Boolean> {
private final boolean[] investedIntervals;
/**
* Creates an indicator that reports invested intervals for the trading record.
*
* @param series the bar series backing the indicator
* @param tradingRecord the trading record used to detect invested intervals
* @since 0.22.2
*/
public InvestedInterval(BarSeries series, TradingRecord tradingRecord) {
this(series, tradingRecord, OpenPositionHandling.MARK_TO_MARKET);
}
/**
* Creates an indicator that reports invested intervals for the trading record.
*
* @param series the bar series backing the indicator
* @param tradingRecord the trading record used to detect invested
* intervals
* @param openPositionHandling how open positions should be handled
* @since 0.22.2
*/
public InvestedInterval(BarSeries series, TradingRecord tradingRecord, OpenPositionHandling openPositionHandling) {
super(series);
Objects.requireNonNull(series, "series cannot be null");
Objects.requireNonNull(tradingRecord, "tradingRecord cannot be null");
Objects.requireNonNull(openPositionHandling, "openPositionHandling cannot be null");
investedIntervals = buildInvestedIntervals(tradingRecord, openPositionHandling);
}
@Override
protected Boolean calculate(int index) {
if (index < 0 || index >= investedIntervals.length) {
return Boolean.FALSE;
}
return investedIntervals[index];
}
private boolean[] buildInvestedIntervals(TradingRecord tradingRecord, OpenPositionHandling openPositionHandling) {
BarSeries series = getBarSeries();
int size = Math.max(series.getEndIndex() + 1, 0);
boolean[] invested = new boolean[size];
tradingRecord.getPositions().forEach(position -> markInvestedIntervals(position, invested));
if (openPositionHandling == OpenPositionHandling.MARK_TO_MARKET) {
List<Position> openPositions = AnalysisPositionSupport.openPositions(tradingRecord, series.getEndIndex());
openPositions.forEach(position -> markInvestedIntervals(position, invested));
}
return invested;
}
private void markInvestedIntervals(Position position, boolean[] invested) {
BarSeries series = getBarSeries();
if (position == null || position.getEntry() == null) {
return;
}
int entryIndex = position.getEntry().getIndex();
int exitIndex = position.isClosed() ? position.getExit().getIndex() : series.getEndIndex();
int start = Math.max(entryIndex + 1, series.getBeginIndex() + 1);
int end = Math.min(exitIndex, invested.length - 1);
for (int i = start; i <= end; i++) {
invested[i] = true;
}
}
@Override
public int getCountOfUnstableBars() {
return 0;
}
}
@@ -0,0 +1,48 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis;
import java.util.function.Function;
/**
* Shared contract for named scoring functions.
*
* <p>
* Implementations expose a stable display name and compute a score-like output
* for an input payload.
* </p>
*
* @param <I> input payload type
* @param <S> score/output type
* @since 0.22.4
*/
public interface NamedScoreFunction<I, S> extends Function<I, S> {
/**
* @return human-readable function name
* @since 0.22.4
*/
String name();
/**
* Computes output for the provided input payload.
*
* @param input input payload
* @return computed output
* @since 0.22.4
*/
S score(I input);
/**
* Function-style alias for {@link #score(Object)}.
*
* @param input input payload
* @return computed output
* @since 0.22.4
*/
@Override
default S apply(I input) {
return score(input);
}
}
@@ -0,0 +1,26 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis;
/**
* Controls how open positions are treated in analysis calculations.
*
* @since 0.22.2
*/
public enum OpenPositionHandling {
/**
* Include open positions and mark them to market at the final index.
*
* @since 0.22.2
*/
MARK_TO_MARKET,
/**
* Ignore open positions.
*
* @since 0.22.2
*/
IGNORE
}
@@ -0,0 +1,179 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Indicator;
import org.ta4j.core.Position;
import org.ta4j.core.Trade;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* Shared contract for performance indicators derived from trading records.
*
* @since 0.22.2
*/
public interface PerformanceIndicator extends Indicator<Num> {
/**
* Returns the equity curve mode that influences open position handling.
*
* @return the equity curve mode
* @since 0.22.2
*/
EquityCurveMode getEquityCurveMode();
/**
* Calculates indicator values for a single position.
*
* @param position the position
* @param finalIndex index up until values of open positions are considered
* @since 0.22.2
*/
void calculatePosition(Position position, int finalIndex);
/**
* Calculates indicator values based on the provided trading record.
*
* @param tradingRecord the trading record
* @param finalIndex index up until values of open positions are
* considered
* @param openPositionHandling how to handle open positions
* @since 0.22.2
*/
default void calculate(TradingRecord tradingRecord, int finalIndex, OpenPositionHandling openPositionHandling) {
Objects.requireNonNull(tradingRecord);
Objects.requireNonNull(openPositionHandling);
OpenPositionHandling effectiveOpenPositionHandling = getEffectiveOpenPositionHandling(openPositionHandling);
List<Position> positions = AnalysisPositionSupport.positionsForAnalysis(tradingRecord, finalIndex,
effectiveOpenPositionHandling, getEquityCurveMode());
positions.forEach(position -> calculatePosition(position, finalIndex));
}
/**
* Derives the open-position handling from the equity curve mode to keep
* realized-only curves from leaking unrealized P&amp;L into the calculation.
*
* <p>
* When the equity curve is realized-only, we force
* {@link OpenPositionHandling#IGNORE} regardless of the caller preference. For
* all other modes we defer to the requested handling, so callers can opt into
* mark-to-market behavior.
* </p>
*
* @param openPositionHandling the requested handling for open positions
* @return the effective handling aligned with the equity curve mode
*/
private OpenPositionHandling getEffectiveOpenPositionHandling(OpenPositionHandling openPositionHandling) {
return getEquityCurveMode() == EquityCurveMode.REALIZED ? OpenPositionHandling.IGNORE : openPositionHandling;
}
/**
* Determines the valid final index to be considered.
*
* @param position the position
* @param finalIndex index up until cash flows of open positions are considered
* @param maxIndex maximal valid index
* @since 0.22.2
*/
default int determineEndIndex(Position position, int finalIndex, int maxIndex) {
int idx = finalIndex;
// After closing of the position, no further accrual necessary
if (position.getExit() != null) {
idx = Math.min(position.getExit().getIndex(), finalIndex);
}
// Accrual at most until maximal index of asset data
if (idx > maxIndex) {
idx = maxIndex;
}
return idx;
}
/**
* Adjusts (intermediate) price to incorporate trading costs.
*
* @param rawPrice the gross asset price
* @param holdingCost share of the holding cost per period
* @param isLongTrade true, if the entry trade type is BUY
* @since 0.22.2
*/
default Num addCost(Num rawPrice, Num holdingCost, boolean isLongTrade) {
if (isLongTrade) {
return rawPrice.minus(holdingCost);
} else {
return rawPrice.plus(holdingCost);
}
}
/**
* Computes the average holding cost per period for the given position.
*
* @param position the position
* @param endIndex index up until cash flows of open positions are considered
* @param numFactory the {@link Num} factory
* @return the average holding cost per period, or zero when no periods elapsed
* @since 0.22.2
*/
default Num averageHoldingCostPerPeriod(Position position, int endIndex, NumFactory numFactory) {
int periods = Math.max(0, endIndex - position.getEntry().getIndex());
if (periods == 0) {
return numFactory.zero();
}
Num holdingCost = position.getHoldingCost(endIndex);
return holdingCost.dividedBy(numFactory.numOf(periods));
}
/**
* Resolves the exit price for a position at the given end index.
*
* @param position the position
* @param endIndex index up until values of open positions are considered
* @param series the bar series
* @return the exit price if an exit exists within the end index, otherwise the
* bar close
* @since 0.22.2
*/
default Num resolveExitPrice(Position position, int endIndex, BarSeries series) {
Trade exit = position.getExit();
if (exit != null && exit.getIndex() <= endIndex) {
return exit.getNetPrice();
}
return series.getBar(endIndex).getClosePrice();
}
/**
* Pads a list up to and including the specified end index using the provided
* pad value.
*
* @param values the list to pad
* @param endIndex the last required index
* @param padValue the value to append while padding
* @since 0.22.2
*/
default void padToEndIndex(List<Num> values, int endIndex, Num padValue) {
if (endIndex >= values.size()) {
padToSize(values, endIndex + 1, padValue);
}
}
/**
* Pads a list up to the required size using the provided pad value.
*
* @param values the list to pad
* @param requiredSize the required list size
* @param padValue the value to append while padding
* @since 0.22.2
*/
default void padToSize(List<Num> values, int requiredSize, Num padValue) {
if (requiredSize > values.size()) {
values.addAll(Collections.nCopies(requiredSize - values.size(), padValue));
}
}
}
@@ -0,0 +1,398 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseTradingRecord;
import org.ta4j.core.Position;
import org.ta4j.core.Trade;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.criteria.ReturnRepresentation;
import org.ta4j.core.criteria.ReturnRepresentationPolicy;
import org.ta4j.core.num.NaN;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* Allows to compute the return rate of a price time-series.
* <p>
* Returns are calculated and formatted according to the specified
* {@link ReturnRepresentation}. Use {@link ReturnRepresentation#LOG} for log
* returns, or {@link ReturnRepresentation#DECIMAL},
* {@link ReturnRepresentation#MULTIPLICATIVE}, or
* {@link ReturnRepresentation#PERCENTAGE} for arithmetic returns in different
* formats.
* <p>
* The default representation (when not explicitly specified) is obtained from
* {@link ReturnRepresentationPolicy#getDefaultRepresentation()}.
*
* @see ReturnRepresentation
* @see ReturnRepresentationPolicy
*/
public class Returns implements PerformanceIndicator {
private final ReturnRepresentation representation;
private final EquityCurveMode equityCurveMode;
/** The bar series. */
private final BarSeries barSeries;
/**
* The raw return rates (before formatting).
* <p>
* Stores log returns if {@code representation == LOG}, otherwise stores
* arithmetic returns in DECIMAL format (0-based, e.g., 0.12 for +12%). Used by
* {@link #getRawValues()} for statistical calculations.
*/
private final List<Num> rawValues;
/**
* The formatted return rates (according to the configured representation).
* <p>
* Values are formatted during calculation using
* {@link ReturnRepresentation#toRepresentationFromRateOfReturn(Num)} for
* arithmetic returns, or returned as-is for log returns.
*/
private final List<Num> values;
private final List<Num> returnFactors;
/**
* Constructor.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
* @param finalIndex the index up to which the returns of open
* positions are considered
* @param representation the return representation (determines both
* calculation method and output format)
* @param equityCurveMode the calculation mode
* @param openPositionHandling how to handle open positions
* @since 0.22.2
*/
public Returns(BarSeries barSeries, TradingRecord tradingRecord, int finalIndex,
ReturnRepresentation representation, EquityCurveMode equityCurveMode,
OpenPositionHandling openPositionHandling) {
this.barSeries = Objects.requireNonNull(barSeries);
this.representation = Objects.requireNonNull(representation);
this.equityCurveMode = Objects.requireNonNull(equityCurveMode);
int seriesEnd = barSeries.getEndIndex();
int size = Math.max(seriesEnd + 1, 0);
Num one = barSeries.numFactory().one();
Num zero = barSeries.numFactory().zero();
Num initial = representation == ReturnRepresentation.LOG ? zero : one;
returnFactors = new ArrayList<>(Collections.nCopies(size, initial));
rawValues = new ArrayList<>(Collections.nCopies(size, zero));
values = new ArrayList<>(Collections.nCopies(size, zero));
calculate(Objects.requireNonNull(tradingRecord), finalIndex, Objects.requireNonNull(openPositionHandling));
buildReturns();
}
/**
* Constructor with default representation from
* {@link ReturnRepresentationPolicy#getDefaultRepresentation()}.
*
* @param barSeries the bar series
* @param position a single position
*/
public Returns(BarSeries barSeries, Position position) {
this(barSeries, position, ReturnRepresentationPolicy.getDefaultRepresentation(),
EquityCurveMode.MARK_TO_MARKET);
}
/**
* Constructor with default representation from
* {@link ReturnRepresentationPolicy#getDefaultRepresentation()}.
*
* @param barSeries the bar series
* @param position a single position
* @param equityCurveMode the calculation mode
* @since 0.22.2
*/
public Returns(BarSeries barSeries, Position position, EquityCurveMode equityCurveMode) {
this(barSeries, position, ReturnRepresentationPolicy.getDefaultRepresentation(), equityCurveMode);
}
/**
* Constructor.
*
* @param barSeries the bar series
* @param position a single position
* @param representation the return representation (determines both calculation
* method and output format)
*/
public Returns(BarSeries barSeries, Position position, ReturnRepresentation representation) {
this(barSeries, position, representation, EquityCurveMode.MARK_TO_MARKET);
}
/**
* Constructor.
*
* @param barSeries the bar series
* @param position a single position
* @param representation the return representation (determines both calculation
* method and output format)
* @param equityCurveMode the calculation mode
* @since 0.22.2
*/
public Returns(BarSeries barSeries, Position position, ReturnRepresentation representation,
EquityCurveMode equityCurveMode) {
this(barSeries, new BaseTradingRecord(position), representation, equityCurveMode);
}
/**
* Constructor.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
* @param representation the return representation (determines both calculation
* method and output format)
* @param equityCurveMode the calculation mode
* @since 0.22.2
*/
public Returns(BarSeries barSeries, TradingRecord tradingRecord, ReturnRepresentation representation,
EquityCurveMode equityCurveMode) {
this(barSeries, tradingRecord, tradingRecord.getEndIndex(barSeries), representation, equityCurveMode,
OpenPositionHandling.MARK_TO_MARKET);
}
/**
* Constructor with default representation from
* {@link ReturnRepresentationPolicy#getDefaultRepresentation()}.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
*/
public Returns(BarSeries barSeries, TradingRecord tradingRecord) {
this(barSeries, tradingRecord, ReturnRepresentationPolicy.getDefaultRepresentation(),
EquityCurveMode.MARK_TO_MARKET);
}
/**
* Constructor with default representation from
* {@link ReturnRepresentationPolicy#getDefaultRepresentation()}.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
* @param equityCurveMode the calculation mode
* @since 0.22.2
*/
public Returns(BarSeries barSeries, TradingRecord tradingRecord, EquityCurveMode equityCurveMode) {
this(barSeries, tradingRecord, ReturnRepresentationPolicy.getDefaultRepresentation(), equityCurveMode);
}
/**
* Constructor.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
* @param representation the return representation (determines both calculation
* method and output format)
*/
public Returns(BarSeries barSeries, TradingRecord tradingRecord, ReturnRepresentation representation) {
this(barSeries, tradingRecord, representation, EquityCurveMode.MARK_TO_MARKET);
}
/**
* Constructor.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
* @param representation the return representation (determines both
* calculation method and output format)
* @param openPositionHandling how to handle open positions
* @since 0.22.2
*/
public Returns(BarSeries barSeries, TradingRecord tradingRecord, ReturnRepresentation representation,
OpenPositionHandling openPositionHandling) {
this(barSeries, tradingRecord, tradingRecord.getEndIndex(barSeries), representation,
EquityCurveMode.MARK_TO_MARKET, openPositionHandling);
}
/**
* Constructor.
*
* @param barSeries the bar series
* @param tradingRecord the trading record
* @param representation the return representation (determines both
* calculation method and output format)
* @param equityCurveMode the calculation mode
* @param openPositionHandling how to handle open positions
* @since 0.22.2
*/
public Returns(BarSeries barSeries, TradingRecord tradingRecord, ReturnRepresentation representation,
EquityCurveMode equityCurveMode, OpenPositionHandling openPositionHandling) {
this(barSeries, tradingRecord, tradingRecord.getEndIndex(barSeries), representation, equityCurveMode,
openPositionHandling);
}
/**
* @return the return rates (formatted according to the configured
* representation)
*/
public List<Num> getValues() {
return values;
}
/**
* @param index the bar index
* @return the return rate value at the index-th position (formatted according
* to the configured representation)
*/
@Override
public Num getValue(int index) {
return values.get(index);
}
/**
* @return the raw return rates (before formatting)
*/
public List<Num> getRawValues() {
return rawValues;
}
@Override
public int getCountOfUnstableBars() {
return 0;
}
@Override
public BarSeries getBarSeries() {
return barSeries;
}
/**
* @return the size of the return series.
*/
public int getSize() {
return barSeries.getBarCount() - 1;
}
/**
* Calculates the returns for a single position.
*
* @param position a single position
* @param finalIndex the index up to which the returns of open positions are
* considered
* @since 0.22.2
*/
@Override
public void calculatePosition(Position position, int finalIndex) {
Trade entry = position.getEntry();
if (entry == null) {
return;
}
int entryIndex = entry.getIndex();
int seriesEnd = barSeries.getEndIndex();
if (entryIndex > finalIndex || entryIndex > seriesEnd) {
return;
}
int endIndex = determineEndIndex(position, finalIndex, seriesEnd);
int seriesBegin = barSeries.getBeginIndex();
if (endIndex < seriesBegin) {
return;
}
NumFactory numFactory = barSeries.numFactory();
Num minusOne = numFactory.minusOne();
boolean isLongTrade = entry.isBuy();
int start = Math.max(entryIndex + 1, seriesBegin + 1);
if (equityCurveMode == EquityCurveMode.MARK_TO_MARKET) {
Num avgCost = averageHoldingCostPerPeriod(position, endIndex, numFactory);
Num lastPrice = entry.getNetPrice();
for (int i = start; i < endIndex; i++) {
Bar bar = barSeries.getBar(i);
Num intermediateNetPrice = addCost(bar.getClosePrice(), avgCost, isLongTrade);
Num rawReturn = calculateReturn(intermediateNetPrice, lastPrice);
Num strategyReturn = isLongTrade ? rawReturn : rawReturn.multipliedBy(minusOne);
combineReturnAtIndex(i, strategyReturn);
lastPrice = intermediateNetPrice;
}
Num exitPrice = resolveExitPrice(position, endIndex, barSeries);
Num rawReturn = calculateReturn(addCost(exitPrice, avgCost, isLongTrade), lastPrice);
Num strategyReturn = isLongTrade ? rawReturn : rawReturn.multipliedBy(minusOne);
combineReturnAtIndex(endIndex, strategyReturn);
return;
}
Trade exit = position.getExit();
if (exit != null && endIndex >= exit.getIndex()) {
Num holdingCost = position.getHoldingCost(endIndex);
Num netExit = addCost(exit.getNetPrice(), holdingCost, isLongTrade);
Num rawReturn = calculateReturn(netExit, entry.getNetPrice());
Num strategyReturn = isLongTrade ? rawReturn : rawReturn.multipliedBy(minusOne);
combineReturnAtIndex(exit.getIndex(), strategyReturn);
}
}
/**
* @return the equity curve mode used for this return series
* @since 0.22.2
*/
@Override
public EquityCurveMode getEquityCurveMode() {
return equityCurveMode;
}
/**
* Calculates the raw return between two prices.
*
* @param xNew the new price
* @param xOld the old price
* @return the raw return (log return if representation is LOG, arithmetic
* return otherwise)
*/
private Num calculateReturn(Num xNew, Num xOld) {
if (representation == ReturnRepresentation.LOG) {
// r_i = ln(P_i/P_(i-1))
return (xNew.dividedBy(xOld)).log();
}
// r_i = P_i/P_(i-1) - 1 (arithmetic return, which is DECIMAL format)
Num one = barSeries.numFactory().one();
return xNew.dividedBy(xOld).minus(one);
}
private Num toFactor(Num strategyReturn) {
Num one = barSeries.numFactory().one();
return strategyReturn.plus(one);
}
private void combineReturnAtIndex(int index, Num strategyReturn) {
if (index < 0 || index >= returnFactors.size()) {
return;
}
if (representation == ReturnRepresentation.LOG) {
returnFactors.set(index, returnFactors.get(index).plus(strategyReturn));
} else {
returnFactors.set(index, returnFactors.get(index).multipliedBy(toFactor(strategyReturn)));
}
}
private void buildReturns() {
if (rawValues.isEmpty()) {
return;
}
rawValues.set(0, NaN.NaN);
values.set(0, NaN.NaN);
Num one = barSeries.numFactory().one();
for (int i = 1; i < rawValues.size(); i++) {
if (representation == ReturnRepresentation.LOG) {
Num logReturn = returnFactors.get(i);
rawValues.set(i, logReturn);
values.set(i, logReturn);
} else {
Num factor = returnFactors.get(i);
Num rawReturn = factor.minus(one);
rawValues.set(i, rawReturn);
values.set(i, representation.toRepresentationFromRateOfReturn(rawReturn));
}
}
}
}
@@ -0,0 +1,26 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis;
import org.ta4j.core.BarSeries;
/**
* Selects a series window or transformed series for one-shot analysis.
*
* @param <C> selector context type (for example degree, timeframe, or offset)
* @since 0.22.4
*/
@FunctionalInterface
public interface SeriesSelector<C> {
/**
* Selects the series to analyze for the supplied context.
*
* @param series root input series
* @param context caller-provided selector context
* @return selected series (may be a subseries or transformed series)
* @since 0.22.4
*/
BarSeries select(BarSeries series, C context);
}
@@ -0,0 +1,131 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import org.ta4j.core.num.NaN;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* Value associated with a finite numeric weight.
*
* <p>
* This reusable primitive centralizes common weighting operations used across
* ranking, objective scoring, and confidence aggregation.
* </p>
*
* @param <T> value type
* @param value weighted value
* @param weight finite weight
* @since 0.22.4
*/
public record WeightedValue<T>(T value, Num weight) {
/**
* Creates a validated weighted value.
*
* @param value weighted value
* @param weight finite weight
* @since 0.22.4
*/
public WeightedValue {
Objects.requireNonNull(value, "value");
validateWeight(weight);
}
/**
* Normalizes weights so their sum is exactly {@code 1}.
*
* @param weightedValues weighted values
* @param numFactory target numeric factory
* @param <T> value type
* @return normalized weighted values preserving order
* @throws IllegalArgumentException if list is empty or total weight is zero
* @since 0.22.4
*/
public static <T> List<WeightedValue<T>> normalizeWeights(List<WeightedValue<T>> weightedValues,
NumFactory numFactory) {
Objects.requireNonNull(weightedValues, "weightedValues");
Objects.requireNonNull(numFactory, "numFactory");
if (weightedValues.isEmpty()) {
throw new IllegalArgumentException("weightedValues must not be empty");
}
List<WeightedValue<T>> normalizedInput = new ArrayList<>(weightedValues.size());
Num totalWeight = numFactory.zero();
for (WeightedValue<T> weightedValue : weightedValues) {
Objects.requireNonNull(weightedValue, "weightedValues must not contain null entries");
Num normalizedWeight = normalize(weightedValue.weight(), numFactory);
validateWeight(normalizedWeight);
normalizedInput.add(new WeightedValue<>(weightedValue.value(), normalizedWeight));
totalWeight = totalWeight.plus(normalizedWeight);
}
if (totalWeight.isZero()) {
throw new IllegalArgumentException("sum of weights must be > 0");
}
List<WeightedValue<T>> normalizedValues = new ArrayList<>(normalizedInput.size());
for (WeightedValue<T> weightedValue : normalizedInput) {
Num normalizedWeight = weightedValue.weight().dividedBy(totalWeight);
normalizedValues.add(new WeightedValue<>(weightedValue.value(), normalizedWeight));
}
return List.copyOf(normalizedValues);
}
/**
* Computes weighted sum for resolved values.
*
* <p>
* Entries with missing or NaN resolved values are skipped.
* </p>
*
* @param weightedValues weighted values
* @param valueResolver resolves value to aggregate for each weighted entry
* @param numFactory target numeric factory
* @param <T> value type
* @return weighted sum
* @since 0.22.4
*/
public static <T> Num weightedSum(List<WeightedValue<T>> weightedValues, Function<T, Num> valueResolver,
NumFactory numFactory) {
Objects.requireNonNull(weightedValues, "weightedValues");
Objects.requireNonNull(valueResolver, "valueResolver");
Objects.requireNonNull(numFactory, "numFactory");
Num sum = numFactory.zero();
for (WeightedValue<T> weightedValue : weightedValues) {
Objects.requireNonNull(weightedValue, "weightedValues must not contain null entries");
Num normalizedWeight = normalize(weightedValue.weight(), numFactory);
validateWeight(normalizedWeight);
Num resolvedValue = normalize(valueResolver.apply(weightedValue.value()), numFactory);
if (Num.isNaNOrNull(resolvedValue)) {
continue;
}
sum = sum.plus(normalizedWeight.multipliedBy(resolvedValue));
}
return sum;
}
private static void validateWeight(Num weight) {
Objects.requireNonNull(weight, "weight");
if (Num.isNaNOrNull(weight) || Double.isNaN(weight.doubleValue()) || Double.isInfinite(weight.doubleValue())) {
throw new IllegalArgumentException("weight must be finite");
}
}
private static Num normalize(Num value, NumFactory numFactory) {
if (Num.isNaNOrNull(value)) {
return NaN.NaN;
}
if (numFactory.produces(value)) {
return value;
}
return numFactory.numOf(value.doubleValue());
}
}
@@ -0,0 +1,42 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis.cost;
import org.ta4j.core.Position;
import org.ta4j.core.num.Num;
/**
* With the {@code CostModel}, we can include trading costs that may be incurred
* when opening or closing a position.
*/
public interface CostModel {
/**
* @param position the position
* @param finalIndex the index up to which open positions are considered
* @return the trading cost of the single {@code position}
*/
Num calculate(Position position, int finalIndex);
/**
* @param position the position
* @return the trading cost of the single {@code position}
*/
Num calculate(Position position);
/**
* @param price the trade price per asset
* @param amount the trade amount (i.e. the number of traded assets)
* @return the trading cost for the traded {@code amount}
*/
Num calculate(Num price, Num amount);
/**
* Evaluates if two models are equal.
*
* @param otherModel
* @return true if {@code this} and {@code otherModel} are equal
*/
boolean equals(CostModel otherModel);
}
@@ -0,0 +1,76 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis.cost;
import org.ta4j.core.Position;
import org.ta4j.core.Trade;
import org.ta4j.core.num.Num;
/**
* With this cost model, the trading costs for opening or closing a position are
* accrued through a constant fee per trade (i.e. a fixed fee per transaction).
*/
public class FixedTransactionCostModel implements CostModel {
/** The fixed fee per {@link Trade trade}. */
private final double feePerTrade;
/**
* Constructor for a fixed fee trading cost model.
*
* <pre>
* Cost of opened {@link Position position}: (fixedFeePerTrade * 1)
* Cost of closed {@link Position position}: (fixedFeePerTrade * 2)
* </pre>
*
* @param feePerTrade the fixed fee per {@link Trade trade}
*/
public FixedTransactionCostModel(double feePerTrade) {
this.feePerTrade = feePerTrade;
}
/**
* @param position the position
* @param currentIndex the current bar index (irrelevant for
* {@code FixedTransactionCostModel})
* @return the transaction cost of the single {@code position}
*/
@Override
public Num calculate(Position position, int currentIndex) {
final var numFactory = position.getEntry().getPricePerAsset().getNumFactory();
Num multiplier = numFactory.one();
if (position.isClosed()) {
multiplier = numFactory.numOf(2);
}
return numFactory.numOf(feePerTrade).multipliedBy(multiplier);
}
/**
* @return the transaction cost of the single {@code position}
*/
@Override
public Num calculate(Position position) {
return this.calculate(position, 0);
}
/**
* <b>Note:</b> Both {@code price} and {@code amount} are irrelevant as the fee
* in {@code FixedTransactionCostModel} is always the same.
*
* @return {@link #feePerTrade}
*/
@Override
public Num calculate(Num price, Num amount) {
return price.getNumFactory().numOf(feePerTrade);
}
@Override
public boolean equals(CostModel otherModel) {
boolean equality = false;
if (this.getClass().equals(otherModel.getClass())) {
equality = ((FixedTransactionCostModel) otherModel).feePerTrade == this.feePerTrade;
}
return equality;
}
}
@@ -0,0 +1,91 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis.cost;
import org.ta4j.core.Position;
import org.ta4j.core.Trade.TradeType;
import org.ta4j.core.Trade;
import org.ta4j.core.num.Num;
/**
* With this cost model, the trading costs for borrowing a position (i.e.
* selling a position short) accrue linearly.
*/
public class LinearBorrowingCostModel implements CostModel {
/** The slope of the linear model (fee per period). */
private final double feePerPeriod;
/**
* Constructor with {@code feePerPeriod * nPeriod}.
*
* @param feePerPeriod the coefficient (e.g. 0.0001 for 1bp per period)
*/
public LinearBorrowingCostModel(double feePerPeriod) {
this.feePerPeriod = feePerPeriod;
}
/**
* @return always {@code 0}, as borrowing costs depend on borrowed period
*/
@Override
public Num calculate(Num price, Num amount) {
return price.getNumFactory().zero();
}
/**
* @return the borrowing cost of the closed {@code position}
* @throws IllegalArgumentException if {@code position} is still open
*/
@Override
public Num calculate(Position position) {
if (position.isOpened()) {
throw new IllegalArgumentException(
"Position is not closed. Final index of observation needs to be provided.");
}
return calculate(position, position.getExit().getIndex());
}
/**
* @return the borrowing cost of the {@code position}
*/
@Override
public Num calculate(Position position, int currentIndex) {
Trade entryTrade = position.getEntry();
Trade exitTrade = position.getExit();
Num borrowingCost = position.getEntry().getNetPrice().getNumFactory().zero();
// Borrowing costs only apply to short positions.
if (entryTrade != null && entryTrade.getType().equals(TradeType.SELL) && entryTrade.getAmount() != null) {
int tradingPeriods = 0;
if (position.isClosed()) {
tradingPeriods = exitTrade.getIndex() - entryTrade.getIndex();
} else if (position.isOpened()) {
tradingPeriods = currentIndex - entryTrade.getIndex();
}
borrowingCost = getHoldingCostForPeriods(tradingPeriods, position.getEntry().getValue());
}
return borrowingCost;
}
/**
* @param tradingPeriods the number of periods
* @param tradedValue the value of the initial trading position of the trade
* @return the absolute borrowing cost
*/
private Num getHoldingCostForPeriods(int tradingPeriods, Num tradedValue) {
return tradedValue.multipliedBy(tradedValue.getNumFactory()
.numOf(tradingPeriods)
.multipliedBy(tradedValue.getNumFactory().numOf(feePerPeriod)));
}
@Override
public boolean equals(CostModel otherModel) {
boolean equality = false;
if (this.getClass().equals(otherModel.getClass())) {
equality = ((LinearBorrowingCostModel) otherModel).feePerPeriod == this.feePerPeriod;
}
return equality;
}
}
@@ -0,0 +1,67 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis.cost;
import org.ta4j.core.Position;
import org.ta4j.core.Trade;
import org.ta4j.core.num.Num;
/**
* With this cost model, the trading costs for opening or closing a position
* accrue linearly.
*/
public class LinearTransactionCostModel implements CostModel {
/** The slope of the linear model (fee per position). */
private final double feePerPosition;
/**
* Constructor with {@code feePerPosition * x}.
*
* @param feePerPosition the feePerPosition coefficient (e.g. 0.005 for 0.5% per
* {@link Trade trade})
*/
public LinearTransactionCostModel(double feePerPosition) {
this.feePerPosition = feePerPosition;
}
/**
* @param position the position
* @param currentIndex current bar index (irrelevant for the
* LinearTransactionCostModel)
* @return the trading cost of the single {@code position}
*/
@Override
public Num calculate(Position position, int currentIndex) {
return this.calculate(position);
}
@Override
public Num calculate(Position position) {
Num totalPositionCost = null;
Trade entryTrade = position.getEntry();
if (entryTrade != null) {
// transaction costs of the entry trade
totalPositionCost = entryTrade.getCost();
if (position.getExit() != null) {
totalPositionCost = totalPositionCost.plus(position.getExit().getCost());
}
}
return totalPositionCost;
}
@Override
public Num calculate(Num price, Num amount) {
return amount.getNumFactory().numOf(feePerPosition).multipliedBy(price).multipliedBy(amount);
}
@Override
public boolean equals(CostModel otherModel) {
boolean equality = false;
if (this.getClass().equals(otherModel.getClass())) {
equality = ((LinearTransactionCostModel) otherModel).feePerPosition == this.feePerPosition;
}
return equality;
}
}
@@ -0,0 +1,72 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis.cost;
import org.ta4j.core.Position;
import org.ta4j.core.Trade;
import org.ta4j.core.num.DoubleNumFactory;
import org.ta4j.core.num.Num;
/**
* Cost model that uses recorded trade costs (fees) instead of recomputing them.
*
* @since 0.22.2
*/
public final class RecordedTradeCostModel implements CostModel {
/** Shared instance. */
public static final RecordedTradeCostModel INSTANCE = new RecordedTradeCostModel();
private RecordedTradeCostModel() {
}
@Override
public Num calculate(Position position, int finalIndex) {
Trade entry = position == null ? null : position.getEntry();
Trade exit = position == null ? null : position.getExit();
Num zero = zeroFor(entry, exit);
if (entry == null) {
return zero;
}
Num total = entry.getIndex() <= finalIndex ? entry.getCost() : zero;
if (exit != null && exit.getIndex() <= finalIndex) {
total = total.plus(exit.getCost());
}
return total;
}
@Override
public Num calculate(Position position) {
Trade entry = position == null ? null : position.getEntry();
Trade exit = position == null ? null : position.getExit();
Num zero = zeroFor(entry, exit);
if (entry == null) {
return zero;
}
if (exit == null) {
return entry.getCost();
}
return entry.getCost().plus(exit.getCost());
}
@Override
public Num calculate(Num price, Num amount) {
return price == null ? DoubleNumFactory.getInstance().zero() : price.getNumFactory().zero();
}
@Override
public boolean equals(CostModel otherModel) {
return otherModel instanceof RecordedTradeCostModel;
}
private Num zeroFor(Trade entry, Trade exit) {
if (entry != null) {
return entry.getCost().getNumFactory().zero();
}
if (exit != null) {
return exit.getCost().getNumFactory().zero();
}
return DoubleNumFactory.getInstance().zero();
}
}
@@ -0,0 +1,22 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis.cost;
/**
* With this cost model there are no trading costs.
*/
public class ZeroCostModel extends FixedTransactionCostModel {
private static final double ZERO_FEE_PER_TRADE = 0.0;
/**
* Constructor with {@code feePerTrade = 0}.
*
* @see FixedTransactionCostModel
*/
public ZeroCostModel() {
super(ZERO_FEE_PER_TRADE);
}
}
@@ -0,0 +1,12 @@
/*
* SPDX-License-Identifier: MIT
*/
/**
* Cost models for transaction and holding expense simulation.
*
* <p>
* Use these models to align backtest assumptions with venue fees, borrow costs,
* and recorded live execution fees.
* </p>
*/
package org.ta4j.core.analysis.cost;
@@ -0,0 +1,14 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis.frequency;
/**
* Pair of indices describing a sampled interval.
*
* @param previousIndex the interval start index
* @param currentIndex the interval end index
* @since 0.22.2
*/
public record IndexPair(int previousIndex, int currentIndex) {
}
@@ -0,0 +1,35 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis.frequency;
import java.util.Objects;
import org.ta4j.core.num.Num;
/**
* Single observation for frequency-aware statistics.
*
* <p>
* Each sample includes the observed {@code value} alongside the elapsed time in
* years ({@code deltaYears}) since the previous observation. The time delta is
* used to derive annualization factors when summarizing unevenly spaced series.
* </p>
*
* @param value the observed numeric value
* @param deltaYears the elapsed time in years since the previous observation
* @since 0.22.2
*/
public record Sample(Num value, Num deltaYears) {
/**
* Creates a sample with the provided value and elapsed time in years.
*
* @param value the observed numeric value
* @param deltaYears the elapsed time in years since the previous observation
* @throws NullPointerException if {@code value} or {@code deltaYears} is
* {@code null}
*/
public Sample {
Objects.requireNonNull(value, "value must not be null");
Objects.requireNonNull(deltaYears, "deltaYears must not be null");
}
}
@@ -0,0 +1,291 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis.frequency;
import java.util.Optional;
import java.util.stream.Stream;
import org.ta4j.core.num.NumFactory;
import org.ta4j.core.num.Num;
/**
* Summary statistics for a numeric sample series with optional annualization
* metadata.
*
* <p>
* The summary accumulates central moments for mean, variance, skewness, and
* kurtosis while also tracking the elapsed time between samples (in years). If
* time deltas are provided, callers can derive an annualization factor for
* volatility scaling.
* </p>
*
* @since 0.22.2
*/
public final class SampleSummary {
private final Moments moments;
private final Num deltaYearsSum;
private final Num deltaCount;
private SampleSummary(Moments moments, Num deltaYearsSum, Num deltaCount) {
this.moments = moments;
this.deltaYearsSum = deltaYearsSum;
this.deltaCount = deltaCount;
}
/**
* Builds a summary from frequency-aware samples.
*
* @param samples the samples to summarize
* @param numFactory the numeric factory to use for calculations
* @return a summary of the provided samples
*/
public static SampleSummary fromSamples(Stream<Sample> samples, NumFactory numFactory) {
var zero = numFactory.zero();
var acc = samples.reduce(Acc.empty(zero),
(current, sample) -> current.add(sample.value(), sample.deltaYears(), numFactory),
(left, right) -> left.merge(right, numFactory));
return acc.toSummary();
}
/**
* Builds a summary from raw values with no annualization metadata.
*
* <p>
* The resulting summary treats all time deltas as zero, so the
* {@link #annualizationFactor(NumFactory)} will be empty.
* </p>
*
* @param values the values to summarize
* @param numFactory the numeric factory to use for calculations
* @return a summary of the provided values
*/
public static SampleSummary fromValues(Stream<Num> values, NumFactory numFactory) {
return fromSamples(values.map(value -> new Sample(value, numFactory.zero())), numFactory);
}
/**
* Returns the number of samples observed.
*
* @return the sample count
*/
public int count() {
return moments.count();
}
/**
* Returns the arithmetic mean of the samples.
*
* @return the sample mean
*/
public Num mean() {
return moments.mean();
}
/**
* Returns the second central moment (sum of squared deviations).
*
* @return the second central moment
*/
public Num m2() {
return moments.m2();
}
/**
* Returns the third central moment.
*
* @return the third central moment
*/
public Num m3() {
return moments.m3();
}
/**
* Returns the fourth central moment.
*
* @return the fourth central moment
*/
public Num m4() {
return moments.m4();
}
/**
* Returns the unbiased sample variance.
*
* @param numFactory the numeric factory to use for calculations
* @return the sample variance (zero when fewer than two samples are available)
*/
public Num sampleVariance(NumFactory numFactory) {
return moments.sampleVariance(numFactory);
}
/**
* Returns the sample skewness.
*
* @param numFactory the numeric factory to use for calculations
* @return the sample skewness (zero when fewer than three samples or variance
* is zero)
*/
public Num sampleSkewness(NumFactory numFactory) {
return moments.sampleSkewness(numFactory);
}
/**
* Returns the sample excess kurtosis.
*
* @param numFactory the numeric factory to use for calculations
* @return the sample kurtosis (zero when fewer than four samples or variance is
* zero)
*/
public Num sampleKurtosis(NumFactory numFactory) {
return moments.sampleKurtosis(numFactory);
}
/**
* Returns the annualization factor derived from positive time deltas.
*
* <p>
* The factor is {@code sqrt(count / deltaYearsSum)} and is typically used to
* annualize volatility-like measures. If there are no positive deltas, the
* result is empty.
* </p>
*
* @param numFactory the numeric factory to use for calculations
* @return the annualization factor, if time deltas are available
*/
public Optional<Num> annualizationFactor(NumFactory numFactory) {
var zero = numFactory.zero();
if (deltaCount.isLessThanOrEqual(zero) || deltaYearsSum.isLessThanOrEqual(zero)) {
return Optional.empty();
}
return Optional.of(deltaCount.dividedBy(deltaYearsSum).sqrt());
}
private record Acc(Moments moments, Num deltaYearsSum, Num deltaCount) {
static Acc empty(Num zero) {
return new Acc(Moments.empty(zero), zero, zero);
}
Acc add(Num value, Num deltaYears, NumFactory numFactory) {
var nextMoments = moments.add(value, numFactory);
if (deltaYears.isLessThanOrEqual(numFactory.zero())) {
return new Acc(nextMoments, deltaYearsSum, deltaCount);
}
return new Acc(nextMoments, deltaYearsSum.plus(deltaYears), deltaCount.plus(numFactory.one()));
}
Acc merge(Acc other, NumFactory numFactory) {
var mergedMoments = moments.merge(other.moments, numFactory);
return new Acc(mergedMoments, deltaYearsSum.plus(other.deltaYearsSum), deltaCount.plus(other.deltaCount));
}
SampleSummary toSummary() {
return new SampleSummary(moments, deltaYearsSum, deltaCount);
}
}
private record Moments(Num mean, Num m2, Num m3, Num m4, int count) {
static Moments empty(Num zero) {
return new Moments(zero, zero, zero, zero, 0);
}
Moments add(Num x, NumFactory f) {
if (count == 0) {
return new Moments(x, f.zero(), f.zero(), f.zero(), 1);
}
var n1 = count;
var n = count + 1;
var nNum = f.numOf(n);
var n1Num = f.numOf(n1);
var delta = x.minus(mean);
var deltaN = delta.dividedBy(nNum);
var deltaN2 = deltaN.multipliedBy(deltaN);
var term1 = delta.multipliedBy(deltaN).multipliedBy(n1Num);
var meanNext = mean.plus(deltaN);
var m4Next = m4.plus(term1.multipliedBy(deltaN2).multipliedBy(f.numOf(n * n - 3 * n + 3)))
.plus(deltaN2.multipliedBy(m2).multipliedBy(f.numOf(6)))
.minus(deltaN.multipliedBy(m3).multipliedBy(f.numOf(4)));
var m3Next = m3.plus(term1.multipliedBy(deltaN).multipliedBy(f.numOf(n - 2)))
.minus(deltaN.multipliedBy(m2).multipliedBy(f.numOf(3)));
var m2Next = m2.plus(term1);
return new Moments(meanNext, m2Next, m3Next, m4Next, n);
}
Moments merge(Moments other, NumFactory f) {
if (other.count == 0) {
return this;
}
if (count == 0) {
return other;
}
var n1 = count;
var n2 = other.count;
var n = n1 + n2;
var n1Num = f.numOf(n1);
var n2Num = f.numOf(n2);
var nNum = f.numOf(n);
var delta = other.mean.minus(mean);
var delta2 = delta.multipliedBy(delta);
var delta3 = delta2.multipliedBy(delta);
var delta4 = delta2.multipliedBy(delta2);
var meanNext = mean.plus(delta.multipliedBy(n2Num).dividedBy(nNum));
var m2Next = m2.plus(other.m2).plus(delta2.multipliedBy(n1Num).multipliedBy(n2Num).dividedBy(nNum));
var m3Next = m3.plus(other.m3)
.plus(delta3.multipliedBy(n1Num)
.multipliedBy(n2Num)
.multipliedBy(f.numOf(n1 - n2))
.dividedBy(nNum.multipliedBy(nNum)))
.plus(delta.multipliedBy(f.numOf(3))
.multipliedBy(n1Num.multipliedBy(other.m2).minus(n2Num.multipliedBy(m2)))
.dividedBy(nNum));
var m4Next = m4.plus(other.m4)
.plus(delta4.multipliedBy(n1Num)
.multipliedBy(n2Num)
.multipliedBy(f.numOf(n1 * n1 - n1 * n2 + n2 * n2))
.dividedBy(nNum.multipliedBy(nNum).multipliedBy(nNum)))
.plus(delta2.multipliedBy(f.numOf(6))
.multipliedBy(n1Num.multipliedBy(n1Num)
.multipliedBy(other.m2)
.plus(n2Num.multipliedBy(n2Num).multipliedBy(m2)))
.dividedBy(nNum.multipliedBy(nNum)))
.plus(delta.multipliedBy(f.numOf(4))
.multipliedBy(n1Num.multipliedBy(other.m3).minus(n2Num.multipliedBy(m3)))
.dividedBy(nNum));
return new Moments(meanNext, m2Next, m3Next, m4Next, n);
}
Num sampleVariance(NumFactory f) {
if (count < 2) {
return f.zero();
}
return m2.dividedBy(f.numOf(count - 1));
}
Num sampleSkewness(NumFactory f) {
if (count < 3 || m2.isZero()) {
return f.zero();
}
var n = f.numOf(count);
var nMinus1 = f.numOf(count - 1);
var nMinus2 = f.numOf(count - 2);
var denom = m2.sqrt().multipliedBy(m2);
var factor = n.multipliedBy(nMinus1).sqrt().dividedBy(nMinus2);
return m3.dividedBy(denom).multipliedBy(factor);
}
Num sampleKurtosis(NumFactory f) {
if (count < 4 || m2.isZero()) {
return f.zero();
}
var n = f.numOf(count);
var nMinus1 = f.numOf(count - 1);
var nMinus2 = f.numOf(count - 2);
var nMinus3 = f.numOf(count - 3);
var m2Squared = m2.multipliedBy(m2);
var term = n.plus(f.numOf(1)).multipliedBy(m4).dividedBy(m2Squared).minus(nMinus1.multipliedBy(f.numOf(3)));
return nMinus1.dividedBy(nMinus2.multipliedBy(nMinus3)).multipliedBy(term);
}
}
}
@@ -0,0 +1,13 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis.frequency;
/**
* Supported sampling granularities.
*
* @since 0.22.2
*/
public enum SamplingFrequency {
BAR, SECOND, MINUTE, HOUR, DAY, WEEK, MONTH
}
@@ -0,0 +1,122 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.analysis.frequency;
import java.time.Instant;
import java.time.YearMonth;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.time.temporal.WeekFields;
import java.util.Objects;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import org.ta4j.core.BarSeries;
/**
* Groups bar indices into sampling periods so return calculations can be
* aggregated.
*
* <p>
* The grouping logic detects period boundaries from bar end times in the
* provided {@link ZoneId}. Weekly grouping follows ISO week rules. Each
* sampling interval is represented by a pair of indices
* {@code (previousIndex, currentIndex)} that spans the period. The first pair
* always uses the supplied anchor index as its starting point.
*
* @since 0.22.2
*/
public final class SamplingFrequencyIndexes {
private static final WeekFields ISO_WEEK_FIELDS = WeekFields.ISO;
private final SamplingFrequency samplingFrequency;
private final ZoneId groupingZoneId;
/**
* Creates a grouping helper for the chosen aggregation mode and time zone.
*
* @param samplingFrequency the sampling granularity
* @param groupingZoneId the time zone used to interpret bar end times
* @since 0.22.2
*/
public SamplingFrequencyIndexes(SamplingFrequency samplingFrequency, ZoneId groupingZoneId) {
this.samplingFrequency = Objects.requireNonNull(samplingFrequency, "samplingFrequency must not be null");
this.groupingZoneId = Objects.requireNonNull(groupingZoneId, "groupingZoneId must not be null");
}
/**
* Returns index pairs spanning each sampling period from the provided range.
*
* @param series the bar series
* @param anchorIndex the starting anchor index for the first sampled pair
* @param start the first index eligible for sampling
* @param end the last index eligible for sampling
* @return a stream of index pairs describing each sampling interval
* @since 0.22.2
*/
public Stream<IndexPair> sample(BarSeries series, int anchorIndex, int start, int end) {
if (start > end || end - start < 1) {
return Stream.empty();
}
if (samplingFrequency == SamplingFrequency.BAR) {
return IntStream.rangeClosed(start, end).mapToObj(i -> new IndexPair(i - 1, i));
}
var periodEndIndices = periodEndIndices(series, start, end).toArray();
if (periodEndIndices.length == 0) {
return Stream.empty();
}
var firstPair = Stream.of(new IndexPair(anchorIndex, periodEndIndices[0]));
var consecutivePairs = IntStream.range(1, periodEndIndices.length)
.mapToObj(k -> new IndexPair(periodEndIndices[k - 1], periodEndIndices[k]));
return Stream.concat(firstPair, consecutivePairs);
}
private IntStream periodEndIndices(BarSeries series, int start, int end) {
return IntStream.rangeClosed(start, end).filter(i -> isPeriodEnd(series, i, end));
}
private boolean isPeriodEnd(BarSeries series, int index, int endIndex) {
if (index == endIndex) {
return true;
}
var now = endTimeZoned(series, index);
var next = endTimeZoned(series, index + 1);
return switch (samplingFrequency) {
case SECOND -> crossesChronoUnitBoundary(now, next, ChronoUnit.SECONDS);
case MINUTE -> crossesChronoUnitBoundary(now, next, ChronoUnit.MINUTES);
case HOUR -> crossesChronoUnitBoundary(now, next, ChronoUnit.HOURS);
case DAY -> !now.toLocalDate().equals(next.toLocalDate());
case WEEK -> !sameIsoWeek(now, next);
case MONTH -> !YearMonth.from(now).equals(YearMonth.from(next));
case BAR -> true;
};
}
private boolean crossesChronoUnitBoundary(ZonedDateTime a, ZonedDateTime b, ChronoUnit chronoUnit) {
return !a.truncatedTo(chronoUnit).equals(b.truncatedTo(chronoUnit));
}
private boolean sameIsoWeek(ZonedDateTime a, ZonedDateTime b) {
var weekA = a.get(ISO_WEEK_FIELDS.weekOfWeekBasedYear());
var weekB = b.get(ISO_WEEK_FIELDS.weekOfWeekBasedYear());
var yearA = a.get(ISO_WEEK_FIELDS.weekBasedYear());
var yearB = b.get(ISO_WEEK_FIELDS.weekBasedYear());
return weekA == weekB && yearA == yearB;
}
private ZonedDateTime endTimeZoned(BarSeries series, int index) {
return endTimeInstant(series, index).atZone(groupingZoneId);
}
private Instant endTimeInstant(BarSeries series, int index) {
return series.getBar(index).getEndTime();
}
}
@@ -0,0 +1,12 @@
/*
* SPDX-License-Identifier: MIT
*/
/**
* Sampling frequency utilities for analysis windows and aggregation.
*
* <p>
* Use this package to normalize return and risk calculations across intraday
* and multi-day horizons with explicit sampling semantics.
* </p>
*/
package org.ta4j.core.analysis.frequency;
@@ -0,0 +1,15 @@
/*
* SPDX-License-Identifier: MIT
*/
/**
* Analysis.
*
* <p>
* This package contains instruments to inspect backtesting results like the
* {@link org.ta4j.core.analysis.CashFlow CashFlow}, window definitions and
* context policies for window-aware criterion evaluation
* ({@link org.ta4j.core.analysis.AnalysisWindow AnalysisWindow},
* {@link org.ta4j.core.analysis.AnalysisContext AnalysisContext}), and to
* calculate {@link org.ta4j.core.analysis.cost.CostModel trading costs}.
*/
package org.ta4j.core.analysis;
@@ -0,0 +1,317 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.backtest;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.ta4j.core.AnalysisCriterion;
import org.ta4j.core.BarSeries;
import org.ta4j.core.num.Num;
import org.ta4j.core.reports.BaseTradingStatement;
import org.ta4j.core.reports.TradingStatement;
import org.ta4j.core.serialization.DurationTypeAdapter;
import java.time.Duration;
import java.util.*;
/**
* Wraps the outcome of a {@link BacktestExecutor} run including runtime
* metrics.
*
* @since 0.19
*/
public record BacktestExecutionResult(BarSeries barSeries, List<TradingStatement> tradingStatements,
BacktestRuntimeReport runtimeReport) implements TradingStatementExecutionResult<BacktestRuntimeReport> {
/**
* Ensures properties are non-null.
*
* @param barSeries the bar series used for backtesting
* @param tradingStatements produced trading statements in the order of the
* supplied strategies
* @param runtimeReport runtime statistics for the execution
*/
public BacktestExecutionResult {
barSeries = Objects.requireNonNull(barSeries, "barSeries must not be null");
tradingStatements = Objects.requireNonNull(tradingStatements, "tradingStatements must not be null");
runtimeReport = Objects.requireNonNull(runtimeReport, "runtimeReport must not be null");
}
/**
* Returns the top strategies sorted by the provided analysis criteria in order
* of importance.
* <p>
* This method preserves the legacy lexicographic behavior where the first
* criterion is primary and later criteria are tie-breakers. For weighted and
* normalized ranking, use
* {@link #getTopStrategiesWeighted(int, RankingProfile)} or
* {@link #getTopStrategiesWeighted(int, TradingStatementExecutionResult.WeightedCriterion...)}.
* </p>
*
* @param limit the maximum number of strategies to return
* @param criteria the analysis criteria to sort by, in order of importance
* (first criterion is primary, second breaks ties, etc.)
* @return a list of the top trading statements sorted by the criteria
* @throws NullPointerException if criteria is null
* @throws IllegalArgumentException if criteria is empty or limit is negative
* @since 0.19
*/
public List<TradingStatement> getTopStrategies(int limit, AnalysisCriterion... criteria) {
Objects.requireNonNull(criteria, "criteria must not be null");
if (criteria.length == 0) {
throw new IllegalArgumentException("At least one criterion must be provided");
}
if (limit < 0) {
throw new IllegalArgumentException("limit must not be negative");
}
return getTopStrategies(limit, Arrays.asList(criteria));
}
/**
* Returns the top strategies sorted by the provided analysis criteria in order
* of importance.
* <p>
* This method preserves the legacy lexicographic behavior where the first
* criterion is primary and later criteria are tie-breakers. For weighted and
* normalized ranking, use
* {@link #getTopStrategiesWeighted(int, RankingProfile)} or
* {@link #getTopStrategiesWeighted(int, TradingStatementExecutionResult.WeightedCriterion...)}.
* </p>
* <p>
* Performance: Uses a hybrid approach that selects the optimal algorithm based
* on the limit size relative to the total number of strategies. For small
* limits ({@literal <} 25% of total), uses a heap-based partial sort O(n log
* k). For larger limits, uses a full sort O(n log n) which is more
* cache-friendly.
*
* @param limit the maximum number of strategies to return
* @param criteria the analysis criteria to sort by, in order of importance
* (first criterion is primary, second breaks ties, etc.)
* @return a list of the top trading statements sorted by the criteria
* @throws NullPointerException if criteria is null
* @throws IllegalArgumentException if criteria is empty or limit is negative
* @since 0.19
*/
public List<TradingStatement> getTopStrategies(int limit, List<AnalysisCriterion> criteria) {
Objects.requireNonNull(criteria, "criteria must not be null");
if (criteria.isEmpty()) {
throw new IllegalArgumentException("At least one criterion must be provided");
}
if (limit < 0) {
throw new IllegalArgumentException("limit must not be negative");
}
// Early returns for edge cases
if (limit == 0 || tradingStatements.isEmpty()) {
return Collections.emptyList();
}
int effectiveLimit = Math.min(limit, tradingStatements.size());
// Pre-calculate criterion values for all statements using IdentityHashMap
// (faster than HashMap for object identity)
Map<TradingStatement, List<Num>> criterionValuesMap = new IdentityHashMap<>(tradingStatements.size());
Map<TradingStatement, Map<AnalysisCriterion, Num>> criterionScoresMap = new IdentityHashMap<>(
tradingStatements.size());
for (TradingStatement statement : tradingStatements) {
List<Num> values = new ArrayList<>(criteria.size());
Map<AnalysisCriterion, Num> scores = new HashMap<>(criteria.size());
for (AnalysisCriterion criterion : criteria) {
Num value = criterion.calculate(barSeries, statement.getTradingRecord());
values.add(value);
scores.put(criterion, value);
}
criterionValuesMap.put(statement, values);
criterionScoresMap.put(statement, scores);
}
Comparator<TradingStatement> comparator = createComparator(criteria, criterionValuesMap);
// Use heap-based partial sort for small limits (more efficient O(n log k))
// Use full sort for large limits (more cache-friendly)
List<TradingStatement> topStatements;
if (effectiveLimit < tradingStatements.size() / 4) {
topStatements = selectTopKWithHeap(tradingStatements, effectiveLimit, comparator);
} else {
topStatements = selectTopKWithSort(tradingStatements, effectiveLimit, comparator);
}
// Attach criterion scores to the returned statements
return attachCriterionScores(topStatements, criterionScoresMap);
}
/**
* Returns the top strategies using weighted, normalized criterion ranking.
* <p>
* Multipliers are normalized internally so any positive scale is accepted (for
* example {@code 1.5/1.1/0.8} and {@code 15/11/8} produce equivalent weight
* proportions).
* </p>
*
* @param limit the maximum number of strategies to return
* @param profile weighted ranking profile
* @return the top trading statements ordered by composite weighted score
* @throws NullPointerException if profile is null
* @throws IllegalArgumentException if limit is negative
* @since 0.22.4
*/
public List<TradingStatement> getTopStrategiesWeighted(int limit, RankingProfile profile) {
if (limit < 0) {
throw new IllegalArgumentException("limit must not be negative");
}
if (limit == 0 || tradingStatements.isEmpty()) {
return Collections.emptyList();
}
List<RankedTradingStatement> rankedStatements = rankTradingStatements(profile);
return attachRankedCriterionScores(rankedStatements, limit);
}
/**
* Returns the top strategies using weighted, normalized criterion ranking with
* the default normalizer and missing-value policy.
*
* <p>
* This overload is the shortest path for weighted ranking when callers already
* know their criteria and relative weights.
* </p>
*
* @param limit the maximum number of strategies to return
* @param criteria weighted criteria to normalize and combine
* @return the top trading statements ordered by composite weighted score
* @throws NullPointerException if criteria is null
* @throws IllegalArgumentException if limit is negative
* @since 0.22.4
*/
public List<TradingStatement> getTopStrategiesWeighted(int limit,
TradingStatementExecutionResult.WeightedCriterion... criteria) {
return getTopStrategiesWeighted(limit, RankingProfile.weighted(criteria));
}
/**
* Attaches criterion scores to trading statements by creating new
* BaseTradingStatement instances with the scores included.
*
* @param statements the trading statements to attach scores to
* @param criterionScoresMap map of statement to criterion scores
* @return list of trading statements with criterion scores attached
*/
private List<TradingStatement> attachCriterionScores(List<TradingStatement> statements,
Map<TradingStatement, Map<AnalysisCriterion, Num>> criterionScoresMap) {
List<TradingStatement> result = new ArrayList<>(statements.size());
for (TradingStatement statement : statements) {
Map<AnalysisCriterion, Num> scores = criterionScoresMap.get(statement);
result.add(attachCriterionScores(statement, scores));
}
return result;
}
private List<TradingStatement> attachRankedCriterionScores(List<RankedTradingStatement> rankedStatements,
int limit) {
int effectiveLimit = Math.min(limit, rankedStatements.size());
List<TradingStatement> statementsWithScores = new ArrayList<>(effectiveLimit);
for (int i = 0; i < effectiveLimit; i++) {
RankedTradingStatement rankedStatement = rankedStatements.get(i);
statementsWithScores.add(attachCriterionScores(rankedStatement.statement(), rankedStatement.rawScores()));
}
return statementsWithScores;
}
private TradingStatement attachCriterionScores(TradingStatement statement, Map<AnalysisCriterion, Num> scores) {
if (statement instanceof BaseTradingStatement && scores != null && !scores.isEmpty()) {
BaseTradingStatement baseStatement = (BaseTradingStatement) statement;
return new BaseTradingStatement(baseStatement.strategy, baseStatement.tradingRecord,
baseStatement.positionStatsReport, baseStatement.performanceReport, scores);
}
return statement;
}
/**
* Creates a comparator that sorts trading statements by multiple criteria in
* order of importance.
*
* @param criteria the analysis criteria to sort by
* @param criterionValuesMap pre-calculated criterion values for each statement
* @return a comparator for trading statements
*/
private Comparator<TradingStatement> createComparator(List<AnalysisCriterion> criteria,
Map<TradingStatement, List<Num>> criterionValuesMap) {
return (statement1, statement2) -> {
List<Num> values1 = criterionValuesMap.get(statement1);
List<Num> values2 = criterionValuesMap.get(statement2);
for (int i = 0; i < criteria.size(); i++) {
AnalysisCriterion criterion = criteria.get(i);
Num value1 = values1.get(i);
Num value2 = values2.get(i);
// Use criterion's betterThan method to determine order
if (criterion.betterThan(value1, value2)) {
return -1; // statement1 is better, should come first
} else if (criterion.betterThan(value2, value1)) {
return 1; // statement2 is better, should come first
}
// If equal, continue to next criterion
}
return 0; // All criteria equal
};
}
/**
* Selects top k strategies using a min-heap (priority queue). Efficient for
* small k relative to n: O(n log k).
*
* @param statements the trading statements to select from
* @param k the number of top strategies to return
* @param comparator the comparator to determine ranking
* @return a list of the top k trading statements
*/
private List<TradingStatement> selectTopKWithHeap(List<TradingStatement> statements, int k,
Comparator<TradingStatement> comparator) {
// Use a min-heap with reversed comparator (so worst of the top-k is at root)
PriorityQueue<TradingStatement> heap = new PriorityQueue<>(k + 1, comparator.reversed());
for (TradingStatement statement : statements) {
heap.offer(statement);
if (heap.size() > k) {
heap.poll(); // Remove the worst element from top-k
}
}
// Extract results and sort them in correct order (best-first)
List<TradingStatement> result = new ArrayList<>(heap);
result.sort(comparator);
return result;
}
/**
* Selects top k strategies using full sort. Efficient for large k relative to
* n: O(n log n).
*
* @param statements the trading statements to select from
* @param k the number of top strategies to return
* @param comparator the comparator to determine ranking
* @return a list of the top k trading statements
*/
private List<TradingStatement> selectTopKWithSort(List<TradingStatement> statements, int k,
Comparator<TradingStatement> comparator) {
List<TradingStatement> sorted = new ArrayList<>(statements);
sorted.sort(comparator);
return sorted.subList(0, k);
}
@Override
public String toString() {
Gson gson = new GsonBuilder().registerTypeAdapter(Duration.class, new DurationTypeAdapter()).create();
JsonObject json = new JsonObject();
json.addProperty("barSeriesName", barSeries.getName());
json.addProperty("tradingStatementsCount", tradingStatements.size());
json.add("runtimeReport", JsonParser.parseString(runtimeReport.toString()).getAsJsonObject());
return gson.toJson(json);
}
}
@@ -0,0 +1,819 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.backtest;
import org.ta4j.core.AnalysisCriterion;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Strategy;
import org.ta4j.core.Trade;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.num.Num;
import org.ta4j.core.reports.TradingStatement;
import org.ta4j.core.reports.TradingStatementGenerator;
import org.ta4j.core.walkforward.AnchoredExpandingWalkForwardSplitter;
import org.ta4j.core.walkforward.WalkForwardConfig;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;
import java.util.stream.IntStream;
/**
* Allows backtesting multiple strategies and comparing them to find out which
* is the best.
*
* <p>
* Prefer this class when running many candidate strategies, parameter sweeps,
* or weighted ranking workflows. For one strategy over one series, use
* {@link BarSeriesManager} for lower setup overhead.
* </p>
*/
public class BacktestExecutor {
private final BarSeriesManager seriesManager;
private final TradingStatementGenerator tradingStatementGenerator;
/**
* Default batch size for processing strategies. When the number of strategies
* exceeds this threshold, they will be processed in batches to prevent memory
* exhaustion. Default is 500.
*/
private static final int DEFAULT_BATCH_SIZE = 500;
/**
* Smaller batch size for very large strategy counts (>5000) to reduce memory
* pressure. Default is 250.
*/
private static final int SMALL_BATCH_SIZE = 250;
/**
* Threshold for switching from parallel to sequential execution. When the
* number of strategies exceeds this value, batched sequential processing is
* used instead of unbounded parallel execution. Default is 1000.
*/
private static final int PARALLEL_THRESHOLD = 1000;
/**
* Threshold for using smaller batch size. When strategy count exceeds this, use
* SMALL_BATCH_SIZE instead of DEFAULT_BATCH_SIZE. Default is 5000.
*/
private static final int LARGE_COUNT_THRESHOLD = 5000;
/**
* Constructor.
*
* @param series the bar series
*/
public BacktestExecutor(BarSeries series) {
this(new BarSeriesManager(series), new TradingStatementGenerator());
}
/**
* Constructor.
*
* @param series the bar series
* @param tradeExecutionModel the trade execution model
* @since 0.22.4
*/
public BacktestExecutor(BarSeries series, TradeExecutionModel tradeExecutionModel) {
this(new BarSeriesManager(series, tradeExecutionModel), new TradingStatementGenerator());
}
/**
* Constructor.
*
* @param series the bar series
* @param transactionCostModel the cost model for transactions of the asset
* @param holdingCostModel the cost model for holding the asset (e.g.
* borrowing)
* @param tradeExecutionModel the trade execution model
* @since 0.22.4
*/
public BacktestExecutor(BarSeries series, CostModel transactionCostModel, CostModel holdingCostModel,
TradeExecutionModel tradeExecutionModel) {
this(new BarSeriesManager(series, transactionCostModel, holdingCostModel, tradeExecutionModel),
new TradingStatementGenerator());
}
/**
* Constructor.
*
* @param series the bar series
* @param tradingStatementGenerator the TradingStatementGenerator
*/
public BacktestExecutor(BarSeries series, TradingStatementGenerator tradingStatementGenerator) {
this(new BarSeriesManager(series), tradingStatementGenerator);
}
/**
* Constructor.
*
* @param series the bar series
* @param tradingStatementGenerator the TradingStatementGenerator
* @param tradeExecutionModel the trade execution model
* @since 0.22.4
*/
public BacktestExecutor(BarSeries series, TradingStatementGenerator tradingStatementGenerator,
TradeExecutionModel tradeExecutionModel) {
this(new BarSeriesManager(series, tradeExecutionModel), tradingStatementGenerator);
}
/**
* Constructor.
*
* @param series the bar series
* @param tradingStatementGenerator the TradingStatementGenerator
* @param transactionCostModel the cost model for transactions of the asset
* @param holdingCostModel the cost model for holding the asset (e.g.
* borrowing)
*/
public BacktestExecutor(BarSeries series, TradingStatementGenerator tradingStatementGenerator,
CostModel transactionCostModel, CostModel holdingCostModel, TradeExecutionModel tradeExecutionModel) {
this(new BarSeriesManager(series, transactionCostModel, holdingCostModel, tradeExecutionModel),
tradingStatementGenerator);
}
/**
* Constructor.
*
* @param seriesManager the preconfigured series manager including its cost
* models, trade execution model, and default
* trading-record creation policy
* @since 0.22.4
*/
public BacktestExecutor(BarSeriesManager seriesManager) {
this(seriesManager, new TradingStatementGenerator());
}
/**
* Constructor.
*
* @param seriesManager the preconfigured series manager including
* its cost models, trade execution model, and
* default trading-record creation policy
* @param tradingStatementGenerator the TradingStatementGenerator
* @since 0.22.4
*/
public BacktestExecutor(BarSeriesManager seriesManager, TradingStatementGenerator tradingStatementGenerator) {
this.seriesManager = Objects.requireNonNull(seriesManager, "seriesManager");
this.tradingStatementGenerator = Objects.requireNonNull(tradingStatementGenerator, "tradingStatementGenerator");
}
/**
* Executes given strategies and returns trading statements with
* {@code tradeType} (to open the position) = BUY.
*
* @param strategies the strategies
* @param amount the amount used to open/close the position
* @return a list of TradingStatements
*/
public List<TradingStatement> execute(List<Strategy> strategies, Num amount) {
return execute(strategies, amount, Trade.TradeType.BUY);
}
/**
* Executes given strategies with specified trade type to open the position and
* return the trading statements.
*
* @param strategies the strategies
* @param amount the amount used to open/close the position
* @param tradeType the {@link Trade.TradeType} used to open the position
* @return a list of TradingStatements
*/
public List<TradingStatement> execute(List<Strategy> strategies, Num amount, Trade.TradeType tradeType) {
return executeWithRuntimeReport(strategies, amount, tradeType).tradingStatements();
}
/**
* Executes strategies while collecting runtime measurements and trading
* statements.
*
* @param strategies the strategies to execute (read-only)
* @param amount the amount used to open/close the position
* @return execution result containing immutable trading statements and a
* runtime report
*
* @since 0.19
*/
public BacktestExecutionResult executeWithRuntimeReport(List<Strategy> strategies, Num amount) {
return executeWithRuntimeReport(strategies, amount, Trade.TradeType.BUY);
}
/**
* Executes strategies while collecting runtime measurements and trading
* statements.
*
* @param strategies the list of strategies to execute (read-only)
* @param amount the amount used to open/close the position
* @param tradeType the {@link Trade.TradeType} used to open the position
* @return execution result containing immutable trading statements and a
* runtime report
*
* @since 0.19
*/
public BacktestExecutionResult executeWithRuntimeReport(List<Strategy> strategies, Num amount,
Trade.TradeType tradeType) {
return executeWithRuntimeReport(strategies, amount, tradeType, null);
}
/**
* Executes strategies while collecting runtime measurements and trading
* statements, with optional progress reporting.
* <p>
* For large strategy counts (> {@value #PARALLEL_THRESHOLD}), automatically
* uses batched sequential processing with a batch size of
* {@value #DEFAULT_BATCH_SIZE} to prevent memory exhaustion. For smaller
* counts, uses standard parallel execution.
* </p>
* <p>
* If {@code progressCallback} is null, uses {@link ProgressCompletion#noOp()}
* as the default (no progress reporting). To use default logging progress, pass
* {@link ProgressCompletion#logging(Class)} or
* {@link ProgressCompletion#logging(String)} with your class or logger name.
* </p>
*
* @param strategies the strategies
* @param amount the amount used to open/close the position
* @param tradeType the {@link Trade.TradeType} used to open the position
* @param progressCallback optional callback for progress updates (receives
* completed count). May be null, in which case
* {@link ProgressCompletion#noOp()} is used.
* @return execution result with trading statements and runtime report
*
* @since 0.19
*/
public BacktestExecutionResult executeWithRuntimeReport(List<Strategy> strategies, Num amount,
Trade.TradeType tradeType, Consumer<Integer> progressCallback) {
return executeWithRuntimeReport(strategies, amount, tradeType, progressCallback, DEFAULT_BATCH_SIZE);
}
/**
* Executes strategies while collecting runtime measurements and trading
* statements, with configurable batch size and optional progress reporting.
* <p>
* When the strategy count exceeds {@value #PARALLEL_THRESHOLD}, uses batched
* sequential processing to prevent memory exhaustion. Each batch is processed
* in parallel, but batches are executed sequentially with explicit GC hints
* between batches to manage memory pressure.
* </p>
*
* @param strategies the strategies
* @param amount the amount used to open/close the position
* @param tradeType the {@link Trade.TradeType} used to open the position
* @param progressCallback optional callback for progress updates (receives
* completed count). May be null.
* @param batchSize the maximum number of strategies to process in each
* batch. Ignored if strategy count {@literal <=}
* {@value #PARALLEL_THRESHOLD}.
* @return execution result with trading statements and runtime report
*
* @since 0.19
*/
public BacktestExecutionResult executeWithRuntimeReport(List<Strategy> strategies, Num amount,
Trade.TradeType tradeType, Consumer<Integer> progressCallback, int batchSize) {
Objects.requireNonNull(strategies, "strategies must not be null");
Objects.requireNonNull(amount, "amount must not be null");
Objects.requireNonNull(tradeType, "tradeType must not be null");
if (batchSize <= 0) {
throw new IllegalArgumentException("batchSize must be positive");
}
if (strategies.isEmpty()) {
return new BacktestExecutionResult(seriesManager.getBarSeries(), new ArrayList<>(),
BacktestRuntimeReport.empty());
}
Strategy[] strategyArray = strategies.toArray(Strategy[]::new);
int strategyCount = strategyArray.length;
TradingStatement[] statements = new TradingStatement[strategyCount];
long[] durations = new long[strategyCount];
// Use default no-op callback if none provided, and set total strategies for
// logging callbacks
Consumer<Integer> effectiveCallback = ProgressCompletion.withTotalStrategies(
progressCallback != null ? progressCallback : ProgressCompletion.noOp(), strategyCount);
long overallStart = System.nanoTime();
// For large strategy counts, use batched processing to prevent memory
// exhaustion. Use smaller batches for very large counts.
if (strategyCount > PARALLEL_THRESHOLD) {
int effectiveBatchSize = strategyCount > LARGE_COUNT_THRESHOLD ? Math.min(batchSize, SMALL_BATCH_SIZE)
: batchSize;
executeBatched(strategyArray, statements, durations, amount, tradeType, effectiveCallback,
effectiveBatchSize);
} else {
executeUnbounded(strategyArray, statements, durations, amount, tradeType, effectiveCallback);
}
Duration overallRuntime = Duration.ofNanos(System.nanoTime() - overallStart);
List<TradingStatement> tradingStatements = new ArrayList<>(strategyCount);
for (TradingStatement statement : statements) {
tradingStatements.add(statement);
}
List<BacktestRuntimeReport.StrategyRuntime> strategyRuntimes = new ArrayList<>(strategyCount);
for (int i = 0; i < strategyCount; i++) {
strategyRuntimes
.add(new BacktestRuntimeReport.StrategyRuntime(strategyArray[i], Duration.ofNanos(durations[i])));
}
BacktestRuntimeReport runtimeReport = buildRuntimeReport(durations, overallRuntime, strategyRuntimes);
return new BacktestExecutionResult(seriesManager.getBarSeries(), tradingStatements, runtimeReport);
}
/**
* Executes walk-forward testing for one strategy using strategy starting type
* and unit amount.
*
* @param strategy strategy to execute
* @param config walk-forward configuration
* @return walk-forward execution result
* @since 0.22.4
*/
public StrategyWalkForwardExecutionResult executeWalkForward(Strategy strategy, WalkForwardConfig config) {
Objects.requireNonNull(strategy, "strategy");
Num unitAmount = seriesManager.getBarSeries().numFactory().one();
return executeWalkForward(strategy, unitAmount, strategy.getStartingType(), config, null);
}
/**
* Executes walk-forward testing for one strategy with explicit amount and
* strategy starting trade type.
*
* @param strategy strategy to execute
* @param amount amount used to open/close trades
* @param config walk-forward configuration
* @return walk-forward execution result
* @since 0.22.4
*/
public StrategyWalkForwardExecutionResult executeWalkForward(Strategy strategy, Num amount,
WalkForwardConfig config) {
Objects.requireNonNull(strategy, "strategy");
return executeWalkForward(strategy, amount, strategy.getStartingType(), config, null);
}
/**
* Executes walk-forward testing for one strategy with explicit amount and trade
* type.
*
* @param strategy strategy to execute
* @param amount amount used to open/close trades
* @param tradeType trade type used to open positions
* @param config walk-forward configuration
* @return walk-forward execution result
* @since 0.22.4
*/
public StrategyWalkForwardExecutionResult executeWalkForward(Strategy strategy, Num amount,
Trade.TradeType tradeType, WalkForwardConfig config) {
return executeWalkForward(strategy, amount, tradeType, config, null);
}
/**
* Executes walk-forward testing for one strategy with optional per-fold
* progress callback.
*
* @param strategy strategy to execute
* @param amount amount used to open/close trades
* @param tradeType trade type used to open positions
* @param config walk-forward configuration
* @param progressCallback optional callback receiving completed fold count
* @return walk-forward execution result
* @since 0.22.4
*/
public StrategyWalkForwardExecutionResult executeWalkForward(Strategy strategy, Num amount,
Trade.TradeType tradeType, WalkForwardConfig config, Consumer<Integer> progressCallback) {
Objects.requireNonNull(strategy, "strategy");
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(tradeType, "tradeType");
Objects.requireNonNull(config, "config");
StrategyWalkForwardExecutor executor = new StrategyWalkForwardExecutor(seriesManager, tradingStatementGenerator,
new AnchoredExpandingWalkForwardSplitter());
return executor.execute(strategy, tradeType, amount, config, progressCallback);
}
/**
* Runs both standard backtest and walk-forward evaluation for one strategy
* using strategy starting type and unit amount.
*
* @param strategy strategy to execute
* @param config walk-forward configuration
* @return combined backtest and walk-forward result
* @since 0.22.4
*/
public BacktestAndWalkForwardResult executeWithWalkForward(Strategy strategy, WalkForwardConfig config) {
Objects.requireNonNull(strategy, "strategy");
Num unitAmount = seriesManager.getBarSeries().numFactory().one();
return executeWithWalkForward(strategy, unitAmount, strategy.getStartingType(), config);
}
/**
* Runs both standard backtest and walk-forward evaluation for one strategy with
* explicit amount and strategy starting trade type.
*
* @param strategy strategy to execute
* @param amount amount used to open/close trades
* @param config walk-forward configuration
* @return combined backtest and walk-forward result
* @since 0.22.4
*/
public BacktestAndWalkForwardResult executeWithWalkForward(Strategy strategy, Num amount,
WalkForwardConfig config) {
Objects.requireNonNull(strategy, "strategy");
return executeWithWalkForward(strategy, amount, strategy.getStartingType(), config);
}
/**
* Runs both standard backtest and walk-forward evaluation for one strategy.
*
* @param strategy strategy to execute
* @param amount amount used to open/close trades
* @param tradeType trade type used to open positions
* @param config walk-forward configuration
* @return combined backtest and walk-forward result
* @since 0.22.4
*/
public BacktestAndWalkForwardResult executeWithWalkForward(Strategy strategy, Num amount, Trade.TradeType tradeType,
WalkForwardConfig config) {
Objects.requireNonNull(strategy, "strategy");
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(tradeType, "tradeType");
Objects.requireNonNull(config, "config");
BacktestExecutionResult backtestResult = executeWithRuntimeReport(List.of(strategy), amount, tradeType);
StrategyWalkForwardExecutionResult walkForwardResult = executeWalkForward(strategy, amount, tradeType, config);
return new BacktestAndWalkForwardResult(backtestResult, walkForwardResult);
}
/**
* Executes strategies and returns only the top K results based on a criterion,
* using a streaming approach that minimizes memory usage.
* <p>
* This method is ideal for very large strategy counts (10,000+) where you only
* need the best performers. It uses a min-heap to track only the top K
* strategies, discarding worse performers immediately to minimize memory
* pressure.
* </p>
* <p>
* Memory usage is O(K + batchSize) instead of O(strategyCount), making it
* suitable for massive parameter sweeps.
* </p>
*
* @param strategies the strategies to evaluate
* @param amount the amount used to open/close the position
* @param tradeType the {@link Trade.TradeType} used to open the position
* @param criterion the criterion used to rank strategies (higher is
* better)
* @param topK the maximum number of top strategies to return
* @param progressCallback optional callback for progress updates (receives
* completed count). May be null.
* @return execution result containing only the top K strategies and runtime
* report
*
* @since 0.19
*/
public BacktestExecutionResult executeAndKeepTopK(List<Strategy> strategies, Num amount, Trade.TradeType tradeType,
AnalysisCriterion criterion, int topK, Consumer<Integer> progressCallback) {
Objects.requireNonNull(strategies, "strategies must not be null");
Objects.requireNonNull(amount, "amount must not be null");
Objects.requireNonNull(tradeType, "tradeType must not be null");
Objects.requireNonNull(criterion, "criterion must not be null");
if (topK <= 0) {
throw new IllegalArgumentException("topK must be positive");
}
if (strategies.isEmpty()) {
return new BacktestExecutionResult(seriesManager.getBarSeries(), new ArrayList<>(),
BacktestRuntimeReport.empty());
}
int strategyCount = strategies.size();
int effectiveTopK = Math.min(topK, strategyCount);
Comparator<StrategyEvaluation> bestFirstComparator = createBestFirstComparator(criterion);
PriorityQueue<StrategyEvaluation> topStrategies = new PriorityQueue<>(effectiveTopK + 1,
bestFirstComparator.reversed());
ConcurrentLinkedQueue<StrategyEvaluation> batchResults = new ConcurrentLinkedQueue<>();
// Use default no-op callback if none provided, and set total strategies for
// logging callbacks
Consumer<Integer> effectiveCallback = ProgressCompletion.withTotalStrategies(
progressCallback != null ? progressCallback : ProgressCompletion.noOp(), strategyCount);
ProgressTracker progressTracker = ProgressTracker.create(effectiveCallback);
long overallStart = System.nanoTime();
// Determine batch size based on strategy count
int batchSize = strategyCount > LARGE_COUNT_THRESHOLD ? SMALL_BATCH_SIZE : DEFAULT_BATCH_SIZE;
Strategy[] strategyArray = strategies.toArray(Strategy[]::new);
long[] durationNanos = new long[strategyCount];
// Process in batches
for (int batchStart = 0; batchStart < strategyCount; batchStart += batchSize) {
int batchEnd = Math.min(batchStart + batchSize, strategyCount);
final int batchStartFinal = batchStart;
batchResults.clear();
// Evaluate batch in parallel
IntStream.range(0, batchEnd - batchStart).parallel().forEach(localIndex -> {
int globalIndex = batchStartFinal + localIndex;
Strategy strategy = strategyArray[globalIndex];
long strategyStart = System.nanoTime();
TradingRecord tradingRecord = seriesManager.run(strategy, tradeType, amount);
TradingStatement statement = tradingStatementGenerator.generate(strategy, tradingRecord,
seriesManager.getBarSeries());
long duration = System.nanoTime() - strategyStart;
durationNanos[globalIndex] = duration;
Num criterionValue = criterion.calculate(seriesManager.getBarSeries(), statement.getTradingRecord());
batchResults.add(new StrategyEvaluation(statement, criterionValue, globalIndex));
if (progressTracker != null) {
progressTracker.reportCompletion();
}
});
// Merge batch results into top-K heap
for (StrategyEvaluation evaluation : batchResults) {
if (topStrategies.size() < effectiveTopK) {
topStrategies.offer(evaluation);
} else {
// Heap is full - compare with worst strategy
StrategyEvaluation worst = topStrategies.peek();
if (worst != null) {
if (bestFirstComparator.compare(evaluation, worst) < 0) {
topStrategies.poll(); // Remove worst
topStrategies.offer(evaluation); // Add new
}
}
}
}
// Clear batch results and suggest GC
batchResults.clear();
if (batchEnd < strategyCount) {
System.gc();
Thread.yield(); // Give GC a chance to run
}
}
Duration overallRuntime = Duration.ofNanos(System.nanoTime() - overallStart);
// Extract top strategies and sort them in correct order (best first)
List<StrategyEvaluation> sortedEvaluations = new ArrayList<>(topStrategies);
sortedEvaluations.sort(bestFirstComparator); // Sort using non-reversed comparator (best first)
List<TradingStatement> resultStatements = new ArrayList<>(sortedEvaluations.size());
for (StrategyEvaluation evaluation : sortedEvaluations) {
resultStatements.add(evaluation.statement());
}
// Build runtime report (approximate, since we don't track all individual times)
List<BacktestRuntimeReport.StrategyRuntime> strategyRuntimes = new ArrayList<>(sortedEvaluations.size());
for (StrategyEvaluation evaluation : sortedEvaluations) {
Duration runtime = Duration.ofNanos(durationNanos[evaluation.index()]);
strategyRuntimes
.add(new BacktestRuntimeReport.StrategyRuntime(evaluation.statement().getStrategy(), runtime));
}
// Calculate summary statistics from saved durations
BacktestRuntimeReport runtimeReport = buildRuntimeReport(durationNanos, overallRuntime, strategyRuntimes);
return new BacktestExecutionResult(seriesManager.getBarSeries(), resultStatements, runtimeReport);
}
private Comparator<StrategyEvaluation> createBestFirstComparator(AnalysisCriterion criterion) {
return (left, right) -> {
Num leftValue = left.criterionValue();
Num rightValue = right.criterionValue();
boolean leftNaN = leftValue.isNaN();
boolean rightNaN = rightValue.isNaN();
if (leftNaN && rightNaN) {
return Integer.compare(left.index(), right.index());
}
if (leftNaN) {
return 1;
}
if (rightNaN) {
return -1;
}
if (criterion.betterThan(leftValue, rightValue)) {
return -1;
}
if (criterion.betterThan(rightValue, leftValue)) {
return 1;
}
int naturalComparison = leftValue.compareTo(rightValue);
if (naturalComparison != 0) {
return naturalComparison;
}
return Integer.compare(left.index(), right.index());
};
}
private static final class StrategyEvaluation {
private final TradingStatement statement;
private final Num criterionValue;
private final int index;
private StrategyEvaluation(TradingStatement statement, Num criterionValue, int index) {
this.statement = statement;
this.criterionValue = criterionValue;
this.index = index;
}
private TradingStatement statement() {
return statement;
}
private Num criterionValue() {
return criterionValue;
}
private int index() {
return index;
}
}
/**
* Combined result for one-strategy backtest and walk-forward execution.
*
* @param backtest standard backtest output
* @param walkForward walk-forward output
* @since 0.22.4
*/
public record BacktestAndWalkForwardResult(BacktestExecutionResult backtest,
StrategyWalkForwardExecutionResult walkForward) {
/**
* Creates a validated combined result.
*
* @param backtest standard backtest output
* @param walkForward walk-forward output
* @since 0.22.4
*/
public BacktestAndWalkForwardResult {
backtest = Objects.requireNonNull(backtest, "backtest");
walkForward = Objects.requireNonNull(walkForward, "walkForward");
}
}
/**
* Executes strategies using unbounded parallel execution (standard behavior).
*/
private void executeUnbounded(Strategy[] strategyArray, TradingStatement[] statements, long[] durations, Num amount,
Trade.TradeType tradeType, Consumer<Integer> progressCallback) {
int strategyCount = strategyArray.length;
ProgressTracker progressTracker = ProgressTracker.create(progressCallback);
IntStream indexStream = IntStream.range(0, strategyCount);
if (strategyCount > 1) {
indexStream = indexStream.parallel();
}
indexStream.forEach(index -> {
Strategy strategy = strategyArray[index];
long strategyStart = System.nanoTime();
TradingRecord tradingRecord = seriesManager.run(strategy, tradeType, amount);
TradingStatement statement = tradingStatementGenerator.generate(strategy, tradingRecord,
seriesManager.getBarSeries());
statements[index] = statement;
durations[index] = System.nanoTime() - strategyStart;
if (progressTracker != null) {
progressTracker.reportCompletion();
}
});
}
/**
* Executes strategies in batches to prevent memory exhaustion. Each batch is
* processed in parallel, but batches are executed sequentially with explicit GC
* hints between batches.
*/
private void executeBatched(Strategy[] strategyArray, TradingStatement[] statements, long[] durations, Num amount,
Trade.TradeType tradeType, Consumer<Integer> progressCallback, int batchSize) {
int strategyCount = strategyArray.length;
ProgressTracker progressTracker = ProgressTracker.create(progressCallback);
for (int batchStart = 0; batchStart < strategyCount; batchStart += batchSize) {
int batchEnd = Math.min(batchStart + batchSize, strategyCount);
final int batchStartFinal = batchStart;
IntStream.range(0, batchEnd - batchStart).parallel().forEach(localIndex -> {
int globalIndex = batchStartFinal + localIndex;
Strategy strategy = strategyArray[globalIndex];
long strategyStart = System.nanoTime();
TradingRecord tradingRecord = seriesManager.run(strategy, tradeType, amount);
TradingStatement statement = tradingStatementGenerator.generate(strategy, tradingRecord,
seriesManager.getBarSeries());
statements[globalIndex] = statement;
durations[globalIndex] = System.nanoTime() - strategyStart;
if (progressTracker != null) {
progressTracker.reportCompletion();
}
});
// Aggressively suggest GC between batches to manage memory pressure
// For very large counts, be more aggressive
if (batchEnd < strategyCount) {
System.gc();
Thread.yield(); // Give GC a chance to run
if (strategyCount > LARGE_COUNT_THRESHOLD) {
// For very large strategy counts, try even harder
try {
Thread.sleep(10); // Brief pause to let GC complete
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}
}
private static final class ProgressTracker {
private final Consumer<Integer> callback;
private final ReentrantLock lock = new ReentrantLock();
private final Condition ready = lock.newCondition();
private int completedCount = 0;
private int nextToReport = 1;
private ProgressTracker(Consumer<Integer> callback) {
this.callback = callback;
}
static ProgressTracker create(Consumer<Integer> callback) {
return callback == null ? null : new ProgressTracker(callback);
}
void reportCompletion() {
int completionOrder;
lock.lock();
try {
completionOrder = ++completedCount;
while (completionOrder != nextToReport) {
ready.await();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while reporting progress", e);
} finally {
lock.unlock();
}
try {
callback.accept(completionOrder);
} finally {
lock.lock();
try {
nextToReport++;
ready.signalAll();
} finally {
lock.unlock();
}
}
}
}
private BacktestRuntimeReport buildRuntimeReport(long[] durations, Duration overallRuntime,
List<BacktestRuntimeReport.StrategyRuntime> strategyRuntimes) {
LongSummaryStatistics summaryStatistics = Arrays.stream(durations).summaryStatistics();
if (summaryStatistics.getCount() == 0) {
return new BacktestRuntimeReport(overallRuntime, Duration.ZERO, Duration.ZERO, Duration.ZERO, Duration.ZERO,
strategyRuntimes);
}
long[] sortedDurations = durations.clone();
Arrays.sort(sortedDurations);
int midPoint = sortedDurations.length / 2;
long medianNanos;
if (sortedDurations.length % 2 == 0) {
medianNanos = (sortedDurations[midPoint - 1] + sortedDurations[midPoint]) / 2;
} else {
medianNanos = sortedDurations[midPoint];
}
Duration min = Duration.ofNanos(summaryStatistics.getMin());
Duration max = Duration.ofNanos(summaryStatistics.getMax());
Duration average = Duration.ofNanos(Math.round(summaryStatistics.getAverage()));
Duration median = Duration.ofNanos(medianNanos);
return new BacktestRuntimeReport(overallRuntime, min, max, average, median, strategyRuntimes);
}
}
@@ -0,0 +1,103 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.backtest;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.ExclusionStrategy;
import com.google.gson.FieldAttributes;
import org.ta4j.core.Strategy;
import org.ta4j.core.serialization.DurationTypeAdapter;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
/**
* Captures runtime statistics collected while executing strategies with the
* {@link BacktestExecutor}.
*
* @since 0.19
*/
public record BacktestRuntimeReport(Duration overallRuntime, Duration minStrategyRuntime, Duration maxStrategyRuntime,
Duration averageStrategyRuntime, Duration medianStrategyRuntime, List<StrategyRuntime> strategyRuntimes) {
/**
* Exclusion strategy for JSON serialization that excludes the strategyRuntimes
* field.
*/
private static final ExclusionStrategy STRATEGY_RUNTIMES_EXCLUSION = new ExclusionStrategy() {
@Override
public boolean shouldSkipField(FieldAttributes f) {
return f.getName().equals("strategyRuntimes");
}
@Override
public boolean shouldSkipClass(Class<?> clazz) {
return false;
}
};
/**
* Constructor that ensures {@link #strategyRuntimes} is non-null.
*
* @param overallRuntime total wall clock time to execute all strategies
* @param minStrategyRuntime minimum runtime measured for a single strategy
* @param maxStrategyRuntime maximum runtime measured for a single strategy
* @param averageStrategyRuntime average runtime measured for the executed
* strategies
* @param medianStrategyRuntime median runtime measured for the executed
* strategies
* @param strategyRuntimes individual runtime per evaluated strategy
*/
public BacktestRuntimeReport {
strategyRuntimes = List.copyOf(Objects.requireNonNull(strategyRuntimes, "strategyRuntimes must not be null"));
}
/**
* Creates an empty runtime report.
*
* @return runtime report with zeroed statistics
* @since 0.19
*/
public static BacktestRuntimeReport empty() {
return new BacktestRuntimeReport(Duration.ZERO, Duration.ZERO, Duration.ZERO, Duration.ZERO, Duration.ZERO,
List.of());
}
/**
* Provides the number of strategies that were evaluated.
*
* @return strategy runtime count
* @since 0.19
*/
public int strategyCount() {
return strategyRuntimes.size();
}
/**
* Returns a JSON string representation of this {@code BacktestRuntimeReport}
* instance. The JSON format includes runtime statistics but excludes the
* detailed strategy runtimes list.
*
* @return a JSON string representing the current state of this report
*/
@Override
public String toString() {
Gson gson = new GsonBuilder().registerTypeAdapter(Duration.class, new DurationTypeAdapter())
.setExclusionStrategies(STRATEGY_RUNTIMES_EXCLUSION)
.create();
return gson.toJson(this);
}
/**
* Records the runtime for an individual strategy evaluation.
*
* @param strategy strategy instance that was evaluated
* @param runtime elapsed time for the evaluation
* @since 0.19
*/
public record StrategyRuntime(Strategy strategy, Duration runtime) {
}
}
@@ -0,0 +1,436 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.backtest;
import java.util.Objects;
import java.util.function.Consumer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseTradingRecord;
import org.ta4j.core.Strategy;
import org.ta4j.core.Trade.TradeType;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.analysis.cost.ZeroCostModel;
import org.ta4j.core.num.Num;
import org.ta4j.core.reports.TradingStatementGenerator;
import org.ta4j.core.walkforward.AnchoredExpandingWalkForwardSplitter;
import org.ta4j.core.walkforward.WalkForwardConfig;
/**
* A manager for {@link BarSeries} objects used for backtesting. Allows to run a
* {@link Strategy trading strategy} over the managed bar series.
*
* <p>
* Default {@code run(...)} overloads create a fresh trading record through this
* manager's configured {@link TradingRecordFactory}. Existing behavior remains
* unchanged by default ({@link BaseTradingRecord}), while callers can inject a
* custom record implementation for unified backtest/live execution paths.
* </p>
*
* <p>
* Use this class as the default backtest entrypoint when you are evaluating one
* strategy over one series. For large strategy batches with ranking and runtime
* telemetry, switch to {@link BacktestExecutor}.
* </p>
*/
public class BarSeriesManager {
/** The logger */
private static final Logger log = LoggerFactory.getLogger(BarSeriesManager.class);
/** Default trading record factory. */
private static final TradingRecordFactory DEFAULT_TRADING_RECORD_FACTORY = (tradeType, startIndex, endIndex,
transactionCostModel, holdingCostModel) -> new BaseTradingRecord(tradeType, startIndex, endIndex,
transactionCostModel, holdingCostModel);
/** The managed bar series */
private final BarSeries barSeries;
/** The trading cost models */
private final CostModel transactionCostModel;
private final CostModel holdingCostModel;
/** The trade execution model to use */
private final TradeExecutionModel tradeExecutionModel;
/** The trading record factory used by default run overloads. */
private final TradingRecordFactory tradingRecordFactory;
/**
* Factory for creating trading records for backtest runs.
*
* <p>
* Implementations must return a fresh mutable {@link TradingRecord} for each
* invocation. Reusing the same instance across runs causes state leakage
* between executions.
* </p>
*
* @since 0.22.4
*/
@FunctionalInterface
public interface TradingRecordFactory {
/**
* Creates a trading record.
*
* @param tradeType strategy entry type
* @param startIndex run start index (already clamped)
* @param endIndex run end index (already clamped)
* @param transactionCostModel transaction cost model
* @param holdingCostModel holding cost model
* @return a new trading record instance for the run
* @since 0.22.4
*/
TradingRecord create(TradeType tradeType, int startIndex, int endIndex, CostModel transactionCostModel,
CostModel holdingCostModel);
}
/**
* Constructor with {@link #tradeExecutionModel} = {@link TradeOnNextOpenModel}.
*
* @param barSeries the bar series to be managed
*/
public BarSeriesManager(BarSeries barSeries) {
this(barSeries, new ZeroCostModel(), new ZeroCostModel(), new TradeOnNextOpenModel());
}
/**
* Constructor.
*
* @param barSeries the bar series to be managed
* @param tradeExecutionModel the trade execution model to use
* @since 0.22.4
*/
public BarSeriesManager(BarSeries barSeries, TradeExecutionModel tradeExecutionModel) {
this(barSeries, new ZeroCostModel(), new ZeroCostModel(), tradeExecutionModel);
}
/**
* Constructor with {@link #tradeExecutionModel} = {@link TradeOnNextOpenModel}.
*
* @param barSeries the bar series to be managed
* @param transactionCostModel the cost model for transactions of the asset
* @param holdingCostModel the cost model for holding the asset (e.g.
* borrowing)
*/
public BarSeriesManager(BarSeries barSeries, CostModel transactionCostModel, CostModel holdingCostModel) {
this(barSeries, transactionCostModel, holdingCostModel, new TradeOnNextOpenModel(),
DEFAULT_TRADING_RECORD_FACTORY);
}
/**
* Constructor.
*
* @param barSeries the bar series to be managed
* @param transactionCostModel the cost model for transactions of the asset
* @param holdingCostModel the cost model for holding asset (e.g. borrowing)
* @param tradeExecutionModel the trade execution model to use
*/
public BarSeriesManager(BarSeries barSeries, CostModel transactionCostModel, CostModel holdingCostModel,
TradeExecutionModel tradeExecutionModel) {
this(barSeries, transactionCostModel, holdingCostModel, tradeExecutionModel, DEFAULT_TRADING_RECORD_FACTORY);
}
/**
* Constructor.
*
* @param barSeries the bar series to be managed
* @param transactionCostModel the cost model for transactions of the asset
* @param holdingCostModel the cost model for holding asset (e.g. borrowing)
* @param tradeExecutionModel the trade execution model to use
* @param tradingRecordFactory factory for default run overloads
* @since 0.22.4
*/
public BarSeriesManager(BarSeries barSeries, CostModel transactionCostModel, CostModel holdingCostModel,
TradeExecutionModel tradeExecutionModel, TradingRecordFactory tradingRecordFactory) {
Objects.requireNonNull(barSeries, "barSeries");
Objects.requireNonNull(transactionCostModel, "transactionCostModel");
Objects.requireNonNull(holdingCostModel, "holdingCostModel");
Objects.requireNonNull(tradeExecutionModel, "tradeExecutionModel");
Objects.requireNonNull(tradingRecordFactory, "tradingRecordFactory");
this.barSeries = barSeries;
this.transactionCostModel = transactionCostModel;
this.holdingCostModel = holdingCostModel;
this.tradeExecutionModel = tradeExecutionModel;
this.tradingRecordFactory = tradingRecordFactory;
}
/**
* @return the managed bar series
*/
public BarSeries getBarSeries() {
return barSeries;
}
/**
* @return the transaction cost model
*/
public CostModel getTransactionCostModel() {
return transactionCostModel;
}
/**
* @return the holding cost model
*/
public CostModel getHoldingCostModel() {
return holdingCostModel;
}
/**
* Runs the provided strategy over the managed series.
*
* Opens the position with the strategy {@link TradeType starting type}.
*
* @return the trading record coming from the run
*/
public TradingRecord run(Strategy strategy) {
return run(strategy, strategy.getStartingType());
}
/**
* Runs the provided strategy over the managed series (from startIndex to
* finishIndex).
*
* Opens the position with the strategy {@link TradeType starting type}.
*
* @param strategy the trading strategy to execute (read-only)
* @param startIndex the start index for the run (included)
* @param finishIndex the finish index for the run (included)
* @return a new trading record populated with the run's trades
*/
public TradingRecord run(Strategy strategy, int startIndex, int finishIndex) {
return run(strategy, strategy.getStartingType(), barSeries.numFactory().one(), startIndex, finishIndex);
}
/**
* Runs the provided strategy over the managed series.
*
* Opens the position with a trade of {@link TradeType tradeType}.
*
* @param strategy the trading strategy to execute (read-only)
* @param tradeType the {@link TradeType} used to open the position
* @return a new trading record populated with the run's trades
*/
public TradingRecord run(Strategy strategy, TradeType tradeType) {
return run(strategy, tradeType, barSeries.numFactory().one());
}
/**
* Runs the provided strategy over the managed series (from startIndex to
* finishIndex).
*
* Opens the position with a trade of {@link TradeType tradeType}.
*
* @param strategy the trading strategy
* @param tradeType the {@link TradeType} used to open the position
* @param startIndex the start index for the run (included)
* @param finishIndex the finish index for the run (included)
* @return the trading record coming from the run
*/
public TradingRecord run(Strategy strategy, TradeType tradeType, int startIndex, int finishIndex) {
return run(strategy, tradeType, barSeries.numFactory().one(), startIndex, finishIndex);
}
/**
* Runs the provided strategy over the managed series.
*
* @param strategy the trading strategy
* @param tradeType the {@link TradeType} used to open the position
* @param amount the amount used to open/close the trades
* @return the trading record coming from the run
*/
public TradingRecord run(Strategy strategy, TradeType tradeType, Num amount) {
return run(strategy, tradeType, amount, barSeries.getBeginIndex(), barSeries.getEndIndex());
}
/**
* Runs the provided strategy over the managed series (from startIndex to
* finishIndex).
*
* @param strategy the trading strategy
* @param tradeType the {@link TradeType} used to open the trades
* @param amount the amount used to open/close the trades
* @param startIndex the start index for the run (included)
* @param finishIndex the finish index for the run (included)
* @return the trading record coming from the run
*/
public TradingRecord run(Strategy strategy, TradeType tradeType, Num amount, int startIndex, int finishIndex) {
TradingRecord tradingRecord = createDefaultTradingRecord(tradeType, startIndex, finishIndex);
return run(strategy, tradingRecord, amount, startIndex, finishIndex);
}
/**
* Runs the provided strategy over the managed series using the supplied trading
* record.
*
* <p>
* This allows callers to backtest with alternate {@link TradingRecord}
* implementations (for example a lot-aware {@code BaseTradingRecord}) while
* reusing {@link BarSeriesManager}'s execution loop.
* </p>
*
* @param strategy the trading strategy
* @param tradingRecord the trading record instance to mutate
* @return the supplied trading record after execution
* @since 0.22.4
*/
public TradingRecord run(Strategy strategy, TradingRecord tradingRecord) {
return run(strategy, tradingRecord, barSeries.numFactory().one());
}
/**
* Runs the provided strategy over the managed series using the supplied trading
* record.
*
* @param strategy the trading strategy
* @param tradingRecord the trading record instance to mutate
* @param amount the amount used to open/close the trades
* @return the supplied trading record after execution
* @since 0.22.4
*/
public TradingRecord run(Strategy strategy, TradingRecord tradingRecord, Num amount) {
return run(strategy, tradingRecord, amount, barSeries.getBeginIndex(), barSeries.getEndIndex());
}
/**
* Runs the provided strategy over the managed series using the supplied trading
* record (from startIndex to finishIndex).
*
* <p>
* <strong>Thread safety:</strong> This {@code BarSeriesManager.run(...)}
* overload mutates the supplied {@link TradingRecord}. Callers must ensure
* exclusive access to that record, or synchronize externally, while the run is
* executing. Concurrent reads or writes against the same {@link TradingRecord}
* during execution lead to undefined behavior.
* </p>
*
* @param strategy the trading strategy
* @param tradingRecord the trading record instance to mutate
* @param amount the amount used to open/close the trades
* @param startIndex the start index for the run (included)
* @param finishIndex the finish index for the run (included)
* @return the supplied trading record after execution
* @since 0.22.4
*/
public TradingRecord run(Strategy strategy, TradingRecord tradingRecord, Num amount, int startIndex,
int finishIndex) {
Objects.requireNonNull(strategy, "strategy");
Objects.requireNonNull(tradingRecord, "tradingRecord");
Objects.requireNonNull(amount, "amount");
int runBeginIndex = Math.max(startIndex, barSeries.getBeginIndex());
int runEndIndex = Math.min(finishIndex, barSeries.getEndIndex());
if (log.isTraceEnabled()) {
log.trace("Running strategy (indexes: {} -> {}): {} (starting with {})", runBeginIndex, runEndIndex,
strategy, tradingRecord.getStartingType());
}
int lastProcessedIndex = runEndIndex;
for (int i = runBeginIndex; i <= runEndIndex; i++) {
lastProcessedIndex = i;
tradeExecutionModel.onBar(i, tradingRecord, barSeries);
// For each bar between both indexes...
if (strategy.shouldOperate(i, tradingRecord)) {
tradeExecutionModel.execute(i, tradingRecord, barSeries, amount);
}
}
if (!tradingRecord.isClosed() && runEndIndex == barSeries.getEndIndex()) {
// If the last position is still open and there are still bars after the
// endIndex of the barSeries, then we execute the strategy on these bars
// to give an opportunity to close this position.
int seriesMaxSize = Math.max(barSeries.getEndIndex() + 1, barSeries.getBarData().size());
for (int i = runEndIndex + 1; i < seriesMaxSize; i++) {
lastProcessedIndex = i;
tradeExecutionModel.onBar(i, tradingRecord, barSeries);
// For each bar after the end index of this run...
// --> Trying to close the last position
if (strategy.shouldOperate(i, tradingRecord)) {
tradeExecutionModel.execute(i, tradingRecord, barSeries, amount);
break;
}
}
}
tradeExecutionModel.onRunEnd(lastProcessedIndex, tradingRecord);
return tradingRecord;
}
private TradingRecord createDefaultTradingRecord(TradeType tradeType, int startIndex, int finishIndex) {
int clampedStartIndex = Math.max(startIndex, barSeries.getBeginIndex());
int clampedEndIndex = Math.min(finishIndex, barSeries.getEndIndex());
TradingRecord tradingRecord = tradingRecordFactory.create(tradeType, clampedStartIndex, clampedEndIndex,
transactionCostModel, holdingCostModel);
if (tradingRecord == null) {
throw new IllegalStateException("tradingRecordFactory returned null");
}
return tradingRecord;
}
/**
* Executes walk-forward testing for one strategy using the strategy starting
* trade type and unit amount.
*
* @param strategy strategy to execute
* @param config walk-forward configuration
* @return walk-forward execution result
* @since 0.22.4
*/
public StrategyWalkForwardExecutionResult runWalkForward(Strategy strategy, WalkForwardConfig config) {
Objects.requireNonNull(strategy, "strategy");
Num unitAmount = barSeries.numFactory().one();
return runWalkForward(strategy, strategy.getStartingType(), unitAmount, config, null);
}
/**
* Executes walk-forward testing for one strategy using the provided entry trade
* type and unit amount.
*
* @param strategy strategy to execute
* @param tradeType trade type used to open positions
* @param config walk-forward configuration
* @return walk-forward execution result
* @since 0.22.4
*/
public StrategyWalkForwardExecutionResult runWalkForward(Strategy strategy, TradeType tradeType,
WalkForwardConfig config) {
Num unitAmount = barSeries.numFactory().one();
return runWalkForward(strategy, tradeType, unitAmount, config, null);
}
/**
* Executes walk-forward testing for one strategy with explicit amount.
*
* @param strategy strategy to execute
* @param tradeType trade type used to open positions
* @param amount amount used to open/close trades
* @param config walk-forward configuration
* @return walk-forward execution result
* @since 0.22.4
*/
public StrategyWalkForwardExecutionResult runWalkForward(Strategy strategy, TradeType tradeType, Num amount,
WalkForwardConfig config) {
return runWalkForward(strategy, tradeType, amount, config, null);
}
/**
* Executes walk-forward testing for one strategy with optional per-fold
* progress updates.
*
* @param strategy strategy to execute
* @param tradeType trade type used to open positions
* @param amount amount used to open/close trades
* @param config walk-forward configuration
* @param progressCallback optional callback receiving completed fold count
* @return walk-forward execution result
* @since 0.22.4
*/
public StrategyWalkForwardExecutionResult runWalkForward(Strategy strategy, TradeType tradeType, Num amount,
WalkForwardConfig config, Consumer<Integer> progressCallback) {
StrategyWalkForwardExecutor executor = new StrategyWalkForwardExecutor(this, new TradingStatementGenerator(),
new AnchoredExpandingWalkForwardSplitter());
return executor.execute(strategy, tradeType, amount, config, progressCallback);
}
}
@@ -0,0 +1,64 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.backtest;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Position;
import org.ta4j.core.Trade.TradeType;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.num.Num;
/**
* Shared helper functions for backtest execution models.
*
* <p>
* Centralizes common execution concerns such as trade-side resolution and
* mapping strategy signal bars to concrete execution index/price pairs.
* </p>
*/
final class ExecutionModelSupport {
private ExecutionModelSupport() {
}
static ExecutionTarget resolveExecutionTarget(int signalIndex, BarSeries barSeries,
TradeExecutionModel.PriceSource priceSource) {
if (signalIndex < barSeries.getBeginIndex()) {
return null;
}
if (priceSource == TradeExecutionModel.PriceSource.CURRENT_CLOSE) {
if (!hasAccessibleBar(signalIndex, barSeries)) {
return null;
}
return new ExecutionTarget(signalIndex, barSeries.getBar(signalIndex).getClosePrice());
}
if (signalIndex > barSeries.getEndIndex()) {
return null;
}
int executionIndex = signalIndex + 1;
if (executionIndex > barSeries.getEndIndex()) {
return null;
}
return new ExecutionTarget(executionIndex, barSeries.getBar(executionIndex).getOpenPrice());
}
private static boolean hasAccessibleBar(int signalIndex, BarSeries barSeries) {
int rawIndex = signalIndex - barSeries.getRemovedBarsCount();
return rawIndex >= 0 && rawIndex < barSeries.getBarData().size();
}
static TradeType nextTradeType(TradingRecord tradingRecord) {
if (tradingRecord.isClosed()) {
return tradingRecord.getStartingType();
}
Position currentPosition = tradingRecord.getCurrentPosition();
if (currentPosition == null || currentPosition.getEntry() == null) {
return tradingRecord.getStartingType();
}
return currentPosition.getEntry().getType().complementType();
}
record ExecutionTarget(int index, Num price) {
}
}
@@ -0,0 +1,596 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.backtest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.function.Consumer;
/**
* Utility class providing default progress completion callbacks for backtest
* execution.
* <p>
* Provides factory methods for common progress reporting patterns:
* <ul>
* <li>{@link #noOp()} - No-op callback that does nothing
* <li>{@link #logging()} - Logs progress using a logger for the calling class
* (convenience method with automatic caller detection)
* <li>{@link #logging(int)} - Logs progress using a logger for the calling
* class at the specified interval (convenience method with automatic caller
* detection)
* <li>{@link #logging(String)} - Logs progress to a logger identified by the
* given name
* <li>{@link #logging(Class)} - Logs progress to a logger for the given class
* <li>{@link #logging(Logger)} - Logs progress to the provided logger
* <li>{@link #logging(String, int)} - Logs progress at specified intervals
* <li>{@link #logging(Class, int)} - Logs progress at specified intervals
* <li>{@link #logging(Logger, int)} - Logs progress at specified intervals
* <li>{@link #loggingWithMemory()} - Logs progress and memory statistics using
* a logger for the calling class (convenience method with automatic caller
* detection)
* <li>{@link #loggingWithMemory(int)} - Logs progress and memory statistics
* using a logger for the calling class at the specified interval (convenience
* method with automatic caller detection)
* <li>{@link #loggingWithMemory(String)} - Logs progress and memory statistics
* to a logger identified by the given name
* <li>{@link #loggingWithMemory(Class)} - Logs progress and memory statistics
* to a logger for the given class
* <li>{@link #loggingWithMemory(Logger)} - Logs progress and memory statistics
* to the provided logger
* <li>{@link #loggingWithMemory(String, int)} - Logs progress and memory
* statistics at specified intervals
* <li>{@link #loggingWithMemory(Class, int)} - Logs progress and memory
* statistics at specified intervals
* <li>{@link #loggingWithMemory(Logger, int)} - Logs progress and memory
* statistics at specified intervals
* </ul>
* <p>
* When using logging callbacks, progress is reported at milestones (every 100
* completions, at 25%, 50%, 75%, and 100% completion) to avoid log spam while
* providing useful feedback. Progress is logged at TRACE level for minimal
* performance impact when trace logging is disabled.
*
* @since 0.19
*/
public final class ProgressCompletion {
/**
* Default interval for logging progress updates. Progress is logged every
* {@value #DEFAULT_LOG_INTERVAL} completions.
*
* @since 0.19
*/
public static final int DEFAULT_LOG_INTERVAL = 100;
private ProgressCompletion() {
// Utility class - prevent instantiation
}
/**
* Returns a no-op progress completion callback that does nothing.
*
* @return a Consumer that ignores all progress updates
* @since 0.19
*/
public static Consumer<Integer> noOp() {
return completed -> {
// No-op
};
}
/**
* Returns a progress completion callback that logs progress using a logger for
* the calling class. This is a convenience method that automatically detects
* the caller class.
* <p>
* Progress is logged at milestones: every {@value #DEFAULT_LOG_INTERVAL}
* completions, and at completion milestones (25%, 50%, 75%, 100%).
* <p>
* <b>Note:</b> Caller detection uses stack trace analysis and may not work
* correctly in all environments (e.g., with proxies, AOP frameworks, or certain
* JVM optimizations). For maximum reliability, use {@link #logging(Class)} or
* {@link #logging(String)} to explicitly specify the logger.
*
* @return a Consumer that logs progress updates
* @since 0.19
*/
public static Consumer<Integer> logging() {
return logging(detectCallerClass());
}
/**
* Returns a progress completion callback that logs progress using a logger for
* the calling class, at the specified interval. This is a convenience method
* that automatically detects the caller class.
* <p>
* Progress is logged every {@code interval} completions, and at completion
* milestones (25%, 50%, 75%, 100%).
* <p>
* <b>Note:</b> Caller detection uses stack trace analysis and may not work
* correctly in all environments (e.g., with proxies, AOP frameworks, or certain
* JVM optimizations). For maximum reliability, use {@link #logging(Class, int)}
* or {@link #logging(String, int)} to explicitly specify the logger.
*
* @param interval the number of completions between log messages
* @return a Consumer that logs progress updates
* @since 0.19
*/
public static Consumer<Integer> logging(int interval) {
return logging(detectCallerClass(), interval);
}
/**
* Returns a progress completion callback that logs progress to a logger
* identified by the given name.
* <p>
* Progress is logged at milestones: every {@value #DEFAULT_LOG_INTERVAL}
* completions, and at completion milestones (25%, 50%, 75%, 100%).
*
* @param loggerName the name of the logger to use (e.g., class name)
* @return a Consumer that logs progress updates
* @since 0.19
*/
public static Consumer<Integer> logging(String loggerName) {
return logging(LoggerFactory.getLogger(loggerName));
}
/**
* Returns a progress completion callback that logs progress to a logger for the
* given class.
* <p>
* Progress is logged at milestones: every {@value #DEFAULT_LOG_INTERVAL}
* completions, and at completion milestones (25%, 50%, 75%, 100%).
*
* @param clazz the class whose logger to use
* @return a Consumer that logs progress updates
* @since 0.19
*/
public static Consumer<Integer> logging(Class<?> clazz) {
return logging(LoggerFactory.getLogger(clazz));
}
/**
* Returns a progress completion callback that logs progress to the provided
* logger.
* <p>
* Progress is logged at milestones: every {@value #DEFAULT_LOG_INTERVAL}
* completions, and at completion milestones (25%, 50%, 75%, 100%).
*
* @param logger the logger to use for progress updates
* @return a Consumer that logs progress updates
* @since 0.19
*/
public static Consumer<Integer> logging(Logger logger) {
return logging(logger, DEFAULT_LOG_INTERVAL);
}
/**
* Returns a progress completion callback that logs progress to a logger
* identified by the given name, at the specified interval.
* <p>
* Progress is logged every {@code interval} completions, and at completion
* milestones (25%, 50%, 75%, 100%).
*
* @param loggerName the name of the logger to use (e.g., class name)
* @param interval the number of completions between log messages
* @return a Consumer that logs progress updates
* @since 0.19
*/
public static Consumer<Integer> logging(String loggerName, int interval) {
return logging(LoggerFactory.getLogger(loggerName), interval);
}
/**
* Returns a progress completion callback that logs progress to a logger for the
* given class, at the specified interval.
* <p>
* Progress is logged every {@code interval} completions, and at completion
* milestones (25%, 50%, 75%, 100%).
*
* @param clazz the class whose logger to use
* @param interval the number of completions between log messages
* @return a Consumer that logs progress updates
* @since 0.19
*/
public static Consumer<Integer> logging(Class<?> clazz, int interval) {
return logging(LoggerFactory.getLogger(clazz), interval);
}
/**
* Returns a progress completion callback that logs progress to the provided
* logger, at the specified interval.
* <p>
* Progress is logged every {@code interval} completions, and at completion
* milestones (25%, 50%, 75%, 100%).
*
* @param logger the logger to use for progress updates
* @param interval the number of completions between log messages
* @return a Consumer that logs progress updates
* @since 0.19
*/
public static Consumer<Integer> logging(Logger logger, int interval) {
if (logger == null) {
throw new IllegalArgumentException("logger must not be null");
}
if (interval <= 0) {
throw new IllegalArgumentException("interval must be positive");
}
return new LoggingProgressCallback(logger, interval);
}
/**
* Returns a progress completion callback that logs progress and memory
* statistics using a logger for the calling class. This is a convenience method
* that automatically detects the caller class.
* <p>
* Progress and memory statistics are logged at milestones: every
* {@value #DEFAULT_LOG_INTERVAL} completions, and at completion milestones
* (25%, 50%, 75%, 100%). Memory statistics include heap used, heap free, and
* heap max.
* <p>
* <b>Note:</b> Caller detection uses stack trace analysis and may not work
* correctly in all environments (e.g., with proxies, AOP frameworks, or certain
* JVM optimizations). For maximum reliability, use
* {@link #loggingWithMemory(Class)} or {@link #loggingWithMemory(String)} to
* explicitly specify the logger.
*
* @return a Consumer that logs progress updates with memory statistics
* @since 0.19
*/
public static Consumer<Integer> loggingWithMemory() {
return loggingWithMemory(detectCallerClass());
}
/**
* Returns a progress completion callback that logs progress and memory
* statistics using a logger for the calling class, at the specified interval.
* This is a convenience method that automatically detects the caller class.
* <p>
* Progress and memory statistics are logged every {@code interval} completions,
* and at completion milestones (25%, 50%, 75%, 100%). Memory statistics include
* heap used, heap free, and heap max.
* <p>
* <b>Note:</b> Caller detection uses stack trace analysis and may not work
* correctly in all environments (e.g., with proxies, AOP frameworks, or certain
* JVM optimizations). For maximum reliability, use
* {@link #loggingWithMemory(Class, int)} or
* {@link #loggingWithMemory(String, int)} to explicitly specify the logger.
*
* @param interval the number of completions between log messages
* @return a Consumer that logs progress updates with memory statistics
* @since 0.19
*/
public static Consumer<Integer> loggingWithMemory(int interval) {
return loggingWithMemory(detectCallerClass(), interval);
}
/**
* Returns a progress completion callback that logs progress and memory
* statistics to a logger identified by the given name.
* <p>
* Progress and memory statistics are logged at milestones: every
* {@value #DEFAULT_LOG_INTERVAL} completions, and at completion milestones
* (25%, 50%, 75%, 100%). Memory statistics include heap used, heap free, and
* heap max.
*
* @param loggerName the name of the logger to use (e.g., class name)
* @return a Consumer that logs progress updates with memory statistics
* @since 0.19
*/
public static Consumer<Integer> loggingWithMemory(String loggerName) {
return loggingWithMemory(LoggerFactory.getLogger(loggerName));
}
/**
* Returns a progress completion callback that logs progress and memory
* statistics to a logger for the given class.
* <p>
* Progress and memory statistics are logged at milestones: every
* {@value #DEFAULT_LOG_INTERVAL} completions, and at completion milestones
* (25%, 50%, 75%, 100%). Memory statistics include heap used, heap free, and
* heap max.
*
* @param clazz the class whose logger to use
* @return a Consumer that logs progress updates with memory statistics
* @since 0.19
*/
public static Consumer<Integer> loggingWithMemory(Class<?> clazz) {
return loggingWithMemory(LoggerFactory.getLogger(clazz));
}
/**
* Returns a progress completion callback that logs progress and memory
* statistics to the provided logger.
* <p>
* Progress and memory statistics are logged at milestones: every
* {@value #DEFAULT_LOG_INTERVAL} completions, and at completion milestones
* (25%, 50%, 75%, 100%). Memory statistics include heap used, heap free, and
* heap max.
*
* @param logger the logger to use for progress updates
* @return a Consumer that logs progress updates with memory statistics
* @since 0.19
*/
public static Consumer<Integer> loggingWithMemory(Logger logger) {
return loggingWithMemory(logger, DEFAULT_LOG_INTERVAL);
}
/**
* Returns a progress completion callback that logs progress and memory
* statistics to a logger identified by the given name, at the specified
* interval.
* <p>
* Progress and memory statistics are logged every {@code interval} completions,
* and at completion milestones (25%, 50%, 75%, 100%). Memory statistics include
* heap used, heap free, and heap max.
*
* @param loggerName the name of the logger to use (e.g., class name)
* @param interval the number of completions between log messages
* @return a Consumer that logs progress updates with memory statistics
* @since 0.19
*/
public static Consumer<Integer> loggingWithMemory(String loggerName, int interval) {
return loggingWithMemory(LoggerFactory.getLogger(loggerName), interval);
}
/**
* Returns a progress completion callback that logs progress and memory
* statistics to a logger for the given class, at the specified interval.
* <p>
* Progress and memory statistics are logged every {@code interval} completions,
* and at completion milestones (25%, 50%, 75%, 100%). Memory statistics include
* heap used, heap free, and heap max.
*
* @param clazz the class whose logger to use
* @param interval the number of completions between log messages
* @return a Consumer that logs progress updates with memory statistics
* @since 0.19
*/
public static Consumer<Integer> loggingWithMemory(Class<?> clazz, int interval) {
return loggingWithMemory(LoggerFactory.getLogger(clazz), interval);
}
/**
* Returns a progress completion callback that logs progress and memory
* statistics to the provided logger, at the specified interval.
* <p>
* Progress and memory statistics are logged every {@code interval} completions,
* and at completion milestones (25%, 50%, 75%, 100%). Memory statistics include
* heap used, heap free, and heap max.
*
* @param logger the logger to use for progress updates
* @param interval the number of completions between log messages
* @return a Consumer that logs progress updates with memory statistics
* @since 0.19
*/
public static Consumer<Integer> loggingWithMemory(Logger logger, int interval) {
if (logger == null) {
throw new IllegalArgumentException("logger must not be null");
}
if (interval <= 0) {
throw new IllegalArgumentException("interval must be positive");
}
return new LoggingWithMemoryProgressCallback(logger, interval);
}
/**
* Detects the calling class by analyzing the stack trace. This method skips
* internal frames (ProgressCompletion, Thread, etc.) to find the actual caller.
*
* @return the calling class, or ProgressCompletion.class as a fallback
*/
private static Class<?> detectCallerClass() {
StackTraceElement[] stack = Thread.currentThread().getStackTrace();
String thisClassName = ProgressCompletion.class.getName();
// Start from index 3 to skip:
// 0: getStackTrace
// 1: detectCallerClass
// 2: logging()/logging(int)/loggingWithMemory()/loggingWithMemory(int)
for (int i = 3; i < stack.length; i++) {
String className = stack[i].getClassName();
// Skip internal frames
if (className.equals(thisClassName) || className.equals(Thread.class.getName())
|| className.startsWith("java.lang.reflect.") || className.startsWith("sun.reflect.")
|| className.startsWith("jdk.internal.reflect.")) {
continue;
}
try {
return Class.forName(className);
} catch (ClassNotFoundException e) {
// Continue searching if class can't be loaded
}
}
// Fallback to ProgressCompletion if we can't detect the caller
return ProgressCompletion.class;
}
/**
* Formats memory size in bytes to a human-readable string (e.g., "512 MB").
*
* @param bytes the size in bytes
* @return formatted string
*/
private static String formatMemory(long bytes) {
if (bytes < 1024) {
return bytes + " B";
} else if (bytes < 1024 * 1024) {
return String.format("%.1f KB", bytes / 1024.0);
} else if (bytes < 1024 * 1024 * 1024) {
return String.format("%.1f MB", bytes / (1024.0 * 1024.0));
} else {
return String.format("%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0));
}
}
/**
* Internal implementation of logging progress callback.
*/
private static final class LoggingProgressCallback implements Consumer<Integer> {
private final Logger logger;
private final int interval;
private volatile int totalStrategies = -1;
private LoggingProgressCallback(Logger logger, int interval) {
this.logger = logger;
this.interval = interval;
}
@Override
public void accept(Integer completed) {
// Early exit if trace logging is not enabled to avoid unnecessary calculations
if (!logger.isTraceEnabled()) {
return;
}
int total = totalStrategies;
// If we don't know the total yet, just log at interval milestones
if (total < 0) {
if (completed % interval == 0) {
logger.trace("Progress: {} strategies completed", completed);
}
return;
}
// Log at interval milestones
if (completed % interval == 0) {
int percentComplete = (int) (completed * 100.0) / total;
logger.trace("Progress: {}/{} strategies completed ({}%)", completed, total, percentComplete);
return;
}
// Log at completion milestones (25%, 50%, 75%, 100%)
int percentComplete = (int) (completed * 100.0) / total;
int prevPercent = completed > 1 ? (int) ((completed - 1) * 100.0) / total : 0;
if (percentComplete >= 25 && prevPercent < 25) {
logger.trace("Progress: {}/{} strategies completed (25%)", completed, total);
} else if (percentComplete >= 50 && prevPercent < 50) {
logger.trace("Progress: {}/{} strategies completed (50%)", completed, total);
} else if (percentComplete >= 75 && prevPercent < 75) {
logger.trace("Progress: {}/{} strategies completed (75%)", completed, total);
} else if (completed == total) {
logger.trace("Progress: {}/{} strategies completed (100%)", completed, total);
}
}
/**
* Sets the total number of strategies for percentage calculation. This is
* called internally by BacktestExecutor.
*
* @param total the total number of strategies
*/
void setTotalStrategies(int total) {
this.totalStrategies = total;
}
}
/**
* Internal implementation of logging progress callback with memory statistics.
*/
private static final class LoggingWithMemoryProgressCallback implements Consumer<Integer> {
private final Logger logger;
private final int interval;
private volatile int totalStrategies = -1;
private LoggingWithMemoryProgressCallback(Logger logger, int interval) {
this.logger = logger;
this.interval = interval;
}
@Override
public void accept(Integer completed) {
// Early exit if trace logging is not enabled to avoid unnecessary calculations
if (!logger.isTraceEnabled()) {
return;
}
int total = totalStrategies;
Runtime runtime = Runtime.getRuntime();
long totalMemory = runtime.totalMemory();
long freeMemory = runtime.freeMemory();
long usedMemory = totalMemory - freeMemory;
long maxMemory = runtime.maxMemory();
String memoryInfo = String.format("Heap: %s/%s used (%.1f%%)", formatMemory(usedMemory),
formatMemory(maxMemory), (usedMemory * 100.0) / maxMemory);
// If we don't know the total yet, just log at interval milestones
if (total < 0) {
if (completed % interval == 0) {
logger.trace("Progress: {} strategies completed | {}", completed, memoryInfo);
}
return;
}
// Log at interval milestones
if (completed % interval == 0) {
int percentComplete = (int) (completed * 100.0) / total;
logger.trace("Progress: {}/{} strategies completed ({}%) | {}", completed, total, percentComplete,
memoryInfo);
return;
}
// Log at completion milestones (25%, 50%, 75%, 100%)
int percentComplete = (int) (completed * 100.0) / total;
int prevPercent = completed > 1 ? (int) ((completed - 1) * 100.0) / total : 0;
if (percentComplete >= 25 && prevPercent < 25) {
logger.trace("Progress: {}/{} strategies completed (25%) | {}", completed, total, memoryInfo);
} else if (percentComplete >= 50 && prevPercent < 50) {
logger.trace("Progress: {}/{} strategies completed (50%) | {}", completed, total, memoryInfo);
} else if (percentComplete >= 75 && prevPercent < 75) {
logger.trace("Progress: {}/{} strategies completed (75%) | {}", completed, total, memoryInfo);
} else if (completed == total) {
logger.trace("Progress: {}/{} strategies completed (100%) | {}", completed, total, memoryInfo);
}
}
/**
* Sets the total number of strategies for percentage calculation. This is
* called internally by BacktestExecutor.
*
* @param total the total number of strategies
*/
void setTotalStrategies(int total) {
this.totalStrategies = total;
}
}
/**
* Configures a progress callback with the total strategies count. This allows
* percentage-based logging to work correctly for logging callbacks.
* <p>
* This method only affects logging callbacks ({@link LoggingProgressCallback}
* and {@link LoggingWithMemoryProgressCallback}). For other callback types, the
* callback is returned unchanged and the {@code totalStrategies} parameter is
* ignored.
*
* @param callback the progress callback to configure (must not be null)
* @param totalStrategies the total number of strategies
* @return the same callback instance (configured if it's a logging callback)
* @throws IllegalArgumentException if callback is null
*/
static Consumer<Integer> withTotalStrategies(Consumer<Integer> callback, int totalStrategies) {
if (callback == null) {
throw new IllegalArgumentException("callback must not be null");
}
if (callback instanceof LoggingProgressCallback) {
((LoggingProgressCallback) callback).setTotalStrategies(totalStrategies);
} else if (callback instanceof LoggingWithMemoryProgressCallback) {
((LoggingWithMemoryProgressCallback) callback).setTotalStrategies(totalStrategies);
}
// For other callback types, return unchanged (totalStrategies is ignored)
return callback;
}
}
@@ -0,0 +1,93 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.backtest;
import java.util.Objects;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Trade.TradeType;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.backtest.ExecutionModelSupport.ExecutionTarget;
import org.ta4j.core.num.Num;
/**
* Execution model that applies configurable slippage to each trade.
*
* <p>
* Buy orders are filled at a worse price ({@code +slippage}), while sell orders
* are filled at a worse price ({@code -slippage}).
* </p>
*
* @since 0.22.4
*/
public class SlippageExecutionModel implements TradeExecutionModel {
private final Num slippageRatio;
private final PriceSource priceSource;
/**
* Creates a slippage execution model based on next-bar open prices.
*
* @param slippageRatio slippage ratio (for example 0.001 for 10 bps)
* @since 0.22.4
*/
public SlippageExecutionModel(Num slippageRatio) {
this(slippageRatio, PriceSource.NEXT_OPEN);
}
/**
* Creates a slippage execution model.
*
* @param slippageRatio slippage ratio in [0,1)
* @param priceSource base price source
* @since 0.22.4
*/
public SlippageExecutionModel(Num slippageRatio, PriceSource priceSource) {
Objects.requireNonNull(slippageRatio, "slippageRatio");
Objects.requireNonNull(priceSource, "priceSource");
if (slippageRatio.isNaN() || slippageRatio.isNegative()) {
throw new IllegalArgumentException("slippageRatio must be positive or zero");
}
Num one = slippageRatio.getNumFactory().one();
if (slippageRatio.isGreaterThanOrEqual(one)) {
throw new IllegalArgumentException("slippageRatio must be less than 1");
}
this.slippageRatio = slippageRatio;
this.priceSource = priceSource;
}
/**
* @return configured slippage ratio
* @since 0.22.4
*/
public Num getSlippageRatio() {
return slippageRatio;
}
/**
* @return configured base execution price source
* @since 0.22.4
*/
public PriceSource getPriceSource() {
return priceSource;
}
@Override
public void execute(int index, TradingRecord tradingRecord, BarSeries barSeries, Num amount) {
ExecutionTarget executionTarget = ExecutionModelSupport.resolveExecutionTarget(index, barSeries, priceSource);
if (executionTarget == null) {
return;
}
TradeType tradeType = ExecutionModelSupport.nextTradeType(tradingRecord);
Num slippedPrice = applySlippage(executionTarget.price(), tradeType, slippageRatio);
tradingRecord.operate(executionTarget.index(), slippedPrice, amount);
}
private static Num applySlippage(Num price, TradeType tradeType, Num slippageRatio) {
Num one = price.getNumFactory().one();
if (tradeType == TradeType.BUY) {
return price.multipliedBy(one.plus(slippageRatio));
}
return price.multipliedBy(one.minus(slippageRatio));
}
}
@@ -0,0 +1,424 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.backtest;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.WeakHashMap;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import org.ta4j.core.ExecutionSide;
import org.ta4j.core.Position;
import org.ta4j.core.Trade;
import org.ta4j.core.Trade.TradeType;
import org.ta4j.core.TradeFill;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.backtest.ExecutionModelSupport.ExecutionTarget;
import org.ta4j.core.num.Num;
/**
* Stop-limit execution model with partial fill progression.
*
* <p>
* Strategy signals place stop-limit orders. Pending orders are evaluated on
* each bar through {@link #onBar(int, TradingRecord, BarSeries)} and can be
* filled progressively based on available bar volume participation. Partially
* filled orders are committed on expiry when the target trading record supports
* partial-exit accounting. Generated fills include execution side and bar end
* timestamps so backtest fills match live-fill metadata shape.
* </p>
*
* @since 0.22.4
*/
public class StopLimitExecutionModel implements TradeExecutionModel {
private final Num stopTriggerRatio;
private final Num limitOffsetRatio;
private final Num maxBarParticipationRate;
private final int maxBarsToFill;
private final PriceSource priceSource;
private final Map<TradingRecord, PendingOrder> pendingOrders = new WeakHashMap<>();
private final Map<TradingRecord, List<RejectedOrder>> rejectedOrders = new WeakHashMap<>();
/**
* Creates a stop-limit execution model using next-bar open as the reference
* price.
*
* @param stopTriggerRatio stop trigger ratio in [0,1)
* @param limitOffsetRatio limit offset ratio in [0,1)
* @param maxBarParticipation max per-bar fill participation in (0,1]
* @param maxBarsToFill order time-to-live in bars (>= 1)
* @since 0.22.4
*/
public StopLimitExecutionModel(Num stopTriggerRatio, Num limitOffsetRatio, Num maxBarParticipation,
int maxBarsToFill) {
this(stopTriggerRatio, limitOffsetRatio, maxBarParticipation, maxBarsToFill, PriceSource.NEXT_OPEN);
}
/**
* Creates a stop-limit execution model.
*
* @param stopTriggerRatio stop trigger ratio in [0,1)
* @param limitOffsetRatio limit offset ratio in [0,1) (must be >= stop
* ratio)
* @param maxBarParticipation max per-bar fill participation in (0,1]
* @param maxBarsToFill order time-to-live in bars (>= 1)
* @param priceSource base signal price source
* @since 0.22.4
*/
public StopLimitExecutionModel(Num stopTriggerRatio, Num limitOffsetRatio, Num maxBarParticipation,
int maxBarsToFill, PriceSource priceSource) {
validateRatio(stopTriggerRatio, "stopTriggerRatio");
validateRatio(limitOffsetRatio, "limitOffsetRatio");
validateRatio(maxBarParticipation, "maxBarParticipation");
Objects.requireNonNull(priceSource, "priceSource");
Num one = stopTriggerRatio.getNumFactory().one();
if (stopTriggerRatio.isGreaterThanOrEqual(one)) {
throw new IllegalArgumentException("stopTriggerRatio must be < 1");
}
if (limitOffsetRatio.isGreaterThanOrEqual(one)) {
throw new IllegalArgumentException("limitOffsetRatio must be < 1");
}
if (maxBarParticipation.isZero()) {
throw new IllegalArgumentException("maxBarParticipation must be > 0");
}
if (maxBarParticipation.isGreaterThan(one)) {
throw new IllegalArgumentException("maxBarParticipation must be <= 1");
}
if (limitOffsetRatio.isLessThan(stopTriggerRatio)) {
throw new IllegalArgumentException("limitOffsetRatio must be >= stopTriggerRatio");
}
if (maxBarsToFill < 1) {
throw new IllegalArgumentException("maxBarsToFill must be >= 1");
}
this.stopTriggerRatio = stopTriggerRatio;
this.limitOffsetRatio = limitOffsetRatio;
this.maxBarParticipationRate = maxBarParticipation;
this.maxBarsToFill = maxBarsToFill;
this.priceSource = priceSource;
}
/**
* Returns rejected orders for a trading record.
*
* @param tradingRecord trading record
* @return rejected orders
* @since 0.22.4
*/
public List<RejectedOrder> getRejectedOrders(TradingRecord tradingRecord) {
List<RejectedOrder> rejected = rejectedOrders.get(tradingRecord);
return rejected == null ? List.of() : List.copyOf(rejected);
}
/**
* Returns the current pending order snapshot for the trading record.
*
* @param tradingRecord trading record
* @return pending order snapshot
* @since 0.22.4
*/
public Optional<PendingOrderSnapshot> getPendingOrder(TradingRecord tradingRecord) {
PendingOrder order = pendingOrders.get(tradingRecord);
if (order == null) {
return Optional.empty();
}
return Optional.of(order.snapshot());
}
@Override
public void execute(int index, TradingRecord tradingRecord, BarSeries barSeries, Num amount) {
Objects.requireNonNull(tradingRecord, "tradingRecord");
Objects.requireNonNull(barSeries, "barSeries");
expireIfStale(index, tradingRecord);
if (amount == null || amount.isNaN() || amount.isZero() || amount.isNegative()) {
Num requestedAmount = amountOrZero(amount, barSeries);
addRejectedOrder(tradingRecord,
new RejectedOrder(index, index, ExecutionModelSupport.nextTradeType(tradingRecord), requestedAmount,
requestedAmount.getNumFactory().zero(), "Invalid requested amount"));
return;
}
Num requestedAmount = resolveRequestedAmount(tradingRecord, amount);
PendingOrder pendingOrder = pendingOrders.get(tradingRecord);
if (pendingOrder != null) {
addRejectedOrder(tradingRecord,
new RejectedOrder(index, index, pendingOrder.tradeType, requestedAmount,
requestedAmount.getNumFactory().zero(),
"Signal ignored while another stop-limit order is pending"));
return;
}
ExecutionTarget referenceTarget = ExecutionModelSupport.resolveExecutionTarget(index, barSeries, priceSource);
if (referenceTarget == null) {
addRejectedOrder(tradingRecord,
new RejectedOrder(index, index, ExecutionModelSupport.nextTradeType(tradingRecord), requestedAmount,
requestedAmount.getNumFactory().zero(),
"Unable to resolve reference bar for stop-limit order"));
return;
}
TradeType tradeType = ExecutionModelSupport.nextTradeType(tradingRecord);
Num stopPrice = toStopPrice(referenceTarget.price(), tradeType);
Num limitPrice = toLimitPrice(referenceTarget.price(), tradeType);
int activationIndex = resolveActivationIndex(referenceTarget.index());
if (activationIndex > barSeries.getEndIndex()) {
addRejectedOrder(tradingRecord, new RejectedOrder(index, index, tradeType, requestedAmount,
requestedAmount.getNumFactory().zero(), "Unable to resolve activation bar for stop-limit order"));
return;
}
pendingOrders.put(tradingRecord, new PendingOrder(index, activationIndex, tradeType, requestedAmount, stopPrice,
limitPrice, activationIndex + maxBarsToFill - 1));
}
@Override
public void onBar(int index, TradingRecord tradingRecord, BarSeries barSeries) {
PendingOrder order = pendingOrders.get(tradingRecord);
if (order == null || index < order.activationIndex) {
return;
}
Bar bar = barSeries.getBar(index);
if (!order.triggered) {
order.triggered = triggerReached(order.tradeType, bar, order.stopPrice);
}
if (order.triggered && limitReachable(order.tradeType, bar, order.limitPrice)) {
Num fillAmount = fillAmount(order.remainingAmount(), bar.getVolume());
if (fillAmount.isPositive()) {
order.recordFill(index, bar, order.limitPrice, fillAmount);
}
}
if (order.isCompletelyFilled()) {
tradingRecord.operate(order.toTrade(tradingRecord));
pendingOrders.remove(tradingRecord);
return;
}
if (index >= order.expiryIndex) {
expireOrder(index, tradingRecord, order);
}
}
@Override
public void onRunEnd(int lastProcessedIndex, TradingRecord tradingRecord) {
PendingOrder order = pendingOrders.get(tradingRecord);
if (order == null) {
return;
}
expireOrder(lastProcessedIndex, tradingRecord, order);
}
private void expireIfStale(int index, TradingRecord tradingRecord) {
PendingOrder order = pendingOrders.get(tradingRecord);
if (order == null || index <= order.expiryIndex) {
return;
}
expireOrder(index, tradingRecord, order);
}
private void expireOrder(int index, TradingRecord tradingRecord, PendingOrder order) {
if (shouldCommitPartial(order, tradingRecord)) {
tradingRecord.operate(order.toTrade(tradingRecord));
}
addRejectedOrder(tradingRecord, order.toExpiryRejection(index));
pendingOrders.remove(tradingRecord);
}
private static boolean shouldCommitPartial(PendingOrder order, TradingRecord tradingRecord) {
if (!order.hasAnyFill()) {
return false;
}
if (order.tradeType == tradingRecord.getStartingType()) {
return true;
}
return !tradingRecord.getOpenPositions().isEmpty();
}
private static void validateRatio(Num ratio, String name) {
Objects.requireNonNull(ratio, name);
if (ratio.isNaN() || ratio.isNegative()) {
throw new IllegalArgumentException(name + " must be positive or zero");
}
}
private int resolveActivationIndex(int referenceIndex) {
if (priceSource == PriceSource.CURRENT_CLOSE) {
return referenceIndex + 1;
}
return referenceIndex;
}
private Num toStopPrice(Num reference, TradeType tradeType) {
Num one = reference.getNumFactory().one();
if (tradeType == TradeType.BUY) {
return reference.multipliedBy(one.plus(stopTriggerRatio));
}
return reference.multipliedBy(one.minus(stopTriggerRatio));
}
private Num toLimitPrice(Num reference, TradeType tradeType) {
Num one = reference.getNumFactory().one();
if (tradeType == TradeType.BUY) {
return reference.multipliedBy(one.plus(limitOffsetRatio));
}
return reference.multipliedBy(one.minus(limitOffsetRatio));
}
private Num fillAmount(Num remainingAmount, Num barVolume) {
Num availableAmount = remainingAmount;
if (!Num.isNaNOrNull(barVolume)) {
if (!barVolume.isPositive()) {
return remainingAmount.getNumFactory().zero();
}
availableAmount = barVolume.multipliedBy(maxBarParticipationRate);
}
if (availableAmount.isNaN() || availableAmount.isNegativeOrZero()) {
return remainingAmount.getNumFactory().zero();
}
if (availableAmount.isGreaterThan(remainingAmount)) {
return remainingAmount;
}
return availableAmount;
}
private static boolean triggerReached(TradeType tradeType, Bar bar, Num stopPrice) {
if (tradeType == TradeType.BUY) {
return bar.getHighPrice().isGreaterThanOrEqual(stopPrice);
}
return bar.getLowPrice().isLessThanOrEqual(stopPrice);
}
private static boolean limitReachable(TradeType tradeType, Bar bar, Num limitPrice) {
if (tradeType == TradeType.BUY) {
return bar.getLowPrice().isLessThanOrEqual(limitPrice);
}
return bar.getHighPrice().isGreaterThanOrEqual(limitPrice);
}
private static Num amountOrZero(Num amount, BarSeries barSeries) {
if (amount == null || amount.isNaN()) {
return barSeries.numFactory().zero();
}
return amount;
}
private static Num resolveRequestedAmount(TradingRecord tradingRecord, Num defaultAmount) {
if (tradingRecord.isClosed()) {
return defaultAmount;
}
Position currentPosition = tradingRecord.getCurrentPosition();
if (currentPosition.isOpened() && currentPosition.getEntry() != null
&& currentPosition.getEntry().getAmount() != null && !currentPosition.getEntry().getAmount().isNaN()) {
return currentPosition.getEntry().getAmount();
}
return defaultAmount;
}
private void addRejectedOrder(TradingRecord tradingRecord, RejectedOrder rejection) {
rejectedOrders.computeIfAbsent(tradingRecord, ignored -> new ArrayList<>()).add(rejection);
}
/**
* Rejected stop-limit order metadata.
*
* @param signalIndex strategy signal index
* @param rejectionIndex bar index where rejection happened
* @param tradeType trade side
* @param requestedAmount requested amount
* @param filledAmount amount filled before rejection
* @param reason rejection reason
* @since 0.22.4
*/
public record RejectedOrder(int signalIndex, int rejectionIndex, TradeType tradeType, Num requestedAmount,
Num filledAmount, String reason) {
}
/**
* Snapshot of a pending stop-limit order.
*
* @param signalIndex strategy signal index
* @param activationIndex first index where order can execute
* @param tradeType order side
* @param requestedAmount requested amount
* @param filledAmount filled amount
* @param stopPrice stop trigger price
* @param limitPrice limit price
* @param expiryIndex last fillable bar index
* @param triggered true if stop trigger was reached
* @param fills current fills
* @since 0.22.4
*/
public record PendingOrderSnapshot(int signalIndex, int activationIndex, TradeType tradeType, Num requestedAmount,
Num filledAmount, Num stopPrice, Num limitPrice, int expiryIndex, boolean triggered,
List<TradeFill> fills) {
}
private static final class PendingOrder {
private final int signalIndex;
private final int activationIndex;
private final TradeType tradeType;
private final Num requestedAmount;
private final Num stopPrice;
private final Num limitPrice;
private final int expiryIndex;
private boolean triggered;
private Num filledAmount;
private final List<TradeFill> fills;
private PendingOrder(int signalIndex, int activationIndex, TradeType tradeType, Num requestedAmount,
Num stopPrice, Num limitPrice, int expiryIndex) {
this.signalIndex = signalIndex;
this.activationIndex = activationIndex;
this.tradeType = tradeType;
this.requestedAmount = requestedAmount;
this.stopPrice = stopPrice;
this.limitPrice = limitPrice;
this.expiryIndex = expiryIndex;
this.triggered = false;
this.filledAmount = requestedAmount.getNumFactory().zero();
this.fills = new ArrayList<>();
}
private Num remainingAmount() {
return requestedAmount.minus(filledAmount);
}
private void recordFill(int index, Bar bar, Num price, Num amount) {
fills.add(new TradeFill(index, bar.getEndTime(), price, amount, sideOf(tradeType)));
filledAmount = filledAmount.plus(amount);
}
private boolean isCompletelyFilled() {
return !requestedAmount.minus(filledAmount).isPositive();
}
private boolean hasAnyFill() {
return filledAmount.isPositive();
}
private Trade toTrade(TradingRecord tradingRecord) {
return Trade.fromFills(tradeType, fills, tradingRecord.getTransactionCostModel());
}
private RejectedOrder toExpiryRejection(int rejectionIndex) {
return new RejectedOrder(signalIndex, rejectionIndex, tradeType, requestedAmount, filledAmount,
"Stop-limit order expired before filling requested amount");
}
private PendingOrderSnapshot snapshot() {
return new PendingOrderSnapshot(signalIndex, activationIndex, tradeType, requestedAmount, filledAmount,
stopPrice, limitPrice, expiryIndex, triggered, List.copyOf(fills));
}
private static ExecutionSide sideOf(TradeType tradeType) {
if (tradeType == TradeType.BUY) {
return ExecutionSide.BUY;
}
return ExecutionSide.SELL;
}
}
}
@@ -0,0 +1,197 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.backtest;
import java.time.Duration;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.ta4j.core.AnalysisCriterion;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Strategy;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.num.Num;
import org.ta4j.core.reports.TradingStatement;
import org.ta4j.core.walkforward.WalkForwardConfig;
import org.ta4j.core.walkforward.WalkForwardRuntimeReport;
import org.ta4j.core.walkforward.WalkForwardSplit;
/**
* Wraps walk-forward execution output for one strategy.
*
* @param barSeries series used for execution
* @param strategy evaluated strategy
* @param config walk-forward configuration
* @param folds fold-level execution results
* @param runtimeReport aggregate runtime report across folds
* @since 0.22.4
*/
public record StrategyWalkForwardExecutionResult(BarSeries barSeries, Strategy strategy, WalkForwardConfig config,
List<FoldResult> folds,
WalkForwardRuntimeReport runtimeReport) implements TradingStatementExecutionResult<WalkForwardRuntimeReport> {
/**
* Creates a validated result.
*
* @param barSeries series used for execution
* @param strategy evaluated strategy
* @param config walk-forward configuration
* @param folds fold-level execution results
* @param runtimeReport aggregate runtime report across folds
* @since 0.22.4
*/
public StrategyWalkForwardExecutionResult {
barSeries = Objects.requireNonNull(barSeries, "barSeries");
strategy = Objects.requireNonNull(strategy, "strategy");
config = Objects.requireNonNull(config, "config");
folds = List.copyOf(Objects.requireNonNull(folds, "folds"));
runtimeReport = Objects.requireNonNull(runtimeReport, "runtimeReport");
}
/**
* @return the optional holdout fold execution result
* @since 0.22.4
*/
public Optional<FoldResult> holdoutFold() {
return folds.stream().filter(fold -> fold.split().holdout()).findFirst();
}
/**
* @return all non-holdout folds
* @since 0.22.4
*/
public List<FoldResult> inSampleFolds() {
return folds.stream().filter(fold -> !fold.split().holdout()).toList();
}
/**
* @return all holdout folds
* @since 0.22.4
*/
public List<FoldResult> outOfSampleFolds() {
return folds.stream().filter(fold -> fold.split().holdout()).toList();
}
/**
* @return fold trading statements in execution order
* @since 0.22.4
*/
@Override
public List<TradingStatement> tradingStatements() {
return folds.stream().map(FoldResult::tradingStatement).toList();
}
/**
* @return fold trading records in execution order
* @since 0.22.4
*/
@Override
public List<TradingRecord> tradingRecords() {
return TradingStatementExecutionResult.super.tradingRecords();
}
/**
* Evaluates one criterion for every fold.
*
* @param criterion analysis criterion
* @return criterion values in fold execution order
* @since 0.22.4
*/
@Override
public List<Num> criterionValues(AnalysisCriterion criterion) {
return TradingStatementExecutionResult.super.criterionValues(criterion);
}
/**
* Evaluates one criterion for every in-sample fold.
*
* @param criterion analysis criterion
* @return criterion values in fold execution order
* @since 0.22.4
*/
public List<Num> inSampleCriterionValues(AnalysisCriterion criterion) {
return criterionValuesFor(criterion, inSampleFolds());
}
/**
* Evaluates one criterion for every out-of-sample fold.
*
* @param criterion analysis criterion
* @return criterion values in fold execution order
* @since 0.22.4
*/
public List<Num> outOfSampleCriterionValues(AnalysisCriterion criterion) {
return criterionValuesFor(criterion, outOfSampleFolds());
}
/**
* Evaluates one criterion for the holdout fold when present.
*
* @param criterion analysis criterion
* @return optional criterion value for the holdout fold
* @since 0.22.4
*/
public Optional<Num> holdoutCriterionValue(AnalysisCriterion criterion) {
Objects.requireNonNull(criterion, "criterion");
return holdoutFold().map(fold -> criterion.calculate(barSeries, fold.tradingRecord()));
}
/**
* Evaluates one criterion and returns values keyed by fold id.
*
* @param criterion analysis criterion
* @return ordered fold-id to criterion value map
* @since 0.22.4
*/
public Map<String, Num> criterionValuesByFold(AnalysisCriterion criterion) {
Objects.requireNonNull(criterion, "criterion");
Map<String, Num> values = new LinkedHashMap<>();
for (FoldResult fold : folds) {
values.put(fold.split().foldId(), criterion.calculate(barSeries, fold.tradingRecord()));
}
return Collections.unmodifiableMap(values);
}
private List<Num> criterionValuesFor(AnalysisCriterion criterion, List<FoldResult> selectedFolds) {
Objects.requireNonNull(criterion, "criterion");
return selectedFolds.stream().map(fold -> criterion.calculate(barSeries, fold.tradingRecord())).toList();
}
/**
* Fold-level walk-forward execution output.
*
* @param split fold boundary metadata
* @param tradingRecord generated trading record for the fold's test window
* @param tradingStatement generated trading statement for the fold's test
* window
* @param runtime fold runtime duration
* @since 0.22.4
*/
public record FoldResult(WalkForwardSplit split, TradingRecord tradingRecord, TradingStatement tradingStatement,
Duration runtime) {
/**
* Creates a validated fold result.
*
* @param split fold boundary metadata
* @param tradingRecord generated trading record for the fold
* @param tradingStatement generated trading statement for the fold
* @param runtime fold runtime duration
* @since 0.22.4
*/
public FoldResult {
split = Objects.requireNonNull(split, "split");
tradingRecord = Objects.requireNonNull(tradingRecord, "tradingRecord");
tradingStatement = Objects.requireNonNull(tradingStatement, "tradingStatement");
runtime = Objects.requireNonNull(runtime, "runtime");
if (runtime.isNegative()) {
throw new IllegalArgumentException("runtime must be >= 0");
}
}
}
}
@@ -0,0 +1,229 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.backtest;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Strategy;
import org.ta4j.core.Trade;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.analysis.cost.ZeroCostModel;
import org.ta4j.core.num.Num;
import org.ta4j.core.reports.TradingStatement;
import org.ta4j.core.reports.TradingStatementGenerator;
import org.ta4j.core.walkforward.AnchoredExpandingWalkForwardSplitter;
import org.ta4j.core.walkforward.WalkForwardConfig;
import org.ta4j.core.walkforward.WalkForwardRuntimeReport;
import org.ta4j.core.walkforward.WalkForwardSplit;
import org.ta4j.core.walkforward.WalkForwardSplitter;
/**
* Executes one strategy in walk-forward mode with a backtest-symmetric API.
*
* @since 0.22.4
*/
public class StrategyWalkForwardExecutor {
private final BarSeriesManager seriesManager;
private final TradingStatementGenerator tradingStatementGenerator;
private final WalkForwardSplitter splitter;
/**
* Creates an executor with default cost and trade execution models.
*
* @param series input bar series
* @since 0.22.4
*/
public StrategyWalkForwardExecutor(BarSeries series) {
this(series, new TradingStatementGenerator(), new ZeroCostModel(), new ZeroCostModel(),
new TradeOnNextOpenModel());
}
/**
* Creates an executor with explicit cost and trade execution models.
*
* @param series input bar series
* @param transactionCostModel transaction cost model
* @param holdingCostModel holding cost model
* @param tradeExecutionModel trade execution model
* @since 0.22.4
*/
public StrategyWalkForwardExecutor(BarSeries series, CostModel transactionCostModel, CostModel holdingCostModel,
TradeExecutionModel tradeExecutionModel) {
this(series, new TradingStatementGenerator(), transactionCostModel, holdingCostModel, tradeExecutionModel);
}
/**
* Creates an executor with explicit statement generator.
*
* @param series input bar series
* @param tradingStatementGenerator trading statement generator
* @since 0.22.4
*/
public StrategyWalkForwardExecutor(BarSeries series, TradingStatementGenerator tradingStatementGenerator) {
this(series, tradingStatementGenerator, new ZeroCostModel(), new ZeroCostModel(), new TradeOnNextOpenModel());
}
/**
* Creates an executor with explicit statement generator, cost models, and trade
* execution model.
*
* @param series input bar series
* @param tradingStatementGenerator trading statement generator
* @param transactionCostModel transaction cost model
* @param holdingCostModel holding cost model
* @param tradeExecutionModel trade execution model
* @since 0.22.4
*/
public StrategyWalkForwardExecutor(BarSeries series, TradingStatementGenerator tradingStatementGenerator,
CostModel transactionCostModel, CostModel holdingCostModel, TradeExecutionModel tradeExecutionModel) {
this(new BarSeriesManager(series, transactionCostModel, holdingCostModel, tradeExecutionModel),
tradingStatementGenerator, new AnchoredExpandingWalkForwardSplitter());
}
StrategyWalkForwardExecutor(BarSeriesManager seriesManager, TradingStatementGenerator tradingStatementGenerator,
WalkForwardSplitter splitter) {
this.seriesManager = Objects.requireNonNull(seriesManager, "seriesManager");
this.tradingStatementGenerator = Objects.requireNonNull(tradingStatementGenerator, "tradingStatementGenerator");
this.splitter = Objects.requireNonNull(splitter, "splitter");
}
/**
* Executes walk-forward testing using strategy starting trade type and unit
* amount.
*
* @param strategy strategy to execute
* @param config walk-forward configuration
* @return execution result
* @since 0.22.4
*/
public StrategyWalkForwardExecutionResult execute(Strategy strategy, WalkForwardConfig config) {
Objects.requireNonNull(strategy, "strategy");
Num amount = seriesManager.getBarSeries().numFactory().one();
return execute(strategy, strategy.getStartingType(), amount, config, null);
}
/**
* Executes walk-forward testing with explicit entry trade type and unit amount.
*
* @param strategy strategy to execute
* @param tradeType trade type used to open positions
* @param config walk-forward configuration
* @return execution result
* @since 0.22.4
*/
public StrategyWalkForwardExecutionResult execute(Strategy strategy, Trade.TradeType tradeType,
WalkForwardConfig config) {
Num amount = seriesManager.getBarSeries().numFactory().one();
return execute(strategy, tradeType, amount, config, null);
}
/**
* Executes walk-forward testing with explicit amount.
*
* @param strategy strategy to execute
* @param tradeType trade type used to open positions
* @param amount amount used for entries/exits
* @param config walk-forward configuration
* @return execution result
* @since 0.22.4
*/
public StrategyWalkForwardExecutionResult execute(Strategy strategy, Trade.TradeType tradeType, Num amount,
WalkForwardConfig config) {
return execute(strategy, tradeType, amount, config, null);
}
/**
* Executes walk-forward testing with optional per-fold progress callback.
*
* @param strategy strategy to execute
* @param tradeType trade type used to open positions
* @param amount amount used for entries/exits
* @param config walk-forward configuration
* @param progressCallback optional callback receiving completed fold count
* @return execution result
* @since 0.22.4
*/
public StrategyWalkForwardExecutionResult execute(Strategy strategy, Trade.TradeType tradeType, Num amount,
WalkForwardConfig config, Consumer<Integer> progressCallback) {
Objects.requireNonNull(strategy, "strategy");
Objects.requireNonNull(tradeType, "tradeType");
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(config, "config");
BarSeries series = seriesManager.getBarSeries();
List<WalkForwardSplit> splits = splitter.split(series, config);
if (splits.isEmpty()) {
return new StrategyWalkForwardExecutionResult(series, strategy, config, List.of(),
WalkForwardRuntimeReport.empty());
}
Consumer<Integer> effectiveCallback = progressCallback == null ? ProgressCompletion.noOp() : progressCallback;
List<StrategyWalkForwardExecutionResult.FoldResult> foldResults = new ArrayList<>(splits.size());
List<WalkForwardRuntimeReport.FoldRuntime> foldRuntimes = new ArrayList<>(splits.size());
long overallStart = System.nanoTime();
int completed = 0;
for (WalkForwardSplit split : splits) {
long foldStart = System.nanoTime();
TradingRecord foldRecord = seriesManager.run(strategy, tradeType, amount, split.testStart(),
split.testEnd());
TradingStatement statement = tradingStatementGenerator.generate(strategy, foldRecord, series);
Duration foldRuntime = Duration.ofNanos(System.nanoTime() - foldStart);
foldResults
.add(new StrategyWalkForwardExecutionResult.FoldResult(split, foldRecord, statement, foldRuntime));
foldRuntimes
.add(new WalkForwardRuntimeReport.FoldRuntime(split.foldId(), foldRuntime, split.testBarCount()));
completed++;
effectiveCallback.accept(completed);
}
Duration overallRuntime = Duration.ofNanos(System.nanoTime() - overallStart);
WalkForwardRuntimeReport runtimeReport = buildRuntimeReport(foldRuntimes, overallRuntime);
return new StrategyWalkForwardExecutionResult(series, strategy, config, foldResults, runtimeReport);
}
private WalkForwardRuntimeReport buildRuntimeReport(List<WalkForwardRuntimeReport.FoldRuntime> foldRuntimes,
Duration overallRuntime) {
if (foldRuntimes.isEmpty()) {
return WalkForwardRuntimeReport.empty();
}
List<Duration> durations = new ArrayList<>(foldRuntimes.size());
for (WalkForwardRuntimeReport.FoldRuntime foldRuntime : foldRuntimes) {
durations.add(foldRuntime.runtime());
}
Duration min = Collections.min(durations);
Duration max = Collections.max(durations);
long totalNanos = 0L;
for (Duration duration : durations) {
totalNanos += duration.toNanos();
}
Duration average = Duration.ofNanos(totalNanos / durations.size());
List<Duration> sorted = new ArrayList<>(durations);
sorted.sort(Comparator.naturalOrder());
int middle = sorted.size() / 2;
Duration median;
if (sorted.size() % 2 == 0) {
long medianNanos = (sorted.get(middle - 1).toNanos() + sorted.get(middle).toNanos()) / 2;
median = Duration.ofNanos(medianNanos);
} else {
median = sorted.get(middle);
}
return new WalkForwardRuntimeReport(overallRuntime, min, max, average, median, foldRuntimes);
}
}
@@ -0,0 +1,88 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.backtest;
import org.ta4j.core.BarSeries;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.num.Num;
/**
* An execution model for {@link BarSeriesManager} objects.
*
* Used for backtesting. Instructs {@link BarSeriesManager} on how to execute
* trades.
*
* <p>
* Selection guidance:
* </p>
* <ul>
* <li>Use {@link TradeOnNextOpenModel} for conservative signal-at-close,
* fill-next-open simulation.</li>
* <li>Use {@link TradeOnCurrentCloseModel} when your strategy intentionally
* fills on bar close.</li>
* <li>Use {@link SlippageExecutionModel} when you need directional price-impact
* assumptions.</li>
* <li>Use {@link StopLimitExecutionModel} when pending-order lifecycle and
* partial fills matter.</li>
* </ul>
*/
public interface TradeExecutionModel {
/**
* Common price-source contract for execution models.
*
* @since 0.22.4
*/
enum PriceSource {
/** Use the current bar close price. */
CURRENT_CLOSE,
/** Use the next bar open price. */
NEXT_OPEN
}
/**
* Processes per-bar execution state before strategy signals are evaluated.
*
* <p>
* Implementations can use this hook to progress pending orders (for example,
* stop/limit orders with partial fills) even when no new strategy signal is
* emitted on the current bar.
* </p>
*
* @param index current bar index
* @param tradingRecord trading record to mutate
* @param barSeries bar series
* @since 0.22.4
*/
default void onBar(int index, TradingRecord tradingRecord, BarSeries barSeries) {
// Default no-op for immediate execution models.
}
/**
* Finalizes model state when a {@link BarSeriesManager} run ends.
*
* <p>
* Implementations can use this hook to expire or flush pending orders that
* would otherwise be stranded when no more bars will be processed.
* </p>
*
* @param lastProcessedIndex last bar index examined during the run
* @param tradingRecord trading record to mutate
* @since 0.22.4
*/
default void onRunEnd(int lastProcessedIndex, TradingRecord tradingRecord) {
// Default no-op for immediate execution models.
}
/**
* Executes a trade in the given {@code tradingRecord}.
*
* @param index the trade index from {@code barSeries}
* @param tradingRecord the trading record to place the trade
* @param barSeries the bar series
* @param amount the trade amount
*/
void execute(int index, TradingRecord tradingRecord, BarSeries barSeries, Num amount);
}
@@ -0,0 +1,31 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.backtest;
import org.ta4j.core.BarSeries;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.backtest.ExecutionModelSupport.ExecutionTarget;
import org.ta4j.core.num.Num;
/**
* An execution model for {@link BarSeriesManager} objects.
*
* Executes trades on the current bar being considered using the closing price.
*
* This is used for strategies that explicitly trade just before the bar closes
* at index `t`, in order to execute new or close existing trades as close as
* possible to the closing price.
*/
public class TradeOnCurrentCloseModel implements TradeExecutionModel {
@Override
public void execute(int index, TradingRecord tradingRecord, BarSeries barSeries, Num amount) {
ExecutionTarget executionTarget = ExecutionModelSupport.resolveExecutionTarget(index, barSeries,
TradeExecutionModel.PriceSource.CURRENT_CLOSE);
if (executionTarget != null) {
tradingRecord.operate(executionTarget.index(), executionTarget.price(), amount);
}
}
}
@@ -0,0 +1,31 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.backtest;
import org.ta4j.core.BarSeries;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.backtest.ExecutionModelSupport.ExecutionTarget;
import org.ta4j.core.num.Num;
/**
* An execution model for {@link BarSeriesManager} objects.
*
* Executes trades on the next bar at the open price.
*
* This is used for strategies that explicitly trade just after a new bar opens
* at bar index `t + 1`, in order to execute new or close existing trades as
* close as possible to the opening price.
*/
public class TradeOnNextOpenModel implements TradeExecutionModel {
@Override
public void execute(int index, TradingRecord tradingRecord, BarSeries barSeries, Num amount) {
ExecutionTarget executionTarget = ExecutionModelSupport.resolveExecutionTarget(index, barSeries,
TradeExecutionModel.PriceSource.NEXT_OPEN);
if (executionTarget != null) {
tradingRecord.operate(executionTarget.index(), executionTarget.price(), amount);
}
}
}
@@ -0,0 +1,653 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.backtest;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.ta4j.core.AnalysisCriterion;
import org.ta4j.core.BarSeries;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.analysis.WeightedValue;
import org.ta4j.core.num.DoubleNumFactory;
import org.ta4j.core.num.NaN;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
import org.ta4j.core.reports.TradingStatement;
/**
* Shared contract for execution results that expose statement-level outputs.
*
* <p>
* This contract provides common statement/criterion utilities for backtest and
* strategy walk-forward result models.
* </p>
*
* @param <R> runtime report type
* @since 0.22.4
*/
public interface TradingStatementExecutionResult<R> {
/**
* @return bar series used to produce this result
* @since 0.22.4
*/
BarSeries barSeries();
/**
* @return ordered trading statements produced by this result
* @since 0.22.4
*/
List<TradingStatement> tradingStatements();
/**
* @return runtime report for this result
* @since 0.22.4
*/
R runtimeReport();
/**
* Returns trading records in statement order.
*
* @return ordered trading records
* @since 0.22.4
*/
default List<TradingRecord> tradingRecords() {
List<TradingRecord> records = new ArrayList<>(tradingStatements().size());
for (TradingStatement statement : tradingStatements()) {
records.add(statement.getTradingRecord());
}
return Collections.unmodifiableList(records);
}
/**
* Evaluates one criterion over all statement trading records in statement
* order.
*
* @param criterion analysis criterion
* @return criterion values in statement order
* @since 0.22.4
*/
default List<Num> criterionValues(AnalysisCriterion criterion) {
Objects.requireNonNull(criterion, "criterion");
List<Num> values = new ArrayList<>(tradingStatements().size());
for (TradingStatement statement : tradingStatements()) {
TradingRecord tradingRecord = statement.getTradingRecord();
Num value = tradingRecord == null ? NaN.NaN : criterion.calculate(barSeries(), tradingRecord);
values.add(value);
}
return Collections.unmodifiableList(values);
}
/**
* Evaluates one criterion and returns values keyed by statement index.
*
* @param criterion analysis criterion
* @return ordered statement-index to criterion value map
* @since 0.22.4
*/
default Map<Integer, Num> criterionValuesByIndex(AnalysisCriterion criterion) {
Objects.requireNonNull(criterion, "criterion");
Map<Integer, Num> values = new LinkedHashMap<>();
List<TradingStatement> statements = tradingStatements();
for (int index = 0; index < statements.size(); index++) {
TradingRecord tradingRecord = statements.get(index).getTradingRecord();
Num value = tradingRecord == null ? NaN.NaN : criterion.calculate(barSeries(), tradingRecord);
values.put(index, value);
}
return Collections.unmodifiableMap(values);
}
/**
* Ranks all statements using a weighted, normalized criterion profile.
*
* @param profile ranking profile
* @return ranked statement rows sorted by composite score descending
* @since 0.22.4
*/
default List<RankedTradingStatement> rankTradingStatements(RankingProfile profile) {
Objects.requireNonNull(profile, "profile");
List<TradingStatement> statements = tradingStatements();
if (statements.isEmpty()) {
return List.of();
}
NumFactory numFactory = barSeries().numFactory();
List<WeightedCriterion> weightedCriteria = profile.criteria();
int criterionCount = weightedCriteria.size();
List<WeightedValue<AnalysisCriterion>> normalizedWeightedCriteria = normalizeCriteria(weightedCriteria,
numFactory);
AnalysisCriterion[] criteria = new AnalysisCriterion[criterionCount];
for (int i = 0; i < criterionCount; i++) {
criteria[i] = normalizedWeightedCriteria.get(i).value();
}
int statementCount = statements.size();
Num[][] rawValuesByCriterion = new Num[criterionCount][statementCount];
for (int statementIndex = 0; statementIndex < statementCount; statementIndex++) {
TradingRecord tradingRecord = statements.get(statementIndex).getTradingRecord();
for (int criterionIndex = 0; criterionIndex < criterionCount; criterionIndex++) {
AnalysisCriterion criterion = criteria[criterionIndex];
rawValuesByCriterion[criterionIndex][statementIndex] = tradingRecord == null ? NaN.NaN
: criterion.calculate(barSeries(), tradingRecord);
}
}
Num[] bestValuesByCriterion = new Num[criterionCount];
Num[] worstValuesByCriterion = new Num[criterionCount];
for (int criterionIndex = 0; criterionIndex < criterionCount; criterionIndex++) {
Num[] pair = findBestWorst(rawValuesByCriterion[criterionIndex], criteria[criterionIndex]);
bestValuesByCriterion[criterionIndex] = pair[0];
worstValuesByCriterion[criterionIndex] = pair[1];
}
List<RankedTradingStatement> rankedStatements = new ArrayList<>(statementCount);
for (int statementIndex = 0; statementIndex < statementCount; statementIndex++) {
TradingStatement statement = statements.get(statementIndex);
Map<AnalysisCriterion, Num> rawScores = new LinkedHashMap<>();
Map<AnalysisCriterion, Num> normalizedScores = new LinkedHashMap<>();
Num weightedScore = numFactory.zero();
Num activeWeight = numFactory.zero();
boolean excluded = false;
for (int criterionIndex = 0; criterionIndex < criterionCount; criterionIndex++) {
AnalysisCriterion criterion = criteria[criterionIndex];
Num weight = normalizedWeightedCriteria.get(criterionIndex).weight();
Num rawValue = rawValuesByCriterion[criterionIndex][statementIndex];
rawScores.put(criterion, rawValue);
Num normalizedValue = resolveNormalizedScore(rawValue, criterion, bestValuesByCriterion[criterionIndex],
worstValuesByCriterion[criterionIndex], profile.normalizer(), numFactory);
Num effectiveScore = normalizedValue;
if (Num.isNaNOrNull(normalizedValue)) {
if (profile.missingValuePolicy() == MissingValuePolicy.EXCLUDE_STATEMENT) {
excluded = true;
break;
}
if (profile.missingValuePolicy() == MissingValuePolicy.RENORMALIZE_WEIGHTS) {
normalizedScores.put(criterion, NaN.NaN);
continue;
}
effectiveScore = numFactory.zero();
}
normalizedScores.put(criterion, effectiveScore);
weightedScore = weightedScore.plus(weight.multipliedBy(effectiveScore));
activeWeight = activeWeight.plus(weight);
}
if (excluded) {
continue;
}
Num compositeScore = weightedScore;
if (profile.missingValuePolicy() == MissingValuePolicy.RENORMALIZE_WEIGHTS) {
compositeScore = activeWeight.isZero() ? numFactory.zero() : weightedScore.dividedBy(activeWeight);
}
rankedStatements
.add(new RankedTradingStatement(statement, compositeScore, Collections.unmodifiableMap(rawScores),
Collections.unmodifiableMap(normalizedScores), statementIndex));
}
rankedStatements.sort(Comparator
.comparing(RankedTradingStatement::compositeScore, TradingStatementExecutionResult::compareScores)
.reversed()
.thenComparingInt(RankedTradingStatement::originalIndex));
return Collections.unmodifiableList(rankedStatements);
}
/**
* Ranks all statements using weighted criteria with the default normalizer and
* missing-value policy.
*
* <p>
* This is the shortest path when callers already know the criteria and weights
* they want to combine and do not need a reusable {@link RankingProfile}
* instance.
* </p>
*
* @param criteria weighted criteria to normalize and combine
* @return ranked statement rows sorted by composite score descending
* @since 0.22.4
*/
default List<RankedTradingStatement> rankTradingStatements(WeightedCriterion... criteria) {
return rankTradingStatements(RankingProfile.weighted(criteria));
}
/**
* Returns top-ranked statements for the supplied weighted profile.
*
* @param limit maximum number of statements to return
* @param profile weighted ranking profile
* @return top-ranked statements
* @since 0.22.4
*/
default List<TradingStatement> topTradingStatements(int limit, RankingProfile profile) {
if (limit < 0) {
throw new IllegalArgumentException("limit must not be negative");
}
if (limit == 0) {
return List.of();
}
List<RankedTradingStatement> ranked = rankTradingStatements(profile);
int effectiveLimit = Math.min(limit, ranked.size());
List<TradingStatement> topStatements = new ArrayList<>(effectiveLimit);
for (int i = 0; i < effectiveLimit; i++) {
topStatements.add(ranked.get(i).statement());
}
return Collections.unmodifiableList(topStatements);
}
/**
* Returns top-ranked statements for the supplied weighted criteria using the
* default normalizer and missing-value policy.
*
* @param limit maximum number of statements to return
* @param criteria weighted criteria to normalize and combine
* @return top-ranked statements
* @since 0.22.4
*/
default List<TradingStatement> topTradingStatements(int limit, WeightedCriterion... criteria) {
return topTradingStatements(limit, RankingProfile.weighted(criteria));
}
/**
* Weighted criterion entry for composite ranking.
*
* @param criterion criterion to evaluate
* @param multiplier arbitrary non-negative multiplier
* @since 0.22.4
*/
record WeightedCriterion(AnalysisCriterion criterion, Num multiplier) {
/**
* Creates a validated weighted criterion.
*
* @param criterion criterion to evaluate
* @param multiplier arbitrary non-negative multiplier
* @since 0.22.4
*/
public WeightedCriterion {
Objects.requireNonNull(criterion, "criterion");
Objects.requireNonNull(multiplier, "multiplier");
if (multiplier.isNaN()) {
throw new IllegalArgumentException("multiplier must be finite");
}
if (Double.isInfinite(multiplier.doubleValue())) {
throw new IllegalArgumentException("multiplier must be finite");
}
if (multiplier.isNegative()) {
throw new IllegalArgumentException("multiplier must be >= 0");
}
}
/**
* Creates an equally weighted criterion.
*
* @param criterion criterion to evaluate
* @return weighted criterion with multiplier {@code 1}
* @since 0.22.4
*/
public static WeightedCriterion of(AnalysisCriterion criterion) {
return new WeightedCriterion(criterion, DoubleNumFactory.getInstance().one());
}
/**
* Creates a weighted criterion from an explicit {@link Num} multiplier.
*
* @param criterion criterion to evaluate
* @param multiplier arbitrary non-negative multiplier
* @return weighted criterion
* @since 0.22.4
*/
public static WeightedCriterion of(AnalysisCriterion criterion, Num multiplier) {
return new WeightedCriterion(criterion, multiplier);
}
/**
* Creates a weighted criterion from a primitive multiplier.
*
* <p>
* Primitive multipliers are stored as {@link org.ta4j.core.num.DoubleNum}
* values and normalized into the result series factory during ranking.
* </p>
*
* @param criterion criterion to evaluate
* @param multiplier arbitrary non-negative multiplier
* @return weighted criterion
* @since 0.22.4
*/
public static WeightedCriterion of(AnalysisCriterion criterion, double multiplier) {
return new WeightedCriterion(criterion, DoubleNumFactory.getInstance().numOf(multiplier));
}
}
/**
* Criterion normalization strategy used by weighted ranking.
*
* @since 0.22.4
*/
@FunctionalInterface
interface CriterionNormalizer {
/**
* Normalizes a raw criterion value using criterion-specific best/worst values.
*
* @param criterion criterion being normalized
* @param rawValue raw criterion value for one statement
* @param bestValue best observed finite value across statements for this
* criterion
* @param worstValue worst observed finite value across statements for this
* criterion
* @param numFactory target num factory
* @return normalized value (expected in {@code [0,1]}); NaN to indicate
* missing/unavailable normalization
* @since 0.22.4
*/
Num normalize(AnalysisCriterion criterion, Num rawValue, Num bestValue, Num worstValue, NumFactory numFactory);
}
/**
* Direction-aware min-max normalizer.
*
* <p>
* Best observed value maps to {@code 1}, worst observed value maps to
* {@code 0}, and intermediate values are scaled linearly.
* </p>
*
* @since 0.22.4
*/
final class DirectionAwareMinMaxNormalizer implements CriterionNormalizer {
/**
* Shared singleton instance.
*
* @since 0.22.4
*/
public static final DirectionAwareMinMaxNormalizer INSTANCE = new DirectionAwareMinMaxNormalizer();
private DirectionAwareMinMaxNormalizer() {
}
@Override
public Num normalize(AnalysisCriterion criterion, Num rawValue, Num bestValue, Num worstValue,
NumFactory numFactory) {
Objects.requireNonNull(criterion, "criterion");
Objects.requireNonNull(rawValue, "rawValue");
Objects.requireNonNull(bestValue, "bestValue");
Objects.requireNonNull(worstValue, "worstValue");
Objects.requireNonNull(numFactory, "numFactory");
Num normalizedRaw = normalizeToFactory(rawValue, numFactory);
Num normalizedBest = normalizeToFactory(bestValue, numFactory);
Num normalizedWorst = normalizeToFactory(worstValue, numFactory);
if (Num.isNaNOrNull(normalizedRaw) || Num.isNaNOrNull(normalizedBest) || Num.isNaNOrNull(normalizedWorst)) {
return NaN.NaN;
}
if (normalizedBest.isEqual(normalizedWorst)) {
return numFactory.one();
}
if (normalizedBest.isGreaterThan(normalizedWorst)) {
Num denominator = normalizedBest.minus(normalizedWorst);
if (denominator.isZero()) {
return numFactory.one();
}
return normalizedRaw.minus(normalizedWorst).dividedBy(denominator);
}
Num denominator = normalizedWorst.minus(normalizedBest);
if (denominator.isZero()) {
return numFactory.one();
}
return normalizedWorst.minus(normalizedRaw).dividedBy(denominator);
}
}
/**
* Missing-value behavior for weighted ranking.
*
* @since 0.22.4
*/
enum MissingValuePolicy {
/** Treat missing criterion values as normalized score 0. */
WORST_SCORE,
/** Exclude missing criteria from statement-level weight sum. */
RENORMALIZE_WEIGHTS,
/** Exclude statements with any missing criterion value. */
EXCLUDE_STATEMENT
}
/**
* Weighted ranking configuration.
*
* @param criteria criterion multipliers
* @param normalizer criterion normalizer (defaults to
* {@link DirectionAwareMinMaxNormalizer#INSTANCE})
* @param missingValuePolicy missing-value handling policy (defaults to
* {@link MissingValuePolicy#WORST_SCORE})
* @since 0.22.4
*/
record RankingProfile(List<WeightedCriterion> criteria, CriterionNormalizer normalizer,
MissingValuePolicy missingValuePolicy) {
/**
* Creates a validated ranking profile.
*
* @param criteria criterion multipliers
* @param normalizer criterion normalizer
* @param missingValuePolicy missing-value handling policy
* @since 0.22.4
*/
public RankingProfile {
criteria = List.copyOf(Objects.requireNonNull(criteria, "criteria"));
if (criteria.isEmpty()) {
throw new IllegalArgumentException("criteria must not be empty");
}
normalizer = normalizer == null ? DirectionAwareMinMaxNormalizer.INSTANCE : normalizer;
missingValuePolicy = missingValuePolicy == null ? MissingValuePolicy.WORST_SCORE : missingValuePolicy;
Set<AnalysisCriterion> uniqueCriteria = new LinkedHashSet<>();
for (WeightedCriterion weightedCriterion : criteria) {
Objects.requireNonNull(weightedCriterion, "criteria must not contain null entries");
if (!uniqueCriteria.add(weightedCriterion.criterion())) {
throw new IllegalArgumentException("criteria must not contain duplicates");
}
}
}
/**
* Creates a profile with default normalizer and missing-value policy.
*
* @param criteria criterion multipliers
* @return ranking profile
* @since 0.22.4
*/
public static RankingProfile of(List<WeightedCriterion> criteria) {
return weighted(criteria);
}
/**
* Creates a profile with default normalizer and missing-value policy.
*
* <p>
* This named factory is the main entry point for callers who want weighted,
* normalized ranking without choosing custom normalization behavior.
* </p>
*
* @param criteria criterion multipliers
* @return ranking profile
* @since 0.22.4
*/
public static RankingProfile weighted(List<WeightedCriterion> criteria) {
return new RankingProfile(criteria, DirectionAwareMinMaxNormalizer.INSTANCE,
MissingValuePolicy.WORST_SCORE);
}
/**
* Creates a profile with default normalizer and missing-value policy.
*
* @param criteria criterion multipliers
* @return ranking profile
* @since 0.22.4
*/
public static RankingProfile of(WeightedCriterion... criteria) {
Objects.requireNonNull(criteria, "criteria");
return weighted(criteria);
}
/**
* Creates a profile with default normalizer and missing-value policy.
*
* @param criteria criterion multipliers
* @return ranking profile
* @since 0.22.4
*/
public static RankingProfile weighted(WeightedCriterion... criteria) {
Objects.requireNonNull(criteria, "criteria");
return weighted(List.of(criteria));
}
}
/**
* Ranked statement row with composite and per-criterion details.
*
* @param statement trading statement
* @param compositeScore weighted normalized composite score
* @param rawScores raw criterion scores by criterion
* @param normalizedScores normalized criterion scores by criterion
* @param originalIndex original statement index before ranking
* @since 0.22.4
*/
record RankedTradingStatement(TradingStatement statement, Num compositeScore, Map<AnalysisCriterion, Num> rawScores,
Map<AnalysisCriterion, Num> normalizedScores, int originalIndex) {
/**
* Creates a validated ranked statement row.
*
* @param statement trading statement
* @param compositeScore weighted normalized composite score
* @param rawScores raw criterion scores by criterion
* @param normalizedScores normalized criterion scores by criterion
* @param originalIndex original statement index before ranking
* @since 0.22.4
*/
public RankedTradingStatement {
Objects.requireNonNull(statement, "statement");
Objects.requireNonNull(compositeScore, "compositeScore");
rawScores = Collections
.unmodifiableMap(new LinkedHashMap<>(Objects.requireNonNull(rawScores, "rawScores")));
normalizedScores = Collections
.unmodifiableMap(new LinkedHashMap<>(Objects.requireNonNull(normalizedScores, "normalizedScores")));
if (originalIndex < 0) {
throw new IllegalArgumentException("originalIndex must be >= 0");
}
}
}
private static Num compareSafe(Num value, NumFactory numFactory) {
if (Num.isNaNOrNull(value)) {
return numFactory.minusOne();
}
return value;
}
private static int compareScores(Num left, Num right) {
NumFactory numFactory = left != null && !left.isNaN() ? left.getNumFactory()
: right != null && !right.isNaN() ? right.getNumFactory() : null;
if (numFactory == null) {
return 0;
}
Num leftComparable = compareSafe(left, numFactory);
Num rightComparable = compareSafe(right, numFactory);
return leftComparable.compareTo(rightComparable);
}
private static Num normalizeToFactory(Num value, NumFactory numFactory) {
if (Num.isNaNOrNull(value)) {
return NaN.NaN;
}
if (numFactory.produces(value)) {
return value;
}
return numFactory.numOf(value.doubleValue());
}
private static Num clamp01(Num value, NumFactory numFactory) {
if (Num.isNaNOrNull(value)) {
return NaN.NaN;
}
Num normalizedValue = normalizeToFactory(value, numFactory);
Num zero = numFactory.zero();
Num one = numFactory.one();
if (normalizedValue.isLessThan(zero)) {
return zero;
}
if (normalizedValue.isGreaterThan(one)) {
return one;
}
return normalizedValue;
}
private static Num resolveNormalizedScore(Num rawValue, AnalysisCriterion criterion, Num bestValue, Num worstValue,
CriterionNormalizer normalizer, NumFactory numFactory) {
if (Num.isNaNOrNull(rawValue)) {
return NaN.NaN;
}
Num normalized = normalizer.normalize(criterion, rawValue, bestValue, worstValue, numFactory);
if (Num.isNaNOrNull(normalized)) {
return NaN.NaN;
}
return clamp01(normalized, numFactory);
}
private static List<WeightedValue<AnalysisCriterion>> normalizeCriteria(List<WeightedCriterion> weightedCriteria,
NumFactory numFactory) {
List<WeightedValue<AnalysisCriterion>> weightedValues = new ArrayList<>(weightedCriteria.size());
for (WeightedCriterion weightedCriterion : weightedCriteria) {
Num multiplier = normalizeToFactory(weightedCriterion.multiplier(), numFactory);
if (Num.isNaNOrNull(multiplier) || multiplier.isNegative()) {
throw new IllegalArgumentException("criterion multiplier must be finite and >= 0");
}
weightedValues.add(new WeightedValue<>(weightedCriterion.criterion(), multiplier));
}
return WeightedValue.normalizeWeights(weightedValues, numFactory);
}
private static Num[] findBestWorst(Num[] values, AnalysisCriterion criterion) {
Num bestValue = null;
Num worstValue = null;
for (Num value : values) {
if (Num.isNaNOrNull(value)) {
continue;
}
if (bestValue == null) {
bestValue = value;
worstValue = value;
continue;
}
if (criterion.betterThan(value, bestValue)) {
bestValue = value;
}
if (criterion.betterThan(worstValue, value)) {
worstValue = value;
}
}
if (bestValue == null) {
return new Num[] { NaN.NaN, NaN.NaN };
}
return new Num[] { bestValue, worstValue };
}
}
@@ -0,0 +1,24 @@
/*
* SPDX-License-Identifier: MIT
*/
/**
* Backtesting execution infrastructure.
*
* <p>
* This package defines how strategies are executed over historical series and
* how runs are ranked or compared.
* </p>
*
* <p>
* Choose by workload:
* </p>
* <ul>
* <li>{@link org.ta4j.core.backtest.BarSeriesManager} for one strategy over one
* series</li>
* <li>{@link org.ta4j.core.backtest.BacktestExecutor} for many strategies,
* telemetry, and ranking</li>
* <li>{@link org.ta4j.core.backtest.TradeExecutionModel} implementations to
* align fill semantics with your simulation assumptions</li>
* </ul>
*/
package org.ta4j.core.backtest;
@@ -0,0 +1,8 @@
# AGENTS instructions for `org.ta4j.core.bars`
## TimeBarBuilder gap semantics
- `TimeBarBuilder` must align bars to the supplied time periods.
- Do not auto-reconcile or backfill missing periods.
- Missing periods should remain missing while preserving correct chronological placement of subsequent bars.
- When changing bar-construction logic, add or update tests that cover both contiguous data and explicit time gaps.
@@ -0,0 +1,574 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.bars;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import org.ta4j.core.Bar;
import org.ta4j.core.BarBuilder;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBar;
import org.ta4j.core.BaseRealtimeBar;
import org.ta4j.core.RealtimeBar;
import org.ta4j.core.num.DoubleNumFactory;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* An amount bar is sampled after a fixed number of amount (= price * volume)
* have been traded.
*/
public class AmountBarBuilder implements BarBuilder {
private Num distinctVolume;
private final Num amountThreshold;
private final boolean setAmountByVolume;
private final NumFactory numFactory;
private final RemainderCarryOverPolicy carryOverPolicy;
private final boolean realtimeBars;
private BarSeries barSeries;
private Duration timePeriod;
private Instant beginTime;
private Instant endTime;
private Num openPrice;
private Num highPrice;
private Num lowPrice;
private Num closePrice;
private Num volume;
private Num amount;
private long trades;
private Num buyVolume;
private Num sellVolume;
private Num buyAmount;
private Num sellAmount;
private long buyTrades;
private long sellTrades;
private boolean hasSideData;
private Num makerVolume;
private Num takerVolume;
private Num makerAmount;
private Num takerAmount;
private long makerTrades;
private long takerTrades;
private boolean hasLiquidityData;
private Num lastTradeVolume;
private Num lastTradePrice;
private RealtimeBar.Side lastTradeSide;
private RealtimeBar.Liquidity lastTradeLiquidity;
/**
* A builder to build a new {@link BaseBar} with {@link DoubleNumFactory}
*
* @param amountThreshold the threshold at which a new bar should be created
* @param setAmountByVolume if {@code true} the {@link #amount} is set by
* {@link #volume} * {@link #closePrice}, otherwise
* {@link #amount} must be explicitly set
*/
public AmountBarBuilder(final int amountThreshold, final boolean setAmountByVolume) {
this(DoubleNumFactory.getInstance(), amountThreshold, setAmountByVolume, false, RemainderCarryOverPolicy.NONE);
}
/**
* A builder to build a new {@link BaseBar}
*
* @param numFactory
* @param amountThreshold the threshold at which a new bar should be created
* @param setAmountByVolume if {@code true} the {@link #amount} is set by
* {@link #volume} * {@link #closePrice}, otherwise
* {@link #amount} must be explicitly set
*/
public AmountBarBuilder(final NumFactory numFactory, final int amountThreshold, final boolean setAmountByVolume) {
this(numFactory, amountThreshold, setAmountByVolume, false, RemainderCarryOverPolicy.NONE);
}
/**
* A builder to build a new {@link BaseBar} or {@link BaseRealtimeBar}
*
* @param numFactory
* @param amountThreshold the threshold at which a new bar should be created
* @param setAmountByVolume if {@code true} the {@link #amount} is set by
* {@link #volume} * {@link #closePrice}, otherwise
* {@link #amount} must be explicitly set
* @param realtimeBars {@code true} to build {@link BaseRealtimeBar}
* instances
*
* @since 0.22.0
*/
public AmountBarBuilder(final NumFactory numFactory, final int amountThreshold, final boolean setAmountByVolume,
final boolean realtimeBars) {
this(numFactory, amountThreshold, setAmountByVolume, realtimeBars, RemainderCarryOverPolicy.NONE);
}
/**
* A builder to build a new {@link BaseBar} or {@link BaseRealtimeBar}
*
* @param numFactory the backing number factory
* @param amountThreshold the threshold at which a new bar should be created
* @param setAmountByVolume if {@code true} the {@link #amount} is set by
* {@link #volume} * {@link #closePrice}, otherwise
* {@link #amount} must be explicitly set
* @param realtimeBars {@code true} to build {@link BaseRealtimeBar}
* instances
* @param carryOverPolicy policy for handling side/liquidity remainder splits
*
* @since 0.22.0
*/
public AmountBarBuilder(final NumFactory numFactory, final int amountThreshold, final boolean setAmountByVolume,
final boolean realtimeBars, final RemainderCarryOverPolicy carryOverPolicy) {
this.numFactory = numFactory;
this.amountThreshold = numFactory.numOf(amountThreshold);
this.setAmountByVolume = setAmountByVolume;
this.carryOverPolicy = carryOverPolicy == null ? RemainderCarryOverPolicy.NONE : carryOverPolicy;
this.realtimeBars = realtimeBars;
reset();
}
@Override
public BarBuilder timePeriod(final Duration timePeriod) {
this.timePeriod = this.timePeriod == null ? timePeriod : this.timePeriod.plus(timePeriod);
return this;
}
@Override
public BarBuilder beginTime(final Instant beginTime) {
this.beginTime = beginTime;
return this;
}
@Override
public BarBuilder endTime(final Instant endTime) {
this.endTime = endTime;
return this;
}
@Override
public BarBuilder openPrice(final Num openPrice) {
throw new IllegalArgumentException("AmountBar can only be built from closePrice");
}
@Override
public BarBuilder openPrice(final Number openPrice) {
throw new IllegalArgumentException("AmountBar can only be built from closePrice");
}
@Override
public BarBuilder openPrice(final String openPrice) {
throw new IllegalArgumentException("AmountBar can only be built from closePrice");
}
@Override
public BarBuilder highPrice(final Number highPrice) {
throw new IllegalArgumentException("AmountBar can only be built from closePrice");
}
@Override
public BarBuilder highPrice(final String highPrice) {
throw new IllegalArgumentException("AmountBar can only be built from closePrice");
}
@Override
public BarBuilder highPrice(final Num highPrice) {
throw new IllegalArgumentException("AmountBar can only be built from closePrice");
}
@Override
public BarBuilder lowPrice(final Num lowPrice) {
throw new IllegalArgumentException("AmountBar can only be built from closePrice");
}
@Override
public BarBuilder lowPrice(final Number lowPrice) {
throw new IllegalArgumentException("AmountBar can only be built from closePrice");
}
@Override
public BarBuilder lowPrice(final String lowPrice) {
throw new IllegalArgumentException("AmountBar can only be built from closePrice");
}
@Override
public BarBuilder closePrice(final Num tickPrice) {
closePrice = tickPrice;
if (openPrice == null) {
openPrice = tickPrice;
}
highPrice = highPrice.max(tickPrice);
lowPrice = lowPrice.min(tickPrice);
return this;
}
@Override
public BarBuilder closePrice(final Number closePrice) {
return closePrice(numFactory.numOf(closePrice));
}
@Override
public BarBuilder closePrice(final String closePrice) {
return closePrice(numFactory.numOf(closePrice));
}
@Override
public BarBuilder volume(final Num volume) {
this.distinctVolume = volume;
this.volume = this.volume == null ? volume : this.volume.plus(volume);
return this;
}
@Override
public BarBuilder volume(final Number volume) {
volume(numFactory.numOf(volume));
return this;
}
@Override
public BarBuilder volume(final String volume) {
volume(numFactory.numOf(volume));
return this;
}
@Override
public BarBuilder amount(final Num amount) {
if (setAmountByVolume) {
throw new IllegalArgumentException("AmountBar.amount can only be built from closePrice*volume");
}
this.amount = this.amount == null ? amount : this.amount.plus(amount);
return this;
}
@Override
public BarBuilder amount(final Number amount) {
amount(numFactory.numOf(amount));
return this;
}
@Override
public BarBuilder amount(final String amount) {
amount(numFactory.numOf(amount));
return this;
}
@Override
public BarBuilder trades(final long trades) {
this.trades += trades;
return this;
}
@Override
public BarBuilder trades(final String trades) {
trades(Long.parseLong(trades));
return this;
}
@Override
public AmountBarBuilder bindTo(final BarSeries barSeries) {
this.barSeries = Objects.requireNonNull(barSeries);
return this;
}
/**
* Ingests a trade into the current amount bar and appends the bar once the
* amount threshold is met.
*
* @param time the trade timestamp (UTC)
* @param tradeVolume the traded volume
* @param tradePrice the traded price
*
* @since 0.22.0
*/
@Override
public void addTrade(final Instant time, final Num tradeVolume, final Num tradePrice) {
addTrade(time, tradeVolume, tradePrice, null, null);
}
/**
* Ingests a trade into the current amount bar and appends the bar once the
* amount threshold is met.
*
* @param time the trade timestamp (UTC)
* @param tradeVolume the traded volume
* @param tradePrice the traded price
* @param side aggressor side (optional)
* @param liquidity liquidity classification (optional)
*
* @since 0.22.0
*/
@Override
public void addTrade(final Instant time, final Num tradeVolume, final Num tradePrice, final RealtimeBar.Side side,
final RealtimeBar.Liquidity liquidity) {
Objects.requireNonNull(time, "time");
Objects.requireNonNull(tradeVolume, "tradeVolume");
Objects.requireNonNull(tradePrice, "tradePrice");
ensureRealtimeTracking(side, liquidity);
if (endTime != null && time.isBefore(endTime)) {
throw new IllegalArgumentException(
String.format("Trade time %s is before current bar end time %s", time, endTime));
}
if (beginTime == null) {
beginTime = time;
}
endTime = time;
closePrice(tradePrice);
volume(tradeVolume);
if (!setAmountByVolume) {
amount(tradePrice.multipliedBy(tradeVolume));
}
trades(1);
lastTradeVolume = tradeVolume;
lastTradePrice = tradePrice;
lastTradeSide = side;
lastTradeLiquidity = liquidity;
recordRealtimeTrade(tradeVolume, tradePrice, side, liquidity);
add();
}
/**
* Builds bar from current state that is modified for each tick.
*
* @return snapshot of current state
*/
@Override
public Bar build() {
if (realtimeBars) {
return new BaseRealtimeBar(timePeriod, beginTime, endTime, openPrice, highPrice, lowPrice, closePrice,
volume, amount, trades, buyVolume, sellVolume, buyAmount, sellAmount, buyTrades, sellTrades,
makerVolume, takerVolume, makerAmount, takerAmount, makerTrades, takerTrades, hasSideData,
hasLiquidityData, numFactory);
}
return new BaseBar(timePeriod, beginTime, endTime, openPrice, highPrice, lowPrice, closePrice, volume, amount,
trades);
}
@Override
public void add() {
if (setAmountByVolume) {
final var calculatedAmount = closePrice.multipliedBy(distinctVolume);
amount = amount == null ? calculatedAmount : amount.plus(calculatedAmount);
}
if (amount.isGreaterThanOrEqual(amountThreshold)) {
// move amount remainder to next bar
var amountRemainder = numFactory.zero();
// move volume remainder to next bar
var volumeRemainder = numFactory.zero();
CarryOverSnapshot carryOverSnapshot = null;
if (amount.isGreaterThan(amountThreshold)) {
amountRemainder = amount.minus(amountThreshold);
// Use closePrice for division, but fall back to lastTradePrice if closePrice is
// zero
// This prevents division by zero when the current trade has a zero price
final Num priceForDivision = (closePrice == null || closePrice.isZero()) && lastTradePrice != null
&& !lastTradePrice.isZero() ? lastTradePrice : closePrice;
if (priceForDivision == null || priceForDivision.isZero()) {
throw new IllegalStateException(
"Cannot calculate volume remainder: both closePrice and lastTradePrice are zero or null, but amount remainder exists");
}
volumeRemainder = amountRemainder.dividedBy(priceForDivision);
// cap currently built bar, amount is then restored to amountRemainder
amount = amountThreshold;
// cap currently built bar, volume is then restored to volumeRemainder
volume = volume.minus(volumeRemainder);
if (carryOverPolicy == RemainderCarryOverPolicy.PROPORTIONAL
|| carryOverPolicy == RemainderCarryOverPolicy.PROPORTIONAL_WITH_TRADE_COUNT) {
carryOverSnapshot = applyProportionalCarryOver(volumeRemainder, amountRemainder);
}
}
barSeries.addBar(build());
amount = amountRemainder;
volume = volumeRemainder;
reset();
if (carryOverSnapshot != null) {
carryOverSnapshot.applyTo(this);
}
}
}
private void reset() {
distinctVolume = null;
timePeriod = null;
beginTime = null;
endTime = null;
openPrice = null;
highPrice = numFactory.zero();
lowPrice = numFactory.numOf(Integer.MAX_VALUE);
closePrice = null;
trades = 0;
buyVolume = null;
sellVolume = null;
buyAmount = null;
sellAmount = null;
buyTrades = 0;
sellTrades = 0;
hasSideData = false;
makerVolume = null;
takerVolume = null;
makerAmount = null;
takerAmount = null;
makerTrades = 0;
takerTrades = 0;
hasLiquidityData = false;
lastTradeVolume = null;
lastTradePrice = null;
lastTradeSide = null;
lastTradeLiquidity = null;
}
private CarryOverSnapshot applyProportionalCarryOver(final Num volumeRemainder, final Num amountRemainder) {
if (volumeRemainder == null || volumeRemainder.isZero() || amountRemainder == null || lastTradeVolume == null
|| lastTradePrice == null) {
return null;
}
final CarryOverSnapshot snapshot = new CarryOverSnapshot();
final boolean carryTradeCount = shouldCarryTradeCount(volumeRemainder);
if (lastTradeSide != null) {
if (lastTradeSide == RealtimeBar.Side.BUY) {
buyVolume = subtractOrNull(buyVolume, volumeRemainder);
buyAmount = subtractOrNull(buyAmount, amountRemainder);
snapshot.buyVolume = volumeRemainder;
snapshot.buyAmount = amountRemainder;
if (carryTradeCount) {
buyTrades = Math.max(0, buyTrades - 1);
snapshot.buyTrades = 1;
}
} else {
sellVolume = subtractOrNull(sellVolume, volumeRemainder);
sellAmount = subtractOrNull(sellAmount, amountRemainder);
snapshot.sellVolume = volumeRemainder;
snapshot.sellAmount = amountRemainder;
if (carryTradeCount) {
sellTrades = Math.max(0, sellTrades - 1);
snapshot.sellTrades = 1;
}
}
}
if (lastTradeLiquidity != null) {
if (lastTradeLiquidity == RealtimeBar.Liquidity.MAKER) {
makerVolume = subtractOrNull(makerVolume, volumeRemainder);
makerAmount = subtractOrNull(makerAmount, amountRemainder);
snapshot.makerVolume = volumeRemainder;
snapshot.makerAmount = amountRemainder;
if (carryTradeCount) {
makerTrades = Math.max(0, makerTrades - 1);
snapshot.makerTrades = 1;
}
} else {
takerVolume = subtractOrNull(takerVolume, volumeRemainder);
takerAmount = subtractOrNull(takerAmount, amountRemainder);
snapshot.takerVolume = volumeRemainder;
snapshot.takerAmount = amountRemainder;
if (carryTradeCount) {
takerTrades = Math.max(0, takerTrades - 1);
snapshot.takerTrades = 1;
}
}
}
if (carryTradeCount) {
trades = Math.max(0, trades - 1);
snapshot.trades = 1;
}
snapshot.hasSideData = snapshot.buyVolume != null || snapshot.sellVolume != null;
snapshot.hasLiquidityData = snapshot.makerVolume != null || snapshot.takerVolume != null;
return snapshot;
}
private boolean shouldCarryTradeCount(final Num volumeRemainder) {
if (carryOverPolicy != RemainderCarryOverPolicy.PROPORTIONAL_WITH_TRADE_COUNT) {
return false;
}
if (lastTradeVolume == null || lastTradeVolume.isZero()) {
return false;
}
return volumeRemainder.multipliedBy(numFactory.numOf(2)).isGreaterThanOrEqual(lastTradeVolume);
}
private Num subtractOrNull(final Num current, final Num remainder) {
if (current == null) {
return null;
}
final Num updated = current.minus(remainder);
return updated.isZero() ? null : updated;
}
private static final class CarryOverSnapshot {
private Num buyVolume;
private Num sellVolume;
private Num buyAmount;
private Num sellAmount;
private Num makerVolume;
private Num takerVolume;
private Num makerAmount;
private Num takerAmount;
private long trades;
private long buyTrades;
private long sellTrades;
private long makerTrades;
private long takerTrades;
private boolean hasSideData;
private boolean hasLiquidityData;
private void applyTo(final AmountBarBuilder builder) {
builder.buyVolume = buyVolume;
builder.sellVolume = sellVolume;
builder.buyAmount = buyAmount;
builder.sellAmount = sellAmount;
builder.makerVolume = makerVolume;
builder.takerVolume = takerVolume;
builder.makerAmount = makerAmount;
builder.takerAmount = takerAmount;
builder.trades = trades;
builder.buyTrades = buyTrades;
builder.sellTrades = sellTrades;
builder.makerTrades = makerTrades;
builder.takerTrades = takerTrades;
builder.hasSideData = hasSideData;
builder.hasLiquidityData = hasLiquidityData;
}
}
private void recordRealtimeTrade(final Num tradeVolume, final Num tradePrice, final RealtimeBar.Side side,
final RealtimeBar.Liquidity liquidity) {
if (side != null) {
hasSideData = true;
final Num tradeAmount = tradePrice.multipliedBy(tradeVolume);
if (side == RealtimeBar.Side.BUY) {
buyVolume = buyVolume == null ? tradeVolume : buyVolume.plus(tradeVolume);
buyAmount = buyAmount == null ? tradeAmount : buyAmount.plus(tradeAmount);
buyTrades++;
} else {
sellVolume = sellVolume == null ? tradeVolume : sellVolume.plus(tradeVolume);
sellAmount = sellAmount == null ? tradeAmount : sellAmount.plus(tradeAmount);
sellTrades++;
}
}
if (liquidity != null) {
hasLiquidityData = true;
final Num tradeAmount = tradePrice.multipliedBy(tradeVolume);
if (liquidity == RealtimeBar.Liquidity.MAKER) {
makerVolume = makerVolume == null ? tradeVolume : makerVolume.plus(tradeVolume);
makerAmount = makerAmount == null ? tradeAmount : makerAmount.plus(tradeAmount);
makerTrades++;
} else {
takerVolume = takerVolume == null ? tradeVolume : takerVolume.plus(tradeVolume);
takerAmount = takerAmount == null ? tradeAmount : takerAmount.plus(tradeAmount);
takerTrades++;
}
}
}
private void ensureRealtimeTracking(final RealtimeBar.Side side, final RealtimeBar.Liquidity liquidity) {
if (!realtimeBars && (side != null || liquidity != null)) {
throw new IllegalStateException("Realtime trade data requires a realtime bar builder");
}
}
}
@@ -0,0 +1,94 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.bars;
import org.ta4j.core.BarBuilderFactory;
import org.ta4j.core.BaseRealtimeBar;
import org.ta4j.core.BarBuilder;
import org.ta4j.core.BarSeries;
public class AmountBarBuilderFactory implements BarBuilderFactory {
private final int amountThreshold;
private final boolean setAmountByVolume;
private final RemainderCarryOverPolicy carryOverPolicy;
private final boolean realtimeBars;
private transient AmountBarBuilder barBuilder;
/**
* Constructor.
*
* @param amountThreshold the threshold at which a new bar should be created
* @param setAmountByVolume if {@code true} the {@code amount} is set by
* {@code volume * closePrice}, otherwise
* {@code amount} must be explicitly set
*/
public AmountBarBuilderFactory(final int amountThreshold, final boolean setAmountByVolume) {
this(amountThreshold, setAmountByVolume, false, RemainderCarryOverPolicy.NONE);
}
/**
* Constructor.
*
* @param amountThreshold the threshold at which a new bar should be created
* @param setAmountByVolume if {@code true} the {@code amount} is set by
* {@code volume * closePrice}, otherwise
* {@code amount} must be explicitly set
* @param realtimeBars {@code true} to build {@link BaseRealtimeBar}
* instances
*
* @since 0.22.2
*/
public AmountBarBuilderFactory(final int amountThreshold, final boolean setAmountByVolume,
final boolean realtimeBars) {
this(amountThreshold, setAmountByVolume, realtimeBars, RemainderCarryOverPolicy.NONE);
}
/**
* Constructor.
*
* @param amountThreshold the threshold at which a new bar should be created
* @param setAmountByVolume if {@code true} the {@code amount} is set by
* {@code volume * closePrice}, otherwise
* {@code amount} must be explicitly set
* @param carryOverPolicy policy for handling side/liquidity remainder splits
*
* @since 0.22.2
*/
public AmountBarBuilderFactory(final int amountThreshold, final boolean setAmountByVolume,
final RemainderCarryOverPolicy carryOverPolicy) {
this(amountThreshold, setAmountByVolume, false, carryOverPolicy);
}
/**
* Constructor.
*
* @param amountThreshold the threshold at which a new bar should be created
* @param setAmountByVolume if {@code true} the {@code amount} is set by
* {@code volume * closePrice}, otherwise
* {@code amount} must be explicitly set
* @param realtimeBars {@code true} to build {@link BaseRealtimeBar}
* instances
* @param carryOverPolicy policy for handling side/liquidity remainder splits
*
* @since 0.22.2
*/
public AmountBarBuilderFactory(final int amountThreshold, final boolean setAmountByVolume,
final boolean realtimeBars, final RemainderCarryOverPolicy carryOverPolicy) {
this.amountThreshold = amountThreshold;
this.setAmountByVolume = setAmountByVolume;
this.realtimeBars = realtimeBars;
this.carryOverPolicy = carryOverPolicy == null ? RemainderCarryOverPolicy.NONE : carryOverPolicy;
}
@Override
public BarBuilder createBarBuilder(final BarSeries series) {
if (this.barBuilder == null) {
this.barBuilder = new AmountBarBuilder(series.numFactory(), this.amountThreshold, this.setAmountByVolume,
this.realtimeBars, this.carryOverPolicy).bindTo(series);
}
return this.barBuilder;
}
}
@@ -0,0 +1,56 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.bars;
import org.ta4j.core.Bar;
import org.ta4j.core.BaseBar;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/*
* Heikin-Ashi bar builder
* @see <a href="https://www.investopedia.com/trading/heikin-ashi-better-candlestick/">Heikin-Ashi</a>
*/
public class HeikinAshiBarBuilder extends TimeBarBuilder {
private Num previousHeikinAshiOpenPrice;
private Num previousHeikinAshiClosePrice;
public HeikinAshiBarBuilder() {
super();
}
public HeikinAshiBarBuilder(NumFactory numFactory) {
super(numFactory);
}
public HeikinAshiBarBuilder previousHeikinAshiOpenPrice(Num previousOpen) {
previousHeikinAshiOpenPrice = previousOpen;
return this;
}
public HeikinAshiBarBuilder previousHeikinAshiClosePrice(Num previousClose) {
previousHeikinAshiClosePrice = previousClose;
return this;
}
@Override
public Bar build() {
if (previousHeikinAshiOpenPrice == null || previousHeikinAshiClosePrice == null) {
return super.build();
} else {
var numFactory = openPrice.getNumFactory();
var heikinAshiClose = openPrice.plus(highPrice)
.plus(lowPrice)
.plus(closePrice)
.dividedBy(numFactory.numOf(4));
var heikinAshiOpen = previousHeikinAshiOpenPrice.plus(previousHeikinAshiClosePrice)
.dividedBy(numFactory.numOf(2));
var heikinAshiHigh = highPrice.max(heikinAshiOpen).max(heikinAshiClose);
var heikinAshiLow = lowPrice.min(heikinAshiOpen).min(heikinAshiClose);
return new BaseBar(timePeriod, beginTime, endTime, heikinAshiOpen, heikinAshiHigh, heikinAshiLow,
heikinAshiClose, volume, amount, trades);
}
}
}
@@ -0,0 +1,17 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.bars;
import org.ta4j.core.BarBuilder;
import org.ta4j.core.BarBuilderFactory;
import org.ta4j.core.BarSeries;
public class HeikinAshiBarBuilderFactory implements BarBuilderFactory {
@Override
public BarBuilder createBarBuilder(BarSeries series) {
return new HeikinAshiBarBuilder(series.numFactory()).bindTo(series);
}
}
@@ -0,0 +1,62 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.bars;
/**
* Policy for handling side/liquidity data when a volume or amount bar carries a
* remainder into the next bar.
*
* <p>
* Trade remainders occur when a single trade pushes the bar past its volume or
* amount threshold. The bar is capped and the remainder is rolled into the next
* bar. This policy controls whether side/liquidity breakdowns follow that
* remainder.
*
* <p>
* In practice:
* <ul>
* <li>{@link #NONE} keeps side/liquidity data attached to the trade that caused
* the rollover. This preserves trade fidelity (no synthetic splits) but can
* make side/liquidity totals diverge from the capped volume/amount.</li>
* <li>{@link #PROPORTIONAL} splits the trade's side/liquidity volumes and
* amounts proportionally between the capped bar and the remainder. This keeps
* side/liquidity totals aligned with volume/amount but injects an assumption
* about how to attribute partial trades.</li>
* <li>{@link #PROPORTIONAL_WITH_TRADE_COUNT} applies the proportional split and
* assigns the trade count to the bar that receives the larger portion of the
* final trade (rounded: the remainder receives the count if its share is at
* least 50%). This preserves integer trade counts while keeping them aligned
* with the majority of the trade volume.</li>
* </ul>
*
* <p>
* Trade counts are not split by this policy unless
* {@link #PROPORTIONAL_WITH_TRADE_COUNT} is selected; they remain whole-trade
* counts.
*
* @since 0.22.2
*/
public enum RemainderCarryOverPolicy {
/**
* Do not carry side/liquidity data with the remainder.
*
* @since 0.22.2
*/
NONE,
/**
* Split side/liquidity volumes and amounts proportionally with the remainder.
*
* @since 0.22.2
*/
PROPORTIONAL,
/**
* Split side/liquidity volumes and amounts proportionally and allocate the
* trade count to the remainder if its share is at least 50%.
*
* @since 0.22.2
*/
PROPORTIONAL_WITH_TRADE_COUNT
}
@@ -0,0 +1,372 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.bars;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import org.ta4j.core.Bar;
import org.ta4j.core.BarBuilder;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBar;
import org.ta4j.core.BaseRealtimeBar;
import org.ta4j.core.RealtimeBar;
import org.ta4j.core.num.DoubleNumFactory;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* A tick bar is sampled after a fixed number of ticks.
*/
public class TickBarBuilder implements BarBuilder {
private final NumFactory numFactory;
private final boolean realtimeBars;
private final int tickCount;
private int passedTicksCount;
private BarSeries barSeries;
private Duration timePeriod;
private Instant beginTime;
private Instant endTime;
private Num volume;
private Num openPrice;
private Num highPrice;
private Num closePrice;
private Num lowPrice;
private Num amount;
private long trades;
private Num buyVolume;
private Num sellVolume;
private Num buyAmount;
private Num sellAmount;
private long buyTrades;
private long sellTrades;
private boolean hasSideData;
private Num makerVolume;
private Num takerVolume;
private Num makerAmount;
private Num takerAmount;
private long makerTrades;
private long takerTrades;
private boolean hasLiquidityData;
/**
* A builder to build a new {@link BaseBar} with {@link DoubleNumFactory}
*
* @param tickCount the number of ticks at which a new bar should be created
*/
public TickBarBuilder(final int tickCount) {
this(DoubleNumFactory.getInstance(), tickCount, false);
}
/**
* A builder to build a new {@link BaseBar}
*
* @param numFactory
* @param tickCount the number of ticks at which a new bar should be created
*/
public TickBarBuilder(final NumFactory numFactory, final int tickCount) {
this(numFactory, tickCount, false);
}
/**
* A builder to build a new {@link BaseBar} or {@link BaseRealtimeBar}
*
* @param numFactory
* @param tickCount the number of ticks at which a new bar should be created
* @param realtimeBars {@code true} to build {@link BaseRealtimeBar} instances
*
* @since 0.22.0
*/
public TickBarBuilder(final NumFactory numFactory, final int tickCount, final boolean realtimeBars) {
this.numFactory = numFactory;
this.realtimeBars = realtimeBars;
this.tickCount = tickCount;
reset();
}
@Override
public BarBuilder timePeriod(final Duration timePeriod) {
this.timePeriod = this.timePeriod == null ? timePeriod : this.timePeriod.plus(timePeriod);
return this;
}
@Override
public BarBuilder beginTime(final Instant beginTime) {
this.beginTime = beginTime;
return this;
}
@Override
public BarBuilder endTime(final Instant endTime) {
this.endTime = endTime;
return this;
}
@Override
public BarBuilder openPrice(final Num openPrice) {
throw new IllegalArgumentException("TickBar can only be built from closePrice");
}
@Override
public BarBuilder openPrice(final Number openPrice) {
throw new IllegalArgumentException("TickBar can only be built from closePrice");
}
@Override
public BarBuilder openPrice(final String openPrice) {
throw new IllegalArgumentException("TickBar can only be built from closePrice");
}
@Override
public BarBuilder highPrice(final Number highPrice) {
throw new IllegalArgumentException("TickBar can only be built from closePrice");
}
@Override
public BarBuilder highPrice(final String highPrice) {
throw new IllegalArgumentException("TickBar can only be built from closePrice");
}
@Override
public BarBuilder highPrice(final Num highPrice) {
throw new IllegalArgumentException("TickBar can only be built from closePrice");
}
@Override
public BarBuilder lowPrice(final Num lowPrice) {
throw new IllegalArgumentException("TickBar can only be built from closePrice");
}
@Override
public BarBuilder lowPrice(final Number lowPrice) {
throw new IllegalArgumentException("TickBar can only be built from closePrice");
}
@Override
public BarBuilder lowPrice(final String lowPrice) {
throw new IllegalArgumentException("TickBar can only be built from closePrice");
}
@Override
public BarBuilder closePrice(final Num tickPrice) {
closePrice = tickPrice;
if (openPrice == null) {
openPrice = tickPrice;
}
highPrice = highPrice.max(tickPrice);
lowPrice = lowPrice.min(tickPrice);
return this;
}
@Override
public BarBuilder closePrice(final Number closePrice) {
return closePrice(numFactory.numOf(closePrice));
}
@Override
public BarBuilder closePrice(final String closePrice) {
return closePrice(numFactory.numOf(closePrice));
}
@Override
public BarBuilder volume(final Num volume) {
this.volume = this.volume.plus(volume);
return this;
}
@Override
public BarBuilder volume(final Number volume) {
volume(this.numFactory.numOf(volume));
return this;
}
@Override
public BarBuilder volume(final String volume) {
volume(this.numFactory.numOf(volume));
return this;
}
@Override
public BarBuilder amount(final Num amount) {
this.amount = this.amount == null ? amount : this.amount.plus(amount);
return this;
}
@Override
public BarBuilder amount(final Number amount) {
amount(this.numFactory.numOf(amount));
return this;
}
@Override
public BarBuilder amount(final String amount) {
amount(this.numFactory.numOf(amount));
return this;
}
@Override
public BarBuilder trades(final long trades) {
this.trades += trades;
return this;
}
@Override
public BarBuilder trades(final String trades) {
trades(Long.parseLong(trades));
return this;
}
@Override
public TickBarBuilder bindTo(final BarSeries barSeries) {
this.barSeries = Objects.requireNonNull(barSeries);
return this;
}
/**
* Ingests a trade into the current tick bar and appends the bar once the tick
* threshold is met.
*
* @param time the trade timestamp (UTC)
* @param tradeVolume the traded volume
* @param tradePrice the traded price
*
* @since 0.22.0
*/
@Override
public void addTrade(final Instant time, final Num tradeVolume, final Num tradePrice) {
addTrade(time, tradeVolume, tradePrice, null, null);
}
/**
* Ingests a trade into the current tick bar and appends the bar once the tick
* threshold is met.
*
* @param time the trade timestamp (UTC)
* @param tradeVolume the traded volume
* @param tradePrice the traded price
* @param side aggressor side (optional)
* @param liquidity liquidity classification (optional)
*
* @since 0.22.0
*/
@Override
public void addTrade(final Instant time, final Num tradeVolume, final Num tradePrice, final RealtimeBar.Side side,
final RealtimeBar.Liquidity liquidity) {
Objects.requireNonNull(time, "time");
Objects.requireNonNull(tradeVolume, "tradeVolume");
Objects.requireNonNull(tradePrice, "tradePrice");
ensureRealtimeTracking(side, liquidity);
if (endTime != null && time.isBefore(endTime)) {
throw new IllegalArgumentException(
String.format("Trade time %s is before current bar end time %s", time, endTime));
}
if (beginTime == null) {
beginTime = time;
}
endTime = time;
closePrice(tradePrice);
volume(tradeVolume);
trades(1);
recordRealtimeTrade(tradeVolume, tradePrice, side, liquidity);
add();
}
/**
* Builds bar from current state that is modified for each tick.
*
* @return snapshot of current state
*/
@Override
public Bar build() {
if (realtimeBars) {
return new BaseRealtimeBar(timePeriod, beginTime, endTime, openPrice, highPrice, lowPrice, closePrice,
volume, amount, trades, buyVolume, sellVolume, buyAmount, sellAmount, buyTrades, sellTrades,
makerVolume, takerVolume, makerAmount, takerAmount, makerTrades, takerTrades, hasSideData,
hasLiquidityData, numFactory);
}
return new BaseBar(timePeriod, beginTime, endTime, openPrice, highPrice, lowPrice, closePrice, volume, amount,
trades);
}
@Override
public void add() {
if (++passedTicksCount % tickCount == 0) {
if (amount == null && volume != null) {
amount = closePrice.multipliedBy(volume);
}
barSeries.addBar(build());
reset();
}
}
private void reset() {
final var zero = numFactory.zero();
timePeriod = null;
beginTime = null;
endTime = null;
openPrice = null;
highPrice = zero;
lowPrice = numFactory.numOf(Integer.MAX_VALUE);
closePrice = null;
amount = null;
trades = 0;
volume = zero;
buyVolume = null;
sellVolume = null;
buyAmount = null;
sellAmount = null;
buyTrades = 0;
sellTrades = 0;
hasSideData = false;
makerVolume = null;
takerVolume = null;
makerAmount = null;
takerAmount = null;
makerTrades = 0;
takerTrades = 0;
hasLiquidityData = false;
}
private void recordRealtimeTrade(final Num tradeVolume, final Num tradePrice, final RealtimeBar.Side side,
final RealtimeBar.Liquidity liquidity) {
if (side != null) {
hasSideData = true;
final Num tradeAmount = tradePrice.multipliedBy(tradeVolume);
if (side == RealtimeBar.Side.BUY) {
buyVolume = buyVolume == null ? tradeVolume : buyVolume.plus(tradeVolume);
buyAmount = buyAmount == null ? tradeAmount : buyAmount.plus(tradeAmount);
buyTrades++;
} else {
sellVolume = sellVolume == null ? tradeVolume : sellVolume.plus(tradeVolume);
sellAmount = sellAmount == null ? tradeAmount : sellAmount.plus(tradeAmount);
sellTrades++;
}
}
if (liquidity != null) {
hasLiquidityData = true;
final Num tradeAmount = tradePrice.multipliedBy(tradeVolume);
if (liquidity == RealtimeBar.Liquidity.MAKER) {
makerVolume = makerVolume == null ? tradeVolume : makerVolume.plus(tradeVolume);
makerAmount = makerAmount == null ? tradeAmount : makerAmount.plus(tradeAmount);
makerTrades++;
} else {
takerVolume = takerVolume == null ? tradeVolume : takerVolume.plus(tradeVolume);
takerAmount = takerAmount == null ? tradeAmount : takerAmount.plus(tradeAmount);
takerTrades++;
}
}
}
private void ensureRealtimeTracking(final RealtimeBar.Side side, final RealtimeBar.Liquidity liquidity) {
if (!realtimeBars && (side != null || liquidity != null)) {
throw new IllegalStateException("Realtime trade data requires a realtime bar builder");
}
}
}
@@ -0,0 +1,47 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.bars;
import org.ta4j.core.BarBuilderFactory;
import org.ta4j.core.BaseRealtimeBar;
import org.ta4j.core.BarBuilder;
import org.ta4j.core.BarSeries;
public class TickBarBuilderFactory implements BarBuilderFactory {
private final boolean realtimeBars;
private final int tickCount;
private transient TickBarBuilder barBuilder;
/**
* Constructor.
*
* @param tickCount the number of ticks at which a new bar should be created
*/
public TickBarBuilderFactory(final int tickCount) {
this(tickCount, false);
}
/**
* Constructor.
*
* @param tickCount the number of ticks at which a new bar should be created
* @param realtimeBars {@code true} to build {@link BaseRealtimeBar} instances
*
* @since 0.22.2
*/
public TickBarBuilderFactory(final int tickCount, final boolean realtimeBars) {
this.tickCount = tickCount;
this.realtimeBars = realtimeBars;
}
@Override
public BarBuilder createBarBuilder(final BarSeries series) {
if (this.barBuilder == null) {
this.barBuilder = new TickBarBuilder(series.numFactory(), this.tickCount, this.realtimeBars).bindTo(series);
}
return this.barBuilder;
}
}
@@ -0,0 +1,448 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.bars;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.ta4j.core.Bar;
import org.ta4j.core.BarBuilder;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBar;
import org.ta4j.core.BaseRealtimeBar;
import org.ta4j.core.RealtimeBar;
import org.ta4j.core.num.DoubleNumFactory;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* A time bar is sampled after a fixed time period.
*
* <p>
* When ingesting trades, missing intervals are omitted. Bars are created only
* when a trade arrives within a time period. If you need continuity, reconcile
* and backfill OHLCV data upstream (often by fetching a window with overlap and
* upserting by bar end time).
*/
public class TimeBarBuilder implements BarBuilder {
private static final Logger LOG = LoggerFactory.getLogger(TimeBarBuilder.class);
private final NumFactory numFactory;
private final boolean realtimeBars;
Duration timePeriod;
Instant beginTime;
Instant endTime;
Num openPrice;
Num highPrice;
Num lowPrice;
Num closePrice;
Num volume;
Num amount;
long trades;
private BarSeries baseBarSeries;
private Num buyVolume;
private Num sellVolume;
private Num buyAmount;
private Num sellAmount;
private long buyTrades;
private long sellTrades;
private boolean hasSideData;
private Num makerVolume;
private Num takerVolume;
private Num makerAmount;
private Num takerAmount;
private long makerTrades;
private long takerTrades;
private boolean hasLiquidityData;
/** A builder to build a new {@link BaseBar} with {@link DoubleNumFactory} */
public TimeBarBuilder() {
this(DoubleNumFactory.getInstance(), false);
}
/**
* A builder to build a new {@link BaseBar}
*
* @param numFactory
*/
public TimeBarBuilder(final NumFactory numFactory) {
this(numFactory, false);
}
/**
* A builder to build a new {@link BaseBar} or {@link BaseRealtimeBar}
*
* @param numFactory
* @param realtimeBars {@code true} to build {@link BaseRealtimeBar} instances
*
* @since 0.22.2
*/
public TimeBarBuilder(final NumFactory numFactory, final boolean realtimeBars) {
this.numFactory = numFactory;
this.realtimeBars = realtimeBars;
}
@Override
public BarBuilder timePeriod(final Duration timePeriod) {
this.timePeriod = timePeriod;
return this;
}
@Override
public BarBuilder beginTime(final Instant beginTime) {
this.beginTime = beginTime;
return this;
}
@Override
public BarBuilder endTime(final Instant endTime) {
this.endTime = endTime;
return this;
}
@Override
public BarBuilder openPrice(final Num openPrice) {
this.openPrice = openPrice;
return this;
}
@Override
public BarBuilder openPrice(final Number openPrice) {
openPrice(this.numFactory.numOf(openPrice));
return this;
}
@Override
public BarBuilder openPrice(final String openPrice) {
openPrice(this.numFactory.numOf(openPrice));
return this;
}
@Override
public BarBuilder highPrice(final Number highPrice) {
highPrice(this.numFactory.numOf(highPrice));
return this;
}
@Override
public BarBuilder highPrice(final String highPrice) {
highPrice(this.numFactory.numOf(highPrice));
return this;
}
@Override
public BarBuilder highPrice(final Num highPrice) {
this.highPrice = highPrice;
return this;
}
@Override
public BarBuilder lowPrice(final Num lowPrice) {
this.lowPrice = lowPrice;
return this;
}
@Override
public BarBuilder lowPrice(final Number lowPrice) {
lowPrice(this.numFactory.numOf(lowPrice));
return this;
}
@Override
public BarBuilder lowPrice(final String lowPrice) {
lowPrice(this.numFactory.numOf(lowPrice));
return this;
}
@Override
public BarBuilder closePrice(final Num closePrice) {
this.closePrice = closePrice;
return this;
}
@Override
public BarBuilder closePrice(final Number closePrice) {
closePrice(this.numFactory.numOf(closePrice));
return this;
}
@Override
public BarBuilder closePrice(final String closePrice) {
closePrice(this.numFactory.numOf(closePrice));
return this;
}
@Override
public BarBuilder volume(final Num volume) {
this.volume = volume;
return this;
}
@Override
public BarBuilder volume(final Number volume) {
volume(this.numFactory.numOf(volume));
return this;
}
@Override
public BarBuilder volume(final String volume) {
volume(this.numFactory.numOf(volume));
return this;
}
@Override
public BarBuilder amount(final Num amount) {
this.amount = amount;
return this;
}
@Override
public BarBuilder amount(final Number amount) {
amount(this.numFactory.numOf(amount));
return this;
}
@Override
public BarBuilder amount(final String amount) {
amount(this.numFactory.numOf(amount));
return this;
}
@Override
public BarBuilder trades(final long trades) {
this.trades = trades;
return this;
}
@Override
public BarBuilder trades(final String trades) {
trades(Long.parseLong(trades));
return this;
}
@Override
public BarBuilder bindTo(final BarSeries barSeries) {
this.baseBarSeries = Objects.requireNonNull(barSeries);
return this;
}
/**
* Ingests a trade into the current time bar and adds/replaces the bar in the
* bound series. Bars are aligned to UTC epoch boundaries based on the current
* {@link #timePeriod}. When the trade time skips one or more full periods,
* those intervals are omitted; no bars are inserted for the gap.
*
* @param time the trade timestamp (UTC)
* @param tradeVolume the traded volume
* @param tradePrice the traded price
*
* @since 0.22.0
*/
@Override
public void addTrade(final Instant time, final Num tradeVolume, final Num tradePrice) {
addTrade(time, tradeVolume, tradePrice, null, null);
}
/**
* Ingests a trade into the current time bar and adds/replaces the bar in the
* bound series. Bars are aligned to UTC epoch boundaries based on the current
* {@link #timePeriod}. When the trade time skips one or more full periods,
* those intervals are omitted; no bars are inserted for the gap.
*
* @param time the trade timestamp (UTC)
* @param tradeVolume the traded volume
* @param tradePrice the traded price
* @param side aggressor side (optional)
* @param liquidity liquidity classification (optional)
*
* @since 0.22.2
*/
@Override
public void addTrade(final Instant time, final Num tradeVolume, final Num tradePrice, final RealtimeBar.Side side,
final RealtimeBar.Liquidity liquidity) {
Objects.requireNonNull(time, "time");
Objects.requireNonNull(tradeVolume, "tradeVolume");
Objects.requireNonNull(tradePrice, "tradePrice");
ensureRealtimeTracking(side, liquidity);
if (timePeriod == null) {
throw new IllegalStateException("Time period must be set before ingesting trades");
}
Objects.requireNonNull(baseBarSeries, "barSeries");
ensureTimeRange(time);
if (time.isBefore(beginTime)) {
throw new IllegalArgumentException(
String.format("Trade time %s is before current bar begin time %s", time, beginTime));
}
Instant previousEndTime = endTime;
long skippedPeriods = 0;
while (!time.isBefore(endTime)) {
persistCurrentBarIfPresent();
resetTradeState();
beginTime = endTime;
endTime = beginTime.plus(timePeriod);
skippedPeriods++;
}
if (skippedPeriods > 1) {
long missingPeriods = skippedPeriods - 1;
LOG.warn("Detected {} missing bar period(s) between {} and {} for series {}", missingPeriods,
previousEndTime, beginTime, baseBarSeries.getName());
}
recordTrade(tradeVolume, tradePrice, side, liquidity);
baseBarSeries.addBar(build(), shouldReplaceCurrentBar());
}
@Override
public Bar build() {
if (realtimeBars) {
return new BaseRealtimeBar(this.timePeriod, this.beginTime, this.endTime, this.openPrice, this.highPrice,
this.lowPrice, this.closePrice, this.volume, this.amount, this.trades, buyVolume, sellVolume,
buyAmount, sellAmount, buyTrades, sellTrades, makerVolume, takerVolume, makerAmount, takerAmount,
makerTrades, takerTrades, hasSideData, hasLiquidityData, numFactory);
}
return new BaseBar(this.timePeriod, this.beginTime, this.endTime, this.openPrice, this.highPrice, this.lowPrice,
this.closePrice, this.volume, this.amount, this.trades);
}
@Override
public void add() {
if (amount == null && closePrice != null && volume != null) {
amount = closePrice.multipliedBy(volume);
}
this.baseBarSeries.addBar(build());
}
private void ensureTimeRange(final Instant time) {
if (beginTime == null && endTime == null) {
beginTime = alignToTimePeriodStart(time);
endTime = beginTime.plus(timePeriod);
return;
}
if (beginTime == null) {
beginTime = endTime.minus(timePeriod);
} else if (endTime == null) {
endTime = beginTime.plus(timePeriod);
}
}
private Instant alignToTimePeriodStart(final Instant time) {
try {
final long periodNanos = timePeriod.toNanos();
if (periodNanos <= 0) {
throw new IllegalStateException("Time period must be positive");
}
final long timeNanos = Math.addExact(Math.multiplyExact(time.getEpochSecond(), 1_000_000_000L),
time.getNano());
final long alignedNanos = timeNanos - Math.floorMod(timeNanos, periodNanos);
final long alignedSeconds = Math.floorDiv(alignedNanos, 1_000_000_000L);
final int alignedNanoPart = (int) Math.floorMod(alignedNanos, 1_000_000_000L);
return Instant.ofEpochSecond(alignedSeconds, alignedNanoPart);
} catch (ArithmeticException ex) {
throw new IllegalStateException("Time period too large to align trade time", ex);
}
}
private void recordTrade(final Num tradeVolume, final Num tradePrice, final RealtimeBar.Side side,
final RealtimeBar.Liquidity liquidity) {
if (openPrice == null) {
openPrice = tradePrice;
highPrice = tradePrice;
lowPrice = tradePrice;
} else {
highPrice = highPrice == null ? tradePrice : highPrice.max(tradePrice);
lowPrice = lowPrice == null ? tradePrice : lowPrice.min(tradePrice);
}
closePrice = tradePrice;
volume = volume == null ? tradeVolume : volume.plus(tradeVolume);
Num tradeAmount = tradePrice.multipliedBy(tradeVolume);
amount = amount == null ? tradeAmount : amount.plus(tradeAmount);
trades++;
if (side != null) {
hasSideData = true;
if (side == RealtimeBar.Side.BUY) {
buyVolume = buyVolume == null ? tradeVolume : buyVolume.plus(tradeVolume);
buyAmount = buyAmount == null ? tradeAmount : buyAmount.plus(tradeAmount);
buyTrades++;
} else {
sellVolume = sellVolume == null ? tradeVolume : sellVolume.plus(tradeVolume);
sellAmount = sellAmount == null ? tradeAmount : sellAmount.plus(tradeAmount);
sellTrades++;
}
}
if (liquidity != null) {
hasLiquidityData = true;
if (liquidity == RealtimeBar.Liquidity.MAKER) {
makerVolume = makerVolume == null ? tradeVolume : makerVolume.plus(tradeVolume);
makerAmount = makerAmount == null ? tradeAmount : makerAmount.plus(tradeAmount);
makerTrades++;
} else {
takerVolume = takerVolume == null ? tradeVolume : takerVolume.plus(tradeVolume);
takerAmount = takerAmount == null ? tradeAmount : takerAmount.plus(tradeAmount);
takerTrades++;
}
}
}
private void persistCurrentBarIfPresent() {
if (!hasBarData()) {
return;
}
if (amount == null && closePrice != null && volume != null) {
amount = closePrice.multipliedBy(volume);
}
baseBarSeries.addBar(build(), shouldReplaceCurrentBar());
}
private boolean shouldReplaceCurrentBar() {
if (baseBarSeries == null || baseBarSeries.isEmpty()) {
return false;
}
return baseBarSeries.getLastBar().getEndTime().equals(endTime);
}
private boolean hasBarData() {
return openPrice != null || highPrice != null || lowPrice != null || closePrice != null || volume != null
|| amount != null || trades > 0 || hasSideData || hasLiquidityData || buyVolume != null
|| sellVolume != null || buyAmount != null || sellAmount != null || buyTrades > 0 || sellTrades > 0
|| makerVolume != null || takerVolume != null || makerAmount != null || takerAmount != null
|| makerTrades > 0 || takerTrades > 0;
}
private void resetTradeState() {
openPrice = null;
highPrice = null;
lowPrice = null;
closePrice = null;
volume = null;
amount = null;
trades = 0;
buyVolume = null;
sellVolume = null;
buyAmount = null;
sellAmount = null;
buyTrades = 0;
sellTrades = 0;
hasSideData = false;
makerVolume = null;
takerVolume = null;
makerAmount = null;
takerAmount = null;
makerTrades = 0;
takerTrades = 0;
hasLiquidityData = false;
}
private void ensureRealtimeTracking(final RealtimeBar.Side side, final RealtimeBar.Liquidity liquidity) {
if (!realtimeBars && (side != null || liquidity != null)) {
throw new IllegalStateException("Realtime trade data requires a realtime bar builder");
}
}
}
@@ -0,0 +1,70 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.bars;
import java.time.Duration;
import org.ta4j.core.BarBuilderFactory;
import org.ta4j.core.BaseRealtimeBar;
import org.ta4j.core.BarBuilder;
import org.ta4j.core.BarSeries;
public class TimeBarBuilderFactory implements BarBuilderFactory {
private final boolean realtimeBars;
private final Duration timePeriod;
/**
* Constructor.
*
* @since 0.22.2
*/
public TimeBarBuilderFactory() {
this(null, false);
}
/**
* Constructor.
*
* @param realtimeBars {@code true} to build {@link BaseRealtimeBar} instances
*
* @since 0.22.2
*/
public TimeBarBuilderFactory(final boolean realtimeBars) {
this(null, realtimeBars);
}
/**
* Constructor.
*
* @param timePeriod the default time period for time bars
*
* @since 0.22.0
*/
public TimeBarBuilderFactory(final Duration timePeriod) {
this(timePeriod, false);
}
/**
* Constructor.
*
* @param timePeriod the default time period for time bars
* @param realtimeBars {@code true} to build {@link BaseRealtimeBar} instances
*
* @since 0.22.2
*/
public TimeBarBuilderFactory(final Duration timePeriod, final boolean realtimeBars) {
this.timePeriod = timePeriod;
this.realtimeBars = realtimeBars;
}
@Override
public BarBuilder createBarBuilder(BarSeries series) {
BarBuilder builder = new TimeBarBuilder(series.numFactory(), realtimeBars).bindTo(series);
if (timePeriod != null) {
builder.timePeriod(timePeriod);
}
return builder;
}
}
@@ -0,0 +1,531 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.bars;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import org.ta4j.core.Bar;
import org.ta4j.core.BarBuilder;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBar;
import org.ta4j.core.BaseRealtimeBar;
import org.ta4j.core.RealtimeBar;
import org.ta4j.core.num.DoubleNumFactory;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* A volume bar is sampled after a fixed number of contracts (volume) have been
* traded.
*/
public class VolumeBarBuilder implements BarBuilder {
private final NumFactory numFactory;
private final RemainderCarryOverPolicy carryOverPolicy;
private final boolean realtimeBars;
private final Num volumeThreshold;
private BarSeries barSeries;
private Duration timePeriod;
private Instant beginTime;
private Instant endTime;
private Num volume;
private Num openPrice;
private Num highPrice;
private Num closePrice;
private Num lowPrice;
private Num amount;
private long trades;
private Num buyVolume;
private Num sellVolume;
private Num buyAmount;
private Num sellAmount;
private long buyTrades;
private long sellTrades;
private boolean hasSideData;
private Num makerVolume;
private Num takerVolume;
private Num makerAmount;
private Num takerAmount;
private long makerTrades;
private long takerTrades;
private boolean hasLiquidityData;
private Num lastTradeVolume;
private Num lastTradePrice;
private RealtimeBar.Side lastTradeSide;
private RealtimeBar.Liquidity lastTradeLiquidity;
/**
* A builder to build a new {@link BaseBar} with {@link DoubleNumFactory}
*
* @param volumeThreshold the threshold at which a new bar should be created
*/
public VolumeBarBuilder(final int volumeThreshold) {
this(DoubleNumFactory.getInstance(), volumeThreshold, false, RemainderCarryOverPolicy.NONE);
}
/**
* A builder to build a new {@link BaseBar}
*
* @param numFactory
* @param volumeThreshold the threshold at which a new bar should be created
*/
public VolumeBarBuilder(final NumFactory numFactory, final int volumeThreshold) {
this(numFactory, volumeThreshold, false, RemainderCarryOverPolicy.NONE);
}
/**
* A builder to build a new {@link BaseBar} or {@link BaseRealtimeBar}
*
* @param numFactory
* @param volumeThreshold the threshold at which a new bar should be created
* @param realtimeBars {@code true} to build {@link BaseRealtimeBar}
* instances
*
* @since 0.22.0
*/
public VolumeBarBuilder(final NumFactory numFactory, final int volumeThreshold, final boolean realtimeBars) {
this(numFactory, volumeThreshold, realtimeBars, RemainderCarryOverPolicy.NONE);
}
/**
* A builder to build a new {@link BaseBar} or {@link BaseRealtimeBar}
*
* @param numFactory the backing number factory
* @param volumeThreshold the threshold at which a new bar should be created
* @param realtimeBars {@code true} to build {@link BaseRealtimeBar}
* instances
* @param carryOverPolicy policy for handling side/liquidity remainder splits
*
* @since 0.22.0
*/
public VolumeBarBuilder(final NumFactory numFactory, final int volumeThreshold, final boolean realtimeBars,
final RemainderCarryOverPolicy carryOverPolicy) {
this.numFactory = numFactory;
this.carryOverPolicy = carryOverPolicy == null ? RemainderCarryOverPolicy.NONE : carryOverPolicy;
this.realtimeBars = realtimeBars;
this.volumeThreshold = numFactory.numOf(volumeThreshold);
reset();
}
@Override
public BarBuilder timePeriod(final Duration timePeriod) {
this.timePeriod = this.timePeriod == null ? timePeriod : this.timePeriod.plus(timePeriod);
return this;
}
@Override
public BarBuilder beginTime(final Instant beginTime) {
this.beginTime = beginTime;
return this;
}
@Override
public BarBuilder endTime(final Instant endTime) {
this.endTime = endTime;
return this;
}
@Override
public BarBuilder openPrice(final Num openPrice) {
throw new IllegalArgumentException("VolumeBar can only be built from closePrice");
}
@Override
public BarBuilder openPrice(final Number openPrice) {
throw new IllegalArgumentException("VolumeBar can only be built from closePrice");
}
@Override
public BarBuilder openPrice(final String openPrice) {
throw new IllegalArgumentException("VolumeBar can only be built from closePrice");
}
@Override
public BarBuilder highPrice(final Number highPrice) {
throw new IllegalArgumentException("VolumeBar can only be built from closePrice");
}
@Override
public BarBuilder highPrice(final String highPrice) {
throw new IllegalArgumentException("VolumeBar can only be built from closePrice");
}
@Override
public BarBuilder highPrice(final Num highPrice) {
throw new IllegalArgumentException("VolumeBar can only be built from closePrice");
}
@Override
public BarBuilder lowPrice(final Num lowPrice) {
throw new IllegalArgumentException("VolumeBar can only be built from closePrice");
}
@Override
public BarBuilder lowPrice(final Number lowPrice) {
throw new IllegalArgumentException("VolumeBar can only be built from closePrice");
}
@Override
public BarBuilder lowPrice(final String lowPrice) {
throw new IllegalArgumentException("VolumeBar can only be built from closePrice");
}
@Override
public BarBuilder closePrice(final Num tickPrice) {
closePrice = tickPrice;
if (openPrice == null) {
openPrice = tickPrice;
}
highPrice = highPrice.max(tickPrice);
lowPrice = lowPrice.min(tickPrice);
return this;
}
@Override
public BarBuilder closePrice(final Number closePrice) {
return closePrice(numFactory.numOf(closePrice));
}
@Override
public BarBuilder closePrice(final String closePrice) {
return closePrice(numFactory.numOf(closePrice));
}
@Override
public BarBuilder volume(final Num volume) {
this.volume = this.volume == null ? volume : this.volume.plus(volume);
return this;
}
@Override
public BarBuilder volume(final Number volume) {
volume(numFactory.numOf(volume));
return this;
}
@Override
public BarBuilder volume(final String volume) {
volume(numFactory.numOf(volume));
return this;
}
@Override
public BarBuilder amount(final Num amount) {
this.amount = this.amount == null ? amount : this.amount.plus(amount);
return this;
}
@Override
public BarBuilder amount(final Number amount) {
amount(numFactory.numOf(amount));
return this;
}
@Override
public BarBuilder amount(final String amount) {
amount(numFactory.numOf(amount));
return this;
}
@Override
public BarBuilder trades(final long trades) {
this.trades += trades;
return this;
}
@Override
public BarBuilder trades(final String trades) {
trades(Long.parseLong(trades));
return this;
}
@Override
public VolumeBarBuilder bindTo(final BarSeries barSeries) {
this.barSeries = Objects.requireNonNull(barSeries);
return this;
}
/**
* Ingests a trade into the current volume bar and appends the bar once the
* volume threshold is met.
*
* @param time the trade timestamp (UTC)
* @param tradeVolume the traded volume
* @param tradePrice the traded price
*
* @since 0.22.0
*/
@Override
public void addTrade(final Instant time, final Num tradeVolume, final Num tradePrice) {
addTrade(time, tradeVolume, tradePrice, null, null);
}
/**
* Ingests a trade into the current volume bar and appends the bar once the
* volume threshold is met.
*
* @param time the trade timestamp (UTC)
* @param tradeVolume the traded volume
* @param tradePrice the traded price
* @param side aggressor side (optional)
* @param liquidity liquidity classification (optional)
*
* @since 0.22.0
*/
@Override
public void addTrade(final Instant time, final Num tradeVolume, final Num tradePrice, final RealtimeBar.Side side,
final RealtimeBar.Liquidity liquidity) {
Objects.requireNonNull(time, "time");
Objects.requireNonNull(tradeVolume, "tradeVolume");
Objects.requireNonNull(tradePrice, "tradePrice");
ensureRealtimeTracking(side, liquidity);
if (endTime != null && time.isBefore(endTime)) {
throw new IllegalArgumentException(
String.format("Trade time %s is before current bar end time %s", time, endTime));
}
if (beginTime == null) {
beginTime = time;
}
endTime = time;
closePrice(tradePrice);
volume(tradeVolume);
trades(1);
lastTradeVolume = tradeVolume;
lastTradePrice = tradePrice;
lastTradeSide = side;
lastTradeLiquidity = liquidity;
recordRealtimeTrade(tradeVolume, tradePrice, side, liquidity);
add();
}
/**
* Builds bar from current state that is modified for each tick.
*
* @return snapshot of current state
*/
@Override
public Bar build() {
if (realtimeBars) {
return new BaseRealtimeBar(timePeriod, beginTime, endTime, openPrice, highPrice, lowPrice, closePrice,
volume, amount, trades, buyVolume, sellVolume, buyAmount, sellAmount, buyTrades, sellTrades,
makerVolume, takerVolume, makerAmount, takerAmount, makerTrades, takerTrades, hasSideData,
hasLiquidityData, numFactory);
}
return new BaseBar(timePeriod, beginTime, endTime, openPrice, highPrice, lowPrice, closePrice, volume, amount,
trades);
}
@Override
public void add() {
if (volume.isGreaterThanOrEqual(volumeThreshold)) {
// move volume remainder to next bar
var volumeRemainder = numFactory.zero();
CarryOverSnapshot carryOverSnapshot = null;
if (volume.isGreaterThan(volumeThreshold)) {
volumeRemainder = volume.minus(volumeThreshold);
// cap currently built bar, volume is then restored to volumeRemainder
volume = volumeThreshold;
if (carryOverPolicy == RemainderCarryOverPolicy.PROPORTIONAL
|| carryOverPolicy == RemainderCarryOverPolicy.PROPORTIONAL_WITH_TRADE_COUNT) {
carryOverSnapshot = applyProportionalCarryOver(volumeRemainder);
}
}
if (amount == null) {
amount = closePrice.multipliedBy(volume);
}
barSeries.addBar(build());
volume = volumeRemainder;
reset();
if (carryOverSnapshot != null) {
carryOverSnapshot.applyTo(this);
}
}
}
private void reset() {
timePeriod = null;
beginTime = null;
endTime = null;
openPrice = null;
highPrice = numFactory.zero();
lowPrice = numFactory.numOf(Integer.MAX_VALUE);
amount = null;
trades = 0;
closePrice = null;
buyVolume = null;
sellVolume = null;
buyAmount = null;
sellAmount = null;
buyTrades = 0;
sellTrades = 0;
hasSideData = false;
makerVolume = null;
takerVolume = null;
makerAmount = null;
takerAmount = null;
makerTrades = 0;
takerTrades = 0;
hasLiquidityData = false;
lastTradeVolume = null;
lastTradePrice = null;
lastTradeSide = null;
lastTradeLiquidity = null;
}
private CarryOverSnapshot applyProportionalCarryOver(final Num volumeRemainder) {
if (volumeRemainder == null || volumeRemainder.isZero() || lastTradeVolume == null || lastTradePrice == null) {
return null;
}
final CarryOverSnapshot snapshot = new CarryOverSnapshot();
final Num remainderAmount = lastTradePrice.multipliedBy(volumeRemainder);
final boolean carryTradeCount = shouldCarryTradeCount(volumeRemainder);
if (lastTradeSide != null) {
if (lastTradeSide == RealtimeBar.Side.BUY) {
buyVolume = subtractOrNull(buyVolume, volumeRemainder);
buyAmount = subtractOrNull(buyAmount, remainderAmount);
snapshot.buyVolume = volumeRemainder;
snapshot.buyAmount = remainderAmount;
if (carryTradeCount) {
buyTrades = Math.max(0, buyTrades - 1);
snapshot.buyTrades = 1;
}
} else {
sellVolume = subtractOrNull(sellVolume, volumeRemainder);
sellAmount = subtractOrNull(sellAmount, remainderAmount);
snapshot.sellVolume = volumeRemainder;
snapshot.sellAmount = remainderAmount;
if (carryTradeCount) {
sellTrades = Math.max(0, sellTrades - 1);
snapshot.sellTrades = 1;
}
}
}
if (lastTradeLiquidity != null) {
if (lastTradeLiquidity == RealtimeBar.Liquidity.MAKER) {
makerVolume = subtractOrNull(makerVolume, volumeRemainder);
makerAmount = subtractOrNull(makerAmount, remainderAmount);
snapshot.makerVolume = volumeRemainder;
snapshot.makerAmount = remainderAmount;
if (carryTradeCount) {
makerTrades = Math.max(0, makerTrades - 1);
snapshot.makerTrades = 1;
}
} else {
takerVolume = subtractOrNull(takerVolume, volumeRemainder);
takerAmount = subtractOrNull(takerAmount, remainderAmount);
snapshot.takerVolume = volumeRemainder;
snapshot.takerAmount = remainderAmount;
if (carryTradeCount) {
takerTrades = Math.max(0, takerTrades - 1);
snapshot.takerTrades = 1;
}
}
}
if (carryTradeCount) {
trades = Math.max(0, trades - 1);
snapshot.trades = 1;
}
snapshot.hasSideData = snapshot.buyVolume != null || snapshot.sellVolume != null;
snapshot.hasLiquidityData = snapshot.makerVolume != null || snapshot.takerVolume != null;
return snapshot;
}
private boolean shouldCarryTradeCount(final Num volumeRemainder) {
if (carryOverPolicy != RemainderCarryOverPolicy.PROPORTIONAL_WITH_TRADE_COUNT) {
return false;
}
if (lastTradeVolume == null || lastTradeVolume.isZero()) {
return false;
}
return volumeRemainder.multipliedBy(numFactory.numOf(2)).isGreaterThanOrEqual(lastTradeVolume);
}
private Num subtractOrNull(final Num current, final Num remainder) {
if (current == null) {
return null;
}
final Num updated = current.minus(remainder);
return updated.isZero() ? null : updated;
}
private static final class CarryOverSnapshot {
private Num buyVolume;
private Num sellVolume;
private Num buyAmount;
private Num sellAmount;
private Num makerVolume;
private Num takerVolume;
private Num makerAmount;
private Num takerAmount;
private long trades;
private long buyTrades;
private long sellTrades;
private long makerTrades;
private long takerTrades;
private boolean hasSideData;
private boolean hasLiquidityData;
private void applyTo(final VolumeBarBuilder builder) {
builder.buyVolume = buyVolume;
builder.sellVolume = sellVolume;
builder.buyAmount = buyAmount;
builder.sellAmount = sellAmount;
builder.makerVolume = makerVolume;
builder.takerVolume = takerVolume;
builder.makerAmount = makerAmount;
builder.takerAmount = takerAmount;
builder.trades = trades;
builder.buyTrades = buyTrades;
builder.sellTrades = sellTrades;
builder.makerTrades = makerTrades;
builder.takerTrades = takerTrades;
builder.hasSideData = hasSideData;
builder.hasLiquidityData = hasLiquidityData;
}
}
private void recordRealtimeTrade(final Num tradeVolume, final Num tradePrice, final RealtimeBar.Side side,
final RealtimeBar.Liquidity liquidity) {
if (side != null) {
hasSideData = true;
final Num tradeAmount = tradePrice.multipliedBy(tradeVolume);
if (side == RealtimeBar.Side.BUY) {
buyVolume = buyVolume == null ? tradeVolume : buyVolume.plus(tradeVolume);
buyAmount = buyAmount == null ? tradeAmount : buyAmount.plus(tradeAmount);
buyTrades++;
} else {
sellVolume = sellVolume == null ? tradeVolume : sellVolume.plus(tradeVolume);
sellAmount = sellAmount == null ? tradeAmount : sellAmount.plus(tradeAmount);
sellTrades++;
}
}
if (liquidity != null) {
hasLiquidityData = true;
final Num tradeAmount = tradePrice.multipliedBy(tradeVolume);
if (liquidity == RealtimeBar.Liquidity.MAKER) {
makerVolume = makerVolume == null ? tradeVolume : makerVolume.plus(tradeVolume);
makerAmount = makerAmount == null ? tradeAmount : makerAmount.plus(tradeAmount);
makerTrades++;
} else {
takerVolume = takerVolume == null ? tradeVolume : takerVolume.plus(tradeVolume);
takerAmount = takerAmount == null ? tradeAmount : takerAmount.plus(tradeAmount);
takerTrades++;
}
}
}
private void ensureRealtimeTracking(final RealtimeBar.Side side, final RealtimeBar.Liquidity liquidity) {
if (!realtimeBars && (side != null || liquidity != null)) {
throw new IllegalStateException("Realtime trade data requires a realtime bar builder");
}
}
}
@@ -0,0 +1,78 @@
/*
* SPDX-License-Identifier: MIT
*/
package org.ta4j.core.bars;
import org.ta4j.core.BarBuilderFactory;
import org.ta4j.core.BaseRealtimeBar;
import org.ta4j.core.BarBuilder;
import org.ta4j.core.BarSeries;
public class VolumeBarBuilderFactory implements BarBuilderFactory {
private final int volumeThreshold;
private final RemainderCarryOverPolicy carryOverPolicy;
private final boolean realtimeBars;
private transient VolumeBarBuilder barBuilder;
/**
* Constructor.
*
* @param volumeThreshold the threshold at which a new bar should be created
*/
public VolumeBarBuilderFactory(final int volumeThreshold) {
this(volumeThreshold, false, RemainderCarryOverPolicy.NONE);
}
/**
* Constructor.
*
* @param volumeThreshold the threshold at which a new bar should be created
* @param realtimeBars {@code true} to build {@link BaseRealtimeBar}
* instances
*
* @since 0.22.2
*/
public VolumeBarBuilderFactory(final int volumeThreshold, final boolean realtimeBars) {
this(volumeThreshold, realtimeBars, RemainderCarryOverPolicy.NONE);
}
/**
* Constructor.
*
* @param volumeThreshold the threshold at which a new bar should be created
* @param carryOverPolicy policy for handling side/liquidity remainder splits
*
* @since 0.22.0
*/
public VolumeBarBuilderFactory(final int volumeThreshold, final RemainderCarryOverPolicy carryOverPolicy) {
this(volumeThreshold, false, carryOverPolicy);
}
/**
* Constructor.
*
* @param volumeThreshold the threshold at which a new bar should be created
* @param realtimeBars {@code true} to build {@link BaseRealtimeBar}
* instances
* @param carryOverPolicy policy for handling side/liquidity remainder splits
*
* @since 0.22.2
*/
public VolumeBarBuilderFactory(final int volumeThreshold, final boolean realtimeBars,
final RemainderCarryOverPolicy carryOverPolicy) {
this.volumeThreshold = volumeThreshold;
this.realtimeBars = realtimeBars;
this.carryOverPolicy = carryOverPolicy == null ? RemainderCarryOverPolicy.NONE : carryOverPolicy;
}
@Override
public BarBuilder createBarBuilder(final BarSeries series) {
if (this.barBuilder == null) {
this.barBuilder = new VolumeBarBuilder(series.numFactory(), this.volumeThreshold, this.realtimeBars,
this.carryOverPolicy).bindTo(series);
}
return this.barBuilder;
}
}
@@ -0,0 +1,12 @@
/*
* SPDX-License-Identifier: MIT
*/
/**
* Bar builder factories and bar-shape construction utilities.
*
* <p>
* Use this package when configuring series aggregation behavior (time bars, and
* other factory-driven bar construction patterns).
* </p>
*/
package org.ta4j.core.bars;
@@ -0,0 +1,6 @@
# AGENTS instructions for org.ta4j.core.criteria
- When implementing `AnalysisCriterion#calculate(BarSeries, TradingRecord)`, prefer delegating to the
`calculate(BarSeries, Position)` implementation for each closed position when it is straightforward.
- It is fine to keep bespoke implementations when delegation would be awkward or inefficient, but all things
being equal, reuse the position-level calculation to keep logic DRY and avoid drift.

Some files were not shown because too many files have changed in this diff Show More