Current section
Files
Jump to
Current section
Files
lib/ccxt/ohlcv.ex
defmodule CCXT.OHLCV do
@moduledoc """
Canonical OHLCV bar normalization and chart-library conversion helpers.
Shared pipeline used by both REST (`ResponseCoercer`) and WS (`Normalizer`).
## Normalization Pipeline
1. Validate each candle is a 6-element list
2. Coerce string values to numbers (timestamps → integer, OHLCV → float)
3. Build `%OHLCVBar{}` structs
4. Sort ascending by timestamp (stable sort — duplicates preserve input order)
## Chart Conversion
Built-in adapters for TradingView and Lightweight Charts, plus a generic
`to_adapter/2` for custom chart libraries (SciChart, etc.).
"""
alias CCXT.Types.OHLCVBar
@candle_length 6
@ohlcv_fields [:open, :high, :low, :close, :volume]
# Column keys for columnar OHLCV format (e.g., Deribit)
@columnar_required_keys ~w(ticks open high low close volume)
# -- Normalization -----------------------------------------------------------
@doc """
Normalizes raw candle data to sorted `[%OHLCVBar{}]`.
Accepts two formats:
- **Row format** (list of lists): `[[timestamp, open, high, low, close, volume], ...]`
- **Columnar format** (map of lists): `%{"ticks" => [...], "open" => [...], ...}`
String values are coerced to numbers. Bars are sorted ascending by timestamp.
Returns `{:ok, [%OHLCVBar{}]}` on success, `{:error, reason}` on failure.
"""
@spec normalize(list() | map()) :: {:ok, [OHLCVBar.t()]} | {:error, term()}
def normalize(data) when is_map(data) do
if Enum.any?(@columnar_required_keys, &Map.has_key?(data, &1)) do
case pivot_columnar(data) do
{:ok, rows} -> normalize(rows)
{:error, _} = err -> err
end
else
{:error, {:invalid_ohlcv_format, "expected list of candles, got map with keys: #{inspect(Map.keys(data))}"}}
end
end
def normalize(candles) when is_list(candles) do
candles
|> Enum.with_index()
|> Enum.reduce_while([], fn {candle, idx}, acc ->
case coerce_candle(candle, idx) do
{:ok, bar} -> {:cont, [bar | acc]}
{:error, _} = error -> {:halt, error}
end
end)
|> case do
{:error, _} = error -> error
bars -> {:ok, bars |> Enum.reverse() |> Enum.sort_by(& &1.timestamp)}
end
end
def normalize(other), do: {:error, {:expected_list, other}}
# -- Chart Conversion --------------------------------------------------------
@doc """
Converts OHLCV bars to TradingView-compatible bar maps.
Time in seconds (Unix timestamp). Atom-keyed for JSON encoding.
"""
@spec to_tradingview([OHLCVBar.t()]) :: [map()]
def to_tradingview(bars), do: Enum.map(bars, &tv_bar/1)
@doc """
Converts OHLCV bars to Lightweight Charts format.
Same structure as TradingView (Lightweight Charts is TradingView's open-source lib).
"""
@spec to_lightweight_charts([OHLCVBar.t()]) :: [map()]
def to_lightweight_charts(bars), do: to_tradingview(bars)
@doc """
Converts OHLCV bars using a custom adapter function.
The adapter receives an `%OHLCVBar{}` and returns the target format.
Use this for SciChart, custom dashboards, or any chart library not covered
by the built-in converters.
## Examples
bars = [%CCXT.Types.OHLCVBar{timestamp: 1704153600000, open: 42000.0,
high: 42500.0, low: 41500.0, close: 42100.0, volume: 1000.0}]
CCXT.OHLCV.to_adapter(bars, fn bar ->
{bar.timestamp, bar.open, bar.high, bar.low, bar.close}
end)
"""
@spec to_adapter([OHLCVBar.t()], (OHLCVBar.t() -> term())) :: [term()]
def to_adapter(bars, adapter_fn) when is_function(adapter_fn, 1) do
Enum.map(bars, adapter_fn)
end
# -- Private: Candle Coercion ------------------------------------------------
@doc false
# Accepts candles with 6+ elements (extra fields like turnover are ignored).
@spec coerce_candle(term(), non_neg_integer()) :: {:ok, OHLCVBar.t()} | {:error, term()}
defp coerce_candle(candle, _idx) when is_list(candle) and length(candle) < @candle_length do
{:error, {:wrong_candle_length, expected: @candle_length, got: length(candle)}}
end
defp coerce_candle(candle, idx) when is_list(candle) do
[raw_ts | rest] = Enum.take(candle, @candle_length)
raw_ohlcv = rest
with {:ok, ts} <- coerce_timestamp(raw_ts, idx),
{:ok, ohlcv} <- coerce_ohlcv_values(raw_ohlcv, idx) do
{:ok, OHLCVBar.from_list([ts | ohlcv])}
end
end
defp coerce_candle(other, idx) do
{:error, {:expected_candle_list, index: idx, got: other}}
end
@doc false
# Coerces a timestamp value to integer milliseconds.
# Timestamps are required (nil is rejected).
@spec coerce_timestamp(term(), non_neg_integer()) :: {:ok, integer()} | {:error, term()}
defp coerce_timestamp(ts, _idx) when is_integer(ts), do: {:ok, ts}
defp coerce_timestamp(ts, _idx) when is_float(ts), do: {:ok, trunc(ts)}
defp coerce_timestamp(ts, _idx) when is_binary(ts) do
cond do
match?({_int, ""}, Integer.parse(ts)) ->
{int, ""} = Integer.parse(ts)
{:ok, int}
match?({_float, ""}, Float.parse(ts)) ->
{float, ""} = Float.parse(ts)
{:ok, trunc(float)}
true ->
{:error, {:invalid_timestamp, ts}}
end
end
defp coerce_timestamp(nil, idx), do: {:error, {:nil_timestamp, idx}}
defp coerce_timestamp(other, _idx), do: {:error, {:invalid_timestamp, other}}
@doc false
# Coerces the 5 OHLCV values (open, high, low, close, volume) to floats.
# nil values are preserved. Strings are parsed. Integers are promoted to float.
@spec coerce_ohlcv_values([term()], non_neg_integer()) :: {:ok, [float() | nil]} | {:error, term()}
defp coerce_ohlcv_values(values, idx) do
values
|> Enum.zip(@ohlcv_fields)
|> Enum.reduce_while([], fn {val, field}, acc ->
case coerce_ohlcv_value(val) do
{:ok, coerced} -> {:cont, [coerced | acc]}
:error -> {:halt, {:error, {:invalid_value, field, val, idx}}}
end
end)
|> case do
{:error, _} = error -> error
coerced -> {:ok, Enum.reverse(coerced)}
end
end
@doc false
# Coerces a single OHLCV value to float.
@spec coerce_ohlcv_value(term()) :: {:ok, float() | nil} | :error
defp coerce_ohlcv_value(nil), do: {:ok, nil}
defp coerce_ohlcv_value(v) when is_float(v), do: {:ok, v}
defp coerce_ohlcv_value(v) when is_integer(v), do: {:ok, v * 1.0}
defp coerce_ohlcv_value(v) when is_binary(v) do
if match?({_float, ""}, Float.parse(v)) do
{float, ""} = Float.parse(v)
{:ok, float}
else
:error
end
end
defp coerce_ohlcv_value(_), do: :error
# -- Private: Columnar pivot -------------------------------------------------
@doc false
# Pivots columnar OHLCV data (e.g., Deribit) into row format.
# Input: %{"ticks" => [t1, t2], "open" => [o1, o2], ...}
# Output: {:ok, [[t1, o1, h1, l1, c1, v1], [t2, o2, h2, l2, c2, v2]]}
@spec pivot_columnar(map()) :: {:ok, [[term()]]} | {:error, term()}
defp pivot_columnar(data) do
with :ok <- validate_columnar_keys(data),
columns = Enum.map(@columnar_required_keys, &Map.fetch!(data, &1)),
:ok <- validate_columnar_types(columns),
:ok <- validate_columnar_lengths(columns) do
rows = columns |> Enum.zip() |> Enum.map(&Tuple.to_list/1)
{:ok, rows}
end
end
defp validate_columnar_keys(data) do
case Enum.reject(@columnar_required_keys, &Map.has_key?(data, &1)) do
[] -> :ok
missing -> {:error, {:missing_ohlcv_columns, missing}}
end
end
defp validate_columnar_types(columns) do
non_lists = Enum.reject(Enum.zip(@columnar_required_keys, columns), fn {_k, v} -> is_list(v) end)
if non_lists == [] do
:ok
else
bad = Enum.map(non_lists, fn {k, v} -> {k, inspect(v)} end)
{:error, {:invalid_ohlcv_column_type, bad}}
end
end
defp validate_columnar_lengths(columns) do
lengths = Enum.map(columns, &length/1)
if length(Enum.uniq(lengths)) > 1 do
{:error, {:mismatched_ohlcv_column_lengths, Enum.zip(@columnar_required_keys, lengths)}}
else
:ok
end
end
# -- Private: Chart helpers --------------------------------------------------
@doc false
defp tv_bar(%OHLCVBar{} = bar) do
%{
time: ms_to_seconds(bar.timestamp),
open: bar.open,
high: bar.high,
low: bar.low,
close: bar.close,
volume: bar.volume
}
end
@doc false
defp ms_to_seconds(ms) when is_integer(ms), do: div(ms, 1000)
end