Skip to content

Commit

Permalink
Issue13 candlestick trading strategy (#18)
Browse files Browse the repository at this point in the history
Provides a simple trading automation that uses the candlestick trading strategy. The setup is Heroku-compliant and comes with support for MongoDB

* #13 [INTERIM] Adds a basic candlestick pattern strategy implementation
- Includes a simple test rig as part of the main application

* #13 [INTERIM] - Allows strategies to leave no decision, if needed

* #6 Adds a basic Heroku deployment file with MongoDB support

* #6 Adds a basic Heroku deployment file with MongoDB support

* #13 Keeps trade records after restart. Adds logging

* #13 #16 Add params support to Poloniex's charting API
- supports a starting and ending timestamp for now only
- adds a few extension methods for converting dates to timestamps and timestamp calculations

* #13 Fixes polling time interval

* #13 #16 Adds various fixes related to time calculation

* #11 Provides a price entry for every trade record

* #13 Reverts the direction
- Charting data from Poloniex comes in reverse chronological order

* #13 Adds a unit test for checking the consistency of the Poloniex charting data

* #13 Tests the potential of the candlestick pattern strategy
-- NOTE: Use for manual testing only

* #13 Redesigns basic candlestick pattern recognition
 - Assumes that the current candlestick is the one having a hammer shape, and thus prevents identifying a pattern too late
- Adds a test rig with some real data

* #3: Ignores data dump directory

* #13 Extends the candlestick strategy test, adds a few helpers, and cleanup

* #7 Updates the Kotlin version to 1.1.4
  • Loading branch information
preslavrachev authored Nov 26, 2017
1 parent 5dc94d6 commit eeec586
Show file tree
Hide file tree
Showing 23 changed files with 1,467 additions and 21 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.gradle
/build/
/out/
/data/
!gradle/wrapper/gradle-wrapper.jar

### STS ###
Expand All @@ -23,4 +24,4 @@ build/
nbbuild/
dist/
nbdist/
.nb-gradle/
.nb-gradle/
1 change: 1 addition & 0 deletions Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web: java -Dserver.port=$PORT -Dspring.data.mongodb.uri=$MONGODB_URI $JAVA_OPTS -jar build/libs/*.jar
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
buildscript {
ext {
kotlinVersion = '1.1.3-2'
kotlinVersion = '1.1.4'
springBootVersion = '2.0.0.BUILD-SNAPSHOT'
}
repositories {
Expand Down Expand Up @@ -42,6 +42,7 @@ dependencies {
compile("com.fasterxml.jackson.core:jackson-core:2.8.8")
compile("com.fasterxml.jackson.module:jackson-module-kotlin:2.8.7")
compile("commons-codec:commons-codec:1.10")
compile("org.knowm.xchart:xchart:3.5.0")
compile('org.springframework.boot:spring-boot-starter-actuator')
// compile('org.springframework.boot:spring-boot-starter-data-jpa')
compile('org.springframework.boot:spring-boot-starter-data-mongodb')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,88 @@
package com.preslavrachev.cryptotrader

import com.preslavrachev.cryptotrader.extension.minusSecondPeriods
import com.preslavrachev.cryptotrader.extension.toCandlestick
import com.preslavrachev.cryptotrader.extension.toUnixTimestamp
import com.preslavrachev.cryptotrader.mvc.model.OrderTypeEnum
import com.preslavrachev.cryptotrader.mvc.model.TradeRecord
import com.preslavrachev.cryptotrader.persistence.repository.TradeRecordRepository
import com.preslavrachev.cryptotrader.session.AppSession
import com.preslavrachev.cryptotrader.trading.instrument.timeline.TimelineNode
import com.preslavrachev.cryptotrader.trading.strategy.TradingStrategyDecisionEnum
import com.preslavrachev.cryptotrader.trading.strategy.impl.CandlestickPatternTradingStrategy
import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.scheduling.annotation.Scheduled
import remote.poloniex.model.ChartDataEntry
import remote.poloniex.service.PoloniexApiService
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.logging.Logger
import javax.inject.Inject

fun ChartDataEntry.calculateLocalDateTime(): LocalDateTime {
return LocalDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneId.systemDefault())
}

@SpringBootApplication
class CryptotraderApplication
class CryptotraderApplication {

companion object {
val LOGGER = Logger.getLogger(CryptotraderApplication::class.simpleName)
}

@Inject
lateinit var session: AppSession

@Inject
lateinit var tradeRecordRepository: TradeRecordRepository

@Inject
lateinit var poloniexApiService: PoloniexApiService

@Scheduled(fixedRate = 5 * 60 * 1000)
fun testStrategy() {
val end = LocalDateTime.now().toUnixTimestamp()
val start = end.minusSecondPeriods(5, 300)
val chartData = poloniexApiService.getChartData(start = start, end = end)
val leftSide = chartData.dropLast(1)
val rightSide = chartData.drop(1)
val lastPair = leftSide.zip(rightSide)
.map { (current, prev) ->
listOf(
TimelineNode(prev.calculateLocalDateTime(), prev.toCandlestick()),
TimelineNode(current.calculateLocalDateTime(), current.toCandlestick())
)
}
.first()

val strategy = CandlestickPatternTradingStrategy()
val decision = strategy.decide(lastPair)

LOGGER.info("Candlestick Pattern Strategy Decision: $decision -- time period $start/$end")

if (decision == TradingStrategyDecisionEnum.BUY) {
val trade = TradeRecord(
amount = 0.00001f,
quoteCurrency = "BTC",
baseCurrency = "USDT",
baseCurrencyPrice = lastPair[0].content.estimateBuyingPrice(),
orderType = OrderTypeEnum.BUY
)
tradeRecordRepository.save(trade)
} else if (decision == TradingStrategyDecisionEnum.SELL) {
val trade = TradeRecord(
amount = 0.00001f,
quoteCurrency = "BTC",
baseCurrency = "USDT",
baseCurrencyPrice = lastPair[0].content.estimateSellingPrice(),
orderType = OrderTypeEnum.SELL
)
tradeRecordRepository.save(trade)
}
}
}

fun main(args: Array<String>) {
SpringApplication.run(CryptotraderApplication::class.java, *args)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.preslavrachev.cryptotrader.extension

import java.time.LocalDateTime

fun LocalDateTime.toUnixTimestamp(): Long {
return this.toEpochSecond(java.time.ZoneOffset.ofHours(2))
}

fun Long.minusSecondPeriods(periods: Long, secondsInPeriod: Long): Long {
val timePeriod = periods * secondsInPeriod
assert(timePeriod <= this) { "The time period cannot exceed the current time!" }

return this - timePeriod
}
Original file line number Diff line number Diff line change
@@ -1,27 +1,43 @@
package com.preslavrachev.cryptotrader.mvc.controller
import com.preslavrachev.cryptotrader.extension.minusSecondPeriods
import com.preslavrachev.cryptotrader.extension.toUnixTimestamp
import com.preslavrachev.cryptotrader.mvc.model.Order
import com.preslavrachev.cryptotrader.session.AppSession
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestMethod
import org.springframework.web.bind.annotation.ResponseBody
import remote.poloniex.service.PoloniexApiService
import java.time.LocalDateTime
import javax.inject.Inject

@Controller
@RequestMapping("/")
class DashboardController {

@Inject
lateinit var session:AppSession

@Inject
lateinit var poloniexApiService: PoloniexApiService

@RequestMapping("hello", method = arrayOf(RequestMethod.GET))
@ResponseBody
fun hello(): PoloniexApiService.ChartDataEntryList {
return poloniexApiService.getChartData()
val end = LocalDateTime.now().toUnixTimestamp()
val start = end.minusSecondPeriods(100, 300)
return poloniexApiService.getChartData(start = start, end = end)
}

@RequestMapping("balances", method = arrayOf(RequestMethod.GET))
@ResponseBody
fun returnBalances(): Any {
return poloniexApiService.returnBalances()
}

@RequestMapping("orders", method = arrayOf(RequestMethod.GET))
@ResponseBody
fun orders(): List<Order> {
return session.orders
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ data class TradeRecord(
val amount: Float, /* always in relation to the quote currency */
val quoteCurrency: String, /* e.g. BTC in BTC/USDT */
val baseCurrency: String, /* e.g. USDT in BTC/USDT */
val baseCurrencyPrice: Float,
val executionDateTime: LocalDateTime = LocalDateTime.now(),
val orderType: OrderTypeEnum,
val orderScope: OrderScopeEnum = OrderScopeEnum.DEMO
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.preslavrachev.cryptotrader.session

class PackageMarker
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,12 @@ data class Candlestick(
fun isBodyTop(): Boolean {
return bodyMidpoint() >= (MAX_HEIGHT_RATIO * shadowHeight())
}

fun estimateBuyingPrice(): Float {
return Math.min(open, close)
}

fun estimateSellingPrice(): Float {
return Math.max(open, close)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,24 @@ package com.preslavrachev.cryptotrader.trading.instrument.candlestick
enum class CandlestickPatternEnum(
val evaluator: (prev: Candlestick, current:Candlestick) -> Boolean
) {
HAMMER ({prev, current -> isHammerShape(prev) && prev.isBodyTop() && current.isGreen() && !current.isDoji()}),
INVERTED_HAMMER ({prev, current -> isHammerShape(prev) && prev.isBodyBottom() && current.isGreen() && !current.isDoji()}),
SHOOTING_STAR ({prev, current -> isHammerShape(prev) && prev.isBodyBottom() && current.isRed() && !current.isDoji()}),
HANGING_MAN ({prev, current -> isHammerShape(prev) && prev.isBodyTop() && current.isRed() && !current.isDoji()}),
// @formatter:off
HAMMER ({prev, current -> isHammerShape(current) && current.isBodyTop() && prev.isRed() && !isHammerShape(prev) }),
INVERTED_HAMMER ({prev, current -> isHammerShape(current) && current.isBodyBottom() && prev.isRed() && !isHammerShape(prev)}),
SHOOTING_STAR ({prev, current -> isHammerShape(current) && current.isBodyBottom() && prev.isGreen() && !isHammerShape(prev)}),
HANGING_MAN ({prev, current -> isHammerShape(current) && current.isBodyTop() && prev.isGreen() && !isHammerShape(prev)}),
;
// @formatter:on

companion object {
private val BODY_SHADOW_MAX_HAMMER_RATIO = 0.6

fun evaluate(prev: Candlestick, current: Candlestick): List<CandlestickPatternEnum> {
return CandlestickPatternEnum.values()
.filter { it.evaluator(prev, current) }
}

fun isHammerShape(candlestick: Candlestick): Boolean {
return candlestick.bodyHeight() <= Candlestick.ONE_HALF * candlestick.shadowHeight()
return candlestick.bodyHeight() <= BODY_SHADOW_MAX_HAMMER_RATIO * candlestick.shadowHeight()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.preslavrachev.cryptotrader.trading.instrument.timeline

import java.time.LocalDateTime

data class TimelineNode<out T>(
val time: LocalDateTime,
val content: T
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.preslavrachev.cryptotrader.trading.strategy

import com.preslavrachev.cryptotrader.trading.instrument.timeline.TimelineNode

interface TradingStrategy<in T> {
fun decide(input: List<TimelineNode<T>>): TradingStrategyDecisionEnum
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.preslavrachev.cryptotrader.trading.strategy

enum class TradingStrategyDecisionEnum {
BUY,
SELL,
NO_DECISION
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.preslavrachev.cryptotrader.trading.strategy.impl

import com.preslavrachev.cryptotrader.trading.instrument.candlestick.Candlestick
import com.preslavrachev.cryptotrader.trading.instrument.candlestick.CandlestickPatternEnum
import com.preslavrachev.cryptotrader.trading.instrument.timeline.TimelineNode
import com.preslavrachev.cryptotrader.trading.strategy.TradingStrategy
import com.preslavrachev.cryptotrader.trading.strategy.TradingStrategyDecisionEnum
import java.util.*
import java.util.logging.Logger

class CandlestickPatternTradingStrategy : TradingStrategy<Candlestick> {

companion object {
val LOGGER = Logger.getLogger(CandlestickPatternTradingStrategy::class.simpleName)

// TODO: move this knowledge over to the candlestick patterns enum
val BOOLISH_PATTERNS = EnumSet.of(CandlestickPatternEnum.HAMMER, CandlestickPatternEnum.INVERTED_HAMMER)
val BEARISH_PATTERNS = EnumSet.of(CandlestickPatternEnum.SHOOTING_STAR, CandlestickPatternEnum.HANGING_MAN)
}

override fun decide(input: List<TimelineNode<Candlestick>>): TradingStrategyDecisionEnum {
assert(input.size >= 2) { LOGGER.warning("The input must have at least two data points!") }

val prev = input[input.lastIndex - 1]
val current = input[input.lastIndex]

val pattern = CandlestickPatternEnum.evaluate(prev.content, current.content)

if (pattern.any { BOOLISH_PATTERNS.contains(it) }) {
return TradingStrategyDecisionEnum.BUY
} else if (pattern.any { BEARISH_PATTERNS.contains(it) }) {
return TradingStrategyDecisionEnum.SELL
} else {
return TradingStrategyDecisionEnum.NO_DECISION
}
}
}
13 changes: 8 additions & 5 deletions src/main/kotlin/remote/poloniex/service/PoloniexApiService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ class PoloniexApiService {
class ChartDataEntryList: ArrayList<ChartDataEntry>()

enum class CurrencyPairEnum(val label: String) {
USDT_BTC ("USDT_BTC")
USDT_BTC ("USDT_BTC"),
BTC_ETH ("BTC_ETH"),
BTC_XRP ("BTC_XRP"),
BTC_DASH ("BTC_DASH"),
}

enum class CommandEnum(val label: String) {
Expand All @@ -49,13 +52,13 @@ class PoloniexApiService {
@Inject
lateinit var restTemplate: RestTemplate

fun getChartData(): ChartDataEntryList {
fun getChartData(start: Long, end: Long): ChartDataEntryList {
val url = PUBLIC_URL_BUILDER
.queryParam(COMMAND_PARAM, CommandEnum.RETURN_CHART_DATA.label)
.queryParam(CURRENCY_PAIR_PARAM, CurrencyPairEnum.USDT_BTC)
.queryParam(PERIOD_PARAM, 300)
.queryParam(START_PARAM, 1500000000)
.queryParam(END_PARAM, 1500030000)
.queryParam(START_PARAM, start)
.queryParam(END_PARAM, end)
.build()

val response = restTemplate.getForEntity(url, ChartDataEntryList::class.java)
Expand Down Expand Up @@ -91,4 +94,4 @@ class PoloniexApiService {
.joinToString(separator = "&") { entry -> "${entry.key}=${entry.value}" }
.encrypt512(apiSecret)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.preslavrachev.cryptotrader.extension

fun <T> List<T>.toPairs(): List<Pair<T,T>> {
val leftSide = this.dropLast(1)
val rightSide = this.drop(1)
return leftSide.zip(rightSide)
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package com.preslavrachev.cryptotrader.instrument

import com.preslavrachev.cryptotrader.config.MainAppConfig
import com.preslavrachev.cryptotrader.extension.minusSecondPeriods
import com.preslavrachev.cryptotrader.extension.toCandlestick
import com.preslavrachev.cryptotrader.extension.toUnixTimestamp
import com.preslavrachev.cryptotrader.trading.instrument.candlestick.CandlestickPatternEnum
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.junit4.SpringRunner
import remote.poloniex.service.PoloniexApiService
import java.time.LocalDateTime
import javax.inject.Inject


Expand All @@ -22,9 +25,11 @@ class CandlestickITest {
@Ignore("Use only manually for now")
@Test()
fun testCandlestickPatterns(): Unit {

val leftSide = poloniexApiService.getChartData().dropLast(1)
val rightSide = poloniexApiService.getChartData().drop(1)
val end = LocalDateTime.now().toUnixTimestamp()
val start = end.minusSecondPeriods(30, 300)
val chartData = poloniexApiService.getChartData(start = start, end = end)
val leftSide = chartData.dropLast(1)
val rightSide = chartData.drop(1)
val data = leftSide.zip(rightSide)
.map { (prev, current) -> Pair(prev.toCandlestick(), current.toCandlestick()) }
.map { (prev, current) -> CandlestickPatternEnum.evaluate(prev, current) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class OrderListProcessorTest {
fun setUp() {
orderListProcessor = OrderListProcessor()
orderListProcessor.tradingApi = DemoTradingService()
//orderListProcessor.postConstruct()
orderListProcessor.postConstruct()
}

@Test
Expand Down
Loading

0 comments on commit eeec586

Please sign in to comment.