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..17aeaa3 100644 --- a/lib/chart/ohlc.ex +++ b/lib/chart/ohlc.ex @@ -27,7 +27,11 @@ defmodule Contex.OHLC do generated to handle the extents of the data. """ + # todo: + # - refactor :tick into :bar + import Contex.SVG + import Extructure alias __MODULE__ alias Contex.{Scale, ContinuousLinearScale, TimeScale} @@ -44,6 +48,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" + @black "000000" + @required_mappings [ datetime: :exactly_one, open: :exactly_one, @@ -60,7 +81,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: @black ] @default_plot_options %{ @@ -69,7 +94,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 +116,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,21 +156,20 @@ 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) @@ -164,84 +197,111 @@ 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) + y_vals = get_y_vals(row, accessors) - colour = get_colour(y_map) + scaled_y_vals = + Map.new(y_vals, fn {k, v} -> + {k, transforms.y.(v)} + end) - draw_row(x, y_map, colour, style) + color = get_colour(y_vals, plot) + draw_row(options, x, scaled_y_vals, 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 - - bar_x = {x - @bar_width, x + @bar_width} - bar_opts = [fill: colour] + # 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, body_color) + + 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} + + 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(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, 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||, - ~s||, - ~s|| + ~s||, + ~s|| ] end - defp get_scaled_y_vals(row, accessors, transforms) 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 - defp get_colour(%{open: open, close: close}) do + @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 +325,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 +352,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 +393,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/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]) 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"},