From 8f800e7d57f51054268760e25bd5cfa53c68aa98 Mon Sep 17 00:00:00 2001 From: damir Date: Sat, 27 Jan 2024 18:15:58 +0100 Subject: [PATCH 01/24] fix body_width to match in pixels --- lib/chart/ohlc.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex index 17aeaa3..02ccc9f 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -236,6 +236,7 @@ defmodule Contex.OHLC do [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 = From ef6f5dd90b0dcc4e4bcbce9902cd00816c1f5fe1 Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 28 Jan 2024 15:21:15 +0100 Subject: [PATCH 02/24] add padding --- lib/chart/dataset.ex | 88 ++++++++++++++++++++++------ lib/chart/ohlc.ex | 105 ++++++++++++++++++++++++++++++---- lib/chart/scale/time_scale.ex | 25 +++++--- 3 files changed, 183 insertions(+), 35 deletions(-) diff --git a/lib/chart/dataset.ex b/lib/chart/dataset.ex index c26a968..d6a09cb 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,7 +90,7 @@ 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 @@ -101,7 +101,7 @@ defmodule Contex.Dataset do 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 +112,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 +120,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 +144,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 +169,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 +195,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,10 +220,67 @@ defmodule Contex.Dataset do def value_fn(_dataset, _column_name), do: fn _ -> nil end + @doc """ + Returns a function that puts (replaces) the value for a given column in a given row, + accessed by the column name. + """ + @spec put_value_fn(t(), column_name()) :: (row(), any() -> row()) + def put_value_fn(dataset, column_name) + + def put_value_fn(%Dataset{data: [first_row | _]}, column_name) + when is_map(first_row) and (is_binary(column_name) or is_atom(column_name)) do + if Map.has_key?(first_row, column_name) do + &Map.put(&1, column_name, &2) + else + put_value_fn(nil, nil) + end + end + + def put_value_fn(%Dataset{data: [first_row | _]} = dataset, column_name) + when is_list(first_row) do + if column_index = column_index(dataset, column_name) do + &List.replace_at(&1, column_index, &2) + else + put_value_fn(nil, nil) + end + end + + def put_value_fn(%Dataset{data: [first_row | _]} = dataset, column_name) + when is_tuple(first_row) do + if column_index = column_index(dataset, column_name) do + &put_elem(&1, column_index, &2) + else + put_value_fn(nil, nil) + end + end + + def put_value_fn(_dataset, _column_name) do + fn row, _ -> row end + 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 """ - @spec column_extents(Contex.Dataset.t(), column_name()) :: {any, any} + @spec column_extents(t(), column_name()) :: {any, any} def column_extents(%Dataset{data: data} = dataset, column_name) do accessor = Dataset.value_fn(dataset, column_name) @@ -241,7 +295,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 +321,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 +345,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/ohlc.ex b/lib/chart/ohlc.ex index 02ccc9f..abda3b7 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -83,6 +83,7 @@ defmodule Contex.OHLC do width: 100, height: 100, zoom: 3, + fixed_spacing: false, bull_color: @green, bear_color: @red, shadow_color: @black @@ -236,7 +237,7 @@ defmodule Contex.OHLC do [body_width] <~ @zoom_levels[zoom] [open, high, low, close] <~ y_map - body_width = ceil( body_width / 2) + body_width = ceil(body_width / 2) bar_x = {x - body_width, x + body_width} body_opts = @@ -319,15 +320,20 @@ defmodule Contex.OHLC do |> Kernel.struct(rotation: rotation) end - @doc false - def prepare_scales(%__MODULE__{} = plot) do - plot - |> prepare_x_scale() + @spec prepare_scales(t()) :: t() + defp prepare_scales(plot) do + if fixed_x_plot = maybe_add_padding(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 + defp prepare_x_scale(plot) do + [dataset, mapping] <~ plot + x_col_name = mapping.column_map[:datetime] width = get_option(plot, :width) custom_x_scale = get_option(plot, :custom_x_scale) @@ -338,11 +344,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 @@ -353,6 +355,87 @@ defmodule Contex.OHLC do |> Scale.set_range(r_min, r_max) end + # todo: prior to considering padding take only the data that fits in and trim the rest + # todo: introduce timescale step for time tick display + + @spec maybe_add_padding(t()) :: t() | nil + defp maybe_add_padding(plot) do + if get_option(plot, :fixed_spacing) && !get_option(plot, :custom_x_scale) do + add_padding(plot) + end + end + + @spec add_padding(t()) :: t() | nil + defp add_padding(plot) do + [ + datetime: dt_column, + open: open_column, + high: high_column, + low: low_column, + close: close_column + ] + <~ plot.mapping.column_map + + {min, max} = Dataset.column_extents(plot.dataset, dt_column) + [zoom, body_border(false)] <~ plot.options + [body_width, spacing] <~ @zoom_levels[zoom] + width = get_option(plot, :width) + interval_width = body_width + spacing + ((body_border && 2) || 0) + interval_count = floor(width / interval_width) + + if max_row = Dataset.max_row(plot.dataset, dt_column) do + max_dt = Dataset.value_fn(plot.dataset, dt_column).(max_row) + close = Dataset.value_fn(plot.dataset, close_column).(max_row) + replace_with_close = &Dataset.put_value_fn(plot.dataset, &2).(&1, close) + replace_dt = Dataset.put_value_fn(plot.dataset, dt_column) + + template = + max_row + |> replace_with_close.(open_column) + |> replace_with_close.(high_column) + |> replace_with_close.(low_column) + + should_pad? = + case max_dt do + %DateTime{} -> &(DateTime.compare(&1, max_dt) == :gt) + %NaiveDateTime{} -> &(NaiveDateTime.compare(&1, max_dt) == :gt) + _ -> fn _ -> false end + end + + x_scale = + TimeScale.new() + |> TimeScale.domain(min, max) + |> Scale.set_range(0, width) + |> struct(interval_count: interval_count) + + {last_dt, padding} = + x_scale + |> Scale.ticks_domain() + |> Enum.reduce({max_dt, []}, fn dt, {last_dt, padding} = acc -> + if should_pad?.(dt) do + {Utils.safe_max(dt, last_dt), [replace_dt.(template, dt) | padding]} + else + acc + end + end) + + x_scale = TimeScale.domain(x_scale, min, last_dt) + data = plot.dataset.data ++ Enum.reverse(padding) + + plot + |> struct!(dataset: struct!(plot.dataset, data: data)) + |> apply_x_scale(x_scale) + end + end + + 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 + @spec prepare_y_scale(t()) :: t() defp prepare_y_scale(plot) do [dataset, mapping: [column_map]] <~ plot diff --git a/lib/chart/scale/time_scale.ex b/lib/chart/scale/time_scale.ex index aa02108..42a1bb3 100644 --- a/lib/chart/scale/time_scale.ex +++ b/lib/chart/scale/time_scale.ex @@ -66,7 +66,7 @@ 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} end @@ -76,7 +76,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 +88,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 +107,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 +119,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 tick_interval = scale.tick_interval do + 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) From 55d04462459d38ff2089f1d8e0bae76bef335e29 Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 4 Feb 2024 16:10:10 +0100 Subject: [PATCH 03/24] optimize fixed spacing --- lib/chart/ohlc.ex | 92 +++++++++++------------------------ lib/chart/scale/time_scale.ex | 2 + 2 files changed, 31 insertions(+), 63 deletions(-) diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex index abda3b7..faac54d 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -83,7 +83,7 @@ defmodule Contex.OHLC do width: 100, height: 100, zoom: 3, - fixed_spacing: false, + timeframe: nil, bull_color: @green, bear_color: @red, shadow_color: @black @@ -308,7 +308,7 @@ defmodule Contex.OHLC do rotation = case get_option(plot, :axis_label_rotation) do :auto -> - if length(Scale.ticks_range(x_scale)) > 8, do: 45, else: 0 + if length(Scale.ticks_range(x_scale)) > 8, do: 90, else: 0 degrees -> degrees @@ -322,7 +322,7 @@ defmodule Contex.OHLC do @spec prepare_scales(t()) :: t() defp prepare_scales(plot) do - if fixed_x_plot = maybe_add_padding(plot) do + if fixed_x_plot = maybe_fix_spacing(plot) do fixed_x_plot else prepare_x_scale(plot) @@ -355,77 +355,43 @@ defmodule Contex.OHLC do |> Scale.set_range(r_min, r_max) end - # todo: prior to considering padding take only the data that fits in and trim the rest - # todo: introduce timescale step for time tick display - - @spec maybe_add_padding(t()) :: t() | nil - defp maybe_add_padding(plot) do - if get_option(plot, :fixed_spacing) && !get_option(plot, :custom_x_scale) do - add_padding(plot) + @spec maybe_fix_spacing(t()) :: t() | nil + defp maybe_fix_spacing(plot) do + if get_option(plot, :timeframe) && !get_option(plot, :custom_x_scale) do + fix_spacing(plot) end end - @spec add_padding(t()) :: t() | nil - defp add_padding(plot) do - [ - datetime: dt_column, - open: open_column, - high: high_column, - low: low_column, - close: close_column - ] - <~ plot.mapping.column_map + # todo: take only the data that fits in and trim the rest to match the x-axis ticks + # todo: introduce timescale step for time tick display + # todo: plot only the inner halves of first and last candle/bar + @spec fix_spacing(t()) :: t() + defp fix_spacing(plot) do + [datetime: dt_column] <~ plot.mapping.column_map {min, max} = Dataset.column_extents(plot.dataset, dt_column) + [zoom, body_border(false)] <~ plot.options [body_width, spacing] <~ @zoom_levels[zoom] width = get_option(plot, :width) interval_width = body_width + spacing + ((body_border && 2) || 0) interval_count = floor(width / interval_width) + tick_interval = get_option(plot, :timeframe) - if max_row = Dataset.max_row(plot.dataset, dt_column) do - max_dt = Dataset.value_fn(plot.dataset, dt_column).(max_row) - close = Dataset.value_fn(plot.dataset, close_column).(max_row) - replace_with_close = &Dataset.put_value_fn(plot.dataset, &2).(&1, close) - replace_dt = Dataset.put_value_fn(plot.dataset, dt_column) - - template = - max_row - |> replace_with_close.(open_column) - |> replace_with_close.(high_column) - |> replace_with_close.(low_column) - - should_pad? = - case max_dt do - %DateTime{} -> &(DateTime.compare(&1, max_dt) == :gt) - %NaiveDateTime{} -> &(NaiveDateTime.compare(&1, max_dt) == :gt) - _ -> fn _ -> false end - end - - x_scale = - TimeScale.new() - |> TimeScale.domain(min, max) - |> Scale.set_range(0, width) - |> struct(interval_count: interval_count) - - {last_dt, padding} = - x_scale - |> Scale.ticks_domain() - |> Enum.reduce({max_dt, []}, fn dt, {last_dt, padding} = acc -> - if should_pad?.(dt) do - {Utils.safe_max(dt, last_dt), [replace_dt.(template, dt) | padding]} - else - acc - end - end) - - x_scale = TimeScale.domain(x_scale, min, last_dt) - data = plot.dataset.data ++ Enum.reverse(padding) - - plot - |> struct!(dataset: struct!(plot.dataset, data: data)) - |> apply_x_scale(x_scale) - end + x_scale = + TimeScale.new() + |> TimeScale.domain(min, max) + |> Scale.set_range(0, width) + + {min_d, _} = x_scale.nice_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) + |> TimeScale.domain(min, last_dt) + + apply_x_scale(plot, x_scale) end defp apply_x_scale(plot, x_scale) do diff --git a/lib/chart/scale/time_scale.ex b/lib/chart/scale/time_scale.ex index 42a1bb3..7ac60b0 100644 --- a/lib/chart/scale/time_scale.ex +++ b/lib/chart/scale/time_scale.ex @@ -51,6 +51,8 @@ defmodule Contex.TimeScale do {:years, 1, @duration_year} ] + def timeframe_d1(), do: {:days, 1, @duration_day} + defstruct [ :domain, :nice_domain, From 61351f435b50076ed6fbba86c756fe25076b368a Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 4 Feb 2024 18:38:55 +0100 Subject: [PATCH 04/24] introduce steps in TimeScale display fix last_td computation. --- lib/chart/ohlc.ex | 39 +++++++++++++++++++++-------------- lib/chart/scale/time_scale.ex | 28 ++++++++++++++++++------- 2 files changed, 44 insertions(+), 23 deletions(-) diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex index faac54d..e0c2f95 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -96,12 +96,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) @@ -305,16 +306,22 @@ 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: 90, else: 0 + degrees = get_option(plot, :axis_label_rotation) - degrees -> - degrees + { step, rotation} = + cond do + degrees != :auto -> + { 1, degrees} + + !get_option(plot, :timeframe) and length(Scale.ticks_range(x_scale)) > 8 -> + { 1, 45} + + true -> + { @zoom_levels[ get_option( plot, :zoom)].step, 0} end x_scale + |> TimeScale.set_step( step) |> Axis.new_bottom_axis() |> Axis.set_offset(get_option(plot, :height)) |> Kernel.struct(rotation: rotation) @@ -363,8 +370,9 @@ defmodule Contex.OHLC do end # todo: take only the data that fits in and trim the rest to match the x-axis ticks - # todo: introduce timescale step for time tick display + # todo: format timeframe timescale # todo: plot only the inner halves of first and last candle/bar + # todo: plot single border line if body_width == 0 @spec fix_spacing(t()) :: t() defp fix_spacing(plot) do @@ -374,7 +382,8 @@ defmodule Contex.OHLC do [zoom, body_border(false)] <~ plot.options [body_width, spacing] <~ @zoom_levels[zoom] width = get_option(plot, :width) - interval_width = body_width + spacing + ((body_border && 2) || 0) + border_width = body_width > 0 && (body_border && 2 || 0) || 1 + interval_width = body_width + spacing + border_width interval_count = floor(width / interval_width) tick_interval = get_option(plot, :timeframe) @@ -383,7 +392,7 @@ defmodule Contex.OHLC do |> TimeScale.domain(min, max) |> Scale.set_range(0, width) - {min_d, _} = x_scale.nice_domain + {min_d, _} = x_scale.domain last_dt = TimeScale.add_interval(min_d, tick_interval, interval_count) x_scale = diff --git a/lib/chart/scale/time_scale.ex b/lib/chart/scale/time_scale.ex index 7ac60b0..8b6082b 100644 --- a/lib/chart/scale/time_scale.ex +++ b/lib/chart/scale/time_scale.ex @@ -59,6 +59,7 @@ defmodule Contex.TimeScale do :range, :interval_count, :tick_interval, + :step, :custom_tick_formatter, :display_format ] @@ -70,7 +71,19 @@ defmodule Contex.TimeScale do """ @spec new :: t() def new() do - %TimeScale{range: {0.0, 1.0}, interval_count: 11} + %TimeScale{ + range: {0.0, 1.0}, + interval_count: 11, + 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 """ @@ -298,16 +311,15 @@ 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 - }) - when is_number(interval_count) do - 0..interval_count + def ticks_domain(%TimeScale{interval_count: interval_count} = scale) when is_number(interval_count) do + [ 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 From 11e8cf2ba1914c3e77a7dfa056f6bfddc8bb44c4 Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 4 Feb 2024 18:45:52 +0100 Subject: [PATCH 05/24] fix step computation; format. --- lib/chart/ohlc.ex | 17 ++++++++++------- lib/chart/scale/time_scale.ex | 7 ++++--- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex index e0c2f95..8f70082 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -308,20 +308,23 @@ defmodule Contex.OHLC do defp get_x_axis(x_scale, plot) do degrees = get_option(plot, :axis_label_rotation) - { step, rotation} = + {step, rotation} = cond do degrees != :auto -> - { 1, degrees} + {1, degrees} - !get_option(plot, :timeframe) and length(Scale.ticks_range(x_scale)) > 8 -> - { 1, 45} + get_option(plot, :timeframe) -> + {@zoom_levels[get_option(plot, :zoom)].step, 0} + + length(Scale.ticks_range(x_scale)) > 8 -> + {1, 45} true -> - { @zoom_levels[ get_option( plot, :zoom)].step, 0} + {1, 0} end x_scale - |> TimeScale.set_step( step) + |> TimeScale.set_step(step) |> Axis.new_bottom_axis() |> Axis.set_offset(get_option(plot, :height)) |> Kernel.struct(rotation: rotation) @@ -382,7 +385,7 @@ defmodule Contex.OHLC do [zoom, body_border(false)] <~ plot.options [body_width, spacing] <~ @zoom_levels[zoom] width = get_option(plot, :width) - border_width = body_width > 0 && (body_border && 2 || 0) || 1 + border_width = (body_width > 0 && ((body_border && 2) || 0)) || 1 interval_width = body_width + spacing + border_width interval_count = floor(width / interval_width) tick_interval = get_option(plot, :timeframe) diff --git a/lib/chart/scale/time_scale.ex b/lib/chart/scale/time_scale.ex index 8b6082b..714dedc 100644 --- a/lib/chart/scale/time_scale.ex +++ b/lib/chart/scale/time_scale.ex @@ -81,7 +81,7 @@ defmodule Contex.TimeScale do @doc """ Sets the timescale tick plotting step. """ - @spec set_step( t(), non_neg_integer()) :: t() + @spec set_step(t(), non_neg_integer()) :: t() def set_step(%TimeScale{} = scale, step) do %TimeScale{scale | step: step} end @@ -316,8 +316,9 @@ defmodule Contex.TimeScale do def domain_to_range_fn(%TimeScale{} = scale), do: TimeScale.get_domain_to_range_function(scale) - def ticks_domain(%TimeScale{interval_count: interval_count} = scale) when is_number(interval_count) do - [ tick_interval, step, nice_domain: ^{ min_d, _}] <~ scale + def ticks_domain(%TimeScale{interval_count: interval_count} = scale) + when is_number(interval_count) do + [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) From c1591bf4f92e983aa4b060a3803ea9e45a9c3574 Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 4 Feb 2024 20:43:27 +0100 Subject: [PATCH 06/24] remove newly added but unused function --- lib/chart/dataset.ex | 38 -------------------------------------- 1 file changed, 38 deletions(-) diff --git a/lib/chart/dataset.ex b/lib/chart/dataset.ex index d6a09cb..41d1867 100644 --- a/lib/chart/dataset.ex +++ b/lib/chart/dataset.ex @@ -220,44 +220,6 @@ defmodule Contex.Dataset do def value_fn(_dataset, _column_name), do: fn _ -> nil end - @doc """ - Returns a function that puts (replaces) the value for a given column in a given row, - accessed by the column name. - """ - @spec put_value_fn(t(), column_name()) :: (row(), any() -> row()) - def put_value_fn(dataset, column_name) - - def put_value_fn(%Dataset{data: [first_row | _]}, column_name) - when is_map(first_row) and (is_binary(column_name) or is_atom(column_name)) do - if Map.has_key?(first_row, column_name) do - &Map.put(&1, column_name, &2) - else - put_value_fn(nil, nil) - end - end - - def put_value_fn(%Dataset{data: [first_row | _]} = dataset, column_name) - when is_list(first_row) do - if column_index = column_index(dataset, column_name) do - &List.replace_at(&1, column_index, &2) - else - put_value_fn(nil, nil) - end - end - - def put_value_fn(%Dataset{data: [first_row | _]} = dataset, column_name) - when is_tuple(first_row) do - if column_index = column_index(dataset, column_name) do - &put_elem(&1, column_index, &2) - else - put_value_fn(nil, nil) - end - end - - def put_value_fn(_dataset, _column_name) do - fn row, _ -> row end - end - @doc """ Returns the row with max value in the column. """ From 59cd74f4699a650e5c0242d6a1c4d81eb111d559 Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 4 Feb 2024 20:46:17 +0100 Subject: [PATCH 07/24] simple-format timeframe ticks; add timeframe sample to gallery. --- lib/chart/gallery/ohlc_candle_d1.sample | 91 +++++++++++++++++++++++++ lib/chart/gallery/ohlc_charts.ex | 2 + lib/chart/ohlc.ex | 17 ++++- lib/chart/scale/time_scale.ex | 31 +++++++++ 4 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 lib/chart/gallery/ohlc_candle_d1.sample diff --git a/lib/chart/gallery/ohlc_candle_d1.sample b/lib/chart/gallery/ohlc_candle_d1.sample new file mode 100644 index 0000000..2bc0554 --- /dev/null +++ b/lib/chart/gallery/ohlc_candle_d1.sample @@ -0,0 +1,91 @@ +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"]) + +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() +] + +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/ohlc.ex b/lib/chart/ohlc.ex index 8f70082..f46ce2f 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -140,12 +140,27 @@ defmodule Contex.OHLC do """ @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() + mapping = Mapping.new(@required_mappings, Keyword.get(options, :mapping), dataset) %OHLC{dataset: dataset, mapping: mapping, options: options} 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 def set_size(%__MODULE__{} = chart, width, height) do chart diff --git a/lib/chart/scale/time_scale.ex b/lib/chart/scale/time_scale.ex index 714dedc..e69f3dc 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,7 +62,27 @@ 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, From 47803b569ae5ef599eda84a51875ed4c047a33bd Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 4 Feb 2024 20:46:54 +0100 Subject: [PATCH 08/24] format --- lib/chart/ohlc.ex | 8 ++++---- lib/chart/scale/time_scale.ex | 24 ++++++++++++------------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex index f46ce2f..6d7fb38 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -142,7 +142,7 @@ defmodule Contex.OHLC do def new(%Dataset{} = dataset, options \\ []) do options = @default_options - |> Keyword.merge( options) + |> Keyword.merge(options) |> maybe_put_custom_x_formatter() mapping = Mapping.new(@required_mappings, Keyword.get(options, :mapping), dataset) @@ -152,10 +152,10 @@ defmodule Contex.OHLC do @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 + 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) + 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 diff --git a/lib/chart/scale/time_scale.ex b/lib/chart/scale/time_scale.ex index e69f3dc..b801268 100644 --- a/lib/chart/scale/time_scale.ex +++ b/lib/chart/scale/time_scale.ex @@ -15,15 +15,15 @@ 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()} + :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 @@ -69,14 +69,14 @@ defmodule Contex.TimeScale do 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_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 + @spec compare_timeframe(timeframe(), timeframe()) :: :eq | :lt | :gt + def compare_timeframe({_, _, millis1}, {_, _, millis2}) do cond do millis1 < millis2 -> :lt millis1 > millis2 -> :gt From 47afca15f2c23a0907700b86edf5e083e4273dba Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 4 Feb 2024 21:13:59 +0100 Subject: [PATCH 09/24] make reuse of existing tick_interval explicit so tests can pass --- lib/chart/ohlc.ex | 6 +++++- lib/chart/scale/time_scale.ex | 6 ++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex index 6d7fb38..d8b2d0c 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -415,7 +415,11 @@ defmodule Contex.OHLC do x_scale = x_scale - |> struct(interval_count: interval_count, tick_interval: tick_interval) + |> 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) diff --git a/lib/chart/scale/time_scale.ex b/lib/chart/scale/time_scale.ex index b801268..b0d8bfb 100644 --- a/lib/chart/scale/time_scale.ex +++ b/lib/chart/scale/time_scale.ex @@ -90,6 +90,7 @@ defmodule Contex.TimeScale do :range, :interval_count, :tick_interval, + :use_existing_tick_interval?, :step, :custom_tick_formatter, :display_format @@ -105,6 +106,7 @@ defmodule Contex.TimeScale do %TimeScale{ range: {0.0, 1.0}, interval_count: 11, + use_existing_tick_interval?: false, step: 1 } end @@ -171,8 +173,8 @@ defmodule Contex.TimeScale do defp nice(%TimeScale{domain: {min_d, max_d}, interval_count: interval_count} = scale) when is_number(interval_count) and interval_count > 1 do tick_interval = - if tick_interval = scale.tick_interval do - 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) From 93620a4827ffb9dbb53a3b7cf595c16461e12911 Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 11 Feb 2024 15:53:56 +0100 Subject: [PATCH 10/24] keep only data that fits in and trim the rest to match the x-axis ticks --- lib/chart/ohlc.ex | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex index d8b2d0c..afdbe6e 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -387,8 +387,6 @@ defmodule Contex.OHLC do end end - # todo: take only the data that fits in and trim the rest to match the x-axis ticks - # todo: format timeframe timescale # todo: plot only the inner halves of first and last candle/bar # todo: plot single border line if body_width == 0 @@ -422,9 +420,12 @@ defmodule Contex.OHLC do ) |> TimeScale.domain(min, last_dt) - apply_x_scale(plot, x_scale) + plot + |> apply_x_scale(x_scale) + |> trim_data_after( last_dt) 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) @@ -433,6 +434,16 @@ defmodule Contex.OHLC do %{plot | x_scale: x_scale, transforms: transforms} end + # Keeps only the data before or at the last datetime. + @spec trim_data_after( t(), TimeScale.datetimes()) :: t() + defp trim_data_after( plot, last_dt) do + [ dataset, mapping] <~ plot + gets_datetime = Dataset.value_fn( dataset, mapping.column_map[ :datetime]) + data = Enum.filter( dataset.data, &Utils.date_compare( gets_datetime.( &1), last_dt) != :gt) + + %{ plot | dataset: Dataset.new( data, dataset.headers)} + end + @spec prepare_y_scale(t()) :: t() defp prepare_y_scale(plot) do [dataset, mapping: [column_map]] <~ plot From 0d62a26eb1b26d1ed147e410f3b702543021fb3d Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 11 Feb 2024 19:32:43 +0100 Subject: [PATCH 11/24] plot only the inner halves of first and last candle/bar; plot just a single border line if body_width is 0; fix bars so they correctly account for body_width. --- lib/chart/ohlc.ex | 46 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex index afdbe6e..f1a9055 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -226,6 +226,18 @@ defmodule Contex.OHLC do defp render_row(plot, row) do [transforms, mapping: [accessors], options: options = %{}] <~ plot + options = + if options[ :timeframe] do + { min, max} = plot.x_scale.domain + row_dt = datetime_fn( plot).( row) + + options + |> put_in( [ :halve_first], Utils.date_compare( row_dt, min) != :gt) + |> put_in( [ :halve_last], Utils.date_compare( row_dt, max) != :lt) + else + options + end + x = accessors.datetime.(row) |> transforms.x.() @@ -249,12 +261,12 @@ defmodule Contex.OHLC do defp draw_row(options, x, y_map, body_color) defp draw_row(%{style: :candle} = options, x, y_map, body_color) do - [zoom, shadow_color, crisp_edges(false), body_border(false)] <~ options + [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 body_width = ceil(body_width / 2) - bar_x = {x - body_width, x + body_width} + bar_x = {x - ( !halve_first && body_width || 0), x + ( !halve_last && body_width || 0)} body_opts = [ @@ -272,16 +284,20 @@ defmodule Contex.OHLC do |> Enum.join() [ - ~s||, - rect(bar_x, {open, close}, "", body_opts) + !halve_first and !halve_last && ~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)] <~ options + [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}"|, @@ -291,9 +307,10 @@ defmodule Contex.OHLC do [ ~s||, - ~s||, - ~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() @@ -387,8 +404,7 @@ defmodule Contex.OHLC do end end - # todo: plot only the inner halves of first and last candle/bar - # todo: plot single border line if body_width == 0 + # todo: if timeframe, always plot border just make it transparent if not requested, add it to style tick width @spec fix_spacing(t()) :: t() defp fix_spacing(plot) do @@ -437,13 +453,21 @@ defmodule Contex.OHLC do # Keeps only the data before or at the last datetime. @spec trim_data_after( t(), TimeScale.datetimes()) :: t() defp trim_data_after( plot, last_dt) do - [ dataset, mapping] <~ plot - gets_datetime = Dataset.value_fn( dataset, mapping.column_map[ :datetime]) + [ dataset] <~ plot + gets_datetime = datetime_fn( plot) data = Enum.filter( dataset.data, &Utils.date_compare( gets_datetime.( &1), last_dt) != :gt) %{ plot | dataset: Dataset.new( data, dataset.headers)} end + # Returns value accessor for the :datetime column + @spec datetime_fn( t()) :: ( Dataset.row() -> TimeScale.datetimes()) + defp datetime_fn( plot) do + [ dataset, mapping] <~ plot + + Dataset.value_fn( dataset, mapping.column_map[ :datetime]) + end + @spec prepare_y_scale(t()) :: t() defp prepare_y_scale(plot) do [dataset, mapping: [column_map]] <~ plot From d818abb848907008c7ff49890905719da850ccde Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 11 Feb 2024 19:42:34 +0100 Subject: [PATCH 12/24] format --- lib/chart/ohlc.ex | 75 +++++++++++++++++++++++++++++++---------------- 1 file changed, 50 insertions(+), 25 deletions(-) diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex index f1a9055..af042dd 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -227,13 +227,13 @@ defmodule Contex.OHLC do [transforms, mapping: [accessors], options: options = %{}] <~ plot options = - if options[ :timeframe] do - { min, max} = plot.x_scale.domain - row_dt = datetime_fn( plot).( row) + if options[:timeframe] do + {min, max} = plot.x_scale.domain + row_dt = datetime_fn(plot).(row) options - |> put_in( [ :halve_first], Utils.date_compare( row_dt, min) != :gt) - |> put_in( [ :halve_last], Utils.date_compare( row_dt, max) != :lt) + |> put_in([:halve_first], Utils.date_compare(row_dt, min) != :gt) + |> put_in([:halve_last], Utils.date_compare(row_dt, max) != :lt) else options end @@ -261,12 +261,21 @@ defmodule Contex.OHLC do defp draw_row(options, x, y_map, body_color) 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 + [ + 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 body_width = ceil(body_width / 2) - bar_x = {x - ( !halve_first && body_width || 0), x + ( !halve_last && body_width || 0)} + bar_x = {x - ((!halve_first && body_width) || 0), x + ((!halve_last && body_width) || 0)} body_opts = [ @@ -284,19 +293,33 @@ defmodule Contex.OHLC do |> Enum.join() [ - !halve_first and !halve_last && ~s||, + !halve_first and !halve_last && + ~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 + [ + 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)} + + bar_x = %{ + l: x - ((!halve_first && body_width) || 0), + r: x + ((!halve_last && body_width) || 0) + } style = [ @@ -307,10 +330,12 @@ defmodule Contex.OHLC do [ ~s||, - body_width > 0 and !halve_first && ~s||, - body_width > 0 and !halve_last && ~s|| + body_width > 0 and !halve_first && + ~s||, + body_width > 0 and !halve_last && + ~s|| ] - |> Enum.filter( & &1) + |> Enum.filter(& &1) end @spec get_y_vals(row(), %{atom() => (row() -> number())}) :: y_vals() @@ -438,10 +463,10 @@ defmodule Contex.OHLC do plot |> apply_x_scale(x_scale) - |> trim_data_after( last_dt) + |> trim_data_after(last_dt) end - @spec apply_x_scale( t(), Contex.Scale.t()) :: t() + @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) @@ -451,21 +476,21 @@ defmodule Contex.OHLC do end # Keeps only the data before or at the last datetime. - @spec trim_data_after( t(), TimeScale.datetimes()) :: t() - defp trim_data_after( plot, last_dt) do - [ dataset] <~ plot - gets_datetime = datetime_fn( plot) - data = Enum.filter( dataset.data, &Utils.date_compare( gets_datetime.( &1), last_dt) != :gt) + @spec trim_data_after(t(), TimeScale.datetimes()) :: t() + defp trim_data_after(plot, last_dt) do + [dataset] <~ plot + gets_datetime = datetime_fn(plot) + data = Enum.filter(dataset.data, &(Utils.date_compare(gets_datetime.(&1), last_dt) != :gt)) - %{ plot | dataset: Dataset.new( data, dataset.headers)} + %{plot | dataset: Dataset.new(data, dataset.headers)} end # Returns value accessor for the :datetime column - @spec datetime_fn( t()) :: ( Dataset.row() -> TimeScale.datetimes()) - defp datetime_fn( plot) do - [ dataset, mapping] <~ plot + @spec datetime_fn(t()) :: (Dataset.row() -> TimeScale.datetimes()) + defp datetime_fn(plot) do + [dataset, mapping] <~ plot - Dataset.value_fn( dataset, mapping.column_map[ :datetime]) + Dataset.value_fn(dataset, mapping.column_map[:datetime]) end @spec prepare_y_scale(t()) :: t() From 453a7afe5b89b082eccfdb2a0ee6b19eb4e13e63 Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 18 Feb 2024 16:16:07 +0100 Subject: [PATCH 13/24] make candle left and right halves equally wide regardless of border --- lib/chart/ohlc.ex | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex index af042dd..2fbe9d7 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -274,13 +274,16 @@ defmodule Contex.OHLC do [body_width] <~ @zoom_levels[zoom] [open, high, low, close] <~ y_map - body_width = ceil(body_width / 2) - bar_x = {x - ((!halve_first && body_width) || 0), x + ((!halve_last && body_width) || 0)} + 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, + stroke: body_border && shadow_color || "transparent", shape_rendering: crisp_edges && "crispEdges" ] |> Enum.filter(&elem(&1, 1)) @@ -293,8 +296,7 @@ defmodule Contex.OHLC do |> Enum.join() [ - !halve_first and !halve_last && - ~s||, + ~s||, body_width > 0 && rect(bar_x, {open, close}, "", body_opts) ] |> Enum.filter(& &1) @@ -429,17 +431,15 @@ defmodule Contex.OHLC do end end - # todo: if timeframe, always plot border just make it transparent if not requested, add it to style tick width - @spec fix_spacing(t()) :: t() defp fix_spacing(plot) do [datetime: dt_column] <~ plot.mapping.column_map {min, max} = Dataset.column_extents(plot.dataset, dt_column) - [zoom, body_border(false)] <~ plot.options + [zoom] <~ plot.options [body_width, spacing] <~ @zoom_levels[zoom] width = get_option(plot, :width) - border_width = (body_width > 0 && ((body_border && 2) || 0)) || 1 + border_width = body_width > 0 && 2 || 1 interval_width = body_width + spacing + border_width interval_count = floor(width / interval_width) tick_interval = get_option(plot, :timeframe) From 179b2f01135fe757574122db306f7354f0ee95db Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 18 Feb 2024 16:38:11 +0100 Subject: [PATCH 14/24] apply timeframe-less drawing logic when timeframe not defined --- lib/chart/ohlc.ex | 54 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex index 2fbe9d7..6cff062 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -260,6 +260,54 @@ 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(%{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 = + [ + fill: body_color, + stroke: body_border && shadow_color, + 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||, + rect(bar_x, {open, close}, "", body_opts) + ] + end + + 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 + + style = + [ + ~s|style="stroke: ##{(colorized_bars && body_color) || shadow_color}"|, + (crisp_edges && ~s| shape-rendering="crispEdges"|) || "" + ] + |> Enum.join() + + [ + ~s||, + ~s||, + ~s|| + ] + end + defp draw_row(%{style: :candle} = options, x, y_map, body_color) do [ zoom, @@ -276,14 +324,14 @@ defmodule Contex.OHLC do right_width = div(body_width, 2) left_width = right_width - left_width = body_width > 0 && left_width + ( body_border && 1 || 0) || left_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", + stroke: (body_border && shadow_color) || "transparent", shape_rendering: crisp_edges && "crispEdges" ] |> Enum.filter(&elem(&1, 1)) @@ -439,7 +487,7 @@ defmodule Contex.OHLC do [zoom] <~ plot.options [body_width, spacing] <~ @zoom_levels[zoom] width = get_option(plot, :width) - border_width = body_width > 0 && 2 || 1 + border_width = (body_width > 0 && 2) || 1 interval_width = body_width + spacing + border_width interval_count = floor(width / interval_width) tick_interval = get_option(plot, :timeframe) From 1b785cfe67dad43c0b9b9a6083092502693cd82b Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 25 Feb 2024 19:39:30 +0100 Subject: [PATCH 15/24] remove redundant dataset (keep just the mapping's one); reuse datetime accessor. --- lib/chart/mapping.ex | 12 ++++++++++++ lib/chart/ohlc.ex | 43 ++++++++++++++++++++----------------------- 2 files changed, 32 insertions(+), 23 deletions(-) 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 6cff062..7de109b 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. @@ -40,7 +40,6 @@ defmodule Contex.OHLC do alias Contex.Utils defstruct [ - :dataset, :mapping, :options, :x_scale, @@ -147,7 +146,7 @@ defmodule Contex.OHLC do mapping = Mapping.new(@required_mappings, Keyword.get(options, :mapping), dataset) - %OHLC{dataset: dataset, mapping: mapping, options: options} + %OHLC{mapping: mapping, options: options} end @spec maybe_put_custom_x_formatter(keyword()) :: keyword() @@ -216,7 +215,7 @@ defmodule Contex.OHLC do @spec render_data(t()) :: [rendered_row()] defp render_data(plot) do - [dataset] <~ plot + [dataset] <~ plot.mapping dataset.data |> Enum.map(fn row -> render_row(plot, row) end) @@ -229,7 +228,7 @@ defmodule Contex.OHLC do options = if options[:timeframe] do {min, max} = plot.x_scale.domain - row_dt = datetime_fn(plot).(row) + row_dt = accessors.datetime.(row) options |> put_in([:halve_first], Utils.date_compare(row_dt, min) != :gt) @@ -449,9 +448,9 @@ defmodule Contex.OHLC do @spec prepare_x_scale(t()) :: t() defp prepare_x_scale(plot) do - [dataset, mapping] <~ plot + [dataset, column_map] <~ plot.mapping - x_col_name = mapping.column_map[:datetime] + x_col_name = column_map[:datetime] width = get_option(plot, :width) custom_x_scale = get_option(plot, :custom_x_scale) @@ -482,7 +481,7 @@ defmodule Contex.OHLC do @spec fix_spacing(t()) :: t() defp fix_spacing(plot) do [datetime: dt_column] <~ plot.mapping.column_map - {min, max} = Dataset.column_extents(plot.dataset, dt_column) + {min, max} = Dataset.column_extents(plot.mapping.dataset, dt_column) [zoom] <~ plot.options [body_width, spacing] <~ @zoom_levels[zoom] @@ -526,24 +525,22 @@ defmodule Contex.OHLC do # Keeps only the data before or at the last datetime. @spec trim_data_after(t(), TimeScale.datetimes()) :: t() defp trim_data_after(plot, last_dt) do - [dataset] <~ plot - gets_datetime = datetime_fn(plot) - data = Enum.filter(dataset.data, &(Utils.date_compare(gets_datetime.(&1), last_dt) != :gt)) - - %{plot | dataset: Dataset.new(data, dataset.headers)} - end - - # Returns value accessor for the :datetime column - @spec datetime_fn(t()) :: (Dataset.row() -> TimeScale.datetimes()) - defp datetime_fn(plot) do - [dataset, mapping] <~ plot - - Dataset.value_fn(dataset, mapping.column_map[:datetime]) + [accessors] <~ plot.mapping + + %{ + plot + | mapping: + Mapping.update_dataset!(plot.mapping, fn dataset -> + dataset.data + |> Enum.filter(&(Utils.date_compare(accessors.datetime.(&1), last_dt) != :gt)) + |> Dataset.new(dataset.headers) + end) + } end @spec prepare_y_scale(t()) :: t() defp prepare_y_scale(plot) do - [dataset, mapping: [column_map]] <~ plot + [dataset, column_map] <~ plot.mapping y_col_names = Enum.map([:open, :high, :low, :close], &column_map[&1]) height = get_option(plot, :height) From 4db508faca8219a13b34d7710fde2faac1206a08 Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 3 Mar 2024 19:47:38 +0100 Subject: [PATCH 16/24] replace trimming with time (domain) window --- lib/chart/dataset.ex | 17 ++++++-- lib/chart/ohlc.ex | 96 +++++++++++++++++++++++--------------------- 2 files changed, 64 insertions(+), 49 deletions(-) diff --git a/lib/chart/dataset.ex b/lib/chart/dataset.ex index 41d1867..bd5bfbe 100644 --- a/lib/chart/dataset.ex +++ b/lib/chart/dataset.ex @@ -241,14 +241,23 @@ defmodule Contex.Dataset do @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(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 diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex index 7de109b..4ac1131 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -185,9 +185,8 @@ defmodule Contex.OHLC do @doc false def to_svg(%__MODULE__{} = plot, plot_options) do plot = prepare_scales(plot) - [x_scale, y_scale] <~ plot - plot_options = Map.merge(@default_plot_options, plot_options) + [x_scale, y_scale] <~ plot x_axis_svg = if plot_options.show_x_axis, @@ -217,39 +216,42 @@ defmodule Contex.OHLC do defp render_data(plot) do [dataset] <~ plot.mapping - dataset.data - |> Enum.map(fn row -> render_row(plot, row) end) + for row <- dataset.data, + rendered_row = maybe_render_row(plot, row), + into: [], + do: rendered_row 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 options = - if options[:timeframe] do - {min, max} = plot.x_scale.domain - row_dt = accessors.datetime.(row) + 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) - else - options end - x = - accessors.datetime.(row) - |> transforms.x.() + if options do + x = + accessors.datetime.(row) + |> transforms.x.() - y_vals = get_y_vals(row, accessors) + y_vals = get_y_vals(row, accessors) - scaled_y_vals = - Map.new(y_vals, fn {k, v} -> - {k, transforms.y.(v)} - end) + 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) + 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 @@ -473,7 +475,7 @@ defmodule Contex.OHLC do @spec maybe_fix_spacing(t()) :: t() | nil defp maybe_fix_spacing(plot) do - if get_option(plot, :timeframe) && !get_option(plot, :custom_x_scale) do + if fixed_timescale?(plot) do fix_spacing(plot) end end @@ -482,6 +484,7 @@ defmodule Contex.OHLC do defp fix_spacing(plot) do [datetime: dt_column] <~ plot.mapping.column_map {min, max} = Dataset.column_extents(plot.mapping.dataset, dt_column) + min = get_option(plot, :domain_min) || min [zoom] <~ plot.options [body_width, spacing] <~ @zoom_levels[zoom] @@ -508,9 +511,7 @@ defmodule Contex.OHLC do ) |> TimeScale.domain(min, last_dt) - plot - |> apply_x_scale(x_scale) - |> trim_data_after(last_dt) + apply_x_scale(plot, x_scale) end @spec apply_x_scale(t(), Contex.Scale.t()) :: t() @@ -522,35 +523,29 @@ defmodule Contex.OHLC do %{plot | x_scale: x_scale, transforms: transforms} end - # Keeps only the data before or at the last datetime. - @spec trim_data_after(t(), TimeScale.datetimes()) :: t() - defp trim_data_after(plot, last_dt) do - [accessors] <~ plot.mapping - - %{ - plot - | mapping: - Mapping.update_dataset!(plot.mapping, fn dataset -> - dataset.data - |> Enum.filter(&(Utils.date_compare(accessors.datetime.(&1), last_dt) != :gt)) - |> Dataset.new(dataset.headers) - end) - } - end - @spec prepare_y_scale(t()) :: t() defp prepare_y_scale(plot) do - [dataset, column_map] <~ plot.mapping + [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) 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() @@ -568,14 +563,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() + defp 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 From e8b740fb9f7b97a5b76641ab597810e381ebb271 Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 10 Mar 2024 11:30:47 +0100 Subject: [PATCH 17/24] integrate overlays --- lib/chart/dataset.ex | 9 +++++ lib/chart/lineplot.ex | 27 +++++++++++-- lib/chart/ohlc.ex | 36 ++++++++++++++--- lib/chart/ohlc/ma.ex | 76 +++++++++++++++++++++++++++++++++++ lib/chart/ohlc/overlayable.ex | 44 ++++++++++++++++++++ 5 files changed, 183 insertions(+), 9 deletions(-) create mode 100644 lib/chart/ohlc/ma.ex create mode 100644 lib/chart/ohlc/overlayable.ex diff --git a/lib/chart/dataset.ex b/lib/chart/dataset.ex index bd5bfbe..48f81ea 100644 --- a/lib/chart/dataset.ex +++ b/lib/chart/dataset.ex @@ -95,6 +95,15 @@ defmodule Contex.Dataset 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. 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/ohlc.ex b/lib/chart/ohlc.ex index 4ac1131..cd0a380 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -38,13 +38,15 @@ defmodule Contex.OHLC do alias Contex.{Dataset, Mapping} alias Contex.Axis alias Contex.Utils + alias Contex.OHLC.Overlayable defstruct [ :mapping, :options, :x_scale, :y_scale, - transforms: %{} + transforms: %{}, + overlays: [] ] @type t() :: %__MODULE__{} @@ -137,6 +139,7 @@ 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 = @@ -144,9 +147,11 @@ defmodule Contex.OHLC do |> Keyword.merge(options) |> maybe_put_custom_x_formatter() + [overlays([]) | options] <~ options mapping = Mapping.new(@required_mappings, Keyword.get(options, :mapping), dataset) %OHLC{mapping: mapping, options: options} + |> init_overlays(overlays) end @spec maybe_put_custom_x_formatter(keyword()) :: keyword() @@ -184,7 +189,7 @@ defmodule Contex.OHLC do @doc false def to_svg(%__MODULE__{} = plot, plot_options) do - plot = prepare_scales(plot) + plot = prepare_scales_and_overlays(plot) plot_options = Map.merge(@default_plot_options, plot_options) [x_scale, y_scale] <~ plot @@ -208,6 +213,7 @@ defmodule Contex.OHLC do y_axis_svg, "", render_data(plot), + render_overlays(plot), "" ] end @@ -222,6 +228,23 @@ defmodule Contex.OHLC do do: rendered_row 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) + + plot.overlays + |> Enum.map(&Overlayable.render(&1, render_config)) + |> List.flatten() + end + @spec maybe_render_row(t(), row()) :: rendered_row() | nil defp maybe_render_row(plot, row) do [transforms, mapping: [accessors], options: options = %{}] <~ plot @@ -438,8 +461,8 @@ defmodule Contex.OHLC do |> Kernel.struct(rotation: rotation) end - @spec prepare_scales(t()) :: t() - defp prepare_scales(plot) do + @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 @@ -523,6 +546,7 @@ defmodule Contex.OHLC do %{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, column_map, accessors] <~ plot.mapping @@ -568,9 +592,9 @@ defmodule Contex.OHLC do !!get_option(plot, :timeframe) and !get_option(plot, :custom_x_scale) end - @spec within_domain?(Timescale.datetimes(), {Timescale.datetimes(), Timescale.datetimes()}) :: + @spec within_domain?(TimeScale.datetimes(), {TimeScale.datetimes(), TimeScale.datetimes()}) :: boolean() - defp within_domain?(dt, {min, max}) do + def within_domain?(dt, {min, max}) do Utils.date_compare(dt, min) != :lt and Utils.date_compare(dt, max) != :gt end diff --git a/lib/chart/ohlc/ma.ex b/lib/chart/ohlc/ma.ex new file mode 100644 index 0000000..4b47359 --- /dev/null +++ b/lib/chart/ohlc/ma.ex @@ -0,0 +1,76 @@ +defmodule Contex.OHLC.MA do + @moduledoc """ + Moving Average indicator + """ + import Extructure + alias Contex.{Dataset, Plot, OHLC, OHLC.Overlayable} + + defstruct dataset: nil, + period: 14, + shift: 0, + method: :simple, + apply_to: :close, + color: "#FF000" + + @type method() :: :simple | :exponential | :smoothed | :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 + + dataset = + dataset.data + |> Enum.map(&{accessors.datetime.(&1), accessors.close.(&1)}) + |> Dataset.new(["Date", "Close"]) + + %{ma | dataset: dataset} + end + + @doc false + @spec render(t(), Overlayable.RenderConfig.t()) :: [Overlayable.rendered_row()] + def render(%__MODULE__{dataset: %Dataset{}} = ma, render_config) do + dataset = + Dataset.update_data(ma.dataset, fn data -> + Enum.filter(data, &OHLC.within_domain?(elem(&1, 0), render_config.domain)) + end) + + options = [ + mapping: %{x_col: "Date", y_cols: ["Close"]}, + smoothed: false, + stroke_width: "1", + colour_palette: ["ff9838"], + show_x_axis: false, + show_y_axis: false, + x_transform: render_config.x_transform, + y_transform: render_config.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 + +defimpl Contex.OHLC.Overlayable, for: Contex.OHLC.MA do + alias Contex.OHLC.MA + + def init(ma, ohlc) do + MA.init(ma, ohlc) + end + + def render(ma, ohlc) do + MA.render(ma, ohlc) + end +end diff --git a/lib/chart/ohlc/overlayable.ex b/lib/chart/ohlc/overlayable.ex new file mode 100644 index 0000000..bfa391a --- /dev/null +++ b/lib/chart/ohlc/overlayable.ex @@ -0,0 +1,44 @@ +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 """ + 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 From bf7a3a5f00271c438fd5ab08bb198cb12d04caf9 Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 10 Mar 2024 13:19:58 +0100 Subject: [PATCH 18/24] implement simple moving average --- lib/chart/gallery/ohlc_candle_d1.sample | 7 +++- lib/chart/ohlc/ma.ex | 54 +++++++++++++++++++------ 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/lib/chart/gallery/ohlc_candle_d1.sample b/lib/chart/gallery/ohlc_candle_d1.sample index 2bc0554..3a046f2 100644 --- a/lib/chart/gallery/ohlc_candle_d1.sample +++ b/lib/chart/gallery/ohlc_candle_d1.sample @@ -74,6 +74,8 @@ data = [ 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, @@ -84,7 +86,10 @@ opts = [ shadow_color: "000000", crisp_edges: true, body_border: true, - timeframe: Contex.TimeScale.timeframe_d1() + timeframe: Contex.TimeScale.timeframe_d1(), + overlays: [ + MA.new( period: 5, color: "0000AA", width: 2) + ] ] Contex.Plot.new( dataset, Contex.OHLC, 1200, 800, opts) diff --git a/lib/chart/ohlc/ma.ex b/lib/chart/ohlc/ma.ex index 4b47359..c8d550b 100644 --- a/lib/chart/ohlc/ma.ex +++ b/lib/chart/ohlc/ma.ex @@ -7,12 +7,12 @@ defmodule Contex.OHLC.MA do defstruct dataset: nil, period: 14, - shift: 0, method: :simple, apply_to: :close, - color: "#FF000" + color: "#FF000", + width: 1 - @type method() :: :simple | :exponential | :smoothed | :weighted + @type method() :: :simple | :exponential | :weighted @type apply_to() :: :open | :high | :low | :close @type t() :: %__MODULE__{} @@ -24,32 +24,62 @@ defmodule Contex.OHLC.MA do @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 - |> Enum.map(&{accessors.datetime.(&1), accessors.close.(&1)}) - |> Dataset.new(["Date", "Close"]) + |> 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 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), render_config.domain)) + Enum.filter(data, &OHLC.within_domain?(elem(&1, 0), domain)) end) options = [ - mapping: %{x_col: "Date", y_cols: ["Close"]}, - smoothed: false, - stroke_width: "1", - colour_palette: ["ff9838"], + 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: render_config.x_transform, - y_transform: render_config.y_transform + x_transform: x_transform, + y_transform: y_transform ] Plot.new(dataset, Contex.LinePlot, 100, 100, options) From 2037f7f6e1fabe802bb3c7b1d2f8c4dd471dd3b9 Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 17 Mar 2024 19:27:13 +0100 Subject: [PATCH 19/24] make domain_min optionally a function --- lib/chart/ohlc.ex | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex index cd0a380..26e5bfc 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -50,6 +50,10 @@ defmodule Contex.OHLC do ] @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>> @@ -505,17 +509,24 @@ defmodule Contex.OHLC do @spec fix_spacing(t()) :: t() defp fix_spacing(plot) do - [datetime: dt_column] <~ plot.mapping.column_map - {min, max} = Dataset.column_extents(plot.mapping.dataset, dt_column) - min = get_option(plot, :domain_min) || min - + [dataset, column_map: [datetime: dt_column]] <~ plot.mapping [zoom] <~ plot.options [body_width, spacing] <~ @zoom_levels[zoom] + width = get_option(plot, :width) border_width = (body_width > 0 && 2) || 1 interval_width = body_width + spacing + border_width interval_count = floor(width / interval_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() From eb0b743ae1505c2bb7407fbc1a6c20dbdb18e07a Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 5 May 2024 18:33:58 +0200 Subject: [PATCH 20/24] avoid failing if data empty --- lib/chart/ohlc/ma.ex | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/lib/chart/ohlc/ma.ex b/lib/chart/ohlc/ma.ex index c8d550b..400b763 100644 --- a/lib/chart/ohlc/ma.ex +++ b/lib/chart/ohlc/ma.ex @@ -72,24 +72,26 @@ defmodule Contex.OHLC.MA do Enum.filter(data, &OHLC.within_domain?(elem(&1, 0), domain)) end) - 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))))) + 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 From 641b253cd3dec515837f34a7b27718d117adce49 Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 5 May 2024 18:44:18 +0200 Subject: [PATCH 21/24] rename arg --- lib/chart/ohlc/ma.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/chart/ohlc/ma.ex b/lib/chart/ohlc/ma.ex index 400b763..5a2df8a 100644 --- a/lib/chart/ohlc/ma.ex +++ b/lib/chart/ohlc/ma.ex @@ -102,7 +102,7 @@ defimpl Contex.OHLC.Overlayable, for: Contex.OHLC.MA do MA.init(ma, ohlc) end - def render(ma, ohlc) do - MA.render(ma, ohlc) + def render(ma, render_config) do + MA.render(ma, render_config) end end From 1887d5e88b8aa405a9a118283d1219ec7160a1c1 Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 5 May 2024 18:51:01 +0200 Subject: [PATCH 22/24] export interval computation function; optimize rendering loop; make optional domain (visible candles) window based y scale computation. --- lib/chart/gallery/ohlc_candle_d1.sample | 1 + lib/chart/ohlc.ex | 33 +++++++++++++++++-------- mix.lock | 3 +++ 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/lib/chart/gallery/ohlc_candle_d1.sample b/lib/chart/gallery/ohlc_candle_d1.sample index 3a046f2..10db50d 100644 --- a/lib/chart/gallery/ohlc_candle_d1.sample +++ b/lib/chart/gallery/ohlc_candle_d1.sample @@ -87,6 +87,7 @@ opts = [ crisp_edges: true, body_border: true, timeframe: Contex.TimeScale.timeframe_d1(), + y_scale_window: true, overlays: [ MA.new( period: 5, color: "0000AA", width: 2) ] diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex index 26e5bfc..87b185f 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -226,10 +226,14 @@ defmodule Contex.OHLC do defp render_data(plot) do [dataset] <~ plot.mapping - for row <- dataset.data, - rendered_row = maybe_render_row(plot, row), - into: [], - do: rendered_row + 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() @@ -510,13 +514,9 @@ defmodule Contex.OHLC do @spec fix_spacing(t()) :: t() defp fix_spacing(plot) do [dataset, column_map: [datetime: dt_column]] <~ plot.mapping - [zoom] <~ plot.options - [body_width, spacing] <~ @zoom_levels[zoom] + interval_count = fixed_interval_count( plot.options) width = get_option(plot, :width) - border_width = (body_width > 0 && 2) || 1 - interval_width = body_width + spacing + border_width - interval_count = floor(width / interval_width) tick_interval = get_option(plot, :timeframe) domain_min = get_option(plot, :domain_min) {min, max} = Dataset.column_extents(dataset, dt_column) @@ -548,6 +548,19 @@ defmodule Contex.OHLC do 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)} @@ -567,7 +580,7 @@ defmodule Contex.OHLC do custom_y_scale = get_option(plot, :custom_y_scale) filter_opts = - if fixed_timescale?(plot) do + if fixed_timescale?(plot) and !!get_option( plot, :y_scale_window) do accessor_dt = accessors.datetime domain = plot.x_scale.nice_domain 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"}, } From b72b52beb0d5eb0c857492827459a2c7ffa02fe0 Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 5 May 2024 18:53:12 +0200 Subject: [PATCH 23/24] format --- lib/chart/ohlc.ex | 16 ++++++++-------- lib/chart/ohlc/ma.ex | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex index 87b185f..d0a0edd 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -226,9 +226,9 @@ defmodule Contex.OHLC do defp render_data(plot) do [dataset] <~ plot.mapping - Enum.reduce( dataset.data, [], fn row, rendered -> + Enum.reduce(dataset.data, [], fn row, rendered -> if rendered_row = maybe_render_row(plot, row) do - [ rendered_row | rendered] + [rendered_row | rendered] else rendered end @@ -515,7 +515,7 @@ defmodule Contex.OHLC do defp fix_spacing(plot) do [dataset, column_map: [datetime: dt_column]] <~ plot.mapping - interval_count = fixed_interval_count( plot.options) + 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) @@ -551,10 +551,10 @@ defmodule Contex.OHLC do @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] + @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 @@ -580,7 +580,7 @@ defmodule Contex.OHLC do custom_y_scale = get_option(plot, :custom_y_scale) filter_opts = - if fixed_timescale?(plot) and !!get_option( plot, :y_scale_window) do + if fixed_timescale?(plot) and !!get_option(plot, :y_scale_window) do accessor_dt = accessors.datetime domain = plot.x_scale.nice_domain diff --git a/lib/chart/ohlc/ma.ex b/lib/chart/ohlc/ma.ex index 5a2df8a..e80f7af 100644 --- a/lib/chart/ohlc/ma.ex +++ b/lib/chart/ohlc/ma.ex @@ -72,7 +72,7 @@ defmodule Contex.OHLC.MA do Enum.filter(data, &OHLC.within_domain?(elem(&1, 0), domain)) end) - with true <- !Enum.empty?( dataset.data) || [] do + with true <- !Enum.empty?(dataset.data) || [] do options = [ mapping: %{x_col: "Date", y_cols: ["Average"]}, stroke_width: "#{Integer.to_string(ma.width)}", From b36fbbe7005a8a8e687c824bced19a750a69af18 Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 5 May 2024 19:53:16 +0200 Subject: [PATCH 24/24] add lag retrieval function --- lib/chart/ohlc/ma.ex | 10 ++++++++++ lib/chart/ohlc/overlayable.ex | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/lib/chart/ohlc/ma.ex b/lib/chart/ohlc/ma.ex index e80f7af..908e6e2 100644 --- a/lib/chart/ohlc/ma.ex +++ b/lib/chart/ohlc/ma.ex @@ -62,6 +62,12 @@ defmodule Contex.OHLC.MA 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 @@ -102,6 +108,10 @@ defimpl Contex.OHLC.Overlayable, for: Contex.OHLC.MA 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 diff --git a/lib/chart/ohlc/overlayable.ex b/lib/chart/ohlc/overlayable.ex index bfa391a..4eeaff1 100644 --- a/lib/chart/ohlc/overlayable.ex +++ b/lib/chart/ohlc/overlayable.ex @@ -17,6 +17,12 @@ defprotocol Contex.OHLC.Overlayable do @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. """