import type { Fn , IObjectOf } from "@thi.ng/api" ; import { dropdown , type , DropDownOption } from "@thi.ng/hdom-components/dropdown" ; import { group } from "@thi.ng/hiccup-svg/group" ; import { line } from "@thi.ng/hiccup-svg/line" ; import { polygon } from "@thi.ng/hiccup-svg/polygon" ; import { polyline } from "@thi.ng/hiccup-svg/polyline" ; import { rect } from "@thi.ng/hiccup-svg/rect" ; import { svg } from "@thi.ng/hiccup-svg/svg" ; import { text } from "@thi.ng/hiccup-svg/text" ; import { fit } from "@thi.ng/math/fit" ; import { resolve } from "@thi.ng/resolve-map" ; import type { ISubscriber } from "@thi.ng/rstream" ; import { fromEvent } from "@thi.ng/rstream/event" ; import { fromInterval } from "@thi.ng/rstream/interval" ; import { resolve as resolvePromise } from "@thi.ng/rstream/resolve" ; import { reactive , stream } from "@thi.ng/rstream/stream" ; import { sync } from "@thi.ng/rstream/sync" ; import { trace } from "@thi.ng/rstream/trace" ; import { Z2 } from "@thi.ng/strings/pad-left" ; import type { Transducer } from "@thi.ng/transducers" ; import { updateDOM } from "@thi.ng/transducers-hdom" ; import { ema } from "@thi.ng/transducers-stats/ema" ; import { hma } from "@thi.ng/transducers-stats/hma" ; import { sma } from "@thi.ng/transducers-stats/sma" ; import { wma } from "@thi.ng/transducers-stats/wma" ; import { comp } from "@thi.ng/transducers/comp" ; import { filter } from "@thi.ng/transducers/filter" ; import { map } from "@thi.ng/transducers/map" ; import { mapIndexed } from "@thi.ng/transducers/map-indexed" ; import { mapcat } from "@thi.ng/transducers/mapcat" ; import { max } from "@thi.ng/transducers/max" ; import { min } from "@thi.ng/transducers/min" ; import { pairs } from "@thi.ng/transducers/pairs" ; import { pluck } from "@thi.ng/transducers/pluck" ; import { push } from "@thi.ng/transducers/push" ; import { range } from "@thi.ng/transducers/range" ; import { transduce } from "@thi.ng/transducers/transduce" ; interface OHLC { open : number ; high : number ; low : number ; close : number ; time : number ; } interface MarketResponse { ohlc : OHLC [ ] ; } const TIMEFRAMES = { 1 : "Minute" , 60 : "Hour" , 1440 : "Day" , } ; const SYMBOL_PAIRS = [ [ "ADAUSD" , "ADA-USD" ] , [ "BTCUSD" , "BTC-USD" ] , [ "ETHUSD" , "ETH-USD" ] , [ "LTCUSD" , "LTC-USD" ] , [ "XLMUSD" , "XLM-USD" ] , [ "XMRUSD" , "XMR-USD" ] , [ "XTZUSD" , "XTZ-USD" ] , ] ; const MA_MODES = { ema : { fn : ema , label : "Exponential" } , hma : { fn : hma , label : "Hull" } , sma : { fn : sma , label : "Simple" } , wma : { fn : wma , label : "Weighted" } , } ; const MARGIN_X = 80 ; const MARGIN_Y = 60 ; const DAY = 60 * 60 * 24 ; const TIME_TICKS = { 1 : 15 * 60 , 60 : DAY , 1440 : DAY * 14 , } ; const TIME_FORMATS = { 1 : ( t ) => { const d = new Date ( t * 1000 ) ; return `${ Z2 ( d . getUTCHours ( ) ) }:${ Z2 ( d . getUTCMinutes ( ) ) }` ; } , 60 : ( t ) => { const d = new Date ( t * 1000 ) ; return `${ d . getUTCFullYear ( ) }-${ Z2 ( d . getUTCMonth ( ) + 1 ) }-${ Z2 ( d . getUTCDate ( ) ) }` ; } , 1440 : ( t ) => { const d = new Date ( t * 1000 ) ; return `${ d . getUTCFullYear ( ) }-${ Z2 ( d . getUTCMonth ( ) + 1 ) }-${ Z2 ( d . getUTCDate ( ) ) }` ; } , } ; const THEMES = { light : { id : "light" , label : "Light" , bg : "white" , body : "black" , chart : { axis : "#000" , price : "#006" , pricelabel : "#fff" , bull : "#6c0" , bear : "#f04" , sma12 : "#f90" , sma24 : "#0ff" , sma50 : "#06f" , sma72 : "#00f" , gridMajor : "#666" , gridMinor : "#ccc" , } , } , dark : { id : "dark" , label : "Dark" , bg : "black" , body : "white" , chart : { axis : "#eee" , price : "#f0f" , pricelabel : "#fff" , bull : "#0c4" , bear : "#f02" , sma12 : "#ff0" , sma24 : "#0ff" , sma50 : "#06f" , sma72 : "#00f" , gridMajor : "#666" , gridMinor : "#333" , } , } , } ; const API_URL = ( market , symbol , period ) => `https://min-api.cryptocompare.com/data/histo${ TIMEFRAMES [ period ] . toLowerCase ( ) }?fsym=${ symbol . substr ( 0 , 3 ) }&tsym=${ symbol . substr ( 3 ) }&limit=168&aggregate=1&e=${ market }` ; const emitOnStream = ( stream ) => ( e ) => stream . next ( ( < HTMLInputElement > e . target ) . value ) ; const menu = ( stream , title , items ) => map ( ( x ) => dropdown ( null , { class : "w-100" , onchange : emitOnStream ( stream ) } , [ [ < any > null , title , true ] , ... items ] , String ( x ) ) ) ; const market = reactive ( "CCCAGG" ) ; const symbol = reactive ( "BTCUSD" ) ; const period = reactive ( 60 ) ; const avgMode = reactive ( "wma" ) ; const theme = reactive ( "dark" ) . transform ( map ( ( id ) => THEMES [ id ] ) ) ; const error = stream < any > ( ) ; error . subscribe ( { next : ( e ) => alert ( `An error occurred:\n${ e }` ) } ) ; const refresh = fromInterval ( 60000 ) . subscribe ( trace ( "refresh" ) ) ; const response = sync ( { src : { market , symbol , period , refresh } , xform : map ( ( inst ) => fetch ( API_URL ( inst . market , inst . symbol , inst . period ) ) . then ( ( res ) => res . ok ? res . json ( ) : error . next ( "error loading OHLC data" ) , ( e ) => error . next ( e . message ) ) . then ( ( json ) => ( { ... inst , ohlc : json ? json . Data : null } ) ) ) , } ) . subscribe ( resolvePromise ( { fail : ( e ) => error . next ( e . message ) } ) ) ; const data = sync ( { src : { response , avg : avgMode . transform ( map ( ( id ) => MA_MODES [ id ] . fn ) ) , } , xform : comp ( filter ( ( { response } ) => ! ! response . ohlc ) , map ( ( { response , avg } ) => resolve ( { ... response , min : ( { ohlc } ) => transduce ( pluck ( "low" ) , min ( ) , ohlc ) , max : ( { ohlc } ) => transduce ( pluck ( "high" ) , max ( ) , ohlc ) , tbounds : ( { ohlc } ) => [ ohlc [ 0 ] . time , ohlc [ ohlc . length - 1 ] . time , ] , sma : ( { ohlc } ) => transduce ( map ( ( period ) => [ period , transduce ( comp ( pluck ( "close" ) , avg ( period ) ) , push ( ) , ohlc ) , ] ) , push ( ) , [ 12 , 24 , 50 , 72 ] ) , } ) ) ) , } ) ; const chart = sync ( { src : { data , theme , window : fromEvent ( window , "resize" ) . transform ( map ( ( ) => [ window . innerWidth , window . innerHeight ] ) ) , } , xform : map ( ( { data , window , theme } ) => { let [ width , height ] = window ; const ohlc = data . ohlc ; const chartW = width - 2 * MARGIN_X ; const chartH = height - 2 * MARGIN_Y ; const bw = Math . max ( 3 , chartW / ohlc . length ) ; const by = height - MARGIN_Y ; const mapX = ( x ) => fit ( x , 0 , ohlc . length , MARGIN_X , width - MARGIN_X ) ; const mapY = ( y ) => fit ( y , data . min , data . max , by , MARGIN_Y ) ; const sma = ( vals , col ) => polyline ( vals . map ( ( y , x ) => [ mapX ( x + ( ohlc . length - vals . length ) + 0.5 ) , mapY ( y ) , ] ) , { stroke : col , fill : "none" } ) ; let tickX = TIME_TICKS [ data . period ] ; const timeRange = data . tbounds [ 1 ] - data . tbounds [ 0 ] ; while ( chartW / ( timeRange / tickX ) < 60 ) { tickX *= 2 ; } const fmtTime = TIME_FORMATS [ data . period ] ; const domain = data . max - data . min ; const minTickY = 0.0025 ; const minProjTickY = Math . max ( chartH / 8 , 50 ) ; let tickY = Math . pow ( 10 , Math . floor ( Math . log ( domain ) / Math . log ( 10 ) ) ) / 2 ; while ( tickY > minTickY && chartH / ( domain / tickY ) > minProjTickY ) { tickY /= 2 ; } while ( chartH / ( domain / tickY ) < minProjTickY ) { tickY *= 2 ; } const lastPrice = ohlc [ ohlc . length - 1 ] . close ; const closeX = width - MARGIN_X ; const closeY = mapY ( lastPrice ) ; return svg ( { width , height , "font-family" : "Arial" , "font-size" : "10px" } , group ( { stroke : theme . chart . axis , fill : theme . chart . axis , "text-anchor" : "end" , } , line ( [ MARGIN_X , MARGIN_Y ] , [ MARGIN_X , by ] ) , line ( [ MARGIN_X , by ] , [ width - MARGIN_X , by ] ) , mapcat ( ( price ) => { const y = mapY ( price ) ; return [ line ( [ MARGIN_X - 10 , y ] , [ MARGIN_X , y ] ) , line ( [ MARGIN_X , y ] , [ width - MARGIN_X , y ] , { stroke : price % 100 < 1 ? theme . chart . gridMajor : theme . chart . gridMinor , "stroke-dasharray" : 2 , } ) , text ( [ MARGIN_X - 15 , y + 4 ] , price . toFixed ( 4 ) , { stroke : "none" , } ) , ] ; } , range ( Math . ceil ( data . min / tickY ) * tickY , data . max , tickY ) ) , mapcat ( ( t ) => { const x = fit ( t , data . tbounds [ 0 ] , data . tbounds [ 1 ] , MARGIN_X + bw / 2 , width - MARGIN_X - bw / 2 ) ; return [ line ( [ x , by ] , [ x , by + 10 ] ) , line ( [ x , MARGIN_Y ] , [ x , by ] , { stroke : theme . chart . gridMinor , "stroke-dasharray" : 2 , } ) , text ( [ x , by + 20 ] , fmtTime ( t ) , { stroke : "none" , "text-anchor" : "middle" , } ) , ] ; } , range ( Math . ceil ( data . tbounds [ 0 ] / tickX ) * tickX , data . tbounds [ 1 ] , tickX ) ) ) , map ( ( [ period , vals ] ) => sma ( vals , theme . chart [ `sma${ period }` ] ) , data . sma ) , mapIndexed ( ( i , candle ) => { const isBullish = candle . open < candle . close ; let y , h ; let col ; if ( isBullish ) { col = theme . chart . bull ; y = mapY ( candle . close ) ; h = mapY ( candle . open ) - y ; } else { col = theme . chart . bear ; y = mapY ( candle . open ) ; h = mapY ( candle . close ) - y ; } return group ( { fill : col , stroke : col } , line ( [ mapX ( i + 0.5 ) , mapY ( candle . low ) ] , [ mapX ( i + 0.5 ) , mapY ( candle . high ) ] ) , rect ( [ mapX ( i ) + 1 , y ] , bw - 2 , h ) ) ; } , ohlc ) , line ( [ MARGIN_X , closeY ] , [ closeX , closeY ] , { stroke : theme . chart . price , } ) , polygon ( [ [ closeX , closeY ] , [ closeX + 10 , closeY - 8 ] , [ width , closeY - 8 ] , [ width , closeY + 8 ] , [ closeX + 10 , closeY + 8 ] , ] , { fill : theme . chart . price } ) , text ( [ closeX + 12 , closeY + 4 ] , lastPrice . toFixed ( 4 ) , { fill : theme . chart . pricelabel , } ) ) ; } ) , } ) ; sync ( { src : { chart , theme , symbol : symbol . transform ( menu ( symbol , "Symbol pair" , SYMBOL_PAIRS ) ) , period : period . transform ( menu ( period , "Time frame" , [ ... pairs ( TIMEFRAMES ) ] ) ) , avg : avgMode . transform ( menu ( avgMode , "Moving average" , [ ... map ( ( [ id , mode ] ) => < DropDownOption > [ id , mode . label ] , pairs ( MA_MODES ) ) , ] ) ) , themeSel : theme . transform ( map ( ( x ) => x . id ) , menu ( theme , "Theme" , [ ... map ( ( [ id , theme ] ) => < DropDownOption > [ id , theme . label ] , pairs ( THEMES ) ) , ] ) ) , } , xform : comp ( map ( ( { theme , themeSel , chart , symbol , period , avg } ) => [ "div" , { class : `sans-serif f7 bg-${ theme . bg } ${ theme . body }` } , chart , [ "div.fixed" , { style : { top : `1rem` , right : `${ MARGIN_X }px` , width : `calc(100vw - 2 * ${ MARGIN_X }px)` , } , } , [ "div.flex" , ... map ( ( x ) => [ "div.w-25.ph2" , x ] , [ symbol , period , avg , themeSel ] ) , ] , ] , [ "div.fixed.tc" , { style : { bottom : `1rem` , left : `${ MARGIN_X }px` , width : `calc(100vw - 2 * ${ MARGIN_X }px)` , } , } , [ "a" , { class : `mr3 b link ${ theme . body }` , href : "https://min-api.cryptocompare.com/" , } , "Data by cyptocompare.com" , ] , [ "a" , { class : `mr3 b link ${ theme . body }` , href : "https://github.com/thi-ng/umbrella/tree/develop/examples/crypto-chart/" , } , "Source" , ] , ] , ] ) , updateDOM ( ) ) , } ) ; window . dispatchEvent ( new CustomEvent ( "resize" ) ) ;