From 6fd41b1975ce8fa1eeb8e9131943d4a7d2eb57f6 Mon Sep 17 00:00:00 2001 From: mindok Date: Fri, 29 Dec 2023 17:09:45 +1100 Subject: [PATCH 1/7] typos --- lib/chart/pie_chart.ex | 2 +- lib/chart/pointplot.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/chart/pie_chart.ex b/lib/chart/pie_chart.ex index 4dc55e7..936e08a 100644 --- a/lib/chart/pie_chart.ex +++ b/lib/chart/pie_chart.ex @@ -39,7 +39,7 @@ defmodule Contex.PieChart do @doc """ Create a new PieChart struct from Dataset. - Options may be passed to control the settings for the barchart. Options available are: + Options may be passed to control the settings for the chart. Options available are: - `:data_labels` : `true` (default) or false - display labels for each slice value - `:colour_palette` : `:default` (default) or colour palette - see `colours/2` diff --git a/lib/chart/pointplot.ex b/lib/chart/pointplot.ex index 8d447da..25e7288 100644 --- a/lib/chart/pointplot.ex +++ b/lib/chart/pointplot.ex @@ -124,7 +124,7 @@ defmodule Contex.PointPlot do ``` The colours will be applied to the data series in the same order as the columns are specified in `set_val_col_names/2` - - `:mapping` : Maps attributes required to generate the barchart to columns in the dataset. + - `:mapping` : Maps attributes required to generate the chart to columns in the dataset. If the data in the dataset is stored as a map, the `:mapping` option is required. If the dataset is not stored as a map, `:mapping` may be left out, in which case the first column will be used From 964298d81fd5e838f1230befb3d9fba3bc9d6d7d Mon Sep 17 00:00:00 2001 From: mindok Date: Fri, 29 Dec 2023 17:10:08 +1100 Subject: [PATCH 2/7] First pass OHLC chart --- lib/chart/gallery/ohlc_candle.sample | 23 ++ lib/chart/gallery/ohlc_charts.ex | 32 +++ lib/chart/gallery/ohlc_tick.sample | 23 ++ lib/chart/ohlc.ex | 334 +++++++++++++++++++++++++++ lib/chart/plot.ex | 6 + 5 files changed, 418 insertions(+) create mode 100644 lib/chart/gallery/ohlc_candle.sample create mode 100644 lib/chart/gallery/ohlc_charts.ex create mode 100644 lib/chart/gallery/ohlc_tick.sample create mode 100644 lib/chart/ohlc.ex diff --git a/lib/chart/gallery/ohlc_candle.sample b/lib/chart/gallery/ohlc_candle.sample new file mode 100644 index 0000000..5e72247 --- /dev/null +++ b/lib/chart/gallery/ohlc_candle.sample @@ -0,0 +1,23 @@ +data = [ + [~N[2023-12-28 00:00:00], "AAPL", 34049900, 193.58, 194.14, 194.66, 193.17], + [~N[2023-12-27 00:00:00], "AAPL", 48087680, 193.15, 192.49, 193.50, 191.09], + [~N[2023-12-26 00:00:00], "AAPL", 28919310, 193.05, 193.61, 193.89, 192.83], + [~N[2023-12-25 00:00:00], "AAPL", 37149570, 193.60, 195.18, 195.41, 192.97], + [~N[2023-12-24 00:00:00], "AAPL", 46482550, 194.68, 196.10, 197.08, 193.50], + [~N[2023-12-23 00:00:00], "AAPL", 52242820, 194.83, 196.90, 197.68, 194.83], + [~N[2023-12-22 00:00:00], "AAPL", 40714050, 196.94, 196.16, 196.95, 195.89], + [~N[2023-12-21 00:00:00], "AAPL", 55751860, 195.89, 196.09, 196.63, 194.39] +] + +test_data = Dataset.new(data, ["Date", "Ticker", "Volume", "Close", "Open", "High", "Low"]) + +options = [ + mapping: %{datetime: "Date", open: "Open", high: "High", low: "Low", close: "Close"}, + style: :candle, + title: "AAPL" +] + +Contex.Plot.new(test_data, Contex.OHLC, 500, 400, options) + |> Contex.Plot.titles("Apple Stock Price", "") + |> Contex.Plot.axis_labels("", "") + |> Contex.Plot.plot_options(%{}) diff --git a/lib/chart/gallery/ohlc_charts.ex b/lib/chart/gallery/ohlc_charts.ex new file mode 100644 index 0000000..0628239 --- /dev/null +++ b/lib/chart/gallery/ohlc_charts.ex @@ -0,0 +1,32 @@ +defmodule Contex.Gallery.OHLCCharts do + import Contex.Gallery.Sample, only: [graph: 1] + + @moduledoc """ + A gallery of OHLC Charts. + + > #### Have one to share? {: .warning} + > + > Do you have an interesting plot you want to + > share? Something you learned the hard way that + > should be here, or that's just great to see? + > Just open a ticket on GitHub and we'll post it here. + + + """ + + @doc """ + Some OHLC charts. + + + #{graph(title: "A simple candle OHLC chart", + file: "ohlc_candle.sample" + )} + + #{graph(title: "A simple tick OHLC chart", + file: "ohlc_tick.sample" + )} + + + """ + def plain(), do: 0 +end diff --git a/lib/chart/gallery/ohlc_tick.sample b/lib/chart/gallery/ohlc_tick.sample new file mode 100644 index 0000000..e8220a5 --- /dev/null +++ b/lib/chart/gallery/ohlc_tick.sample @@ -0,0 +1,23 @@ +data = [ + [~N[2023-12-28 00:00:00], "AAPL", 34049900, 193.58, 194.14, 194.66, 193.17], + [~N[2023-12-27 00:00:00], "AAPL", 48087680, 193.15, 192.49, 193.50, 191.09], + [~N[2023-12-26 00:00:00], "AAPL", 28919310, 193.05, 193.61, 193.89, 192.83], + [~N[2023-12-25 00:00:00], "AAPL", 37149570, 193.60, 195.18, 195.41, 192.97], + [~N[2023-12-24 00:00:00], "AAPL", 46482550, 194.68, 196.10, 197.08, 193.50], + [~N[2023-12-23 00:00:00], "AAPL", 52242820, 194.83, 196.90, 197.68, 194.83], + [~N[2023-12-22 00:00:00], "AAPL", 40714050, 196.94, 196.16, 196.95, 195.89], + [~N[2023-12-21 00:00:00], "AAPL", 55751860, 195.89, 196.09, 196.63, 194.39] +] + +test_data = Dataset.new(data, ["Date", "Ticker", "Volume", "Close", "Open", "High", "Low"]) + +options = [ + mapping: %{datetime: "Date", open: "Open", high: "High", low: "Low", close: "Close"}, + style: :tick, + title: "AAPL" +] + +Contex.Plot.new(test_data, Contex.OHLC, 500, 400, options) + |> Contex.Plot.titles("Apple Stock Price", "") + |> Contex.Plot.axis_labels("", "") + |> Contex.Plot.plot_options(%{}) diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex new file mode 100644 index 0000000..9440611 --- /dev/null +++ b/lib/chart/ohlc.ex @@ -0,0 +1,334 @@ +defmodule Contex.OHLC do + @moduledoc """ + An open-high-low-close plot suitable for displaying stock/share prices + over time. + + The following columns are required in your dataset mapping: + - datetime + - open + - high + - low + - close + + You can elect to plot as candles or ticks. + + If close is higher than open, the candle or line will be plotted in green. + If close is lower than open, the candle or line will be plotted in red. + 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. + + The open / high / low / close columns must be of a numeric type (float or integer). + Decimals are not currently supported. + + If a custom x scale option is not provided, a linear y-axis scale will be automatically + generated to handle the extents of the data. + """ + + import Contex.SVG + + alias __MODULE__ + alias Contex.{Scale, ContinuousLinearScale, TimeScale} + alias Contex.{Dataset, Mapping} + alias Contex.Axis + alias Contex.Utils + + defstruct [ + :dataset, + :mapping, + :options, + :x_scale, + :y_scale, + transforms: %{}, + ] + + @required_mappings [ + datetime: :exactly_one, + open: :exactly_one, + high: :exactly_one, + low: :exactly_one, + close: :exactly_one + ] + + @default_options [ + axis_label_rotation: :auto, + style: :candle, + custom_x_scale: nil, + custom_y_scale: nil, + custom_x_formatter: nil, + custom_y_formatter: nil, + width: 100, + height: 100 + ] + + @default_plot_options %{ + show_x_axis: true, + show_y_axis: true, + legend_setting: :legend_none + } + + @type t() :: %__MODULE__{} + + + @doc """ + Create a new `OHLC` struct from Dataset. + + Options may be passed to control the settings for the chart. Options available are: + + - `:style` : `:candle` (default) or `:tick` - display style + + in addition to the common options + + An example: + data = [ + [~N[2023-12-28 00:00:00], "AAPL", 34049900, 193.58, 194.14, 194.66, 193.17], + [~N[2023-12-27 00:00:00], "AAPL", 48087680, 193.15, 192.49, 193.50, 191.09], + [~N[2023-12-26 00:00:00], "AAPL", 28919310, 193.05, 193.61, 193.89, 192.83], + [~N[2023-12-25 00:00:00], "AAPL", 37149570, 193.60, 195.18, 195.41, 192.97], + [~N[2023-12-24 00:00:00], "AAPL", 46482550, 194.68, 196.10, 197.08, 193.50], + [~N[2023-12-23 00:00:00], "AAPL", 52242820, 194.83, 196.90, 197.68, 194.83], + [~N[2023-12-22 00:00:00], "AAPL", 40714050, 196.94, 196.16, 196.95, 195.89], + [~N[2023-12-21 00:00:00], "AAPL", 55751860, 195.89, 196.09, 196.63, 194.39], + ] + + dataset = Dataset.new(data, ["Date", "Ticker", "Volume", "Close", "Open", "High", "Low"]) + + opts = [ + mapping: %{datetime: "Date", open: "Open", high: "High", low: "Low", close: "Close"}, + style: :tick, + title: "AAPL" + ] + + Contex.Plot.new(dataset, Contex.OHLC, 600, 400, opts) + """ + @spec new(Contex.Dataset.t(), keyword()) :: Contex.OHLC.t() + def new(%Dataset{} = dataset, options \\ []) do + options = Keyword.merge(@default_options, options) + mapping = Mapping.new(@required_mappings, Keyword.get(options, :mapping), dataset) + + %OHLC{dataset: dataset, mapping: mapping, options: options} + end + + @doc false + def set_size(%__MODULE__{} = chart, width, height) do + chart + |> set_option(:width, width) + |> set_option(:height, height) + end + + @doc false + def get_legend_scales(%__MODULE__{} = _chart) do + [] + end + + defp set_option(%__MODULE__{options: options} = plot, key, value) do + options = Keyword.put(options, key, value) + + %{plot | options: options} + end + + defp get_option(%__MODULE__{options: options}, key) do + Keyword.get(options, key) + end + + @doc false + def to_svg(%__MODULE__{} = plot, plot_options) do + plot = prepare_scales(plot) + x_scale = plot.x_scale + y_scale = plot.y_scale + + plot_options = Map.merge(@default_plot_options, plot_options) + + x_axis_svg = + if plot_options.show_x_axis, + do: + get_x_axis(x_scale, plot) + |> Axis.to_svg(), + else: "" + + y_axis_svg = + if plot_options.show_y_axis, + do: + Axis.new_left_axis(y_scale) + |> Axis.set_offset(get_option(plot, :width)) + |> Axis.to_svg(), + else: "" + + [ + x_axis_svg, + y_axis_svg, + "", + render_data(plot), + "" + ] + end + + @green "00AA00" + @red "AA0000" + @grey "444444" + @bar_width 2 + + defp render_data(%__MODULE__{dataset: dataset} = plot) do + style = get_option(plot, :style) + + dataset.data + |> Enum.map(fn row -> render_row(plot, row, style) end) + end + + defp render_row(%__MODULE__{mapping: mapping, transforms: transforms}, row, style) do + accessors = mapping.accessors + + x = + accessors.datetime.(row) + |> transforms.x.() + + y_map = get_scaled_y_vals(row, accessors, transforms) + + colour = get_colour(y_map) + + draw_row(x, y_map, colour, style) + end + + defp draw_row(x, y_map, colour, :candle) do + # We'll draw a grey line from low to high, then overlay a coloured rect + # for open / close + open = y_map.open + low = y_map.low + high = y_map.high + close = y_map.close + + bar_x = {x - @bar_width, x + @bar_width} + bar_opts = [fill: colour] + + [ + ~s||, + rect(bar_x, {open, close}, "", bar_opts) + ] + end + + defp draw_row(x, y_map, colour, :tick) do + # We'll draw a grey line from low to high, and tick from left for open + # and to right for close + open = y_map.open + low = y_map.low + high = y_map.high + close = y_map.close + + style = ~s|style="stroke: ##{colour}"| + + [ + ~s||, + ~s||, + ~s|| + ] + end + + defp get_scaled_y_vals(row, accessors, transforms) do + [:open, :high, :low, :close] + |> Enum.map(fn col -> + y = accessors[col].(row) |> transforms.y.() + + {col, y} + end) + |> Enum.into(%{}) + end + + defp get_colour(%{open: open, close: close}) do + cond do + close > open -> @green + close < open -> @red + true -> @grey + end + end + + defp get_x_axis(x_scale, plot) do + rotation = + case get_option(plot, :axis_label_rotation) do + :auto -> + if length(Scale.ticks_range(x_scale)) > 8, do: 45, else: 0 + + degrees -> + degrees + end + + x_scale + |> Axis.new_bottom_axis() + |> Axis.set_offset(get_option(plot, :height)) + |> Kernel.struct(rotation: rotation) + end + + @doc false + def prepare_scales(%__MODULE__{} = plot) do + plot + |> prepare_x_scale() + |> prepare_y_scale() + end + + defp prepare_x_scale(%__MODULE__{dataset: dataset, mapping: mapping} = plot) do + x_col_name = mapping.column_map[:datetime] + width = get_option(plot, :width) + custom_x_scale = get_option(plot, :custom_x_scale) + + x_scale = + case custom_x_scale do + nil -> create_timescale_for_column(dataset, x_col_name, {0, width}) + _ -> 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} + end + + defp create_timescale_for_column(dataset, column, {r_min, r_max}) do + {min, max} = Dataset.column_extents(dataset, column) + + TimeScale.new() + |> TimeScale.domain(min, max) + |> Scale.set_range(r_min, r_max) + end + + defp prepare_y_scale(%__MODULE__{dataset: dataset, mapping: mapping} = plot) do + y_col_names = [mapping.column_map[:open], mapping.column_map[:high], mapping.column_map[:low], mapping.column_map[:close]] + height = get_option(plot, :height) + custom_y_scale = get_option(plot, :custom_y_scale) + + y_scale = + case custom_y_scale do + nil -> + {min, max} = + get_overall_domain(dataset, y_col_names) + |> Utils.fixup_value_range() + + ContinuousLinearScale.new() + |> ContinuousLinearScale.domain(min, max) + |> Scale.set_range(height, 0) + + _ -> + custom_y_scale |> Scale.set_range(height, 0) + end + + y_scale = %{y_scale | custom_tick_formatter: get_option(plot, :custom_y_formatter)} + y_transform = Scale.domain_to_range_fn(y_scale) + transforms = Map.merge(plot.transforms, %{y: y_transform}) + + %{plot | y_scale: y_scale, transforms: transforms} + end + + # TODO: Extract into Dataset + defp get_overall_domain(dataset, col_names) 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) + combiner.(acc_extents, inner_extents) + end) + end + +end diff --git a/lib/chart/plot.ex b/lib/chart/plot.ex index ea6af9c..5f9fbdd 100644 --- a/lib/chart/plot.ex +++ b/lib/chart/plot.ex @@ -479,3 +479,9 @@ defimpl Contex.PlotContent, for: Contex.PieChart do def get_legend_scales(plot), do: Contex.PieChart.get_legend_scales(plot) def set_size(plot, width, height), do: Contex.PieChart.set_size(plot, width, height) end + +defimpl Contex.PlotContent, for: Contex.OHLC do + def to_svg(plot, options), do: Contex.OHLC.to_svg(plot, options) + def get_legend_scales(plot), do: Contex.OHLC.get_legend_scales(plot) + def set_size(plot, width, height), do: Contex.OHLC.set_size(plot, width, height) +end From 7d9c7b6c01abc509c33ccd3ac912564baa28fcd1 Mon Sep 17 00:00:00 2001 From: mindok Date: Fri, 29 Dec 2023 17:10:46 +1100 Subject: [PATCH 3/7] Formatting --- lib/chart/gallery/ohlc_charts.ex | 6 ++---- lib/chart/ohlc.ex | 18 +++++++++++------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/lib/chart/gallery/ohlc_charts.ex b/lib/chart/gallery/ohlc_charts.ex index 0628239..b03fd71 100644 --- a/lib/chart/gallery/ohlc_charts.ex +++ b/lib/chart/gallery/ohlc_charts.ex @@ -19,12 +19,10 @@ defmodule Contex.Gallery.OHLCCharts do #{graph(title: "A simple candle OHLC chart", - file: "ohlc_candle.sample" - )} + file: "ohlc_candle.sample")} #{graph(title: "A simple tick OHLC chart", - file: "ohlc_tick.sample" - )} + file: "ohlc_tick.sample")} """ diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex index 9440611..5fff1a1 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -41,7 +41,7 @@ defmodule Contex.OHLC do :options, :x_scale, :y_scale, - transforms: %{}, + transforms: %{} ] @required_mappings [ @@ -71,7 +71,6 @@ defmodule Contex.OHLC do @type t() :: %__MODULE__{} - @doc """ Create a new `OHLC` struct from Dataset. @@ -133,7 +132,7 @@ defmodule Contex.OHLC do Keyword.get(options, key) end - @doc false + @doc false def to_svg(%__MODULE__{} = plot, plot_options) do plot = prepare_scales(plot) x_scale = plot.x_scale @@ -288,12 +287,18 @@ defmodule Contex.OHLC do {min, max} = Dataset.column_extents(dataset, column) TimeScale.new() - |> TimeScale.domain(min, max) - |> Scale.set_range(r_min, r_max) + |> TimeScale.domain(min, max) + |> Scale.set_range(r_min, r_max) end defp prepare_y_scale(%__MODULE__{dataset: dataset, mapping: mapping} = plot) do - y_col_names = [mapping.column_map[:open], mapping.column_map[:high], mapping.column_map[:low], mapping.column_map[:close]] + y_col_names = [ + mapping.column_map[:open], + mapping.column_map[:high], + mapping.column_map[:low], + mapping.column_map[:close] + ] + height = get_option(plot, :height) custom_y_scale = get_option(plot, :custom_y_scale) @@ -330,5 +335,4 @@ defmodule Contex.OHLC do combiner.(acc_extents, inner_extents) end) end - end From f19fc6f41e089ffa69ef13ad43ae8cb0489337ac Mon Sep 17 00:00:00 2001 From: damir Date: Sat, 6 Jan 2024 22:23:14 +0100 Subject: [PATCH 4/7] fix input for bull/bear color selection; make configurable zoom level and colors. --- lib/chart/gallery/ohlc_candle.sample | 18 +-- lib/chart/ohlc.ex | 189 ++++++++++++++++----------- mix.exs | 1 + mix.lock | 1 + 4 files changed, 127 insertions(+), 82 deletions(-) diff --git a/lib/chart/gallery/ohlc_candle.sample b/lib/chart/gallery/ohlc_candle.sample index 5e72247..22b74fa 100644 --- a/lib/chart/gallery/ohlc_candle.sample +++ b/lib/chart/gallery/ohlc_candle.sample @@ -1,15 +1,15 @@ data = [ - [~N[2023-12-28 00:00:00], "AAPL", 34049900, 193.58, 194.14, 194.66, 193.17], - [~N[2023-12-27 00:00:00], "AAPL", 48087680, 193.15, 192.49, 193.50, 191.09], - [~N[2023-12-26 00:00:00], "AAPL", 28919310, 193.05, 193.61, 193.89, 192.83], - [~N[2023-12-25 00:00:00], "AAPL", 37149570, 193.60, 195.18, 195.41, 192.97], - [~N[2023-12-24 00:00:00], "AAPL", 46482550, 194.68, 196.10, 197.08, 193.50], - [~N[2023-12-23 00:00:00], "AAPL", 52242820, 194.83, 196.90, 197.68, 194.83], - [~N[2023-12-22 00:00:00], "AAPL", 40714050, 196.94, 196.16, 196.95, 195.89], - [~N[2023-12-21 00:00:00], "AAPL", 55751860, 195.89, 196.09, 196.63, 194.39] + [~N[2023-12-28 00:00:00], "AAPL", 34049900, 194.14, 194.66, 193.17, 193.58], + [~N[2023-12-27 00:00:00], "AAPL", 48087680, 192.49, 193.50, 191.09, 193.15], + [~N[2023-12-26 00:00:00], "AAPL", 28919310, 193.61, 193.89, 192.83, 193.05], + [~N[2023-12-25 00:00:00], "AAPL", 37149570, 195.18, 195.41, 192.97, 193.60], + [~N[2023-12-24 00:00:00], "AAPL", 46482550, 196.10, 197.08, 193.50, 194.68], + [~N[2023-12-23 00:00:00], "AAPL", 52242820, 196.90, 197.68, 194.83, 194.83], + [~N[2023-12-22 00:00:00], "AAPL", 40714050, 196.16, 196.95, 195.89, 196.94], + [~N[2023-12-21 00:00:00], "AAPL", 55751860, 196.09, 196.63, 194.39, 195.89] ] -test_data = Dataset.new(data, ["Date", "Ticker", "Volume", "Close", "Open", "High", "Low"]) +test_data = Dataset.new(data, ["Date", "Ticker", "Volume", "Open", "High", "Low", "Close"]) options = [ mapping: %{datetime: "Date", open: "Open", high: "High", low: "Low", close: "Close"}, diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex index 5fff1a1..33a5fa2 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -27,7 +27,12 @@ defmodule Contex.OHLC do generated to handle the extents of the data. """ + # todo: + # - invert row order and update docs to reflect it (i.e. from past to present not the other way around) + # - refactor :tick into :bar + import Contex.SVG + import Extructure alias __MODULE__ alias Contex.{Scale, ContinuousLinearScale, TimeScale} @@ -44,6 +49,23 @@ defmodule Contex.OHLC do transforms: %{} ] + @type t() :: %__MODULE__{} + @typep row() :: list() + @typep rendered_row() :: list() + @typep color() :: <<_::24>> + + @typep y_vals() :: + %{ + open: number(), + high: number(), + low: number(), + close: number() + } + + @green "00AA00" + @red "AA0000" + @grey "444444" + @required_mappings [ datetime: :exactly_one, open: :exactly_one, @@ -60,7 +82,11 @@ defmodule Contex.OHLC do custom_x_formatter: nil, custom_y_formatter: nil, width: 100, - height: 100 + height: 100, + zoom: 3, + bull_color: @green, + bear_color: @red, + shadow_color: @grey ] @default_plot_options %{ @@ -69,7 +95,16 @@ defmodule Contex.OHLC do legend_setting: :legend_none } - @type t() :: %__MODULE__{} + @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] + ] + |> Stream.with_index() + |> Map.new(fn {k, v} -> {v, Map.new(k)} end) @doc """ Create a new `OHLC` struct from Dataset. @@ -82,17 +117,17 @@ defmodule Contex.OHLC do An example: data = [ - [~N[2023-12-28 00:00:00], "AAPL", 34049900, 193.58, 194.14, 194.66, 193.17], - [~N[2023-12-27 00:00:00], "AAPL", 48087680, 193.15, 192.49, 193.50, 191.09], - [~N[2023-12-26 00:00:00], "AAPL", 28919310, 193.05, 193.61, 193.89, 192.83], - [~N[2023-12-25 00:00:00], "AAPL", 37149570, 193.60, 195.18, 195.41, 192.97], - [~N[2023-12-24 00:00:00], "AAPL", 46482550, 194.68, 196.10, 197.08, 193.50], - [~N[2023-12-23 00:00:00], "AAPL", 52242820, 194.83, 196.90, 197.68, 194.83], - [~N[2023-12-22 00:00:00], "AAPL", 40714050, 196.94, 196.16, 196.95, 195.89], - [~N[2023-12-21 00:00:00], "AAPL", 55751860, 195.89, 196.09, 196.63, 194.39], + [~N[2023-12-28 00:00:00], "AAPL", 34049900, 194.14, 194.66, 193.17, 193.58], + [~N[2023-12-27 00:00:00], "AAPL", 48087680, 192.49, 193.50, 191.09, 193.15], + [~N[2023-12-26 00:00:00], "AAPL", 28919310, 193.61, 193.89, 192.83, 193.05], + [~N[2023-12-25 00:00:00], "AAPL", 37149570, 195.18, 195.41, 192.97, 193.60], + [~N[2023-12-24 00:00:00], "AAPL", 46482550, 196.10, 197.08, 193.50, 194.68], + [~N[2023-12-23 00:00:00], "AAPL", 52242820, 196.90, 197.68, 194.83, 194.83], + [~N[2023-12-22 00:00:00], "AAPL", 40714050, 196.16, 196.95, 195.89, 196.94], + [~N[2023-12-21 00:00:00], "AAPL", 55751860, 196.09, 196.63, 194.39, 195.89], ] - dataset = Dataset.new(data, ["Date", "Ticker", "Volume", "Close", "Open", "High", "Low"]) + dataset = Dataset.new(data, ["Date", "Ticker", "Volume", "Open", "High", "Low", "Close"]) opts = [ mapping: %{datetime: "Date", open: "Open", high: "High", low: "Low", close: "Close"}, @@ -122,38 +157,37 @@ defmodule Contex.OHLC do [] end - defp set_option(%__MODULE__{options: options} = plot, key, value) do - options = Keyword.put(options, key, value) - - %{plot | options: options} + @spec set_option(t(), atom(), any()) :: t() + defp set_option(plot, key, value) do + update(plot, :options, &Keyword.put(&1, key, value)) end - defp get_option(%__MODULE__{options: options}, key) do - Keyword.get(options, key) + @spec get_option(t(), atom()) :: term() + defp get_option(plot, key) do + Keyword.get(plot.options, key) end @doc false def to_svg(%__MODULE__{} = plot, plot_options) do plot = prepare_scales(plot) - x_scale = plot.x_scale - y_scale = plot.y_scale + [x_scale, y_scale] <~ plot plot_options = Map.merge(@default_plot_options, plot_options) x_axis_svg = if plot_options.show_x_axis, - do: - get_x_axis(x_scale, plot) - |> Axis.to_svg(), - else: "" + do: + get_x_axis(x_scale, plot) + |> Axis.to_svg(), + else: "" y_axis_svg = if plot_options.show_y_axis, - do: - Axis.new_left_axis(y_scale) - |> Axis.set_offset(get_option(plot, :width)) - |> Axis.to_svg(), - else: "" + do: + Axis.new_left_axis(y_scale) + |> Axis.set_offset(get_option(plot, :width)) + |> Axis.to_svg(), + else: "" [ x_axis_svg, @@ -164,41 +198,39 @@ defmodule Contex.OHLC do ] end - @green "00AA00" - @red "AA0000" - @grey "444444" - @bar_width 2 - - defp render_data(%__MODULE__{dataset: dataset} = plot) do - style = get_option(plot, :style) + @spec render_data(t()) :: [rendered_row()] + defp render_data(plot) do + [dataset] <~ plot dataset.data - |> Enum.map(fn row -> render_row(plot, row, style) end) + |> Enum.map(fn row -> render_row(plot, row) end) end - defp render_row(%__MODULE__{mapping: mapping, transforms: transforms}, row, style) do - accessors = mapping.accessors + @spec render_row(t(), row()) :: rendered_row() + defp render_row(plot, row) do + [transforms, mapping: [accessors], options: options = %{}] <~ plot x = accessors.datetime.(row) |> transforms.x.() - y_map = get_scaled_y_vals(row, accessors, transforms) - - colour = get_colour(y_map) - - draw_row(x, y_map, colour, style) + color = get_colour(get_y_vals(row, accessors), plot) + draw_row(options, x, get_y_vals(row, accessors, transforms.y), color) end - defp draw_row(x, y_map, colour, :candle) do - # We'll draw a grey line from low to high, then overlay a coloured rect - # for open / close - open = y_map.open - low = y_map.low - high = y_map.high - close = y_map.close + # Draws a grey line from low to high, then overlay a coloured rect + # for open / close if `:candle` style. + # Draws a grey line from low to high, and tick from left for open + # and to right for close if `:tick` style. + @spec draw_row(map(), number(), y_vals(), color()) :: rendered_row() + defp draw_row(options, x, y_map, colour) - bar_x = {x - @bar_width, x + @bar_width} + defp draw_row(%{style: :candle} = options, x, y_map, colour) do + [zoom] <~ options + [body_width] <~ @zoom_levels[zoom] + [open, high, low, close] <~ y_map + + bar_x = {x - body_width, x + body_width} bar_opts = [fill: colour] [ @@ -207,41 +239,49 @@ defmodule Contex.OHLC do ] end - defp draw_row(x, y_map, colour, :tick) do - # We'll draw a grey line from low to high, and tick from left for open - # and to right for close - open = y_map.open - low = y_map.low - high = y_map.high - close = y_map.close + defp draw_row(%{style: :tick} = options, x, y_map, colour) do + [zoom] <~ options + [body_width] <~ @zoom_levels[zoom] + [open, high, low, close] <~ y_map style = ~s|style="stroke: ##{colour}"| [ ~s||, - ~s||, - ~s|| + ~s||, + ~s|| ] end - defp get_scaled_y_vals(row, accessors, transforms) do + @spec get_y_vals(row(), accessors, transforms_y) :: y_vals() + when accessors: %{atom() => (row() -> number())}, + transforms_y: (number() -> number()) + defp get_y_vals(row, accessors, transforms_y \\ & &1) do [:open, :high, :low, :close] |> Enum.map(fn col -> - y = accessors[col].(row) |> transforms.y.() + y = + accessors[col].(row) + |> transforms_y.() {col, y} end) |> Enum.into(%{}) end - defp get_colour(%{open: open, close: close}) do + # todo: remove shadow color redundancy once body border is implemented + @spec get_colour(y_vals(), t()) :: color() + defp get_colour(y_map, plot) do + [bull_color, bear_color, shadow_color] <~ plot.options + [open, close] <~ y_map + cond do - close > open -> @green - close < open -> @red - true -> @grey + close > open -> bull_color + close < open -> bear_color + true -> shadow_color end end + @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 @@ -265,6 +305,7 @@ defmodule Contex.OHLC do |> prepare_y_scale() end + @spec prepare_x_scale(t()) :: t() defp prepare_x_scale(%__MODULE__{dataset: dataset, mapping: mapping} = plot) do x_col_name = mapping.column_map[:datetime] width = get_option(plot, :width) @@ -291,14 +332,11 @@ defmodule Contex.OHLC do |> Scale.set_range(r_min, r_max) end - defp prepare_y_scale(%__MODULE__{dataset: dataset, mapping: mapping} = plot) do - y_col_names = [ - mapping.column_map[:open], - mapping.column_map[:high], - mapping.column_map[:low], - mapping.column_map[:close] - ] + @spec prepare_y_scale(t()) :: t() + defp prepare_y_scale(plot) do + [dataset, mapping: [column_map]] <~ plot + 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) @@ -335,4 +373,9 @@ defmodule Contex.OHLC do combiner.(acc_extents, inner_extents) end) end + + @spec update(t(), atom(), (term() -> term())) :: t() + defp update(plot, field, updater) do + struct!(plot, %{field => updater.(Map.fetch!(plot, field))}) + end end diff --git a/mix.exs b/mix.exs index 1e35664..b0fd3b8 100644 --- a/mix.exs +++ b/mix.exs @@ -34,6 +34,7 @@ defmodule Contex.MixProject do {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, {:sweet_xml, "~> 0.7.3", only: :test}, {:floki, "~> 0.34.2", only: :test}, + {:extructure, "~> 1.0"}, {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false} ] end diff --git a/mix.lock b/mix.lock index 3eabc7c..2b51e10 100644 --- a/mix.lock +++ b/mix.lock @@ -4,6 +4,7 @@ "earmark_parser": {:hex, :earmark_parser, "1.4.26", "f4291134583f373c7d8755566122908eb9662df4c4b63caa66a0eabe06569b0a", [:mix], [], "hexpm", "48d460899f8a0c52c5470676611c01f64f3337bad0b26ddab43648428d94aabc"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "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"}, "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"}, From 8c5ee81b83e03a6e25ab9ab1e9dae7a19d9ffa9c Mon Sep 17 00:00:00 2001 From: damir Date: Sat, 6 Jan 2024 23:43:58 +0100 Subject: [PATCH 5/7] make shadow_color work; add crisp_edges option; make colorized_bars optional; make body border optional. --- lib/chart/ohlc.ex | 59 +++++++++++++++++++++++++++++++---------------- lib/chart/svg.ex | 3 +++ 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex index 33a5fa2..deabf5a 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -64,7 +64,7 @@ defmodule Contex.OHLC do @green "00AA00" @red "AA0000" - @grey "444444" + @black "000000" @required_mappings [ datetime: :exactly_one, @@ -86,7 +86,7 @@ defmodule Contex.OHLC do zoom: 3, bull_color: @green, bear_color: @red, - shadow_color: @grey + shadow_color: @black ] @default_plot_options %{ @@ -176,18 +176,18 @@ defmodule Contex.OHLC do x_axis_svg = if plot_options.show_x_axis, - do: - get_x_axis(x_scale, plot) - |> Axis.to_svg(), - else: "" + do: + get_x_axis(x_scale, plot) + |> Axis.to_svg(), + else: "" y_axis_svg = if plot_options.show_y_axis, - do: - Axis.new_left_axis(y_scale) - |> Axis.set_offset(get_option(plot, :width)) - |> Axis.to_svg(), - else: "" + do: + Axis.new_left_axis(y_scale) + |> Axis.set_offset(get_option(plot, :width)) + |> Axis.to_svg(), + else: "" [ x_axis_svg, @@ -223,28 +223,47 @@ defmodule Contex.OHLC do # Draws a grey line from low to high, and tick from left for open # and to right for close if `:tick` style. @spec draw_row(map(), number(), y_vals(), color()) :: rendered_row() - defp draw_row(options, x, y_map, colour) + defp draw_row(options, x, y_map, body_color) - defp draw_row(%{style: :candle} = options, x, y_map, colour) do - [zoom] <~ options + defp draw_row(%{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 bar_x = {x - body_width, x + body_width} - bar_opts = [fill: colour] + + 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}, "", bar_opts) + ~s||, + rect(bar_x, {open, close}, "", body_opts) ] end - defp draw_row(%{style: :tick} = options, x, y_map, colour) do - [zoom] <~ options + defp draw_row(%{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: ##{colour}"| + style = + [ + ~s|style="stroke: ##{colorized_bars && body_color || shadow_color}"|, + (crisp_edges && ~s| shape-rendering="crispEdges"|) || "" + ] + |> Enum.join() [ ~s||, diff --git a/lib/chart/svg.ex b/lib/chart/svg.ex index 55f16d0..6547df3 100644 --- a/lib/chart/svg.ex +++ b/lib/chart/svg.ex @@ -221,6 +221,9 @@ defmodule Contex.SVG do defp opts_to_attrs([{:marker_end, val} | t], attrs), do: opts_to_attrs(t, [[" marker-end=\"", val, "\""] | attrs]) + defp opts_to_attrs([{:shape_rendering, val} | t], attrs), + do: opts_to_attrs(t, [[" shape-rendering=\"", val, "\""] | attrs]) + defp opts_to_attrs([{key, val} | t], attrs) when is_atom(key), do: opts_to_attrs(t, [[" ", Atom.to_string(key), "=\"", clean(val), "\""] | attrs]) From 1bf65d89e4e970c500c897b31a8e8bfb1a049e77 Mon Sep 17 00:00:00 2001 From: damir Date: Sun, 7 Jan 2024 11:05:30 +0100 Subject: [PATCH 6/7] remove redundant invocation of get_y_vals --- lib/chart/ohlc.ex | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex index deabf5a..066a60a 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -28,7 +28,6 @@ defmodule Contex.OHLC do """ # todo: - # - invert row order and update docs to reflect it (i.e. from past to present not the other way around) # - refactor :tick into :bar import Contex.SVG @@ -214,8 +213,14 @@ defmodule Contex.OHLC do accessors.datetime.(row) |> transforms.x.() - color = get_colour(get_y_vals(row, accessors), plot) - draw_row(options, x, get_y_vals(row, accessors, transforms.y), color) + y_vals = get_y_vals(row, accessors) + + scaled_y_vals = Map.new( y_vals, fn { k, v} -> + { k, transforms.y.( v)} + end) + + color = get_colour(y_vals, plot) + draw_row(options, x, scaled_y_vals, color) end # Draws a grey line from low to high, then overlay a coloured rect @@ -272,22 +277,17 @@ defmodule Contex.OHLC do ] end - @spec get_y_vals(row(), accessors, transforms_y) :: y_vals() - when accessors: %{atom() => (row() -> number())}, - transforms_y: (number() -> number()) - defp get_y_vals(row, accessors, transforms_y \\ & &1) do + @spec get_y_vals(row(), %{atom() => (row() -> number())}) :: y_vals() + defp get_y_vals(row, accessors) do [:open, :high, :low, :close] |> Enum.map(fn col -> - y = - accessors[col].(row) - |> transforms_y.() + y = accessors[col].(row) {col, y} end) |> Enum.into(%{}) end - # todo: remove shadow color redundancy once body border is implemented @spec get_colour(y_vals(), t()) :: color() defp get_colour(y_map, plot) do [bull_color, bear_color, shadow_color] <~ plot.options From e32997d589d800e25d1447ed0a8c68779924d215 Mon Sep 17 00:00:00 2001 From: damir Date: Tue, 16 Jan 2024 12:25:41 +0100 Subject: [PATCH 7/7] format --- lib/chart/ohlc.ex | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/chart/ohlc.ex b/lib/chart/ohlc.ex index 066a60a..17aeaa3 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -215,9 +215,10 @@ defmodule Contex.OHLC do 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) @@ -265,7 +266,7 @@ defmodule Contex.OHLC do style = [ - ~s|style="stroke: ##{colorized_bars && body_color || shadow_color}"|, + ~s|style="stroke: ##{(colorized_bars && body_color) || shadow_color}"|, (crisp_edges && ~s| shape-rendering="crispEdges"|) || "" ] |> Enum.join()