diff --git a/FranzCocoa/Backend.swift b/FranzCocoa/Backend.swift index a3081d3..f4beb23 100644 --- a/FranzCocoa/Backend.swift +++ b/FranzCocoa/Backend.swift @@ -95,6 +95,7 @@ public enum ChartScaleType: Readable, Writable { public enum ChartStyle: Readable, Writable { case bar + case candlestick(UVarint) case line case scatter @@ -104,8 +105,12 @@ public enum ChartStyle: Readable, Writable { case 0x0000: return .bar case 0x0001: - return .line + return .candlestick( + UVarint.read(from: inp, using: &buf) + ) case 0x0002: + return .line + case 0x0003: return .scatter default: preconditionFailure("ChartStyle: unexpected tag \(tag)") @@ -116,29 +121,45 @@ public enum ChartStyle: Readable, Writable { switch self { case .bar: UVarint(0x0000).write(to: out) - case .line: + case .candlestick(let width): UVarint(0x0001).write(to: out) - case .scatter: + width.write(to: out) + case .line: UVarint(0x0002).write(to: out) + case .scatter: + UVarint(0x0003).write(to: out) } } } public enum ChartValue: Readable, Writable { + case candlestick(Float64, Float64, Float64, Float64) case categorical(String) case numerical(Float64) + case timestamp(UVarint) public static func read(from inp: InputPort, using buf: inout Data) -> ChartValue { let tag = UVarint.read(from: inp, using: &buf) switch tag { case 0x0000: + return .candlestick( + Float64.read(from: inp, using: &buf), + Float64.read(from: inp, using: &buf), + Float64.read(from: inp, using: &buf), + Float64.read(from: inp, using: &buf) + ) + case 0x0001: return .categorical( String.read(from: inp, using: &buf) ) - case 0x0001: + case 0x0002: return .numerical( Float64.read(from: inp, using: &buf) ) + case 0x0003: + return .timestamp( + UVarint.read(from: inp, using: &buf) + ) default: preconditionFailure("ChartValue: unexpected tag \(tag)") } @@ -146,12 +167,21 @@ public enum ChartValue: Readable, Writable { public func write(to out: OutputPort) { switch self { - case .categorical(let s): + case .candlestick(let o, let h, let l, let c): UVarint(0x0000).write(to: out) + o.write(to: out) + h.write(to: out) + l.write(to: out) + c.write(to: out) + case .categorical(let s): + UVarint(0x0001).write(to: out) s.write(to: out) case .numerical(let n): - UVarint(0x0001).write(to: out) + UVarint(0x0002).write(to: out) n.write(to: out) + case .timestamp(let t): + UVarint(0x0003).write(to: out) + t.write(to: out) } } } diff --git a/FranzCocoa/ResultDetail.swift b/FranzCocoa/ResultDetail.swift index 91d4f54..606413d 100644 --- a/FranzCocoa/ResultDetail.swift +++ b/FranzCocoa/ResultDetail.swift @@ -56,14 +56,21 @@ fileprivate struct ChartResult: View { } private func chartView() -> any View { - let view = Charts.Chart(pairs(chart.xs, chart.ys)) { p in + let ps = pairs(chart.xs, chart.ys) + let view = Charts.Chart(ps) { p in switch chart.style { case .bar: - p.barMark(xLabel: chart.xLabel, yLabel: chart.yLabel) + try? p.barMark(xLabel: chart.xLabel, yLabel: chart.yLabel) + case .candlestick: + try? p.candlestickMark( + xLabel: chart.xLabel, + yLabel: chart.yLabel, + previous: p.id > 0 && p.id < ps.endIndex ? ps[p.id-1] : nil + ) case .line: - p.lineMark(xLabel: chart.xLabel, yLabel: chart.yLabel) + try? p.lineMark(xLabel: chart.xLabel, yLabel: chart.yLabel) case .scatter: - p.pointMark(xLabel: chart.xLabel, yLabel: chart.yLabel) + try? p.pointMark(xLabel: chart.xLabel, yLabel: chart.yLabel) } } switch (chart.xScale, chart.yScale) { @@ -80,12 +87,34 @@ fileprivate struct ChartResult: View { } } + enum ChartError: Swift.Error { + case badMarks + } + + private enum CandlestickDirection { + case up + case down + } + + private struct CandlestickMark: ChartContent { + let x: PlottableValue + let o: PlottableValue + let h: PlottableValue + let l: PlottableValue + let c: PlottableValue + + var body: some ChartContent { + RectangleMark(x: x, yStart: l, yEnd: h, width: 1) + RectangleMark(x: x, yStart: o, yEnd: c, width: 14) + } + } + private struct Pair: Identifiable { let id: Int let x: ChartValue let y: ChartValue - func barMark(xLabel: String, yLabel: String) -> BarMark { + func barMark(xLabel: String, yLabel: String) throws -> BarMark { switch (x, y) { case (.categorical(let xcat), .categorical(let ycat)): return BarMark(x: .value(xLabel, xcat), y: .value(yLabel, ycat)) @@ -95,10 +124,39 @@ fileprivate struct ChartResult: View { return BarMark(x: .value(xLabel, x), y: .value(yLabel, ycat)) case (.numerical(let x), .numerical(let y)): return BarMark(x: .value(xLabel, x), y: .value(yLabel, y)) + default: + throw ChartError.badMarks + } + } + + func candlestickMark( + xLabel: String, + yLabel: String, + previous: Pair? + ) throws -> some ChartContent { + switch (x, y) { + case (.timestamp(let ts), .candlestick(let o, let h, let l, let c)): + var direction: CandlestickDirection + switch previous?.y { + case .some(.candlestick(_, _, _, let oc)): + direction = oc > c ? .down : .up + default: + direction = .up + } + return CandlestickMark( + x: .value("Date", Date(timeIntervalSince1970: Double(ts))), + o: .value("Open", o), + h: .value("High", h), + l: .value("Low", l), + c: .value("Close", c) + ) + .foregroundStyle(direction == .down ? .red : .green) + default: + throw ChartError.badMarks } } - func lineMark(xLabel: String, yLabel: String) -> LineMark { + func lineMark(xLabel: String, yLabel: String) throws -> LineMark { switch (x, y) { case (.categorical(let xcat), .categorical(let ycat)): return LineMark(x: .value(xLabel, xcat), y: .value(yLabel, ycat)) @@ -108,10 +166,12 @@ fileprivate struct ChartResult: View { return LineMark(x: .value(xLabel, x), y: .value(yLabel, ycat)) case (.numerical(let x), .numerical(let y)): return LineMark(x: .value(xLabel, x), y: .value(yLabel, y)) + default: + throw ChartError.badMarks } } - func pointMark(xLabel: String, yLabel: String) -> PointMark { + func pointMark(xLabel: String, yLabel: String) throws -> PointMark { switch (x, y) { case (.categorical(let xcat), .categorical(let ycat)): return PointMark(x: .value(xLabel, xcat), y: .value(yLabel, ycat)) @@ -121,6 +181,8 @@ fileprivate struct ChartResult: View { return PointMark(x: .value(xLabel, x), y: .value(yLabel, ycat)) case (.numerical(let x), .numerical(let y)): return PointMark(x: .value(xLabel, x), y: .value(yLabel, y)) + default: + throw ChartError.badMarks } } } diff --git a/FranzCross/result-detail.rkt b/FranzCross/result-detail.rkt index 1304f8c..d060dbd 100644 --- a/FranzCross/result-detail.rkt +++ b/FranzCross/result-detail.rkt @@ -38,8 +38,10 @@ (define (ChartValue-> v) (match v + [(ChartValue.candlestick o h l c) (list o h l c)] [(ChartValue.categorical category) category] - [(ChartValue.numerical n) n])) + [(ChartValue.numerical n) n] + [(ChartValue.timestamp t) t])) (define (chart-view c) (hpanel @@ -49,26 +51,45 @@ (ChartScale-> (Chart-x-scale c))) (define-values (y-min y-max) (ChartScale-> (Chart-y-scale c))) - (apply - plot-snip - #:width width - #:height height - #:x-min x-min - #:x-max x-max - #:x-label (Chart-x-label c) - #:y-min y-min - #:y-max y-max - #:y-label (Chart-y-label c) - (list - ((match (Chart-style c) - [(ChartStyle.bar) discrete-histogram] - [(ChartStyle.line) lines] - [(ChartStyle.scatter) points]) - (for/list ([x (in-list (Chart-xs c))] - [y (in-list (Chart-ys c))]) - (list - (ChartValue-> x) - (ChartValue-> y)))))))))) + (define x-date-ticks? + (ormap ChartValue.timestamp? (Chart-xs c))) + (define y-date-ticks? + (ormap ChartValue.timestamp? (Chart-ys c))) + (parameterize ([candlestick-width + (match (Chart-style c) + [(ChartStyle.candlestick width) width] + [_ 1])] + [plot-x-ticks + (if x-date-ticks? + (date-ticks) + (linear-ticks))] + [plot-y-ticks + (if y-date-ticks? + (date-ticks) + (linear-ticks))]) + (apply + plot-snip + #:width width + #:height height + #:x-min x-min + #:x-max x-max + #:x-label (Chart-x-label c) + #:y-min y-min + #:y-max y-max + #:y-label (Chart-y-label c) + (list + ((match (Chart-style c) + [(ChartStyle.bar) discrete-histogram] + [(ChartStyle.candlestick _) candlesticks] + [(ChartStyle.line) lines] + [(ChartStyle.scatter) points]) + (for/list ([x (in-list (Chart-xs c))] + [y (in-list (Chart-ys c))]) + ((match (Chart-style c) + [(ChartStyle.candlestick _) list*] + [_ list]) + (ChartValue-> x) + (ChartValue-> y))))))))))) (define (text-view s) (hpanel diff --git a/core/extlib/render.lua b/core/extlib/render.lua index 02f4597..d8288d8 100644 --- a/core/extlib/render.lua +++ b/core/extlib/render.lua @@ -1,5 +1,17 @@ #lang lua +local render = {} + +local function check(who, v, typ, n) + if type(v) ~= typ then + if n == nil then + error(string.format("%s: expected a %s, but got %s", who, typ, type(v))) + else + error(string.format("%s: arg #%d must be a %s, but got %s", who, n, typ, type(v))) + end + end +end + -- Chart --------------------------------------------------------------- local function makeChartClass(name) @@ -97,6 +109,50 @@ local function makeChartClass(name) return Chart end +render.BarChart = makeChartClass("BarChart") +render.CandlestickChart = makeChartClass("CandlestickChart") +render.LineChart = makeChartClass("LineChart") +render.ScatterChart = makeChartClass("ScatterChart") + +function render.CandlestickChart:setwidth(w) + check("CandlestickChart:setwidth", w, "number") + self.candlestick_width = w + return self +end + +render.Candlestick = Class { + name = "Candlestick", + constructor = function(o, h, l, c) + check("Candlestick", o, "number", 1) + check("Candlestick", h, "number", 2) + check("Candlestick", l, "number", 3) + check("Candlestick", c, "number", 4) + return { + __type = "Candlestick", + o = o, + h = h, + l = l, + c = c + } + end +} + +render.Timestamp = Class { + name = "Timestamp", + constructor = function(ts) + check("Timestamp", ts, "number") + return { + __type = "Timestamp", + ts = ts + } + end +} + +function render.Timestamp:__lt(other) + return self.ts < other.ts +end + + -- Stack --------------------------------------------------------------- local function makeStackClass(name) @@ -117,26 +173,22 @@ local function makeStackClass(name) return Stack end +render.HStack = makeStackClass("HStack") +render.VStack = makeStackClass("VStack") + + -- Table --------------------------------------------------------------- -local Table = Class { +render.Table = Class { name = "Table", constructor = function(columns, ...) return { __type = "Table", columns = columns, - rows = table.pack(...) + rows = {...} } end } - -return { - BarChart = makeChartClass("BarChart"), - LineChart = makeChartClass("LineChart"), - ScatterChart = makeChartClass("ScatterChart"), - HStack = makeStackClass("HStack"), - VStack = makeStackClass("VStack"), - Table = Table -} +return render diff --git a/core/script.rkt b/core/script.rkt index ed02622..2f077f6 100644 --- a/core/script.rkt +++ b/core/script.rkt @@ -44,12 +44,20 @@ (define-enum ChartStyle [bar] + [candlestick + {width : UVarint}] [line] [scatter]) (define-enum ChartValue + [candlestick + {o : Float64} + {h : Float64} + {l : Float64} + {c : Float64}] [categorical {s : String}] - [numerical {n : Float64}]) + [numerical {n : Float64}] + [timestamp {t : UVarint}]) (define-record Chart [style : ChartStyle] @@ -91,17 +99,42 @@ [else (error '->ChartScale "invalid scale type")]))] [else (error '->ChartScale "invalid scale value: ~s" v)])) -(define (->ChartStyle v) - (case v - [(#"BarChart") (ChartStyle.bar)] - [(#"LineChart") (ChartStyle.line)] - [(#"ScatterChart") (ChartStyle.scatter)] - [else (error '->ReduceResult "invalid chart type: ~s" v)])) +(define (->ChartStyle type v) + (case type + [(#"BarChart") + (ChartStyle.bar)] + [(#"CandlestickChart") + (ChartStyle.candlestick + (let ([width (table-ref v #"candlestick_width")]) + (if (nil? width) 1 (inexact->exact width))))] + [(#"LineChart") + (ChartStyle.line)] + [(#"ScatterChart") + (ChartStyle.scatter)] + [else (error '->ReduceResult "invalid chart type: ~s" type)])) (define (->ChartValue v) (cond - [(bytes? v) (ChartValue.categorical (bytes->string v))] - [(number? v) (ChartValue.numerical v)] + [(bytes? v) + (ChartValue.categorical (bytes->string v))] + [(number? v) + (ChartValue.numerical v)] + [(table? v) + (define type + (table-ref v #"__type")) + (case type + [(#"Candlestick") + (ChartValue.candlestick + (table-ref-number v #"o") + (table-ref-number v #"h") + (table-ref-number v #"l") + (table-ref-number v #"c"))] + [(#"Timestamp") + (ChartValue.timestamp + (inexact->exact + (table-ref-number v #"ts")))] + [else + (error '->ChartValue "invalid table: ~s" v)])] [else (error '->ChartValue "invalid value: ~s" v)])) (define (->ReduceResult v) @@ -114,10 +147,10 @@ (define type (table-ref v #"__type")) (case type - [(#"BarChart" #"LineChart" #"ScatterChart") + [(#"BarChart" #"CandlestickChart" #"LineChart" #"ScatterChart") (ReduceResult.chart (make-Chart - #:style (->ChartStyle type) + #:style (->ChartStyle type v) #:x-scale (->ChartScale (table-ref v #"xscale")) #:x-label (table-ref-string v #"xlabel") #:xs (table->list (table-ref v #"xs") ->ChartValue) @@ -147,6 +180,12 @@ (define (bytes->string bs) (bytes->string/utf-8 bs #\uFFFD)) +(define (table-ref-number t k) + (define v (table-ref t k)) + (begin0 v + (unless (number? v) + (error 'table-ref-number "expected a number, but found: ~s" v)))) + (define (table-ref-string t k) (bytes->string (table-ref t k))) diff --git a/manual/index.scrbl b/manual/index.scrbl index 6cc5b2c..24efce6 100644 --- a/manual/index.scrbl +++ b/manual/index.scrbl @@ -798,6 +798,33 @@ aggregated data to a window when applying a script. y-axis. } +@deflua[render.CandlestickChart (xlabel ylabel) Chart]{ + Returns an instance of a candlestick @chart-ref{chart renderer}. The + first argument represents the x axis label and the second argument, + the y axis. + + The values of the x axis values must be @lua[render.Timestamp] + values the y axis values must be @lua[render.Candlestick]s. +} + +@deflua[render.CandlestickChart:setwidth (width) Chart]{ + On Windows and Linux, you must set the width of the candles in the + chart. The width of a candle must be the length of the interval (in + seconds) between x ticks on the chart. For example, if the chart is to + display daily candles, the width of each candle would be @tt{86400}. +} + +@deflua[render.Candlestick (o h l c) Candlestick]{ + Returns an instance of a candlestick. The arguments must be numbers + representing the ope, high, low and close price, respectively, of + some asset. +} + +@deflua[render.Timestamp (seconds) Timestamp]{ + Returns an instance of a timestamp value. The argument must be the + number of seconds since the UNIX epoch. +} + @deflua[render.LineChart (xlabel ylabel) Chart]{ Returns an instance of a line @chart-ref{chart renderer}. The first argument represents the x-axis label and the second argument, the