Skip to content

Commit c67d9f6

Browse files
authored
feat(feed): add support for Heikin Ashi candle type (#144)
1 parent 3a10640 commit c67d9f6

File tree

5 files changed

+155
-11
lines changed

5 files changed

+155
-11
lines changed

exchange/binance.go

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type Binance struct {
2323
client *binance.Client
2424
assetsInfo map[string]model.AssetInfo
2525
userInfo UserInfo
26+
HeikinAshi bool
2627

2728
APIKey string
2829
APISecret string
@@ -37,6 +38,12 @@ func WithBinanceCredentials(key, secret string) BinanceOption {
3738
}
3839
}
3940

41+
func WithBinanceHeikinAshiCandle() BinanceOption {
42+
return func(b *Binance) {
43+
b.HeikinAshi = true
44+
}
45+
}
46+
4047
func NewBinance(ctx context.Context, options ...BinanceOption) (*Binance, error) {
4148
binance.WebsocketKeepalive = true
4249
exchange := &Binance{ctx: ctx}
@@ -457,16 +464,25 @@ func (b *Binance) Position(pair string) (asset, quote float64, err error) {
457464
func (b *Binance) CandlesSubscription(ctx context.Context, pair, period string) (chan model.Candle, chan error) {
458465
ccandle := make(chan model.Candle)
459466
cerr := make(chan error)
467+
ha := model.NewHeikinAshi()
460468

461469
go func() {
462-
b := &backoff.Backoff{
470+
ba := &backoff.Backoff{
463471
Min: 100 * time.Millisecond,
464472
Max: 1 * time.Second,
465473
}
474+
466475
for {
467476
done, _, err := binance.WsKlineServe(pair, period, func(event *binance.WsKlineEvent) {
468-
b.Reset()
469-
ccandle <- CandleFromWsKline(pair, event.Kline)
477+
ba.Reset()
478+
candle := CandleFromWsKline(pair, event.Kline)
479+
480+
if candle.Complete && b.HeikinAshi {
481+
candle = candle.ToHeikinAshi(ha)
482+
}
483+
484+
ccandle <- candle
485+
470486
}, func(err error) {
471487
cerr <- err
472488
})
@@ -483,7 +499,7 @@ func (b *Binance) CandlesSubscription(ctx context.Context, pair, period string)
483499
close(ccandle)
484500
return
485501
case <-done:
486-
time.Sleep(b.Duration())
502+
time.Sleep(ba.Duration())
487503
}
488504
}
489505
}()
@@ -494,6 +510,7 @@ func (b *Binance) CandlesSubscription(ctx context.Context, pair, period string)
494510
func (b *Binance) CandlesByLimit(ctx context.Context, pair, period string, limit int) ([]model.Candle, error) {
495511
candles := make([]model.Candle, 0)
496512
klineService := b.client.NewKlinesService()
513+
ha := model.NewHeikinAshi()
497514

498515
data, err := klineService.Symbol(pair).
499516
Interval(period).
@@ -505,7 +522,13 @@ func (b *Binance) CandlesByLimit(ctx context.Context, pair, period string, limit
505522
}
506523

507524
for _, d := range data {
508-
candles = append(candles, CandleFromKline(pair, *d))
525+
candle := CandleFromKline(pair, *d)
526+
527+
if b.HeikinAshi {
528+
candle = candle.ToHeikinAshi(ha)
529+
}
530+
531+
candles = append(candles, candle)
509532
}
510533

511534
// discard last candle, because it is incomplete
@@ -517,6 +540,7 @@ func (b *Binance) CandlesByPeriod(ctx context.Context, pair, period string,
517540

518541
candles := make([]model.Candle, 0)
519542
klineService := b.client.NewKlinesService()
543+
ha := model.NewHeikinAshi()
520544

521545
data, err := klineService.Symbol(pair).
522546
Interval(period).
@@ -529,7 +553,13 @@ func (b *Binance) CandlesByPeriod(ctx context.Context, pair, period string,
529553
}
530554

531555
for _, d := range data {
532-
candles = append(candles, CandleFromKline(pair, *d))
556+
candle := CandleFromKline(pair, *d)
557+
558+
if b.HeikinAshi {
559+
candle = candle.ToHeikinAshi(ha)
560+
}
561+
562+
candles = append(candles, candle)
533563
}
534564

535565
return candles, nil

exchange/csvfeed.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,16 @@ import (
1111
"time"
1212

1313
"github.com/rodrigo-brito/ninjabot/model"
14-
1514
"github.com/xhit/go-str2duration/v2"
1615
)
1716

1817
var ErrInsufficientData = errors.New("insufficient data")
1918

2019
type PairFeed struct {
21-
Pair string
22-
File string
23-
Timeframe string
20+
Pair string
21+
File string
22+
Timeframe string
23+
HeikinAshi bool
2424
}
2525

2626
type CSVFeed struct {
@@ -62,6 +62,8 @@ func NewCSVFeed(targetTimeframe string, feeds ...PairFeed) (*CSVFeed, error) {
6262
}
6363

6464
var candles []model.Candle
65+
ha := model.NewHeikinAshi()
66+
6567
for _, line := range csvLines {
6668
timestamp, err := strconv.Atoi(line[0])
6769
if err != nil {
@@ -100,6 +102,10 @@ func NewCSVFeed(targetTimeframe string, feeds ...PairFeed) (*CSVFeed, error) {
100102
return nil, err
101103
}
102104

105+
if feed.HeikinAshi {
106+
candle = candle.ToHeikinAshi(ha)
107+
}
108+
103109
candles = append(candles, candle)
104110
}
105111

model/model.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package model
22

33
import (
44
"fmt"
5+
"sort"
56
"strconv"
67
"time"
78
)
@@ -68,6 +69,14 @@ type Candle struct {
6869
Complete bool
6970
}
7071

72+
type HeikinAshi struct {
73+
PreviousHACandle Candle
74+
}
75+
76+
func NewHeikinAshi() *HeikinAshi {
77+
return &HeikinAshi{}
78+
}
79+
7180
func (c Candle) ToSlice(precision int) []string {
7281
return []string{
7382
fmt.Sprintf("%d", c.Time.Unix()),
@@ -80,6 +89,23 @@ func (c Candle) ToSlice(precision int) []string {
8089
}
8190
}
8291

92+
func (c Candle) ToHeikinAshi(ha *HeikinAshi) Candle {
93+
haCandle := ha.CalculateHeikinAshi(c)
94+
95+
return Candle{
96+
Pair: c.Pair,
97+
Open: haCandle.Open,
98+
High: haCandle.High,
99+
Low: haCandle.Low,
100+
Close: haCandle.Close,
101+
Volume: c.Volume,
102+
Complete: c.Complete,
103+
Time: c.Time,
104+
UpdatedAt: c.UpdatedAt,
105+
Trades: c.Trades,
106+
}
107+
}
108+
83109
func (c Candle) Less(j Item) bool {
84110
diff := j.(Candle).Time.Sub(c.Time)
85111
if diff < 0 {
@@ -123,3 +149,30 @@ func (a Account) Equity() float64 {
123149

124150
return total
125151
}
152+
153+
func (ha *HeikinAshi) CalculateHeikinAshi(c Candle) Candle {
154+
var hkCandle Candle
155+
156+
highValues := []float64{c.High, c.Open, c.Close}
157+
sort.Float64s(highValues)
158+
159+
lowValues := []float64{c.Low, c.Open, c.Close}
160+
sort.Float64s(lowValues)
161+
162+
openValue := ha.PreviousHACandle.Open
163+
closeValue := ha.PreviousHACandle.Close
164+
165+
// First HA candle is calculated using current candle
166+
if (ha.PreviousHACandle == Candle{}) {
167+
openValue = c.Open
168+
closeValue = c.Close
169+
}
170+
171+
hkCandle.Open = (openValue + closeValue) / 2
172+
hkCandle.High = highValues[2]
173+
hkCandle.Close = (c.Open + c.High + c.Low + c.Close) / 4
174+
hkCandle.Low = lowValues[0]
175+
ha.PreviousHACandle = hkCandle
176+
177+
return hkCandle
178+
}

model/model_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,58 @@ func TestAccount_Balance(t *testing.T) {
4747
require.Equal(t, Balance{}, account.Balance("A"))
4848
require.Equal(t, Balance{Tick: "B", Free: 1.1, Lock: 1.3}, account.Balance("B"))
4949
}
50+
51+
func TestHeikinAshi_CalculateHeikinAshi(t *testing.T) {
52+
ha := NewHeikinAshi()
53+
54+
if (HeikinAshi{}.PreviousHACandle != Candle{}) {
55+
t.Errorf("PreviousCandle should be empty")
56+
}
57+
58+
// BTC-USDT weekly candles from Binance from 2017-08-14 to 2017-10-09
59+
// First market candles were used to easily test accuracy against
60+
// TradingView without having to download all market data.
61+
candles := []Candle{
62+
{Open: 4261.48, Close: 4086.29, High: 4485.39, Low: 3850.00},
63+
{Open: 4069.13, Close: 4310.01, High: 4453.91, Low: 3400.00},
64+
{Open: 4310.01, Close: 4509.08, High: 4939.19, Low: 4124.54},
65+
{Open: 4505.00, Close: 4130.37, High: 4788.59, Low: 3603.00},
66+
{Open: 4153.62, Close: 3699.99, High: 4394.59, Low: 2817.00},
67+
{Open: 3690.00, Close: 3660.02, High: 4123.20, Low: 3505.55},
68+
{Open: 3660.02, Close: 4378.48, High: 4406.52, Low: 3653.69},
69+
{Open: 4400.00, Close: 4640.00, High: 4658.00, Low: 4110.00},
70+
{Open: 4640.00, Close: 5709.99, High: 5922.30, Low: 4550.00},
71+
}
72+
73+
var results []Candle
74+
75+
for _, candle := range candles {
76+
haCandle := ha.CalculateHeikinAshi(candle)
77+
results = append(results, haCandle)
78+
}
79+
80+
// Expected values taken from TradingView.
81+
// Source: Binance BTC-USDT
82+
expected := []Candle{
83+
{Open: 4173.885, Close: 4170.79, High: 4485.39, Low: 3850},
84+
{Open: 4172.3375, Close: 4058.2625000000003, High: 4453.91, Low: 3400},
85+
{Open: 4115.3, Close: 4470.705, High: 4939.19, Low: 4124.54},
86+
{Open: 4293.0025000000005, Close: 4256.74, High: 4788.59, Low: 3603},
87+
{Open: 4274.87125, Close: 3766.2999999999997, High: 4394.59, Low: 2817},
88+
{Open: 4020.5856249999997, Close: 3744.6925, High: 4123.2, Low: 3505.55},
89+
{Open: 3882.6390625, Close: 4024.6775000000002, High: 4406.52, Low: 3653.69},
90+
{Open: 3953.65828125, Close: 4452, High: 4658, Low: 4110},
91+
{Open: 4202.829140625, Close: 5205.5725, High: 5922.3, Low: 4550},
92+
}
93+
94+
if len(expected) != len(results) {
95+
t.Errorf("Expected %d HA candles. Got %d.", len(expected), len(results))
96+
}
97+
98+
for index, expectedHaCandle := range expected {
99+
require.Equal(t, expectedHaCandle.Open, results[index].Open)
100+
require.Equal(t, expectedHaCandle.Close, results[index].Close)
101+
require.Equal(t, expectedHaCandle.High, results[index].High)
102+
require.Equal(t, expectedHaCandle.Low, results[index].Low)
103+
}
104+
}

readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ Chart available at http://localhost:8080
102102
- [x] CLI to download historical data
103103
- [x] Plot (Candles + Sell / Buy orders, Indicators)
104104
- [x] Telegram Controller (Status, Buy, Sell, and Notification)
105-
105+
- [x] Heikin Ashi candle type support
106106

107107
# Roadmap
108108
- [ ] Include Web UI Controller

0 commit comments

Comments
 (0)