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"},