diff --git a/lib/chart/dataset.ex b/lib/chart/dataset.ex
index c26a968..48f81ea 100644
--- a/lib/chart/dataset.ex
+++ b/lib/chart/dataset.ex
@@ -79,7 +79,7 @@ defmodule Contex.Dataset do
Data is expected to be a list of tuples of the same size, a list of lists of same size, or a list of maps with the same keys.
Columns in map data are accessed by key. For lists of lists or tuples, if no headers are specified, columns are access by index.
"""
- @spec new(list(row())) :: Contex.Dataset.t()
+ @spec new(list(row())) :: t()
def new(data) when is_list(data) do
%Dataset{headers: nil, data: data}
end
@@ -90,18 +90,27 @@ defmodule Contex.Dataset do
Data is expected to be a list of tuples of the same size or list of lists of same size. Headers provided with a list of maps
are ignored; column names from map data are inferred from the maps' keys.
"""
- @spec new(list(row()), list(String.t())) :: Contex.Dataset.t()
+ @spec new(list(row()), list(String.t())) :: t()
def new(data, headers) when is_list(data) and is_list(headers) do
%Dataset{headers: headers, data: data}
end
+ @doc """
+ Updates data in the dataset.
+ The row structure is expected to remain the same.
+ """
+ @spec update_data(t(), (row() -> row())) :: t()
+ def update_data(%Dataset{} = dataset, updater) do
+ %{dataset | data: updater.(dataset.data)}
+ end
+
@doc """
Optionally sets a title.
Not really used at the moment to be honest, but seemed like a good
idea at the time. Might come in handy when overlaying plots.
"""
- @spec title(Contex.Dataset.t(), String.t()) :: Contex.Dataset.t()
+ @spec title(t(), String.t()) :: t()
def title(%Dataset{} = dataset, title) do
%{dataset | title: title}
end
@@ -112,7 +121,7 @@ defmodule Contex.Dataset do
Allows you to attach whatever you want to the dataset for later retrieval - e.g. information about where the
data came from.
"""
- @spec meta(Contex.Dataset.t(), String.t()) :: Contex.Dataset.t()
+ @spec meta(t(), String.t()) :: t()
def meta(%Dataset{} = dataset, meta) do
%{dataset | meta: meta}
end
@@ -120,7 +129,7 @@ defmodule Contex.Dataset do
@doc """
Looks up the index for a given column name. Returns nil if not found.
"""
- @spec column_index(Contex.Dataset.t(), column_name()) :: nil | column_name()
+ @spec column_index(t(), column_name()) :: nil | column_name()
def column_index(%Dataset{data: [first_row | _rest]}, column_name) when is_map(first_row) do
if Map.has_key?(first_row, column_name) do
column_name
@@ -144,7 +153,7 @@ defmodule Contex.Dataset do
Returns a list of the names of all of the columns in the dataset data (irrespective of
whether the column names are mapped to plot elements).
"""
- @spec column_names(Contex.Dataset.t()) :: list(column_name())
+ @spec column_names(t()) :: list(column_name())
def column_names(%Dataset{headers: headers}) when not is_nil(headers), do: headers
def column_names(%Dataset{data: [first_row | _]}) when is_map(first_row) do
@@ -169,7 +178,7 @@ defmodule Contex.Dataset do
If there are no headers, or the index is outside the range of the headers
the requested index is returned.
"""
- @spec column_name(Contex.Dataset.t(), integer() | any) :: column_name()
+ @spec column_name(t(), integer() | any) :: column_name()
def column_name(%Dataset{headers: headers} = _dataset, column_index)
when is_list(headers) and
is_integer(column_index) and
@@ -195,14 +204,11 @@ defmodule Contex.Dataset do
iex> category_accessor.(hd(data))
"Hippo"
"""
- @spec value_fn(Contex.Dataset.t(), column_name()) :: (row() -> any)
- def value_fn(%Dataset{data: [first_row | _]}, column_name)
- when is_map(first_row) and is_binary(column_name) do
- fn row -> row[column_name] end
- end
+ @spec value_fn(t(), column_name()) :: (row() -> any)
+ def value_fn(dataset, column_name)
def value_fn(%Dataset{data: [first_row | _]}, column_name)
- when is_map(first_row) and is_atom(column_name) do
+ when is_map(first_row) and (is_binary(column_name) or is_atom(column_name)) do
fn row -> row[column_name] end
end
@@ -223,16 +229,44 @@ defmodule Contex.Dataset do
def value_fn(_dataset, _column_name), do: fn _ -> nil end
+ @doc """
+ Returns the row with max value in the column.
+ """
+ @spec max_row(t(), column_name()) :: row()
+ def max_row(%Dataset{} = dataset, column_name) do
+ accessor = value_fn(dataset, column_name)
+
+ Enum.reduce(dataset.data, {nil, nil}, fn row, {max, max_row} ->
+ val = accessor.(row)
+
+ if Utils.safe_max(val, max) == val do
+ {val, row}
+ else
+ {max, max_row}
+ end
+ end)
+ |> elem(1)
+ end
+
@doc """
Calculates the min and max value in the specified column
+
+ Options:
+ - filter: function filtering rows to take into account; takes a row and
+ returns a boolean
"""
- @spec column_extents(Contex.Dataset.t(), column_name()) :: {any, any}
- def column_extents(%Dataset{data: data} = dataset, column_name) do
+ @spec column_extents(t(), column_name(), keyword()) :: {any, any}
+ def column_extents(%Dataset{data: data} = dataset, column_name, opts \\ []) do
accessor = Dataset.value_fn(dataset, column_name)
+ filter = opts[:filter]
Enum.reduce(data, {nil, nil}, fn row, {min, max} ->
- val = accessor.(row)
- {Utils.safe_min(val, min), Utils.safe_max(val, max)}
+ if !filter or filter.(row) do
+ val = accessor.(row)
+ {Utils.safe_min(val, min), Utils.safe_max(val, max)}
+ else
+ {min, max}
+ end
end)
end
@@ -241,7 +275,7 @@ defmodule Contex.Dataset do
Looks through the rows and returns the first match it can find.
"""
- @spec guess_column_type(Contex.Dataset.t(), column_name()) :: column_type()
+ @spec guess_column_type(t(), column_name()) :: column_type()
def guess_column_type(%Dataset{data: data} = dataset, column_name) do
accessor = Dataset.value_fn(dataset, column_name)
@@ -267,7 +301,7 @@ defmodule Contex.Dataset do
It is the equivalent of evaluating the extents of a calculated row where the calculating
is the sum of the values identified by column_names.
"""
- @spec combined_column_extents(Contex.Dataset.t(), list(column_name())) :: {any(), any()}
+ @spec combined_column_extents(t(), list(column_name())) :: {any(), any()}
def combined_column_extents(%Dataset{data: data} = dataset, column_names) do
accessors =
Enum.map(column_names, fn column_name -> Dataset.value_fn(dataset, column_name) end)
@@ -291,7 +325,7 @@ defmodule Contex.Dataset do
Note that the unique values will maintain order of first detection
in the data.
"""
- @spec unique_values(Contex.Dataset.t(), String.t() | integer()) :: [any]
+ @spec unique_values(t(), String.t() | integer()) :: [any]
def unique_values(%Dataset{data: data} = dataset, column_name) do
accessor = Dataset.value_fn(dataset, column_name)
diff --git a/lib/chart/gallery/ohlc_candle_d1.sample b/lib/chart/gallery/ohlc_candle_d1.sample
new file mode 100644
index 0000000..10db50d
--- /dev/null
+++ b/lib/chart/gallery/ohlc_candle_d1.sample
@@ -0,0 +1,97 @@
+data = [
+ { ~N[2016-02-17 00:00:00], 1898.8, 1930.68, 1898.8, 1926.82, 2784188889},
+ { ~N[2016-02-18 00:00:00], 1927.57, 1930, 1915.09, 1917.83, 2464716667},
+ { ~N[2016-02-19 00:00:00], 1916.74, 1918.78, 1902.17, 1917.78, 2301583333},
+ { ~N[2016-02-22 00:00:00], 1924.44, 1946.7, 1924.44, 1945.5, 2252616667},
+ { ~N[2016-02-23 00:00:00], 1942.38, 1942.38, 1919.44, 1921.27, 2161472222},
+ { ~N[2016-02-24 00:00:00], 1917.56, 1932.08, 1891, 1929.8, 2398472222},
+ { ~N[2016-02-25 00:00:00], 1931.87, 1951.83, 1925.41, 1951.7, 2287894444},
+ { ~N[2016-02-26 00:00:00], 1954.95, 1962.96, 1945.78, 1948.05, 2415838889},
+ { ~N[2016-02-29 00:00:00], 1947.13, 1958.27, 1931.81, 1932.23, 2548988889},
+ { ~N[2016-03-01 00:00:00], 1937.09, 1978.35, 1937.09, 1978.35, 2677638889},
+ { ~N[2016-03-02 00:00:00], 1976.6, 1986.51, 1968.8, 1986.45, 2592561111},
+ { ~N[2016-03-03 00:00:00], 1985.6, 1993.69, 1977.37, 1993.4, 2823166667},
+ { ~N[2016-03-04 00:00:00], 1994.01, 2009.13, 1986.77, 1999.99, 3361072222},
+ { ~N[2016-03-07 00:00:00], 1996.11, 2006.12, 1989.38, 2001.76, 2760100000},
+ { ~N[2016-03-08 00:00:00], 1996.88, 1996.88, 1977.43, 1979.26, 2578694444},
+ { ~N[2016-03-09 00:00:00], 1981.44, 1992.69, 1979.84, 1989.26, 2243400000},
+ { ~N[2016-03-10 00:00:00], 1990.97, 2005.08, 1969.25, 1989.57, 2431550000},
+ { ~N[2016-03-11 00:00:00], 1994.71, 2022.37, 1994.71, 2022.19, 2265900000},
+ { ~N[2016-03-14 00:00:00], 2019.27, 2024.57, 2012.05, 2019.64, 1937694444},
+ { ~N[2016-03-15 00:00:00], 2015.27, 2015.94, 2005.23, 2015.93, 1977933333},
+ { ~N[2016-03-16 00:00:00], 2014.24, 2032.02, 2010.04, 2027.22, 2253900000},
+ { ~N[2016-03-17 00:00:00], 2026.9, 2046.24, 2022.16, 2040.59, 2516933333},
+ { ~N[2016-03-18 00:00:00], 2041.16, 2052.36, 2041.16, 2049.58, 3612855556},
+ { ~N[2016-03-21 00:00:00], 2047.88, 2053.91, 2043.14, 2051.6, 1875888889},
+ { ~N[2016-03-22 00:00:00], 2048.64, 2056.6, 2040.57, 2049.8, 1899144444},
+ { ~N[2016-03-23 00:00:00], 2048.55, 2048.55, 2034.86, 2036.71, 2021950000},
+ { ~N[2016-03-24 00:00:00], 2032.48, 2036.04, 2022.49, 2035.94, 1893177778},
+ { ~N[2016-03-28 00:00:00], 2037.89, 2042.67, 2031.96, 2037.05, 1560605556},
+ { ~N[2016-03-29 00:00:00], 2035.75, 2055.91, 2028.31, 2055.01, 2123516667},
+ { ~N[2016-03-30 00:00:00], 2058.27, 2072.21, 2058.27, 2063.95, 1994616667},
+ { ~N[2016-03-31 00:00:00], 2063.77, 2067.92, 2057.46, 2059.74, 2064044444},
+ { ~N[2016-04-01 00:00:00], 2056.62, 2075.07, 2043.98, 2072.78, 2083327778},
+ { ~N[2016-04-04 00:00:00], 2073.19, 2074.02, 2062.57, 2066.13, 1936505556},
+ { ~N[2016-04-05 00:00:00], 2062.5, 2062.5, 2042.56, 2045.17, 2308288889},
+ { ~N[2016-04-06 00:00:00], 2045.56, 2067.33, 2043.09, 2066.66, 2083777778},
+ { ~N[2016-04-07 00:00:00], 2063.01, 2063.01, 2033.8, 2041.91, 2111805556},
+ { ~N[2016-04-08 00:00:00], 2045.54, 2060.63, 2041.69, 2047.6, 1866405556},
+ { ~N[2016-04-11 00:00:00], 2050.23, 2062.93, 2041.88, 2041.99, 1982133333},
+ { ~N[2016-04-12 00:00:00], 2043.72, 2065.05, 2039.74, 2061.72, 2355411111},
+ { ~N[2016-04-13 00:00:00], 2065.92, 2083.18, 2065.92, 2082.42, 2328794444},
+ { ~N[2016-04-14 00:00:00], 2082.89, 2087.84, 2078.13, 2082.78, 2092150000},
+ { ~N[2016-04-15 00:00:00], 2083.1, 2083.22, 2076.31, 2080.73, 2056361111},
+ { ~N[2016-04-18 00:00:00], 2078.83, 2094.66, 2073.65, 2094.34, 1842711111},
+ { ~N[2016-04-19 00:00:00], 2096.05, 2104.05, 2091.68, 2100.8, 2164905556},
+ { ~N[2016-04-20 00:00:00], 2101.52, 2111.05, 2096.32, 2102.4, 2324933333},
+ { ~N[2016-04-21 00:00:00], 2102.09, 2103.78, 2088.52, 2091.48, 2319605556},
+ { ~N[2016-04-22 00:00:00], 2091.49, 2094.32, 2081.2, 2091.58, 2105877778},
+ { ~N[2016-04-25 00:00:00], 2089.37, 2089.37, 2077.52, 2087.79, 1844300000},
+ { ~N[2016-04-26 00:00:00], 2089.84, 2096.87, 2085.8, 2091.7, 1976216667},
+ { ~N[2016-04-27 00:00:00], 2092.33, 2099.89, 2082.31, 2095.15, 2277838889},
+ { ~N[2016-04-28 00:00:00], 2090.93, 2099.3, 2071.62, 2075.81, 2394355556},
+ { ~N[2016-04-29 00:00:00], 2071.82, 2073.85, 2052.28, 2065.3, 2613733333},
+ { ~N[2016-05-02 00:00:00], 2067.17, 2083.42, 2066.11, 2081.43, 2133950000},
+ { ~N[2016-05-03 00:00:00], 2077.18, 2077.18, 2054.89, 2063.37, 2318550000},
+ { ~N[2016-05-04 00:00:00], 2060.3, 2060.3, 2045.55, 2051.12, 2254755556},
+ { ~N[2016-05-05 00:00:00], 2052.95, 2060.23, 2045.77, 2050.63, 2226961111},
+ { ~N[2016-05-06 00:00:00], 2047.77, 2057.72, 2039.45, 2057.14, 2109083333},
+ { ~N[2016-05-09 00:00:00], 2057.55, 2064.15, 2054.31, 2058.69, 2104788889},
+ { ~N[2016-05-10 00:00:00], 2062.63, 2084.87, 2062.63, 2084.39, 2000111111},
+ { ~N[2016-05-11 00:00:00], 2083.29, 2083.29, 2064.46, 2064.46, 2123322222},
+ { ~N[2016-05-12 00:00:00], 2067.17, 2073.99, 2053.13, 2064.11, 2101327778},
+ { ~N[2016-05-13 00:00:00], 2062.5, 2066.79, 2043.13, 2046.61, 1988822222},
+ { ~N[2016-05-16 00:00:00], 2046.53, 2071.88, 2046.53, 2066.66, 1945200000},
+ { ~N[2016-05-17 00:00:00], 2065.04, 2065.69, 2040.82, 2047.21, 2282755556},
+ { ~N[2016-05-18 00:00:00], 2044.38, 2060.61, 2034.49, 2047.63, 2278511111},
+ { ~N[2016-05-19 00:00:00], 2044.21, 2044.21, 2025.91, 2040.04, 2137094444},
+ { ~N[2016-05-20 00:00:00], 2041.88, 2058.35, 2041.88, 2052.32, 1948694444},
+ { ~N[2016-05-23 00:00:00], 2052.23, 2055.58, 2047.26, 2048.04, 1697488889},
+ { ~N[2016-05-24 00:00:00], 2052.65, 2079.67, 2052.65, 2076.06, 2015188889},
+ { ~N[2016-05-25 00:00:00], 2078.93, 2094.73, 2078.93, 2090.54, 2143977778},
+ { ~N[2016-05-26 00:00:00], 2091.44, 2094.3, 2087.08, 2090.1, 1794994444}
+]
+
+dataset = Dataset.new( data, ["Date", "Open", "High", "Low", "Close", "Volume"])
+
+alias Contex.OHLC.MA
+
+opts = [
+ mapping: %{ datetime: "Date", open: "Open", high: "High", low: "Low", close: "Close"},
+ style: :candle,
+ title: "SPX",
+ zoom: 3,
+ bull_color: "00FF77",
+ bear_color: "FF3333",
+ shadow_color: "000000",
+ crisp_edges: true,
+ body_border: true,
+ timeframe: Contex.TimeScale.timeframe_d1(),
+ y_scale_window: true,
+ overlays: [
+ MA.new( period: 5, color: "0000AA", width: 2)
+ ]
+]
+
+Contex.Plot.new( dataset, Contex.OHLC, 1200, 800, opts)
+|> Contex.Plot.titles( "D1 Candlestick Chart", nil)
diff --git a/lib/chart/gallery/ohlc_charts.ex b/lib/chart/gallery/ohlc_charts.ex
index b03fd71..b8d7a03 100644
--- a/lib/chart/gallery/ohlc_charts.ex
+++ b/lib/chart/gallery/ohlc_charts.ex
@@ -24,6 +24,8 @@ defmodule Contex.Gallery.OHLCCharts do
#{graph(title: "A simple tick OHLC chart",
file: "ohlc_tick.sample")}
+ #{graph(title: "D1 timeframe OHLC chart",
+ file: "ohlc_candle_d1.sample")}
"""
def plain(), do: 0
diff --git a/lib/chart/lineplot.ex b/lib/chart/lineplot.ex
index 48e6b3e..33871d2 100644
--- a/lib/chart/lineplot.ex
+++ b/lib/chart/lineplot.ex
@@ -16,9 +16,8 @@ defmodule Contex.LinePlot do
A column in the dataset can optionally be used to control the colours. See
`colours/2` and `set_colour_col_name/2`
"""
-
+ import Extructure
import Contex.SVG
-
alias __MODULE__
alias Contex.{Scale, ContinuousLinearScale, TimeScale}
alias Contex.CategoryColourScale
@@ -186,7 +185,11 @@ defmodule Contex.LinePlot do
@doc false
def to_svg(%LinePlot{} = plot, plot_options) do
- plot = prepare_scales(plot)
+ plot =
+ plot
+ |> prepare_scales()
+ |> maybe_override_transforms()
+
x_scale = plot.x_scale
y_scale = plot.y_scale
@@ -216,6 +219,24 @@ defmodule Contex.LinePlot do
]
end
+ # Replaces original x & y transforms if so specified in options
+ @spec maybe_override_transforms(t()) :: t()
+ defp maybe_override_transforms(plot) do
+ [_x_transform, _y_transform] <~ plot.options
+
+ %{
+ plot
+ | transforms:
+ Map.merge(
+ plot.transforms,
+ %{
+ x: x_transform || plot.transforms.x,
+ y: y_transform || plot.transforms.y
+ }
+ )
+ }
+ end
+
defp get_x_axis(x_scale, plot) do
rotation =
case get_option(plot, :axis_label_rotation) do
diff --git a/lib/chart/mapping.ex b/lib/chart/mapping.ex
index 865fc00..2b448f0 100644
--- a/lib/chart/mapping.ex
+++ b/lib/chart/mapping.ex
@@ -65,6 +65,18 @@ defmodule Contex.Mapping do
}
end
+ @doc """
+ Updates the dataset while ensuring sure the required columns are still present.
+ """
+ @spec update_dataset!(t(), (Contex.Dataset.t() -> Contex.Dataset.t())) :: t()
+ def update_dataset!(mapping, updater) do
+ mapping = %{mapping | dataset: updater.(mapping.dataset)}
+
+ confirm_columns_in_dataset!(mapping.dataset, mapping.column_map)
+
+ mapping
+ end
+
@doc """
Given a plot that already has a mapping and a new map of elements to columns,
updates the mapping accordingly and returns the plot.
diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex
index 17aeaa3..d0a0edd 100644
--- a/lib/chart/ohlc.ex
+++ b/lib/chart/ohlc.ex
@@ -17,8 +17,8 @@ defmodule Contex.OHLC do
If they are equal, the candle will be plotted in dark grey.
The datetime column must be of consist of DateTime or NaiveDateTime entries.
- If a custom x scale option is not provided, a `Contex.TimeScale` scale will automatically be generated from the datetime extents
- of the dataset and used for the x-axis.
+ If a custom x scale option is not provided, a `Contex.TimeScale` scale will automatically be generated from the
+ datetime extents of the dataset and used for the x-axis.
The open / high / low / close columns must be of a numeric type (float or integer).
Decimals are not currently supported.
@@ -38,17 +38,22 @@ defmodule Contex.OHLC do
alias Contex.{Dataset, Mapping}
alias Contex.Axis
alias Contex.Utils
+ alias Contex.OHLC.Overlayable
defstruct [
- :dataset,
:mapping,
:options,
:x_scale,
:y_scale,
- transforms: %{}
+ transforms: %{},
+ overlays: []
]
@type t() :: %__MODULE__{}
+
+ @type domain_min() ::
+ (t(), interval_count :: non_neg_integer() -> min :: TimeScale.datetimes())
+
@typep row() :: list()
@typep rendered_row() :: list()
@typep color() :: <<_::24>>
@@ -83,6 +88,7 @@ defmodule Contex.OHLC do
width: 100,
height: 100,
zoom: 3,
+ timeframe: nil,
bull_color: @green,
bear_color: @red,
shadow_color: @black
@@ -95,12 +101,13 @@ defmodule Contex.OHLC do
}
@zoom_levels [
- [body_width: 0, spacing: 0],
- [body_width: 0, spacing: 1],
- [body_width: 1, spacing: 1],
- [body_width: 3, spacing: 3],
- [body_width: 9, spacing: 5],
- [body_width: 23, spacing: 7]
+ [body_width: 0, spacing: 0, step: 64],
+ [body_width: 0, spacing: 1, step: 32],
+ [body_width: 1, spacing: 1, step: 16],
+ [body_width: 3, spacing: 3, step: 8],
+ [body_width: 9, spacing: 5, step: 4],
+ [body_width: 23, spacing: 7, step: 2],
+ [body_width: 53, spacing: 9, step: 1]
]
|> Stream.with_index()
|> Map.new(fn {k, v} -> {v, Map.new(k)} end)
@@ -136,12 +143,30 @@ defmodule Contex.OHLC do
Contex.Plot.new(dataset, Contex.OHLC, 600, 400, opts)
"""
+ @dialyzer {:nowarn_function, new: 2}
@spec new(Contex.Dataset.t(), keyword()) :: Contex.OHLC.t()
def new(%Dataset{} = dataset, options \\ []) do
- options = Keyword.merge(@default_options, options)
+ options =
+ @default_options
+ |> Keyword.merge(options)
+ |> maybe_put_custom_x_formatter()
+
+ [overlays([]) | options] <~ options
mapping = Mapping.new(@required_mappings, Keyword.get(options, :mapping), dataset)
- %OHLC{dataset: dataset, mapping: mapping, options: options}
+ %OHLC{mapping: mapping, options: options}
+ |> init_overlays(overlays)
+ end
+
+ @spec maybe_put_custom_x_formatter(keyword()) :: keyword()
+ defp maybe_put_custom_x_formatter(options) do
+ if (timeframe = options[:timeframe]) && !options[:custom_x_formatter] do
+ intraday? = TimeScale.compare_timeframe(timeframe, TimeScale.timeframe_d1()) == :lt
+ custom_x_formatter = &NimbleStrftime.format(&1, (intraday? && "%d %b %H:%M") || "%d %b %Y")
+ Keyword.put(options, :custom_x_formatter, custom_x_formatter)
+ else
+ options
+ end
end
@doc false
@@ -168,10 +193,9 @@ defmodule Contex.OHLC do
@doc false
def to_svg(%__MODULE__{} = plot, plot_options) do
- plot = prepare_scales(plot)
- [x_scale, y_scale] <~ plot
-
+ plot = prepare_scales_and_overlays(plot)
plot_options = Map.merge(@default_plot_options, plot_options)
+ [x_scale, y_scale] <~ plot
x_axis_svg =
if plot_options.show_x_axis,
@@ -193,35 +217,72 @@ defmodule Contex.OHLC do
y_axis_svg,
"",
render_data(plot),
+ render_overlays(plot),
""
]
end
@spec render_data(t()) :: [rendered_row()]
defp render_data(plot) do
- [dataset] <~ plot
+ [dataset] <~ plot.mapping
+
+ Enum.reduce(dataset.data, [], fn row, rendered ->
+ if rendered_row = maybe_render_row(plot, row) do
+ [rendered_row | rendered]
+ else
+ rendered
+ end
+ end)
+ |> Enum.reverse()
+ end
+
+ @spec init_overlays(t(), [Contex.Dataset.t()]) :: t()
+ defp init_overlays(plot, overlays) do
+ %{
+ plot
+ | overlays: Enum.map(overlays, &Overlayable.init(&1, plot))
+ }
+ end
+
+ @spec render_overlays(t()) :: [rendered_row()]
+ defp render_overlays(plot) do
+ render_config = Overlayable.RenderConfig.new(plot)
- dataset.data
- |> Enum.map(fn row -> render_row(plot, row) end)
+ plot.overlays
+ |> Enum.map(&Overlayable.render(&1, render_config))
+ |> List.flatten()
end
- @spec render_row(t(), row()) :: rendered_row()
- defp render_row(plot, row) do
+ @spec maybe_render_row(t(), row()) :: rendered_row() | nil
+ defp maybe_render_row(plot, row) do
[transforms, mapping: [accessors], options: options = %{}] <~ plot
- x =
- accessors.datetime.(row)
- |> transforms.x.()
+ options =
+ with true <- !!options[:timeframe] || options,
+ row_dt = accessors.datetime.(row),
+ true <- within_domain?(row_dt, plot.x_scale.nice_domain) do
+ {min, max} = plot.x_scale.nice_domain
+
+ options
+ |> put_in([:halve_first], Utils.date_compare(row_dt, min) != :gt)
+ |> put_in([:halve_last], Utils.date_compare(row_dt, max) != :lt)
+ end
- y_vals = get_y_vals(row, accessors)
+ if options do
+ x =
+ accessors.datetime.(row)
+ |> transforms.x.()
- scaled_y_vals =
- Map.new(y_vals, fn {k, v} ->
- {k, transforms.y.(v)}
- end)
+ y_vals = get_y_vals(row, accessors)
- color = get_colour(y_vals, plot)
- draw_row(options, x, scaled_y_vals, color)
+ scaled_y_vals =
+ Map.new(y_vals, fn {k, v} ->
+ {k, transforms.y.(v)}
+ end)
+
+ color = get_colour(y_vals, plot)
+ draw_row(options, x, scaled_y_vals, color)
+ end
end
# Draws a grey line from low to high, then overlay a coloured rect
@@ -231,11 +292,12 @@ defmodule Contex.OHLC do
@spec draw_row(map(), number(), y_vals(), color()) :: rendered_row()
defp draw_row(options, x, y_map, body_color)
- defp draw_row(%{style: :candle} = options, x, y_map, body_color) do
+ defp draw_row(%{timeframe: nil, style: :candle} = options, x, y_map, body_color) do
[zoom, shadow_color, crisp_edges(false), body_border(false)] <~ options
[body_width] <~ @zoom_levels[zoom]
[open, high, low, close] <~ y_map
+ body_width = ceil(body_width / 2)
bar_x = {x - body_width, x + body_width}
body_opts =
@@ -259,7 +321,7 @@ defmodule Contex.OHLC do
]
end
- defp draw_row(%{style: :tick} = options, x, y_map, body_color) do
+ defp draw_row(%{timeframe: nil, style: :tick} = options, x, y_map, body_color) do
[zoom, shadow_color, crisp_edges(false), colorized_bars(false)] <~ options
[body_width] <~ @zoom_levels[zoom]
[open, high, low, close] <~ y_map
@@ -278,6 +340,86 @@ defmodule Contex.OHLC do
]
end
+ defp draw_row(%{style: :candle} = options, x, y_map, body_color) do
+ [
+ zoom,
+ shadow_color,
+ crisp_edges(false),
+ body_border(false),
+ halve_first(false),
+ halve_last(false)
+ ]
+ <~ options
+
+ [body_width] <~ @zoom_levels[zoom]
+ [open, high, low, close] <~ y_map
+
+ right_width = div(body_width, 2)
+ left_width = right_width
+ left_width = (body_width > 0 && left_width + ((body_border && 1) || 0)) || left_width
+ body_width = left_width + right_width
+ bar_x = {x - ((!halve_first && left_width) || 0), x + ((!halve_last && right_width) || 0) + 1}
+
+ body_opts =
+ [
+ fill: body_color,
+ stroke: (body_border && shadow_color) || "transparent",
+ shape_rendering: crisp_edges && "crispEdges"
+ ]
+ |> Enum.filter(&elem(&1, 1))
+
+ style =
+ [
+ ~s|style="stroke: ##{shadow_color}"|,
+ (crisp_edges && ~s| shape-rendering="crispEdges"|) || ""
+ ]
+ |> Enum.join()
+
+ [
+ ~s||,
+ body_width > 0 && rect(bar_x, {open, close}, "", body_opts)
+ ]
+ |> Enum.filter(& &1)
+ end
+
+ defp draw_row(%{style: :tick} = options, x, y_map, body_color) do
+ [
+ zoom,
+ shadow_color,
+ crisp_edges(false),
+ colorized_bars(false),
+ halve_first(false),
+ halve_last(false)
+ ]
+ <~ options
+
+ [body_width] <~ @zoom_levels[zoom]
+ [open, high, low, close] <~ y_map
+
+ body_width = ceil(body_width / 2)
+
+ bar_x = %{
+ l: x - ((!halve_first && body_width) || 0),
+ r: x + ((!halve_last && body_width) || 0)
+ }
+
+ style =
+ [
+ ~s|style="stroke: ##{(colorized_bars && body_color) || shadow_color}"|,
+ (crisp_edges && ~s| shape-rendering="crispEdges"|) || ""
+ ]
+ |> Enum.join()
+
+ [
+ ~s||,
+ body_width > 0 and !halve_first &&
+ ~s||,
+ body_width > 0 and !halve_last &&
+ ~s||
+ ]
+ |> Enum.filter(& &1)
+ end
+
@spec get_y_vals(row(), %{atom() => (row() -> number())}) :: y_vals()
defp get_y_vals(row, accessors) do
[:open, :high, :low, :close]
@@ -303,31 +445,45 @@ defmodule Contex.OHLC do
@spec get_x_axis(Contex.TimeScale.t(), t()) :: Contex.Axis.t()
defp get_x_axis(x_scale, plot) do
- rotation =
- case get_option(plot, :axis_label_rotation) do
- :auto ->
- if length(Scale.ticks_range(x_scale)) > 8, do: 45, else: 0
+ degrees = get_option(plot, :axis_label_rotation)
- degrees ->
- degrees
+ {step, rotation} =
+ cond do
+ degrees != :auto ->
+ {1, degrees}
+
+ get_option(plot, :timeframe) ->
+ {@zoom_levels[get_option(plot, :zoom)].step, 0}
+
+ length(Scale.ticks_range(x_scale)) > 8 ->
+ {1, 45}
+
+ true ->
+ {1, 0}
end
x_scale
+ |> TimeScale.set_step(step)
|> Axis.new_bottom_axis()
|> Axis.set_offset(get_option(plot, :height))
|> Kernel.struct(rotation: rotation)
end
- @doc false
- def prepare_scales(%__MODULE__{} = plot) do
- plot
- |> prepare_x_scale()
+ @spec prepare_scales_and_overlays(t()) :: t()
+ defp prepare_scales_and_overlays(plot) do
+ if fixed_x_plot = maybe_fix_spacing(plot) do
+ fixed_x_plot
+ else
+ prepare_x_scale(plot)
+ end
|> prepare_y_scale()
end
@spec prepare_x_scale(t()) :: t()
- defp prepare_x_scale(%__MODULE__{dataset: dataset, mapping: mapping} = plot) do
- x_col_name = mapping.column_map[:datetime]
+ defp prepare_x_scale(plot) do
+ [dataset, column_map] <~ plot.mapping
+
+ x_col_name = column_map[:datetime]
width = get_option(plot, :width)
custom_x_scale = get_option(plot, :custom_x_scale)
@@ -337,11 +493,7 @@ defmodule Contex.OHLC do
_ -> custom_x_scale |> Scale.set_range(0, width)
end
- x_scale = %{x_scale | custom_tick_formatter: get_option(plot, :custom_x_formatter)}
- x_transform = Scale.domain_to_range_fn(x_scale)
- transforms = Map.merge(plot.transforms, %{x: x_transform})
-
- %{plot | x_scale: x_scale, transforms: transforms}
+ apply_x_scale(plot, x_scale)
end
defp create_timescale_for_column(dataset, column, {r_min, r_max}) do
@@ -352,19 +504,96 @@ defmodule Contex.OHLC do
|> Scale.set_range(r_min, r_max)
end
+ @spec maybe_fix_spacing(t()) :: t() | nil
+ defp maybe_fix_spacing(plot) do
+ if fixed_timescale?(plot) do
+ fix_spacing(plot)
+ end
+ end
+
+ @spec fix_spacing(t()) :: t()
+ defp fix_spacing(plot) do
+ [dataset, column_map: [datetime: dt_column]] <~ plot.mapping
+
+ interval_count = fixed_interval_count(plot.options)
+ width = get_option(plot, :width)
+ tick_interval = get_option(plot, :timeframe)
+ domain_min = get_option(plot, :domain_min)
+ {min, max} = Dataset.column_extents(dataset, dt_column)
+
+ min =
+ if is_function(domain_min) do
+ domain_min.(plot, interval_count)
+ else
+ domain_min || min
+ end
+
+ x_scale =
+ TimeScale.new()
+ |> TimeScale.domain(min, max)
+ |> Scale.set_range(0, width)
+
+ {min_d, _} = x_scale.domain
+ last_dt = TimeScale.add_interval(min_d, tick_interval, interval_count)
+
+ x_scale =
+ x_scale
+ |> struct(
+ interval_count: interval_count,
+ tick_interval: tick_interval,
+ use_existing_tick_interval?: true
+ )
+ |> TimeScale.domain(min, last_dt)
+
+ apply_x_scale(plot, x_scale)
+ end
+
+ @doc """
+ Computes the count of candles to be displayed based on plot options.
+ """
+ @spec fixed_interval_count(keyword()) :: non_neg_integer()
+ def fixed_interval_count(opts) do
+ [width, zoom] <~ opts
+ [body_width, spacing] <~ @zoom_levels[zoom]
+
+ border_width = (body_width > 0 && 2) || 1
+ interval_width = body_width + spacing + border_width
+ floor(width / interval_width)
+ end
+
+ @spec apply_x_scale(t(), Contex.Scale.t()) :: t()
+ defp apply_x_scale(plot, x_scale) do
+ x_scale = %{x_scale | custom_tick_formatter: get_option(plot, :custom_x_formatter)}
+ x_transform = Scale.domain_to_range_fn(x_scale)
+ transforms = Map.merge(plot.transforms, %{x: x_transform})
+
+ %{plot | x_scale: x_scale, transforms: transforms}
+ end
+
+ # todo: take into account any value outliers of the overlay charts
@spec prepare_y_scale(t()) :: t()
defp prepare_y_scale(plot) do
- [dataset, mapping: [column_map]] <~ plot
+ [dataset, column_map, accessors] <~ plot.mapping
y_col_names = Enum.map([:open, :high, :low, :close], &column_map[&1])
height = get_option(plot, :height)
custom_y_scale = get_option(plot, :custom_y_scale)
+ filter_opts =
+ if fixed_timescale?(plot) and !!get_option(plot, :y_scale_window) do
+ accessor_dt = accessors.datetime
+ domain = plot.x_scale.nice_domain
+
+ [filter: &within_domain?(accessor_dt.(&1), domain)]
+ else
+ []
+ end
+
y_scale =
case custom_y_scale do
nil ->
{min, max} =
- get_overall_domain(dataset, y_col_names)
+ get_overall_domain(dataset, y_col_names, filter_opts)
|> Utils.fixup_value_range()
ContinuousLinearScale.new()
@@ -382,14 +611,25 @@ defmodule Contex.OHLC do
%{plot | y_scale: y_scale, transforms: transforms}
end
+ @spec fixed_timescale?(t()) :: boolean()
+ defp fixed_timescale?(plot) do
+ !!get_option(plot, :timeframe) and !get_option(plot, :custom_x_scale)
+ end
+
+ @spec within_domain?(TimeScale.datetimes(), {TimeScale.datetimes(), TimeScale.datetimes()}) ::
+ boolean()
+ def within_domain?(dt, {min, max}) do
+ Utils.date_compare(dt, min) != :lt and Utils.date_compare(dt, max) != :gt
+ end
+
# TODO: Extract into Dataset
- defp get_overall_domain(dataset, col_names) do
+ defp get_overall_domain(dataset, col_names, opts) do
combiner = fn {min1, max1}, {min2, max2} ->
{Utils.safe_min(min1, min2), Utils.safe_max(max1, max2)}
end
Enum.reduce(col_names, {nil, nil}, fn col, acc_extents ->
- inner_extents = Dataset.column_extents(dataset, col)
+ inner_extents = Dataset.column_extents(dataset, col, opts)
combiner.(acc_extents, inner_extents)
end)
end
diff --git a/lib/chart/ohlc/ma.ex b/lib/chart/ohlc/ma.ex
new file mode 100644
index 0000000..908e6e2
--- /dev/null
+++ b/lib/chart/ohlc/ma.ex
@@ -0,0 +1,118 @@
+defmodule Contex.OHLC.MA do
+ @moduledoc """
+ Moving Average indicator
+ """
+ import Extructure
+ alias Contex.{Dataset, Plot, OHLC, OHLC.Overlayable}
+
+ defstruct dataset: nil,
+ period: 14,
+ method: :simple,
+ apply_to: :close,
+ color: "#FF000",
+ width: 1
+
+ @type method() :: :simple | :exponential | :weighted
+ @type apply_to() :: :open | :high | :low | :close
+ @type t() :: %__MODULE__{}
+
+ @spec new(map() | keyword()) :: t()
+ def new(args \\ []) do
+ struct!(__MODULE__, args)
+ end
+
+ @spec init(t(), OHLC.t()) :: t()
+ def init(%__MODULE__{} = ma, ohlc) do
+ [dataset, accessors] <~ ohlc.mapping
+ value_fn = Map.fetch!(accessors, ma.apply_to)
+
+ dataset =
+ dataset.data
+ |> Stream.map(&{accessors.datetime.(&1), value_fn.(&1)})
+ |> generate(ma.method, ma.period)
+ |> Dataset.new(["Date", "Average"])
+
+ %{ma | dataset: dataset}
+ end
+
+ @spec generate(Enumerable.t(Overlayable.row()), method(), non_neg_integer()) :: [
+ Overlayable.row()
+ ]
+ defp generate(data, method, period)
+
+ defp generate(data, :simple, period) do
+ {sma, _, _} =
+ Enum.reduce(data, {[], [], 0}, fn row, {rows, subset, count} ->
+ {dt, value} = row
+ subset = Enum.take([value | subset], period)
+
+ if count >= period do
+ average = Enum.sum(subset) / period
+
+ {[{dt, average} | rows], subset, count + 1}
+ else
+ {rows, subset, count + 1}
+ end
+ end)
+
+ Enum.reverse(sma)
+ end
+
+ defp generate(_data, method, _period) do
+ raise "#{inspect(method)} not yet supported"
+ end
+
+ @doc false
+ @spec lag(t()) :: non_neg_integer()
+ def lag(ma) do
+ ma.period
+ end
+
+ @doc false
+ @spec render(t(), Overlayable.RenderConfig.t()) :: [Overlayable.rendered_row()]
+ def render(%__MODULE__{dataset: %Dataset{}} = ma, render_config) do
+ [domain, x_transform, y_transform] <~ render_config
+
+ dataset =
+ Dataset.update_data(ma.dataset, fn data ->
+ Enum.filter(data, &OHLC.within_domain?(elem(&1, 0), domain))
+ end)
+
+ with true <- !Enum.empty?(dataset.data) || [] do
+ options = [
+ mapping: %{x_col: "Date", y_cols: ["Average"]},
+ stroke_width: "#{Integer.to_string(ma.width)}",
+ colour_palette: [ma.color],
+ show_x_axis: false,
+ show_y_axis: false,
+ x_transform: x_transform,
+ y_transform: y_transform
+ ]
+
+ Plot.new(dataset, Contex.LinePlot, 100, 100, options)
+ |> Plot.to_svg()
+ |> elem(1)
+ |> List.flatten()
+ |> Enum.split_while(&(!String.starts_with?(&1, " elem(1)
+ |> Enum.split_while(&(!String.ends_with?(&1, "")))
+ |> then(&(elem(&1, 0) ++ List.wrap(List.first(elem(&1, 1)))))
+ end
+ end
+end
+
+defimpl Contex.OHLC.Overlayable, for: Contex.OHLC.MA do
+ alias Contex.OHLC.MA
+
+ def init(ma, ohlc) do
+ MA.init(ma, ohlc)
+ end
+
+ def lag(ma) do
+ MA.lag(ma)
+ end
+
+ def render(ma, render_config) do
+ MA.render(ma, render_config)
+ end
+end
diff --git a/lib/chart/ohlc/overlayable.ex b/lib/chart/ohlc/overlayable.ex
new file mode 100644
index 0000000..4eeaff1
--- /dev/null
+++ b/lib/chart/ohlc/overlayable.ex
@@ -0,0 +1,50 @@
+defprotocol Contex.OHLC.Overlayable do
+ @moduledoc """
+ Provides a common interface for charts that can be overlaid
+ over an OHLC chart.
+ """
+ alias Contex.OHLC
+ alias Contex.OHLC.Overlayable
+
+ @type t() :: term()
+ @type row() :: list()
+ @type ohlc_options() :: keyword()
+ @type rendered_row() :: list()
+
+ @doc """
+ Initializes overlay data base on the provided OHLC
+ """
+ @spec init(t(), OHLC.t()) :: t()
+ def init(overlay, ohlc)
+
+ @doc """
+ Returns the interval count it lags behind the present.
+ """
+ @spec lag(t()) :: non_neg_integer()
+ def lag(overlay)
+
+ @doc """
+ Renders overlay.
+ """
+ @spec render(t(), Overlayable.RenderConfig.t()) :: [rendered_row()]
+ def render(overlay, render_config)
+end
+
+defmodule Contex.OHLC.Overlayable.RenderConfig do
+ alias Contex.OHLC
+
+ @enforce_keys [:domain, :x_transform, :y_transform]
+ defstruct @enforce_keys
+
+ @type t() :: %__MODULE__{}
+
+ @spec new(OHLC.t()) :: t()
+ def new(ohlc) do
+ struct!(
+ __MODULE__,
+ domain: ohlc.x_scale.domain,
+ x_transform: ohlc.transforms.x,
+ y_transform: ohlc.transforms.y
+ )
+ end
+end
diff --git a/lib/chart/scale/time_scale.ex b/lib/chart/scale/time_scale.ex
index aa02108..b0d8bfb 100644
--- a/lib/chart/scale/time_scale.ex
+++ b/lib/chart/scale/time_scale.ex
@@ -14,6 +14,16 @@ defmodule Contex.TimeScale do
alias Contex.Utils
+ @type units() ::
+ :seconds
+ | :minutes
+ | :hours
+ | :days
+ | :weeks
+ | :months
+ | :years
+
+ @type timeframe() :: {units(), non_neg_integer(), non_neg_integer()}
@type datetimes() :: NaiveDateTime.t() | DateTime.t()
# Approximate durations in ms for calculating ideal tick intervals
@@ -39,6 +49,7 @@ defmodule Contex.TimeScale do
{:minutes, 30, @duration_min * 30},
{:hours, 1, @duration_hour},
{:hours, 3, @duration_hour * 3},
+ {:hours, 4, @duration_hour * 4},
{:hours, 6, @duration_hour * 6},
{:hours, 12, @duration_hour * 12},
{:days, 1, @duration_day},
@@ -51,12 +62,36 @@ defmodule Contex.TimeScale do
{:years, 1, @duration_year}
]
+ def timeframe_m1(), do: {:minutes, 1, @duration_min}
+ def timeframe_m5(), do: {:minutes, 5, @duration_min * 5}
+ def timeframe_m15(), do: {:minutes, 15, @duration_min * 15}
+ def timeframe_m30(), do: {:minutes, 30, @duration_min * 30}
+ def timeframe_h1(), do: {:hours, 1, @duration_hour}
+ def timeframe_h4(), do: {:hours, 4, @duration_hour * 4}
+ def timeframe_d1(), do: {:days, 1, @duration_day}
+ # def timeframe_w1(), do: {:days, 1, @duration_week}
+ def timeframe_mn(), do: {:months, 1, @duration_month}
+
+ @doc """
+ Compares two timeframes.
+ """
+ @spec compare_timeframe(timeframe(), timeframe()) :: :eq | :lt | :gt
+ def compare_timeframe({_, _, millis1}, {_, _, millis2}) do
+ cond do
+ millis1 < millis2 -> :lt
+ millis1 > millis2 -> :gt
+ true -> :eq
+ end
+ end
+
defstruct [
:domain,
:nice_domain,
:range,
:interval_count,
:tick_interval,
+ :use_existing_tick_interval?,
+ :step,
:custom_tick_formatter,
:display_format
]
@@ -66,9 +101,22 @@ defmodule Contex.TimeScale do
@doc """
Creates a new TimeScale struct with basic defaults set
"""
- @spec new :: Contex.TimeScale.t()
+ @spec new :: t()
def new() do
- %TimeScale{range: {0.0, 1.0}, interval_count: 11}
+ %TimeScale{
+ range: {0.0, 1.0},
+ interval_count: 11,
+ use_existing_tick_interval?: false,
+ step: 1
+ }
+ end
+
+ @doc """
+ Sets the timescale tick plotting step.
+ """
+ @spec set_step(t(), non_neg_integer()) :: t()
+ def set_step(%TimeScale{} = scale, step) do
+ %TimeScale{scale | step: step}
end
@doc """
@@ -76,7 +124,7 @@ defmodule Contex.TimeScale do
Default is 10.
"""
- @spec interval_count(Contex.TimeScale.t(), integer()) :: Contex.TimeScale.t()
+ @spec interval_count(t(), integer()) :: t()
def interval_count(%TimeScale{} = scale, interval_count)
when is_integer(interval_count) and interval_count > 1 do
scale
@@ -88,8 +136,10 @@ defmodule Contex.TimeScale do
@doc """
Define the data domain for the scale
+
+ If `tick_interval` is already defined in the structure, it will not be recomputed.
"""
- @spec domain(Contex.TimeScale.t(), datetimes(), datetimes()) :: Contex.TimeScale.t()
+ @spec domain(t(), datetimes(), datetimes()) :: t()
def domain(%TimeScale{} = scale, min, max) do
# We can be flexible with the range start > end, but the domain needs to start from the min
{d_min, d_max} =
@@ -105,10 +155,11 @@ defmodule Contex.TimeScale do
@doc """
Define the data domain for the scale from a list of data.
+ If `tick_interval` is already defined in the structure, it will not be recomputed.
Extents will be calculated by the scale.
"""
- @spec domain(Contex.TimeScale.t(), list(datetimes())) :: Contex.TimeScale.t()
+ @spec domain(t(), list(datetimes())) :: t()
def domain(%TimeScale{} = scale, data) when is_list(data) do
{min, max} = extents(data)
domain(scale, min, max)
@@ -116,11 +167,19 @@ defmodule Contex.TimeScale do
# NOTE: interval count will likely get adjusted down here to keep things looking nice
# TODO: no type checks on the domain
+ @spec nice(t()) :: t()
+ defp nice(scale)
+
defp nice(%TimeScale{domain: {min_d, max_d}, interval_count: interval_count} = scale)
when is_number(interval_count) and interval_count > 1 do
- width = Utils.date_diff(max_d, min_d, :millisecond)
- unrounded_interval_size = width / (interval_count - 1)
- tick_interval = lookup_tick_interval(unrounded_interval_size)
+ tick_interval =
+ if scale.use_existing_tick_interval? do
+ scale.tick_interval
+ else
+ width = Utils.date_diff(max_d, min_d, :millisecond)
+ unrounded_interval_size = width / (interval_count - 1)
+ lookup_tick_interval(unrounded_interval_size)
+ end
min_nice = round_down_to(min_d, tick_interval)
@@ -285,16 +344,16 @@ defmodule Contex.TimeScale do
defp round_down_multiple(value, multiple), do: div(value, multiple) * multiple
defimpl Contex.Scale do
+ import Extructure
+
def domain_to_range_fn(%TimeScale{} = scale),
do: TimeScale.get_domain_to_range_function(scale)
- def ticks_domain(%TimeScale{
- nice_domain: {min_d, _},
- interval_count: interval_count,
- tick_interval: tick_interval
- })
+ def ticks_domain(%TimeScale{interval_count: interval_count} = scale)
when is_number(interval_count) do
- 0..interval_count
+ [tick_interval, step, nice_domain: ^{min_d, _}] <~ scale
+
+ 0..interval_count//step
|> Enum.map(fn i -> TimeScale.add_interval(min_d, tick_interval, i) end)
end
diff --git a/mix.lock b/mix.lock
index 2b51e10..c2c9360 100644
--- a/mix.lock
+++ b/mix.lock
@@ -6,10 +6,13 @@
"ex_doc": {:hex, :ex_doc, "0.28.4", "001a0ea6beac2f810f1abc3dbf4b123e9593eaa5f00dd13ded024eae7c523298", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bf85d003dd34911d89c8ddb8bda1a958af3471a274a4c2150a9c01c78ac3f8ed"},
"extructure": {:hex, :extructure, "1.0.0", "7fb05a7d05094bb381ae753226f8ceca6adbbaa5bd0c90ebe3d286f20d87fc1e", [:mix], [], "hexpm", "5f67c55786867a92c549aaaace29c898c2cc02cc01b69f2192de7b7bdb5c8078"},
"floki": {:hex, :floki, "0.34.2", "5fad07ef153b3b8ec110b6b155ec3780c4b2c4906297d0b4be1a7162d04a7e02", [:mix], [], "hexpm", "26b9d50f0f01796bc6be611ca815c5e0de034d2128e39cc9702eee6b66a4d1c8"},
+ "flow": {:hex, :flow, "1.2.4", "1dd58918287eb286656008777cb32714b5123d3855956f29aa141ebae456922d", [:mix], [{:gen_stage, "~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}], "hexpm", "874adde96368e71870f3510b91e35bc31652291858c86c0e75359cbdd35eb211"},
+ "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"},
"makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"},
"makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"},
"makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"},
"nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"},
"nimble_strftime": {:hex, :nimble_strftime, "0.1.1", "b988184d1bd945bc139b2c27dd00a6c0774ec94f6b0b580083abd62d5d07818b", [:mix], [], "hexpm", "89e599c9b8b4d1203b7bb5c79eb51ef7c6a28fbc6228230b312f8b796310d755"},
+ "parallel": {:hex, :parallel, "0.0.3", "d1c9a03f0fd6c85ba174938b9823db51e01a68f9f0e76e3f3e11989cbeb607e7", [:mix], [], "hexpm", "d9b5e98c1892f5376b4dfa28c48a3a17029f86a28d1f9ec2f7c1a2747f256a4d"},
"sweet_xml": {:hex, :sweet_xml, "0.7.3", "debb256781c75ff6a8c5cbf7981146312b66f044a2898f453709a53e5031b45b", [:mix], [], "hexpm", "e110c867a1b3fe74bfc7dd9893aa851f0eed5518d0d7cad76d7baafd30e4f5ba"},
}