/* * The MIT License (MIT) * * Copyright (c) 2017-2025 Ta4j Organization & respective * authors (see AUTHORS) * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in * the Software without restriction, including without limitation the rights to * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of * the Software, and to permit persons to whom the Software is furnished to do so, * subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package ta4jexamples.datasources; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.ta4j.core.Bar; import org.ta4j.core.BarSeries; import org.ta4j.core.BaseBarSeriesBuilder; import ta4jexamples.datasources.http.AbstractHttpBarSeriesDataSource; import ta4jexamples.datasources.http.DefaultHttpClientWrapper; import ta4jexamples.datasources.http.HttpClientWrapper; import ta4jexamples.datasources.http.HttpResponseWrapper; import ta4jexamples.datasources.json.AdaptiveBarSeriesTypeAdapter; import java.io.IOException; import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.TreeMap; /** * Loads OHLCV data from Coinbase Advanced Trade API. *

* This loader fetches historical price data from Coinbase's public market data * API without requiring authentication. It supports all Coinbase trading pairs * (e.g., BTC-USD, ETH-USD). *

* Example usage: * *

 * // Load 1 year of daily data for Bitcoin (using days)
 * BarSeries series = CoinbaseHttpBarSeriesDataSource.loadSeries("BTC-USD", 365);
 *
 * // Load 500 bars of hourly data for Ethereum (using bar count)
 * BarSeries ethSeries = CoinbaseHttpBarSeriesDataSource.loadSeries("ETH-USD", CoinbaseInterval.ONE_HOUR, 500);
 *
 * // Load data for a specific date range
 * Instant start = Instant.parse("2023-01-01T00:00:00Z");
 * Instant end = Instant.parse("2023-12-31T23:59:59Z");
 * BarSeries btcSeries = CoinbaseHttpBarSeriesDataSource.loadSeries("BTC-USD", CoinbaseInterval.ONE_DAY, start, end);
 * 
*

* Response Caching: To enable response caching for faster * subsequent requests, use the constructor with {@code enableResponseCaching}: * *

 * CoinbaseHttpBarSeriesDataSource loader = new CoinbaseHttpBarSeriesDataSource(true);
 * BarSeries series = loader.loadSeriesInstance("BTC-USD", CoinbaseInterval.ONE_DAY, start, end);
 * 
*

* To use a custom cache directory, use the constructor with * {@code responseCacheDir}: * *

 * CoinbaseHttpBarSeriesDataSource loader = new CoinbaseHttpBarSeriesDataSource("/path/to/cache");
 * BarSeries series = loader.loadSeriesInstance("BTC-USD", CoinbaseInterval.ONE_DAY, start, end);
 * 
*

* When caching is enabled, responses are saved to the cache directory (default: * {@code temp/responses}) and reused for requests within the cache validity * period (based on the interval). For example, daily data is cached for the * day, 15-minute data is cached for 15 minutes, etc. Historical data (end date * in the past) is cached indefinitely. *

* Unit Testing: For unit testing with a mock HttpClient, use * the constructor: * *

 * HttpClientWrapper mockHttpClient = mock(HttpClientWrapper.class);
 * CoinbaseHttpBarSeriesDataSource loader = new CoinbaseHttpBarSeriesDataSource(mockHttpClient);
 * // Use loader instance methods or inject into your code
 * 
*

* API Limits: Coinbase API has a maximum of 350 candles per * request. This implementation automatically paginates large requests into * multiple API calls and merges the results. *

* Note: This uses Coinbase's public market data endpoint which * does not require authentication. For production use with higher rate limits, * consider using authenticated endpoints. * * @since 0.20 */ public class CoinbaseHttpBarSeriesDataSource extends AbstractHttpBarSeriesDataSource { public static final String COINBASE_API_URL = "https://api.coinbase.com/api/v3/brokerage/market/products/"; public static final int MAX_CANDLES_PER_REQUEST = 350; private static final Logger LOG = LogManager.getLogger(CoinbaseHttpBarSeriesDataSource.class); @Override public String getSourceName() { return "Coinbase"; } private static final HttpClientWrapper DEFAULT_HTTP_CLIENT = new DefaultHttpClientWrapper(); private static final CoinbaseHttpBarSeriesDataSource DEFAULT_INSTANCE = new CoinbaseHttpBarSeriesDataSource( DEFAULT_HTTP_CLIENT); /** * Creates a new CoinbaseHttpBarSeriesDataSource with a default HttpClient. For * unit testing, use {@link #CoinbaseHttpBarSeriesDataSource(HttpClientWrapper)} * to inject a mock HttpClientWrapper. */ public CoinbaseHttpBarSeriesDataSource() { super(DEFAULT_HTTP_CLIENT, false); } /** * Creates a new CoinbaseHttpBarSeriesDataSource with a default HttpClient and * caching option. * * @param enableResponseCaching if true, responses will be cached to disk for * faster subsequent requests */ public CoinbaseHttpBarSeriesDataSource(boolean enableResponseCaching) { super(DEFAULT_HTTP_CLIENT, enableResponseCaching); } /** * Creates a new CoinbaseHttpBarSeriesDataSource with a default HttpClient and * custom cache directory. Response caching is automatically enabled when a * cache directory is specified. * * @param responseCacheDir the directory path for caching responses (can be * relative or absolute) */ public CoinbaseHttpBarSeriesDataSource(String responseCacheDir) { super(DEFAULT_HTTP_CLIENT, responseCacheDir); } /** * Creates a new CoinbaseHttpBarSeriesDataSource with the specified * HttpClientWrapper. This constructor allows dependency injection of a mock * HttpClientWrapper for unit testing. * * @param httpClient the HttpClientWrapper to use for API requests (can be a * mock for testing) */ public CoinbaseHttpBarSeriesDataSource(HttpClientWrapper httpClient) { super(httpClient, false); } /** * Creates a new CoinbaseHttpBarSeriesDataSource with the specified * HttpClientWrapper and caching option. This constructor allows dependency * injection of a mock HttpClientWrapper for unit testing and enables response * caching. * * @param httpClient the HttpClientWrapper to use for API requests * (can be a mock for testing) * @param enableResponseCaching if true, responses will be cached to disk for * faster subsequent requests */ public CoinbaseHttpBarSeriesDataSource(HttpClientWrapper httpClient, boolean enableResponseCaching) { super(httpClient, enableResponseCaching); } /** * Creates a new CoinbaseHttpBarSeriesDataSource with the specified HttpClient. * This is a convenience constructor that wraps the HttpClient in a * DefaultHttpClientWrapper. * * @param httpClient the HttpClient to use for API requests */ public CoinbaseHttpBarSeriesDataSource(HttpClient httpClient) { super(httpClient, false); } /** * Creates a new CoinbaseHttpBarSeriesDataSource with the specified HttpClient * and caching option. * * @param httpClient the HttpClient to use for API requests * @param enableResponseCaching if true, responses will be cached to disk for * faster subsequent requests */ public CoinbaseHttpBarSeriesDataSource(HttpClient httpClient, boolean enableResponseCaching) { super(httpClient, enableResponseCaching); } /** * Creates a new CoinbaseHttpBarSeriesDataSource with the specified * HttpClientWrapper and custom cache directory. Response caching is * automatically enabled when a cache directory is specified. * * @param httpClient the HttpClientWrapper to use for API requests (can be * a mock for testing) * @param responseCacheDir the directory path for caching responses (can be * relative or absolute) */ public CoinbaseHttpBarSeriesDataSource(HttpClientWrapper httpClient, String responseCacheDir) { super(httpClient, responseCacheDir); } /** * Creates a new CoinbaseHttpBarSeriesDataSource with the specified HttpClient * and custom cache directory. Response caching is automatically enabled when a * cache directory is specified. * * @param httpClient the HttpClient to use for API requests * @param responseCacheDir the directory path for caching responses (can be * relative or absolute) */ public CoinbaseHttpBarSeriesDataSource(HttpClient httpClient, String responseCacheDir) { super(httpClient, responseCacheDir); } /** * Loads historical OHLCV data for a given product ID within a specified date * range. This is the base method that all other convenience methods delegate * to. *

* Automatic Pagination: If the requested date range would * exceed 350 candles (Coinbase's maximum per request), this method * automatically splits the request into multiple smaller chunks, fetches them * sequentially, and merges the results into a single BarSeries. This ensures * reliable data retrieval for large date ranges while respecting API limits. * * @param productId the product ID (e.g., "BTC-USD", "ETH-USD") * @param interval the bar interval (must be one of the supported Coinbase * intervals) * @param startDateTime the start date/time for the data range (inclusive) * @param endDateTime the end date/time for the data range (inclusive) * @return a BarSeries containing the historical data, or null if the request * fails */ public static BarSeries loadSeries(String productId, CoinbaseInterval interval, Instant startDateTime, Instant endDateTime) { return DEFAULT_INSTANCE.loadSeriesInstance(productId, interval, startDateTime, endDateTime); } /** * Loads historical OHLCV data for a given product ID with a specified number of * bars. The end date/time is set to the current time, and the start date/time * is calculated based on the bar count and interval. *

* Note: If the calculated date range would exceed 350 candles, * this method will automatically paginate the request into multiple API calls * and merge the results. This ensures reliable data retrieval for large bar * counts. * * @param productId the product ID (e.g., "BTC-USD", "ETH-USD") * @param interval the bar interval (must be one of the supported Coinbase * intervals) * @param barCount the number of bars to fetch * @return a BarSeries containing the historical data, or null if the request * fails */ public static BarSeries loadSeries(String productId, CoinbaseInterval interval, int barCount) { if (barCount <= 0) { LOG.error("Bar count must be greater than 0"); return null; } Instant endDateTime = Instant.now(); Duration totalDuration = interval.getDuration().multipliedBy(barCount); Instant startDateTime = endDateTime.minus(totalDuration); return loadSeries(productId, interval, startDateTime, endDateTime); } /** * Loads historical OHLCV data for a given product ID with daily bars. * Convenience method that uses the number of days to calculate the date range. * * @param productId the product ID (e.g., "BTC-USD", "ETH-USD") * @param days the number of days of historical data to fetch * @return a BarSeries containing the historical data, or null if the request * fails */ public static BarSeries loadSeries(String productId, int days) { return loadSeries(productId, CoinbaseInterval.ONE_DAY, days); } /** * Loads historical OHLCV data for a given product ID with a specified interval. * Convenience method that uses the number of days to calculate the date range. * * @param productId the product ID (e.g., "BTC-USD", "ETH-USD") * @param days the number of days of historical data to fetch * @param interval the bar interval (must be one of the supported Coinbase * intervals) * @return a BarSeries containing the historical data, or null if the request * fails */ public static BarSeries loadSeries(String productId, int days, CoinbaseInterval interval) { if (days <= 0) { LOG.error("Days must be greater than 0"); return null; } Instant endDateTime = Instant.now(); Instant startDateTime = endDateTime.minusSeconds(days * 86400L); return loadSeries(productId, interval, startDateTime, endDateTime); } /** * Parses the Coinbase API JSON response into a BarSeries using * AdaptiveBarSeriesTypeAdapter. *

* This method reuses the existing AdaptiveBarSeriesTypeAdapter which already * supports Coinbase format and handles null values. The adapter correctly * interprets Coinbase's "start" field as the start of the candle period and * calculates the end time as start + interval. The known interval from the API * request is used directly. * * @param jsonResponse the JSON response string from Coinbase API * @param productId the product ID (used as series name) * @param barInterval the known bar interval from the API request * @return a BarSeries containing the parsed data, or null if parsing fails */ private static BarSeries parseCoinbaseResponse(String jsonResponse, String productId, Duration barInterval) { try { JsonObject root = JsonParser.parseString(jsonResponse).getAsJsonObject(); // Use AdaptiveBarSeriesTypeAdapter's static helper method which handles // Coinbase format correctly (treats "start" as start time, calculates end as // start + interval) // and uses the known interval from the API request BarSeries series = AdaptiveBarSeriesTypeAdapter.parseCoinbaseFormat(root, productId, barInterval); if (series == null || series.isEmpty()) { LOG.error("No candles found in Coinbase response for product: {}", productId); return null; } LOG.debug("Successfully loaded {} bars for product {}", series.getBarCount(), productId); return series; } catch (Exception e) { LOG.error("Error parsing Coinbase response for product {}: {}", productId, e.getMessage(), e); return null; } } /** * Merges multiple BarSeries into a single BarSeries, removing duplicates and * sorting chronologically. Uses a TreeMap keyed by timestamp to automatically * handle deduplication and sorting. * * @param chunks list of BarSeries to merge * @param productId the product ID (for the merged series name) * @param barInterval the bar interval * @return a merged BarSeries */ private static BarSeries mergeBarSeries(List chunks, String productId, Duration barInterval) { // Use TreeMap to automatically sort by timestamp and deduplicate TreeMap barMap = new TreeMap<>(); // Collect all bars from all chunks for (BarSeries chunk : chunks) { for (int i = 0; i < chunk.getBarCount(); i++) { var bar = chunk.getBar(i); Instant endTime = bar.getEndTime(); // If we already have a bar at this timestamp, keep the first one barMap.putIfAbsent(endTime, new BarData(bar)); } } // Build the merged series BarSeries merged = new BaseBarSeriesBuilder().withName(productId).build(); for (BarData barData : barMap.values()) { merged.barBuilder() .timePeriod(barInterval) .endTime(barData.endTime) .openPrice(barData.open) .highPrice(barData.high) .lowPrice(barData.low) .closePrice(barData.close) .volume(barData.volume) .amount(0) .add(); } LOG.debug("Merged {} chunks into {} unique bars for product {}", chunks.size(), merged.getBarCount(), productId); return merged; } /** * Instance method that loads historical OHLCV data for a given product ID with * a specified number of bars. The end date/time is set to the current time, and * the start date/time is calculated based on the bar count and interval. * * @param productId the product ID (e.g., "BTC-USD", "ETH-USD") * @param interval the bar interval (must be one of the supported Coinbase * intervals) * @param barCount the number of bars to fetch * @return a BarSeries containing the historical data, or null if the request * fails */ public BarSeries loadSeriesInstance(String productId, CoinbaseInterval interval, int barCount) { return loadSeriesInstance(productId, interval, barCount, null); } /** * Instance method that loads historical OHLCV data for a given product ID with * a specified number of bars and optional notes for cache file naming. The end * date/time is set to the current time, and the start date/time is calculated * based on the bar count and interval. * * @param productId the product ID (e.g., "BTC-USD", "ETH-USD") * @param interval the bar interval (must be one of the supported Coinbase * intervals) * @param barCount the number of bars to fetch * @param notes optional notes to include in cache filename (for uniqueness, * e.g., test identifiers) * @return a BarSeries containing the historical data, or null if the request * fails */ public BarSeries loadSeriesInstance(String productId, CoinbaseInterval interval, int barCount, String notes) { if (barCount <= 0) { LOG.error("Bar count must be greater than 0"); return null; } Instant endDateTime = Instant.now(); Duration totalDuration = interval.getDuration().multipliedBy(barCount); Instant startDateTime = endDateTime.minus(totalDuration); return loadSeriesInstance(productId, interval, startDateTime, endDateTime, notes); } @Override public BarSeries loadSeries(String productId, Duration interval, Instant start, Instant end) { if (productId == null || productId.trim().isEmpty()) { throw new IllegalArgumentException("Product ID cannot be null or empty"); } if (interval == null || interval.isNegative() || interval.isZero()) { throw new IllegalArgumentException("Interval must be positive"); } if (start == null || end == null) { throw new IllegalArgumentException("Start and end dates cannot be null"); } if (start.isAfter(end)) { throw new IllegalArgumentException("Start date must be before or equal to end date"); } // Map Duration to CoinbaseInterval CoinbaseInterval cbInterval = mapDurationToInterval(interval); if (cbInterval == null) { LOG.warn("Unsupported interval duration: {}. Falling back to ONE_DAY", interval); cbInterval = CoinbaseInterval.ONE_DAY; } return loadSeriesInstance(productId, cbInterval, start, end); } @Override public BarSeries loadSeries(String source) { if (source == null || source.trim().isEmpty()) { throw new IllegalArgumentException("Source cannot be null or empty"); } // Check if it's a cache file path String sourcePrefix = getSourceName().isEmpty() ? "" : getSourceName() + "-"; if (source.startsWith(responseCacheDir) || (!sourcePrefix.isEmpty() && source.contains(sourcePrefix))) { Path cacheFile = Paths.get(source); if (Files.exists(cacheFile)) { String cachedResponse = readFromCache(cacheFile); if (cachedResponse != null) { // Try to extract product ID from filename String filename = cacheFile.getFileName().toString(); // Format: {sourceName}-PRODUCTID-INTERVAL-START-END[_NOTES].json // Remove extension String baseName = filename.replace(".json", ""); String[] parts = baseName.split("-"); if (parts.length >= 5) { String productId = parts[1]; // Try to determine interval from filename CoinbaseInterval interval = CoinbaseInterval.ONE_DAY; // Default try { interval = parseIntervalFromApiValue(parts[2]); } catch (IllegalArgumentException e) { LOG.debug("Could not parse interval from filename, using default: {}", e.getMessage()); } return parseCoinbaseResponse(cachedResponse, productId, interval.getDuration()); } } } } // If not a cache file, return null (could be extended to parse other formats) return null; } /** * Maps a Duration to the closest matching CoinbaseInterval. * * @param duration the duration to map * @return the matching CoinbaseInterval, or null if no close match is found */ private CoinbaseInterval mapDurationToInterval(Duration duration) { long seconds = duration.getSeconds(); for (CoinbaseInterval interval : CoinbaseInterval.values()) { if (interval.getDuration().getSeconds() == seconds) { return interval; } } return null; } /** * Parses a CoinbaseInterval from its API value string. * * @param apiValue the API value (e.g., "ONE_MINUTE", "ONE_DAY") * @return the matching CoinbaseInterval * @throws IllegalArgumentException if no matching interval is found */ private CoinbaseInterval parseIntervalFromApiValue(String apiValue) { for (CoinbaseInterval interval : CoinbaseInterval.values()) { if (interval.getApiValue().equals(apiValue)) { return interval; } } throw new IllegalArgumentException("Unknown interval API value: " + apiValue); } /** * Instance method that performs the actual loading logic. This method uses the * instance's HttpClient (which can be injected for testing). */ public BarSeries loadSeriesInstance(String productId, CoinbaseInterval interval, Instant startDateTime, Instant endDateTime) { return loadSeriesInstance(productId, interval, startDateTime, endDateTime, null); } /** * Instance method that performs the actual loading logic with optional notes. * This method uses the instance's HttpClient (which can be injected for * testing). * * @param productId the product ID * @param interval the interval * @param startDateTime the start date/time * @param endDateTime the end date/time * @param notes optional notes to include in cache filename (for * uniqueness) * @return the BarSeries or null if request fails */ public BarSeries loadSeriesInstance(String productId, CoinbaseInterval interval, Instant startDateTime, Instant endDateTime, String notes) { if (productId == null || productId.trim().isEmpty()) { LOG.error("Product ID cannot be null or empty"); return null; } if (startDateTime == null || endDateTime == null) { LOG.error("Start and end date/time cannot be null"); return null; } if (startDateTime.isAfter(endDateTime)) { LOG.error("Start date/time must be before or equal to end date/time"); return null; } // Calculate if we need pagination (max 350 candles per request) Duration requestedRange = Duration.between(startDateTime, endDateTime); long requestedBars = requestedRange.dividedBy(interval.getDuration()); if (requestedBars > MAX_CANDLES_PER_REQUEST) { LOG.debug( "Requested date range would result in {} bars (max {}). " + "Splitting into multiple requests and combining results.", requestedBars, MAX_CANDLES_PER_REQUEST); return loadSeriesPaginated(productId, interval, startDateTime, endDateTime, notes); } // Single request for smaller ranges return loadSeriesSingleRequest(productId, interval, startDateTime, endDateTime, notes); } /** * Generates the cache file path for a given request. * * @param productId the product ID * @param interval the interval * @param startDateTime the start date/time (will be truncated) * @param endDateTime the end date/time (will be truncated) * @param notes optional notes section to append to filename (can be * null or empty) * @return the cache file path */ private Path getCacheFilePath(String productId, CoinbaseInterval interval, Instant startDateTime, Instant endDateTime, String notes) { return getCacheFilePath(productId, startDateTime, endDateTime, interval.getDuration(), notes); } /** * Generates the cache file path for a given request (without notes). * * @param productId the product ID * @param interval the interval * @param startDateTime the start date/time (will be truncated) * @param endDateTime the end date/time (will be truncated) * @return the cache file path */ private Path getCacheFilePath(String productId, CoinbaseInterval interval, Instant startDateTime, Instant endDateTime) { return getCacheFilePath(productId, interval, startDateTime, endDateTime, null); } /** * Makes a single API request for the specified date range with optional notes. * This is used for requests that don't exceed 350 candles. If caching is * enabled, checks cache first before making the API request. * * @param productId the product ID * @param interval the interval * @param startDateTime the start date/time * @param endDateTime the end date/time * @param notes optional notes to include in cache filename (for * uniqueness) * @return the BarSeries or null if request fails */ private BarSeries loadSeriesSingleRequest(String productId, CoinbaseInterval interval, Instant startDateTime, Instant endDateTime, String notes) { // Check cache first if caching is enabled if (enableResponseCaching) { // Try exact match first (with or without notes) Path cacheFile = getCacheFilePath(productId, interval, startDateTime, endDateTime, notes); if (isCacheValid(cacheFile, interval.getDuration(), endDateTime)) { String cachedResponse = readFromCache(cacheFile); if (cachedResponse != null) { LOG.debug("Using cached response for {} ({} to {})", productId, startDateTime, endDateTime); return parseCoinbaseResponse(cachedResponse, productId, interval.getDuration()); } } // Also try without notes (for backward compatibility) if (notes != null && !notes.trim().isEmpty()) { Path cacheFileNoNotes = getCacheFilePath(productId, interval, startDateTime, endDateTime); if (isCacheValid(cacheFileNoNotes, interval.getDuration(), endDateTime)) { String cachedResponse = readFromCache(cacheFileNoNotes); if (cachedResponse != null) { LOG.debug("Using cached response for {} ({} to {})", productId, startDateTime, endDateTime); return parseCoinbaseResponse(cachedResponse, productId, interval.getDuration()); } } } } try { String encodedProductId = URLEncoder.encode(productId.trim(), StandardCharsets.UTF_8); long startTimestamp = startDateTime.getEpochSecond(); long endTimestamp = endDateTime.getEpochSecond(); String url = String.format("%s%s/candles?start=%d&end=%d&granularity=%s&limit=%d", COINBASE_API_URL, encodedProductId, startTimestamp, endTimestamp, interval.getApiValue(), MAX_CANDLES_PER_REQUEST); LOG.trace("Fetching data from Coinbase: {}", url); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .header("Accept", "application/json") .timeout(Duration.ofSeconds(30)) .GET() .build(); HttpResponseWrapper response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() != 200) { LOG.error("Coinbase API returned status code: {}", response.statusCode()); return null; } String responseBody = response.body(); LOG.trace("Response body: {}", responseBody); // Cache the response if caching is enabled if (enableResponseCaching) { Path cacheFile = getCacheFilePath(productId, interval, startDateTime, endDateTime, notes); writeToCache(cacheFile, responseBody); } return parseCoinbaseResponse(responseBody, productId, interval.getDuration()); } catch (IOException | InterruptedException e) { LOG.error("Error fetching data from Coinbase for product {}: {}", productId, e.getMessage(), e); return null; } } /** * Loads data by splitting a large date range into multiple smaller requests * (pagination). Each chunk respects the 350 candle limit, and results are * merged chronologically. * * @param productId the product ID * @param interval the bar interval * @param startDateTime the start date/time * @param endDateTime the end date/time * @param notes optional notes to include in cache filename (for * uniqueness) * @return a BarSeries containing all merged data, or null if all requests fail */ private BarSeries loadSeriesPaginated(String productId, CoinbaseInterval interval, Instant startDateTime, Instant endDateTime, String notes) { List chunks = new ArrayList<>(); Instant currentStart = startDateTime; int requestCount = 0; // Calculate chunk size (350 candles worth of time) Duration chunkSize = interval.getDuration().multipliedBy(MAX_CANDLES_PER_REQUEST); // Calculate number of chunks needed Duration totalRange = Duration.between(startDateTime, endDateTime); int estimatedChunks = (int) Math.ceil((double) totalRange.toSeconds() / chunkSize.toSeconds()); LOG.trace("Splitting request into approximately {} chunks", estimatedChunks); while (currentStart.isBefore(endDateTime)) { // Calculate chunk end time (don't exceed the requested end time) Instant chunkEnd = currentStart.plus(chunkSize); if (chunkEnd.isAfter(endDateTime)) { chunkEnd = endDateTime; } requestCount++; LOG.trace("Fetching chunk {}/? ({} to {})", requestCount, currentStart, chunkEnd); BarSeries chunk = loadSeriesSingleRequest(productId, interval, currentStart, chunkEnd, notes); if (chunk != null && chunk.getBarCount() > 0) { chunks.add(chunk); LOG.trace("Successfully loaded chunk {} with {} bars", requestCount, chunk.getBarCount()); } else { LOG.warn("Chunk {} returned no data or failed", requestCount); } // Move to next chunk (start from the end of current chunk) currentStart = chunkEnd; // If we've reached the end, break if (chunkEnd.equals(endDateTime) || !currentStart.isBefore(endDateTime)) { break; } // Add a small delay between requests to avoid rate limiting try { Thread.sleep(100); // 100ms delay between requests } catch (InterruptedException e) { Thread.currentThread().interrupt(); LOG.warn("Interrupted during pagination delay"); break; } } if (chunks.isEmpty()) { LOG.error("All paginated requests failed for product {}", productId); return null; } LOG.debug("Successfully fetched {} chunks, merging {} total bars", chunks.size(), chunks.stream().mapToInt(BarSeries::getBarCount).sum()); return mergeBarSeries(chunks, productId, interval.getDuration()); } /** * Supported intervals for Coinbase API. These correspond to the intervals that * Coinbase's Advanced Trade API supports. */ public enum CoinbaseInterval { /** * 1 minute bars */ ONE_MINUTE(Duration.ofMinutes(1), "ONE_MINUTE"), /** * 5 minute bars */ FIVE_MINUTE(Duration.ofMinutes(5), "FIVE_MINUTE"), /** * 15 minute bars */ FIFTEEN_MINUTE(Duration.ofMinutes(15), "FIFTEEN_MINUTE"), /** * 30 minute bars */ THIRTY_MINUTE(Duration.ofMinutes(30), "THIRTY_MINUTE"), /** * 1 hour bars */ ONE_HOUR(Duration.ofHours(1), "ONE_HOUR"), /** * 2 hour bars */ TWO_HOUR(Duration.ofHours(2), "TWO_HOUR"), /** * 4 hour bars */ FOUR_HOUR(Duration.ofHours(4), "FOUR_HOUR"), /** * 6 hour bars */ SIX_HOUR(Duration.ofHours(6), "SIX_HOUR"), /** * 1 day bars */ ONE_DAY(Duration.ofDays(1), "ONE_DAY"); private final Duration duration; private final String apiValue; CoinbaseInterval(Duration duration, String apiValue) { this.duration = duration; this.apiValue = apiValue; } /** * Returns the Duration for this interval. * * @return the Duration */ public Duration getDuration() { return duration; } /** * Returns the API string value for this interval. * * @return the API string value */ public String getApiValue() { return apiValue; } } /** * Helper class to hold bar data during merging. */ private static class BarData { final Instant endTime; final double open; final double high; final double low; final double close; final double volume; BarData(Bar bar) { this.endTime = bar.getEndTime(); this.open = bar.getOpenPrice().doubleValue(); this.high = bar.getHighPrice().doubleValue(); this.low = bar.getLowPrice().doubleValue(); this.close = bar.getClosePrice().doubleValue(); this.volume = bar.getVolume().doubleValue(); } } }