├── .gitignore ├── LICENSE.md ├── README.md ├── bfbook └── bfbook.go ├── bitarb ├── bitarb.gcfg ├── bitarb.go └── bitarb_test.go ├── bitfinex ├── bitfinex.go └── bitfinex_test.go ├── btcbook └── btcbook.go ├── btcchina ├── btcchina.go └── btcchina_test.go ├── exchange └── exchange.go ├── forex ├── forex.go └── forex_test.go ├── okbook └── okbook.go └── okcoin ├── okcoin.go └── okcoin_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.sh 3 | *.csv 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Trading system bitarb/bitarb.go conducts high-performance concurrent arbitrage across Bitfinex, OKCoin USD, OKCoin CNY, and BTC China. Position management is fully automated. The system is functional and can be run autonomously but is not intended as a turn-key system for general use. 2 | 3 | Configuration settings are in bitarb/bitarb.gcfg. Environment variables exchange_KEY and exchange_SECRET are needed for access to each exchange. New exchanges can be added by implementing exchange.Interface. 4 | -------------------------------------------------------------------------------- /bfbook/bfbook.go: -------------------------------------------------------------------------------- 1 | // Tester program for displaying Bitfinex book data to terminal 2 | 3 | package main 4 | 5 | import ( 6 | "bitfx/bitfinex" 7 | "bitfx/exchange" 8 | "fmt" 9 | "log" 10 | "os" 11 | "os/exec" 12 | ) 13 | 14 | var bf = bitfinex.New("", "", "ltc", "usd", 0, 0, 0, 0) 15 | 16 | func main() { 17 | filename := "bfbook.log" 18 | logFile, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | log.SetOutput(logFile) 23 | log.Println("Starting new run") 24 | 25 | bookChan := make(chan exchange.Book) 26 | if book := bf.CommunicateBook(bookChan); book.Error != nil { 27 | log.Fatal(book.Error) 28 | } 29 | inputChan := make(chan rune) 30 | go checkStdin(inputChan) 31 | 32 | Loop: 33 | for { 34 | select { 35 | case book := <-bookChan: 36 | printBook(book) 37 | case <-inputChan: 38 | bf.Done() 39 | break Loop 40 | } 41 | } 42 | 43 | } 44 | 45 | // Check for any user input 46 | func checkStdin(inputChan chan<- rune) { 47 | var ch rune 48 | fmt.Scanf("%c", &ch) 49 | inputChan <- ch 50 | } 51 | 52 | // Print book data from each exchange 53 | func printBook(book exchange.Book) { 54 | clearScreen() 55 | if book.Error != nil { 56 | log.Println(book.Error) 57 | } else { 58 | fmt.Println("----------------------------") 59 | fmt.Printf("%-10s%-10s%8s\n", " Bid", " Ask", "Size ") 60 | fmt.Println("----------------------------") 61 | for i := range book.Asks { 62 | item := book.Asks[len(book.Asks)-1-i] 63 | fmt.Printf("%-10s%-10.4f%8.2f\n", "", item.Price, item.Amount) 64 | } 65 | for _, item := range book.Bids { 66 | fmt.Printf("%-10.4f%-10.2s%8.2f\n", item.Price, "", item.Amount) 67 | } 68 | fmt.Println("----------------------------") 69 | } 70 | } 71 | 72 | // Clear the terminal between prints 73 | func clearScreen() { 74 | c := exec.Command("clear") 75 | c.Stdout = os.Stdout 76 | c.Run() 77 | } 78 | -------------------------------------------------------------------------------- /bitarb/bitarb.gcfg: -------------------------------------------------------------------------------- 1 | [sec] 2 | symbol = "btc" # Symbol to trade 3 | maxArb = 2 # Top limit for position entry 4 | minArb = -.5 # Bottom limit for position exit 5 | fxPremium = .5 # Amount added to arb for taking FX risk 6 | availShortBitfinex = 10 # Max short position size 7 | availFundsBitfinex = 3000 # Fiat available for trading 8 | availShortOKusd = 10 # Max short position size 9 | availFundsOKusd = 3000 # Fiat available for trading 10 | availShortOKcny = 10 # Max short position size 11 | availFundsOKcny = 20000 # Fiat available for trading 12 | availShortBTC = 10 # Max short position size 13 | availFundsBTC = 20000 # Fiat available for trading 14 | minNetPos = .1 # Min acceptable net position 15 | minOrder = .1 # Min order size for arb trade 16 | maxOrder = 1 # Max order size for arb trade 17 | printOn = true # Display results in terminal 18 | -------------------------------------------------------------------------------- /bitarb/bitarb.go: -------------------------------------------------------------------------------- 1 | // Cryptocurrency arbitrage trading system 2 | 3 | package main 4 | 5 | import ( 6 | "bitfx/bitfinex" 7 | "bitfx/btcchina" 8 | "bitfx/exchange" 9 | "bitfx/forex" 10 | "bitfx/okcoin" 11 | "encoding/csv" 12 | "flag" 13 | "fmt" 14 | "log" 15 | "math" 16 | "os" 17 | "os/exec" 18 | "strconv" 19 | "time" 20 | 21 | "code.google.com/p/gcfg" 22 | ) 23 | 24 | // Config stores user configuration 25 | type Config struct { 26 | Sec struct { 27 | Symbol string // Symbol to trade 28 | MaxArb float64 // Top limit for position entry 29 | MinArb float64 // Bottom limit for position exit 30 | FXPremium float64 // Amount added to arb for taking FX risk 31 | AvailShortBitfinex float64 // Max short position size 32 | AvailFundsBitfinex float64 // Fiat available for trading 33 | AvailShortOKusd float64 // Max short position size 34 | AvailFundsOKusd float64 // Fiat available for trading 35 | AvailShortOKcny float64 // Max short position size 36 | AvailFundsOKcny float64 // Fiat available for trading 37 | AvailShortBTC float64 // Max short position size 38 | AvailFundsBTC float64 // Fiat available for trading 39 | MinNetPos float64 // Min acceptable net position 40 | MinOrder float64 // Min order size for arb trade 41 | MaxOrder float64 // Max order size for arb trade 42 | PrintOn bool // Display results in terminal 43 | } 44 | } 45 | 46 | // Used for filtered book data 47 | type filteredBook struct { 48 | bid, ask market 49 | time time.Time 50 | } 51 | type market struct { 52 | exg exchange.Interface 53 | orderPrice, amount, adjPrice float64 54 | } 55 | 56 | // Global variables 57 | var ( 58 | logFile os.File // Log printed to file 59 | cfg Config // Configuration struct 60 | exchanges []exchange.Interface // Slice of exchanges in use 61 | currencies []string // Slice of forein currencies in use 62 | netPosition float64 // Net position accross exchanges 63 | pl float64 // Net P&L for current run 64 | ) 65 | 66 | // Set config info 67 | func setConfig() { 68 | configFile := flag.String("config", "bitarb.gcfg", "Configuration file") 69 | flag.Parse() 70 | err := gcfg.ReadFileInto(&cfg, *configFile) 71 | if err != nil { 72 | log.Fatal(err) 73 | } 74 | } 75 | 76 | // Set file for logging 77 | func setLog() { 78 | logFile, err := os.OpenFile("bitarb.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) 79 | if err != nil { 80 | log.Fatal(err) 81 | } 82 | log.SetOutput(logFile) 83 | log.Println("Starting new run") 84 | } 85 | 86 | // Initialize exchanges 87 | func setExchanges() { 88 | exchanges = []exchange.Interface{ 89 | bitfinex.New(os.Getenv("BITFINEX_KEY"), os.Getenv("BITFINEX_SECRET"), cfg.Sec.Symbol, "usd", 1, 0.001, cfg.Sec.AvailShortBitfinex, cfg.Sec.AvailFundsBitfinex), 90 | okcoin.New(os.Getenv("OKUSD_KEY"), os.Getenv("OKUSD_SECRET"), cfg.Sec.Symbol, "usd", 1, 0.002, cfg.Sec.AvailShortOKusd, cfg.Sec.AvailFundsOKusd), 91 | okcoin.New(os.Getenv("OKCNY_KEY"), os.Getenv("OKCNY_SECRET"), cfg.Sec.Symbol, "cny", 1, 0.000, cfg.Sec.AvailShortOKcny, cfg.Sec.AvailFundsOKcny), 92 | btcchina.New(os.Getenv("BTC_KEY"), os.Getenv("BTC_SECRET"), cfg.Sec.Symbol, "cny", 1, 0.000, cfg.Sec.AvailShortBTC, cfg.Sec.AvailFundsBTC), 93 | } 94 | for _, exg := range exchanges { 95 | log.Printf("Using exchange %s with priority %d and fee of %.4f", exg, exg.Priority(), exg.Fee()) 96 | } 97 | currencies = append(currencies, "cny") 98 | } 99 | 100 | // Set status from previous run if file exists 101 | func setStatus() { 102 | if file, err := os.Open("status.csv"); err == nil { 103 | defer file.Close() 104 | reader := csv.NewReader(file) 105 | status, err := reader.Read() 106 | if err != nil { 107 | log.Fatal(err) 108 | } 109 | for i, exg := range exchanges { 110 | position, err := strconv.ParseFloat(status[i], 64) 111 | if err != nil { 112 | log.Fatal(err) 113 | } 114 | exg.SetPosition(position) 115 | } 116 | pl, err = strconv.ParseFloat(status[len(status)-1], 64) 117 | if err != nil { 118 | log.Fatal(err) 119 | } 120 | log.Printf("Loaded positions %v\n", status[0:len(status)-1]) 121 | log.Printf("Loaded P&L %f\n", pl) 122 | } 123 | } 124 | 125 | // Calculate total position across exchanges 126 | func calcNetPosition() { 127 | netPosition = 0 128 | for _, exg := range exchanges { 129 | netPosition += exg.Position() 130 | log.Printf("%s Position: %.2f\n", exg, exg.Position()) 131 | } 132 | } 133 | 134 | func main() { 135 | fmt.Println("Running...") 136 | 137 | // Initialization 138 | setConfig() 139 | setLog() 140 | setExchanges() 141 | setStatus() 142 | calcNetPosition() 143 | 144 | // Terminate on user input 145 | doneChan := make(chan bool, 1) 146 | go checkStdin(doneChan) 147 | 148 | // Communicate data 149 | requestBook := make(chan exchange.Interface) 150 | receiveBook := make(chan filteredBook) 151 | newBook := make(chan bool) 152 | go handleData(requestBook, receiveBook, newBook, doneChan) 153 | 154 | // Check for opportunities 155 | considerTrade(requestBook, receiveBook, newBook) 156 | 157 | // Finish 158 | saveStatus() 159 | closeLogFile() 160 | fmt.Println("~~~ Fini ~~~") 161 | } 162 | 163 | // Check for user input 164 | func checkStdin(doneChan chan<- bool) { 165 | var ch rune 166 | fmt.Scanf("%c", &ch) 167 | doneChan <- true 168 | } 169 | 170 | // Handle all data communication 171 | func handleData(requestBook <-chan exchange.Interface, receiveBook chan<- filteredBook, newBook chan<- bool, doneChan <-chan bool) { 172 | // Communicate forex 173 | requestFX := make(chan string) 174 | receiveFX := make(chan float64) 175 | fxDoneChan := make(chan bool, 1) 176 | go handleFX(requestFX, receiveFX, fxDoneChan) 177 | 178 | // Filtered book data for each exchange 179 | markets := make(map[exchange.Interface]filteredBook) 180 | // Channel to receive book data from exchanges 181 | bookChan := make(chan exchange.Book) 182 | 183 | // Initiate communication with each exchange and initialize markets map 184 | for _, exg := range exchanges { 185 | book := exg.CommunicateBook(bookChan) 186 | if book.Error != nil { 187 | log.Fatal(book.Error) 188 | } 189 | requestFX <- exg.Currency() 190 | markets[exg] = filterBook(book, <-receiveFX) 191 | } 192 | 193 | // Handle data until notified of termination 194 | for { 195 | select { 196 | // Incoming data from an exchange 197 | case book := <-bookChan: 198 | if !isError(book.Error) { 199 | requestFX <- book.Exg.Currency() 200 | markets[book.Exg] = filterBook(book, <-receiveFX) 201 | // Notify of new data if receiver is not busy 202 | select { 203 | case newBook <- true: 204 | default: 205 | } 206 | } 207 | // New request for data 208 | case exg := <-requestBook: 209 | receiveBook <- markets[exg] 210 | // Termination 211 | case <-doneChan: 212 | close(newBook) 213 | fxDoneChan <- true 214 | for _, exg := range exchanges { 215 | exg.Done() 216 | } 217 | return 218 | } 219 | } 220 | } 221 | 222 | // Handle FX quotes 223 | func handleFX(requestFX <-chan string, receiveFX chan<- float64, doneChan <-chan bool) { 224 | prices := make(map[string]float64) 225 | prices["usd"] = 1 226 | fxChan := make(chan forex.Quote) 227 | fxDoneChan := make(chan bool) 228 | // Initiate communication and initialize prices map 229 | for _, symbol := range currencies { 230 | quote := forex.CommunicateFX(symbol, fxChan, fxDoneChan) 231 | if quote.Error != nil { 232 | log.Fatal(quote.Error) 233 | } 234 | prices[symbol] = quote.Price 235 | } 236 | 237 | // Handle data until notified of termination 238 | for { 239 | select { 240 | // Incoming forex quote 241 | case quote := <-fxChan: 242 | if !isError(quote.Error) { 243 | prices[quote.Symbol] = quote.Price 244 | } 245 | // New request for price 246 | case symbol := <-requestFX: 247 | receiveFX <- prices[symbol] 248 | // Termination 249 | case <-doneChan: 250 | fxDoneChan <- true 251 | return 252 | } 253 | } 254 | } 255 | 256 | // Filter book down to relevant data for trading decisions 257 | // Adjusts market amounts according to MaxOrder 258 | func filterBook(book exchange.Book, fxPrice float64) filteredBook { 259 | // Default with a high ask.adjPrice in case sufficient size doesn't exist 260 | fb := filteredBook{ 261 | time: book.Time, 262 | bid: market{exg: book.Exg}, 263 | ask: market{exg: book.Exg, adjPrice: math.MaxFloat64}, 264 | } 265 | 266 | // Loop through bids and aggregate amounts until required size 267 | var amount, aggPrice float64 268 | for _, bid := range book.Bids { 269 | aggPrice += bid.Price * math.Min(cfg.Sec.MaxOrder-amount, bid.Amount) 270 | amount += math.Min(cfg.Sec.MaxOrder-amount, bid.Amount) 271 | if amount >= cfg.Sec.MinOrder { 272 | // Amount-weighted average subject to MaxOrder, adjusted for fees and currency 273 | adjPrice := (aggPrice / amount) * (1 - book.Exg.Fee()) / fxPrice 274 | fb.bid = market{book.Exg, bid.Price, amount, adjPrice} 275 | break 276 | } 277 | } 278 | 279 | // Loop through asks and aggregate amounts until required size 280 | amount, aggPrice = 0, 0 281 | for _, ask := range book.Asks { 282 | aggPrice += ask.Price * math.Min(cfg.Sec.MaxOrder-amount, ask.Amount) 283 | amount += math.Min(cfg.Sec.MaxOrder-amount, ask.Amount) 284 | if amount >= cfg.Sec.MinOrder { 285 | // Amount-weighted average subject to MaxOrder, adjusted for fees and currency 286 | adjPrice := (aggPrice / amount) * (1 + book.Exg.Fee()) / fxPrice 287 | fb.ask = market{book.Exg, ask.Price, amount, adjPrice} 288 | break 289 | } 290 | } 291 | 292 | return fb 293 | } 294 | 295 | // Trade on net position exits and arb opportunities 296 | func considerTrade(requestBook chan<- exchange.Interface, receiveBook <-chan filteredBook, newBook <-chan bool) { 297 | // Local data copy 298 | var markets map[exchange.Interface]filteredBook 299 | // For tracking last trade, to prevent false repeats on slow exchange updates 300 | var lastArb, lastAmount float64 301 | 302 | // Check for trade whenever new data is available 303 | for _ = range newBook { 304 | // Build local snapshot of latest data 305 | markets = make(map[exchange.Interface]filteredBook) 306 | for _, exg := range exchanges { 307 | requestBook <- exg 308 | // Don't use stale data 309 | if fb := <-receiveBook; time.Since(fb.time) < time.Minute { 310 | markets[exg] = fb 311 | // Set MaxPos according to fiat funds and crypto available to short 312 | exg.SetMaxPos(math.Min(exg.AvailFunds()/fb.ask.orderPrice, exg.AvailShort())) 313 | } 314 | } 315 | // If net long from a previous missed leg, hit best bid 316 | if netPosition >= cfg.Sec.MinNetPos { 317 | bestBid := findBestBid(markets) 318 | amount := math.Min(netPosition, bestBid.amount) 319 | fillChan := make(chan float64) 320 | log.Println("NET LONG POSITION EXIT") 321 | go fillOrKill(bestBid.exg, "sell", amount, bestBid.orderPrice, fillChan) 322 | updatePL(bestBid.adjPrice, <-fillChan, "sell") 323 | calcNetPosition() 324 | if cfg.Sec.PrintOn { 325 | printResults() 326 | } 327 | // Else if net short, lift best ask 328 | } else if netPosition <= -cfg.Sec.MinNetPos { 329 | bestAsk := findBestAsk(markets) 330 | amount := math.Min(-netPosition, bestAsk.amount) 331 | fillChan := make(chan float64) 332 | log.Println("NET SHORT POSITION EXIT") 333 | go fillOrKill(bestAsk.exg, "buy", amount, bestAsk.orderPrice, fillChan) 334 | updatePL(bestAsk.adjPrice, <-fillChan, "buy") 335 | calcNetPosition() 336 | if cfg.Sec.PrintOn { 337 | printResults() 338 | } 339 | // Else check for arb opportunities 340 | } else { 341 | // If an opportunity exists 342 | if bestBid, bestAsk, exists := findBestArb(markets); exists { 343 | arb := bestBid.adjPrice - bestAsk.adjPrice 344 | amount := math.Min(bestBid.amount, bestAsk.amount) 345 | 346 | // If it's not a false repeat, then trade 347 | if math.Abs(arb-lastArb) > .000001 || math.Abs(amount-lastAmount) > .000001 || math.Abs(amount-cfg.Sec.MaxOrder) < .000001 { 348 | log.Printf("***** Arb Opportunity: %.4f for %.4f on %s vs %s *****\n", arb, amount, bestAsk.exg, bestBid.exg) 349 | sendPair(bestBid, bestAsk, amount) 350 | calcNetPosition() 351 | if cfg.Sec.PrintOn { 352 | printResults() 353 | } 354 | lastArb = arb 355 | lastAmount = amount 356 | } 357 | } 358 | } 359 | } 360 | } 361 | 362 | // Find best bid able to sell 363 | // Adjusts market amount according to exchange position 364 | func findBestBid(markets map[exchange.Interface]filteredBook) market { 365 | var bestBid market 366 | 367 | for exg, fb := range markets { 368 | ableToSell := exg.Position() + exg.MaxPos() 369 | // If not already max short 370 | if ableToSell >= cfg.Sec.MinOrder { 371 | // If highest bid 372 | if fb.bid.adjPrice > bestBid.adjPrice { 373 | bestBid = fb.bid 374 | bestBid.amount = math.Min(bestBid.amount, ableToSell) 375 | } 376 | } 377 | } 378 | 379 | return bestBid 380 | } 381 | 382 | // Find best ask able to buy 383 | // Adjusts market amount according to exchange position 384 | func findBestAsk(markets map[exchange.Interface]filteredBook) market { 385 | var bestAsk market 386 | // Need to start with a high number 387 | bestAsk.adjPrice = math.MaxFloat64 388 | 389 | for exg, fb := range markets { 390 | ableToBuy := exg.MaxPos() - exg.Position() 391 | // If not already max long 392 | if ableToBuy >= cfg.Sec.MinOrder { 393 | // If lowest ask 394 | if fb.ask.adjPrice < bestAsk.adjPrice { 395 | bestAsk = fb.ask 396 | bestAsk.amount = math.Min(bestAsk.amount, ableToBuy) 397 | } 398 | } 399 | } 400 | 401 | return bestAsk 402 | 403 | } 404 | 405 | // Find best arbitrage opportunity 406 | // Adjusts market amounts according to exchange positions 407 | func findBestArb(markets map[exchange.Interface]filteredBook) (market, market, bool) { 408 | var ( 409 | bestBid, bestAsk market 410 | bestOpp float64 411 | exists bool 412 | ) 413 | 414 | // Compare each bid to all other asks 415 | for exg1, fb1 := range markets { 416 | ableToSell := exg1.Position() + exg1.MaxPos() 417 | // If exg1 is not already max short 418 | if ableToSell >= cfg.Sec.MinOrder { 419 | for exg2, fb2 := range markets { 420 | ableToBuy := exg2.MaxPos() - exg2.Position() 421 | // If exg2 is not already max long 422 | if ableToBuy >= cfg.Sec.MinOrder { 423 | opp := fb1.bid.adjPrice - fb2.ask.adjPrice - calcNeededArb(exg2, exg1) 424 | // If best opportunity 425 | if opp >= bestOpp { 426 | bestBid = fb1.bid 427 | bestBid.amount = math.Min(bestBid.amount, ableToSell) 428 | bestAsk = fb2.ask 429 | bestAsk.amount = math.Min(bestAsk.amount, ableToBuy) 430 | exists = true 431 | bestOpp = opp 432 | } 433 | } 434 | } 435 | } 436 | } 437 | 438 | return bestBid, bestAsk, exists 439 | } 440 | 441 | // Calculate arb needed for a trade based on existing positions 442 | func calcNeededArb(buyExg, sellExg exchange.Interface) float64 { 443 | // Middle between min and max 444 | center := (cfg.Sec.MaxArb + cfg.Sec.MinArb) / 2 445 | // Half distance from center to min and max 446 | halfDist := (cfg.Sec.MaxArb - center) / 2 447 | // If taking currency risk, add required premium 448 | if buyExg.CurrencyCode() != sellExg.CurrencyCode() { 449 | center += cfg.Sec.FXPremium 450 | } 451 | // Percent of max 452 | buyExgPct := buyExg.Position() / buyExg.MaxPos() 453 | sellExgPct := sellExg.Position() / sellExg.MaxPos() 454 | 455 | // Return required arb 456 | return center + buyExgPct*halfDist - sellExgPct*halfDist 457 | } 458 | 459 | // Logic for sending a pair of orders 460 | func sendPair(bestBid, bestAsk market, amount float64) { 461 | fillChan1 := make(chan float64) 462 | fillChan2 := make(chan float64) 463 | // If exchanges have equal priority, send simultaneous orders 464 | if bestBid.exg.Priority() == bestAsk.exg.Priority() { 465 | go fillOrKill(bestAsk.exg, "buy", amount, bestAsk.orderPrice, fillChan1) 466 | go fillOrKill(bestBid.exg, "sell", amount, bestBid.orderPrice, fillChan2) 467 | updatePL(bestAsk.adjPrice, <-fillChan1, "buy") 468 | updatePL(bestBid.adjPrice, <-fillChan2, "sell") 469 | // Else if bestBid exchange has priority, confirm fill before sending other side 470 | } else if bestBid.exg.Priority() < bestAsk.exg.Priority() { 471 | go fillOrKill(bestBid.exg, "sell", amount, bestBid.orderPrice, fillChan2) 472 | amount = <-fillChan2 473 | updatePL(bestBid.adjPrice, amount, "sell") 474 | if amount >= cfg.Sec.MinNetPos { 475 | go fillOrKill(bestAsk.exg, "buy", amount, bestAsk.orderPrice, fillChan1) 476 | updatePL(bestAsk.adjPrice, <-fillChan1, "buy") 477 | } 478 | // Else reverse priority 479 | } else { 480 | go fillOrKill(bestAsk.exg, "buy", amount, bestAsk.orderPrice, fillChan1) 481 | amount = <-fillChan1 482 | updatePL(bestAsk.adjPrice, amount, "buy") 483 | if amount >= cfg.Sec.MinNetPos { 484 | go fillOrKill(bestBid.exg, "sell", amount, bestBid.orderPrice, fillChan2) 485 | updatePL(bestBid.adjPrice, <-fillChan2, "sell") 486 | } 487 | } 488 | } 489 | 490 | // Update P&L 491 | func updatePL(price, amount float64, action string) { 492 | if action == "buy" { 493 | amount = -amount 494 | } 495 | pl += price * amount 496 | } 497 | 498 | // Handle communication for a FOK order 499 | func fillOrKill(exg exchange.Interface, action string, amount, price float64, fillChan chan<- float64) { 500 | var ( 501 | id int64 502 | err error 503 | order exchange.Order 504 | ) 505 | 506 | // Send order 507 | id, err = exg.SendOrder(action, "limit", amount, price) 508 | if isError(err) || id == 0 { 509 | fillChan <- 0 510 | return 511 | } 512 | 513 | // Check status and cancel if necessary 514 | for { 515 | order, err = exg.GetOrderStatus(id) 516 | isError(err) 517 | if order.Status == "live" { 518 | _, err = exg.CancelOrder(id) 519 | isError(err) 520 | } else if order.Status == "dead" { 521 | break 522 | } 523 | // Continues while order status is empty 524 | } 525 | 526 | filledAmount := order.FilledAmount 527 | 528 | // Update position 529 | if action == "buy" { 530 | if exg.HasCryptoFee() { 531 | filledAmount = filledAmount * (1 - exg.Fee()) 532 | } 533 | exg.SetPosition(exg.Position() + filledAmount) 534 | } else { 535 | exg.SetPosition(exg.Position() - filledAmount) 536 | } 537 | // Print to log 538 | log.Printf("%s trade: %s %.4f at %.4f\n", exg, action, order.FilledAmount, price) 539 | 540 | fillChan <- filledAmount 541 | } 542 | 543 | // Print relevant data to terminal 544 | func printResults() { 545 | clearScreen() 546 | 547 | fmt.Println(" Positions:") 548 | fmt.Println("--------------------------") 549 | for _, exg := range exchanges { 550 | fmt.Printf("%-13s %10.2f\n", exg, exg.Position()) 551 | } 552 | fmt.Println("--------------------------") 553 | fmt.Printf("\nRun P&L: $%.2f\n", pl) 554 | } 555 | 556 | // Clear the terminal between prints 557 | func clearScreen() { 558 | c := exec.Command("clear") 559 | c.Stdout = os.Stdout 560 | c.Run() 561 | } 562 | 563 | // Called on any error 564 | func isError(err error) bool { 565 | if err != nil { 566 | log.Println(err) 567 | return true 568 | } 569 | return false 570 | } 571 | 572 | // Save status to file 573 | func saveStatus() { 574 | file, err := os.Create("status.csv") 575 | if err != nil { 576 | log.Fatal(err) 577 | } 578 | defer file.Close() 579 | status := make([]string, len(exchanges)+1) 580 | for i, exg := range exchanges { 581 | status[i] = fmt.Sprintf("%f", exg.Position()) 582 | } 583 | status[len(exchanges)] = fmt.Sprintf("%f", pl) 584 | writer := csv.NewWriter(file) 585 | err = writer.Write(status) 586 | if err != nil { 587 | log.Fatal(err) 588 | } 589 | writer.Flush() 590 | } 591 | 592 | // Close log file on exit 593 | func closeLogFile() { 594 | log.Println("Ending run") 595 | logFile.Close() 596 | } 597 | -------------------------------------------------------------------------------- /bitarb/bitarb_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bitfx/bitfinex" 5 | "bitfx/exchange" 6 | "bitfx/okcoin" 7 | "math" 8 | "testing" 9 | ) 10 | 11 | func init() { 12 | cfg.Sec.MaxArb = .02 13 | cfg.Sec.MinArb = -.01 14 | cfg.Sec.FXPremium = .01 15 | cfg.Sec.MinOrder = 25 16 | cfg.Sec.MaxOrder = 50 17 | } 18 | 19 | type neededArb struct { 20 | buyExgPos, sellExgPos, arb float64 21 | } 22 | 23 | func TestCalculateNeededArb(t *testing.T) { 24 | // Test without FX 25 | neededArbTests := []neededArb{ 26 | {500, -500, .02}, 27 | {-500, 500, -.01}, 28 | {500, 500, .005}, 29 | {-100, -100, .005}, 30 | {0, 0, .005}, 31 | {-250, 250, -.0025}, 32 | {250, -250, .0125}, 33 | {100, -100, .008}, 34 | {0, -200, .008}, 35 | {-200, 0, .002}, 36 | {-100, 100, .002}, 37 | } 38 | buyExg := okcoin.New("", "", "", "usd", 1, 0.002, 500, 0) 39 | buyExg.SetMaxPos(500) 40 | sellExg := bitfinex.New("", "", "", "usd", 2, 0.001, 500, 0) 41 | sellExg.SetMaxPos(500) 42 | 43 | for _, neededArb := range neededArbTests { 44 | buyExg.SetPosition(neededArb.buyExgPos) 45 | sellExg.SetPosition(neededArb.sellExgPos) 46 | arb := calcNeededArb(buyExg, sellExg) 47 | if math.Abs(arb-neededArb.arb) > .000001 { 48 | t.Errorf("For %.4f / %.4f expect %.4f, got %.4f\n", buyExg.Position(), sellExg.Position(), neededArb.arb, arb) 49 | } 50 | } 51 | 52 | // Test with FX 53 | neededArbTests = []neededArb{ 54 | {500, -500, .03}, 55 | {-500, 500, 0}, 56 | {500, 500, .015}, 57 | {-100, -100, .015}, 58 | {0, 0, .015}, 59 | {-250, 250, .0075}, 60 | {250, -250, .0225}, 61 | {100, -100, .018}, 62 | {0, -200, .018}, 63 | {-200, 0, .012}, 64 | {-100, 100, .012}, 65 | } 66 | buyExg = okcoin.New("", "", "", "cny", 1, 0.002, 500, 0) 67 | buyExg.SetMaxPos(500) 68 | sellExg = bitfinex.New("", "", "", "usd", 2, 0.001, 500, 0) 69 | sellExg.SetMaxPos(500) 70 | 71 | for _, neededArb := range neededArbTests { 72 | buyExg.SetPosition(neededArb.buyExgPos) 73 | sellExg.SetPosition(neededArb.sellExgPos) 74 | arb := calcNeededArb(buyExg, sellExg) 75 | if math.Abs(arb-neededArb.arb) > .000001 { 76 | t.Errorf("For %.4f / %.4f expect %.4f, got %.4f\n", buyExg.Position(), sellExg.Position(), neededArb.arb, arb) 77 | } 78 | } 79 | 80 | } 81 | 82 | func TestFilterBook(t *testing.T) { 83 | testBook := exchange.Book{ 84 | Exg: okcoin.New("", "", "", "usd", 1, 0.002, 500, 0), 85 | Bids: exchange.BidItems{ 86 | 0: {Price: 1.90, Amount: 10}, 87 | 1: {Price: 1.80, Amount: 10}, 88 | 2: {Price: 1.70, Amount: 100}, 89 | }, 90 | Asks: exchange.AskItems{ 91 | 0: {Price: 2.10, Amount: 10}, 92 | 1: {Price: 2.20, Amount: 20}, 93 | 2: {Price: 2.30, Amount: 10}, 94 | }, 95 | } 96 | testBook.Exg.SetMaxPos(500) 97 | market := filterBook(testBook, 1) 98 | if math.Abs(market.bid.orderPrice-1.70) > .000001 { 99 | t.Errorf("Wrong bid order price") 100 | } 101 | if math.Abs(market.bid.amount-50) > .000001 { 102 | t.Errorf("Wrong bid amount") 103 | } 104 | adjPrice := ((1.90*10 + 1.80*10 + 1.70*30) / 50) * (1 - .002) 105 | if math.Abs(market.bid.adjPrice-adjPrice) > .000001 { 106 | t.Errorf("Wrong bid adjusted price") 107 | } 108 | if math.Abs(market.ask.orderPrice-2.20) > .000001 { 109 | t.Errorf("Wrong ask order price") 110 | } 111 | if math.Abs(market.ask.amount-30) > .000001 { 112 | t.Errorf("Wrong ask amount") 113 | } 114 | adjPrice = ((2.10*10 + 2.20*20) / 30) * (1 + .002) 115 | if math.Abs(market.ask.adjPrice-adjPrice) > .000001 { 116 | t.Errorf("Wrong ask adjusted price") 117 | } 118 | // Same test but with FX adjustment 119 | fxPrice := 2.0 120 | market = filterBook(testBook, fxPrice) 121 | if math.Abs(market.bid.orderPrice-1.70) > .000001 { 122 | t.Errorf("Wrong bid order price") 123 | } 124 | if math.Abs(market.bid.amount-50) > .000001 { 125 | t.Errorf("Wrong bid amount") 126 | } 127 | adjPrice = ((1.90*10 + 1.80*10 + 1.70*30) / 50) * (1 - .002) / fxPrice 128 | if math.Abs(market.bid.adjPrice-adjPrice) > .000001 { 129 | t.Errorf("Wrong bid adjusted price") 130 | } 131 | if math.Abs(market.ask.orderPrice-2.20) > .000001 { 132 | t.Errorf("Wrong ask order price") 133 | } 134 | if math.Abs(market.ask.amount-30) > .000001 { 135 | t.Errorf("Wrong ask amount") 136 | } 137 | adjPrice = ((2.10*10 + 2.20*20) / 30) * (1 + .002) / fxPrice 138 | if math.Abs(market.ask.adjPrice-adjPrice) > .000001 { 139 | t.Errorf("Wrong ask adjusted price") 140 | } 141 | 142 | testBook = exchange.Book{ 143 | Exg: okcoin.New("", "", "", "usd", 2, 0.002, 500, 0), 144 | Bids: exchange.BidItems{ 145 | 0: {Price: 1.90, Amount: 30}, 146 | 1: {Price: 1.80, Amount: 10}, 147 | 2: {Price: 1.70, Amount: 100}, 148 | }, 149 | Asks: exchange.AskItems{ 150 | 0: {Price: 2.10, Amount: 100}, 151 | 1: {Price: 2.20, Amount: 20}, 152 | 2: {Price: 2.30, Amount: 10}, 153 | }, 154 | } 155 | testBook.Exg.SetMaxPos(500) 156 | market = filterBook(testBook, 1) 157 | if math.Abs(market.bid.orderPrice-1.90) > .000001 { 158 | t.Errorf("Wrong bid order price") 159 | } 160 | if math.Abs(market.bid.amount-30) > .000001 { 161 | t.Errorf("Wrong bid amount") 162 | } 163 | adjPrice = 1.90 * (1 - .002) 164 | if math.Abs(market.bid.adjPrice-adjPrice) > .000001 { 165 | t.Errorf("Wrong bid adjusted price") 166 | } 167 | if math.Abs(market.ask.orderPrice-2.10) > .000001 { 168 | t.Errorf("Wrong ask order price") 169 | } 170 | if math.Abs(market.ask.amount-50) > .000001 { 171 | t.Errorf("Wrong ask amount") 172 | } 173 | adjPrice = 2.10 * (1 + .002) 174 | if math.Abs(market.ask.adjPrice-adjPrice) > .000001 { 175 | t.Errorf("Wrong ask adjusted price") 176 | } 177 | // Same test as above, but wiht FX adjustment 178 | fxPrice = 3.0 179 | market = filterBook(testBook, fxPrice) 180 | if math.Abs(market.bid.orderPrice-1.90) > .000001 { 181 | t.Errorf("Wrong bid order price") 182 | } 183 | if math.Abs(market.bid.amount-30) > .000001 { 184 | t.Errorf("Wrong bid amount") 185 | } 186 | adjPrice = 1.90 * (1 - .002) / fxPrice 187 | if math.Abs(market.bid.adjPrice-adjPrice) > .000001 { 188 | t.Errorf("Wrong bid adjusted price") 189 | } 190 | if math.Abs(market.ask.orderPrice-2.10) > .000001 { 191 | t.Errorf("Wrong ask order price") 192 | } 193 | if math.Abs(market.ask.amount-50) > .000001 { 194 | t.Errorf("Wrong ask amount") 195 | } 196 | adjPrice = 2.10 * (1 + .002) / fxPrice 197 | if math.Abs(market.ask.adjPrice-adjPrice) > .000001 { 198 | t.Errorf("Wrong ask adjusted price") 199 | } 200 | } 201 | 202 | func TestFindBestBid(t *testing.T) { 203 | markets := make(map[exchange.Interface]filteredBook) 204 | exg1 := okcoin.New("", "", "", "usd", 1, 0.002, 500, 0) 205 | exg1.SetMaxPos(500) 206 | exg2 := okcoin.New("", "", "", "usd", 1, 0.002, 500, 0) 207 | exg2.SetMaxPos(500) 208 | exg3 := okcoin.New("", "", "", "usd", 1, 0.002, 500, 0) 209 | exg2.SetMaxPos(500) 210 | markets[exg1] = filteredBook{bid: market{adjPrice: 2.00, amount: 500}} 211 | markets[exg2] = filteredBook{bid: market{adjPrice: 1.99}} 212 | markets[exg3] = filteredBook{bid: market{adjPrice: 1.98}} 213 | if math.Abs(findBestBid(markets).adjPrice-2.00) > .000001 { 214 | t.Error("Returned wrong best bid") 215 | } 216 | exg1.SetPosition(-490) 217 | if math.Abs(findBestBid(markets).adjPrice-1.99) > .000001 { 218 | t.Error("Returned wrong best bid after position update") 219 | } 220 | exg1.SetPosition(-250) 221 | if math.Abs(findBestBid(markets).amount-250) > .000001 { 222 | t.Error("Returned wrong best bid amount after position update") 223 | } 224 | } 225 | 226 | func TestFindBestAsk(t *testing.T) { 227 | markets := make(map[exchange.Interface]filteredBook) 228 | exg1 := okcoin.New("", "", "", "usd", 1, 0.002, 500, 0) 229 | exg1.SetMaxPos(500) 230 | exg2 := okcoin.New("", "", "", "usd", 1, 0.002, 500, 0) 231 | exg2.SetMaxPos(500) 232 | exg3 := okcoin.New("", "", "", "usd", 1, 0.002, 500, 0) 233 | exg2.SetMaxPos(500) 234 | markets[exg1] = filteredBook{ask: market{adjPrice: 1.98, amount: 500}} 235 | markets[exg2] = filteredBook{ask: market{adjPrice: 1.99}} 236 | markets[exg3] = filteredBook{ask: market{adjPrice: 2.00}} 237 | if math.Abs(findBestAsk(markets).adjPrice-1.98) > .000001 { 238 | t.Error("Returned wrong best ask") 239 | } 240 | exg1.SetPosition(490) 241 | if math.Abs(findBestAsk(markets).adjPrice-1.99) > .000001 { 242 | t.Error("Returned wrong best ask after position update") 243 | } 244 | exg1.SetPosition(250) 245 | if math.Abs(findBestAsk(markets).amount-250) > .000001 { 246 | t.Error("Returned wrong best ask after position update") 247 | } 248 | } 249 | 250 | func TestFindBestArb(t *testing.T) { 251 | // No opportunity 252 | markets := make(map[exchange.Interface]filteredBook) 253 | exg1 := okcoin.New("", "", "", "usd", 1, 0.002, 500, 0) 254 | exg1.SetMaxPos(500) 255 | exg2 := okcoin.New("", "", "", "usd", 1, 0.002, 500, 0) 256 | exg2.SetMaxPos(500) 257 | exg3 := okcoin.New("", "", "", "usd", 1, 0.002, 500, 0) 258 | exg3.SetMaxPos(500) 259 | markets[exg1] = filteredBook{ 260 | bid: market{adjPrice: 1.98, amount: 50, exg: exg1}, 261 | ask: market{adjPrice: 2.00, amount: 50, exg: exg1}, 262 | } 263 | markets[exg2] = filteredBook{ 264 | bid: market{adjPrice: 1.99, amount: 50, exg: exg2}, 265 | ask: market{adjPrice: 2.01, amount: 50, exg: exg2}, 266 | } 267 | markets[exg3] = filteredBook{ 268 | bid: market{adjPrice: 2.00, amount: 50, exg: exg3}, 269 | ask: market{adjPrice: 2.02, amount: 50, exg: exg3}, 270 | } 271 | if _, _, exists := findBestArb(markets); exists { 272 | t.Errorf("Should be no arb opportunity") 273 | } 274 | // Change positions to create an exit opportunity 275 | exg1.SetPosition(-500) 276 | exg3.SetPosition(500) 277 | bestBid, bestAsk, exists := findBestArb(markets) 278 | if !exists || bestBid.exg != exg3 || bestAsk.exg != exg1 { 279 | t.Errorf("Should be an exit opportunity after position update") 280 | } 281 | exg1.SetPosition(0) 282 | exg3.SetPosition(0) 283 | 284 | // Create an arb opportunity 285 | markets[exg1] = filteredBook{ 286 | bid: market{adjPrice: 2.03, amount: 50, exg: exg1}, 287 | ask: market{adjPrice: 2.04, amount: 50, exg: exg1}, 288 | } 289 | markets[exg2] = filteredBook{ 290 | bid: market{adjPrice: 2.04, amount: 50, exg: exg2}, 291 | ask: market{adjPrice: 2.05, amount: 50, exg: exg2}, 292 | } 293 | markets[exg3] = filteredBook{ 294 | bid: market{adjPrice: 1.99, amount: 50, exg: exg3}, 295 | ask: market{adjPrice: 2.00, amount: 50, exg: exg3}, 296 | } 297 | bestBid, bestAsk, exists = findBestArb(markets) 298 | if !exists || bestBid.exg != exg2 || bestAsk.exg != exg3 { 299 | t.Errorf("Should be an arb opportunity") 300 | } 301 | 302 | // Set exg3 postion to only allow for 30 more 303 | exg3.SetPosition(470) 304 | _, bestAsk, _ = findBestArb(markets) 305 | if math.Abs(bestAsk.amount-30) > .000001 { 306 | t.Errorf("Should be a decrease in best ask amount") 307 | } 308 | 309 | // Change exg3 postion 310 | exg2.SetPosition(-500) 311 | bestBid, _, _ = findBestArb(markets) 312 | if bestBid.exg != exg1 { 313 | t.Errorf("Best bid exchange should have changed") 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /bitfinex/bitfinex.go: -------------------------------------------------------------------------------- 1 | // Bitfinex exchange API 2 | 3 | package bitfinex 4 | 5 | import ( 6 | "bitfx/exchange" 7 | "crypto/hmac" 8 | "crypto/sha512" 9 | "encoding/base64" 10 | "encoding/hex" 11 | "encoding/json" 12 | "fmt" 13 | "io/ioutil" 14 | "math" 15 | "net/http" 16 | "sort" 17 | "strconv" 18 | "time" 19 | ) 20 | 21 | // Client contains all exchange information 22 | type Client struct { 23 | key, secret, symbol, currency, name, baseURL string 24 | priority int 25 | position, fee, maxPos, availShort, availFunds float64 26 | currencyCode byte 27 | done chan bool 28 | } 29 | 30 | // New returns a pointer to a Client instance 31 | func New(key, secret, symbol, currency string, priority int, fee, availShort, availFunds float64) *Client { 32 | return &Client{ 33 | key: key, 34 | secret: secret, 35 | symbol: symbol, 36 | currency: currency, 37 | priority: priority, 38 | fee: fee, 39 | availShort: availShort, 40 | availFunds: availFunds, 41 | currencyCode: 0, 42 | name: fmt.Sprintf("Bitfinex(%s)", currency), 43 | baseURL: "https://api.bitfinex.com", 44 | done: make(chan bool, 1), 45 | } 46 | } 47 | 48 | // Done closes all connections 49 | func (client *Client) Done() { 50 | client.done <- true 51 | } 52 | 53 | // String implements the Stringer interface 54 | func (client *Client) String() string { 55 | return client.name 56 | } 57 | 58 | // Priority returns the exchange priority for order execution 59 | func (client *Client) Priority() int { 60 | return client.priority 61 | } 62 | 63 | // Fee returns the exchange order fee 64 | func (client *Client) Fee() float64 { 65 | return client.fee 66 | } 67 | 68 | // SetPosition sets the exchange position 69 | func (client *Client) SetPosition(pos float64) { 70 | client.position = pos 71 | } 72 | 73 | // Position returns the exchange position 74 | func (client *Client) Position() float64 { 75 | return client.position 76 | } 77 | 78 | // Currency returns the exchange currency 79 | func (client *Client) Currency() string { 80 | return client.currency 81 | } 82 | 83 | // CurrencyCode returns the exchange currency code 84 | func (client *Client) CurrencyCode() byte { 85 | return client.currencyCode 86 | } 87 | 88 | // SetMaxPos sets the exchange max position 89 | func (client *Client) SetMaxPos(maxPos float64) { 90 | client.maxPos = maxPos 91 | } 92 | 93 | // MaxPos returns the exchange max position 94 | func (client *Client) MaxPos() float64 { 95 | return client.maxPos 96 | } 97 | 98 | // AvailFunds returns the exchange available funds 99 | func (client *Client) AvailFunds() float64 { 100 | return client.availFunds 101 | } 102 | 103 | // AvailShort returns the exchange quantity available for short selling 104 | func (client *Client) AvailShort() float64 { 105 | return client.availShort 106 | } 107 | 108 | // HasCrytpoFee returns true if fee is taken in cryptocurrency on buys 109 | func (client *Client) HasCryptoFee() bool { 110 | return false 111 | } 112 | 113 | // CommunicateBook sends the latest available book data on the supplied channel 114 | func (client *Client) CommunicateBook(bookChan chan<- exchange.Book) exchange.Book { 115 | // Initial book to return 116 | book, _ := client.getBook() 117 | 118 | // Run read loop in new goroutine 119 | go client.runLoop(bookChan) 120 | 121 | return book 122 | } 123 | 124 | // HTTP read loop 125 | func (client *Client) runLoop(bookChan chan<- exchange.Book) { 126 | // Used to compare timestamps 127 | oldTimestamps := make([]float64, 40) 128 | 129 | for { 130 | select { 131 | case <-client.done: 132 | return 133 | default: 134 | book, newTimestamps := client.getBook() 135 | // Send out only if changed 136 | if bookChanged(oldTimestamps, newTimestamps) { 137 | bookChan <- book 138 | } 139 | oldTimestamps = newTimestamps 140 | } 141 | } 142 | } 143 | 144 | // Get book data with an HTTP request 145 | func (client *Client) getBook() (exchange.Book, []float64) { 146 | // Used to compare timestamps 147 | timestamps := make([]float64, 40) 148 | 149 | // Send GET request 150 | url := fmt.Sprintf("%s/v1/book/%s%s?limit_bids=%d&limit_asks=%d", client.baseURL, client.symbol, client.currency, 20, 20) 151 | data, err := client.get(url) 152 | if err != nil { 153 | return exchange.Book{Error: fmt.Errorf("%s UpdateBook error: %s", client, err.Error())}, timestamps 154 | } 155 | 156 | // Unmarshal 157 | var response struct { 158 | Bids []struct { 159 | Price float64 `json:"price,string"` 160 | Amount float64 `json:"amount,string"` 161 | Timestamp float64 `json:"timestamp,string"` 162 | } `json:"bids"` 163 | Asks []struct { 164 | Price float64 `json:"price,string"` 165 | Amount float64 `json:"amount,string"` 166 | Timestamp float64 `json:"timestamp,string"` 167 | } `json:"asks"` 168 | } 169 | if err := json.Unmarshal(data, &response); err != nil { 170 | return exchange.Book{Error: fmt.Errorf("%s UpdateBook error: %s", client, err.Error())}, timestamps 171 | } 172 | 173 | // Translate into an exchange.Book 174 | bids := make(exchange.BidItems, 20) 175 | asks := make(exchange.AskItems, 20) 176 | for i := 0; i < 20; i++ { 177 | bids[i].Price = response.Bids[i].Price 178 | bids[i].Amount = response.Bids[i].Amount 179 | asks[i].Price = response.Asks[i].Price 180 | asks[i].Amount = response.Asks[i].Amount 181 | timestamps[i] = response.Bids[i].Timestamp 182 | timestamps[i+20] = response.Asks[i].Timestamp 183 | } 184 | sort.Sort(bids) 185 | sort.Sort(asks) 186 | 187 | // Return book and timestamps 188 | return exchange.Book{ 189 | Exg: client, 190 | Time: time.Now(), 191 | Bids: bids, 192 | Asks: asks, 193 | Error: nil, 194 | }, timestamps 195 | } 196 | 197 | // Returns true if the book has changed 198 | func bookChanged(timestamps1, timestamps2 []float64) bool { 199 | for i := 0; i < 40; i++ { 200 | if math.Abs(timestamps1[i]-timestamps2[i]) > .5 { 201 | return true 202 | } 203 | } 204 | return false 205 | } 206 | 207 | // SendOrder sends an order to the exchange 208 | func (client *Client) SendOrder(action, otype string, amount, price float64) (int64, error) { 209 | // Create request struct 210 | request := struct { 211 | URL string `json:"request"` 212 | Nonce string `json:"nonce"` 213 | Symbol string `json:"symbol"` 214 | Amount float64 `json:"amount,string"` 215 | Price float64 `json:"price,string"` 216 | Exchange string `json:"exchange"` 217 | Side string `json:"side"` 218 | Type string `json:"type"` 219 | }{ 220 | "/v1/order/new", 221 | strconv.FormatInt(time.Now().UnixNano(), 10), 222 | client.symbol + client.currency, 223 | amount, 224 | price, 225 | "bitfinex", 226 | action, 227 | otype, 228 | } 229 | 230 | // Send POST request 231 | data, err := client.post(client.baseURL+request.URL, request) 232 | if err != nil { 233 | return 0, fmt.Errorf("%s SendOrder error: %s", client, err.Error()) 234 | } 235 | 236 | // Unmarshal response 237 | var response struct { 238 | ID int64 `json:"order_id"` 239 | Message string `json:"message"` 240 | } 241 | err = json.Unmarshal(data, &response) 242 | if err != nil { 243 | return 0, fmt.Errorf("%s SendOrder error: %s", client, err.Error()) 244 | } 245 | if response.Message != "" { 246 | return 0, fmt.Errorf("%s SendOrder error: %s", client, response.Message) 247 | } 248 | 249 | return response.ID, nil 250 | } 251 | 252 | // CancelOrder cancels an order on the exchange 253 | func (client *Client) CancelOrder(id int64) (bool, error) { 254 | // Create request struct 255 | request := struct { 256 | URL string `json:"request"` 257 | Nonce string `json:"nonce"` 258 | OrderID int64 `json:"order_id"` 259 | }{ 260 | "/v1/order/cancel", 261 | strconv.FormatInt(time.Now().UnixNano(), 10), 262 | id, 263 | } 264 | 265 | // Send POST request 266 | data, err := client.post(client.baseURL+request.URL, request) 267 | if err != nil { 268 | return false, fmt.Errorf("%s CancelOrder error: %s", client, err.Error()) 269 | } 270 | 271 | // Unmarshal response 272 | var response struct { 273 | Message string `json:"message"` 274 | } 275 | err = json.Unmarshal(data, &response) 276 | if err != nil { 277 | return false, fmt.Errorf("%s CancelOrder error: %s", client, err.Error()) 278 | } 279 | if response.Message != "" { 280 | return false, fmt.Errorf("%s CancelOrder error: %s", client, response.Message) 281 | } 282 | 283 | return true, nil 284 | } 285 | 286 | // GetOrderStatus gets the status of an order on the exchange 287 | func (client *Client) GetOrderStatus(id int64) (exchange.Order, error) { 288 | // Create request struct 289 | request := struct { 290 | URL string `json:"request"` 291 | Nonce string `json:"nonce"` 292 | OrderID int64 `json:"order_id"` 293 | }{ 294 | "/v1/order/status", 295 | strconv.FormatInt(time.Now().UnixNano(), 10), 296 | id, 297 | } 298 | 299 | // Create order to be returned 300 | var order exchange.Order 301 | 302 | // Send POST request 303 | data, err := client.post(client.baseURL+request.URL, request) 304 | if err != nil { 305 | return order, fmt.Errorf("%s GetOrderStatus error: %s", client, err.Error()) 306 | } 307 | 308 | // Unmarshal response 309 | var response struct { 310 | Message string `json:"message"` 311 | IsLive bool `json:"is_live,bool"` 312 | ExecutedAmount float64 `json:"executed_amount,string"` 313 | } 314 | err = json.Unmarshal(data, &response) 315 | if err != nil { 316 | return order, fmt.Errorf("%s GetOrderStatus error: %s", client, err.Error()) 317 | } 318 | if response.Message != "" { 319 | return order, fmt.Errorf("%s GetOrderStatus error: %s", client, response.Message) 320 | } 321 | 322 | if response.IsLive { 323 | order.Status = "live" 324 | } else { 325 | order.Status = "dead" 326 | } 327 | order.FilledAmount = math.Abs(response.ExecutedAmount) 328 | return order, nil 329 | } 330 | 331 | // Authenticated POST 332 | func (client *Client) post(url string, payload interface{}) ([]byte, error) { 333 | // Payload = parameters-dictionary -> JSON encode -> base64 334 | payloadJSON, err := json.Marshal(payload) 335 | if err != nil { 336 | return []byte{}, err 337 | } 338 | payloadBase64 := base64.StdEncoding.EncodeToString(payloadJSON) 339 | 340 | // Signature = HMAC-SHA384(payload, api-secret) as hexadecimal 341 | h := hmac.New(sha512.New384, []byte(client.secret)) 342 | h.Write([]byte(payloadBase64)) 343 | signature := hex.EncodeToString(h.Sum(nil)) 344 | 345 | req, err := http.NewRequest("POST", url, nil) 346 | if err != nil { 347 | return []byte{}, err 348 | } 349 | 350 | // HTTP headers: 351 | // X-BFX-APIKEY 352 | // X-BFX-PAYLOAD 353 | // X-BFX-SIGNATURE 354 | req.Header.Add("X-BFX-APIKEY", client.key) 355 | req.Header.Add("X-BFX-PAYLOAD", payloadBase64) 356 | req.Header.Add("X-BFX-SIGNATURE", signature) 357 | 358 | // Send POST 359 | httpClient := http.Client{} 360 | resp, err := httpClient.Do(req) 361 | if err != nil { 362 | return []byte{}, err 363 | } 364 | if resp.StatusCode != 200 { 365 | return []byte{}, fmt.Errorf(resp.Status) 366 | } 367 | defer resp.Body.Close() 368 | 369 | return ioutil.ReadAll(resp.Body) 370 | } 371 | 372 | // Unauthenticated GET 373 | func (client *Client) get(url string) ([]byte, error) { 374 | resp, err := http.Get(url) 375 | if err != nil { 376 | return []byte{}, err 377 | } 378 | if resp.StatusCode != 200 { 379 | return []byte{}, fmt.Errorf(resp.Status) 380 | } 381 | defer resp.Body.Close() 382 | 383 | return ioutil.ReadAll(resp.Body) 384 | } 385 | -------------------------------------------------------------------------------- /bitfinex/bitfinex_test.go: -------------------------------------------------------------------------------- 1 | package bitfinex 2 | 3 | import ( 4 | "bitfx/exchange" 5 | "fmt" 6 | "math" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "testing" 11 | ) 12 | 13 | var ( 14 | book exchange.Book 15 | client = New(os.Getenv("BITFINEX_KEY"), os.Getenv("BITFINEX_SECRET"), "ltc", "usd", 2, 0.001, 2, .1) 16 | ) 17 | 18 | // Returns a mock HTTP server 19 | func testServer(code int, body string) *httptest.Server { 20 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 | w.WriteHeader(code) 22 | fmt.Fprintln(w, body) 23 | })) 24 | } 25 | 26 | // Used for float equality 27 | func notEqual(f1, f2 float64) bool { 28 | if math.Abs(f1-f2) > 0.000001 { 29 | return true 30 | } 31 | return false 32 | } 33 | 34 | // Test retrieving book data with mock server 35 | func TestGetBook(t *testing.T) { 36 | body := `{"bids":[{"price":"1.6391","amount":"53.08276864","timestamp":"1427811013.0"},{"price":"1.639","amount":"13.62","timestamp":"1427810280.0"},{"price":"1.638","amount":"14.26","timestamp":"1427810251.0"},{"price":"1.637","amount":"8.44","timestamp":"1427810231.0"},{"price":"1.636","amount":"21.43","timestamp":"1427810216.0"},{"price":"1.634","amount":"9.96","timestamp":"1427810238.0"},{"price":"1.631","amount":"11.7","timestamp":"1427809353.0"},{"price":"1.63","amount":"0.1","timestamp":"1427788892.0"},{"price":"1.629","amount":"6.98","timestamp":"1427809000.0"},{"price":"1.628","amount":"11.7","timestamp":"1427809359.0"},{"price":"1.627","amount":"25.91512719","timestamp":"1427808956.0"},{"price":"1.6269","amount":"13.54211743","timestamp":"1427811077.0"},{"price":"1.626","amount":"6.98","timestamp":"1427808940.0"},{"price":"1.625","amount":"11.7","timestamp":"1427809365.0"},{"price":"1.6233","amount":"0.1","timestamp":"1427680917.0"},{"price":"1.622","amount":"15.68","timestamp":"1427808196.0"},{"price":"1.6201","amount":"174.0","timestamp":"1427810992.0"},{"price":"1.62","amount":"119.94830228","timestamp":"1427810640.0"},{"price":"1.6159","amount":"200.0","timestamp":"1427811056.0"},{"price":"1.6157","amount":"2151.8","timestamp":"1427811049.0"}],"asks":[{"price":"1.649","amount":"8.225777","timestamp":"1427811011.0"},{"price":"1.65","amount":"118.35905692","timestamp":"1427807969.0"},{"price":"1.651","amount":"56.3099955","timestamp":"1427810969.0"},{"price":"1.652","amount":"21.79","timestamp":"1427810806.0"},{"price":"1.653","amount":"21.29","timestamp":"1427810776.0"},{"price":"1.654","amount":"21.1","timestamp":"1427811017.0"},{"price":"1.655","amount":"21.69","timestamp":"1427810883.0"},{"price":"1.656","amount":"19.45","timestamp":"1427810790.0"},{"price":"1.657","amount":"27.1030322","timestamp":"1427803455.0"},{"price":"1.658","amount":"21.69","timestamp":"1427810824.0"},{"price":"1.659","amount":"26.8","timestamp":"1427810129.0"},{"price":"1.66","amount":"27.20087772","timestamp":"1427800329.0"},{"price":"1.661","amount":"21.69","timestamp":"1427810843.0"},{"price":"1.662","amount":"44.3","timestamp":"1427811018.0"},{"price":"1.6792","amount":"3.0","timestamp":"1427808043.0"},{"price":"1.68","amount":"119.94830228","timestamp":"1427810640.0"},{"price":"1.681","amount":"7.1386","timestamp":"1427784448.0"},{"price":"1.684","amount":"10.0","timestamp":"1427771020.0"},{"price":"1.6868","amount":"100.0","timestamp":"1427787418.0"},{"price":"1.6935","amount":"200.0","timestamp":"1427811056.0"}]}` 37 | server := testServer(200, body) 38 | client := Client{baseURL: server.URL} 39 | book, timeStamps := client.getBook() 40 | if len(timeStamps) != 40 || len(book.Bids) != 20 || len(book.Asks) != 20 { 41 | t.Fatal("Should have returned 20 items") 42 | } 43 | if notEqual(book.Bids[0].Price, 1.6391) || notEqual(book.Bids[19].Price, 1.6157) { 44 | t.Fatal("Bids not sorted properly") 45 | } 46 | if notEqual(book.Asks[0].Price, 1.649) || notEqual(book.Asks[19].Price, 1.6935) { 47 | t.Fatal("Asks not sorted properly") 48 | } 49 | } 50 | 51 | func TestPriority(t *testing.T) { 52 | if client.Priority() != 2 { 53 | t.Fatal("Priority should be 2") 54 | } 55 | } 56 | 57 | func TestFee(t *testing.T) { 58 | if notEqual(client.Fee(), 0.001) { 59 | t.Fatal("Fee should be 0.001") 60 | } 61 | } 62 | 63 | func TestUpdatePositon(t *testing.T) { 64 | if notEqual(client.Position(), 0) { 65 | t.Fatal("Should start with zero position") 66 | } 67 | client.SetPosition(10) 68 | if notEqual(client.Position(), 10) { 69 | t.Fatal("Position should have updated to 10") 70 | } 71 | } 72 | 73 | func TestCurrency(t *testing.T) { 74 | if client.Currency() != "usd" { 75 | t.Fatal("Currency should be usd") 76 | } 77 | } 78 | 79 | func TestCurrencyCode(t *testing.T) { 80 | if client.CurrencyCode() != 0 { 81 | t.Fatal("Currency code should be 0") 82 | } 83 | } 84 | 85 | func TestMaxPos(t *testing.T) { 86 | if notEqual(client.MaxPos(), 0) { 87 | t.Fatal("MaxPos should start at 0") 88 | } 89 | client.SetMaxPos(23) 90 | if notEqual(client.MaxPos(), 23) { 91 | t.Fatal("MaxPos should be set to 23") 92 | } 93 | } 94 | 95 | func TestAvailFunds(t *testing.T) { 96 | if notEqual(client.AvailFunds(), 0.1) { 97 | t.Fatal("Available funds should be 0.1") 98 | } 99 | } 100 | 101 | func TestAvailShort(t *testing.T) { 102 | if notEqual(client.AvailShort(), 2) { 103 | t.Fatal("Available short should be 2") 104 | } 105 | } 106 | 107 | func TestHasCryptoFee(t *testing.T) { 108 | if client.HasCryptoFee() { 109 | t.Fatal("Should not have cryptocurrency fee") 110 | } 111 | } 112 | 113 | // ***** Live exchange communication tests ***** 114 | // Slow... skip when not needed 115 | 116 | func TestCommunicateBook(t *testing.T) { 117 | bookChan := make(chan exchange.Book) 118 | if book = client.CommunicateBook(bookChan); book.Error != nil { 119 | t.Fatal(book.Error) 120 | } 121 | 122 | book = <-bookChan 123 | t.Logf("Received book data") 124 | // spew.Dump(book) 125 | if len(book.Bids) != 20 || len(book.Asks) != 20 { 126 | t.Fatal("Expected 20 book entries") 127 | } 128 | if book.Bids[0].Price < book.Bids[1].Price { 129 | t.Fatal("Bids not sorted correctly") 130 | } 131 | if book.Asks[0].Price > book.Asks[1].Price { 132 | t.Fatal("Asks not sorted correctly") 133 | } 134 | } 135 | 136 | func TestNewOrder(t *testing.T) { 137 | action := "sell" 138 | otype := "limit" 139 | amount := 0.1 140 | price := book.Asks[0].Price + 0.10 141 | 142 | // Test submitting a new order 143 | id, err := client.SendOrder(action, otype, amount, price) 144 | if err != nil || id == 0 { 145 | t.Fatal(err) 146 | } 147 | t.Logf("Placed a new sell order of 0.1 ltcusd @ %v limit with ID: %d", price, id) 148 | 149 | // Check status 150 | order, err := client.GetOrderStatus(id) 151 | if err != nil { 152 | t.Fatal(err) 153 | } 154 | if order.Status != "live" { 155 | t.Fatal("Order should be live") 156 | } 157 | t.Logf("Order confirmed live") 158 | if order.FilledAmount != 0 { 159 | t.Fatal("Order should not be filled") 160 | } 161 | t.Logf("Order confirmed unfilled") 162 | 163 | // Test cancelling the order 164 | success, err := client.CancelOrder(id) 165 | if err != nil { 166 | t.Fatal(err) 167 | } 168 | if !success { 169 | t.Fatal("Order not cancelled") 170 | } 171 | t.Logf("Sent cancellation") 172 | 173 | // Check status 174 | tryAgain := true 175 | for tryAgain { 176 | t.Logf("checking status...") 177 | order, err = client.GetOrderStatus(id) 178 | tryAgain = order.Status == "" 179 | } 180 | if err != nil { 181 | t.Fatal(err) 182 | } 183 | if order.Status != "dead" { 184 | t.Fatal("Order should be dead after cancel") 185 | } 186 | t.Logf("Order confirmed dead") 187 | if order.FilledAmount != 0 { 188 | t.Fatal("Order should not be filled") 189 | } 190 | t.Logf("Order confirmed unfilled") 191 | 192 | // Test bad order 193 | id, err = client.SendOrder("kill", otype, amount, price) 194 | if id != 0 { 195 | t.Fatal("Expected id = 0") 196 | } 197 | 198 | client.Done() 199 | } 200 | -------------------------------------------------------------------------------- /btcbook/btcbook.go: -------------------------------------------------------------------------------- 1 | // Tester program for displaying OKCoin book data to terminal 2 | 3 | package main 4 | 5 | import ( 6 | "bitfx/btcchina" 7 | "bitfx/exchange" 8 | "bitfx/forex" 9 | "fmt" 10 | "log" 11 | "os" 12 | "os/exec" 13 | ) 14 | 15 | var ( 16 | btc = btcchina.New("", "", "btc", "cny", 0, 0, 0, 0) 17 | cny float64 18 | ) 19 | 20 | func main() { 21 | filename := "btcbook.log" 22 | logFile, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | log.SetOutput(logFile) 27 | log.Println("Starting new run") 28 | fxChan := make(chan forex.Quote) 29 | fxDoneChan := make(chan bool, 1) 30 | quote := forex.CommunicateFX("cny", fxChan, fxDoneChan) 31 | if quote.Error != nil || quote.Price == 0 { 32 | log.Fatal(quote.Error) 33 | } 34 | cny = quote.Price 35 | 36 | bookChan := make(chan exchange.Book) 37 | if book := btc.CommunicateBook(bookChan); book.Error != nil { 38 | log.Fatal(book.Error) 39 | } 40 | inputChan := make(chan rune) 41 | go checkStdin(inputChan) 42 | 43 | Loop: 44 | for { 45 | select { 46 | case book := <-bookChan: 47 | printBook(book) 48 | case <-inputChan: 49 | btc.Done() 50 | fxDoneChan <- true 51 | break Loop 52 | } 53 | } 54 | 55 | } 56 | 57 | // Check for any user input 58 | func checkStdin(inputChan chan<- rune) { 59 | var ch rune 60 | fmt.Scanf("%c", &ch) 61 | inputChan <- ch 62 | } 63 | 64 | // Print book data from each exchange 65 | func printBook(book exchange.Book) { 66 | clearScreen() 67 | if book.Error != nil { 68 | log.Println(book.Error) 69 | } else { 70 | fmt.Println("----------------------------") 71 | fmt.Printf("%-10s%-10s%8s\n", " Bid", " Ask", "Size ") 72 | fmt.Println("----------------------------") 73 | for i := range book.Asks { 74 | item := book.Asks[len(book.Asks)-1-i] 75 | fmt.Printf("%-10s%-10.4f%8.4f\n", "", item.Price/cny, item.Amount) 76 | } 77 | for _, item := range book.Bids { 78 | fmt.Printf("%-10.4f%-10.2s%8.4f\n", item.Price/cny, "", item.Amount) 79 | } 80 | fmt.Println("----------------------------") 81 | } 82 | } 83 | 84 | // Clear the terminal between prints 85 | func clearScreen() { 86 | c := exec.Command("clear") 87 | c.Stdout = os.Stdout 88 | c.Run() 89 | } 90 | -------------------------------------------------------------------------------- /btcchina/btcchina.go: -------------------------------------------------------------------------------- 1 | // BTCChina exchange API 2 | 3 | package btcchina 4 | 5 | import ( 6 | "bitfx/exchange" 7 | "bytes" 8 | "crypto/hmac" 9 | "crypto/sha1" 10 | "encoding/json" 11 | "fmt" 12 | "io/ioutil" 13 | "log" 14 | "net/http" 15 | "sort" 16 | "strconv" 17 | "strings" 18 | "time" 19 | 20 | "github.com/gorilla/websocket" 21 | ) 22 | 23 | // Client contains all exchange information 24 | type Client struct { 25 | key, secret, symbol, currency, websocketURL, restURL, name, market string 26 | priority int 27 | position, fee, maxPos, availShort, availFunds float64 28 | currencyCode byte 29 | done chan bool 30 | } 31 | 32 | // Exchange request format 33 | type request struct { 34 | Method string `json:"method"` 35 | Params []interface{} `json:"params"` 36 | ID int `json:"id"` 37 | } 38 | 39 | // New returns a pointer to a Client instance 40 | func New(key, secret, symbol, currency string, priority int, fee, availShort, availFunds float64) *Client { 41 | return &Client{ 42 | key: key, 43 | secret: secret, 44 | symbol: symbol, 45 | currency: currency, 46 | websocketURL: "websocket.btcchina.com/socket.io", 47 | restURL: "api.btcchina.com/api_trade_v1.php", 48 | priority: priority, 49 | fee: fee, 50 | availShort: availShort, 51 | availFunds: availFunds, 52 | currencyCode: 1, 53 | name: fmt.Sprintf("BTCChina(%s)", currency), 54 | market: strings.ToUpper(symbol + currency), 55 | done: make(chan bool, 1), 56 | } 57 | } 58 | 59 | // Done closes all connections 60 | func (client *Client) Done() { 61 | client.done <- true 62 | } 63 | 64 | // String implements the Stringer interface 65 | func (client *Client) String() string { 66 | return client.name 67 | } 68 | 69 | // Priority returns the exchange priority for order execution 70 | func (client *Client) Priority() int { 71 | return client.priority 72 | } 73 | 74 | // Fee returns the exchange order fee 75 | func (client *Client) Fee() float64 { 76 | return client.fee 77 | } 78 | 79 | // SetPosition sets the exchange position 80 | func (client *Client) SetPosition(pos float64) { 81 | client.position = pos 82 | } 83 | 84 | // Position returns the exchange position 85 | func (client *Client) Position() float64 { 86 | return client.position 87 | } 88 | 89 | // Currency returns the exchange currency 90 | func (client *Client) Currency() string { 91 | return client.currency 92 | } 93 | 94 | // CurrencyCode returns the exchange currency code 95 | func (client *Client) CurrencyCode() byte { 96 | return client.currencyCode 97 | } 98 | 99 | // SetMaxPos sets the exchange max position 100 | func (client *Client) SetMaxPos(maxPos float64) { 101 | client.maxPos = maxPos 102 | } 103 | 104 | // MaxPos returns the exchange max position 105 | func (client *Client) MaxPos() float64 { 106 | return client.maxPos 107 | } 108 | 109 | // AvailFunds returns the exchange available funds 110 | func (client *Client) AvailFunds() float64 { 111 | return client.availFunds 112 | } 113 | 114 | // AvailShort returns the exchange quantity available for short selling 115 | func (client *Client) AvailShort() float64 { 116 | return client.availShort 117 | } 118 | 119 | // HasCrytpoFee returns true if fee is taken in cryptocurrency on buys 120 | func (client *Client) HasCryptoFee() bool { 121 | return false 122 | } 123 | 124 | // CommunicateBook sends the latest available book data on the supplied channel 125 | func (client *Client) CommunicateBook(bookChan chan<- exchange.Book) exchange.Book { 126 | // Connect to Socket.IO 127 | ws, pingInterval, err := client.connectSocketIO() 128 | if err != nil { 129 | return exchange.Book{Error: fmt.Errorf("%s CommunicateBook error: %s", client, err)} 130 | } 131 | 132 | // Get an initial book to return 133 | _, data, err := ws.ReadMessage() 134 | if err != nil { 135 | return exchange.Book{Error: fmt.Errorf("%s CommunicateBook error: %s", client, err)} 136 | } 137 | book := client.convertToBook(data) 138 | 139 | // Run a read loop in new goroutine 140 | go client.runLoop(ws, pingInterval, bookChan) 141 | 142 | return book 143 | } 144 | 145 | // Connect to Socket.IO 146 | func (client *Client) connectSocketIO() (*websocket.Conn, time.Duration, error) { 147 | // Socket.IO handshake 148 | getURL := fmt.Sprintf("https://%s/?transport=polling", client.websocketURL) 149 | resp, err := http.Get(getURL) 150 | if err != nil { 151 | return nil, time.Duration(0), err 152 | } 153 | body, err := ioutil.ReadAll(resp.Body) 154 | if err != nil { 155 | return nil, time.Duration(0), err 156 | } 157 | resp.Body.Close() 158 | message := strings.TrimLeftFunc(string(body), func(char rune) bool { return string(char) != "{" }) 159 | var session struct { 160 | Sid string 161 | Upgrades []string 162 | PingInterval int 163 | PingTimeout int 164 | } 165 | if err := json.Unmarshal([]byte(message), &session); err != nil { 166 | return nil, time.Duration(0), err 167 | } 168 | for _, value := range session.Upgrades { 169 | if strings.ToLower(value) == "websocket" { 170 | break 171 | } 172 | return nil, time.Duration(0), fmt.Errorf("WebSocket upgrade not available") 173 | } 174 | wsURL := fmt.Sprintf("wss://%s/?transport=websocket&sid=%s", client.websocketURL, session.Sid) 175 | ws, _, err := websocket.DefaultDialer.Dial(wsURL, http.Header{}) 176 | if err != nil { 177 | return nil, time.Duration(0), err 178 | } 179 | 180 | // Upgrade connection to WebSocket 181 | if err := ws.WriteMessage(1, []byte("52")); err != nil { 182 | return nil, time.Duration(0), err 183 | } 184 | _, data, err := ws.ReadMessage() 185 | if err != nil { 186 | return nil, time.Duration(0), err 187 | } 188 | if string(data) != "40" { 189 | return nil, time.Duration(0), fmt.Errorf("Failed WebSocket upgrade") 190 | } 191 | 192 | // Subscribe to channel 193 | subMsg := fmt.Sprintf("42[\"subscribe\",\"grouporder_%s%s\"]", client.currency, client.symbol) 194 | if err := ws.WriteMessage(1, []byte(subMsg)); err != nil { 195 | return nil, time.Duration(0), err 196 | } 197 | 198 | // Return WebSocket and ping interval 199 | return ws, time.Duration(session.PingInterval) * time.Millisecond, nil 200 | } 201 | 202 | // Websocket read loop 203 | func (client *Client) runLoop(ws *websocket.Conn, pingInterval time.Duration, bookChan chan<- exchange.Book) { 204 | // Syncronize access to *websocket.Conn 205 | receiveWS := make(chan *websocket.Conn) 206 | reconnectWS := make(chan bool) 207 | closeWS := make(chan bool) 208 | go func() { 209 | LOOP: 210 | for { 211 | select { 212 | // Request to use websocket 213 | case receiveWS <- ws: 214 | // Request to reconnect websocket 215 | case <-reconnectWS: 216 | ws.Close() 217 | var err error 218 | ws, _, err = client.connectSocketIO() 219 | // Keep trying on error 220 | for err != nil { 221 | log.Printf("%s WebSocket error: %s", client, err) 222 | time.Sleep(1 * time.Second) 223 | ws, _, err = client.connectSocketIO() 224 | } 225 | // Request to close websocket 226 | case <-closeWS: 227 | ws.Close() 228 | break LOOP 229 | } 230 | } 231 | }() 232 | 233 | // Read from websocket 234 | dataChan := make(chan []byte) 235 | go func() { 236 | for { 237 | (<-receiveWS).SetReadDeadline(time.Now().Add(pingInterval + time.Second)) 238 | _, data, err := (<-receiveWS).ReadMessage() 239 | if err != nil { 240 | // Reconnect on error 241 | log.Printf("%s WebSocket error: %s", client, err) 242 | reconnectWS <- true 243 | } else if string(data) != "3" { 244 | // If not a pong, send for processing 245 | dataChan <- data 246 | } 247 | } 248 | }() 249 | 250 | // Setup heartbeat 251 | ticker := time.NewTicker(pingInterval) 252 | ping := []byte("2") 253 | 254 | for { 255 | select { 256 | case <-client.done: 257 | // End if notified 258 | ticker.Stop() 259 | closeWS <- true 260 | return 261 | case <-ticker.C: 262 | // Send Socket.IO ping 263 | if err := (<-receiveWS).WriteMessage(1, ping); err != nil { 264 | // Reconnect on error 265 | log.Printf("%s WebSocket error: %s", client, err) 266 | reconnectWS <- true 267 | } 268 | case data := <-dataChan: 269 | // Process data and send out to user 270 | bookChan <- client.convertToBook(data) 271 | } 272 | } 273 | } 274 | 275 | // Convert websocket data to an exchange.Book 276 | func (client *Client) convertToBook(data []byte) exchange.Book { 277 | // Remove Socket.IO crap 278 | message := strings.TrimLeftFunc(string(data), func(char rune) bool { return string(char) != "{" }) 279 | message = strings.TrimRightFunc(message, func(char rune) bool { return string(char) != "}" }) 280 | // Unmarshal 281 | var response struct { 282 | GroupOrder struct { 283 | Bid []struct { 284 | Price float64 285 | TotalAmount float64 286 | } 287 | Ask []struct { 288 | Price float64 289 | TotalAmount float64 290 | } 291 | } 292 | } 293 | if err := json.Unmarshal([]byte(message), &response); err != nil { 294 | return exchange.Book{Error: fmt.Errorf("%s book error: %s", client, err)} 295 | } 296 | 297 | // Translate into exchange.Book structure 298 | bids := make(exchange.BidItems, 5) 299 | asks := make(exchange.AskItems, 5) 300 | // Only depth of 5 is available 301 | for i := 0; i < 5; i++ { 302 | bids[i].Price = response.GroupOrder.Bid[i].Price 303 | bids[i].Amount = response.GroupOrder.Bid[i].TotalAmount 304 | asks[i].Price = response.GroupOrder.Ask[i].Price 305 | asks[i].Amount = response.GroupOrder.Ask[i].TotalAmount 306 | } 307 | sort.Sort(bids) 308 | sort.Sort(asks) 309 | 310 | // Return book 311 | return exchange.Book{ 312 | Exg: client, 313 | Time: time.Now(), 314 | Bids: bids, 315 | Asks: asks, 316 | Error: nil, 317 | } 318 | } 319 | 320 | // SendOrder sends an order to the exchange 321 | func (client *Client) SendOrder(action, otype string, amount, price float64) (int64, error) { 322 | // Set method 323 | var method string 324 | if action == "buy" { 325 | method = "buyOrder2" 326 | } else if action == "sell" { 327 | method = "sellOrder2" 328 | } else { 329 | return 0, fmt.Errorf("%s SendOrder error: only \"buy\" and \"sell\" actions supported", client) 330 | } 331 | 332 | // Check order type 333 | if otype != "limit" { 334 | return 0, fmt.Errorf("%s SendOrder error: only limit orders supported", client) 335 | } 336 | 337 | // Set params 338 | strPrice := strconv.FormatFloat(price, 'f', 2, 64) 339 | strAmount := strconv.FormatFloat(amount, 'f', 4, 64) 340 | params := []interface{}{strPrice, strAmount, client.market} 341 | paramString := strings.Join([]string{strPrice, strAmount, client.market}, ",") 342 | 343 | // Send POST 344 | req := request{method, params, 1} 345 | data, err := client.post(method, paramString, req) 346 | if err != nil { 347 | return 0, fmt.Errorf("%s SendOrder error: %s", client, err) 348 | } 349 | 350 | // Unmarshal 351 | var response struct { 352 | Result int64 353 | Error struct { 354 | Code int 355 | Message string 356 | } 357 | } 358 | if err := json.Unmarshal(data, &response); err != nil { 359 | return 0, fmt.Errorf("%s SendOrder error: %s", client, err) 360 | } 361 | if response.Error.Message != "" { 362 | return 0, fmt.Errorf("%s SendOrder error code %d: %s", client, response.Error.Code, response.Error.Message) 363 | } 364 | 365 | return response.Result, nil 366 | } 367 | 368 | // CancelOrder cancels an order on the exchange 369 | func (client *Client) CancelOrder(id int64) (bool, error) { 370 | // Set params 371 | method := "cancelOrder" 372 | params := []interface{}{id, client.market} 373 | paramString := strconv.FormatInt(id, 10) + "," + client.market 374 | 375 | // Send POST 376 | req := request{method, params, 1} 377 | data, err := client.post(method, paramString, req) 378 | if err != nil { 379 | return false, fmt.Errorf("%s CancelOrder error: %s", client, err) 380 | } 381 | 382 | // Unmarshal 383 | var response struct { 384 | Result bool 385 | Error struct { 386 | Code int 387 | Message string 388 | } 389 | } 390 | if err := json.Unmarshal(data, &response); err != nil { 391 | return false, fmt.Errorf("%s CancelOrder error: %s", client, err) 392 | } 393 | if response.Error.Message != "" { 394 | return false, fmt.Errorf("%s CancelOrder error code %d: %s", client, response.Error.Code, response.Error.Message) 395 | } 396 | 397 | return response.Result, nil 398 | } 399 | 400 | // GetOrderStatus gets the status of an order on the exchange 401 | func (client *Client) GetOrderStatus(id int64) (exchange.Order, error) { 402 | // Set params 403 | method := "getOrder" 404 | params := []interface{}{id, client.market} 405 | paramString := strconv.FormatInt(id, 10) + "," + client.market 406 | 407 | // Send POST 408 | req := request{method, params, 1} 409 | data, err := client.post(method, paramString, req) 410 | if err != nil { 411 | return exchange.Order{}, fmt.Errorf("%s GetOrderStatus error: %s", client, err) 412 | } 413 | 414 | // Unmarshal 415 | var response struct { 416 | Result struct { 417 | Order struct { 418 | Status string 419 | Amount float64 `json:"amount,string"` 420 | OrigAmount float64 `json:"amount_original,string"` 421 | } 422 | } 423 | Error struct { 424 | Code int 425 | Message string 426 | } 427 | } 428 | if err := json.Unmarshal(data, &response); err != nil { 429 | return exchange.Order{}, fmt.Errorf("%s CancelOrder error: %s", client, err) 430 | } 431 | if response.Error.Message != "" { 432 | return exchange.Order{}, fmt.Errorf("%s CancelOrder error code %d: %s", client, response.Error.Code, response.Error.Message) 433 | } 434 | 435 | // Status from exchange can be "pending", "open", "cancelled", or "closed" 436 | var status string 437 | if response.Result.Order.Status == "cancelled" || response.Result.Order.Status == "closed" { 438 | status = "dead" 439 | } else if response.Result.Order.Status == "open" { 440 | status = "live" 441 | } // else empty string is returned 442 | 443 | // Calculate filled amount (positive number is returned for buys and sells) 444 | filled := response.Result.Order.OrigAmount - response.Result.Order.Amount 445 | 446 | return exchange.Order{FilledAmount: filled, Status: status}, nil 447 | } 448 | 449 | // Authenticated POST 450 | func (client *Client) post(method, params string, payload interface{}) ([]byte, error) { 451 | // Create signature to be signed 452 | tonce := strconv.FormatInt(time.Now().UnixNano()/1000, 10) 453 | signature := fmt.Sprintf("tonce=%s&accesskey=%s&requestmethod=post&id=1&method=%s¶ms=%s", 454 | tonce, client.key, method, params) 455 | // Perform HMAC on signature using client.secret 456 | h := hmac.New(sha1.New, []byte(client.secret)) 457 | h.Write([]byte(signature)) 458 | 459 | // Marshal payload 460 | body, err := json.Marshal(payload) 461 | if err != nil { 462 | return []byte{}, err 463 | } 464 | // Create http request using specified url 465 | url := fmt.Sprintf("https://%s:%x@%s", client.key, h.Sum(nil), client.restURL) 466 | req, err := http.NewRequest("POST", url, bytes.NewBuffer(body)) 467 | if err != nil { 468 | return []byte{}, err 469 | } 470 | 471 | // Add tonce header 472 | req.Header.Add("Json-Rpc-Tonce", tonce) 473 | 474 | // Send POST 475 | httpClient := http.Client{} 476 | resp, err := httpClient.Do(req) 477 | if err != nil { 478 | return []byte{}, err 479 | } 480 | if resp.StatusCode != 200 { 481 | return []byte{}, fmt.Errorf(resp.Status) 482 | } 483 | defer resp.Body.Close() 484 | 485 | return ioutil.ReadAll(resp.Body) 486 | } 487 | -------------------------------------------------------------------------------- /btcchina/btcchina_test.go: -------------------------------------------------------------------------------- 1 | package btcchina 2 | 3 | import ( 4 | "bitfx/exchange" 5 | "math" 6 | "os" 7 | "testing" 8 | ) 9 | 10 | var ( 11 | book exchange.Book 12 | client = New(os.Getenv("BTC_KEY"), os.Getenv("BTC_SECRET"), "btc", "cny", 1, 0.002, 2, .1) 13 | ) 14 | 15 | // Used for float equality 16 | func notEqual(f1, f2 float64) bool { 17 | if math.Abs(f1-f2) > 0.000001 { 18 | return true 19 | } 20 | return false 21 | } 22 | 23 | func TestPriority(t *testing.T) { 24 | if client.Priority() != 1 { 25 | t.Fatal("Priority should be 1") 26 | } 27 | } 28 | 29 | func TestFee(t *testing.T) { 30 | if notEqual(client.Fee(), 0.002) { 31 | t.Fatal("Fee should be 0.002") 32 | } 33 | } 34 | 35 | func TestUpdatePositon(t *testing.T) { 36 | if notEqual(client.Position(), 0) { 37 | t.Fatal("Should start with zero position") 38 | } 39 | client.SetPosition(10) 40 | if notEqual(client.Position(), 10) { 41 | t.Fatal("Position should have updated to 10") 42 | } 43 | } 44 | 45 | func TestCurrency(t *testing.T) { 46 | if client.Currency() != "cny" { 47 | t.Fatal("Currency should be cny") 48 | } 49 | } 50 | 51 | func TestCurrencyCode(t *testing.T) { 52 | if client.CurrencyCode() != 1 { 53 | t.Fatal("Currency code should be 1") 54 | } 55 | } 56 | 57 | func TestMaxPos(t *testing.T) { 58 | if notEqual(client.MaxPos(), 0) { 59 | t.Fatal("MaxPos should start at 0") 60 | } 61 | client.SetMaxPos(23) 62 | if notEqual(client.MaxPos(), 23) { 63 | t.Fatal("MaxPos should be set to 23") 64 | } 65 | } 66 | 67 | func TestAvailFunds(t *testing.T) { 68 | if notEqual(client.AvailFunds(), 0.1) { 69 | t.Fatal("Available funds should be 0.1") 70 | } 71 | } 72 | 73 | func TestAvailShort(t *testing.T) { 74 | if notEqual(client.AvailShort(), 2) { 75 | t.Fatal("Available short should be 2") 76 | } 77 | } 78 | 79 | func TestHasCryptoFee(t *testing.T) { 80 | if client.HasCryptoFee() { 81 | t.Fatal("Should not have cryptocurrency fee") 82 | } 83 | } 84 | 85 | // ***** Live exchange communication tests ***** 86 | // Slow... skip when not needed 87 | 88 | func TestCommunicateBook(t *testing.T) { 89 | bookChan := make(chan exchange.Book) 90 | if book = client.CommunicateBook(bookChan); book.Error != nil { 91 | t.Fatal(book.Error) 92 | } 93 | 94 | book = <-bookChan 95 | t.Logf("Received book data") 96 | // spew.Dump(book) 97 | if len(book.Bids) != 5 || len(book.Asks) != 5 { 98 | t.Fatal("Expected 5 book entries") 99 | } 100 | if book.Bids[0].Price < book.Bids[1].Price { 101 | t.Fatal("Bids not sorted correctly") 102 | } 103 | if book.Asks[0].Price > book.Asks[1].Price { 104 | t.Fatal("Asks not sorted correctly") 105 | } 106 | } 107 | 108 | func TestNewOrder(t *testing.T) { 109 | action := "buy" 110 | otype := "limit" 111 | amount := 0.001 112 | price := book.Bids[0].Price - 10 113 | 114 | // Test submitting a new order 115 | id, err := client.SendOrder(action, otype, amount, price) 116 | if err != nil || id == 0 { 117 | t.Fatal(err) 118 | } 119 | t.Logf("Placed a new buy order of 0.0001 btc_usd @ %v limit with ID: %d", price, id) 120 | 121 | // Check status 122 | var order exchange.Order 123 | tryAgain := true 124 | for tryAgain { 125 | t.Logf("checking status...") 126 | order, err = client.GetOrderStatus(id) 127 | tryAgain = order.Status == "" 128 | } 129 | if err != nil { 130 | t.Fatal(err) 131 | } 132 | if order.Status != "live" { 133 | t.Fatal("Order should be live") 134 | } 135 | t.Logf("Order confirmed live") 136 | if order.FilledAmount != 0 { 137 | t.Fatal("Order should not be filled") 138 | } 139 | t.Logf("Order confirmed unfilled") 140 | 141 | // Test cancelling the order 142 | success, err := client.CancelOrder(id) 143 | if err != nil { 144 | t.Fatal(err) 145 | } 146 | if !success { 147 | t.Fatal("Order not cancelled") 148 | } 149 | t.Logf("Sent cancellation") 150 | 151 | // Check status 152 | tryAgain = true 153 | for tryAgain { 154 | t.Logf("checking status...") 155 | order, err = client.GetOrderStatus(id) 156 | tryAgain = order.Status == "" 157 | } 158 | if err != nil { 159 | t.Fatal(err) 160 | } 161 | t.Logf("Order confirmed dead") 162 | if order.FilledAmount != 0 { 163 | t.Fatal("Order should not be filled") 164 | } 165 | t.Logf("Order confirmed unfilled") 166 | 167 | // Test bad order 168 | id, err = client.SendOrder("buy", otype, 0, price) 169 | if id != 0 { 170 | t.Fatal("Expected id = 0") 171 | } 172 | if err == nil { 173 | t.Fatal("Expected error on bad order") 174 | } 175 | 176 | client.Done() 177 | } 178 | -------------------------------------------------------------------------------- /exchange/exchange.go: -------------------------------------------------------------------------------- 1 | // Cryptocurrency exchange abstraction 2 | 3 | package exchange 4 | 5 | import ( 6 | "time" 7 | ) 8 | 9 | // Interface defines exchange methods for data and trading 10 | type Interface interface { 11 | // Implement Stringer interface 12 | String() string 13 | // Return exchange priority for order execution 14 | // Lower priority number is executed first 15 | // Equal priority results in concurrent execution 16 | Priority() int 17 | // Return percent fee charged for taking a market 18 | Fee() float64 19 | // Position setter method 20 | SetPosition(float64) 21 | // Return position set above 22 | Position() float64 23 | // Max allowed position setter method 24 | SetMaxPos(float64) 25 | // Return max allowed position set above 26 | MaxPos() float64 27 | // Return fiat currency funds available for purchases 28 | AvailFunds() float64 29 | // Return amount of cryptocurrency available for short selling 30 | AvailShort() float64 31 | // Return the fiat currency in use 32 | Currency() string 33 | // Return the fiat currency code 34 | // USD = 0 35 | // CNY = 1 36 | CurrencyCode() byte 37 | // Send the latest available exchange.Book on the supplied channel 38 | CommunicateBook(bookChan chan<- Book) Book 39 | // Send an order to the exchange 40 | // action = "buy" or "sell" 41 | // otype = "limit" or "market" 42 | SendOrder(action, otype string, amount, price float64) (int64, error) 43 | // Cancel an existing order on the exchange 44 | CancelOrder(id int64) (bool, error) 45 | // Return status of an existing order on the exchange 46 | GetOrderStatus(id int64) (Order, error) 47 | // Return true if fees are charged in cryptocurrency on purchases 48 | HasCryptoFee() bool 49 | // Close all connections 50 | Done() 51 | } 52 | 53 | // Order defines the order status format 54 | type Order struct { 55 | FilledAmount float64 // Positive number for buys and sells 56 | Status string // "live" or "dead" 57 | } 58 | 59 | // Book defines the book data format 60 | type Book struct { 61 | Exg Interface 62 | Time time.Time 63 | Bids BidItems // Sort by price high to low 64 | Asks AskItems // Sort by price low to high 65 | Error error 66 | } 67 | 68 | // BidItems defines the inner book data format 69 | type BidItems []struct { 70 | Price float64 71 | Amount float64 72 | } 73 | 74 | // AskItems defines the inner book data format 75 | type AskItems []struct { 76 | Price float64 77 | Amount float64 78 | } 79 | 80 | // Len implements sort.Interface on BidItems 81 | func (items BidItems) Len() int { 82 | return len(items) 83 | } 84 | 85 | // Swap implements sort.Interface on BidItems 86 | func (items BidItems) Swap(i, j int) { 87 | items[i], items[j] = items[j], items[i] 88 | } 89 | 90 | // Less implements sort.Interface on BidItems 91 | func (items BidItems) Less(i, j int) bool { 92 | return items[i].Price > items[j].Price 93 | } 94 | 95 | // Len implements sort.Interface on AskItems 96 | func (items AskItems) Len() int { 97 | return len(items) 98 | } 99 | 100 | // Swap implements sort.Interface on AskItems 101 | func (items AskItems) Swap(i, j int) { 102 | items[i], items[j] = items[j], items[i] 103 | } 104 | 105 | // Less implements sort.Interface on AskItems 106 | func (items AskItems) Less(i, j int) bool { 107 | return items[i].Price < items[j].Price 108 | } 109 | -------------------------------------------------------------------------------- /forex/forex.go: -------------------------------------------------------------------------------- 1 | // Forex data API 2 | // Currently using yahoo finance 3 | // http://finance.yahoo.com/webservice/v1/symbols/CNY=X/quote?format=json 4 | 5 | package forex 6 | 7 | import ( 8 | "encoding/json" 9 | "fmt" 10 | "io/ioutil" 11 | "net/http" 12 | "time" 13 | ) 14 | 15 | // Forex data API URL 16 | const DATAURL = "http://finance.yahoo.com/webservice/v1/symbols/" 17 | 18 | // Quote contains forex quote information 19 | type Quote struct { 20 | Price float64 21 | Symbol string 22 | Error error 23 | } 24 | 25 | // CommunicateFX sends the latest FX quote to the supplied channel 26 | func CommunicateFX(symbol string, fxChan chan<- Quote, doneChan <-chan bool) Quote { 27 | // Initial quote to return 28 | quote := getQuote(symbol) 29 | 30 | // Run read loop in new goroutine 31 | go runLoop(symbol, fxChan, doneChan) 32 | 33 | return quote 34 | } 35 | 36 | // HTTP read loop 37 | func runLoop(symbol string, fxChan chan<- Quote, doneChan <-chan bool) { 38 | ticker := time.NewTicker(15 * time.Second) 39 | 40 | for { 41 | select { 42 | case <-doneChan: 43 | ticker.Stop() 44 | return 45 | case <-ticker.C: 46 | fxChan <- getQuote(symbol) 47 | } 48 | } 49 | } 50 | 51 | // Returns quote for requested currency 52 | func getQuote(symbol string) Quote { 53 | // Get data 54 | url := fmt.Sprintf("%s%s=x/quote?format=json", DATAURL, symbol) 55 | data, err := get(url) 56 | if err != nil { 57 | return Quote{Error: fmt.Errorf("Forex error %s", err)} 58 | } 59 | 60 | // Unmarshal 61 | response := struct { 62 | List struct { 63 | Resources []struct { 64 | Resource struct { 65 | Fields struct { 66 | Price float64 `json:"price,string"` 67 | } `json:"fields"` 68 | } `json:"resource"` 69 | } `json:"resources"` 70 | } `json:"list"` 71 | }{} 72 | if err = json.Unmarshal(data, &response); err != nil { 73 | return Quote{Error: fmt.Errorf("Forex error %s", err)} 74 | } 75 | 76 | // Pull out price 77 | price := response.List.Resources[0].Resource.Fields.Price 78 | if price < .000001 { 79 | return Quote{Error: fmt.Errorf("Forex zero price error")} 80 | } 81 | 82 | return Quote{ 83 | Price: price, 84 | Symbol: symbol, 85 | Error: nil, 86 | } 87 | } 88 | 89 | // Unauthenticated GET 90 | func get(url string) ([]byte, error) { 91 | resp, err := http.Get(url) 92 | if err != nil { 93 | return []byte{}, err 94 | } 95 | if resp.StatusCode != 200 { 96 | return []byte{}, fmt.Errorf(resp.Status) 97 | } 98 | defer resp.Body.Close() 99 | 100 | return ioutil.ReadAll(resp.Body) 101 | } 102 | -------------------------------------------------------------------------------- /forex/forex_test.go: -------------------------------------------------------------------------------- 1 | package forex 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGetQuote(t *testing.T) { 8 | quote := getQuote("cny") 9 | if quote.Error != nil { 10 | t.Fatal(quote.Error) 11 | } 12 | // spew.Dump(quote) 13 | } 14 | 15 | func TestCommunicateFX(t *testing.T) { 16 | fxChan := make(chan Quote) 17 | doneChan := make(chan bool) 18 | if quote := CommunicateFX("cny", fxChan, doneChan); quote.Error != nil { 19 | t.Fatal(quote.Error) 20 | } 21 | 22 | if quote := <-fxChan; quote.Error != nil { 23 | t.Fatal(quote.Error) 24 | } 25 | t.Logf("Received quote") 26 | // spew.Dump(quote) 27 | } 28 | -------------------------------------------------------------------------------- /okbook/okbook.go: -------------------------------------------------------------------------------- 1 | // Tester program for displaying OKCoin book data to terminal 2 | 3 | package main 4 | 5 | import ( 6 | "bitfx/exchange" 7 | "bitfx/forex" 8 | "bitfx/okcoin" 9 | "fmt" 10 | "log" 11 | "os" 12 | "os/exec" 13 | ) 14 | 15 | var ( 16 | ok = okcoin.New("", "", "ltc", "cny", 0, 0, 0, 0) 17 | cny float64 18 | ) 19 | 20 | func main() { 21 | filename := "okbook.log" 22 | logFile, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | log.SetOutput(logFile) 27 | log.Println("Starting new run") 28 | fxChan := make(chan forex.Quote) 29 | fxDoneChan := make(chan bool, 1) 30 | quote := forex.CommunicateFX("cny", fxChan, fxDoneChan) 31 | if quote.Error != nil || quote.Price == 0 { 32 | log.Fatal(quote.Error) 33 | } 34 | cny = quote.Price 35 | 36 | bookChan := make(chan exchange.Book) 37 | if book := ok.CommunicateBook(bookChan); book.Error != nil { 38 | log.Fatal(book.Error) 39 | } 40 | inputChan := make(chan rune) 41 | go checkStdin(inputChan) 42 | 43 | Loop: 44 | for { 45 | select { 46 | case book := <-bookChan: 47 | printBook(book) 48 | case <-inputChan: 49 | ok.Done() 50 | fxDoneChan <- true 51 | break Loop 52 | } 53 | } 54 | 55 | } 56 | 57 | // Check for any user input 58 | func checkStdin(inputChan chan<- rune) { 59 | var ch rune 60 | fmt.Scanf("%c", &ch) 61 | inputChan <- ch 62 | } 63 | 64 | // Print book data from each exchange 65 | func printBook(book exchange.Book) { 66 | clearScreen() 67 | if book.Error != nil { 68 | log.Println(book.Error) 69 | } else { 70 | fmt.Println("----------------------------") 71 | fmt.Printf("%-10s%-10s%8s\n", " Bid", " Ask", "Size ") 72 | fmt.Println("----------------------------") 73 | for i := range book.Asks { 74 | item := book.Asks[len(book.Asks)-1-i] 75 | fmt.Printf("%-10s%-10.4f%8.2f\n", "", item.Price/cny, item.Amount) 76 | } 77 | for _, item := range book.Bids { 78 | fmt.Printf("%-10.4f%-10.2s%8.2f\n", item.Price/cny, "", item.Amount) 79 | } 80 | fmt.Println("----------------------------") 81 | } 82 | } 83 | 84 | // Clear the terminal between prints 85 | func clearScreen() { 86 | c := exec.Command("clear") 87 | c.Stdout = os.Stdout 88 | c.Run() 89 | } 90 | -------------------------------------------------------------------------------- /okcoin/okcoin.go: -------------------------------------------------------------------------------- 1 | // OKCoin exchange API 2 | 3 | package okcoin 4 | 5 | import ( 6 | "bitfx/exchange" 7 | "crypto/md5" 8 | "encoding/json" 9 | "fmt" 10 | "log" 11 | "math" 12 | "net/http" 13 | "net/url" 14 | "sort" 15 | "strings" 16 | "time" 17 | 18 | "github.com/gorilla/websocket" 19 | ) 20 | 21 | // Client contains all exchange information 22 | type Client struct { 23 | key, secret, symbol, currency, websocketURL, name string 24 | priority int 25 | position, fee, maxPos, availShort, availFunds float64 26 | currencyCode byte 27 | done chan bool 28 | writeBookMsg chan request 29 | readBookMsg chan response 30 | writeOrderMsg chan request 31 | readOrderMsg chan response 32 | } 33 | 34 | // Exchange request format 35 | type request struct { 36 | Event string `json:"event"` // Event to request 37 | Channel string `json:"channel"` // Channel on which to make request 38 | Parameters map[string]string `json:"parameters"` // Additional parameters 39 | } 40 | 41 | // Exchange response format 42 | type response []struct { 43 | Channel string `json:"channel"` // Channel name 44 | ErrorCode int64 `json:"errorcode,string"` // Error code if not successful 45 | Data json.RawMessage `json:"data"` // Data specific to channel 46 | } 47 | 48 | // New returns a pointer to a Client instance 49 | func New(key, secret, symbol, currency string, priority int, fee, availShort, availFunds float64) *Client { 50 | // URL depends on currency 51 | var websocketURL string 52 | var currencyCode byte 53 | if strings.ToLower(currency) == "usd" { 54 | websocketURL = "wss://real.okcoin.com:10440/websocket/okcoinapi" 55 | currencyCode = 0 56 | } else if strings.ToLower(currency) == "cny" { 57 | websocketURL = "wss://real.okcoin.cn:10440/websocket/okcoinapi" 58 | currencyCode = 1 59 | } else { 60 | log.Fatal("Currency must be USD or CNY") 61 | } 62 | name := fmt.Sprintf("OKCoin(%s)", currency) 63 | 64 | // Channels for WebSocket connections 65 | done := make(chan bool, 2) 66 | writeBookMsg := make(chan request) 67 | readBookMsg := make(chan response) 68 | writeOrderMsg := make(chan request) 69 | readOrderMsg := make(chan response) 70 | 71 | client := &Client{ 72 | key: key, 73 | secret: secret, 74 | symbol: symbol, 75 | currency: currency, 76 | websocketURL: websocketURL, 77 | priority: priority, 78 | fee: fee, 79 | availShort: availShort, 80 | availFunds: availFunds, 81 | currencyCode: currencyCode, 82 | name: name, 83 | done: done, 84 | writeOrderMsg: writeOrderMsg, 85 | readOrderMsg: readOrderMsg, 86 | writeBookMsg: writeBookMsg, 87 | readBookMsg: readBookMsg, 88 | } 89 | 90 | // Run WebSocket connections 91 | initMsg := request{Event: "addChannel", Channel: fmt.Sprintf("ok_%s%s_depth", symbol, currency)} 92 | go client.maintainWS(initMsg, writeBookMsg, readBookMsg) 93 | go client.maintainWS(request{}, writeOrderMsg, readOrderMsg) 94 | 95 | return client 96 | } 97 | 98 | // Done closes all connections 99 | func (client *Client) Done() { 100 | client.done <- true 101 | client.done <- true 102 | close(client.readBookMsg) 103 | close(client.readOrderMsg) 104 | } 105 | 106 | // String implements the Stringer interface 107 | func (client *Client) String() string { 108 | return client.name 109 | } 110 | 111 | // Priority returns the exchange priority for order execution 112 | func (client *Client) Priority() int { 113 | return client.priority 114 | } 115 | 116 | // Fee returns the exchange order fee 117 | func (client *Client) Fee() float64 { 118 | return client.fee 119 | } 120 | 121 | // SetPosition sets the exchange position 122 | func (client *Client) SetPosition(pos float64) { 123 | client.position = pos 124 | } 125 | 126 | // Position returns the exchange position 127 | func (client *Client) Position() float64 { 128 | return client.position 129 | } 130 | 131 | // Currency returns the exchange currency 132 | func (client *Client) Currency() string { 133 | return client.currency 134 | } 135 | 136 | // CurrencyCode returns the exchange currency code 137 | func (client *Client) CurrencyCode() byte { 138 | return client.currencyCode 139 | } 140 | 141 | // SetMaxPos sets the exchange max position 142 | func (client *Client) SetMaxPos(maxPos float64) { 143 | client.maxPos = maxPos 144 | } 145 | 146 | // MaxPos returns the exchange max position 147 | func (client *Client) MaxPos() float64 { 148 | return client.maxPos 149 | } 150 | 151 | // AvailFunds returns the exchange available funds 152 | func (client *Client) AvailFunds() float64 { 153 | return client.availFunds 154 | } 155 | 156 | // AvailShort returns the exchange quantity available for short selling 157 | func (client *Client) AvailShort() float64 { 158 | return client.availShort 159 | } 160 | 161 | // HasCrytpoFee returns true if fee is taken in cryptocurrency on buys 162 | func (client *Client) HasCryptoFee() bool { 163 | return true 164 | } 165 | 166 | // CommunicateBook sends the latest available book data on the supplied channel 167 | func (client *Client) CommunicateBook(bookChan chan<- exchange.Book) exchange.Book { 168 | // Get an initial book to return 169 | book := client.convertToBook(<-client.readBookMsg) 170 | 171 | // Run a read loop in new goroutine 172 | go client.runBookLoop(bookChan) 173 | 174 | return book 175 | } 176 | 177 | // Book WebSocket read loop 178 | func (client *Client) runBookLoop(bookChan chan<- exchange.Book) { 179 | for resp := range client.readBookMsg { 180 | // Process data and send out to user 181 | bookChan <- client.convertToBook(resp) 182 | } 183 | } 184 | 185 | // Convert websocket data to an exchange.Book 186 | func (client *Client) convertToBook(resp response) exchange.Book { 187 | // Unmarshal 188 | var bookData struct { 189 | Bids [][2]float64 `json:"bids"` // Slice of bid data items 190 | Asks [][2]float64 `json:"asks"` // Slice of ask data items 191 | Timestamp int64 `json:"timestamp,string"` // Timestamp 192 | UnitAmount int `json:"unit_amount"` // Unit amount for futures 193 | 194 | } 195 | if err := json.Unmarshal(resp[0].Data, &bookData); err != nil { 196 | return exchange.Book{Error: fmt.Errorf("%s book error: %s", client, err)} 197 | } 198 | 199 | // Translate into exchange.Book structure 200 | bids := make(exchange.BidItems, 20) 201 | asks := make(exchange.AskItems, 20) 202 | for i := 0; i < 20; i++ { 203 | bids[i].Price = bookData.Bids[i][0] 204 | bids[i].Amount = bookData.Bids[i][1] 205 | asks[i].Price = bookData.Asks[i][0] 206 | asks[i].Amount = bookData.Asks[i][1] 207 | } 208 | sort.Sort(bids) 209 | sort.Sort(asks) 210 | 211 | // Return book 212 | return exchange.Book{ 213 | Exg: client, 214 | Time: time.Now(), 215 | Bids: bids, 216 | Asks: asks, 217 | Error: nil, 218 | } 219 | } 220 | 221 | // SendOrder sends an order to the exchange 222 | func (client *Client) SendOrder(action, otype string, amount, price float64) (int64, error) { 223 | // Construct parameters 224 | params := make(map[string]string) 225 | params["api_key"] = client.key 226 | params["symbol"] = fmt.Sprintf("%s_%s", client.symbol, client.currency) 227 | if otype == "limit" { 228 | params["type"] = action 229 | } else if otype == "market" { 230 | params["type"] = fmt.Sprintf("%s_%s", action, otype) 231 | } 232 | params["price"] = fmt.Sprintf("%f", price) 233 | params["amount"] = fmt.Sprintf("%f", amount) 234 | params["sign"] = client.constructSign(params) 235 | 236 | // Construct request 237 | channel := fmt.Sprintf("ok_spot%s_trade", client.currency) 238 | req := request{Event: "addChannel", Channel: channel, Parameters: params} 239 | 240 | // Write to WebSocket 241 | client.writeOrderMsg <- req 242 | 243 | // Read response 244 | var resp response 245 | select { 246 | case resp = <-client.readOrderMsg: 247 | case <-time.After(3 * time.Second): 248 | return 0, fmt.Errorf("%s SendOrder read timeout", client) 249 | } 250 | 251 | if len(resp) == 0 { 252 | return 0, fmt.Errorf("%s SendOrder bad message", client) 253 | } 254 | 255 | if resp[0].ErrorCode != 0 { 256 | return 0, fmt.Errorf("%s SendOrder error code: %d", client, resp[0].ErrorCode) 257 | } 258 | 259 | // Unmarshal 260 | var orderData struct { 261 | ID int64 `json:"order_id,string"` 262 | Result bool `json:"result,string"` 263 | } 264 | if err := json.Unmarshal(resp[0].Data, &orderData); err != nil { 265 | return 0, fmt.Errorf("%s SendOrder error: %s", client, err) 266 | } 267 | if !orderData.Result { 268 | return 0, fmt.Errorf("%s SendOrder failure", client) 269 | } 270 | 271 | return orderData.ID, nil 272 | } 273 | 274 | // CancelOrder cancels an order on the exchange 275 | func (client *Client) CancelOrder(id int64) (bool, error) { 276 | // Construct parameters 277 | params := make(map[string]string) 278 | params["api_key"] = client.key 279 | params["symbol"] = fmt.Sprintf("%s_%s", client.symbol, client.currency) 280 | params["order_id"] = fmt.Sprintf("%d", id) 281 | params["sign"] = client.constructSign(params) 282 | 283 | // Construct request 284 | channel := fmt.Sprintf("ok_spot%s_cancel_order", client.currency) 285 | req := request{Event: "addChannel", Channel: channel, Parameters: params} 286 | 287 | // Write to WebSocket 288 | client.writeOrderMsg <- req 289 | 290 | // Read response 291 | var resp response 292 | select { 293 | case resp = <-client.readOrderMsg: 294 | case <-time.After(3 * time.Second): 295 | return false, fmt.Errorf("%s CancelOrder read timeout", client) 296 | } 297 | 298 | if len(resp) == 0 { 299 | return false, fmt.Errorf("%s CancelOrder bad message", client) 300 | } 301 | 302 | if resp[0].ErrorCode != 0 { 303 | return false, fmt.Errorf("%s CancelOrder error code: %d", client, resp[0].ErrorCode) 304 | } 305 | 306 | // Unmarshal 307 | var orderData struct { 308 | ID int64 `json:"order_id,string"` 309 | Result bool `json:"result,string"` 310 | } 311 | if err := json.Unmarshal(resp[0].Data, &orderData); err != nil { 312 | return false, fmt.Errorf("%s CancelOrder error: %s", client, err) 313 | } 314 | 315 | return orderData.Result, nil 316 | } 317 | 318 | // GetOrderStatus gets the status of an order on the exchange 319 | func (client *Client) GetOrderStatus(id int64) (exchange.Order, error) { 320 | // Construct parameters 321 | params := make(map[string]string) 322 | params["api_key"] = client.key 323 | params["symbol"] = fmt.Sprintf("%s_%s", client.symbol, client.currency) 324 | params["order_id"] = fmt.Sprintf("%d", id) 325 | params["sign"] = client.constructSign(params) 326 | 327 | // Construct request 328 | channel := fmt.Sprintf("ok_spot%s_order_info", client.currency) 329 | req := request{Event: "addChannel", Channel: channel, Parameters: params} 330 | 331 | // Write to WebSocket 332 | client.writeOrderMsg <- req 333 | 334 | // Create order to be returned 335 | var order exchange.Order 336 | 337 | // Read response 338 | var resp response 339 | select { 340 | case resp = <-client.readOrderMsg: 341 | case <-time.After(3 * time.Second): 342 | return order, fmt.Errorf("%s GetOrderStatus read timeout", client) 343 | } 344 | 345 | if len(resp) == 0 { 346 | return order, fmt.Errorf("%s GetOrderStatus bad message", client) 347 | } 348 | 349 | if resp[0].ErrorCode != 0 { 350 | return order, fmt.Errorf("%s GetOrderStatus error code: %d", client, resp[0].ErrorCode) 351 | } 352 | 353 | // Unmarshal 354 | var orderData struct { 355 | Orders []struct { 356 | Status int `json:"status"` 357 | DealAmount float64 `json:"deal_amount"` 358 | } `json:"orders"` 359 | } 360 | if err := json.Unmarshal(resp[0].Data, &orderData); err != nil { 361 | return order, fmt.Errorf("%s GetOrderStatus error: %s", client, err) 362 | } 363 | 364 | if len(orderData.Orders) == 0 { 365 | return order, fmt.Errorf("%s GetOrderStatus no orders, received: %+v", client, resp) 366 | } 367 | 368 | // Determine order status 369 | if orderData.Orders[0].Status == -1 || orderData.Orders[0].Status == 2 { 370 | order.Status = "dead" 371 | } else if orderData.Orders[0].Status == 4 || orderData.Orders[0].Status == 5 { 372 | order.Status = "" 373 | } else { 374 | order.Status = "live" 375 | } 376 | order.FilledAmount = math.Abs(orderData.Orders[0].DealAmount) 377 | 378 | return order, nil 379 | 380 | } 381 | 382 | // Construct sign for authentication 383 | func (client *Client) constructSign(params map[string]string) string { 384 | // Make url.Values from params 385 | values := url.Values{} 386 | for param, value := range params { 387 | values.Set(param, value) 388 | } 389 | // Add authorization key to url.Values 390 | values.Set("api_key", client.key) 391 | // Prepare string to sign with MD5 392 | stringParams := values.Encode() 393 | // Add the authorization secret to the end 394 | stringParams += fmt.Sprintf("&secret_key=%s", client.secret) 395 | // Sign with MD5 396 | sum := md5.Sum([]byte(stringParams)) 397 | 398 | return strings.ToUpper(fmt.Sprintf("%x", sum)) 399 | } 400 | 401 | // Maintain a WebSocket connection 402 | func (client *Client) maintainWS(initMsg request, writeMsg <-chan request, readMsg chan<- response) { 403 | // Get a WebSocket connection 404 | ws := client.persistentNewWS(initMsg) 405 | 406 | // Syncronize access to *websocket.Conn 407 | receiveWS := make(chan *websocket.Conn) 408 | reconnectWS := make(chan bool) 409 | closeWS := make(chan bool) 410 | go func() { 411 | LOOP: 412 | for { 413 | select { 414 | // Request to use websocket 415 | case receiveWS <- ws: 416 | // Request to reconnect websocket 417 | case <-reconnectWS: 418 | ws.Close() 419 | ws = client.persistentNewWS(initMsg) 420 | // Request to close websocket 421 | case <-closeWS: 422 | ws.Close() 423 | break LOOP 424 | } 425 | } 426 | }() 427 | 428 | // Setup heartbeat 429 | pingInterval := 15 * time.Second 430 | ticker := time.NewTicker(pingInterval) 431 | ping := []byte(`{"event":"ping"}`) 432 | 433 | // Read from connection 434 | go func() { 435 | for { 436 | (<-receiveWS).SetReadDeadline(time.Now().Add(pingInterval + 3*time.Second)) 437 | _, data, err := (<-receiveWS).ReadMessage() 438 | if err != nil { 439 | // Reconnect on error 440 | log.Printf("%s WebSocket error: %s", client, err) 441 | reconnectWS <- true 442 | } else if string(data) != `{"event":"pong"}` { 443 | // Send out if not a pong and a receiver is ready 444 | var resp response 445 | if err := json.Unmarshal(data, &resp); err != nil { 446 | // Send response with error code on unmarshal errors 447 | resp = response{{ErrorCode: -2}} 448 | } 449 | select { 450 | case readMsg <- resp: 451 | default: 452 | // Discard data if a receiver is not ready 453 | } 454 | } 455 | } 456 | }() 457 | 458 | // Manage connection 459 | for { 460 | select { 461 | case <-client.done: 462 | // End if notified 463 | ticker.Stop() 464 | closeWS <- true 465 | return 466 | case <-ticker.C: 467 | // Send ping (true type-9 pings not supported by server) 468 | if err := (<-receiveWS).WriteMessage(1, ping); err != nil { 469 | // Reconnect on error 470 | log.Printf("%s WebSocket error: %s", client, err) 471 | reconnectWS <- true 472 | } 473 | case msg := <-writeMsg: 474 | // Write received message to WebSocket 475 | if err := (<-receiveWS).WriteJSON(msg); err != nil { 476 | // Notify sender and reconnect on error 477 | log.Printf("%s WebSocket error: %s", client, err) 478 | readMsg <- response{{ErrorCode: -1}} 479 | reconnectWS <- true 480 | } 481 | } 482 | } 483 | 484 | } 485 | 486 | // Get a new WebSocket connection subscribed to specified channel 487 | func (client *Client) newWS(initMsg request) (*websocket.Conn, error) { 488 | // Get WebSocket connection 489 | ws, _, err := websocket.DefaultDialer.Dial(client.websocketURL, http.Header{}) 490 | if err != nil { 491 | return nil, err 492 | } 493 | 494 | // Subscribe to channel if specified 495 | if initMsg.Event != "" { 496 | if err = ws.SetWriteDeadline(time.Now().Add(3 * time.Second)); err != nil { 497 | return nil, err 498 | } 499 | if err = ws.WriteJSON(initMsg); err != nil { 500 | return nil, err 501 | } 502 | } 503 | 504 | // Set a zero timeout for future writes 505 | if err = ws.SetWriteDeadline(time.Time{}); err != nil { 506 | return nil, err 507 | } 508 | 509 | log.Println("Successful Connect") 510 | return ws, nil 511 | } 512 | 513 | // Connect WebSocket with repeated tries on failure 514 | func (client *Client) persistentNewWS(initMsg request) *websocket.Conn { 515 | // Try connecting 516 | ws, err := client.newWS(initMsg) 517 | 518 | // Keep trying on error 519 | for err != nil { 520 | log.Printf("%s WebSocket error: %s", client, err) 521 | time.Sleep(1 * time.Second) 522 | ws, err = client.newWS(initMsg) 523 | } 524 | 525 | return ws 526 | } 527 | -------------------------------------------------------------------------------- /okcoin/okcoin_test.go: -------------------------------------------------------------------------------- 1 | package okcoin 2 | 3 | import ( 4 | "bitfx/exchange" 5 | "math" 6 | "os" 7 | "testing" 8 | ) 9 | 10 | var ( 11 | book exchange.Book 12 | client = New(os.Getenv("OKUSD_KEY"), os.Getenv("OKUSD_SECRET"), "ltc", "usd", 1, 0.002, 2, .1) 13 | ) 14 | 15 | // Used for float equality 16 | func notEqual(f1, f2 float64) bool { 17 | if math.Abs(f1-f2) > 0.000001 { 18 | return true 19 | } 20 | return false 21 | } 22 | 23 | func TestPriority(t *testing.T) { 24 | if client.Priority() != 1 { 25 | t.Fatal("Priority should be 1") 26 | } 27 | } 28 | 29 | func TestFee(t *testing.T) { 30 | if notEqual(client.Fee(), 0.002) { 31 | t.Fatal("Fee should be 0.002") 32 | } 33 | } 34 | 35 | func TestUpdatePositon(t *testing.T) { 36 | if notEqual(client.Position(), 0) { 37 | t.Fatal("Should start with zero position") 38 | } 39 | client.SetPosition(10) 40 | if notEqual(client.Position(), 10) { 41 | t.Fatal("Position should have updated to 10") 42 | } 43 | } 44 | 45 | func TestCurrency(t *testing.T) { 46 | if client.Currency() != "usd" { 47 | t.Fatal("Currency should be usd") 48 | } 49 | } 50 | 51 | func TestCurrencyCode(t *testing.T) { 52 | if client.CurrencyCode() != 0 { 53 | t.Fatal("Currency code should be 0") 54 | } 55 | } 56 | 57 | func TestMaxPos(t *testing.T) { 58 | if notEqual(client.MaxPos(), 0) { 59 | t.Fatal("MaxPos should start at 0") 60 | } 61 | client.SetMaxPos(23) 62 | if notEqual(client.MaxPos(), 23) { 63 | t.Fatal("MaxPos should be set to 23") 64 | } 65 | } 66 | 67 | func TestAvailFunds(t *testing.T) { 68 | if notEqual(client.AvailFunds(), 0.1) { 69 | t.Fatal("Available funds should be 0.1") 70 | } 71 | } 72 | 73 | func TestAvailShort(t *testing.T) { 74 | if notEqual(client.AvailShort(), 2) { 75 | t.Fatal("Available short should be 2") 76 | } 77 | } 78 | 79 | func TestHasCryptoFee(t *testing.T) { 80 | if !client.HasCryptoFee() { 81 | t.Fatal("Should have cryptocurrency fee") 82 | } 83 | } 84 | 85 | // ***** Live exchange communication tests ***** 86 | // Slow... skip when not needed 87 | 88 | // USD tesing 89 | 90 | func TestCommunicateBookUSD(t *testing.T) { 91 | bookChan := make(chan exchange.Book) 92 | if book = client.CommunicateBook(bookChan); book.Error != nil { 93 | t.Fatal(book.Error) 94 | } 95 | 96 | book = <-bookChan 97 | t.Logf("Received book data") 98 | // spew.Dump(book) 99 | if len(book.Bids) != 20 || len(book.Asks) != 20 { 100 | t.Fatal("Expected 20 book entries") 101 | } 102 | if book.Bids[0].Price < book.Bids[1].Price { 103 | t.Fatal("Bids not sorted correctly") 104 | } 105 | if book.Asks[0].Price > book.Asks[1].Price { 106 | t.Fatal("Asks not sorted correctly") 107 | } 108 | } 109 | 110 | func TestNewOrderUSD(t *testing.T) { 111 | action := "buy" 112 | otype := "limit" 113 | amount := 0.1 114 | price := book.Bids[0].Price - 0.20 115 | 116 | // Test submitting a new order 117 | id, err := client.SendOrder(action, otype, amount, price) 118 | if err != nil || id == 0 { 119 | t.Fatal(err) 120 | } 121 | t.Logf("Placed a new buy order of 0.1 ltc_usd @ %v limit with ID: %d", price, id) 122 | 123 | // Check status 124 | order, err := client.GetOrderStatus(id) 125 | if err != nil { 126 | t.Fatal(err) 127 | } 128 | if order.Status != "live" { 129 | t.Fatal("Order should be live") 130 | } 131 | t.Logf("Order confirmed live") 132 | if order.FilledAmount != 0 { 133 | t.Fatal("Order should not be filled") 134 | } 135 | t.Logf("Order confirmed unfilled") 136 | 137 | // Test cancelling the order 138 | success, err := client.CancelOrder(id) 139 | if err != nil { 140 | t.Fatal(err) 141 | } 142 | if !success { 143 | t.Fatal("Order not cancelled") 144 | } 145 | t.Logf("Sent cancellation") 146 | 147 | // Check status 148 | tryAgain := true 149 | for tryAgain { 150 | t.Logf("checking status...") 151 | order, err = client.GetOrderStatus(id) 152 | tryAgain = order.Status == "" 153 | } 154 | if err != nil { 155 | t.Fatal(err) 156 | } 157 | if order.Status != "dead" { 158 | t.Fatal("Order should be dead after cancel") 159 | } 160 | t.Logf("Order confirmed dead") 161 | if order.FilledAmount != 0 { 162 | t.Fatal("Order should not be filled") 163 | } 164 | t.Logf("Order confirmed unfilled") 165 | 166 | // Test bad order 167 | id, err = client.SendOrder("kill", otype, amount, price) 168 | if id != 0 { 169 | t.Fatal("Expected id = 0") 170 | } 171 | if err == nil { 172 | t.Fatal("Expected error on bad order") 173 | } 174 | 175 | client.Done() 176 | } 177 | 178 | // CNY tesing 179 | 180 | func TestCurrencyCodeCNY(t *testing.T) { 181 | // Reset global variables 182 | book = exchange.Book{} 183 | client = New(os.Getenv("OKCNY_KEY"), os.Getenv("OKCNY_SECRET"), "ltc", "cny", 1, 0.002, 2, .1) 184 | 185 | if client.CurrencyCode() != 1 { 186 | t.Fatal("Currency code should be 1") 187 | } 188 | } 189 | 190 | func TestCommunicateBookCNY(t *testing.T) { 191 | bookChan := make(chan exchange.Book) 192 | if book = client.CommunicateBook(bookChan); book.Error != nil { 193 | t.Fatal(book.Error) 194 | } 195 | 196 | book = <-bookChan 197 | t.Logf("Received book data") 198 | // spew.Dump(book) 199 | if len(book.Bids) != 20 || len(book.Asks) != 20 { 200 | t.Fatal("Expected 20 book entries") 201 | } 202 | if book.Bids[0].Price < book.Bids[1].Price { 203 | t.Fatal("Bids not sorted correctly") 204 | } 205 | if book.Asks[0].Price > book.Asks[1].Price { 206 | t.Fatal("Asks not sorted correctly") 207 | } 208 | } 209 | 210 | func TestNewOrderCNY(t *testing.T) { 211 | action := "buy" 212 | otype := "limit" 213 | amount := 0.1 214 | price := book.Bids[0].Price - 1 215 | 216 | // Test submitting a new order 217 | id, err := client.SendOrder(action, otype, amount, price) 218 | if err != nil || id == 0 { 219 | t.Fatal(err) 220 | } 221 | t.Logf("Placed a new buy order of 0.1 ltc_cny @ %v limit with ID: %d", price, id) 222 | 223 | // Check status 224 | order, err := client.GetOrderStatus(id) 225 | if err != nil { 226 | t.Fatal(err) 227 | } 228 | if order.Status != "live" { 229 | t.Fatal("Order should be live") 230 | } 231 | t.Logf("Order confirmed live") 232 | if order.FilledAmount != 0 { 233 | t.Fatal("Order should not be filled") 234 | } 235 | t.Logf("Order confirmed unfilled") 236 | 237 | // Test cancelling the order 238 | success, err := client.CancelOrder(id) 239 | if err != nil { 240 | t.Fatal(err) 241 | } 242 | if !success { 243 | t.Fatal("Order not cancelled") 244 | } 245 | t.Logf("Sent cancellation") 246 | 247 | // Check status 248 | tryAgain := true 249 | for tryAgain { 250 | t.Logf("checking status...") 251 | order, err = client.GetOrderStatus(id) 252 | tryAgain = order.Status == "" 253 | } 254 | if err != nil { 255 | t.Fatal(err) 256 | } 257 | if order.Status != "dead" { 258 | t.Fatal("Order should be dead after cancel") 259 | } 260 | t.Logf("Order confirmed dead") 261 | if order.FilledAmount != 0 { 262 | t.Fatal("Order should not be filled") 263 | } 264 | t.Logf("Order confirmed unfilled") 265 | 266 | // Test bad order 267 | id, err = client.SendOrder("kill", otype, amount, price) 268 | if id != 0 { 269 | t.Fatal("Expected id = 0") 270 | } 271 | if err == nil { 272 | t.Fatal("Expected error on bad order") 273 | } 274 | 275 | client.Done() 276 | } 277 | --------------------------------------------------------------------------------