Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions lib/chart/gallery/ohlc_candle.sample
Original file line number Diff line number Diff line change
@@ -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"},
Expand Down
197 changes: 130 additions & 67 deletions lib/chart/ohlc.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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,
Expand All @@ -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 %{
Expand All @@ -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.
Expand All @@ -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"},
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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|<line x1="#{x}" x2="#{x}" y1="#{low}" y2="#{high}" stroke="#{colour}" />|,
rect(bar_x, {open, close}, "", bar_opts)
~s|<line x1="#{x}" x2="#{x}" y1="#{low}" y2="#{high}" #{style} />|,
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|<line x1="#{x}" x2="#{x}" y1="#{low}" y2="#{high}" #{style} />|,
~s|<line x1="#{x - @bar_width}" x2="#{x}" y1="#{open}" y2="#{open}" #{style}" />|,
~s|<line x1="#{x}" x2="#{x + @bar_width}" y1="#{close}" y2="#{close}" #{style}" />|
~s|<line x1="#{x - body_width}" x2="#{x}" y1="#{open}" y2="#{open}" #{style}" />|,
~s|<line x1="#{x}" x2="#{x + body_width}" y1="#{close}" y2="#{close}" #{style}" />|
]
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
Expand All @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions lib/chart/svg.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down