├── project ├── build.properties └── assembly.sbt ├── .idea ├── vcs.xml ├── kotlinc.xml ├── misc.xml ├── hydra.xml ├── scala_compiler.xml ├── modules.xml └── sbt.xml ├── src ├── main │ └── scala │ │ └── me │ │ └── mbcu │ │ └── integrated │ │ └── mmm │ │ ├── ops │ │ ├── ddex │ │ │ ├── DdexActor.scala │ │ │ └── Ddex.scala │ │ ├── common │ │ │ ├── AbsOpActor.scala │ │ │ ├── BotCache.scala │ │ │ ├── AbsExchange.scala │ │ │ ├── AbsRestRequest.scala │ │ │ ├── AbsWsParser.scala │ │ │ ├── Offer.scala │ │ │ ├── AbsRestActor.scala │ │ │ ├── Config.scala │ │ │ └── AbsOrder.scala │ │ ├── hitbtc │ │ │ ├── HitbtcResponse.scala │ │ │ ├── Hitbtc.scala │ │ │ ├── HitbtcRequest.scala │ │ │ └── HitbtcParser.scala │ │ ├── fcoin │ │ │ ├── Fcoin.scala │ │ │ ├── FcoinRequest.scala │ │ │ └── FcoinActor.scala │ │ ├── btcalpha │ │ │ ├── Btcalpha.scala │ │ │ ├── BtcalphaRequest.scala │ │ │ └── BtcalphaActor.scala │ │ ├── livecoin │ │ │ ├── Livecoin.scala │ │ │ ├── LivecoinRequest.scala │ │ │ └── LivecoinActor.scala │ │ ├── yobit │ │ │ ├── Sign.scala │ │ │ ├── Yobit.scala │ │ │ └── YobitRequest.scala │ │ ├── okex │ │ │ ├── OkexRest.scala │ │ │ ├── OkexParameters.scala │ │ │ ├── OkexRestActor.scala │ │ │ └── OkexRestRequest.scala │ │ └── Definitions.scala │ │ ├── utils │ │ └── MyUtils.scala │ │ ├── Application.scala │ │ ├── actors │ │ ├── OpWsActor.scala │ │ ├── OpRestActor.scala │ │ ├── SesActor.scala │ │ ├── OpGIActor.scala │ │ ├── FileActor.scala │ │ ├── BaseActor.scala │ │ ├── OpDdexActor.scala │ │ ├── OrderRestActor.scala │ │ ├── WsActor.scala │ │ ├── OrderWsActor.scala │ │ └── OrderGIActor.scala │ │ └── sequences │ │ └── Strategy.scala └── test │ └── scala │ └── me │ └── mbcu │ └── integrated │ └── mmm │ ├── ops │ ├── yobit │ │ └── YobitRequestTest.scala │ ├── hitbtc │ │ └── HitbtcRequestTest.scala │ ├── common │ │ ├── OfferTest.scala │ │ └── AbsOrderTest.scala │ ├── okex │ │ └── OkexRestActorTest.scala │ ├── fcoin │ │ └── FcoinActorTest.scala │ └── livecoin │ │ └── LivecoinActorTest.scala │ ├── utils │ └── MyUtilsTest.scala │ └── actors │ └── OrderWsActorTest.scala ├── config-template ├── .gitignore ├── EXCHANGES.md ├── README.md ├── VERSIONS.md └── OPERATION.md /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version = 1.1.6 -------------------------------------------------------------------------------- /project/assembly.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.6") -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/ddex/DdexActor.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.ddex 2 | 3 | import akka.actor.Actor 4 | 5 | class DdexActor extends Actor { 6 | override def receive: Receive = ??? 7 | } 8 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/common/AbsOpActor.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.common 2 | 3 | import akka.actor.{Actor, ActorRef} 4 | 5 | abstract case class AbsOpActor(exchangeDef: AbsExchange, bots: Seq[Bot], fileActor: ActorRef) extends Actor 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/utils/MyUtils.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.utils 2 | 3 | 4 | import scala.util.{Failure, Success, Try} 5 | 6 | object MyUtils { 7 | 8 | def parseToBigDecimal(s: String): Option[BigDecimal] = { 9 | Try(BigDecimal(s)) match { 10 | case Success(r) => Some(r) 11 | case Failure(e) => None 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.idea/hydra.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/hitbtc/HitbtcResponse.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.hitbtc 2 | 3 | import play.api.libs.json.{Json, OFormat} 4 | 5 | object RPCError { 6 | implicit val jsonFormat: OFormat[RPCError] = Json.format[RPCError] 7 | } 8 | case class RPCError (code : Int, message : Option[String], description : Option[String]) 9 | 10 | class HitbtcResponse { 11 | 12 | } 13 | -------------------------------------------------------------------------------- /.idea/scala_compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | -------------------------------------------------------------------------------- /src/test/scala/me/mbcu/integrated/mmm/ops/yobit/YobitRequestTest.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.yobit 2 | 3 | import org.scalatest.FunSuite 4 | 5 | class YobitRequestTest extends FunSuite{ 6 | val secret = "sknfskfw3904239sdf233242" 7 | 8 | test ("test Yobit sha512HMAC sign has to be in lowercase") { 9 | val test = YobitRequest.ownTrades(secret, "trx_eth") 10 | assert(test.sign.contains("a")) 11 | assert(!test.sign.contains("A")) 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/test/scala/me/mbcu/integrated/mmm/utils/MyUtilsTest.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.utils 2 | 3 | import org.scalatest.FunSuite 4 | 5 | import scala.collection.mutable.ListBuffer 6 | 7 | class MyUtilsTest extends FunSuite { 8 | 9 | 10 | test("test sorting shutdown code") { 11 | val a = new ListBuffer[(String, Option[Int])] 12 | // a += (("a", Some(1))) 13 | // a += (("b", Some(1))) 14 | a += (("c", Some(-1))) 15 | a += (("d", None)) 16 | a += (("d", None)) 17 | 18 | val z = a.flatMap(_._2).reduceOption(_ min _) 19 | assert(z === Some(-1)) 20 | 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/common/BotCache.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.common 2 | 3 | import play.api.libs.json._ 4 | 5 | object BotCache { 6 | def apply(latestCounterId: Option[String]): BotCache = { 7 | latestCounterId match { 8 | case Some(v) => BotCache(v) 9 | case _ => BotCache(defaultLastCounteredId) 10 | } 11 | } 12 | 13 | implicit val jsonFormat: OFormat[BotCache] = Json.format[BotCache] 14 | 15 | val defaultLastCounteredId: String = "-1" 16 | val default: BotCache = BotCache(defaultLastCounteredId) 17 | } 18 | 19 | 20 | case class BotCache(lastCounteredId: String) 21 | -------------------------------------------------------------------------------- /.idea/sbt.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/common/AbsExchange.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.common 2 | 3 | import akka.actor.{ActorRef, Props} 4 | import me.mbcu.integrated.mmm.ops.Definitions.Exchange.Exchange 5 | import me.mbcu.integrated.mmm.ops.Definitions.Op.Protocol 6 | 7 | abstract class AbsExchange { 8 | def name: Exchange 9 | 10 | def protocol : Protocol 11 | 12 | def endpoint : String 13 | 14 | def intervalMillis : Int 15 | 16 | def getActorRefProps: Props 17 | 18 | 19 | } 20 | 21 | trait AbsWsExchange { 22 | def getParser(op: ActorRef): AbsWsParser 23 | 24 | def getRequest: AbsWsRequest 25 | 26 | def orderId(offer: Offer): String 27 | } 28 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/ddex/Ddex.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.ddex 2 | 3 | import akka.actor.Props 4 | import me.mbcu.integrated.mmm.ops.Definitions.{Exchange, Op} 5 | import me.mbcu.integrated.mmm.ops.Definitions.Exchange.Exchange 6 | import me.mbcu.integrated.mmm.ops.Definitions.Op.Protocol 7 | import me.mbcu.integrated.mmm.ops.common.AbsExchange 8 | 9 | object Ddex extends AbsExchange { 10 | override def name: Exchange = Exchange.ddex 11 | 12 | override def protocol: Protocol = Op.ddex 13 | 14 | override def endpoint: String = "https://api.ddex.io/v2/%s" 15 | 16 | override def intervalMillis: Int = 10 17 | 18 | override def getActorRefProps: Props = Props(new DdexActor()) 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/fcoin/Fcoin.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.fcoin 2 | 3 | import akka.actor.Props 4 | import me.mbcu.integrated.mmm.ops.Definitions.Exchange.Exchange 5 | import me.mbcu.integrated.mmm.ops.Definitions.{Exchange, Op} 6 | import me.mbcu.integrated.mmm.ops.Definitions.Op.Protocol 7 | import me.mbcu.integrated.mmm.ops.common.{AbsExchange, Bot} 8 | 9 | object Fcoin extends AbsExchange { 10 | 11 | override val name: Exchange = Exchange.fcoin 12 | 13 | override val protocol: Protocol = Op.rest 14 | 15 | override val endpoint: String = "https://api.fcoin.com/v2/%s" 16 | 17 | override def intervalMillis: Int = 800 18 | 19 | override def getActorRefProps: Props = Props(new FcoinActor()) 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/btcalpha/Btcalpha.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.btcalpha 2 | 3 | import akka.actor.Props 4 | import me.mbcu.integrated.mmm.ops.Definitions.Exchange.Exchange 5 | import me.mbcu.integrated.mmm.ops.Definitions.Op.Protocol 6 | import me.mbcu.integrated.mmm.ops.Definitions.{Exchange, Op} 7 | import me.mbcu.integrated.mmm.ops.common.AbsExchange 8 | 9 | object Btcalpha extends AbsExchange { 10 | 11 | override val name: Exchange = Exchange.btcalpha 12 | 13 | override val protocol: Protocol = Op.restgi 14 | 15 | override val endpoint: String = "https://btc-alpha.com/api/%s" 16 | 17 | override def intervalMillis: Int = 1000 18 | 19 | override def getActorRefProps: Props = Props(new BtcalphaActor()) 20 | 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/livecoin/Livecoin.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.livecoin 2 | 3 | import akka.actor.Props 4 | import me.mbcu.integrated.mmm.ops.Definitions.Exchange.Exchange 5 | import me.mbcu.integrated.mmm.ops.Definitions.{Exchange, Op} 6 | import me.mbcu.integrated.mmm.ops.Definitions.Op.Protocol 7 | import me.mbcu.integrated.mmm.ops.common.AbsExchange 8 | import me.mbcu.integrated.mmm.ops.fcoin.FcoinActor 9 | 10 | object Livecoin extends AbsExchange{ 11 | 12 | override val name: Exchange = Exchange.livecoin 13 | 14 | override val protocol: Protocol = Op.rest 15 | 16 | override val endpoint: String = "https://api.livecoin.net/%s" 17 | 18 | override def intervalMillis: Int = 900 19 | 20 | override def getActorRefProps: Props = Props(new LivecoinActor()) 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/yobit/Sign.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.yobit 2 | 3 | import me.mbcu.integrated.mmm.ops.common.Side 4 | 5 | object Sign extends App { 6 | 7 | 8 | val test = YobitRequest.newOrder(args(0), "trx_eth", Side.buy, BigDecimal("0.00008137"), BigDecimal("10")) 9 | println(test.sign) 10 | println(test.params) 11 | 12 | val test2 = YobitRequest.ownTrades(args(0), "trx_eth") 13 | println(test2.sign) 14 | println(test2.params) 15 | 16 | val test3 =YobitRequest.infoOrder(args(0), "106374985614339") 17 | println(test3.sign) 18 | println(test3.params) 19 | 20 | val test4 = YobitRequest.cancelOrder(args(0), "106374965614339") 21 | println(test4.sign) 22 | println(test4.params) 23 | 24 | val test5 = YobitRequest.activeOrders(args(0), "trx_eth") 25 | println(test5.sign) 26 | println(test5.params) 27 | } 28 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/Application.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm 2 | 3 | import java.util.TimeZone 4 | 5 | import akka.actor.{ActorSystem, Props} 6 | import akka.stream.ActorMaterializer 7 | import me.mbcu.integrated.mmm.actors.BaseActor 8 | import me.mbcu.scala.{MyLogging, MyLoggingSingle} 9 | 10 | object Application extends App with MyLogging { 11 | implicit val system: ActorSystem = akka.actor.ActorSystem("mmm") 12 | implicit val materializer: ActorMaterializer = akka.stream.ActorMaterializer() 13 | 14 | if (args.length != 3){ 15 | println("Requires two arguments : ") 16 | System.exit(-1) 17 | } 18 | MyLoggingSingle.init(args(1), TimeZone.getTimeZone("Asia/Tokyo")) 19 | val mainActor = system.actorOf(Props(new BaseActor(args(0), args(2))), name = "main") 20 | mainActor ! "start" 21 | } 22 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/yobit/Yobit.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.yobit 2 | 3 | import akka.actor.Props 4 | import me.mbcu.integrated.mmm.ops.Definitions.Exchange.Exchange 5 | import me.mbcu.integrated.mmm.ops.Definitions.Op.Protocol 6 | import me.mbcu.integrated.mmm.ops.Definitions.{Exchange, Op} 7 | import me.mbcu.integrated.mmm.ops.common.{AbsExchange, Bot} 8 | 9 | object Yobit extends AbsExchange { 10 | 11 | override val name: Exchange = Exchange.yobit 12 | 13 | override val protocol: Protocol = Op.restgi 14 | 15 | override val endpoint: String = "https://yobit.io/api/3/%s/%s" 16 | 17 | override val intervalMillis: Int = 2000 18 | 19 | val endpointTrade: String = "https://yobit.io/tapi/" 20 | 21 | override def getActorRefProps: Props = Props(new YobitActor()) 22 | 23 | val nonceFactor : Long = 1530165626l // some random ts June 28 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/okex/OkexRest.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.okex 2 | 3 | import akka.actor.Props 4 | import me.mbcu.integrated.mmm.ops.Definitions.{Exchange, Op} 5 | import me.mbcu.integrated.mmm.ops.common.{AbsExchange, Bot} 6 | 7 | object OkexRest extends AbsExchange { 8 | 9 | val name = Exchange.okexRest 10 | val protocol = Op.rest 11 | val endpoint = "https://www.okex.com/api/v1" 12 | override val intervalMillis: Int = 500 13 | 14 | override def getActorRefProps: Props = Props(new OkexRestActor()) 15 | 16 | val OKEX_ERRORS = Map( 17 | 1002 -> "The transaction amount exceed the balance", 18 | 1003 -> "The transaction amount is less than the minimum", 19 | 20100 -> "request time out", 20 | 10005 -> "'SecretKey' does not exist. WARNING: This error can be caused by server error", 21 | 10007 -> "Signature does not match. WARNING: This error can be caused by server error", 22 | 10009 -> "Order does not exist", 23 | 10010 -> "Insufficient funds", 24 | 10011 -> "Amount too low", 25 | 10014 -> "Order price must be between 0 and 1,000,000", 26 | 10016 -> "Insufficient coins balance", 27 | 10024 -> "balance not sufficient", 28 | -1000 -> "Server returns html garbage, most likely Cloudflare / SSL issues" 29 | ) 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/hitbtc/Hitbtc.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.hitbtc 2 | 3 | import akka.actor.{ActorRef, Props} 4 | import me.mbcu.integrated.mmm.actors.WsActor.SendJs 5 | import me.mbcu.integrated.mmm.ops.Definitions.Exchange.Exchange 6 | import me.mbcu.integrated.mmm.ops.Definitions.Op.Protocol 7 | import me.mbcu.integrated.mmm.ops.Definitions.{Exchange, Op} 8 | import me.mbcu.integrated.mmm.ops.common.AbsWsParser.SendWs 9 | import me.mbcu.integrated.mmm.ops.common._ 10 | import me.mbcu.integrated.mmm.ops.yobit.YobitActor 11 | import play.api.libs.json.{JsValue, Json} 12 | 13 | object Hitbtc extends AbsExchange with AbsWsExchange { 14 | 15 | override val name: Exchange = Exchange.hitbtc 16 | 17 | override val protocol: Protocol = Op.ws 18 | 19 | override val endpoint: String = "wss://api.hitbtc.com/api/2/ws" 20 | 21 | override val intervalMillis: Int = 10 22 | 23 | override def getActorRefProps: Props = Props.empty 24 | 25 | // override def login(credentials: Credentials): SendWs = HitbtcRequest.login(credentials) 26 | 27 | override def getParser(op: ActorRef): AbsWsParser = new HitbtcParser(op) 28 | 29 | override val getRequest: AbsWsRequest = HitbtcRequest 30 | 31 | override def orderId(offer: Offer): String = HitbtcRequest.orderId(offer) 32 | 33 | // override def subscribe: SendWs = HitbtcRequest.subscribe 34 | 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/test/scala/me/mbcu/integrated/mmm/ops/hitbtc/HitbtcRequestTest.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.hitbtc 2 | 3 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor.As 4 | import me.mbcu.integrated.mmm.ops.common.{Offer, Side} 5 | import org.scalatest.FunSuite 6 | import play.api.libs.json.Json 7 | 8 | class HitbtcRequestTest extends FunSuite{ 9 | 10 | test("cancel order") { 11 | val js = HitbtcRequest.cancelOrder("aaa", As.Trim).jsValue 12 | val res = Json.parse(js.toString()) 13 | assert((res \ "params" \ "clientOrderId").as[String] === "aaa") 14 | assert((res \ "id").as[String] === "cancelOrder.aaa") 15 | } 16 | 17 | test("clientOrderId length") { 18 | 19 | val pair1 = "PREMINEPREMINE" 20 | 21 | val o1 = Offer.newOffer(pair1, Side.sell, BigDecimal(100), BigDecimal(100)) 22 | val res1= HitbtcRequest.orderId(o1) 23 | assert(res1.length === 32) 24 | 25 | val pair2 = "NOAHETH" 26 | val o2 = Offer.newOffer(pair2, Side.sell, BigDecimal(100), BigDecimal(100)) 27 | 28 | val res2= HitbtcRequest.orderId(o2) 29 | assert(res2.length === 32) 30 | } 31 | 32 | test ("clientOrderId from id") { 33 | val pair = "NOAHETH" 34 | val method = "newOrder" 35 | val o = Offer.newOffer(pair, Side.sell, BigDecimal(100), BigDecimal(100)) 36 | 37 | val clientOrderId = HitbtcRequest.orderId(o) 38 | val id = HitbtcRequest.requestId(clientOrderId, method) 39 | val res = HitbtcRequest.clientOrderIdFrom(id) 40 | assert(res === clientOrderId) 41 | } 42 | 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/test/scala/me/mbcu/integrated/mmm/actors/OrderWsActorTest.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.actors 2 | 3 | import me.mbcu.integrated.mmm.ops.common.{Offer, Side, Status} 4 | import org.scalatest.FunSuite 5 | 6 | class OrderWsActorTest extends FunSuite{ 7 | 8 | 9 | test(" match two maps for missing id"){ 10 | val o = Seq( 11 | Offer("aaa", "XRPUSD", Side.sell, Status.active, 10L, None, BigDecimal(10), BigDecimal(1), None), 12 | Offer("bbb", "XRPUSD", Side.sell, Status.active, 10L, None, BigDecimal(10), BigDecimal(1), None), 13 | Offer("ccc", "XRPUSD", Side.sell, Status.active, 10L, None, BigDecimal(10), BigDecimal(1), None), 14 | Offer("ddd", "XRPUSD", Side.sell, Status.active, 10L, None, BigDecimal(10), BigDecimal(1), None), 15 | Offer("eee", "XRPUSD", Side.buy, Status.active, 10L, None, BigDecimal(10), BigDecimal(1), None), 16 | Offer("fff", "XRPUSD", Side.buy, Status.active, 10L, None, BigDecimal(10), BigDecimal(1), None), 17 | Offer("ggg", "XRPUSD", Side.buy, Status.active, 10L, None, BigDecimal(10), BigDecimal(1), None), 18 | Offer("hhh", "XRPUSD", Side.buy, Status.active, 10L, None, BigDecimal(10), BigDecimal(1), None) 19 | ) 20 | 21 | val p = Seq( 22 | Offer("ccc", "XRPUSD", Side.sell, Status.active, 10L, None, BigDecimal(10), BigDecimal(1), None), 23 | Offer("ddd", "XRPUSD", Side.sell, Status.active, 10L, None, BigDecimal(10), BigDecimal(1), None) 24 | ) 25 | 26 | val x = o.map(a => a.id -> a).toMap 27 | val y = p.map(a => a.id -> a).toMap 28 | val res = x.collect{case a @ (_: String, _: Offer) if !y.contains(a._1) => a._2}.map(_.id).toSet 29 | assert(!res.contains("ccc")) 30 | assert(!res.contains("ddd")) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/common/AbsRestRequest.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.common 2 | 3 | import java.math.BigInteger 4 | import java.net.URLEncoder 5 | import java.nio.charset.StandardCharsets 6 | import java.security.MessageDigest 7 | 8 | import javax.crypto.Mac 9 | import javax.crypto.spec.SecretKeySpec 10 | 11 | abstract class AbsRestRequest { 12 | 13 | private val md5: MessageDigest = MessageDigest.getInstance("MD5") 14 | 15 | def md5(secretKey: String, data: String): String = { 16 | import java.nio.charset.StandardCharsets 17 | md5.update(StandardCharsets.UTF_8.encode(data)) 18 | String.format("%032x", new BigInteger(1, md5.digest)).toUpperCase 19 | } 20 | 21 | def signHmacSHA512(secret: String, data: String): Array[Byte] = signHmac(secret, data, "HmacSHA512") 22 | 23 | def signHmac(secret: String, data: String, fun: String): Array[Byte] = { 24 | val signedSecret = new SecretKeySpec(secret.getBytes("UTF-8"), fun) 25 | val mac = Mac.getInstance(fun) 26 | mac.init(signedSecret) 27 | mac.doFinal(data.getBytes("UTF-8")) 28 | } 29 | 30 | def toHex(bytes: Array[Byte], isCapital: Boolean = true): String = { 31 | val x = if (isCapital) "X" else "x" 32 | val bi = new BigInteger(1, bytes) 33 | String.format("%0" + (bytes.length << 1) + x, bi) 34 | } 35 | 36 | def signHmacSHA1(secret: String, data: String): Array[Byte] = signHmac(secret, data, "HmacSHA1") 37 | 38 | def signHmacSHA256(secret: String, data: String): Array[Byte] = signHmac(secret, data, "HmacSHA256") 39 | 40 | def sortToForm(params: Map[String, String]) :String = params.toSeq.sortBy(_._1).map(c => s"${c._1}=${urlEncode(c._2)}").mkString("&") 41 | 42 | def urlEncode(in: String): String = URLEncoder.encode(in, StandardCharsets.UTF_8.name()) 43 | 44 | } 45 | -------------------------------------------------------------------------------- /config-template: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "emails" : ["youremail@errorreport.com"], 4 | "sesKey" : "seskey", 5 | "sesSecret" : "sessecret", 6 | "logSeconds" : 10 7 | }, 8 | "bots" : [ 9 | { 10 | "exchange" : "hitbtc", 11 | "credentials" : { 12 | "pKey" : "apikeyWithTradeAllowed", 13 | "nonce" : "abcdef", 14 | "signature": "secret" 15 | }, 16 | "pair": "NOAHBTC", 17 | "seed" : "lastTicker", 18 | "gridSpace": "0.5", 19 | "buyGridLevels":3, 20 | "sellGridLevels": 0, 21 | "buyOrderQuantity": "3000", 22 | "sellOrderQuantity": "3000", 23 | "quantityPower" : 2, 24 | "counterScale" : 0, 25 | "baseScale" : 9, 26 | "isStrictLevels" : true, 27 | "isNoQtyCutoff" : true, 28 | "strategy" : "ppt" 29 | }, 30 | { 31 | "exchange" : "yobit", 32 | "credentials" : { 33 | "pKey" : "apikeyWithTradeAllowed", 34 | "nonce" : "", 35 | "signature": "secret" 36 | }, 37 | "pair": "noah_eth", 38 | "seed" : "lastTicker", 39 | "gridSpace": "0.5", 40 | "buyGridLevels":3, 41 | "sellGridLevels": 0, 42 | "buyOrderQuantity": "3000", 43 | "sellOrderQuantity": "3000", 44 | "quantityPower" : 2, 45 | "counterScale" :0, 46 | "baseScale" : 8, 47 | "isStrictLevels" : true, 48 | "isNoQtyCutoff" : true, 49 | "strategy" : "ppt" 50 | } 51 | ] 52 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/intellij 2 | 3 | ### Intellij ### 4 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 5 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 6 | 7 | # User-specific stuff: 8 | .idea/**/workspace.xml 9 | .idea/**/tasks.xml 10 | .idea/dictionaries 11 | 12 | # Sensitive or high-churn files: 13 | .idea/**/dataSources/ 14 | .idea/**/dataSources.ids 15 | .idea/**/dataSources.xml 16 | .idea/**/dataSources.local.xml 17 | .idea/**/sqlDataSources.xml 18 | .idea/**/dynamic.xml 19 | .idea/**/uiDesigner.xml 20 | 21 | # Gradle: 22 | .idea/**/gradle.xml 23 | .idea/**/libraries 24 | 25 | # CMake 26 | cmake-build-debug/ 27 | 28 | # Mongo Explorer plugin: 29 | .idea/**/mongoSettings.xml 30 | 31 | ## File-based project format: 32 | *.iws 33 | 34 | ## Plugin-specific files: 35 | 36 | # IntelliJ 37 | /out/ 38 | 39 | # mpeltonen/sbt-idea plugin 40 | .idea_modules/ 41 | 42 | # JIRA plugin 43 | atlassian-ide-plugin.xml 44 | 45 | # Cursive Clojure plugin 46 | .idea/replstate.xml 47 | 48 | # Ruby plugin and RubyMine 49 | /.rakeTasks 50 | 51 | # Crashlytics plugin (for Android Studio and IntelliJ) 52 | com_crashlytics_export_strings.xml 53 | crashlytics.properties 54 | crashlytics-build.properties 55 | fabric.properties 56 | 57 | ### Intellij Patch ### 58 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 59 | 60 | # *.iml 61 | # modules.xml 62 | # .idea/misc.xml 63 | # *.ipr 64 | 65 | # Sonarlint plugin 66 | .idea/sonarlint 67 | 68 | 69 | # End of https://www.gitignore.io/api/intellij 70 | 71 | dist/* 72 | target/ 73 | lib_managed/ 74 | src_managed/ 75 | project/boot/ 76 | project/plugins/project/ 77 | .lib/ 78 | 79 | ### Scala ### 80 | *.class 81 | *.log 82 | 83 | # mac 84 | .DS_Store 85 | 86 | # other 87 | .history 88 | .scala_dependencies 89 | .cache 90 | .cache-main 91 | 92 | credentials 93 | stuff 94 | last 95 | config 96 | config-extra 97 | log/ 98 | millis/ 99 | cache/ 100 | .jar -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/common/AbsWsParser.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.common 2 | 3 | import akka.actor.ActorRef 4 | import me.mbcu.integrated.mmm.ops.Definitions.ShutdownCode.ShutdownCode 5 | import me.mbcu.integrated.mmm.ops.Definitions.{ErrorIgnore, ErrorShutdown} 6 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor.As.As 7 | import me.mbcu.integrated.mmm.ops.common.AbsWsParser.SendWs 8 | import me.mbcu.integrated.mmm.ops.common.Side.Side 9 | import me.mbcu.scala.MyLogging 10 | import play.api.libs.json.JsValue 11 | 12 | trait AbsWsRequest { 13 | 14 | def subscribe: SendWs 15 | 16 | def cancelOrder(orderId: String, as: As): SendWs 17 | 18 | def newOrder(offer: Offer, as: As): SendWs 19 | 20 | def login(credentials: Credentials): SendWs 21 | 22 | def subsTicker(pair: String): SendWs 23 | 24 | def unsubsTicker(pair: String): SendWs 25 | 26 | } 27 | 28 | 29 | object AbsWsParser { 30 | 31 | trait GotWs { 32 | def requestId: String 33 | } 34 | 35 | 36 | case class SendWs(requestId: String, jsValue: JsValue, as: As) 37 | 38 | case class RemoveOfferWs(isRetry: Boolean, orderId: String, side: Side, override val requestId: String) extends GotWs 39 | 40 | case class GotActiveOrdersWs(offers: Seq[Offer], override val requestId: String) extends GotWs 41 | 42 | case class GotOfferWs(offer: Offer, override val requestId: String) extends GotWs 43 | 44 | case class GotTickerPriceWs(price: Option[BigDecimal], override val requestId: String) extends GotWs 45 | 46 | case class RemovePendingWs(override val requestId: String) extends GotWs 47 | 48 | case class RetryPendingWs(override val requestId: String) extends GotWs 49 | 50 | case class GotSubscribe(override val requestId: String) extends GotWs 51 | 52 | case class LoggedIn(override val requestId: String) extends GotWs 53 | } 54 | 55 | abstract class AbsWsParser(op: ActorRef) extends MyLogging{ 56 | 57 | def parse(raw: String, botMap: Map[String, ActorRef]): Unit 58 | 59 | def errorShutdown(shutdownCode: ShutdownCode, code: Int, msg: String): Unit = op ! ErrorShutdown(shutdownCode, code, msg) 60 | 61 | def errorIgnore(code: Int, msg: String, shouldEmail: Boolean = false): Unit = op ! ErrorIgnore(code, msg, shouldEmail) 62 | } 63 | 64 | -------------------------------------------------------------------------------- /EXCHANGES.md: -------------------------------------------------------------------------------- 1 | (the first item in each exchange is the supported seed method) 2 | 3 | ### hitbtc 4 | - ws: supports `lastTicker` and custom start price 5 | 6 | ### okexRest 7 | - rest: supports `lastTicker`, `cont` and custom start price 8 | - *Make sure API key and secret are correct.* Okex returns errors reserved for sign error (10007, 10005) even a Cloudlare website for non-sign error. 9 | As such 10007, 10005 and html response will be retried. 10 | - no mention of API limit 11 | - TradeHistory 12 | - return sorted without key. 13 | - Use order id (Long) as indicator 14 | 15 | ### yobit 16 | - restGI: supports `lastTicker`, `lastOwn` and custom start price 17 | - The HmacSHA512 signature has to be in lowercased hex 18 | - ~Trade returns order status.~ Trade returns order status which always indicates that the order is unfilled. 19 | - ActiveOrder returns only the current amount which might have been partially filled. To get complete info, we still need to call OrderInfo on each order 20 | - "Admissible quantity of requests to API from user's software is 100 units per minute." 21 | - TradeHistory 22 | - return sorted with special key (Long) 23 | - Use this key as indicator 24 | - Trade History response doesn't have filled / partially filled status. To rebuild a fully filled order, need gather multiple fragmented trades. 25 | 26 | ### fcoin 27 | - rest: supports `lastTicker`,`cont` and custom start price 28 | - Timestamp is required to sign 29 | - API limit = 100 / 10 seconds per user 30 | - TradeHistory 31 | - return sorted without key. 32 | - Use order id (String) as indicator 33 | 34 | ### livecoin 35 | - rest: supports `lastTicker`, `cont` and custom start price 36 | - get client orders's pagination is broken (startRow doesn't work) 37 | - may have super long timeout (more than 2 minutes) 38 | - expect all active order to be at most 100 at a time 39 | 40 | ### btcalpha 41 | - restGI: supports `lastTicker`, `lastOwn`, and custom start price 42 | - **All IP 35.* (Google Cloud) are blocked by BtcAlpha** 43 | - **replace official pair name with underscore** (NOAH/BTC to NOAH_BTC) 44 | - api secret has to be escaped 45 | - single-quotes: escaped with //' 46 | - double-quotes: escaped with ///" 47 | - doesn't have partially-filled status 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/test/scala/me/mbcu/integrated/mmm/ops/common/OfferTest.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.common 2 | 3 | import me.mbcu.integrated.mmm.actors.OrderRestActor 4 | import org.scalatest.FunSuite 5 | 6 | class OfferTest extends FunSuite { 7 | 8 | 9 | test("sort created_at desc") { 10 | val o1 = Offer("a", "eth_noah", Side.buy, Status.debug, 1532579178L, None, BigDecimal("1000"), BigDecimal("0.00000031"), None) 11 | val o2 = Offer("a", "eth_noah", Side.buy, Status.active, 1532579278L, None, BigDecimal("1000"), BigDecimal("0.00000032"), None) 12 | val o3 = Offer("a", "eth_noah", Side.buy, Status.active, 1532579378L, None, BigDecimal("1000"), BigDecimal("0.00000033"), None) 13 | val o4 = Offer("a", "eth_noah", Side.buy, Status.active, 1532579478L, None, BigDecimal("1000"), BigDecimal("0.00000034"), None) 14 | val o5 = Offer("a", "eth_noah", Side.buy, Status.active, 1532679278L, None, BigDecimal("1000"), BigDecimal("0.00000035"), None) 15 | val a = Seq(o1, o2, o3, o4, o5) 16 | scala.util.Random.shuffle(a) 17 | val res = Offer.sortTimeDesc(a) 18 | assert(res.head.createdAt === 1532679278L) 19 | assert(res.last.createdAt === 1532579178L) 20 | } 21 | 22 | test("partition active orders into two sides") { 23 | val o1 = Offer("a", "eth_noah", Side.buy, Status.debug, 1532579178L, None, BigDecimal("1000"), BigDecimal("0.00000031"), None) 24 | val o2 = Offer("a", "eth_noah", Side.sell, Status.active, 1532579278L, None, BigDecimal("1000"), BigDecimal("0.00000032"), None) 25 | val o3 = Offer("a", "eth_noah", Side.buy, Status.active, 1532579378L, None, BigDecimal("1000"), BigDecimal("0.00000033"), None) 26 | val o4 = Offer("a", "eth_noah", Side.sell, Status.active, 1532579478L, None, BigDecimal("1000"), BigDecimal("0.00000034"), None) 27 | val o5 = Offer("a", "eth_noah", Side.buy, Status.active, 1532679278L, None, BigDecimal("1000"), BigDecimal("0.00000035"), None) 28 | val a = Seq(o1, o2, o3, o4, o5) 29 | scala.util.Random.shuffle(a) 30 | val (p,q) = Offer.splitToBuysSels(a) 31 | assert(p.head.side === Side.buy) 32 | assert(p.size === 3) 33 | assert(p.head.price > p(1).price) 34 | assert(p(1).price > p(2).price) 35 | assert(q.head.side === Side.sell) 36 | assert(q.size === 2) 37 | assert(q.head.price < q(1).price) 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/Definitions.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops 2 | 3 | import me.mbcu.integrated.mmm.ops.Definitions.ShutdownCode.ShutdownCode 4 | import me.mbcu.integrated.mmm.ops.btcalpha.Btcalpha 5 | import me.mbcu.integrated.mmm.ops.common.AbsExchange 6 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor.SendRest 7 | import me.mbcu.integrated.mmm.ops.ddex.Ddex 8 | import me.mbcu.integrated.mmm.ops.fcoin.Fcoin 9 | import me.mbcu.integrated.mmm.ops.hitbtc.Hitbtc 10 | import me.mbcu.integrated.mmm.ops.livecoin.Livecoin 11 | import me.mbcu.integrated.mmm.ops.okex.OkexRest 12 | import me.mbcu.integrated.mmm.ops.yobit.Yobit 13 | import play.api.libs.json.{Reads, Writes} 14 | 15 | import scala.language.implicitConversions 16 | 17 | object Definitions { 18 | 19 | val exchangeMap = Map[Exchange.Value, AbsExchange]( 20 | Exchange.okexRest -> OkexRest, 21 | Exchange.yobit -> Yobit, 22 | Exchange.fcoin -> Fcoin, 23 | Exchange.ddex -> Ddex, 24 | Exchange.livecoin -> Livecoin, 25 | Exchange.btcalpha -> Btcalpha, 26 | Exchange.hitbtc -> Hitbtc 27 | ) 28 | 29 | object ShutdownCode extends Enumeration { 30 | type ShutdownCode = Value 31 | val fatal = Value(-1) // auth, login 32 | val recover = Value(1) // server error 33 | } 34 | 35 | object Exchange extends Enumeration { 36 | type Exchange = Value 37 | val okexRest, yobit, fcoin, ddex, livecoin, btcalpha, hitbtc = Value 38 | 39 | implicit val reads = Reads.enumNameReads(Exchange) 40 | implicit val writes = Writes.enumNameWrites 41 | } 42 | 43 | object Op extends Enumeration { 44 | type Protocol = Value 45 | val rest, ws, restgi, ddex = Value 46 | } 47 | 48 | object Strategies extends Enumeration { 49 | type Strategies = Value 50 | val ppt, fullfixed = Value 51 | 52 | implicit val reads = Reads.enumNameReads(Strategies) 53 | implicit val writes = Writes.enumNameWrites 54 | } 55 | 56 | 57 | case class ErrorIgnore(code: Int, msg: String, shouldEmail: Boolean) 58 | case class ErrorShutdown(shutdown: ShutdownCode, code: Int, msg: String) 59 | case class ErrorRetryRest(sendRequest: SendRest, code: Int, msg: String, shouldEmail: Boolean) 60 | 61 | 62 | object Settings { 63 | val cachingEmailSeconds:Int = 60 64 | val getActiveSeconds:Int = 5 65 | val getFilledSeconds:Int = 5 66 | val intervalLogSeconds = 15 67 | val intervalSeedSeconds:Int = 5 68 | val wsInitSeconds:Int = 1 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/okex/OkexParameters.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.okex 2 | 3 | import java.security.MessageDigest 4 | 5 | import me.mbcu.integrated.mmm.ops.common.Side.Side 6 | import me.mbcu.integrated.mmm.ops.okex.OkexStatus.OkexStatus 7 | import play.api.libs.functional.syntax._ 8 | import play.api.libs.json._ 9 | 10 | object OkexStatus extends Enumeration { 11 | type OkexStatus = Value 12 | val filled = Value(2) 13 | val unfilled = Value(0) 14 | 15 | implicit val enumFormat = new Format[OkexStatus] { 16 | override def reads(json: JsValue): JsResult[OkexStatus] = json.validate[Int].map(OkexStatus(_)) 17 | override def writes(enum: OkexStatus) = JsNumber(enum.id) 18 | } 19 | } 20 | 21 | object OkexParameters { 22 | val md5: MessageDigest = MessageDigest.getInstance("MD5") 23 | 24 | implicit val jsonFormat = Json.format[OkexParameters] 25 | 26 | object Implicits { 27 | implicit val writes = new Writes[OkexParameters] { 28 | def writes(r: OkexParameters): JsValue = Json.obj( 29 | "sign" -> r.sign, 30 | "api_key" -> r.api_key, 31 | "symbol" -> r.symbol, 32 | "order_id" -> r.order_id, 33 | "type" -> r.symbol, 34 | "price" -> r.price, 35 | "amount" -> r.amount, 36 | "status" -> r.status, 37 | "current_page" -> r.current_page, 38 | "page_length" -> r.page_length 39 | ) 40 | } 41 | 42 | implicit val reads: Reads[OkexParameters] = ( 43 | (JsPath \ "sign").readNullable[String] and 44 | (JsPath \ "api_key").read[String] and 45 | (JsPath \ "symbol").readNullable[String] and 46 | (JsPath \ "order_id").readNullable[String] and 47 | (JsPath \ "type").readNullable[Side] and 48 | (JsPath \ "price").readNullable[BigDecimal] and 49 | (JsPath \ "amount").readNullable[BigDecimal] and 50 | (JsPath \ "status").readNullable[OkexStatus] and 51 | (JsPath \ "current_page").readNullable[Int] and 52 | (JsPath \ "page_length").readNullable[Int] 53 | ) (OkexParameters.apply _) 54 | } 55 | 56 | } 57 | 58 | case class OkexParameters ( 59 | sign : Option[String], 60 | api_key : String, 61 | symbol : Option[String], 62 | order_id : Option[String], 63 | `type` : Option[Side], 64 | price : Option[BigDecimal], 65 | amount: Option[BigDecimal], 66 | status : Option[OkexStatus] = None, 67 | current_page : Option[Int] = None, 68 | page_length : Option[Int] = None 69 | 70 | 71 | ) 72 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/actors/OpWsActor.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.actors 2 | 3 | import akka.actor.{ActorRef, Props} 4 | import akka.dispatch.ExecutionContexts.global 5 | import me.mbcu.integrated.mmm.actors.OpWsActor._ 6 | import me.mbcu.integrated.mmm.actors.WsActor._ 7 | import me.mbcu.integrated.mmm.ops.Definitions.{ErrorIgnore, ErrorShutdown, Settings} 8 | import me.mbcu.integrated.mmm.ops.common.AbsWsParser.{GotSubscribe, LoggedIn, SendWs} 9 | import me.mbcu.integrated.mmm.ops.common._ 10 | import me.mbcu.scala.MyLogging 11 | 12 | import scala.concurrent.ExecutionContextExecutor 13 | import scala.concurrent.duration._ 14 | import scala.language.postfixOps 15 | 16 | object OpWsActor { 17 | 18 | case class QueueWs(sendWs: Seq[SendWs]) 19 | 20 | } 21 | 22 | class OpWsActor(exchangeDef: AbsExchange, bots: Seq[Bot], fileActor: ActorRef) extends AbsOpActor(exchangeDef, bots, fileActor) with MyLogging { 23 | private var base: Option[ActorRef] = None 24 | private val wsEx: AbsWsExchange = exchangeDef.asInstanceOf[AbsWsExchange] 25 | private val parser: AbsWsParser = wsEx.getParser(self) 26 | private implicit val ec: ExecutionContextExecutor = global 27 | private var client: Option[ActorRef] = None 28 | private var botMap: Map[String, ActorRef] = Map.empty 29 | 30 | override def receive: Receive = { 31 | 32 | case "start" => 33 | base = Some(sender) 34 | botMap ++= bots.map(bot => bot.pair -> context.actorOf(Props(new OrderWsActor(bot, exchangeDef, wsEx.getRequest)), name = s"${bot.pair}")) 35 | botMap.values.foreach(_ ! "start") 36 | client = Some(context.actorOf(Props(new WsActor()), name = "wsclient")) 37 | client.foreach(_ ! "start") 38 | 39 | case WsRequestClient => 40 | botMap.values.foreach(_ ! WsRequestClient) 41 | context.system.scheduler.scheduleOnce(Settings.wsInitSeconds seconds, self, "reconnect websocket") 42 | 43 | case "reconnect websocket" => client.foreach(_ ! WsConnect(exchangeDef.endpoint)) 44 | 45 | case WsConnected => send(wsEx.getRequest.login(bots.head.credentials)) 46 | 47 | case WsGotText(text) => parser.parse(text, botMap) 48 | 49 | case a: LoggedIn => send(wsEx.getRequest.subscribe) 50 | 51 | case a: GotSubscribe => botMap.values.foreach(_ ! a) 52 | 53 | case QueueWs(sendWs) => sendWs foreach send 54 | 55 | case a: ErrorShutdown => base foreach (_ ! a) 56 | 57 | case a: ErrorIgnore => base foreach (_ ! a) 58 | 59 | } 60 | 61 | def send(s: SendWs): Unit = { 62 | info(s"${s.as} : ${s.jsValue.toString}") 63 | client foreach(_ ! SendJs(s.jsValue)) 64 | } 65 | 66 | 67 | 68 | } 69 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/actors/OpRestActor.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.actors 2 | 3 | import akka.actor.{ActorRef, Cancellable, Props} 4 | import akka.dispatch.ExecutionContexts.global 5 | import me.mbcu.integrated.mmm.ops.Definitions.{ErrorIgnore, ErrorRetryRest, ErrorShutdown} 6 | import me.mbcu.integrated.mmm.ops.common.AbsOrder.{CheckSafeForSeed, QueueRequest, SafeForSeed} 7 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor._ 8 | import me.mbcu.integrated.mmm.ops.common.{AbsExchange, AbsOpActor, AbsOrder, Bot} 9 | import me.mbcu.scala.MyLogging 10 | 11 | import scala.concurrent.ExecutionContextExecutor 12 | import scala.concurrent.duration._ 13 | import scala.language.postfixOps 14 | 15 | object OpRestActor extends MyLogging { 16 | } 17 | 18 | class OpRestActor(exchangeDef: AbsExchange, bots: Seq[Bot], fileActor: ActorRef) extends AbsOpActor(exchangeDef, bots, fileActor) with MyLogging { 19 | private implicit val ec: ExecutionContextExecutor = global 20 | private var base: Option[ActorRef] = None 21 | private var rest: Option[ActorRef] = None 22 | private var dqCancellable: Option[Cancellable] = None 23 | private val q = new scala.collection.mutable.Queue[SendRest] 24 | private val nos = scala.collection.mutable.Set[NewOrder]() 25 | 26 | override def receive: Receive = { 27 | 28 | case "start" => 29 | base = Some(sender) 30 | rest = Some(context.actorOf(exchangeDef.getActorRefProps)) 31 | rest foreach (_ ! StartRestActor) 32 | bots.foreach(bot => { 33 | val book = context.actorOf(Props(new OrderRestActor(bot, exchangeDef, fileActor)), name= s"${Bot.filePath(bot)}") 34 | book ! "start" 35 | }) 36 | self ! "init dequeue scheduler" 37 | 38 | case "init dequeue scheduler" => dqCancellable = Some(context.system.scheduler.schedule(1 second, exchangeDef.intervalMillis milliseconds, self, "dequeue")) 39 | 40 | case "dequeue" => 41 | if (q.nonEmpty) { 42 | val next = q.dequeue() 43 | next match { 44 | case order: NewOrder => nos += order 45 | case _ => 46 | } 47 | rest foreach(_ ! next) 48 | } 49 | 50 | case QueueRequest(seq) => q ++= seq 51 | 52 | case a: GotNewOrderId => nos -= a.send 53 | 54 | case CheckSafeForSeed(ref, bot) => ref ! SafeForSeed(AbsOrder.isSafeForSeed(q, nos, bot)) 55 | 56 | case ErrorRetryRest(sendRequest, code, msg, shouldEmail) => 57 | q += sendRequest 58 | base foreach(_ ! ErrorRetryRest(sendRequest, code, msg, shouldEmail)) 59 | 60 | case a : ErrorShutdown => base foreach(_ ! a) 61 | 62 | case a : ErrorIgnore => base foreach(_ ! a) 63 | 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/actors/SesActor.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.actors 2 | 3 | import akka.actor.{Actor, ActorRef, Cancellable} 4 | import akka.dispatch.ExecutionContexts.global 5 | import com.amazonaws.regions.Regions 6 | import com.amazonaws.services.simpleemail.model.SendEmailResult 7 | import jp.co.bizreach.ses.SESClient 8 | import jp.co.bizreach.ses.models.{Address, Content, Email} 9 | import me.mbcu.integrated.mmm.actors.SesActor.{CacheMessages, MailSent} 10 | import me.mbcu.integrated.mmm.ops.Definitions 11 | import me.mbcu.scala.MyLogging 12 | 13 | import scala.collection.mutable.ListBuffer 14 | import scala.concurrent.ExecutionContextExecutor 15 | import scala.concurrent.duration._ 16 | import scala.language.postfixOps 17 | import scala.util.Try 18 | 19 | object SesActor { 20 | 21 | case class CacheMessages(msg :String, shutdownCode : Option[Int]) 22 | 23 | object MailTimer 24 | 25 | case class MailSent(t : Try[SendEmailResult], shutdownCode : Option[Int]) 26 | 27 | } 28 | 29 | class SesActor(sesKey : Option[String], sesSecret : Option[String], emails : Option[Seq[String]]) extends Actor with MyLogging{ 30 | private implicit val ec: ExecutionContextExecutor = global 31 | val title = "MMM Error" 32 | var tos : Seq[Address] = Seq.empty 33 | var cli : Option[SESClient] = None 34 | private implicit val region: Regions = Regions.US_EAST_1 35 | private var base : Option[ActorRef] = None 36 | private val cache = new ListBuffer[(String, Option[Int])] 37 | var sendCancellable : Option[Cancellable] = None 38 | 39 | override def receive: Receive = { 40 | 41 | case "start" => 42 | base = Some(sender) 43 | (sesKey, sesSecret, emails) match { 44 | case (Some(k), Some(s), Some(m)) => 45 | cli = Some(SESClient(k, s)) 46 | this.tos ++= m map (Address(_)) 47 | case _ => 48 | } 49 | 50 | case CacheMessages(msg, shutdownCode) => 51 | sendCancellable foreach (_.cancel()) 52 | sendCancellable = Some(context.system.scheduler.scheduleOnce(Definitions.Settings.cachingEmailSeconds second, self, "execute send")) 53 | cache += ((msg, shutdownCode)) 54 | 55 | case "execute send" => 56 | val shutdownCode = cache flatMap(_._2) reduceOption(_ min _) 57 | val msg = cache map (_._1) mkString "\n\n" 58 | send(title, msg, shutdownCode) 59 | } 60 | 61 | def send(title: String, body: String, shutdownCode : Option[Int] = None): Unit = { 62 | (tos.headOption, cli) match { 63 | case (Some(address), Some(c)) => 64 | val email = Email(Content(title), address, Some(Content(body)), None, tos) 65 | val future = c send email 66 | future.onComplete(t => base foreach(_ ! MailSent(t, shutdownCode))) 67 | case _ => 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/test/scala/me/mbcu/integrated/mmm/ops/okex/OkexRestActorTest.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.okex 2 | 3 | import me.mbcu.integrated.mmm.ops.common.Status 4 | import org.scalatest.FunSuite 5 | import play.api.libs.json.{JsObject, Json} 6 | 7 | class OkexRestActorTest extends FunSuite{ 8 | 9 | test("parse filled") { 10 | val a = 11 | """ 12 | |{ 13 | | "result": true, 14 | | "total": 5, 15 | | "currency_page": 1, 16 | | "page_length": 200, 17 | | "orders": [ 18 | | { 19 | | "amount": 1E+1, 20 | | "avg_price": 0.00008081, 21 | | "create_date": 1532873222000, 22 | | "deal_amount": 1E+1, 23 | | "order_id": 40611446, 24 | | "orders_id": 40611446, 25 | | "price": 0.00008081, 26 | | "status": 2, 27 | | "symbol": "trx_eth", 28 | | "type": "sell" 29 | | }, 30 | | { 31 | | "amount": 1E+1, 32 | | "avg_price": 0, 33 | | "create_date": 1532873073000, 34 | | "deal_amount": 0, 35 | | "order_id": 40610842, 36 | | "orders_id": 40610842, 37 | | "price": 0.00008117, 38 | | "status": -1, 39 | | "symbol": "trx_eth", 40 | | "type": "sell" 41 | | }, 42 | | { 43 | | "amount": 1E+1, 44 | | "avg_price": 0, 45 | | "create_date": 1532872788000, 46 | | "deal_amount": 0, 47 | | "order_id": 40609669, 48 | | "orders_id": 40609669, 49 | | "price": 0.00008118, 50 | | "status": -1, 51 | | "symbol": "trx_eth", 52 | | "type": "sell" 53 | | }, 54 | | { 55 | | "amount": 1E+1, 56 | | "avg_price": 0.00008090, 57 | | "create_date": 1532867632000, 58 | | "deal_amount": 1E+1, 59 | | "order_id": 40586533, 60 | | "orders_id": 40586533, 61 | | "price": 0.0000809, 62 | | "status": 2, 63 | | "symbol": "trx_eth", 64 | | "type": "sell" 65 | | }, 66 | | { 67 | | "amount": 1E+1, 68 | | "avg_price": 0.00008057, 69 | | "create_date": 1532865557000, 70 | | "deal_amount": 1E+1, 71 | | "order_id": 40575742, 72 | | "orders_id": 40575742, 73 | | "price": 0.00008057, 74 | | "status": 2, 75 | | "symbol": "trx_eth", 76 | | "type": "sell" 77 | | } 78 | | ] 79 | |} 80 | """.stripMargin 81 | 82 | val res = OkexRestActor.parseFilled(Json.parse(a), 40575742) 83 | assert(res._1.size === 2) 84 | assert(res._1.head.id.toLong < res._1(1).id.toLong) 85 | 86 | 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/actors/OpGIActor.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.actors 2 | 3 | import akka.actor.{Actor, ActorRef, Cancellable, Props} 4 | import akka.dispatch.ExecutionContexts.global 5 | import me.mbcu.integrated.mmm.actors.OrderGIActor.{CheckSafeForGI, SafeForGI} 6 | import me.mbcu.integrated.mmm.ops.Definitions.{ErrorIgnore, ErrorRetryRest, ErrorShutdown} 7 | import me.mbcu.integrated.mmm.ops.common.AbsOrder.{CheckSafeForSeed, QueueRequest, SafeForSeed} 8 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor._ 9 | import me.mbcu.integrated.mmm.ops.common.{AbsExchange, AbsOrder, Bot} 10 | import me.mbcu.scala.MyLogging 11 | 12 | import scala.collection.mutable 13 | import scala.concurrent.ExecutionContextExecutor 14 | import scala.concurrent.duration._ 15 | import scala.language.postfixOps 16 | 17 | object OpGIActor { 18 | def isSafeForGI(q: mutable.Queue[SendRest], nos: mutable.Set[NewOrder], bot: Bot): Boolean = 19 | (q.filter(_.bot == bot) ++ nos.filter(_.bot == bot)).isEmpty 20 | 21 | } 22 | 23 | class OpGIActor(exchangeDef: AbsExchange, bots: Seq[Bot]) extends Actor with MyLogging { 24 | private implicit val ec: ExecutionContextExecutor = global 25 | private var base: Option[ActorRef] = None 26 | private var rest: Option[ActorRef] = None 27 | private var dqCancellable: Option[Cancellable] = None 28 | private val q = new scala.collection.mutable.Queue[SendRest] 29 | private val nos = scala.collection.mutable.Set[NewOrder]() 30 | 31 | override def receive: Receive = { 32 | 33 | case "start" => 34 | base = Some(sender) 35 | rest = Some(context.actorOf(exchangeDef.getActorRefProps)) 36 | rest foreach (_ ! StartRestActor) 37 | bots.foreach(bot => { 38 | val book = context.actorOf(Props(new OrderGIActor(bot, exchangeDef)), name = s"${Bot.filePath(bot)}") 39 | book ! "start" 40 | }) 41 | self ! "init dequeue scheduler" 42 | 43 | case "init dequeue scheduler" => dqCancellable = Some(context.system.scheduler.schedule(1 second, exchangeDef.intervalMillis milliseconds, self, "dequeue")) 44 | 45 | case "dequeue" => 46 | if (q.nonEmpty) { 47 | val next = q.dequeue() 48 | next match { 49 | case order: NewOrder => nos += order 50 | case _ => 51 | } 52 | rest foreach (_ ! next) 53 | } 54 | 55 | case QueueRequest(seq) => q ++= seq 56 | 57 | case a: CheckSafeForGI => a.ref ! SafeForGI(OpGIActor.isSafeForGI(q, nos, a.bot)) 58 | 59 | case a: CheckSafeForSeed => a.ref ! SafeForSeed(AbsOrder.isSafeForSeed(q, nos, a.bot)) 60 | 61 | case a: GetOrderInfo => q += a 62 | 63 | case a: GotNewOrderId => nos -= a.send 64 | 65 | case ErrorRetryRest(sendRequest, code, msg, shouldEmail) => 66 | base foreach (_ ! ErrorRetryRest(sendRequest, code, msg, shouldEmail)) 67 | q += sendRequest 68 | 69 | case a: ErrorShutdown => base foreach (_ ! a) 70 | 71 | case a: ErrorIgnore => base foreach (_ ! a) 72 | 73 | } 74 | 75 | 76 | } 77 | 78 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/yobit/YobitRequest.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.yobit 2 | 3 | import me.mbcu.integrated.mmm.ops.common.Side.Side 4 | import me.mbcu.integrated.mmm.ops.common.{AbsRestRequest, Credentials} 5 | 6 | object YobitRequest extends AbsRestRequest { 7 | 8 | case class YobitParams(sign: String, params: String) 9 | 10 | def nonce : Long = System.currentTimeMillis() / 1000 - Yobit.nonceFactor 11 | 12 | def addNonce(params: Map[String, String]): Map[String, String] = params + ("nonce" -> nonce.toString) 13 | 14 | def body(params: Map[String, String]): String = params.toSeq.sortBy(_._1).map(v => s"${v._1}=${v._2}").mkString("&") 15 | 16 | def toYobitParams(params : Map[String, String], secret: String) : YobitParams = { 17 | val withNonce = body(addNonce(params)) 18 | YobitParams(toHex(signHmacSHA512(secret, withNonce), isCapital = false), withNonce) 19 | } 20 | 21 | def ownTrades(credentials: Credentials, pair: String) : YobitParams = ownTrades( credentials.signature, pair ) 22 | 23 | def ownTrades(secret:String, pair: String) : YobitParams = { 24 | val params = Map( 25 | "method" -> "TradeHistory", //default order is DESC 26 | "pair" -> pair, 27 | "count" -> 100.toString 28 | ) 29 | toYobitParams(params, secret) 30 | } 31 | 32 | def infoOrder(credentials: Credentials, orderId: String) : YobitParams = infoOrder(credentials.signature, orderId: String) 33 | 34 | def infoOrder(secret: String, orderId: String) : YobitParams = { 35 | val params = Map( 36 | "method" -> "OrderInfo", 37 | "order_id" -> orderId 38 | ) 39 | toYobitParams(params, secret) 40 | } 41 | 42 | def newOrder(credentials: Credentials, pair: String, `type`: Side, price: BigDecimal, amount: BigDecimal) : YobitParams = 43 | newOrder(credentials.signature, pair, `type`, price, amount) 44 | 45 | def newOrder(secret: String, pair: String, `type`: Side, price: BigDecimal, amount: BigDecimal) : YobitParams = { 46 | val params = Map( 47 | "method" -> "Trade", 48 | "pair" -> pair, 49 | "type" -> `type`.toString, 50 | "rate" -> price.bigDecimal.toPlainString, 51 | "amount" -> amount.bigDecimal.toPlainString 52 | ) 53 | toYobitParams(params, secret) 54 | } 55 | 56 | def cancelOrder(credentials: Credentials, orderId : String) : YobitParams = cancelOrder(credentials.signature, orderId) 57 | 58 | def cancelOrder(secret : String, orderId : String) : YobitParams = { 59 | val params = Map( 60 | "method" -> "CancelOrder", 61 | "order_id" -> orderId 62 | ) 63 | toYobitParams(params, secret) 64 | } 65 | 66 | def activeOrders(credentials: Credentials, pair: String) : YobitParams = activeOrders(credentials.signature, pair) 67 | 68 | def activeOrders(secret: String, pair: String) : YobitParams = { 69 | val params = Map( 70 | "method" -> "ActiveOrders", 71 | "pair" -> pair 72 | ) 73 | toYobitParams(params, secret) 74 | } 75 | 76 | def getInfo(credentials: Credentials) : YobitParams = getInfo(credentials.signature) 77 | 78 | def getInfo(secret: String) : YobitParams = { 79 | val params = Map( 80 | "method" -> "getInfo" 81 | ) 82 | toYobitParams(params, secret) 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/actors/FileActor.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.actors 2 | 3 | import java.io._ 4 | import java.nio.charset.StandardCharsets 5 | import java.nio.file.StandardWatchEventKinds._ 6 | import java.nio.file.{Files, Paths} 7 | 8 | import akka.actor.{Actor, ActorRef, Props} 9 | import com.beachape.filemanagement.Messages._ 10 | import com.beachape.filemanagement.MonitorActor 11 | import com.beachape.filemanagement.RegistryTypes._ 12 | import me.mbcu.integrated.mmm.actors.BaseActor.ConfigReady 13 | import me.mbcu.integrated.mmm.actors.FileActor.millisFileName 14 | import me.mbcu.integrated.mmm.actors.OrderRestActor._ 15 | import me.mbcu.integrated.mmm.ops.common.{Bot, BotCache, Config} 16 | import me.mbcu.scala.MyLogging 17 | import play.api.libs.json.Json 18 | 19 | import scala.util.{Failure, Success, Try} 20 | 21 | object FileActor extends MyLogging { 22 | def props(configPath: String, millisPath: String): Props = Props(new FileActor(configPath, millisPath)) 23 | 24 | def readConfig(path: String): Option[Config] = { 25 | val source = scala.io.Source.fromFile(path) 26 | val rawJson = try source.mkString finally source.close() 27 | Try(Json.parse(rawJson).as[Config]) match { 28 | case Success(config) => Some(config) 29 | case Failure(e) => 30 | error(e.getMessage) 31 | None 32 | } 33 | } 34 | 35 | 36 | def millisFileName(msPath: String, bot: Bot): String = msPath + Bot.millisFileFormat.format(bot.exchange.toString, Bot.filePath(bot)) 37 | 38 | def readLastCounterId(path: String, bot: Bot): BotCache = { 39 | val fName = millisFileName(path, bot) 40 | if (!Files.exists(Paths.get(fName))) { 41 | val pw = new PrintWriter(new File(fName)) 42 | pw.close() 43 | } 44 | val raw = readFile(fName) 45 | Try(Json.parse(raw).as[BotCache]) match { 46 | case Success(bc) => bc 47 | case Failure(e) => 48 | e.getMessage match { 49 | case a if a contains "No content to map" => error("File is empty. Creating default.") 50 | case _ => error _ 51 | } 52 | BotCache.default 53 | } 54 | } 55 | 56 | def readFile(path: String): String = { 57 | val source = scala.io.Source.fromFile(path) 58 | try source.mkString finally source.close() 59 | } 60 | 61 | def writeFile(path: String, content: String): Unit = Files.write(Paths.get(path), content.getBytes(StandardCharsets.UTF_8)) 62 | 63 | } 64 | 65 | class FileActor(cfgPath: String, msPath: String) extends Actor { 66 | private var base: Option[ActorRef] = None 67 | 68 | override def receive: Receive = { 69 | 70 | case "start" => 71 | import FileActor._ 72 | base = Some(sender) 73 | sender ! ConfigReady(readConfig(cfgPath)) 74 | 75 | case "listen" => 76 | val fileMonitorActor = context.actorOf(MonitorActor(concurrency = 2)) 77 | val modifyCallbackFile: Callback = { path => println(s"Something was modified in a file: $path") } 78 | val file = Paths get cfgPath 79 | /* 80 | This will receive callbacks for just the one file 81 | */ 82 | fileMonitorActor ! RegisterCallback( 83 | event = ENTRY_MODIFY, 84 | path = file, 85 | callback = modifyCallbackFile 86 | ) 87 | 88 | case GetLastCounter(book, bot, as) => book ! GotLastCounter(FileActor.readLastCounterId(msPath, bot), as) 89 | 90 | case WriteLastCounter(book, bot, m) => FileActor.writeFile(millisFileName(msPath, bot), Json.toJson(m).toString()) 91 | 92 | } 93 | 94 | 95 | } -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/btcalpha/BtcalphaRequest.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.btcalpha 2 | 3 | import me.mbcu.integrated.mmm.ops.btcalpha.BtcalphaRequest.BtcalphaStatus.BtcalphaStatus 4 | import me.mbcu.integrated.mmm.ops.common.Side.Side 5 | import me.mbcu.integrated.mmm.ops.common.{AbsRestRequest, Credentials} 6 | import me.mbcu.scala.MyLogging 7 | import play.api.libs.json._ 8 | 9 | object BtcalphaRequest extends AbsRestRequest with MyLogging { 10 | 11 | def getNonce: String = System.currentTimeMillis().toString 12 | 13 | object BtcalphaStatus extends Enumeration { 14 | type BtcalphaStatus = Value 15 | val active: BtcalphaRequest.BtcalphaStatus.Value = Value(1) 16 | val cancelled: BtcalphaRequest.BtcalphaStatus.Value = Value(2) 17 | val done: BtcalphaRequest.BtcalphaStatus.Value = Value(3) 18 | 19 | implicit val enumFormat: Format[BtcalphaStatus] = new Format[BtcalphaStatus] { 20 | override def reads(json: JsValue): JsResult[BtcalphaStatus] = json.validate[Int].map(BtcalphaStatus(_)) 21 | override def writes(enum: BtcalphaStatus) = JsNumber(enum.id) 22 | } 23 | } 24 | 25 | def sanitizeSecret(s: String): String = StringContext treatEscapes s 26 | 27 | case class BtcalphaParams(sign: String, nonce: String, url: String = "", params: String) 28 | 29 | def getTickers(pair: String): BtcalphaParams = BtcalphaParams("unused", "unused", Btcalpha.endpoint.format(s"charts/$pair/1/chart/?format=json&limit=1"), "unused") 30 | 31 | def getOrders(credentials: Credentials, pair: String, status: BtcalphaStatus): BtcalphaParams = getOrders(credentials.pKey, credentials.signature, pair, status) 32 | 33 | def getOrders(key:String, secret: String, pair: String, status: BtcalphaStatus): BtcalphaParams = { 34 | val params = Map( 35 | "pair" -> pair, 36 | "status" -> status.id.toString 37 | ) 38 | val sorted = sortToForm(params) 39 | BtcalphaParams(sign(key, secret), getNonce, Btcalpha.endpoint.format(s"v1/orders/own/?$sorted"), sorted) 40 | } 41 | 42 | def newOrder(credentials: Credentials, pair: String, side: Side, price: BigDecimal, amount: BigDecimal) : BtcalphaParams = 43 | newOrder(credentials.pKey, credentials.signature, pair, side, price, amount) 44 | 45 | def newOrder(key: String, secret: String, pair: String, side: Side, price: BigDecimal, amount: BigDecimal) : BtcalphaParams = { 46 | //67a6cfe5-c6fc-46fd-8e4d-b7f87533fdc7amount=1000&pair=NOAH_ETH&price=0.000001&type=buy 47 | val params = Map( 48 | "pair" -> pair, 49 | "type" -> side.toString, 50 | "price" -> price.bigDecimal.toPlainString, 51 | "amount" -> amount.bigDecimal.toPlainString 52 | ) 53 | 54 | val sorted = sortToForm(params) 55 | val signed = sign(arrangePost(key, sorted), secret) 56 | BtcalphaParams(signed, getNonce, Btcalpha.endpoint.format("v1/order/"), sorted) 57 | } 58 | 59 | def cancelOrder(credentials: Credentials, id: String) : BtcalphaParams = cancelOrder(credentials.pKey, credentials.signature, id) 60 | 61 | def cancelOrder(key: String, secret: String, id: String) : BtcalphaParams = { 62 | val params = s"order=$id" 63 | val signed = sign(arrangePost(key, params), secret) 64 | BtcalphaParams(signed, getNonce, Btcalpha.endpoint.format("v1/order-cancel/"), params) 65 | } 66 | 67 | def getOrderInfo(credentials: Credentials, id: String) : BtcalphaParams = getOrderInfo(credentials.pKey, credentials.signature, id) 68 | 69 | def getOrderInfo(key: String, secret: String, id: String) : BtcalphaParams = { 70 | val signed = sign(key, secret) 71 | BtcalphaParams(signed, getNonce, Btcalpha.endpoint.format(s"v1/order/$id/"), id) 72 | } 73 | 74 | def arrangePost(key: String, sorted: String): String = s"$key$sorted" 75 | 76 | def sign(payload: String, secret: String): String = toHex(signHmacSHA256(sanitizeSecret(secret), payload), isCapital = false) 77 | 78 | } 79 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/actors/BaseActor.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.actors 2 | 3 | import akka.actor.{Actor, ActorRef, Props} 4 | import akka.dispatch.ExecutionContexts.global 5 | import me.mbcu.integrated.mmm.actors.BaseActor.{ConfigReady, Shutdown} 6 | import me.mbcu.integrated.mmm.actors.SesActor.{CacheMessages, MailSent} 7 | import me.mbcu.integrated.mmm.ops.Definitions 8 | import me.mbcu.integrated.mmm.ops.Definitions._ 9 | import me.mbcu.integrated.mmm.ops.common.{Bot, Config} 10 | import me.mbcu.scala.MyLogging 11 | 12 | import scala.concurrent.duration._ 13 | import scala.concurrent.{ExecutionContext, ExecutionContextExecutor} 14 | import scala.language.postfixOps 15 | import scala.util.{Failure, Success} 16 | 17 | object BaseActor { 18 | 19 | case class ConfigReady(config: Option[Config]) 20 | 21 | case class HandleRPCError(shutdowncode: Option[Int], errorCode: Int, msg: String) 22 | 23 | case class Shutdown(code: Option[Int]) 24 | 25 | case class HandleError(msg: String, code: Option[Int] = None) 26 | 27 | case class ConfigUpdate(bot:Bot, lastGetFilledMs: Long ) 28 | 29 | } 30 | 31 | class BaseActor(configPath: String, msPath: String) extends Actor with MyLogging { 32 | private var config: Option[Config] = None 33 | private var ses: Option[ActorRef] = None 34 | private implicit val ec: ExecutionContextExecutor = global 35 | val botName = "bot-%s" 36 | private var fileActor: Option[ActorRef] = None 37 | 38 | def receive: Receive = { 39 | 40 | case "start" => 41 | fileActor = Some(context.actorOf(Props(new FileActor(configPath, msPath)), name ="fio")) 42 | fileActor foreach(_ ! "start") 43 | 44 | case ConfigReady(tryCfg) => tryCfg match { 45 | 46 | case Some(c) => 47 | config = Some(c) 48 | 49 | 50 | ses = Some(context.actorOf(Props(new SesActor(c.env.sesKey, c.env.sesSecret, c.env.emails)), name = "ses")) 51 | ses foreach (_ ! "start") 52 | self ! "init bot" 53 | 54 | case _ => self ! Shutdown(Some(ShutdownCode.fatal.id)) 55 | } 56 | 57 | case "init bot" => 58 | (config, fileActor) match { 59 | case (Some(c), Some(f)) => 60 | Config.groupBots(c.bots).zipWithIndex.foreach { 61 | case (b, i) => 62 | val excDef = Definitions.exchangeMap(b._1) 63 | val props = excDef.protocol match { 64 | case Op.rest => Props(new OpRestActor(excDef, b._2, f)) 65 | case Op.ddex => Props(new OpDdexActor(excDef, b._2, f)) 66 | case Op.ws => Props(new OpWsActor(excDef, b._2, f)) 67 | case Op.restgi => Props(new OpGIActor(excDef, b._2)) 68 | } 69 | val opActor = context.actorOf(props, botName.format(i)) 70 | opActor ! "start" 71 | } 72 | case _ => 73 | } 74 | 75 | case ErrorShutdown(shutdown, code, msg) => ses foreach(_ ! CacheMessages(msg, Some(shutdown.id))) 76 | 77 | case ErrorIgnore(code, msg, shouldEmail) => if(shouldEmail) ses foreach(_ ! CacheMessages(msg, None)) 78 | 79 | case ErrorRetryRest(sendRequest, code, msg, shouldEmail) => if(shouldEmail) ses foreach(_ ! CacheMessages(msg, None)) 80 | 81 | case MailSent(t, shutdownCode) => 82 | t match { 83 | case Success(_) => info("Email Sent") 84 | case Failure(c) => info( 85 | s"""Failed sending email 86 | |${c.getMessage} 87 | """.stripMargin) 88 | } 89 | self ! Shutdown(shutdownCode) 90 | 91 | case Shutdown(code) => 92 | code foreach (_ => { 93 | info(s"Stopping application, shutdown code $code") 94 | implicit val executionContext: ExecutionContext = context.system.dispatcher 95 | context.system.scheduler.scheduleOnce(Duration.Zero)(System.exit(_)) 96 | }) 97 | 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/fcoin/FcoinRequest.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.fcoin 2 | 3 | import java.util.Base64 4 | 5 | import me.mbcu.integrated.mmm.ops.common.Side.Side 6 | import me.mbcu.integrated.mmm.ops.common.{AbsRestRequest, Credentials} 7 | import me.mbcu.integrated.mmm.ops.fcoin.FcoinMethod.FcoinMethod 8 | import me.mbcu.integrated.mmm.ops.fcoin.FcoinState.FcoinState 9 | import play.api.libs.json.{Json, Reads, Writes} 10 | 11 | object FcoinState extends Enumeration { 12 | type FcoinState = Value 13 | val submitted, partial_filled, partial_canceled, filled, canceled, pending_cancel = Value 14 | 15 | implicit val read = Reads.enumNameReads(FcoinState) 16 | implicit val write = Writes.enumNameWrites 17 | } 18 | 19 | object FcoinMethod extends Enumeration { 20 | type FcoinMethod = Value 21 | val POST, GET = Value 22 | } 23 | 24 | object FcoinRestRequest extends AbsRestRequest { 25 | 26 | case class FcoinParams(sign: String, params: String, ts: Long, url: String = "", js: String) 27 | 28 | def getOrders(secret: String, pair: String, state: FcoinState, after: Int) : FcoinParams = { 29 | val params = Map( 30 | "symbol" -> pair, 31 | "states" -> state.toString, 32 | "after" -> after.toString, 33 | "limit" -> 100.toString 34 | ) 35 | sign(params, secret, FcoinMethod.GET, Fcoin.endpoint.format("orders")) 36 | } 37 | 38 | 39 | def getTickers(pair: String): FcoinParams = FcoinParams("unused", pair, System.currentTimeMillis(), Fcoin.endpoint.format(s"market/ticker/$pair"), "unused") 40 | 41 | def getOrders(credentials: Credentials, pair: String, state: FcoinState, after: Int) : FcoinParams = getOrders(credentials.signature, pair, state, after) 42 | 43 | def newOrder(credentials: Credentials, pair: String, side: Side, price: BigDecimal, amount: BigDecimal) : FcoinParams 44 | = newOrder(credentials.signature, pair, side, price, amount) 45 | 46 | def newOrder(secret: String, pair: String, side: Side, price: BigDecimal, amount: BigDecimal) : FcoinParams = { 47 | val params = Map( 48 | "symbol" -> pair, 49 | "side" -> side.toString, 50 | "type" -> "limit", 51 | "price" -> price.bigDecimal.toPlainString, 52 | "amount" -> amount.bigDecimal.toPlainString 53 | ) 54 | sign(params, secret, FcoinMethod.POST, Fcoin.endpoint.format("orders")) 55 | } 56 | 57 | def cancelOrder(secret : String, orderId : String): FcoinParams = { 58 | val params = Map( 59 | "order_id" -> orderId 60 | ) 61 | 62 | val url = Fcoin.endpoint.format(s"orders/$orderId/submit-cancel") 63 | sign(params, secret, FcoinMethod.POST, url) 64 | } 65 | 66 | def cancelOrder(credentials: Credentials, orderId: String): FcoinParams = cancelOrder(credentials.signature, orderId) 67 | 68 | def getOrderInfo(secret: String, orderId: String): FcoinParams = { 69 | val params = Map( 70 | "order_id" -> orderId 71 | ) 72 | val url = Fcoin.endpoint.format(s"orders/$orderId") 73 | sign(params, secret, FcoinMethod.GET, url) 74 | } 75 | 76 | def getOrderInfo(credentials: Credentials, orderId: String): FcoinParams = getOrderInfo(credentials.signature, orderId) 77 | 78 | def sign(params: Map[String, String], secret: String, method: FcoinMethod, baseUrl: String) : FcoinParams = { 79 | val ts = System.currentTimeMillis() 80 | val jsParams = Json.toJson(params).toString() 81 | val formParams = params.toSeq.sortBy(_._1).map(c => s"${c._1}=${c._2}").mkString("&") 82 | val template = "%s%s%s%s" 83 | val res = if (method.toString == "GET") { 84 | val getUrl = "%s%s%s".format(baseUrl, "?", formParams) 85 | val formatted = template.format(method, getUrl, ts, "") 86 | (formatted, getUrl) 87 | } else { 88 | val formatted = template.format(method, baseUrl, ts, formParams) 89 | (formatted, baseUrl) 90 | } 91 | val first = Base64.getEncoder.encodeToString(res._1.getBytes) 92 | val second = signHmacSHA1(secret, first) 93 | val signed = Base64.getEncoder.encodeToString(second) 94 | FcoinParams(signed, formParams, ts, res._2, jsParams) 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/livecoin/LivecoinRequest.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.livecoin 2 | 3 | import java.net.URLEncoder 4 | import java.nio.charset.StandardCharsets 5 | 6 | import me.mbcu.integrated.mmm.ops.common.Side.Side 7 | import me.mbcu.integrated.mmm.ops.common.{AbsRestRequest, Credentials, Side} 8 | import me.mbcu.integrated.mmm.ops.livecoin.LivecoinRequest.LivecoinState.LivecoinState 9 | import play.api.libs.json.{Json, Reads, Writes} 10 | 11 | object LivecoinRequest extends AbsRestRequest { 12 | 13 | object LivecoinState extends Enumeration { 14 | type LivecoinState = Value 15 | val ALL, OPEN, CLOSED, CANCELLED, EXECUTED, NOT_CANCELLED, PARTIALLY, PARTIALLY_FILLED, PARTIALLY_FILLED_AND_CANCELLED = Value 16 | 17 | implicit val read = Reads.enumNameReads(LivecoinState) 18 | implicit val write = Writes.enumNameWrites 19 | } 20 | 21 | case class LivecoinParams(sign: String, url: String = "", params: String) 22 | 23 | def getTicker(pair: String): LivecoinParams = LivecoinParams("unused", Livecoin.endpoint.format(s"exchange/ticker?currencyPair=${urlEncode(pair)}"), "") 24 | 25 | def getOwnTrades(credentials: Credentials, pair: String, state: LivecoinState, after: Long): LivecoinParams = 26 | getOwnTrades(credentials.signature, pair, state, after) 27 | 28 | def getOwnTrades(secret: String, pair: String, state: LivecoinState, after: Long): LivecoinParams = { 29 | var params = Map( 30 | "currencyPair" -> pair, 31 | "openClosed" -> state.toString, 32 | "issuedFrom" -> after.toString 33 | ) 34 | val sorted = sortToForm(params) 35 | LivecoinParams(sign(sorted, secret), Livecoin.endpoint.format(s"exchange/client_orders?$sorted"), sorted) 36 | } 37 | 38 | def getActiveOrders(credentials: Credentials, pair: String, state: LivecoinState, startRow: Int): LivecoinParams = 39 | getActiveOrders(credentials.signature, pair, state, startRow) 40 | 41 | def getActiveOrders(secret: String, pair: String, state: LivecoinState, startRow: Int): LivecoinParams = { 42 | var params = Map( 43 | "currencyPair" -> pair, 44 | "openClosed" -> state.toString, 45 | "startRow" -> startRow.toString 46 | ) 47 | val sorted = sortToForm(params) 48 | LivecoinParams(sign(sorted, secret), Livecoin.endpoint.format(s"exchange/client_orders?$sorted"), sorted) 49 | } 50 | 51 | 52 | def newOrder(credentials: Credentials, pair: String, side: Side, price: BigDecimal, amount: BigDecimal): LivecoinParams = newOrder(credentials.signature, pair, side, price, amount) 53 | 54 | def newOrder(secret: String, pair:String, side: Side, price: BigDecimal, amount: BigDecimal): LivecoinParams = { 55 | val params = Map( 56 | "currencyPair" -> pair, 57 | "price" -> price.bigDecimal.toPlainString, 58 | "quantity" -> amount.bigDecimal.toPlainString 59 | ) 60 | val sorted = sortToForm(params) 61 | val path = if (side == Side.buy) s"exchange/buylimit" else s"exchange/selllimit" 62 | LivecoinParams(sign(sorted, secret), Livecoin.endpoint.format(path), sorted) 63 | } 64 | 65 | def cancelOrder(credentials: Credentials, pair: String, orderId: String): LivecoinParams = cancelOrder(credentials.signature, pair, orderId) 66 | 67 | def cancelOrder(secret: String, pair: String, orderId: String): LivecoinParams = { 68 | val params = Map( 69 | "currencyPair" -> pair, 70 | "orderId" -> orderId 71 | ) 72 | val sorted = sortToForm(params) 73 | LivecoinParams(sign(sorted, secret), Livecoin.endpoint.format("exchange/cancellimit"), sorted) 74 | } 75 | 76 | def getOrderInfo(credentials: Credentials, orderId: String): LivecoinParams = getOrderInfo(credentials.signature, orderId) 77 | 78 | def getOrderInfo(secret: String, orderId: String): LivecoinParams = { 79 | val params = Map( 80 | "orderId" -> orderId 81 | ) 82 | val sorted = sortToForm(params) 83 | LivecoinParams(sign(sorted, secret), Livecoin.endpoint.format(s"/exchange/order?$sorted"), sorted) 84 | } 85 | 86 | 87 | 88 | def sign(sorted:String, secret: String): String = toHex(signHmacSHA256(secret, sorted)) 89 | 90 | } 91 | 92 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/common/Offer.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.common 2 | 3 | import me.mbcu.integrated.mmm.ops.common.Side.Side 4 | import me.mbcu.integrated.mmm.ops.common.Status.Status 5 | import play.api.libs.functional.syntax._ 6 | import play.api.libs.json._ 7 | 8 | object Side extends Enumeration { 9 | type Side = Value 10 | val buy, sell = Value 11 | 12 | implicit val read = Reads.enumNameReads(Side) 13 | implicit val write = Writes.enumNameWrites 14 | 15 | def reverse(a: Side): Side = if (a == Side.buy) sell else buy 16 | def withNameOpt(s: String): Option[Value] = values.find(_.toString == s) 17 | } 18 | 19 | object Status extends Enumeration { 20 | type Status = Value 21 | val active, filled, partiallyFilled, cancelled, expired, debug = Value 22 | 23 | implicit val read = Reads.enumNameReads(Status) 24 | implicit val write = Writes.enumNameWrites 25 | } 26 | 27 | object Offer { 28 | implicit val jsonFormat = Json.format[Offer] 29 | val sortTimeDesc: Seq[Offer] => Seq[Offer] = in => in.sortWith(_.createdAt > _.createdAt) 30 | val sortTimeAsc: Seq[Offer] => Seq[Offer] = in => in.sortWith(_.createdAt < _.createdAt) 31 | 32 | def newOffer(symbol: String, side: Side, price: BigDecimal, quantity: BigDecimal): Offer = Offer("unused", symbol, side, Status.debug, -1L, None, quantity, price, None) 33 | 34 | def splitToBuysSels(in: Seq[Offer]): (Seq[Offer], Seq[Offer]) = { 35 | val t = in.partition(_.side == Side.buy) 36 | (sortBuys(t._1), sortSels(t._2)) 37 | } 38 | 39 | def sortBuys(buys: Seq[Offer]): scala.collection.immutable.Seq[Offer] = 40 | collection.immutable.Seq(buys.sortWith(_.price > _.price): _*) 41 | 42 | def sortSels(sels: Seq[Offer]): scala.collection.immutable.Seq[Offer] = 43 | collection.immutable.Seq(sels.sortWith(_.price < _.price): _*) 44 | 45 | def dump(bot: Bot, sortedBuys: Seq[Offer], sortedSels: Seq[Offer]): String = { 46 | val builder = StringBuilder.newBuilder 47 | builder.append(System.getProperty("line.separator")) 48 | builder.append(s"Open Orders ${bot.exchange}: ${bot.pair}") 49 | builder.append(System.getProperty("line.separator")) 50 | builder.append(s"sells : ${sortedSels.size}") 51 | builder.append(System.getProperty("line.separator")) 52 | sortedSels.sortWith(_.price > _.price).foreach(s => { 53 | builder.append(s"id:${s.id} quantity:${s.quantity.bigDecimal.toPlainString} price:${s.price.bigDecimal.toPlainString} filled:${s.cumQuantity.getOrElse(BigDecimal("0").bigDecimal.toPlainString)}") 54 | builder.append(System.getProperty("line.separator")) 55 | }) 56 | builder.append(s"buys : ${sortedBuys.size}") 57 | builder.append(System.getProperty("line.separator")) 58 | sortedBuys.foreach(b => { 59 | builder.append(s"id:${b.id} quantity:${b.quantity.bigDecimal.toPlainString} price:${b.price.bigDecimal.toPlainString} filled:${b.cumQuantity.getOrElse(BigDecimal("0").bigDecimal.toPlainString)}") 60 | builder.append(System.getProperty("line.separator")) 61 | }) 62 | 63 | builder.toString() 64 | } 65 | 66 | object Implicits { 67 | implicit val writes: Writes[Offer] = (o: Offer) => Json.obj( 68 | "id" -> o.id, 69 | "symbol" -> o.symbol, 70 | "side" -> o.side, 71 | "status" -> o.status, 72 | "createdAt" -> o.createdAt, 73 | "updatedAt" -> o.updatedAt, 74 | "quantity" -> o.quantity, 75 | "price" -> o.price, 76 | "cumQuantity" -> o.cumQuantity 77 | ) 78 | 79 | implicit val reads: Reads[Offer] = ( 80 | (JsPath \ "id").read[String] and 81 | (JsPath \ "symbol").read[String] and 82 | (JsPath \ "side").read[Side] and 83 | (JsPath \ "status").read[Status] and 84 | (JsPath \ "createdAt").read[Long] and 85 | (JsPath \ "updatedAt").readNullable[Long] and 86 | (JsPath \ "quantity").read[BigDecimal] and 87 | (JsPath \ "price").read[BigDecimal] and 88 | (JsPath \ "cumQuantity").readNullable[BigDecimal] 89 | ) (Offer.apply _) 90 | } 91 | 92 | } 93 | 94 | case class Offer( 95 | id: String, 96 | symbol: String, 97 | side: Side, 98 | status: Status, 99 | createdAt: Long, 100 | updatedAt: Option[Long], 101 | quantity: BigDecimal, 102 | price: BigDecimal, 103 | cumQuantity: Option[BigDecimal] 104 | ) 105 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/common/AbsRestActor.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.common 2 | 3 | import akka.actor.{Actor, ActorRef} 4 | import me.mbcu.integrated.mmm.ops.Definitions.ShutdownCode.ShutdownCode 5 | import me.mbcu.integrated.mmm.ops.Definitions.{ErrorIgnore, ErrorRetryRest, ErrorShutdown} 6 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor.As.As 7 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor._ 8 | 9 | object AbsRestActor { 10 | 11 | object As extends Enumeration { 12 | type As = Value 13 | val Seed, Trim, Counter, ClearOpenOrders, KillDupes, RoutineCheck, Init = Value 14 | } 15 | 16 | trait SendRest{ 17 | def as:As 18 | def bot: Bot 19 | def book: ActorRef 20 | } 21 | 22 | trait GotRest{ 23 | def arriveMs : Long 24 | def send : SendRest 25 | } 26 | 27 | case class GetActiveOrders(lastMs: Long, cache: Seq[Offer], page: Int, override val as: As)(implicit val bot:Bot, implicit val book:ActorRef) extends SendRest 28 | 29 | case class GetFilledOrders(lastCounterId: String, override val as: As)(implicit val bot:Bot, implicit val book:ActorRef) extends SendRest 30 | 31 | case class CancelOrder(offer: Offer, as: As)(implicit val bot:Bot, implicit val book:ActorRef) extends SendRest 32 | 33 | case class NewOrder(offer: Offer, as: As)(implicit val bot:Bot, implicit val book:ActorRef) extends SendRest 34 | 35 | case class GetTickerStartPrice(override val as:As)(implicit val bot:Bot, implicit val book:ActorRef) extends SendRest 36 | 37 | case class GetOrderInfo(id: String, as: As)(implicit val bot:Bot, implicit val book:ActorRef) extends SendRest 38 | 39 | case class GetOwnPastTrades(override val as:As)(implicit val bot:Bot, implicit val book:ActorRef) extends SendRest 40 | 41 | case class GotNewOrderId(id: String, as: As, override val arriveMs: Long, override val send: NewOrder) extends GotRest 42 | 43 | case class GotTickerStartPrice(price: Option[BigDecimal], override val arriveMs: Long, override val send: GetTickerStartPrice) extends GotRest 44 | 45 | case class GotLastOwnStartPrice(price: Option[BigDecimal], override val arriveMs: Long, override val send: SendRest) extends GotRest 46 | 47 | case class GotOrderInfo(offer: Offer, override val arriveMs: Long, override val send: GetOrderInfo) extends GotRest 48 | 49 | case class GotOrderCancelled(as: As, override val arriveMs: Long, override val send: CancelOrder) extends GotRest 50 | 51 | case class GotActiveOrders(offers: Seq[Offer], currentPage: Int, nextPage: Boolean, override val arriveMs: Long, override val send: GetActiveOrders) extends GotRest 52 | 53 | case class GotUncounteredOrders(offers: Seq[Offer], latestCounterId: Option[String], isSortedFromOldest: Boolean = true, override val arriveMs: Long, override val send: GetFilledOrders) extends GotRest 54 | 55 | case class GotStartPrice(price: Option[BigDecimal], override val arriveMs: Long, override val send: SendRest) extends GotRest 56 | 57 | case class GotProvisionalOffer(id: String, provisionalTs: Long, offer: Offer) // this comes after NewOrder with id and provisionalTs (used to correctly remove duplicates) 58 | 59 | object StartRestActor 60 | 61 | } 62 | 63 | abstract class AbsRestActor() extends Actor { 64 | var op : Option[ActorRef] = None 65 | 66 | def sendRequest(r: SendRest) 67 | 68 | def setOp(op: Option[ActorRef]) : Unit = this.op = op 69 | 70 | def start() 71 | 72 | def errorRetry(sendRequest: SendRest, code: Int, msg: String, shouldEmail: Boolean = true): Unit = op foreach (_ ! ErrorRetryRest(sendRequest, code, msg, shouldEmail)) 73 | 74 | def errorShutdown(shutdownCode: ShutdownCode, code: Int, msg: String): Unit = op foreach (_ ! ErrorShutdown(shutdownCode, code, msg)) 75 | 76 | def errorIgnore(code: Int, msg: String, shouldEmail: Boolean = false): Unit = op foreach (_ ! ErrorIgnore(code, msg, shouldEmail)) 77 | 78 | override def receive: Receive = { 79 | 80 | case StartRestActor => start() 81 | 82 | case a: SendRest => sendRequest(a) 83 | 84 | } 85 | 86 | def stringifyXWWWForm(params: Map[String, String]): String = params.map(r => s"${r._1}=${r._2}").mkString("&") 87 | 88 | def logResponse(a: SendRest, raw: String): String = { 89 | 90 | def bound(r: String, max: Int): String = if (raw.length > max) s"${r.substring(0, max)}..." + "..." else r 91 | 92 | val response = a match { 93 | case t: GetFilledOrders => bound(raw, max = 500) 94 | 95 | case t: GetActiveOrders => bound(raw, max = 500) 96 | 97 | case _ => raw 98 | } 99 | s""" 100 | |Request: As: $a, ${a.bot.exchange} : ${a.bot.pair} 101 | |Response: 102 | |$response 103 | """.stripMargin 104 | } 105 | 106 | val requestTimeoutSec = 10 107 | 108 | } 109 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/hitbtc/HitbtcRequest.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.hitbtc 2 | 3 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor.As 4 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor.As.As 5 | import me.mbcu.integrated.mmm.ops.common.AbsWsParser.SendWs 6 | import me.mbcu.integrated.mmm.ops.common.Side.Side 7 | import me.mbcu.integrated.mmm.ops.common.{AbsWsRequest, Credentials, Offer, Side} 8 | import play.api.libs.json._ 9 | 10 | object HitbtcStatus extends Enumeration { 11 | type HitbtcStatus = Value 12 | val `new`, suspended, partiallyFilled, filled, canceled, expired = Value 13 | 14 | implicit val read = Reads.enumNameReads(HitbtcStatus) 15 | implicit val write = Writes.enumNameWrites 16 | } 17 | 18 | object HitbtcRequest extends AbsWsRequest{ 19 | 20 | def sha256Hash(text: String) : String = String.format("%064x", new java.math.BigInteger(1, java.security.MessageDigest.getInstance("SHA-256").digest(text.getBytes("UTF-8")))) 21 | 22 | def requestId(pair: String, method: String) : String = s"$method.$pair" // requestId has no length limit 23 | 24 | def orderId(offer: Offer): String = { // has to be at most 32 chars 25 | val prefix = s"${offer.symbol}.${offer.side}." 26 | val random = scala.util.Random.alphanumeric.take(15).mkString 27 | val hash = sha256Hash(random + System.currentTimeMillis()).substring(0, 32 - prefix.length) 28 | s"$prefix$hash" 29 | } 30 | 31 | def pairFromId(id :String) : String = id.split("[.]")(1) 32 | 33 | def clientOrderIdFrom(id: String): String = { 34 | val p = id.split("[.]") 35 | s"${p(1)}.${p(2)}.${p(3)}" 36 | } 37 | 38 | def sideFromId(id : String) : Side = id match { 39 | case a if a contains ".sell." => Side.sell 40 | case _ => Side.buy 41 | } 42 | 43 | override def login(credentials: Credentials): SendWs = { 44 | val method = "login" 45 | val requestId = "login" 46 | SendWs( 47 | requestId, 48 | Json.parse( 49 | s""" 50 | |{ 51 | | "params": { 52 | | "algo": "HS256", 53 | | "pKey": "${credentials.pKey}", 54 | | "nonce": "${credentials.nonce}", 55 | | "signature": "${credentials.signature}" 56 | | }, 57 | | "method": "$method", 58 | | "id": "$requestId" 59 | |} 60 | """.stripMargin), 61 | As.Init 62 | 63 | ) 64 | } 65 | 66 | override val subscribe: SendWs = { 67 | val method = "subscribeReports" 68 | val requestId = "subscribeReports" 69 | SendWs( 70 | requestId, 71 | Json.parse( 72 | s""" 73 | |{ 74 | | "method": "$method", 75 | | "id": "$requestId", 76 | | "params": {} 77 | |} 78 | """.stripMargin), 79 | As.Init 80 | ) 81 | } 82 | 83 | override def cancelOrder(orderId: String, as: As): SendWs = { 84 | val method = "cancelOrder" 85 | val requestId = HitbtcRequest.requestId(orderId, method) 86 | SendWs(requestId, 87 | Json.parse ( 88 | s""" 89 | |{ 90 | | "method": "$method", 91 | | "params": { 92 | | "clientOrderId": "$orderId" 93 | | }, 94 | | "id": "$requestId" 95 | |} 96 | """.stripMargin 97 | ), 98 | as 99 | ) 100 | } 101 | 102 | override def newOrder(offer: Offer, as: As): SendWs = { 103 | val method = "newOrder" 104 | val requestId = HitbtcRequest.requestId(offer.id, method) 105 | 106 | SendWs(requestId, 107 | Json.parse( 108 | s""" 109 | |{ 110 | | "method": "$method", 111 | | "params": { 112 | | "clientOrderId": "${offer.id}", 113 | | "symbol": "${offer.symbol}", 114 | | "side": "${offer.side.toString}", 115 | | "price": "${offer.price.bigDecimal.toPlainString}", 116 | | "quantity": "${offer.quantity.bigDecimal.toPlainString}" 117 | | }, 118 | | "id": "$requestId" 119 | |} 120 | """.stripMargin), 121 | as 122 | ) 123 | } 124 | 125 | val marketSubs: String = 126 | """ 127 | |{ 128 | | "method": "%s", 129 | | "id": "%s", 130 | | "params": { 131 | | "symbol": "%s" 132 | | } 133 | |} 134 | """.stripMargin 135 | 136 | override def subsTicker(pair: String): SendWs = { 137 | val method = "subscribeTicker" 138 | val requestId = HitbtcRequest.requestId(pair, method) 139 | SendWs(requestId, Json.parse(marketSubs.format(method, requestId, pair)), As.Init) 140 | } 141 | 142 | 143 | override def unsubsTicker(pair: String): SendWs = { 144 | val method = "unsubscribeTicker" 145 | val requestId = HitbtcRequest.requestId(pair, method) 146 | SendWs(requestId, Json.parse(marketSubs.format(method, requestId, pair)), As.Init) 147 | } 148 | 149 | } 150 | 151 | 152 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/common/Config.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.common 2 | 3 | import me.mbcu.integrated.mmm.ops.Definitions.Exchange.Exchange 4 | import me.mbcu.integrated.mmm.ops.Definitions.Strategies.Strategies 5 | import play.api.libs.functional.syntax._ 6 | import play.api.libs.json._ 7 | 8 | 9 | case class Credentials(pKey: String, nonce: String, signature: String) 10 | 11 | object Credentials { 12 | implicit val jsonFormat: OFormat[Credentials] = Json.format[Credentials] 13 | } 14 | 15 | case class Env(emails: Option[Seq[String]], sesKey: Option[String], sesSecret: Option[String], logSeconds: Int) 16 | 17 | object Env { 18 | implicit val jsonFormat: OFormat[Env] = Json.format[Env] 19 | 20 | object Implicits { 21 | implicit val envWrites: Writes[Env] { 22 | def writes(env: Env): JsValue 23 | } = new Writes[Env] { 24 | def writes(env: Env): JsValue = Json.obj( 25 | "emails" -> env.emails, 26 | "sesKey" -> env.sesKey, 27 | "sesSecret" -> env.sesSecret, 28 | "logSeconds" -> env.logSeconds 29 | ) 30 | } 31 | 32 | implicit val envReads: Reads[Env] = ( 33 | (JsPath \ "emails").readNullable[Seq[String]] and 34 | (JsPath \ "sesKey").readNullable[String] and 35 | (JsPath \ "sesSecret").readNullable[String] and 36 | (JsPath \ "logSeconds").read[Int] 37 | ) (Env.apply _) 38 | } 39 | 40 | } 41 | 42 | object StartMethods extends Enumeration { 43 | type StartMethods = Value 44 | val lastTicker, lastOwn, cont = Value 45 | } 46 | 47 | case class Bot( 48 | exchange: Exchange, 49 | credentials: Credentials, 50 | pair: String, 51 | seed : String, 52 | gridSpace: BigDecimal, 53 | buyGridLevels: Int, 54 | sellGridLevels: Int, 55 | buyOrderQuantity: BigDecimal, 56 | sellOrderQuantity: BigDecimal, 57 | quantityPower: Int, 58 | maxPrice: Option[BigDecimal], 59 | minPrice: Option[BigDecimal], 60 | counterScale: Int, 61 | baseScale: Int, 62 | isStrictLevels: Boolean, 63 | isNoQtyCutoff: Boolean, 64 | strategy: Strategies 65 | ) 66 | 67 | object Bot { 68 | val millisFileFormat = "%s_%s" 69 | 70 | def filePath(bot: Bot):String = bot.pair.replaceAll("/", "_").toLowerCase() 71 | 72 | implicit val jsonFormat: OFormat[Bot] = Json.format[Bot] 73 | 74 | object Implicits { 75 | implicit val botWrites: Writes[Bot] { 76 | def writes(bot: Bot): JsValue 77 | } = new Writes[Bot] { 78 | def writes(bot: Bot): JsValue = Json.obj( 79 | "exchange" -> bot.exchange, 80 | "credentials" -> bot.credentials, 81 | "pair" -> bot.pair, 82 | "seed" -> bot.seed, 83 | "gridSpace" -> bot.gridSpace, 84 | "buyGridLevels" -> bot.buyGridLevels, 85 | "sellGridLevels" -> bot.sellGridLevels, 86 | "buyOrderQuantity" -> bot.buyOrderQuantity, 87 | "sellOrderQuantity" -> bot.sellOrderQuantity, 88 | "quantityPower" -> bot.quantityPower, 89 | "maxPrice" -> bot.maxPrice, 90 | "minPrice" -> bot.minPrice, 91 | "counterScale" -> bot.counterScale, 92 | "baseScale" -> bot.baseScale, 93 | "isStrictLevels" -> bot.isStrictLevels, 94 | "isNoQtyCutoff" -> bot.isNoQtyCutoff, 95 | "strategy" -> bot.strategy 96 | ) 97 | } 98 | 99 | implicit val botReads: Reads[Bot] = ( 100 | (JsPath \ "ops").read[Exchange] and 101 | (JsPath \ "credentials").read[Credentials] and 102 | (JsPath \ "pair").read[String] and 103 | (JsPath \ "seed").read[String] and 104 | (JsPath \ "gridSpace").read[BigDecimal] and 105 | (JsPath \ "buyGridLevels").read[Int] and 106 | (JsPath \ "sellGridLevels").read[Int] and 107 | (JsPath \ "buyOrderQuantity").read[BigDecimal] and 108 | (JsPath \ "sellOrderQuantity").read[BigDecimal] and 109 | (JsPath \ "quantityPower").read[Int] and 110 | (JsPath \ "maxPrice").readNullable[BigDecimal] and 111 | (JsPath \ "minPrice").readNullable[BigDecimal] and 112 | (JsPath \ "counterScale").read[Int] and 113 | (JsPath \ "baseScale").read[Int] and 114 | (JsPath \ "isStrictLevels").read[Boolean] and 115 | (JsPath \ "isNoQtyCutoff").read[Boolean] and 116 | (JsPath \ "strategy").read[Strategies] 117 | ) (Bot.apply _) 118 | } 119 | 120 | } 121 | 122 | case class Config(env: Env, bots: List[Bot]) 123 | 124 | object Config { 125 | implicit val jsonFormat: OFormat[Config] = Json.format[Config] 126 | 127 | def groupBots(bots: Seq[Bot]) : Map[Exchange , Seq[Bot]] = { 128 | bots.map(p => (p.exchange, p.pair) -> p) 129 | .groupBy(_._1) 130 | .mapValues(_.map(_._2).head) 131 | .groupBy(_._1._1) 132 | .mapValues(_.values.toList) 133 | } 134 | 135 | } 136 | 137 | -------------------------------------------------------------------------------- /src/test/scala/me/mbcu/integrated/mmm/ops/fcoin/FcoinActorTest.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.fcoin 2 | 3 | import me.mbcu.integrated.mmm.ops.common.Status 4 | import org.scalatest.FunSuite 5 | import play.api.libs.json.{JsValue, Json} 6 | 7 | class FcoinActorTest extends FunSuite{ 8 | 9 | test("parse response to offer (filled") { 10 | val a = 11 | """ 12 | |{ 13 | | "status": 0, 14 | | "data": [ 15 | | { 16 | | "id": "h_tbQC5CJIbBeCgtIq3f3KUP_3ODRt6mlw4W9m7MwVg=", 17 | | "symbol": "xrpusdt", 18 | | "amount": "3.040000000000000000", 19 | | "price": "0.440000000000000000", 20 | | "created_at": 1532066585373, 21 | | "type": "limit", 22 | | "side": "buy", 23 | | "filled_amount": "3.040000000000000000", 24 | | "executed_value": "1.337600000000000000", 25 | | "fill_fees": "0.003040000000000000", 26 | | "source": "api", 27 | | "state": "filled" 28 | | }, 29 | | { 30 | | "id": "IUDiFCdkTPatOu0Lpu5TD3fRVMD_nYokzgrY2ggiRZY=", 31 | | "symbol": "xrpusdt", 32 | | "amount": "3.020000000000000000", 33 | | "price": "0.450000000000000000", 34 | | "created_at": 1532066584567, 35 | | "type": "limit", 36 | | "side": "buy", 37 | | "filled_amount": "3.020000000000000000", 38 | | "executed_value": "1.359000000000000000", 39 | | "fill_fees": "0.003020000000000000", 40 | | "source": "api", 41 | | "state": "filled" 42 | | }, 43 | | { 44 | | "id": "UfqKwFqGPaIQax6xMml0G-oUdY19AobGxmJNKXfk4Z8=", 45 | | "symbol": "xrpusdt", 46 | | "amount": "14.920000000000000000", 47 | | "price": "0.478400000000000000", 48 | | "created_at": 1531844657648, 49 | | "type": "limit", 50 | | "side": "sell", 51 | | "filled_amount": "14.920000000000000000", 52 | | "executed_value": "7.137728000000000000", 53 | | "fill_fees": "0.007137728000000000", 54 | | "source": "api", 55 | | "state": "filled" 56 | | }, 57 | | { 58 | | "id": "NO5qUaGqzppONSEmlor3iYiUPhTYNgko3zsLCVdjFAk=", 59 | | "symbol": "xrpusdt", 60 | | "amount": "15.080000000000000000", 61 | | "price": "0.473600000000000000", 62 | | "created_at": 1531844368429, 63 | | "type": "limit", 64 | | "side": "buy", 65 | | "filled_amount": "15.080000000000000000", 66 | | "executed_value": "7.141888000000000000", 67 | | "fill_fees": "0.015080000000000000", 68 | | "source": "api", 69 | | "state": "filled" 70 | | }, 71 | | { 72 | | "id": "RcHxUSl3u3jCdBcnH196hzUgYpEc2xV5i-_XYXxgfCk=", 73 | | "symbol": "xrpusdt", 74 | | "amount": "300.000000000000000000", 75 | | "price": "0.463200000000000000", 76 | | "created_at": 1531751435077, 77 | | "type": "limit", 78 | | "side": "sell", 79 | | "filled_amount": "300.000000000000000000", 80 | | "executed_value": "138.960000000000000000", 81 | | "fill_fees": "0.138960000000000000", 82 | | "source": "web", 83 | | "state": "filled" 84 | | } 85 | | ] 86 | |} 87 | """.stripMargin 88 | 89 | 90 | val data = Json.parse(a) \ "data" 91 | val res1 = FcoinActor.parseFilled(data, "NO5qUaGqzppONSEmlor3iYiUPhTYNgko3zsLCVdjFAk=") 92 | assert(res1._1.size === 3) 93 | assert(res1._2 === Some("h_tbQC5CJIbBeCgtIq3f3KUP_3ODRt6mlw4W9m7MwVg=")) 94 | 95 | val res2 = FcoinActor.parseFilled(data, "id not exist") // all offers are uncountered 96 | assert(res2._1.size === 5) 97 | } 98 | 99 | test( "parse response to offer (submitted)") { 100 | val a = 101 | """ 102 | |{ 103 | | "status": 0, 104 | | "data": { 105 | | "id": "YnkBPuViqnLSJMh5p2t3wAqJ042mZOt93A2TVUCSqIs=", 106 | | "symbol": "ethusdt", 107 | | "amount": "0.001900000000000000", 108 | | "price": "477.570000000000000000", 109 | | "created_at": 1531803578106, 110 | | "type": "limit", 111 | | "side": "sell", 112 | | "filled_amount": "0.000000000000000000", 113 | | "executed_value": "0.000000000000000000", 114 | | "fill_fees": "0.000000000000000000", 115 | | "source": "api", 116 | | "state": "submitted" 117 | | } 118 | |} 119 | """.stripMargin 120 | 121 | val js = Json.parse(a) 122 | val data = (js \ "data").as[JsValue] 123 | val offer = FcoinActor.toOffer(data) 124 | assert(offer.status === Status.active) 125 | 126 | } 127 | 128 | 129 | } 130 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/actors/OpDdexActor.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.actors 2 | 3 | import java.math.BigInteger 4 | 5 | import akka.actor.{Actor, ActorRef, Cancellable} 6 | import akka.dispatch.ExecutionContexts.global 7 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor.{NewOrder, SendRest} 8 | import me.mbcu.integrated.mmm.ops.common.{AbsExchange, AbsOpActor, Bot} 9 | import me.mbcu.scala.MyLogging 10 | import org.bouncycastle.util.encoders.Hex 11 | import org.web3j.crypto.{Credentials, ECKeyPair, Sign} 12 | 13 | import scala.concurrent.ExecutionContextExecutor 14 | 15 | class OpDdexActor(exchangeDef: AbsExchange, bots: Seq[Bot], fileActor: ActorRef) extends AbsOpActor(exchangeDef, bots, fileActor) with MyLogging{ 16 | private implicit val ec: ExecutionContextExecutor = global 17 | private var base: Option[ActorRef] = None 18 | private var rest: Option[ActorRef] = None 19 | private var dqCancellable: Option[Cancellable] = None 20 | private val q = new scala.collection.mutable.Queue[SendRest] 21 | private val nos = scala.collection.mutable.Set[NewOrder]() 22 | 23 | override def receive: Receive = { 24 | 25 | case "start" => 26 | base = Some(sender()) 27 | self ! "test" 28 | 29 | //7196933fe363871920c59be78aa5c478bf6e6271532db5d0ce3b090518f91f03 30 | case "test" => 31 | // val privateKey = "" 32 | // import org.web3j.crypto.Hash 33 | // import org.web3j.utils.Numeric 34 | // val test = "HYDRO-AUTHENTICATION@" + "aa" 35 | // val hash = Hash.sha3(Numeric.toHexStringNoPrefix(test.getBytes)) 36 | // val message = "\\u0019Ethereum Signed Message:\n" + hash.length + hash 37 | // val message2 = "\\\\x19Ethereum Signed Message:\n" + hash.length + hash 38 | // 39 | // println(hash) 40 | // 41 | // println(s"Message: $message") 42 | // 43 | // val cred = Credentials.create(privateKey) 44 | // println(s"Address:${cred.getAddress}") 45 | // val signatureData = Sign.signMessage(message.getBytes(), cred.getEcKeyPair) 46 | // import org.web3j.utils.Numeric 47 | // val r = Numeric.toHexString(signatureData.getR) 48 | // val s = Numeric.toHexString(signatureData.getS) 49 | // val v = "%02X".format(signatureData.getV) 50 | // 51 | // val signed = s"${Numeric.toHexString(signatureData.getR)}${Numeric.toHexString(signatureData.getS).substring(2)}${"%02X".format(signatureData.getV).toLowerCase()}" 52 | // println(signed) 53 | 54 | import org.web3j.crypto.Credentials 55 | import org.web3j.crypto.Hash 56 | import org.web3j.crypto.Sign 57 | import org.web3j.utils.Numeric 58 | val privateKey1 = "fba4137335dc20dc23ad3dcd9f4ad728370b09131a6a14abf6c839748700780d" 59 | val credentials = Credentials.create(privateKey1) 60 | System.out.println("Address: " + credentials.getAddress) 61 | 62 | // val hash = Hash.sha3(Numeric.toHexStringNoPrefix("TEST".getBytes)) 63 | // System.out.println("Hash" + hash) 64 | // 65 | // val message = " val message = "\u0019Ethereum Signed Message:\n32" + hash.length + hash 66 | // println(s"Message: $message")" + hash.length + hash 67 | // println(s"Message: $message") 68 | // 69 | // val data = message.getBytes 70 | // 71 | // val signature = Sign.signMessage(data, credentials.getEcKeyPair) 72 | // 73 | // System.out.println("R: " + Numeric.toHexString(signature.getR)) 74 | // System.out.println("S: " + Numeric.toHexString(signature.getS)) 75 | // System.out.println("V: " + Integer.toString(signature.getV)) 76 | // val signed = s"${Numeric.toHexString(signature.getR)}${Numeric.toHexString(signature.getS).substring(2)}${"%02X".format(signature.getV).toLowerCase()}" 77 | // println(signed) 78 | // 79 | // 80 | // val pub = Sign.signedMessageToKey(message.getBytes, signature) 81 | // println(s"Pub key: $pub") 82 | // println(credentials.getEcKeyPair.getPublicKey) 83 | 84 | // sign(keccak256("\x19Ethereum Signed Message:\n" + len(message) + message))) 85 | val test = "TEST" 86 | val message = "\u0019Ethereum Signed Message:\n32" + test.length + test 87 | val hash = Hash.sha3(message) 88 | val signature = Sign.signMessage(hash.getBytes, credentials.getEcKeyPair) 89 | val r = Numeric.toHexString(signature.getR) 90 | val s = Numeric.toHexString(signature.getS) 91 | val v = "%02X".format(signature.getV) 92 | val signed = s"${Numeric.toHexString(signature.getR)}${Numeric.toHexString(signature.getS).substring(2)}${"%02X".format(signature.getV).toLowerCase()}" 93 | println(signed) 94 | //0x5d46da45a1af583990eeb142199e593480d0b1e5f88903e287ff67209140dcc646384d275b500ded85ca16dea5ff369532a27fc92721189cef68d652ab18580a1c 95 | 96 | // val sData = new org.web3j.crypto.Sign.SignatureData( 97 | // 98 | // new BigInteger(v, 16).toByteArray, 99 | // Hex.decode(r), 100 | // Hex.decode(s)) 101 | // val extractedPublicKey = org.web3j.crypto.Sign.signedMessageToKey(signed.getBytes, sData).toString(16) 102 | // println(extractedPublicKey) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MMM: Integrated Crypto Market Making Bot 2 | 3 | An automated grid/ping-pong market-making bot supporting various exchanges. 4 | 5 | In general, market making works by placing orders on both buy and sell sides. When an order is filled, the bot counters it by placing a new order on the opposing side.
6 | The bot also maintains the number of order levels by seeding (placing new orders) or trimming (cancelling extra orders) as defined in config. 7 | In this bot only filled orders are countered. It doesn't counter partially-filled orders. 8 | 9 | As having multiple orders on the same price and quantity is undesirable the bot will also remove such duplicates starting from the newest one. 10 | 11 | One bot (defined in config's `bots`) should operate on one symbol (pair) only. 12 | 13 | Supported exchanges: 14 | - HitBtc 15 | - Okex 16 | - Yobit 17 | - Fcoin 18 | - Livecoin 19 | - Btcalpha 20 | 21 | [Exchanges](./EXCHANGES.md) 22 | 23 | [Operation](./OPERATION.md) 24 | 25 | [Version notes](./VERSIONS.md) 26 | 27 | ## Running 28 | Use [config-template](./config-template) as config reference. 29 | 30 | One bot in [bots] will market-make on one pair. 31 | 32 | Clone the repo and run this to build and package it into a fat jar. 33 | 34 | ``` 35 | sbt assembly 36 | ``` 37 | 38 | (result jar is in /target) 39 | 40 | To run 41 | 42 | ``` 43 | java -jar program.jar 44 | ``` 45 | 46 | ## Config 47 | 48 | #### exchange 49 | 50 | Type: `string`
51 | 52 | Name of the exchange. See [Exchanges](./EXCHANGES.md) for supported exchanges. 53 | 54 | #### credentials 55 | 56 | **pKey** 57 | 58 | Type: `string`
59 | 60 | Api Key 61 | 62 | **nonce** 63 | 64 | Type: `string`
65 | 66 | Nonce (used for Hitbtc). Set empty string if it's not needed. 67 | 68 | **signature** 69 | 70 | Type: `string`
71 | 72 | Secret key or signed nonce (Hitbtc). 73 | 74 | #### pair 75 | 76 | Type: `string`
77 | 78 | Pair or symbol in format accepted by the exchange. 79 | 80 | #### seed 81 | 82 | Type: `string`
83 | Enum: `lastTicker`, `cont`, or any valid number as starting price. 84 | 85 | Seed determines how the bot starts. Refer to Supported Exchanges for allowed methods. 86 | 87 | **lastTicker**, **any valid number**: cancels all active orders, seeds new orders from config. Any uncountered orders during the time the bot is not running will not be countered.
88 | This method is used when starting fresh or when config was changed. 89 | 90 | **lastOwn** : uses last traded price as new seed price. As the amount information is lost, amount is taken from config. 91 | 92 | **cont**: continues from last session. Any uncountered orders during the time the bot is not running will be countered.
93 | To correctly run this method, the account **needs to have some active orders** and the bot should not be inactive longer than exchange's trade history retention. Some exchanges keep trade history for 2 days so if the bot is restarted later, it may not be able to retrieve all filled orders. 94 | 95 | #### gridSpace 96 | 97 | Type: `string`
98 | 99 | Price level between orders, behavior determined by strategy. 100 | 101 | #### buyGridLevels, sellGridLevels 102 | 103 | Type: `int`
104 | 105 | Number of orders to be maintained in orderbook. 106 | 107 | #### buyOrderQuantity, sellOrderQuantity 108 | 109 | Type: `string`
110 | 111 | Amount of order. 112 | 113 | #### quantityPower 114 | 115 | Type: `int`
116 | 117 | Only used in **ppt**. Rate of quantity appreciation / depreciation which is denoted as (sqrt(gridSpace))^quantityPower. 118 | 119 | #### counterScale, baseScale 120 | 121 | Type: `int`
122 | 123 | Scale of minimum amount of counter / base currency accepted by the exchange. 124 | 125 | Examples: 126 | - If minimum quantity 1 -> scale = 0 127 | - If minimum quantity 0.001 -> scale = 3 128 | - If minimum quantity 1000 -> scale = -3 129 | 130 | #### isHardReset (deprecated) 131 | 132 | Type: `boolean`
133 | 134 | If true then when the bot starts it will remove all orders and seed new orders with recalculated middle price. This method will clear any holes (missing levels) but lose all ping/pong information from the old orders. 135 | 136 | If false then the bot will only fill the hole closest to market middle price. This will preserve the ping/pong info of each order but not fill all possible holes. 137 | 138 | 139 | #### isStrictLevels 140 | 141 | Type: `boolean`
142 | 143 | If true then number of orders is kept according to buyGridLevels pr sellGridLevels by removing tail orders. WARNING : this may cause holes in orderbook. 144 | 145 | #### isNoQtyCutoff 146 | 147 | Type: `boolean`
148 | 149 | If true then order's quantity will never become zero. Instead it will be replaced with the currency's minimum amount. 150 | 151 | 152 | #### maxPrice, minPrice (Optional) 153 | 154 | Type: `string`
155 | 156 | Maximum and minimum price the bot will operate on 157 | 158 | #### strategy 159 | 160 | Type: `string`
161 | 162 | Strategy to be used. Refer to strategy section for valid names. 163 | 164 | 165 | ## Strategies 166 | 167 | ### Proportional 168 | 169 | code: `ppt`
170 | 171 | Both base quantity and unit price are spaced by percentage point of the previous offerCreate level. 172 | 173 | For sell direction p1 = (1 + gridSpace / 100) * p0 and q1 = q0 / ((1 + gridSpace / 100)^0.5)^quantityPower 174 | 175 | For buy direction p1 = p0 / (1 + gridSpace / 100) and q1 = q0 * ((1 + gridSpace / 100)^0.5)^quantityPower 176 | 177 | Pay attention minimum quantity. Ideally minimum quantity should be smaller than (gridSpace / 100 * quantity) 178 | 179 | ### Full-fixed 180 | 181 | Code: `fullfixed`
182 | 183 | The new unit price is spaced rigidly by gridSpace, e.g. if a buy order with price X is consumed then a new sell order selling the same quantity with price X + gridSpace will be created. 184 | 185 | If a sell order with price Y is consumed then a buy order with the same quantity and price Y - gridSpace will be created. 186 | 187 | 188 | 189 | -------------------------------------------------------------------------------- /VERSIONS.md: -------------------------------------------------------------------------------- 1 | 1.1.1 2 | - added error log on FCoin undetermined response 3 | 4 | 1.1.0 5 | - fixed OrderRestActor, initseed happened to fast before ticker price arrived 6 | 7 | 1.0.9 8 | - replaced local MyLogging 9 | 10 | 1.0.8 11 | - Added timeout mechanism in websocket 12 | - undo onDisconnect method 13 | 14 | 1.0.7 15 | - WsActor now doesn't wait for onDisconnect to reconnect 16 | 17 | 1.0.6 18 | - added Livecoin error condition 19 | - added rest timeout handler (Livecoin suffers from super long timeout) 20 | 21 | 1.0.5 22 | - fixed orderGI: counter the order cache which has the original order info not the API response 23 | 24 | 1.0.4 25 | - fixed orderGI: order info should update offer not replace it 26 | 27 | 1.0.2 28 | - minor fix Livecoin filled amount 29 | 30 | 1.0.1 31 | - escaped secret for Btcalpha 32 | 33 | 1.0.0 34 | - added Hitbtc 35 | - automatic websocket reconnect and cache drain 36 | 37 | 0.8.3 38 | - fixed Btcalpha pair name and request 39 | 40 | 0.8.2 41 | - Livecoin added errorRetry "Cannot get a connection" 42 | - first version of untested integrated HitBtc 43 | 44 | 0.8.1 45 | - changed yobit endpoint to yobit.io 46 | - changed yobit rate to 1500ms 47 | 48 | 0.8.0 49 | - added bitcoinalpha 50 | 51 | 0.7.2 52 | - refactored Livecoin getOrder 53 | - removed Livecoin pagination (doesn't work) 54 | 55 | 0.7.1 56 | - added Livecoin status 57 | 58 | 0.7.0 59 | - added Livecoin 60 | - Bitcalpha in progress 61 | 62 | 0.6.1 63 | - fixed Yobit status 3 from filled to partially-filled 64 | 65 | 0.6.0 66 | - brought back GI (GetOrderInfo) method: checking order status by calling GetOrderInfo 67 | - avoided waiting for GetOrderInfo by using provisional offer: offer whose values come from request and id from newOrder 68 | - fixed find duplicates: returns more than one if more than one dupe 69 | - seed starts only when theres' no NewOrder and CancelOrder 70 | - ProvisionalOffer is set with serverTs to allow duplicates removal 71 | - trims and remove duplicates requests are now merged 72 | 73 | 0.5.4 74 | - added KillDupes enum 75 | - more readme 76 | 77 | 0.5.3 78 | - made getActive arrive before getFilled: seed happens before counter 79 | - added delete duplicates 80 | 81 | 0.5.2 82 | - fixed bug in grow 83 | 84 | 0.5.1 85 | - fixed bug on unfunded newOrder : needs to clear the set in op for each successful request 86 | - fixed wiring 87 | 88 | 0.5.0 89 | - revamped 90 | - input last order sent/ts 91 | - active orders cannot queue when new orders are in q 92 | - active orders need to be partitioned and sorted 93 | - active orders : seed and trim, filled orders : counter. no dependency relation between them 94 | - counter mechanism: store last countered id on bot cache, read it before getFilled is called, and use it as lower bound of all uncountered orders 95 | - different exchange has different indicator 96 | 97 | 0.4.3 98 | - fixed small bug in dump 99 | 100 | 0.4.2 101 | - counter needs to cancel all seeds 102 | 103 | 0.4.1 104 | - fixed bug in reseed 105 | 106 | 0.4.0 107 | - Introduced a new variable in Exchange `seedIfEmpty` to seed only if the side has no open orders. 108 | - because during GetOrderInfo sequence multiple orders may get filled, seed while half-empty cannot work well 109 | - Yobit status 3 and _ is now cancelled 110 | 111 | 0.3.7 112 | - solution 0.3.6 was not perfect. After receiving a new order id, this id must be queried again with GetInfo. During this duration, there was a hole where `CheckInQueue` cannot find any pending orders. This caused multiple to be pushed. 113 | - OpRest now holds a set to hold pending NewOrders or successful new orders that are still waiting for their info. `CheckInQueue` will also check any pending orders here. 114 | 115 | 0.3.6 116 | - doubles were caused by multiple GetOrderInfo (with same id) being queued too many times (order check just keeps pushing new requests every interval). If multiple GetOrderInfo check a same filled order then the bot will create more than one counter. 117 | - fixed it by removing inbound GetOrderInfo requests which have the same order Id as the ones in queue. 118 | - Grow is put in the same place as counter. Removed grow from balancer scheduler 119 | - GotOrderCancel is not a place to put grow. This is used to clear orderbook the first time. 120 | 121 | 0.3.5 122 | - doubles were caused by GotOrderInfo's starting refresh before orderbook init was complete (startPrice not yet available) 123 | - fixed by not launching refresh if the cancellable is None. 124 | - changed isInQueue 125 | 126 | 0.3.4 127 | - fixed isInQueue 128 | - enumerized As 129 | - all request should have As. Refresh orders will first check As in queue along with GetOrderInfo which sets off after NewOrder 130 | 131 | 0.3.3 132 | - do check on request sequence whenever "refresh order" is called. This should prevent double orders, possibly caused by balancer ("reseed") called when the queue still has reseed orders. 133 | - if not ok, check the Strategy for reseed 134 | NOTE: even more secure way is not to pop the request from the queue but remove it once the request has been executed and returned response. 135 | 136 | 0.2.3 137 | - make errorRetry's send email optional 138 | 139 | 0.2.2 140 | - added exchange and pair in log orderbook 141 | 142 | 0.2.1 143 | - cleaned up codes 144 | - add docs and template 145 | 146 | 0.2.0 147 | - grouped all bot of same exchanges with one sender sequence 148 | - created ConfigTest for groupBots 149 | - refactored OpActor, OrderbookActor. Orderbook now sends orders to Op's queue but gets the results directly from RestActor 150 | 151 | 0.1.1 152 | - added Fcoin 153 | 154 | 0.1.0 155 | - Refactor OpRestActor, now uses queue for unified request delay 156 | - Strategy requires ceil and floor on price with its scaling factor 157 | 158 | 0.0.6 159 | - Yobit new offer needed to avoid scientific notation 160 | 161 | 0.0.5 162 | - specified Application in build.sbt 163 | 164 | 0.0.4 165 | - added trim 166 | - refactored orderbook 167 | 168 | 0.0.3 169 | - removed isHardReset 170 | - changes startPrice to seed 171 | 172 | 0.0.2 173 | - Yobit 174 | 175 | 0.0.1 176 | - Refactoring from Okex MMM 177 | - Supports okexRest 178 | -------------------------------------------------------------------------------- /src/test/scala/me/mbcu/integrated/mmm/ops/common/AbsOrderTest.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.common 2 | 3 | import org.scalatest.FunSuite 4 | import org.scalatest.mockito.MockitoSugar 5 | 6 | class AbsOrderTest extends FunSuite with MockitoSugar{ 7 | 8 | test ("get duplicates from list with duplicates") { 9 | 10 | val symbol = "noah_rur" 11 | val active = Status.active 12 | val side = Side.sell // symbol, side, BigDecimal("0.07742766"), 13 | val offers = Seq( 14 | Offer("2073861098400280", symbol, side, active, 1L, None, BigDecimal(1972), BigDecimal(".07742766"), None), 15 | Offer("2073861098400222", symbol, side, active, 2L, None, BigDecimal(1986), BigDecimal(".07688943"), None), 16 | Offer("2073861098403259", symbol, side, active, 3L, None, BigDecimal(2000), BigDecimal(".07635494"), None), 17 | Offer("2073861098402160", symbol, side, active, 4L, None, BigDecimal(2000), BigDecimal(".07635494"), None), 18 | Offer("2073861098403224", symbol, side, active, 9L, None, BigDecimal(2014), BigDecimal(".07582417"), None), 19 | Offer("2073861098403220", symbol, side, active, 5L, None, BigDecimal(2014), BigDecimal(".07582417"), None), 20 | Offer("2073861098402116", symbol, side, active, 6L, None, BigDecimal(2014), BigDecimal(".07582417"), None), 21 | Offer("2073861098403227", symbol, side, active, 7L, None, BigDecimal(2028), BigDecimal(".07582400"), None), 22 | Offer("2073861098402119", symbol, side, active, 8L, None, BigDecimal(2014), BigDecimal(".07582400"), None) 23 | ) 24 | scala.util.Random.shuffle(offers) 25 | val res = AbsOrder.getDuplicates(offers) 26 | 27 | assert(res.size === 3) 28 | assert(res.head.price === BigDecimal(".07582417")) 29 | assert(res(1).price === BigDecimal(".07582417")) 30 | assert(res.last.price === BigDecimal(".07635494")) 31 | 32 | val noDup = Seq( 33 | Offer("2073861098400280", symbol, side, active, 1L, None, BigDecimal(1972), BigDecimal(".07742766"), None), 34 | Offer("2073861098400222", symbol, side, active, 2L, None, BigDecimal(1986), BigDecimal(".07688943"), None), 35 | Offer("2073861098402160", symbol, side, active, 4L, None, BigDecimal(2000), BigDecimal(".07635494"), None), 36 | Offer("2073861098403220", symbol, side, active, 5L, None, BigDecimal(2014), BigDecimal(".07582417"), None), 37 | Offer("2073861098403227", symbol, side, active, 7L, None, BigDecimal(2028), BigDecimal(".07582400"), None), 38 | Offer("2073861098402119", symbol, side, active, 8L, None, BigDecimal(2014), BigDecimal(".07582400"), None) 39 | ) 40 | scala.util.Random.shuffle(noDup) 41 | val res2 = AbsOrder.getDuplicates(noDup) 42 | assert(res2.size === 0) 43 | 44 | val one = Seq( 45 | // id:1073861122230849 quantity:2000 price:0.06267738 filled:0 46 | Offer("2073861098400280", symbol, side, active, 1L, None, BigDecimal(2000), BigDecimal(".06267738"), None) 47 | 48 | ) 49 | val res3 = AbsOrder.getDuplicates(one) 50 | assert(res3.size === 0) 51 | 52 | val singles = Seq( 53 | Offer("2073861098400280", symbol, side, active, 1L, None, BigDecimal(1972), BigDecimal(".07742766"), None), 54 | Offer("2073861098400222", symbol, side, active, 2L, None, BigDecimal(1986), BigDecimal(".07688943"), None) 55 | ) 56 | val res4 = AbsOrder.getDuplicates(singles) 57 | assert(res4.size === 0) 58 | } 59 | 60 | test("merge trims and cancel dupes") { 61 | val symbol = "noah_rur" 62 | val active = Status.active 63 | val side = Side.sell // symbol, side, BigDecimal("0.07742766"), 64 | 65 | val trims1 = Seq( 66 | Offer("2073861098400280", symbol, side, active, 1L, None, BigDecimal(1972), BigDecimal(".07742766"), None), 67 | Offer("2073861098400222", symbol, side, active, 2L, None, BigDecimal(1986), BigDecimal(".07688943"), None), 68 | Offer("2073861098402160", symbol, side, active, 4L, None, BigDecimal(2000), BigDecimal(".07635494"), None), 69 | Offer("2073861098403220", symbol, side, active, 5L, None, BigDecimal(2014), BigDecimal(".07582417"), None) 70 | ) 71 | 72 | val dupes1 = Seq( 73 | Offer("2073861098400280", symbol, side, active, 1L, None, BigDecimal(1972), BigDecimal(".07742766"), None), 74 | Offer("2073861098400222", symbol, side, active, 2L, None, BigDecimal(1986), BigDecimal(".07688943"), None), 75 | Offer("2073861098403227", symbol, side, active, 7L, None, BigDecimal(2028), BigDecimal(".07582400"), None), 76 | Offer("2073861098403228", symbol, side, active, 7L, None, BigDecimal(2028), BigDecimal(".07582400"), None), 77 | Offer("2073861098403229", symbol, side, active, 7L, None, BigDecimal(2028), BigDecimal(".07582400"), None), 78 | Offer("2073861098403230", symbol, side, active, 7L, None, BigDecimal(2028), BigDecimal(".07582400"), None) 79 | ) 80 | 81 | val res1 = AbsOrder.margeTrimAndDupes(trims1, dupes1) 82 | assert(res1._1.size === 2) 83 | assert(res1._2.size === 6) 84 | 85 | 86 | val trims2 = Seq( 87 | Offer("2073861098400280", symbol, side, active, 1L, None, BigDecimal(1972), BigDecimal(".07742766"), None), 88 | Offer("2073861098400222", symbol, side, active, 2L, None, BigDecimal(1986), BigDecimal(".07688943"), None), 89 | Offer("2073861098402160", symbol, side, active, 4L, None, BigDecimal(2000), BigDecimal(".07635494"), None), 90 | Offer("2073861098403220", symbol, side, active, 5L, None, BigDecimal(2014), BigDecimal(".07582417"), None) 91 | ) 92 | 93 | val dupes2 = Seq() 94 | 95 | val res2 = AbsOrder.margeTrimAndDupes(trims2, dupes2) 96 | assert(res2._1.size === 4) 97 | assert(res2._2.size === 0) 98 | 99 | val trims3 = Seq() 100 | 101 | val dupes3 = Seq( 102 | Offer("2073861098400280", symbol, side, active, 1L, None, BigDecimal(1972), BigDecimal(".07742766"), None), 103 | Offer("2073861098400222", symbol, side, active, 2L, None, BigDecimal(1986), BigDecimal(".07688943"), None), 104 | Offer("2073861098402160", symbol, side, active, 4L, None, BigDecimal(2000), BigDecimal(".07635494"), None), 105 | Offer("2073861098403220", symbol, side, active, 5L, None, BigDecimal(2014), BigDecimal(".07582417"), None) 106 | ) 107 | 108 | val res3 = AbsOrder.margeTrimAndDupes(trims3, dupes3) 109 | assert(res3._1.size === 0) 110 | assert(res3._2.size === 4) 111 | } 112 | 113 | } 114 | -------------------------------------------------------------------------------- /src/test/scala/me/mbcu/integrated/mmm/ops/livecoin/LivecoinActorTest.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.livecoin 2 | 3 | import me.mbcu.integrated.mmm.ops.Definitions.ShutdownCode 4 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor._ 5 | import me.mbcu.integrated.mmm.ops.common.{AbsRestActor, Offer, Status} 6 | import org.scalatest.FunSuite 7 | import org.scalatest.mockito.MockitoSugar 8 | import play.api.libs.json.{JsLookupResult, JsValue, Json} 9 | 10 | import scala.util.{Failure, Success, Try} 11 | 12 | 13 | class LivecoinActorTest extends FunSuite with MockitoSugar{ 14 | 15 | test ("Livecoin to offer") { 16 | val a = 17 | """ 18 | |{ 19 | | "totalRows": 3, 20 | | "startRow": 0, 21 | | "endRow": 2, 22 | | "data": [ 23 | | { 24 | | "id": 17444431201, 25 | | "currencyPair": "ETH/BTC", 26 | | "goodUntilTime": 0, 27 | | "type": "LIMIT_SELL", 28 | | "orderStatus": "CANCELLED", 29 | | "issueTime": 1535380653251, 30 | | "price": 0.05, 31 | | "quantity": 0.01, 32 | | "remainingQuantity": 0.01, 33 | | "commissionByTrade": 0, 34 | | "bonusByTrade": 0, 35 | | "bonusRate": 0, 36 | | "commissionRate": 0.0018, 37 | | "lastModificationTime": 1535517866196 38 | | }, 39 | | { 40 | | "id": 17597454851, 41 | | "currencyPair": "ETH/BTC", 42 | | "goodUntilTime": 0, 43 | | "type": "LIMIT_SELL", 44 | | "orderStatus": "EXECUTED", 45 | | "issueTime": 1535512555998, 46 | | "price": 0.01, 47 | | "quantity": 0.01, 48 | | "remainingQuantity": 0, 49 | | "commissionByTrade": 7.5e-7, 50 | | "bonusByTrade": 0, 51 | | "bonusRate": 0, 52 | | "commissionRate": 0.0018, 53 | | "lastModificationTime": 1535512555998 54 | | }, 55 | | { 56 | | "id": 17592585201, 57 | | "currencyPair": "ETH/BTC", 58 | | "goodUntilTime": 0, 59 | | "type": "LIMIT_SELL", 60 | | "orderStatus": "EXECUTED", 61 | | "issueTime": 1535508494524, 62 | | "price": 0.04175, 63 | | "quantity": 0.01, 64 | | "remainingQuantity": 0, 65 | | "commissionByTrade": 7.6e-7, 66 | | "bonusByTrade": 0, 67 | | "bonusRate": 0, 68 | | "commissionRate": 0.0018, 69 | | "lastModificationTime": 1535508501398 70 | | } 71 | | ] 72 | |} 73 | """.stripMargin 74 | 75 | val js = Json.parse(a) 76 | val data = (js \ "data").as[List[JsValue]] 77 | val res = data.map(LivecoinActor.toOffer) 78 | assert(res.size === 3) 79 | assert(res.head.status === Status.cancelled) 80 | } 81 | 82 | 83 | test("Livecoin getUncounteredOffers") { 84 | 85 | val a = 86 | """ 87 | |{ 88 | | "totalRows": 4, 89 | | "startRow": 0, 90 | | "endRow": 3, 91 | | "data": [ 92 | | { 93 | | "id": 17551577701, 94 | | "currencyPair": "ETH/BTC", 95 | | "goodUntilTime": 0, 96 | | "type": "LIMIT_SELL", 97 | | "orderStatus": "CANCELLED", 98 | | "issueTime": 1535474938522, 99 | | "price": 0.05, 100 | | "quantity": 0.01, 101 | | "remainingQuantity": 0.01, 102 | | "commissionByTrade": 0, 103 | | "bonusByTrade": 0, 104 | | "bonusRate": 0, 105 | | "commissionRate": 0.0018, 106 | | "lastModificationTime": 1535518250557 107 | | }, 108 | | { 109 | | "id": 17444431201, 110 | | "currencyPair": "ETH/BTC", 111 | | "goodUntilTime": 0, 112 | | "type": "LIMIT_SELL", 113 | | "orderStatus": "CANCELLED", 114 | | "issueTime": 1535380653251, 115 | | "price": 0.05, 116 | | "quantity": 0.01, 117 | | "remainingQuantity": 0.01, 118 | | "commissionByTrade": 0, 119 | | "bonusByTrade": 0, 120 | | "bonusRate": 0, 121 | | "commissionRate": 0.0018, 122 | | "lastModificationTime": 1535517866196 123 | | }, 124 | | { 125 | | "id": 17597454851, 126 | | "currencyPair": "ETH/BTC", 127 | | "goodUntilTime": 0, 128 | | "type": "LIMIT_SELL", 129 | | "orderStatus": "EXECUTED", 130 | | "issueTime": 1535512555998, 131 | | "price": 0.01, 132 | | "quantity": 0.01, 133 | | "remainingQuantity": 0, 134 | | "commissionByTrade": 7.5e-7, 135 | | "bonusByTrade": 0, 136 | | "bonusRate": 0, 137 | | "commissionRate": 0.0018, 138 | | "lastModificationTime": 1535512555998 139 | | }, 140 | | { 141 | | "id": 17592585201, 142 | | "currencyPair": "ETH/BTC", 143 | | "goodUntilTime": 0, 144 | | "type": "LIMIT_SELL", 145 | | "orderStatus": "EXECUTED", 146 | | "issueTime": 1535508494524, 147 | | "price": 0.04175, 148 | | "quantity": 0.01, 149 | | "remainingQuantity": 0, 150 | | "commissionByTrade": 7.6e-7, 151 | | "bonusByTrade": 0, 152 | | "bonusRate": 0, 153 | | "commissionRate": 0.0018, 154 | | "lastModificationTime": 1535508501398 155 | | } 156 | | ] 157 | |} 158 | """.stripMargin 159 | val js = Json.parse(a) 160 | val res = LivecoinActor.getUncounteredOrders(( js \ "data").as[List[JsValue]]) 161 | assert(res.size === 2) 162 | assert(res.head.updatedAt.get < res(1).updatedAt.get) 163 | } 164 | 165 | 166 | } 167 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/sequences/Strategy.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.sequences 2 | 3 | import java.math.MathContext 4 | 5 | import me.mbcu.integrated.mmm.ops.Definitions.Strategies 6 | import me.mbcu.integrated.mmm.ops.Definitions.Strategies.Strategies 7 | import me.mbcu.integrated.mmm.ops.common.Side.Side 8 | import me.mbcu.integrated.mmm.ops.common.{Offer, Side} 9 | import me.mbcu.integrated.mmm.sequences.Strategy.Movement.Movement 10 | import me.mbcu.integrated.mmm.sequences.Strategy.PingPong.PingPong 11 | 12 | import scala.math.BigDecimal.RoundingMode 13 | 14 | object Strategy { 15 | 16 | val mc : MathContext = MathContext.DECIMAL64 17 | val ZERO = BigDecimal("0") 18 | val ONE = BigDecimal("1") 19 | val CENT = BigDecimal("100") 20 | val INFINITY = BigDecimal("1e60") 21 | 22 | object Movement extends Enumeration { 23 | type Movement = Value 24 | val UP, DOWN = Value 25 | } 26 | 27 | object PingPong extends Enumeration { 28 | type PingPong = Value 29 | val ping, pong = Value 30 | 31 | def reverse (a : PingPong) : PingPong = if (a == ping) pong else ping 32 | } 33 | 34 | def seed (qty0 : BigDecimal, unitPrice0 : BigDecimal, amtPwr : Int, 35 | ctrScale : Int, basScale : Int, symbol : String, levels : Int, gridSpace : BigDecimal, 36 | side: Side, act : PingPong, 37 | isPulledFromOtherSide : Boolean, strategy : Strategies, isNoQtyCutoff : Boolean, 38 | maxPrice : Option[BigDecimal] = None, minPrice : Option[BigDecimal] = None) : Seq[Offer] = { 39 | val range = strategy match { 40 | case Strategies.ppt => if(isPulledFromOtherSide) 2 else 1 41 | case Strategies.fullfixed => if(isPulledFromOtherSide) 2 else 1 42 | case _ => 0 43 | } 44 | val zero = PriceQuantity(unitPrice0, qty0) 45 | 46 | (range until (levels + range)) 47 | .map(n => { 48 | val movement = if (side == Side.buy) Movement.DOWN else Movement.UP 49 | strategy match { 50 | case Strategies.ppt => 51 | val mtp = ONE + gridSpace(mc) / CENT 52 | List.fill(n)(1) 53 | .foldLeft(zero)((z, i) => ppt(z.price, z.quantity, amtPwr, mtp, ctrScale, basScale, movement)) 54 | 55 | case Strategies.fullfixed => 56 | List.fill(n)(1) 57 | .foldLeft(zero)((z, i) => step(z.price, z.quantity, gridSpace, ctrScale, movement)) 58 | 59 | case _ => PriceQuantity(ZERO, ZERO) 60 | } 61 | } 62 | ) 63 | .filter(_.price > 0) 64 | .map(p1q1 => if (p1q1.quantity <= 0 && isNoQtyCutoff) PriceQuantity(p1q1.price, calcMinAmount(ctrScale)) else p1q1) 65 | .filter(_.quantity > 0) 66 | .filter(minPrice match { 67 | case Some(mp) => _.price >= mp 68 | case _ => _.price > ZERO 69 | }) 70 | .filter(maxPrice match { 71 | case Some(mp) => _.price <= mp 72 | case None => _.price < INFINITY 73 | }) 74 | .map(p1q1 => Offer.newOffer(symbol, side, p1q1.price, p1q1.quantity.setScale(ctrScale, RoundingMode.HALF_EVEN))) 75 | } 76 | 77 | case class PriceQuantity(price : BigDecimal, quantity: BigDecimal) 78 | 79 | 80 | def counter(qty0 : BigDecimal, unitPrice0 : BigDecimal, amtPwr : Int, ctrScale : Int, basScale : Int, symbol : String, gridSpace : BigDecimal, side : Side, 81 | oldAct : PingPong, strategy : Strategies, isNoQtyCutoff : Boolean, 82 | maxPrice : Option[BigDecimal] = None, minPrice : Option[BigDecimal] = None): Offer = { 83 | val newSide = Side.reverse(side) 84 | val newAct = PingPong.reverse(oldAct) 85 | seed(qty0, unitPrice0, amtPwr, ctrScale, basScale, symbol, 1, gridSpace, newSide, newAct, isPulledFromOtherSide = false, strategy, isNoQtyCutoff, maxPrice, minPrice).head 86 | } 87 | 88 | def ppt(unitPrice0 : BigDecimal, qty0 : BigDecimal, amtPower : Int, rate : BigDecimal, ctrScale : Int, basScale: Int, movement: Movement): PriceQuantity ={ 89 | val unitPrice1 = if (movement == Movement.DOWN) roundFloor(unitPrice0(mc) / rate, basScale) else roundCeil(unitPrice0 * rate, basScale) 90 | val mtpBoost = sqrt(rate) pow amtPower 91 | val qty1 = if (movement == Movement.DOWN) roundCeil(qty0 * mtpBoost, ctrScale) else roundFloor(qty0(mc) / mtpBoost, ctrScale) 92 | PriceQuantity(unitPrice1, qty1) 93 | } 94 | 95 | def step(unitPrice0 : BigDecimal, qty0 : BigDecimal, rate : BigDecimal, ctrScale : Int, movement: Movement ): PriceQuantity ={ 96 | val unitPrice1 = if (movement == Movement.DOWN) unitPrice0(mc) - rate else unitPrice0 + rate 97 | val qty1 = qty0 98 | PriceQuantity(unitPrice1, qty1) 99 | } 100 | 101 | def calcMinAmount(ctrScale : Int) : BigDecimal = { 102 | ONE.setScale(ctrScale, BigDecimal.RoundingMode.CEILING) 103 | } 104 | 105 | def calcMid(unitPrice0 : BigDecimal, qty0 : BigDecimal, amtPower : Int, gridSpace : BigDecimal, ctrScale : Int, basScale:Int, side: Side, marketMidPrice : BigDecimal, strategy : Strategies) 106 | : (BigDecimal, BigDecimal, Int) = { 107 | var base = PriceQuantity(unitPrice0, qty0) 108 | var levels = 0 109 | val mtp = ONE + gridSpace(mc) / CENT 110 | 111 | side match { 112 | case Side.buy => 113 | while (base.price < marketMidPrice) { 114 | levels = levels + 1 115 | strategy match { 116 | case Strategies.ppt => base = ppt(base.price, base.quantity, amtPower, mtp, ctrScale, basScale, Movement.UP) 117 | case _ => base = step(base.price, base.quantity, gridSpace, ctrScale, Movement.UP) 118 | } 119 | } 120 | case _ => 121 | while (base.price > marketMidPrice) { 122 | levels = levels + 1 123 | strategy match { 124 | case Strategies.ppt => base = ppt(base.price, base.quantity, amtPower, mtp, ctrScale, basScale, Movement.DOWN) 125 | case _ => base = step(base.price, base.quantity, gridSpace, ctrScale, Movement.DOWN) 126 | } 127 | } 128 | } 129 | (base.price, base.quantity, levels) 130 | } 131 | 132 | private def sqrt(a: BigDecimal, scale: Int = 16): BigDecimal = { 133 | val mc = MathContext.DECIMAL64 134 | var x = BigDecimal( Math.sqrt(a.doubleValue()), mc ) 135 | 136 | if (scale < 17) { 137 | return x 138 | } 139 | 140 | var tempScale = 16 141 | while(tempScale < scale){ 142 | x = x - (x * x - a)(mc) / (2 * x) 143 | tempScale *= 2 144 | } 145 | x 146 | } 147 | 148 | private def roundCeil(a : BigDecimal, scale : Int): BigDecimal =a.setScale(scale, RoundingMode.CEILING) 149 | 150 | private def roundFloor(a : BigDecimal, scale : Int): BigDecimal =a.setScale(scale, RoundingMode.FLOOR) 151 | 152 | private def roundHalfDown(a : BigDecimal, scale : Int) : BigDecimal = a.setScale(scale, RoundingMode.HALF_DOWN) 153 | 154 | } -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/actors/OrderRestActor.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.actors 2 | 3 | import akka.actor.{ActorRef, Cancellable} 4 | import akka.dispatch.ExecutionContexts.global 5 | import me.mbcu.integrated.mmm.actors.OrderRestActor._ 6 | import me.mbcu.integrated.mmm.ops.Definitions.{ErrorShutdown, Settings, ShutdownCode} 7 | import me.mbcu.integrated.mmm.ops.common.AbsOrder.{CheckSafeForSeed, QueueRequest, SafeForSeed} 8 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor.As.As 9 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor._ 10 | import me.mbcu.integrated.mmm.ops.common._ 11 | import me.mbcu.scala.MyLogging 12 | 13 | import scala.concurrent.ExecutionContextExecutor 14 | import scala.concurrent.duration._ 15 | import scala.language.postfixOps 16 | 17 | object OrderRestActor { 18 | 19 | case class GetLastCounter(book: ActorRef, bot: Bot, as: As) 20 | 21 | case class GotLastCounter(bc: BotCache, as: As) 22 | 23 | case class WriteLastCounter(book: ActorRef, bot: Bot, m: BotCache) 24 | 25 | case class LogActives(arriveMs: Long, buys: Seq[Offer], sels: Seq[Offer]) 26 | } 27 | 28 | class OrderRestActor(bot: Bot, exchange: AbsExchange, fileActor: ActorRef) extends AbsOrder(bot) with MyLogging { 29 | private implicit val ec: ExecutionContextExecutor = global 30 | private var op: Option[ActorRef] = None 31 | private implicit val book: ActorRef = self 32 | private implicit val imBot: Bot = bot 33 | private var getFilledCancellable: Option[Cancellable] = None 34 | private var getActiveCancellable: Option[Cancellable] = None 35 | 36 | def receive: Receive = { 37 | 38 | case "start" => 39 | op = Some(sender()) 40 | fileActor ! GetLastCounter(self, bot, As.Init) 41 | 42 | case GotLastCounter(botCache, as) => qFilledOrders(Seq.empty[Offer], botCache.lastCounteredId, as) //must start from past filled orders. Starting bout qFilledOrders and qActiverOrders will mess up init orders 43 | 44 | case GotActiveOrders(offers, currentPage, nextPage, arriveMs, send) => 45 | if (nextPage) { 46 | qActiveOrders(offers ++ send.cache, send.lastMs, currentPage + 1, send.as) 47 | } 48 | else { 49 | val activeOrders = send.cache ++ offers 50 | val (sortedBuys,sortedSels) = Offer.splitToBuysSels(activeOrders) 51 | send.as match { 52 | case As.Init => 53 | bot.seed match { 54 | case a if a.equals(StartMethods.cont.toString) => // wont arrive here 55 | 56 | case a if a.equals(StartMethods.lastTicker.toString) => 57 | qClearOrders(activeOrders, As.Init) 58 | queue1(GetTickerStartPrice(As.Init)) 59 | 60 | case a if a.equals(StartMethods.lastOwn.toString) => op foreach(_ ! ErrorShutdown(ShutdownCode.fatal, -1, s"$a is not supported")) 61 | 62 | case _ => 63 | qClearOrders(activeOrders, As.Init) 64 | qSeed(initialSeed(Seq.empty[Offer], Seq.empty[Offer], BigDecimal(bot.seed))) 65 | scheduleGetFilled(Settings.getFilledSeconds) 66 | 67 | } 68 | 69 | case As.RoutineCheck => 70 | val dupes = AbsOrder.getDuplicates(sortedBuys) ++ AbsOrder.getDuplicates(sortedSels) 71 | val trims = if (bot.isStrictLevels) trim(sortedBuys, sortedSels, Side.buy) ++ trim(sortedBuys, sortedSels, Side.sell) else Seq.empty[Offer] 72 | val cancels = AbsOrder.margeTrimAndDupes(trims, dupes) 73 | qClearOrders(cancels._1, As.Trim) 74 | qClearOrders(cancels._2, As.KillDupes) 75 | qSeed(grow(sortedBuys, sortedSels, Side.buy) ++ grow(sortedBuys, sortedSels, Side.sell)) 76 | 77 | case _ => // not handled 78 | } 79 | self ! LogActives(arriveMs, sortedBuys, sortedSels) 80 | } 81 | 82 | case GotUncounteredOrders(uncountereds, latestCounterId, isSortedFromOldest, arriveMs, send) => 83 | fileActor ! WriteLastCounter(self, bot, BotCache(latestCounterId.getOrElse(send.lastCounterId))) 84 | def pipe(): Unit = { 85 | if (uncountereds.isEmpty) scheduleGetActive(1) else qCounter(uncountereds) 86 | scheduleGetFilled(Settings.getFilledSeconds) // should it be moved to GotActiveOrders$As.RoutineCheck ? 87 | } 88 | send.as match { 89 | case As.Init => 90 | bot.seed match { 91 | case a if a.equals(StartMethods.cont.toString) => pipe() 92 | 93 | case _ => qActiveOrders(Seq.empty[Offer], System.currentTimeMillis(), page = 1, As.Init) // lastTicker or custom = config change, so past filled orders are not countered 94 | } 95 | 96 | case As.RoutineCheck => pipe() 97 | 98 | case _ => // not handled 99 | } 100 | 101 | 102 | case GotTickerStartPrice(price, arriveMs, send) => // start ownTicker 103 | price match { 104 | case Some(p) => 105 | qSeed(initialSeed(Seq.empty[Offer], Seq.empty[Offer], p)) 106 | scheduleGetFilled(Settings.getFilledSeconds) 107 | case _ => // no ticker price found 108 | } 109 | 110 | case SafeForSeed(yes) => 111 | if (yes) { 112 | qActiveOrders(Seq.empty[Offer], System.currentTimeMillis(), page = 1, As.RoutineCheck) 113 | } else{ 114 | scheduleGetActive(1) 115 | } 116 | 117 | case "get routine active orders" => op foreach(_! CheckSafeForSeed(self, bot)) 118 | 119 | case "get filled orders" => fileActor ! GetLastCounter(self, bot, As.RoutineCheck) 120 | 121 | case LogActives(arriveMs, buys, sels) => 122 | (arriveMs / 1000L) % 30 match { 123 | case x if x < 33 | x > 27 => info(Offer dump(bot, buys, sels)) 124 | case _ => // ignore log 125 | } 126 | } 127 | 128 | def scheduleGetActive(s: Int) : Unit = { 129 | getActiveCancellable foreach(_.cancel()) 130 | getActiveCancellable = Some(context.system.scheduler.scheduleOnce(s seconds, self, message="get routine active orders")) 131 | } 132 | 133 | def scheduleGetFilled(s: Int): Unit = { 134 | getFilledCancellable foreach(_.cancel()) 135 | getFilledCancellable = Some(context.system.scheduler.scheduleOnce(s seconds, self, message="get filled orders")) 136 | } 137 | 138 | def qCounter(seq: Seq[Offer]): Unit = { 139 | val counters = Offer.sortTimeDesc(seq).map(counter).map(NewOrder(_, As.Counter)) 140 | queue(counters) 141 | } 142 | 143 | def qSeed(offers: Seq[Offer]): Unit = queue(offers.map(NewOrder(_, As.Seed))) 144 | 145 | def qClearOrders(offers: Seq[Offer], as: As): Unit = queue(offers.map(CancelOrder(_,as))) 146 | 147 | def qActiveOrders(cache: Seq[Offer], lastMs: Long, page:Int, as: As) : Unit = queue1(GetActiveOrders(lastMs, cache, page, as)) 148 | 149 | def qFilledOrders(cache: Seq[Offer], lastCounterId:String, as: As) : Unit = queue1(GetFilledOrders(lastCounterId, as)) 150 | 151 | def queue(reqs: Seq[SendRest]): Unit = op foreach(_ ! QueueRequest(reqs)) 152 | 153 | def queue1(req: SendRest): Unit = queue(Seq(req)) 154 | 155 | } 156 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/hitbtc/HitbtcParser.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.hitbtc 2 | 3 | import akka.actor.ActorRef 4 | import me.mbcu.integrated.mmm.ops.Definitions.ShutdownCode 5 | import me.mbcu.integrated.mmm.ops.common.AbsWsParser._ 6 | import me.mbcu.integrated.mmm.ops.common.Side.Side 7 | import me.mbcu.integrated.mmm.ops.common.{AbsWsParser, Offer, Status} 8 | import me.mbcu.integrated.mmm.ops.hitbtc.HitbtcStatus.HitbtcStatus 9 | import org.joda.time.DateTime 10 | import play.api.libs.json.{JsValue, Json} 11 | 12 | import scala.util.{Failure, Success, Try} 13 | 14 | object HitbtcParser { 15 | 16 | def toOffer(js: JsValue): Offer = { 17 | Offer( 18 | (js \ "clientOrderId").as[String], 19 | (js \ "symbol").as[String], 20 | (js \ "side").as[Side], 21 | (js \ "status").as[HitbtcStatus] match { 22 | case HitbtcStatus.`new` => Status.active 23 | case HitbtcStatus.canceled => Status.cancelled 24 | case HitbtcStatus.expired => Status.cancelled 25 | case HitbtcStatus.filled => Status.filled 26 | case HitbtcStatus.partiallyFilled => Status.partiallyFilled 27 | case _ => Status.cancelled 28 | }, 29 | DateTime.parse((js \ "createdAt").as[String]).getMillis, 30 | Some(DateTime.parse((js \ "updatedAt").as[String]).getMillis), 31 | (js \ "quantity").as[BigDecimal], 32 | (js \ "price").as[BigDecimal], 33 | Some((js \ "cumQuantity").as[BigDecimal]) 34 | ) 35 | 36 | /* 37 | "id": "4345613661", 38 | "clientOrderId": "57d5525562c945448e3cbd559bd068c3", 39 | "symbol": "BCCBTC", 40 | "side": "sell", 41 | "status": "new", 42 | "type": "limit", 43 | "timeInForce": "GTC", 44 | "quantity": "0.013", 45 | "price": "0.100000", 46 | "cumQuantity": "0.000", 47 | "createdAt": "2017-10-20T12:17:12.245Z", 48 | "updatedAt": "2017-10-20T12:17:12.245Z", 49 | "reportType": "status" 50 | */ 51 | } 52 | 53 | } 54 | 55 | class HitbtcParser(op: ActorRef) extends AbsWsParser(op){ 56 | 57 | override def parse(raw: String, botMap: Map[String, ActorRef]): Unit = { 58 | info( 59 | s"""Raw response 60 | |$raw 61 | """.stripMargin) 62 | 63 | val x = Try(Json parse raw) 64 | x match { 65 | case Success(js) => 66 | if ((js \ "error").isDefined){ 67 | val error = (js \ "error").as[RPCError] 68 | val requestId = (js \ "id").as[String] 69 | val book = botMap(HitbtcRequest.pairFromId(requestId)) 70 | error.code match { 71 | case 2011 | 10001 | 20001 | 20002 => book ! RemoveOfferWs(isRetry = false, HitbtcRequest.clientOrderIdFrom(requestId), HitbtcRequest.sideFromId(requestId), requestId) 72 | case 20008 | 20004 => 73 | val isRetry = if(requestId.contains("newOrder")) true else false 74 | book ! RemoveOfferWs(isRetry, HitbtcRequest.clientOrderIdFrom(requestId), HitbtcRequest.sideFromId(requestId), requestId) // duplicate clientOrderId 75 | case 2001 | 2002 => errorShutdown(ShutdownCode.fatal, error.code, error.message.getOrElse("Currency / symbol not found")) 76 | case 1001 | 1002 | 1003 | 1004 | 403 => errorShutdown(ShutdownCode.fatal, error.code, error.message.getOrElse("Authentication failed")) 77 | case 429 | 500 | 503 | 504 => book ! RetryPendingWs(requestId) 78 | case _ => // ErrorNonAffecting(error, id) 79 | } 80 | /* 81 | 403 401 Action is forbidden for account 82 | 429 429 Too many requests Action is being rate limited for account 83 | 500 500 Internal Server Error 84 | 503 503 Service Unavailable Try it again later 85 | 504 504 Gateway Timeout Check the result of your request later 86 | 1001 401 Authorisation required 87 | 1002 401 Authorisation failed 88 | 1003 403 Action is forbidden for this API key Check permissions for API key 89 | 1004 401 Unsupported authorisation method Use Basic authentication 90 | 2001 400 Symbol not found 91 | 2002 400 Currency not found 92 | 20001 400 Insufficient funds Insufficient funds for creating order or any account operation 93 | 20002 400 Order not found Attempt to get active order that not existing, filled, canceled or expired. Attempt to cancel not existing order. Attempt to cancel already filled or expired order. 94 | 20003 400 Limit exceeded Withdrawal limit exceeded 95 | 20004 400 Transaction not found Requested transaction not found 96 | 20005 400 Payout not found 97 | 20006 400 Payout already committed 98 | 20007 400 Payout already rolled back 99 | 20008 {"jsonrpc":"2.0","error":{"code":20008,"message":"Duplicate clientOrderId","description":"ClientOrderId must be unique during trading session"},"id":"cancelOrder.NOAHBTC.buy.559071f116b6b5fcadde"} 100 | code":2011,"message":"Quant ity too low 101 | {"code":10001,"message":"\"price\" must be a positive number 102 | code":2011,"message":"Quantity too low","description":"Minimum quantity 1" 103 | */ 104 | } 105 | else { 106 | if((js \ "method").isDefined && (js \ "params").isDefined){ // stream report comes here 107 | val method = (js \ "method").as[String] 108 | val params = js \ "params" 109 | method match { 110 | 111 | case "activeOrders" => 112 | val mapped = params.as[List[JsValue]].map(HitbtcParser.toOffer).groupBy(_.symbol) 113 | botMap.map(p => (p._2, mapped.getOrElse(p._1, Seq.empty[Offer]))).foreach(p => p._1 ! GotActiveOrdersWs(p._2, "noId")) 114 | 115 | case "report" => 116 | val offer = HitbtcParser.toOffer(params.as[JsValue]) 117 | botMap(offer.symbol) ! GotOfferWs(offer, offer.id) 118 | 119 | case "ticker" => 120 | val pair = (params \ "symbol").as[String] 121 | val last = (params \ "last").as[BigDecimal] 122 | botMap(pair) ! GotTickerPriceWs(Some(last), "subscribeReports") 123 | 124 | case _ => 125 | } 126 | 127 | } 128 | else if ((js \ "id").isDefined){ // RPC response 129 | val id = (js \ "id").as[String] 130 | id match { 131 | case a if a equals "login" => op ! LoggedIn(a) // {"jsonrpc":"2.0","result":true,"id":"login"} 132 | case a if a equals "subscribeReports" => op ! GotSubscribe(a) 133 | case _ => botMap(HitbtcRequest.pairFromId(id)) ! RemovePendingWs(id) 134 | } 135 | } 136 | } 137 | 138 | case Failure(e) => errorIgnore(-1, s"Cannot parse into Json: $raw", shouldEmail = true) 139 | 140 | } 141 | 142 | } 143 | 144 | } 145 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/fcoin/FcoinActor.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.fcoin 2 | 3 | import akka.dispatch.ExecutionContexts.global 4 | import akka.stream.ActorMaterializer 5 | import me.mbcu.integrated.mmm.ops.Definitions.ShutdownCode 6 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor._ 7 | import me.mbcu.integrated.mmm.ops.common.Side.Side 8 | import me.mbcu.integrated.mmm.ops.common.{AbsRestActor, Offer, Status} 9 | import me.mbcu.integrated.mmm.ops.fcoin.FcoinRestRequest.FcoinParams 10 | import me.mbcu.integrated.mmm.ops.fcoin.FcoinState.FcoinState 11 | import me.mbcu.scala.MyLogging 12 | import play.api.libs.json.{JsLookupResult, JsValue, Json} 13 | import play.api.libs.ws.ahc.StandaloneAhcWSClient 14 | 15 | import scala.concurrent.ExecutionContextExecutor 16 | import scala.language.postfixOps 17 | import scala.util.{Failure, Success, Try} 18 | 19 | object FcoinActor { 20 | 21 | def toOffer(p: JsValue): Offer = { 22 | val state = (p \ "state").as[FcoinState] 23 | val status = state match { 24 | case FcoinState.filled => Status.filled 25 | case FcoinState.submitted => Status.active 26 | case FcoinState.partial_filled => Status.partiallyFilled 27 | case _ => Status.cancelled 28 | } 29 | new Offer( 30 | (p \ "id").as[String], 31 | (p \ "symbol").as[String], 32 | (p \ "side").as[Side], 33 | status, 34 | (p \ "created_at").as[Long], 35 | None, 36 | (p \ "amount").as[BigDecimal], 37 | (p \ "price").as[BigDecimal], 38 | Some((p \ "filled_amount").as[BigDecimal]) 39 | ) 40 | /* 41 | { 42 | "id" : "string" , 43 | "symbol" : "string" , 44 | "type" : "limit" , 45 | "side" : "buy" , 46 | "price" : "string" , 47 | "amount" : "string" , 48 | "state" : "submitted" , 49 | "executed_value" : "string" , 50 | "fill_fees" : "string" , 51 | "filled_amount" : "string" , 52 | "created_at" : 0 , 53 | "source" : "web" 54 | } 55 | ] 56 | */ 57 | } 58 | 59 | def parseFilled(data: JsLookupResult, lastCounterId: String): (Seq[Offer], Option[String]) = { 60 | val dexed = data.as[Seq[JsValue]].map(toOffer).filter(_.status == Status.filled).zipWithIndex 61 | 62 | if (dexed.isEmpty){ 63 | (Seq.empty[Offer], None) 64 | } else { 65 | val latestCounterId = dexed.head._1.id 66 | val idPos = dexed.find(a => a._1.id == lastCounterId) match { 67 | case Some(pos) => pos._2 68 | case _ => Int.MaxValue 69 | } 70 | val uncountereds = dexed.filter(_._2 < idPos).sortWith(_._2 < _._2).map(_._1) 71 | (uncountereds, Some(latestCounterId)) 72 | } 73 | } 74 | } 75 | 76 | class FcoinActor() extends AbsRestActor() with MyLogging { 77 | import play.api.libs.ws.DefaultBodyReadables._ 78 | import play.api.libs.ws.DefaultBodyWritables._ 79 | 80 | import scala.concurrent.duration._ 81 | 82 | private implicit val materializer = ActorMaterializer() 83 | private implicit val ec: ExecutionContextExecutor = global 84 | private var ws = StandaloneAhcWSClient() 85 | 86 | override def start(): Unit = setOp(Some(sender())) 87 | 88 | override def sendRequest(r: AbsRestActor.SendRest): Unit = { 89 | 90 | r match { 91 | case a: GetTickerStartPrice => httpGet(a, FcoinRestRequest.getTickers(a.bot.pair)) 92 | 93 | case a: GetActiveOrders => httpGet(a, FcoinRestRequest.getOrders(a.bot.credentials, a.bot.pair, FcoinState.submitted, a.page)) 94 | 95 | case a: GetFilledOrders => httpGet(a, FcoinRestRequest.getOrders(a.bot.credentials, a.bot.pair, FcoinState.filled, 1)) 96 | 97 | case a: NewOrder => httpPost(a, FcoinRestRequest.newOrder(a.bot.credentials, a.bot.pair, a.offer.side, a.offer.price, a.offer.quantity)) 98 | 99 | case a: CancelOrder => httpPost(a, FcoinRestRequest.cancelOrder(a.bot.credentials, a.offer.id)) 100 | 101 | } 102 | 103 | def httpGet(a: SendRest, f: FcoinParams): Unit = { 104 | ws.url(f.url) 105 | .addHttpHeaders("FC-ACCESS-KEY" -> a.bot.credentials.pKey) 106 | .addHttpHeaders("FC-ACCESS-SIGNATURE" -> f.sign) 107 | .addHttpHeaders("FC-ACCESS-TIMESTAMP" -> f.ts.toString) 108 | .withRequestTimeout(requestTimeoutSec seconds) 109 | .get() 110 | .map(response => parse(a, f.url, response.body[String])) 111 | .recover{ 112 | case e: Exception => errorRetry(a, 0, e.getMessage, shouldEmail = false) 113 | } 114 | } 115 | 116 | def httpPost(a: SendRest, r: FcoinParams): Unit = { 117 | ws.url(r.url) 118 | .addHttpHeaders("Content-Type" -> "application/json;charset=UTF-8") 119 | .addHttpHeaders("FC-ACCESS-KEY" -> a.bot.credentials.pKey) 120 | .addHttpHeaders("FC-ACCESS-SIGNATURE" -> r.sign) 121 | .addHttpHeaders("FC-ACCESS-TIMESTAMP" -> r.ts.toString) 122 | .withRequestTimeout(requestTimeoutSec seconds) 123 | .post(r.js) 124 | .map(response => parse(a, r.params, response.body[String])) 125 | .recover{ 126 | case e: Exception => errorRetry(a, 0, e.getMessage, shouldEmail = false) 127 | } 128 | } 129 | } 130 | 131 | def parse(a: AbsRestActor.SendRest, request: String, raw: String): Unit = { 132 | val arriveMs = System.currentTimeMillis() 133 | 134 | a match { 135 | case p: NewOrder => op foreach (_ ! GotNewOrderId("unused", p.as, arriveMs, p)) 136 | case _ => 137 | } 138 | 139 | val x = Try(Json parse raw) 140 | x match { 141 | case Success(js) => 142 | val rootStatus = (js \ "status").as[Int] 143 | if (rootStatus != 0) { 144 | val msg = (js \ "msg").as[String] 145 | msg match { 146 | case m if m.contains("api key check fail") => errorShutdown(ShutdownCode.fatal, 0, s"$request $m") 147 | case _ => 148 | error(raw) 149 | errorIgnore(0, msg) 150 | } 151 | } 152 | else { 153 | val data = js \ "data" 154 | a match { 155 | case a: GetTickerStartPrice => 156 | val lastPrice = (data \ "ticker").head.as[BigDecimal] 157 | a.book ! GotTickerStartPrice(Some(lastPrice), arriveMs, a) 158 | 159 | case a: GetFilledOrders => 160 | val res = FcoinActor.parseFilled(data, a.lastCounterId) 161 | a.book ! GotUncounteredOrders(res._1, res._2, isSortedFromOldest = true, arriveMs, a) 162 | 163 | case a: GetActiveOrders => 164 | val res = data.as[Seq[JsValue]].map(FcoinActor.toOffer) 165 | a.book ! GotActiveOrders(res, a.page, if (res.size == 100) true else false, arriveMs, a) 166 | 167 | case a: CancelOrder => // not handled 168 | 169 | case a: NewOrder => // not handled, serverTime not available 170 | 171 | } 172 | } 173 | 174 | case Failure(e) => 175 | raw match { 176 | case u: String if u.contains("") => errorRetry(a, 0, raw) 177 | case _ => errorIgnore(0, s"Unknown FcoinActor#parse : $raw") 178 | } 179 | } 180 | 181 | } 182 | 183 | } 184 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/okex/OkexRestActor.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.okex 2 | 3 | import akka.dispatch.ExecutionContexts.global 4 | import akka.stream.ActorMaterializer 5 | import me.mbcu.integrated.mmm.ops.Definitions.ShutdownCode 6 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor._ 7 | import me.mbcu.integrated.mmm.ops.common.Side.Side 8 | import me.mbcu.integrated.mmm.ops.common.{AbsRestActor, Offer, Status} 9 | import me.mbcu.scala.MyLogging 10 | import play.api.libs.json.{JsValue, Json} 11 | import play.api.libs.ws.ahc.StandaloneAhcWSClient 12 | 13 | import scala.concurrent.ExecutionContextExecutor 14 | import scala.concurrent.duration._ 15 | import scala.language.postfixOps 16 | import scala.util.{Failure, Success, Try} 17 | 18 | object OkexRestActor { 19 | def parseForId(js: JsValue): String = (js \ "order_id").as[Long].toString 20 | 21 | val toOffer: JsValue => Offer = (data: JsValue) => { 22 | val status = (data \ "status").as[Int] match { 23 | case -1 => Status.cancelled 24 | case 0 => Status.active 25 | case 1 => Status.partiallyFilled 26 | case 2 => Status.filled 27 | case 3 => Status.cancelled 28 | case _ => Status.cancelled 29 | } 30 | new Offer( 31 | (data \ "order_id").as[Long].toString, 32 | (data \ "symbol").as[String], 33 | (data \ "type").as[Side], 34 | status, 35 | (data \ "create_date").as[Long], 36 | None, 37 | (data \ "amount").as[BigDecimal], 38 | (data \ "price").as[BigDecimal], 39 | (data \ "deal_amount").asOpt[BigDecimal] 40 | ) 41 | } 42 | 43 | def parseFilled(js: JsValue, lastCounterId: Long): (Seq[Offer], Option[String]) = { 44 | val filleds = (js \ "orders").as[List[JsValue]] 45 | .filter(a => (a \ "status").as[Int] == OkexStatus.filled.id) // assuming partially filled orders eventually have filled status 46 | .filter(a => (a \ "order_id").as[Long] > lastCounterId) 47 | .map(toOffer) 48 | if (filleds.isEmpty){ 49 | (Seq.empty[Offer], None) 50 | } else { 51 | val latestCounterId = filleds.head.id 52 | val sorted = filleds.sortWith(_.createdAt < _.createdAt) 53 | (sorted, Some(latestCounterId)) 54 | } 55 | } 56 | } 57 | 58 | class OkexRestActor() extends AbsRestActor() with MyLogging { 59 | import play.api.libs.ws.DefaultBodyReadables._ 60 | import play.api.libs.ws.DefaultBodyWritables._ 61 | 62 | private implicit val materializer = ActorMaterializer() 63 | private implicit val ec: ExecutionContextExecutor = global 64 | val OKEX_ERRORS: Map[Int, String] = OkexRest.OKEX_ERRORS 65 | val url: String = OkexRest.endpoint 66 | 67 | private var ws = StandaloneAhcWSClient() 68 | 69 | override def start(): Unit = setOp(Some(sender())) 70 | 71 | override def sendRequest(r: SendRest): Unit = { 72 | 73 | r match { 74 | case a: NewOrder => httpPost(a,s"$url/trade.do", OkexRestRequest.restNewOrder(a.bot.credentials, a.bot.pair, a.offer.side, a.offer.price, a.offer.quantity)) 75 | 76 | case a: CancelOrder => httpPost(a,s"$url/cancel_order.do", OkexRestRequest.restCancelOrder(a.bot.credentials, a.bot.pair, a.offer.id)) 77 | 78 | case a: GetActiveOrders => httpPost(a,s"$url/order_history.do", OkexRestRequest.restOwnTrades(a.bot.credentials, a.bot.pair, OkexStatus.unfilled, a.page) ) 79 | 80 | case a: GetFilledOrders => httpPost(a,s"$url/order_history.do", OkexRestRequest.restOwnTrades(a.bot.credentials, a.bot.pair, OkexStatus.filled, 1)) 81 | 82 | case a: GetTickerStartPrice => httpGet(a,s"$url/ticker.do", OkexRestRequest.restTicker(a.bot.pair)) 83 | 84 | } 85 | 86 | def httpPost(a: SendRest, url: String, params: Map[String, String]): Unit = 87 | ws.url(url) 88 | .addHttpHeaders("Content-Type" -> "application/x-www-form-urlencoded") 89 | .withRequestTimeout(requestTimeoutSec seconds) 90 | .post(stringifyXWWWForm(params)) 91 | .map(response => parse(a, response.body[String])) 92 | .recover{ 93 | case e: Exception => errorRetry(a, 0, e.getMessage, shouldEmail = false) 94 | } 95 | 96 | def httpGet(a: SendRest, url: String, params: Map[String, String]): Unit = 97 | ws.url(url) 98 | .addQueryStringParameters(params.toSeq: _*) 99 | .withRequestTimeout(requestTimeoutSec seconds) 100 | .get() 101 | .map(response => parse(a, response.body[String])) 102 | .recover{ 103 | case e: Exception => errorRetry(a, 0, e.getMessage, shouldEmail = false) 104 | } 105 | 106 | } 107 | 108 | def parse(a: SendRest, raw: String): Unit = { 109 | info(logResponse(a, raw)) 110 | val arriveMs = System.currentTimeMillis() 111 | 112 | a match { 113 | case p: NewOrder => op foreach (_ ! GotNewOrderId("unused", p.as, arriveMs, p)) 114 | case _ => 115 | } 116 | 117 | val x = Try(Json parse raw) 118 | x match { 119 | case Success(js) => 120 | if ((js \ "success").isDefined) { 121 | val code = (js \ "error_code").as[Int] 122 | val msg = OKEX_ERRORS.getOrElse(code, " code list : https://github.com/okcoin-okex/API-docs-OKEx.com/blob/master/API-For-Spot-EN/Error%20Code%20For%20Spot.md") 123 | pipeErrors(code, msg, a) 124 | } 125 | else { 126 | val book = a.book 127 | a match { 128 | case t: GetTickerStartPrice => 129 | val lastTrade = (js \ "ticker" \ "last").as[BigDecimal] 130 | book ! GotTickerStartPrice(Some(lastTrade), arriveMs, t) 131 | 132 | case t: GetFilledOrders => 133 | val res = OkexRestActor.parseFilled(js, t.lastCounterId.toLong) 134 | book ! GotUncounteredOrders(res._1, res._2, isSortedFromOldest = true, arriveMs, t) 135 | 136 | case t: GetActiveOrders => 137 | val res = (js \ "orders").as[List[JsValue]].map(OkexRestActor.toOffer) 138 | val currentPage = (js \ "currency_page").as[Int] 139 | val nextPage = if ((js \ "page_length").as[Int] > 200) true else false 140 | book ! GotActiveOrders(res, currentPage, nextPage, arriveMs, t) 141 | 142 | case t: CancelOrder => // not handled 143 | 144 | case t: NewOrder => // not handled, serverTime not available 145 | 146 | case _ => error(s"Unknown OkexRestActor#parseRest : $raw") 147 | } 148 | } 149 | 150 | case Failure(e) => raw match { 151 | case u: String if u.contains("") => 152 | val code = -1000 153 | val msg = 154 | s"""${OKEX_ERRORS(code)} 155 | |$raw 156 | """.stripMargin 157 | pipeErrors(code, msg, a) 158 | case _ => error(s"Unknown OkexRestActor#parseRest : $raw") 159 | } 160 | } 161 | } 162 | 163 | private def pipeErrors(code: Int, msg: String, sendRequest: SendRest): Unit = { 164 | code match { 165 | case 10009 | 10010 | 10011 | 10014 | 10016 | 10024 | 1002 => errorIgnore(code, msg) 166 | case 20100 | 10007 | 10005 | -1000 => errorRetry(sendRequest, code, msg) 167 | case 10008 | 10100 => errorShutdown(ShutdownCode.fatal, code, msg) 168 | case _ => errorRetry(sendRequest, code, msg) 169 | } 170 | } 171 | 172 | } 173 | -------------------------------------------------------------------------------- /OPERATION.md: -------------------------------------------------------------------------------- 1 | # Operation 2 | 3 | How the bot operates depends on the exchange's type. Here is a list the bot operates on 4 | 5 | ## Exchange Types 6 | 7 | ### REST Exchanges 8 | 9 | REST Exchanges supports only REST methods for trades. This is the most common type of exchange. 10 | 11 | Characteristics: 12 | 13 | - Order cannot be pre-set with custom id. A raw offer is sent then returned with a response that contains order id. 14 | - To get the status of an order, we do another request gerOrderInfo. 15 | 16 | Example: Okex, Binance, Huobi 17 | 18 | #### Operations 19 | 20 | There are two ways a bot manages orders for REST exchange. By fetching Trade History and Active Orders or by checking orders with GetOrderInfo 21 | 22 | #### Trade History Method 23 | 24 | The bot basically fetches two pieces of information to operate: 25 | - Trade History or history of filled orders for the bot to counter 26 | - Active Orders to see if the bot needs to seed empty side or trim extra orders. 27 | 28 | As the bot simply counters whatever orders that come out in Trade History the only problem is to mark the last offer the bot left out.
29 | For this purpose the bot saves the last countered order on a text file, in the form of order id or timestamp.
30 | The next time it fetches Trade History, it will start countering from this id.
31 | If the boundary doesn't exist in the response, the bot will consider all orders there to be uncountered and counter them all
32 | 33 | Active Order on the other hand are needed for seeding or trimming. 34 | 35 | Both Trade History and Active Orders are fetched cyclically. 36 | 37 | This is the simplest and the best possible method for REST exchange as it doesn't require many calls to the server. 38 | 39 | For this method to work, an exchange must have Trade History method that returns one among other things the status (filled, partially-filled, cancelled) 40 | 41 | All requests are pooled in a queue and popped by the rate allowed by the exchange. 42 | 43 | #### GetOrderInfo Method 44 | 45 | Some exchanges have Trade History that doesn't return trade status. We cannot know if it's filled or partially filled. 46 | 47 | As the bot only counters one order with one order, countering an order with a lot of partially-filled fractured orders is not desirable. 48 | 49 | In this method, the bot maintains in-memory open orders. Every interval, the bot sends getOrderInfo request to check the status of an order. 50 | 51 | The original request is cached in memory. Subsequent order info will only update it (to preserve the original information). 52 | 53 | If the status is filled the bot will counter it and remove the order from the memory. 54 | 55 | The bot also checks regularly if it needs to reseed or trim a side. 56 | 57 | This method requires a lot of requests to be sent to server and may not work well if there are too many orders. 58 | 59 | ### Websocket Exchanges 60 | 61 | Websocket exchanges supports Websocket methods for trades 62 | 63 | Characteristics: 64 | 65 | - Offer can be pre-set with a custom id. This is critical since in websocket a request doesn't correspond to a response. 66 | - Successful offer request returns its status (filled, partially-filled, etc) 67 | - Has event stream. Any trade event is broadcast into a channel. 68 | 69 | Example: HitBTC 70 | 71 | #### Operations 72 | 73 | It is similar to GetOrderInfo method except that the bot doesn't need to send getOrderInfo request. The bot simply listens to a subscribed channel for trade events. 74 | 75 | As such the bot can act very fast to counter a filled order. 76 | 77 | Websocket may get disconnected. To handle such scenario, the bot caches all outbound orders. Successful request will remove the corresponding order from the cache. 78 | If websocket is down, re-send all cached requests. 79 | 80 | ### DEX (Distributed Exchange) 81 | 82 | Trades are done on or off blockchain directly from wallet. There are many DEX platforms such as Ripple or Stellar we are working on DDEX API which implements 0x. 83 | 84 | Characteristics 85 | - Mix of websocket and REST methods 86 | - Many operations depend on blockhain events (counter immediately upon trade executed event vs wait until it is finalized on ledger) 87 | 88 | #### Operations 89 | 90 | This is similar to Websocket. Trades are done with REST but updates come from websocket stream. 91 | 92 | ### Stupid Websocket Exchanges 93 | 94 | Websocket that just wraps REST methods 95 | 96 | Characteristics: 97 | - Basically REST methods are done through websocket 98 | 99 | Example: Okex 100 | 101 | In REST it is possible to map request and response. In websocket it is not possible. 102 | That's why client uses response-challenge on a param like request id to match it with incoming response. 103 | ~Having one websocket for one bot(one symbol) can make things easier, since we can eliminate the possibilities of getting responses not related to that bot.~ API quota ! 104 | Although it is very likely such server isn't designed to handle HFT (since it just wraps REST) it is still worthwhile to do websocket method if only to save some latency from Http POST/GET. 105 | 106 | ## Duplicates 107 | 108 | Duplicates are orders with the same price and amount. This is the no.1 undesirable. Duplicates are always the result of seeding at the wrong time. 109 | 110 | To prevent duplicates, seeding should initiate only when there's no pending trades in the request queue. The goal is that seeding should be based on stable open orders. 111 | 112 | The bot also has remove duplicates function that fires off regularly. However the use should not be exploited as sending order reduces the API quota. 113 | 114 | ## Extension 115 | 116 | This app is built with Akka framework that uses Actors. The core ones are: 117 | - BaseActor: reads config 118 | - OpActor : contains FIFO sequence that caches requests and sends them one-by-one with rate allowed by the exchange 119 | - OrderActor: manages order operation for a pair 120 | - counter : happens immediately after getFilledOrders returns uncounteres orders 121 | - cancel duplicates : happens if getActiveOrders returns duplicates 122 | - seed : happens if getActiveOrders returns no duplicates and has any side that is shorter than config's gridLevels 123 | - trim : happens if `bot.isStrictLevel = true` and getActiveOrders returns any side longer than config's gridLevels 124 | - ExchangeActor: handles API's request, response specific for an exchange 125 | 126 | To include a new exchange, these items need to be checked: 127 | - the type of exchange 128 | - if it's REST check if TradeHistory returns 129 | - execution timestamp (or any sortable id) to know the order the trades are executed 130 | - status (filled, partially-filled) and see if ever returns a `filled` after an order is partially-filled many times 131 | - figure out how to sign a request 132 | - implement the necessary API 133 | 134 | ### For `rest` 135 | - own past trades API must have filled, partially filled, closed status and have sortable field 136 | - the API command has sort parameter or is sorted naturally 137 | - sort parameter or sortable field (e.g updatedTime or tradeId) will be lastCounterId 138 | - if it has sort parameter, check inclusion/exclusion (ts=a returns data exclusively after a or not) 139 | - if these requirements are not met use `restgi`: check all orders one-by-one with GetOrderInfo API 140 | 141 | 142 | 143 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/common/AbsOrder.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.common 2 | 3 | import akka.actor.{Actor, ActorRef} 4 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor.{As, CancelOrder, NewOrder, SendRest} 5 | import me.mbcu.integrated.mmm.ops.common.Side.Side 6 | import me.mbcu.integrated.mmm.sequences.Strategy 7 | import me.mbcu.integrated.mmm.sequences.Strategy.PingPong 8 | 9 | import scala.collection.mutable 10 | 11 | object AbsOrder { 12 | // find duplicates: same price, same quantity, returns the newests (for cancellation) 13 | def getDuplicates(offers: Seq[Offer]): Seq[Offer] = 14 | offers.groupBy(_.price).collect { case (x, ys) if ys.lengthCompare(1) > 0 => 15 | ys.groupBy(_.quantity).collect { case (r, s) if s.lengthCompare(1) > 0 => s.sortWith(_.createdAt < _.createdAt) } 16 | }.flatten.flatMap(_.drop(1)).toSeq 17 | 18 | def isSafeForSeed(q: mutable.Queue[SendRest], nos: mutable.Set[NewOrder], bot: Bot): Boolean = 19 | (q.filter(_.bot == bot) ++ nos.filter(_.bot == bot).toSeq) 20 | .collect { 21 | case a: NewOrder => a 22 | case a: CancelOrder => a 23 | } 24 | .isEmpty 25 | 26 | /** 27 | * merge trims and killDupes while preserving As 28 | * @param trims 29 | * @param dupes 30 | * @return (trims, killDupes) 31 | */ 32 | def margeTrimAndDupes(trims: Seq[Offer], dupes: Seq[Offer]): (Seq[Offer], Seq[Offer]) = { 33 | val res = (trims.map(p => (p, As.Trim)) ++ dupes.map(p => (p, As.KillDupes))) 34 | .map(p => p._1.id -> p).toMap 35 | .values 36 | .groupBy(p => p._2) 37 | .toSeq.sortWith(_._1.toString > _._1.toString) 38 | .map(p => (p._1, p._2.toSeq.unzip._1)) 39 | .partition(_._1 == As.Trim) 40 | (res._1.unzip._2.flatten, res._2.unzip._2.flatten) 41 | } 42 | 43 | case class CheckSafeForSeed(ref: ActorRef, bot: Bot) 44 | 45 | case class SafeForSeed(yes: Boolean) 46 | 47 | case class QueueRequest(a: Seq[SendRest]) 48 | 49 | 50 | } 51 | 52 | abstract class AbsOrder(bot: Bot) extends Actor { 53 | 54 | def initialSeed(sortedBuys: Seq[Offer], sortedSels: Seq[Offer], midPrice: BigDecimal): Seq[Offer] = { 55 | var res: Seq[Offer] = Seq.empty[Offer] 56 | var buyQty = BigDecimal(0) 57 | var selQty = BigDecimal(0) 58 | var calcMidPrice = midPrice 59 | var buyLevels = 0 60 | var selLevels = 0 61 | 62 | def set(up: BigDecimal, bq: BigDecimal, sq: BigDecimal, bl: Int, sl: Int): Unit = { 63 | buyQty = bq 64 | selQty = sq 65 | buyLevels = bl 66 | selLevels = sl 67 | calcMidPrice = up 68 | } 69 | 70 | (sortedBuys.size, sortedSels.size) match { 71 | case (a, s) if a == 0 && s == 0 => set(midPrice, bot.buyOrderQuantity, bot.sellOrderQuantity, bot.buyGridLevels, bot.sellGridLevels) 72 | 73 | case (a, s) if a != 0 && s == 0 => 74 | val anyBuy = sortedBuys.head 75 | val calcMid = Strategy.calcMid(anyBuy.price, anyBuy.quantity, bot.quantityPower, bot.gridSpace, bot.counterScale, bot.baseScale, Side.buy, midPrice, bot.strategy) 76 | val bl = calcMid._3 - 1 77 | set(calcMid._1, calcMid._2, calcMid._2, bl, bot.sellGridLevels) 78 | 79 | case (a, s) if a == 0 && s != 0 => 80 | val anySel = sortedSels.head 81 | val calcMid = Strategy.calcMid(anySel.price, anySel.quantity, bot.quantityPower, bot.gridSpace, bot.counterScale, bot.baseScale, Side.sell, midPrice, bot.strategy) 82 | val sl = calcMid._3 - 1 83 | set(calcMid._1, calcMid._2, calcMid._2, bot.buyGridLevels, sl) 84 | 85 | case (a, s) if a != 0 && s != 0 => 86 | val anySel = sortedSels.head 87 | val calcMidSel = Strategy.calcMid(anySel.price, anySel.quantity, bot.quantityPower, bot.gridSpace, bot.counterScale, bot.baseScale, Side.sell, midPrice, bot.strategy) 88 | val anyBuy = sortedBuys.head 89 | val calcMidBuy = Strategy.calcMid(anyBuy.price, anyBuy.quantity, bot.quantityPower, bot.gridSpace, bot.counterScale, bot.baseScale, Side.buy, calcMidSel._1, bot.strategy) 90 | val bl = calcMidBuy._3 - 1 91 | val sl = calcMidSel._3 - 1 92 | set(calcMidSel._1, calcMidBuy._2, calcMidSel._2, bl, sl) 93 | } 94 | 95 | res ++= Strategy.seed(buyQty, calcMidPrice, bot.quantityPower, bot.counterScale, bot.baseScale, bot.pair, levels = buyLevels, gridSpace = bot.gridSpace, side = Side.buy, act = PingPong.ping, isPulledFromOtherSide = false, strategy = bot.strategy, isNoQtyCutoff = bot.isNoQtyCutoff, maxPrice = bot.maxPrice, minPrice = bot.minPrice) 96 | res ++= Strategy.seed(selQty, calcMidPrice, bot.quantityPower, bot.counterScale, bot.baseScale, bot.pair, selLevels, bot.gridSpace, Side.sell, PingPong.ping, isPulledFromOtherSide = false, bot.strategy, bot.isNoQtyCutoff, bot.maxPrice, bot.minPrice) 97 | res 98 | } 99 | 100 | def counter(order: Offer): Offer = Strategy.counter(order.quantity, order.price, bot.quantityPower, bot.counterScale, bot.baseScale, bot.pair, bot.gridSpace, order.side, PingPong.pong, bot.strategy, bot.isNoQtyCutoff, bot.maxPrice, bot.minPrice) 101 | 102 | def grow(sortedBuys: Seq[Offer], sortedSels: Seq[Offer], side: Side): Seq[Offer] = { 103 | def matcher(side: Side): Seq[Offer] = { 104 | val preSeed = getRuntimeSeedStart(sortedBuys, sortedSels, side) 105 | Strategy.seed(preSeed._2, preSeed._3, bot.quantityPower, bot.counterScale, bot.baseScale, bot.pair, preSeed._1, bot.gridSpace, side, PingPong.ping, preSeed._4, bot.strategy, bot.isNoQtyCutoff, bot.maxPrice, bot.minPrice) 106 | } 107 | 108 | side match { 109 | case Side.buy => matcher(Side.buy) 110 | case Side.sell => matcher(Side.sell) 111 | case _ => Seq.empty[Offer] // unsupported operation at runtime 112 | } 113 | } 114 | 115 | def trim(sortedBuys: Seq[Offer], sortedSels: Seq[Offer], side : Side): Seq[Offer] = { 116 | val (orders, limit) = if (side == Side.buy) (sortedBuys, bot.buyGridLevels) else (sortedSels, bot.sellGridLevels) 117 | orders.slice(limit, orders.size) 118 | } 119 | 120 | def getRuntimeSeedStart(sortedBuys: Seq[Offer], sortedSels: Seq[Offer], side: Side): (Int, BigDecimal, BigDecimal, Boolean) = { 121 | var isPulledFromOtherSide: Boolean = false 122 | var levels: Int = 0 123 | 124 | val q0p0 = side match { 125 | case Side.buy => 126 | sortedBuys.size match { 127 | case 0 => 128 | val topSel = sortedSels.head 129 | levels = bot.buyGridLevels 130 | isPulledFromOtherSide = true 131 | (topSel.quantity, topSel.price) 132 | case _ => 133 | val lowBuy = sortedBuys.last 134 | levels = bot.buyGridLevels - sortedBuys.size 135 | (lowBuy.quantity, lowBuy.price) 136 | } 137 | case Side.sell => 138 | sortedSels.size match { 139 | case 0 => 140 | val topBuy = sortedBuys.head 141 | levels = bot.sellGridLevels 142 | isPulledFromOtherSide = true 143 | (topBuy.quantity, topBuy.price) 144 | case _ => 145 | val lowSel = sortedSels.last 146 | levels = bot.sellGridLevels - sortedSels.size 147 | (lowSel.quantity, lowSel.price) 148 | } 149 | case _ => (BigDecimal("0"), BigDecimal("0")) 150 | } 151 | (levels, q0p0._1, q0p0._2, isPulledFromOtherSide) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/okex/OkexRestRequest.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.okex 2 | 3 | import me.mbcu.integrated.mmm.ops.common.Side.Side 4 | import me.mbcu.integrated.mmm.ops.common.{AbsRestRequest, Credentials, Offer} 5 | import me.mbcu.integrated.mmm.ops.okex.OkexChannels.OkexChannels 6 | import me.mbcu.integrated.mmm.ops.okex.OkexEvents.OkexEvents 7 | import me.mbcu.integrated.mmm.ops.okex.OkexStatus.OkexStatus 8 | import play.api.libs.functional.syntax._ 9 | import play.api.libs.json._ 10 | 11 | 12 | object OkexEvents extends Enumeration { 13 | type OkexEvents = Value 14 | val addChannel, removeChannel, login, ping, pong = Value 15 | 16 | implicit val read = Reads.enumNameReads(OkexEvents) 17 | implicit val write = Writes.enumNameWrites 18 | 19 | def withNameOpt(s: String): Option[Value] = values.find(_.toString == s) 20 | } 21 | 22 | object OkexChannels extends Enumeration { 23 | type OkexChannels = Value 24 | val ok_spot_orderinfo, ok_spot_order, ok_spot_cancel_order, login = Value 25 | 26 | implicit val read = Reads.enumNameReads(OkexChannels) 27 | implicit val write = Writes.enumNameWrites 28 | 29 | def withNameOpt(s: String): Option[Value] = values.find(_.toString == s) 30 | } 31 | 32 | object OkexRestRequest extends AbsRestRequest{ 33 | 34 | implicit val jsonFormat = Json.format[OkexRestRequest] 35 | 36 | object Implicits { 37 | implicit val writes = new Writes[OkexRestRequest] { 38 | def writes(r: OkexRestRequest): JsValue = Json.obj( 39 | "event" -> r.event, 40 | "channel" -> r.channel, 41 | "parameters" -> r.parameters 42 | ) 43 | } 44 | 45 | implicit val reads: Reads[OkexRestRequest] = ( 46 | (JsPath \ "event").read[OkexEvents] and 47 | (JsPath \ "channel").readNullable[OkexChannels] and 48 | (JsPath \ "parameters").readNullable[OkexParameters] 49 | ) (OkexRestRequest.apply _) 50 | 51 | } 52 | 53 | def login(apiKey : String, secret : String) : OkexRestRequest = { 54 | val params = OkexParameters(None, apiKey, None, None, None, None, None) 55 | val signed = sign(secret, params) 56 | val p = OkexParameters(Some(signed), apiKey, None, None, None, None, None) 57 | OkexRestRequest(OkexEvents.login, None, Some(p)) 58 | } 59 | 60 | def newOrder(credentials: Credentials, offer: Offer): OkexRestRequest = newOrder(credentials.pKey, credentials.signature, offer.symbol, offer.side, offer.quantity, offer.price) 61 | 62 | 63 | def newOrder(apiKey : String, secret : String, symbol : String, `type` : Side, amount : BigDecimal, price : BigDecimal): OkexRestRequest = { 64 | val params = OkexParameters(None, apiKey, Some(symbol), None, Some(`type`), Some(price), Some(amount)) 65 | val signed = sign(secret, params) 66 | val p = OkexParameters(Some(signed), apiKey, Some(symbol), None, Some(`type`), Some(price), Some(amount)) 67 | OkexRestRequest(OkexEvents.addChannel, Some(OkexChannels.ok_spot_order), Some(p)) 68 | } 69 | 70 | def restCancelOrder(credentials: Credentials, symbol: String, orderId : String) : Map[String, String] = 71 | restCancelOrder(credentials.pKey, credentials.signature, symbol, orderId) 72 | 73 | def restCancelOrder(apiKey: String, secret : String, symbol: String, orderId : String) : Map[String, String] = { 74 | val params = OkexParameters(None, apiKey, Some(symbol), Some(orderId), None, None, None, None, None, None) 75 | val signed = sign(secret, params) 76 | val p = OkexParameters(Some(signed), apiKey, Some(symbol), Some(orderId), None, None, None, None, None, None ) 77 | Json.toJson(p).as[JsObject].value.map(r => r._1 -> r._2.toString().replace("\"", "")).toMap 78 | } 79 | 80 | def cancelOrder(apiKey: String, secret : String, symbol : String, orderId : String) : OkexRestRequest = { 81 | val p = min3(apiKey, secret, symbol, orderId) 82 | OkexRestRequest(OkexEvents.addChannel, Some(OkexChannels.ok_spot_cancel_order), Some(p)) 83 | } 84 | 85 | def infoOrder(apiKey : String, secret : String, symbol : String, orderId : String) : OkexRestRequest = { 86 | val p = min3(apiKey, secret, symbol, orderId) 87 | OkexRestRequest(OkexEvents.addChannel, Some(OkexChannels.ok_spot_orderinfo), Some(p)) 88 | } 89 | 90 | def restNewOrder(credentials: Credentials, symbol: String, `type`: Side, price: BigDecimal, amount: BigDecimal) : Map[String, String] = 91 | restNewOrder(credentials.pKey, credentials.signature, symbol, `type`, price, amount) 92 | 93 | def restNewOrder(apiKey: String, secret: String, symbol: String, `type`: Side, price: BigDecimal, amount: BigDecimal) : Map[String, String] = { 94 | val params = OkexParameters(None, apiKey, Some(symbol), None, Some(`type`), Some(price), Some(amount), None, None, None) 95 | val signed = sign(secret, params) 96 | val p = OkexParameters(Some(signed), apiKey, Some(symbol), None, Some(`type`), Some(price), Some(amount), None, None, None) 97 | Json.toJson(p).as[JsObject].value.map(r => r._1 -> r._2.toString().replace("\"", "")).toMap 98 | } 99 | 100 | def restInfoOrder(credentials: Credentials, symbol: String, orderId: String) : Map[String, String] = 101 | restInfoOrder(credentials.pKey, credentials.signature, symbol : String, orderId: String) 102 | 103 | def restInfoOrder(apiKey : String, secret: String, symbol : String, orderId: String) : Map[String, String] = { 104 | val params = OkexParameters(None, apiKey, Some(symbol), Some(orderId), None, None, None, None, None, None) 105 | val signed = sign(secret, params) 106 | val p = OkexParameters(Some(signed), apiKey, Some(symbol), Some(orderId), None, None, None, None, None, None) 107 | Json.toJson(p).as[JsObject].value.map(r => r._1 -> r._2.toString().replace("\"", "")).toMap 108 | } 109 | 110 | def restOwnTrades(credentials: Credentials, symbol: String, status: OkexStatus, currentPage : Int) : Map[String, String] = 111 | restOwnTrades(credentials.pKey, credentials.signature, symbol, status, currentPage ) 112 | 113 | def restOwnTrades(apiKey:String, secret:String, symbol:String, status: OkexStatus, currentPage : Int) : Map[String, String] = { 114 | val pageLength = 200 115 | val params = OkexParameters(None, apiKey, Some(symbol), None, None, None, None, Some(status), Some(currentPage), Some(pageLength)) 116 | val signed = sign(secret, params) 117 | val p = OkexParameters(Some(signed), apiKey, Some(symbol), None, None, None, None, Some(status), Some(currentPage), Some(pageLength)) 118 | Json.toJson(p).as[JsObject].value.map(r => r._1 -> r._2.toString().replace("\"", "")).toMap 119 | } 120 | 121 | def restTicker(symbol: String) : Map[String, String] = Map("symbol" -> symbol) 122 | 123 | def ping() : OkexRestRequest = OkexRestRequest(OkexEvents.ping, None, None) 124 | 125 | 126 | private def min3(apiKey: String, secret: String, symbol: String, orderId: String ) : OkexParameters = { 127 | val params = OkexParameters(None, apiKey, Some(symbol), Some(orderId), None, None, None) 128 | OkexParameters(Some(sign(secret, params)), apiKey, Some(symbol), Some(orderId), None, None, None) 129 | } 130 | 131 | 132 | val sign : (String, OkexParameters) => String = (secret, params) => { 133 | val a = Json.toJson(params).as[JsObject] 134 | val b = a.fields.sortBy(_._1).map(c => s"${c._1.toString}=${c._2}").reduce((l, r) => s"$l&$r") 135 | val d = s"""$b&secret_key=$secret""".replace("\"", "") 136 | md5(secret, d) 137 | } 138 | } 139 | 140 | case class OkexRestRequest(event : OkexEvents, channel : Option[OkexChannels], parameters : Option[OkexParameters]) 141 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/btcalpha/BtcalphaActor.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.btcalpha 2 | 3 | import akka.dispatch.ExecutionContexts.global 4 | import akka.stream.ActorMaterializer 5 | import me.mbcu.integrated.mmm.ops.Definitions.ShutdownCode 6 | import me.mbcu.integrated.mmm.ops.btcalpha.BtcalphaRequest.BtcalphaStatus.BtcalphaStatus 7 | import me.mbcu.integrated.mmm.ops.btcalpha.BtcalphaRequest.{BtcalphaParams, BtcalphaStatus} 8 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor._ 9 | import me.mbcu.integrated.mmm.ops.common.Side.Side 10 | import me.mbcu.integrated.mmm.ops.common.{AbsRestActor, Offer, Status} 11 | import me.mbcu.scala.MyLogging 12 | import play.api.libs.json.{JsValue, Json} 13 | import play.api.libs.ws.ahc.StandaloneAhcWSClient 14 | 15 | import scala.concurrent.ExecutionContextExecutor 16 | import scala.concurrent.duration._ 17 | import scala.language.postfixOps 18 | import scala.util.{Failure, Success, Try} 19 | 20 | object BtcalphaActor { 21 | 22 | def parseTicker(js: JsValue): BigDecimal = BigDecimal((js.as[List[JsValue]].head \ "close").as[Double]) 23 | 24 | def toOffer(js: JsValue): Offer = 25 | new Offer( 26 | (js \ "id").as[Long].toString, 27 | (js \ "pair").as[String], 28 | (js \ "type").as[Side], 29 | (js \ "status").as[BtcalphaStatus] match { 30 | case BtcalphaStatus.done => Status.filled 31 | case BtcalphaStatus.active => Status.active 32 | case BtcalphaStatus.cancelled => Status.cancelled 33 | case _ => Status.cancelled 34 | }, 35 | createdAt = -1, 36 | None, 37 | (js \ "amount").as[BigDecimal], // this is the remaining amount not original amount 38 | (js \ "price").as[BigDecimal], 39 | None 40 | ) 41 | 42 | /* 43 | [ 44 | { 45 | "id": 28004855, 46 | "type": "buy", 47 | "pair": "NOAH_ETH", 48 | "price": "0.00000100", 49 | "amount": "1000.00000000", 50 | "status": 1 51 | } 52 | ] 53 | */ 54 | 55 | } 56 | 57 | class BtcalphaActor extends AbsRestActor with MyLogging { 58 | import play.api.libs.ws.DefaultBodyReadables._ 59 | import play.api.libs.ws.DefaultBodyWritables._ 60 | 61 | private implicit val materializer = ActorMaterializer() 62 | private implicit val ec: ExecutionContextExecutor = global 63 | private var ws = StandaloneAhcWSClient() 64 | 65 | override def start(): Unit = setOp(Some(sender())) 66 | 67 | override def sendRequest(r: AbsRestActor.SendRest): Unit = { 68 | r match { 69 | 70 | case a: GetTickerStartPrice => httpGet(a, BtcalphaRequest.getTickers(a.bot.pair)) // https://btc-alpha.com/api/charts/BTC_USD/1/chart/?format=json&limit=1 71 | 72 | case a: NewOrder => httpPost(a, BtcalphaRequest.newOrder(a.bot.credentials, a.bot.pair, a.offer.side, a.offer.price, a.offer.quantity)) 73 | 74 | case a: CancelOrder => httpPost(a, BtcalphaRequest.cancelOrder(a.bot.credentials, a.offer.id)) 75 | 76 | case a: GetActiveOrders => httpGet(a, BtcalphaRequest.getOrders(a.bot.credentials, a.bot.pair, BtcalphaStatus.active)) 77 | 78 | case a: GetOwnPastTrades => httpGet(a, BtcalphaRequest.getOrders(a.bot.credentials, a.bot.pair, BtcalphaStatus.done)) 79 | 80 | case a: GetOrderInfo => httpGet(a, BtcalphaRequest.getOrderInfo(a.bot.credentials, a.id)) 81 | 82 | } 83 | 84 | def httpPost(a: SendRest, r: BtcalphaParams): Unit = { 85 | ws.url(r.url) 86 | .addHttpHeaders("Content-Type" -> "application/x-www-form-urlencoded") 87 | .addHttpHeaders("X-KEY" -> a.bot.credentials.pKey) 88 | .addHttpHeaders("X-SIGN" -> r.sign) 89 | .addHttpHeaders("X-NONCE" -> r.nonce) 90 | .withRequestTimeout(requestTimeoutSec seconds) 91 | .post(r.params) 92 | .map(response => parse(a, r.params, response.body[String])) 93 | .recover{ 94 | case e: Exception => errorRetry(a, 0, e.getMessage, shouldEmail = false) 95 | } 96 | } 97 | 98 | def httpGet(a: SendRest, r: BtcalphaParams): Unit = { 99 | ws.url(r.url) 100 | .addHttpHeaders("X-KEY" -> a.bot.credentials.pKey) 101 | .addHttpHeaders("X-SIGN" -> r.sign) 102 | .addHttpHeaders("X-NONCE" -> r.nonce) 103 | .withRequestTimeout(requestTimeoutSec seconds) 104 | .get() 105 | .map(response => parse(a, r.params, response.body[String])) 106 | .recover{ 107 | case e: Exception => errorRetry(a, 0, e.getMessage, shouldEmail = false) 108 | } 109 | } 110 | } 111 | 112 | def parse(a: AbsRestActor.SendRest, request: String, raw: String): Unit = { 113 | info(logResponse(a, raw)) 114 | val arriveMs = System.currentTimeMillis() 115 | val book = a.book 116 | 117 | a match { 118 | case p: NewOrder => op foreach (_ ! GotNewOrderId(s"prov-${System.currentTimeMillis()}", p.as, arriveMs, p)) 119 | case _ => 120 | } 121 | 122 | val x = Try(Json parse raw) 123 | x match { 124 | case Success(js) => 125 | 126 | val detail = (js \ "detail").asOpt[String] 127 | if (detail.isDefined) { 128 | detail.get match { 129 | case m if m contains "Wrong Nonce" => errorRetry(a, 0, m, false) 130 | case m if m contains "Incorrect authentication credentials" => errorShutdown(ShutdownCode.fatal, 0, s"$request $m") 131 | case _ => errorIgnore(-1, raw) 132 | } 133 | } 134 | else if (raw contains "Order already done"){ 135 | /* 136 | { 137 | "order": [ 138 | "Order already done" 139 | ] 140 | } 141 | */ 142 | book ! GotOrderCancelled(a.as, arriveMs,a.asInstanceOf[CancelOrder]) 143 | errorIgnore(0, "order has gone") 144 | } 145 | else if (raw contains "Out of balance"){ 146 | errorIgnore(0, s"Not enough balance ${a.bot.pair}") 147 | } 148 | else { 149 | 150 | a match { 151 | 152 | case a: GetTickerStartPrice => book ! GotStartPrice(Some(BtcalphaActor.parseTicker(js)), arriveMs, a) 153 | 154 | case a: GetOwnPastTrades => 155 | val orders = js.as[List[JsValue]].map(BtcalphaActor.toOffer) 156 | val price = if (orders.nonEmpty) Some(orders.head.price) else None 157 | book ! GotStartPrice(price, arriveMs, a) 158 | 159 | case a: GetActiveOrders => 160 | val activeOrders = js.as[List[JsValue]].map(BtcalphaActor.toOffer) 161 | book ! GotActiveOrders(activeOrders, a.page, nextPage = false, arriveMs, a) 162 | 163 | case a: GetOrderInfo => book ! GotOrderInfo(BtcalphaActor.toOffer(js), arriveMs, a) 164 | 165 | case a: CancelOrder => book ! GotOrderCancelled(a.as, arriveMs, a) 166 | 167 | case a: NewOrder => 168 | val id = (js \ "oid").as[Long].toString 169 | val serverMs = ((js \ "date").as[Double] * 1000).toLong 170 | book ! GotProvisionalOffer(id, serverMs, a.offer) 171 | // op foreach (_ ! GotNewOrderId(id, a.as, arriveMs, a)) 172 | 173 | case _ => error(s"Unknown BtcalphaActor#parse : $raw") 174 | } 175 | } 176 | 177 | case Failure(r) => 178 | raw match { 179 | case m if m contains "html" => errorRetry(a, 0, raw) 180 | case m if m contains "Ddos" => errorRetry(a, 0, m, shouldEmail = false) 181 | case m if m.isEmpty => errorRetry(a, 0, m, shouldEmail = false) 182 | case _ => errorIgnore(0, s"Unknown BtcalphaActor#parse : $raw") 183 | } 184 | } 185 | 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/actors/WsActor.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.actors 2 | 3 | import java.util 4 | 5 | import akka.actor.{Actor, ActorRef, Cancellable, Props} 6 | import akka.dispatch.ExecutionContexts.global 7 | import com.neovisionaries.ws.client 8 | import com.neovisionaries.ws.client.{WebSocketFactory, WebSocketListener, WebSocketState} 9 | import me.mbcu.integrated.mmm.actors.WsActor._ 10 | import me.mbcu.integrated.mmm.ops.Definitions.ShutdownCode 11 | import me.mbcu.scala.MyLogging 12 | import play.api.libs.json.{JsValue, Json} 13 | 14 | import scala.concurrent.ExecutionContextExecutor 15 | import scala.language.postfixOps 16 | import scala.util.{Failure, Success, Try} 17 | 18 | object WsActor { 19 | def props(): Props = Props(new WsActor()) 20 | 21 | object WsConnected 22 | 23 | object WsDisconnected 24 | 25 | object WsRequestClient 26 | 27 | case class WsConnect(url: String) 28 | 29 | case class SendJs(jsValue: JsValue) 30 | 31 | case class WsGotText(text: String) 32 | 33 | case class WsError(msg: String) 34 | 35 | } 36 | 37 | class WsActor() extends Actor with MyLogging{ 38 | private implicit val ec: ExecutionContextExecutor = global 39 | private var ws : Option[com.neovisionaries.ws.client.WebSocket] = None 40 | private var main: Option[ActorRef] = None 41 | private var disCan: Option[Cancellable] = None 42 | private val timeout: Int = 5000 43 | 44 | override def receive: Receive = { 45 | 46 | case "start" => 47 | main = Some(sender) 48 | main foreach(_ ! WsRequestClient) 49 | 50 | case WsConnect(url) => 51 | ws match { 52 | case None => 53 | val factory = new WebSocketFactory 54 | factory.setConnectionTimeout(timeout) 55 | val websocket = factory.createSocket(url) 56 | websocket.addListener(ScalaWebSocketListener) 57 | ws = Some(websocket) 58 | Try(websocket.connect) match { // failure at connecting will result in exception. Listeners only sense errors when ws is alive 59 | case Success(w) => info("WsActor got connection. See onConnected for handler") 60 | case Failure(e) => // timeout error 61 | error(e.getMessage) 62 | self ! WsRequestClient 63 | } 64 | 65 | case _ => // don't init a client 66 | 67 | } 68 | 69 | case SendJs(jsValue) => 70 | ws match { 71 | case Some(w) => 72 | if (w.isOpen){ 73 | ws.foreach(_.sendText(Json.stringify(jsValue))) 74 | } else { 75 | error("WsActor# websocket is not open") 76 | } 77 | case _ => 78 | } 79 | 80 | case WsError(msg) => 81 | error(msg) 82 | ws foreach(_.disconnect("server down")) // One client can only be disconnected once 83 | 84 | case WsDisconnected => 85 | info("WsActor client disconnected") 86 | self ! WsRequestClient 87 | 88 | case WsRequestClient => 89 | ws foreach(_.clearListeners()) 90 | ws = None 91 | main foreach(_ ! WsRequestClient) 92 | 93 | } 94 | 95 | object ScalaWebSocketListener extends WebSocketListener { 96 | val fatal = Some(ShutdownCode.fatal.id) 97 | val recover = Some(ShutdownCode.recover.id) 98 | 99 | override def onConnected(websocket: client.WebSocket, headers: util.Map[String, util.List[String]]): Unit = main foreach (_ ! WsConnected) 100 | 101 | def onTextMessage(websocket: com.neovisionaries.ws.client.WebSocket, data: String) : Unit = 102 | main foreach (_ ! WsGotText(data)) 103 | 104 | override def onStateChanged(websocket: client.WebSocket, newState: WebSocketState): Unit = {} 105 | override def handleCallbackError(x$1: com.neovisionaries.ws.client.WebSocket, t: Throwable): Unit = 106 | self ! WsError(s"WsActor#handleCallbackError: ${t.getMessage}") 107 | 108 | override def onBinaryFrame(x$1: com.neovisionaries.ws.client.WebSocket, f: com.neovisionaries.ws.client.WebSocketFrame): Unit = {} 109 | override def onBinaryMessage(x$1: com.neovisionaries.ws.client.WebSocket, d: Array[Byte]): Unit = {} 110 | override def onCloseFrame(x$1: com.neovisionaries.ws.client.WebSocket, x$2: com.neovisionaries.ws.client.WebSocketFrame): Unit = {} 111 | override def onConnectError(x$1: com.neovisionaries.ws.client.WebSocket, e: com.neovisionaries.ws.client.WebSocketException): Unit = 112 | self ! WsError(s"WsActor#onConnectError: ${e.getMessage}") 113 | 114 | 115 | override def onContinuationFrame(x$1: com.neovisionaries.ws.client.WebSocket, x$2: com.neovisionaries.ws.client.WebSocketFrame): Unit = {} 116 | override def onDisconnected(x$1: com.neovisionaries.ws.client.WebSocket, x$2: com.neovisionaries.ws.client.WebSocketFrame, x$3: com.neovisionaries.ws.client.WebSocketFrame, closedByServer: Boolean): Unit = 117 | self ! WsDisconnected 118 | 119 | override def onError(x$1: com.neovisionaries.ws.client.WebSocket, e: com.neovisionaries.ws.client.WebSocketException): Unit = 120 | self ! WsError(s"WsActor#onError: ${e.getMessage}") 121 | 122 | 123 | override def onFrame(x$1: com.neovisionaries.ws.client.WebSocket, x$2: com.neovisionaries.ws.client.WebSocketFrame): Unit = {} 124 | override def onFrameError(x$1: com.neovisionaries.ws.client.WebSocket, e: com.neovisionaries.ws.client.WebSocketException, x$3: com.neovisionaries.ws.client.WebSocketFrame): Unit = 125 | self ! WsError(s"WsActor#onFrameError: ${e.getMessage}") 126 | 127 | override def onFrameSent(x$1: com.neovisionaries.ws.client.WebSocket, x$2: com.neovisionaries.ws.client.WebSocketFrame): Unit = {} 128 | override def onFrameUnsent(x$1: com.neovisionaries.ws.client.WebSocket, x$2: com.neovisionaries.ws.client.WebSocketFrame): Unit = {} 129 | override def onMessageDecompressionError(x$1: com.neovisionaries.ws.client.WebSocket, e: com.neovisionaries.ws.client.WebSocketException, x$3: Array[Byte]): Unit = 130 | self ! WsError(s"WsActor#onMessageDecompressionError: ${e.getMessage}") 131 | 132 | override def onMessageError(x$1: com.neovisionaries.ws.client.WebSocket, e: com.neovisionaries.ws.client.WebSocketException, x$3: java.util.List[com.neovisionaries.ws.client.WebSocketFrame]): Unit = 133 | self ! WsError(s"WsActor#onMessageError: ${e.getMessage}") 134 | 135 | override def onPingFrame(x$1: com.neovisionaries.ws.client.WebSocket, x$2: com.neovisionaries.ws.client.WebSocketFrame): Unit = {} 136 | override def onPongFrame(x$1: com.neovisionaries.ws.client.WebSocket, x$2: com.neovisionaries.ws.client.WebSocketFrame): Unit = {} 137 | override def onSendError(x$1: com.neovisionaries.ws.client.WebSocket, e: com.neovisionaries.ws.client.WebSocketException, x$3: com.neovisionaries.ws.client.WebSocketFrame): Unit = 138 | self ! WsError(s"WsActor#onSendError: ${e.getMessage}") 139 | 140 | override def onSendingFrame(x$1: com.neovisionaries.ws.client.WebSocket, x$2: com.neovisionaries.ws.client.WebSocketFrame): Unit = {} 141 | override def onSendingHandshake(x$1: com.neovisionaries.ws.client.WebSocket, x$2: String, x$3: java.util.List[Array[String]]): Unit = {} 142 | override def onTextFrame(x$1: com.neovisionaries.ws.client.WebSocket, x$2: com.neovisionaries.ws.client.WebSocketFrame): Unit = {} 143 | override def onTextMessageError(x$1: com.neovisionaries.ws.client.WebSocket, e: com.neovisionaries.ws.client.WebSocketException, x$3: Array[Byte]): Unit = 144 | self ! WsError(s"WsActor#onTextMessageError: ${e.getMessage}") 145 | 146 | override def onThreadCreated(x$1: com.neovisionaries.ws.client.WebSocket, x$2: com.neovisionaries.ws.client.ThreadType, x$3: Thread): Unit = {} 147 | override def onThreadStarted(x$1: com.neovisionaries.ws.client.WebSocket, x$2: com.neovisionaries.ws.client.ThreadType, x$3: Thread): Unit = {} 148 | override def onThreadStopping(x$1: com.neovisionaries.ws.client.WebSocket, x$2: com.neovisionaries.ws.client.ThreadType, x$3: Thread): Unit = {} 149 | override def onUnexpectedError(x$1: com.neovisionaries.ws.client.WebSocket, e: com.neovisionaries.ws.client.WebSocketException): Unit = 150 | self ! WsError(s"WsActor#onUnexpectedError: ${e.getMessage}") 151 | 152 | } 153 | 154 | 155 | 156 | } 157 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/ops/livecoin/LivecoinActor.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.ops.livecoin 2 | 3 | import akka.dispatch.ExecutionContexts.global 4 | import akka.stream.ActorMaterializer 5 | import me.mbcu.integrated.mmm.ops.Definitions.ShutdownCode 6 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor._ 7 | import me.mbcu.integrated.mmm.ops.common.{AbsRestActor, Offer, Side, Status} 8 | import me.mbcu.integrated.mmm.ops.livecoin.LivecoinRequest.LivecoinState.LivecoinState 9 | import me.mbcu.integrated.mmm.ops.livecoin.LivecoinRequest.{LivecoinParams, LivecoinState} 10 | import me.mbcu.scala.MyLogging 11 | import play.api.libs.json.{JsValue, Json} 12 | import play.api.libs.ws.ahc.StandaloneAhcWSClient 13 | 14 | import scala.concurrent.ExecutionContextExecutor 15 | import scala.concurrent.duration._ 16 | import scala.language.postfixOps 17 | import scala.util.{Failure, Success, Try} 18 | 19 | object LivecoinActor { 20 | 21 | def toOffer(data: JsValue):Offer = { 22 | val state = (data \ "orderStatus").as[LivecoinState] 23 | 24 | val status = state match { 25 | case LivecoinState.CLOSED => Status.expired // returns EXECUTED and CANCELLED, only used in request 26 | case LivecoinState.EXECUTED => Status.filled 27 | case LivecoinState.OPEN => Status.active 28 | case LivecoinState.CANCELLED => Status.cancelled 29 | case LivecoinState.NOT_CANCELLED => Status.active 30 | case LivecoinState.PARTIALLY => Status.partiallyFilled // used in request 31 | case LivecoinState.PARTIALLY_FILLED => Status.partiallyFilled // used in request 32 | case LivecoinState.PARTIALLY_FILLED_AND_CANCELLED => Status.partiallyFilled 33 | case LivecoinState.ALL => Status.active 34 | case _ => Status.cancelled 35 | } 36 | val quantity = (data \ "quantity").as[BigDecimal] 37 | new Offer( 38 | (data \ "id").as[Long].toString, 39 | (data \ "currencyPair").as[String], 40 | if ((data \ "type").as[String] contains "LIMIT_BUY") Side.buy else Side.sell, 41 | status, 42 | (data \ "issueTime").as[Long], 43 | Some((data \ "lastModificationTime").as[Long]), 44 | quantity, 45 | (data \ "price").as[BigDecimal], 46 | Some(quantity - (data \ "remainingQuantity").as[BigDecimal]) 47 | ) 48 | 49 | /* 50 | "id": 17551577701, 51 | "currencyPair": "ETH/BTC", 52 | "goodUntilTime": 0, 53 | "type": "LIMIT_SELL", 54 | "orderStatus": "OPEN", 55 | "issueTime": 1535474938522, 56 | "price": 0.05, 57 | "quantity": 0.01, 58 | "remainingQuantity": 0.01, 59 | "commissionByTrade": 0, 60 | "bonusByTrade": 0, 61 | "bonusRate": 0, 62 | "commissionRate": 0.0018, 63 | "lastModificationTime": 1535474938522 64 | */ 65 | } 66 | 67 | def getUncounteredOrders(list: List[JsValue]): Seq[Offer] = 68 | list.map(LivecoinActor.toOffer) 69 | .filter(_.status == Status.filled) 70 | .sortWith((p,q) => p.updatedAt.getOrElse(p.createdAt) < q.updatedAt.getOrElse(q.createdAt)) 71 | 72 | } 73 | 74 | class LivecoinActor extends AbsRestActor with MyLogging{ 75 | import play.api.libs.ws.DefaultBodyReadables._ 76 | import play.api.libs.ws.DefaultBodyWritables._ 77 | 78 | private implicit val materializer = ActorMaterializer() 79 | private implicit val ec: ExecutionContextExecutor = global 80 | private var ws = StandaloneAhcWSClient() 81 | override def start(): Unit = setOp(Some(sender())) 82 | 83 | override def sendRequest(r: AbsRestActor.SendRest): Unit = { 84 | 85 | r match { 86 | 87 | case a: GetTickerStartPrice => httpGet(a, LivecoinRequest.getTicker(a.bot.pair)) 88 | 89 | case a: GetActiveOrders => httpGet(a, LivecoinRequest.getActiveOrders(a.bot.credentials, a.bot.pair, LivecoinState.OPEN, (a.page - 1) * 100 )) 90 | 91 | case a: GetFilledOrders => httpGet(a, LivecoinRequest.getOwnTrades(a.bot.credentials, a.bot.pair, LivecoinState.CLOSED, a.lastCounterId.toLong)) 92 | 93 | case a: NewOrder => httpPost(a, LivecoinRequest.newOrder(a.bot.credentials, a.offer.symbol, a.offer.side, a.offer.price, a.offer.quantity)) 94 | 95 | case a: CancelOrder => httpPost(a, LivecoinRequest.cancelOrder(a.bot.credentials, a.offer.symbol, a.offer.id)) 96 | } 97 | 98 | def httpPost(a: SendRest, r: LivecoinParams): Unit = { 99 | ws.url(r.url) 100 | .addHttpHeaders("Content-Type" -> "application/x-www-form-urlencoded") 101 | .addHttpHeaders("Sign" -> r.sign) 102 | .addHttpHeaders("API-key" -> a.bot.credentials.pKey) 103 | .withRequestTimeout(requestTimeoutSec seconds) 104 | .post(r.params) 105 | .map(response => parse(a, r.params, response.body[String])) 106 | .recover{ 107 | case e: Exception => errorRetry(a, 0, e.getMessage, shouldEmail = false) 108 | } 109 | } 110 | 111 | def httpGet(a: SendRest, r: LivecoinParams): Unit = { 112 | ws.url(r.url) 113 | .addHttpHeaders("Content-Type" -> "application/x-www-form-urlencoded") 114 | .addHttpHeaders("Sign" -> r.sign) 115 | .addHttpHeaders("API-key" -> a.bot.credentials.pKey) 116 | .withRequestTimeout(requestTimeoutSec seconds) 117 | .get() 118 | .map(response => parse(a, r.params, response.body[String])) 119 | .recover{ 120 | case e: Exception => errorRetry(a, 0, e.getMessage, shouldEmail = false) 121 | } 122 | } 123 | } 124 | 125 | def parse(a: AbsRestActor.SendRest, request: String, raw: String): Unit = { 126 | info(logResponse(a, raw)) 127 | val arriveMs = System.currentTimeMillis() 128 | 129 | a match { 130 | case p: NewOrder => op foreach (_ ! GotNewOrderId("unused", p.as, arriveMs, p)) 131 | case _ => 132 | } 133 | 134 | val x = Try(Json parse raw) 135 | x match { 136 | case Success(js) => 137 | val success = js \ "success" 138 | if (success.isDefined) { 139 | if (!success.as[Boolean]) { 140 | if (raw.contains("invalid signature")) errorIgnore(-1, "invalid signature") 141 | else if (raw contains "incorrect key" ) errorShutdown(ShutdownCode.fatal, -1, "wrong API key: ${a.bot.pair}") 142 | else if (raw contains "Minimal amount is") errorIgnore(-1, "minimal amount too low: ${a.bot.pair}") 143 | else if (raw contains "insufficient funds") errorIgnore(-1, s"insufficient funds: ${a.bot.pair}") 144 | else if (raw contains "Cannot get a connection, pool error Timeout waiting for idle object" ) errorRetry(a, 0, raw, shouldEmail = false) 145 | else if (raw contains "Service is under maintenance") errorRetry(a, 0, raw, shouldEmail = false) 146 | else errorIgnore(-1, raw) 147 | } 148 | } 149 | else { 150 | a match { 151 | 152 | case a: GetTickerStartPrice => 153 | val lastPrice = (js \ "last").as[BigDecimal] 154 | a.book ! GotTickerStartPrice(Some(lastPrice), arriveMs, a) 155 | 156 | case a: GetFilledOrders => 157 | val offers = LivecoinActor.getUncounteredOrders((js \ "data").as[List[JsValue]]) 158 | val lastCounterId = if (offers.nonEmpty) Some((offers.last.updatedAt.getOrElse(offers.last.createdAt) + 1L).toString) else None 159 | a.book ! GotUncounteredOrders(offers, lastCounterId, isSortedFromOldest = true, arriveMs, a) 160 | 161 | case a: GetActiveOrders => 162 | val res = (js \ "data").as[List[JsValue]].map(LivecoinActor.toOffer) 163 | // val isNextPage = (js \ "totalRows").as[Int] > (js \ "endRow").as[Int] // broken 164 | a.book ! GotActiveOrders(res, a.page, nextPage = false , arriveMs, a) 165 | } 166 | 167 | } 168 | 169 | case Failure(e) => 170 | raw match { 171 | case m if m contains "html" => errorRetry(a, 0, raw, shouldEmail = false) 172 | case m if m.isEmpty => errorRetry(a, 0, m, shouldEmail = false) 173 | case _ => errorIgnore(0, s"Unknown LivecoinActor#parse : $raw") 174 | } 175 | } 176 | } 177 | 178 | } 179 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/actors/OrderWsActor.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.actors 2 | 3 | import akka.actor.{ActorRef, Cancellable} 4 | import akka.dispatch.ExecutionContexts.global 5 | import me.mbcu.integrated.mmm.actors.OpWsActor.QueueWs 6 | import me.mbcu.integrated.mmm.actors.WsActor.WsRequestClient 7 | import me.mbcu.integrated.mmm.ops.Definitions.{ErrorShutdown, Settings, ShutdownCode} 8 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor.As 9 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor.As.As 10 | import me.mbcu.integrated.mmm.ops.common.AbsWsParser._ 11 | import me.mbcu.integrated.mmm.ops.common.Side.Side 12 | import me.mbcu.integrated.mmm.ops.common._ 13 | import me.mbcu.scala.MyLogging 14 | 15 | import scala.collection.concurrent.TrieMap 16 | import scala.concurrent.ExecutionContextExecutor 17 | import scala.concurrent.duration._ 18 | import scala.language.postfixOps 19 | 20 | class OrderWsActor(bot: Bot, exchange: AbsExchange, req: AbsWsRequest) extends AbsOrder(bot) with MyLogging{ 21 | private implicit val ec: ExecutionContextExecutor = global 22 | private val wsEx: AbsWsExchange = exchange.asInstanceOf[AbsWsExchange] 23 | var sels: TrieMap[String, Offer] = TrieMap.empty[String, Offer] 24 | var buys: TrieMap[String, Offer] = TrieMap.empty[String, Offer] 25 | var sortedSels: scala.collection.immutable.Seq[Offer] = scala.collection.immutable.Seq.empty[Offer] 26 | var sortedBuys: scala.collection.immutable.Seq[Offer] = scala.collection.immutable.Seq.empty[Offer] 27 | var selTrans : TrieMap[String, Offer] = TrieMap.empty[String, Offer] 28 | var buyTrans : TrieMap[String, Offer] = TrieMap.empty[String, Offer] 29 | var pendings : TrieMap[String, SendWs] = TrieMap.empty[String, SendWs] 30 | var isStartingPrice: Boolean = true 31 | 32 | var logCancellable: Option[Cancellable] = None 33 | private var op: Option[ActorRef] = None 34 | 35 | override def receive: Receive = { 36 | 37 | case "start" => 38 | op = Some(sender()) 39 | logCancellable = Some(context.system.scheduler.schedule(15 second, Settings.intervalLogSeconds seconds, self, message="log")) 40 | 41 | case "log" => info(Offer.dump(bot, sortedBuys, sortedSels)) 42 | 43 | case WsRequestClient => // stop scheduler 44 | 45 | case GotSubscribe(requestId) => queue(pendings.values.toSeq) 46 | 47 | case GotActiveOrdersWs(offers, requestId) => 48 | 49 | if (isStartingPrice) { 50 | addSort(offers) 51 | 52 | bot.seed match { 53 | case a if a.equals(StartMethods.lastTicker.toString) => 54 | cancelOrders((buys ++ sels).values.toSeq, As.ClearOpenOrders) 55 | unlockInitSeed() 56 | 57 | case a if a.equals(StartMethods.cont.toString) => isStartingPrice = false 58 | 59 | case a if a.equals(StartMethods.lastOwn.toString) => op.foreach(_ ! ErrorShutdown(ShutdownCode.fatal, -1, "lastOwn is not supported")) 60 | 61 | case _ => 62 | cancelOrders((buys ++ sels).values.toSeq, As.ClearOpenOrders) 63 | unlockInitSeed() 64 | 65 | } 66 | } else { // after ws gets reconnected 67 | val m = offers.map(p => p.id -> p).toMap 68 | val gones = (buys ++ sels).collect{case p @ (_: String, _: Offer) if !m.contains(p._1) => p._2}.toSeq 69 | prepareSend(gones.map(counter), As.Counter) 70 | 71 | resetAll() 72 | addSort(offers) 73 | 74 | val selGrows = grow(sortedBuys, sortedSels, Side.sell) 75 | val buyGrows = grow(sortedBuys, sortedSels, Side.buy) 76 | prepareSend(selGrows ++ buyGrows, As.Seed) 77 | trim() 78 | } 79 | 80 | case GotTickerPriceWs(price, requestId) => 81 | queue1(req.unsubsTicker(bot.pair)) 82 | seedFromStartPrice(price) 83 | 84 | case GotOfferWs(offer, requestId) => 85 | 86 | offer.status match { 87 | 88 | case Status.filled => 89 | removeSort(offer) 90 | prepareSend(counter(offer), As.Counter) 91 | prepareSend(grow(offer), As.Seed) 92 | 93 | case Status.partiallyFilled => addSort(offer) 94 | 95 | case Status.active => 96 | addSort(offer) 97 | trim() 98 | 99 | case Status.cancelled => 100 | removeSort(offer) 101 | if (isStartingPrice) { 102 | unlockInitSeed() 103 | } else { 104 | prepareSend(grow(offer), As.Seed) 105 | } 106 | 107 | case _ => info(s"OrderWsActor#GotOfferWs unhandled status ${offer.status} ${offer.id}") 108 | 109 | } 110 | 111 | case RemoveOfferWs(isRetry, orderId, side, requestId) => 112 | if (isRetry){ 113 | val retry = (buyTrans ++ selTrans)(orderId) 114 | val pending = pendings(requestId) 115 | prepareSend(retry, pending.as) 116 | } 117 | removeSort(side, orderId) 118 | removePending(requestId) 119 | 120 | case RemovePendingWs(requestId) => removePending(requestId) 121 | 122 | case RetryPendingWs(id) => queue1(pendings(id)) 123 | 124 | } 125 | 126 | def unlockInitSeed(): Unit = { 127 | if (buys.isEmpty && sels.isEmpty) { 128 | isStartingPrice = false 129 | bot.seed match { 130 | case a if a.equals(StartMethods.lastTicker.toString) => queue1(req.subsTicker(bot.pair)) 131 | 132 | case a if a.equals(StartMethods.cont.toString) => 133 | 134 | case a if a.equals(StartMethods.lastOwn.toString) => // unsupported 135 | 136 | case _ => 137 | val customStartPrice = Some(BigDecimal(bot.seed)) 138 | seedFromStartPrice(customStartPrice) 139 | } 140 | } 141 | } 142 | 143 | def grow(offer: Offer): Seq[Offer] = grow(sortedBuys, sortedSels, offer.side) 144 | 145 | def removePending(requestId: String): Unit = pendings -= requestId 146 | 147 | def seedFromStartPrice(price: Option[BigDecimal]): Unit = price match { 148 | case Some(p) => 149 | info(s"Got initial price ${bot.exchange} ${bot.pair} : $price, starting operation") 150 | val seed = initialSeed(sortedBuys, sortedSels, p) 151 | prepareSend(seed, As.Seed) 152 | case _ => error(s"Orderbook#GotStartPrice : Starting price for ${bot.exchange} / ${bot.pair} not found. Try different startPrice in bot") 153 | } 154 | 155 | def trim(): Unit = { 156 | val dupes = AbsOrder.getDuplicates(sortedBuys) ++ AbsOrder.getDuplicates(sortedSels) 157 | val trims = if (bot.isStrictLevels) trim(sortedBuys, sortedSels, Side.buy) ++ trim(sortedBuys, sortedSels, Side.sell) else Seq.empty[Offer] 158 | val cancels = AbsOrder.margeTrimAndDupes(trims, dupes) 159 | cancelOrders(cancels._1, As.Trim) 160 | cancelOrders(cancels._2, As.KillDupes) 161 | } 162 | 163 | def cancelOrders(o: Seq[Offer], as: As): Unit = { 164 | val cancels = o.map(p => req.cancelOrder(p.id, as)) 165 | queue(cancels) 166 | } 167 | 168 | def prepareSend(o: Offer, as: As): Unit = prepareSend(Seq(o), as) 169 | 170 | def prepareSend(o: Seq[Offer], as: As): Unit = { 171 | val withId = o.map(withOrderId) 172 | addSort(withId) 173 | 174 | val newOrders = withId.map(p => req.newOrder(p, as)) 175 | queue(newOrders) 176 | } 177 | 178 | def queue1(sendWs: SendWs): Unit = queue(Seq(sendWs)) 179 | 180 | 181 | def queue(sendWs: Seq[SendWs]): Unit = { 182 | pendings ++= sendWs.map(p => p.requestId -> p) 183 | op foreach (_ ! QueueWs(sendWs)) 184 | } 185 | 186 | def resetAll(): Unit = { 187 | buys.clear() 188 | sels.clear() 189 | sortedBuys = scala.collection.immutable.Seq.empty[Offer] 190 | sortedSels = scala.collection.immutable.Seq.empty[Offer] 191 | } 192 | 193 | def withOrderId(offer: Offer):Offer = offer.copy(id = wsEx.orderId(offer)) 194 | 195 | def withOrderId(offers: Seq[Offer]): Seq[Offer] = offers.map(withOrderId) 196 | 197 | def addSort(offer: Offer): Unit = addSort(Seq(offer)) 198 | 199 | def addSort(offers: Seq[Offer]): Unit = { 200 | val (b, s) = offers.partition(_.side == Side.buy) 201 | buys ++= b.map(p => p.id -> p) 202 | sels ++= s.map(p => p.id -> p) 203 | sort(Side.buy) 204 | sort(Side.sell) 205 | } 206 | 207 | def removeSort(offer: Offer): Unit = removeSort(offer.side, offer.id) 208 | 209 | def removeSort(side: Side, id: String): Unit = { 210 | remove(side, id) 211 | sort(side) 212 | } 213 | 214 | def remove(side: Side, clientOrderId: String): Unit = { 215 | var l = side match { 216 | case Side.buy => buys 217 | case Side.sell => sels 218 | case _ => TrieMap.empty[String, Offer] 219 | } 220 | l -= clientOrderId 221 | } 222 | 223 | def sort(side: Side): Unit = { 224 | side match { 225 | case Side.buy => sortedBuys = Offer.sortBuys(buys.values.toSeq) 226 | case _ => sortedSels = Offer.sortSels(sels.values.toSeq) 227 | } 228 | } 229 | 230 | } 231 | -------------------------------------------------------------------------------- /src/main/scala/me/mbcu/integrated/mmm/actors/OrderGIActor.scala: -------------------------------------------------------------------------------- 1 | package me.mbcu.integrated.mmm.actors 2 | 3 | import akka.actor.{ActorRef, Cancellable} 4 | import akka.dispatch.ExecutionContexts.global 5 | import me.mbcu.integrated.mmm.actors.OrderGIActor.{CheckSafeForGI, SafeForGI} 6 | import me.mbcu.integrated.mmm.ops.Definitions.Settings 7 | import me.mbcu.integrated.mmm.ops.common.AbsOrder.{CheckSafeForSeed, QueueRequest, SafeForSeed} 8 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor.As.As 9 | import me.mbcu.integrated.mmm.ops.common.AbsRestActor._ 10 | import me.mbcu.integrated.mmm.ops.common.Side.Side 11 | import me.mbcu.integrated.mmm.ops.common._ 12 | import me.mbcu.scala.MyLogging 13 | 14 | import scala.collection.concurrent.TrieMap 15 | import scala.concurrent.ExecutionContextExecutor 16 | import scala.concurrent.duration._ 17 | import scala.language.postfixOps 18 | 19 | object OrderGIActor { 20 | 21 | case class CheckSafeForGI(ref: ActorRef, bot: Bot) 22 | 23 | case class SafeForGI(yes: Boolean) 24 | 25 | } 26 | 27 | class OrderGIActor(bot: Bot, exchange: AbsExchange) extends AbsOrder(bot) with MyLogging{ 28 | private implicit val ec: ExecutionContextExecutor = global 29 | var sels: TrieMap[String, Offer] = TrieMap.empty[String, Offer] 30 | var buys: TrieMap[String, Offer] = TrieMap.empty[String, Offer] 31 | var sortedSels: scala.collection.immutable.Seq[Offer] = scala.collection.immutable.Seq.empty[Offer] 32 | var sortedBuys: scala.collection.immutable.Seq[Offer] = scala.collection.immutable.Seq.empty[Offer] 33 | var maintainCancellable: Option[Cancellable] = None 34 | var logCancellable: Option[Cancellable] = None 35 | var seedCancellable: Option[Cancellable] = None 36 | private var op: Option[ActorRef] = None 37 | private implicit val book: ActorRef = self 38 | private implicit val imBot: Bot = bot 39 | 40 | override def receive: Receive = { 41 | 42 | case "start" => 43 | op = Some(sender()) 44 | logCancellable = Some(context.system.scheduler.schedule(15 second, Settings.intervalLogSeconds seconds, self, message="log")) 45 | queue1(GetActiveOrders(-1, Seq.empty[Offer], 1, As.Init)) 46 | 47 | case "log" => info(Offer.dump(bot, sortedBuys, sortedSels)) 48 | 49 | case GotActiveOrders(offers, currentPage, nextPage, arriveMs, send) => 50 | if (nextPage) { 51 | queue1(GetActiveOrders(-1, offers, currentPage + 1, As.Init)) 52 | } else { 53 | offers.foreach(add) 54 | sortBoth() 55 | self ! "keep or clear orderbook" 56 | } 57 | 58 | case "keep or clear orderbook" => 59 | bot.seed match { 60 | case a if a.equalsIgnoreCase(StartMethods.lastOwn.toString) | a.equalsIgnoreCase(StartMethods.lastTicker.toString) => 61 | if (sels.isEmpty && buys.isEmpty) self ! "init price" else cancelOrders((buys ++ sels).toSeq.map(_._2), As.ClearOpenOrders) 62 | case _ => 63 | self ! "init price" 64 | info(s"Book ${bot.exchange} ${bot.pair} : ${bot.seed} ") 65 | } 66 | 67 | case "init price" => 68 | bot.seed match { 69 | case s if s contains "last" => s match { 70 | case m if m.equalsIgnoreCase(StartMethods.lastOwn.toString) => queue1(GetOwnPastTrades(As.Init)) 71 | case m if m.equalsIgnoreCase(StartMethods.lastTicker.toString) => queue1(GetTickerStartPrice(As.Init)) 72 | } 73 | case s if s contains "cont" => gotStartPrice(None) 74 | case _ => gotStartPrice(Some(BigDecimal(bot.seed))) 75 | } 76 | 77 | case "maintain" => op.foreach(_ ! CheckSafeForGI(self, bot)) 78 | 79 | case SafeForGI(yes) => 80 | if (!yes) { 81 | scheduleMaintain(1) 82 | } else { 83 | queue((sortedBuys ++ sortedSels).map(_.id).map(GetOrderInfo(_, As.RoutineCheck))) 84 | } 85 | 86 | case "reseed" => op.foreach(_ ! CheckSafeForSeed(self, bot)) 87 | 88 | case SafeForSeed(yes) => 89 | if (yes) { 90 | val growth = grow(sortedBuys, sortedSels, Side.buy) ++ grow(sortedBuys, sortedSels, Side.sell) 91 | sendOrders(growth, As.Seed) 92 | } 93 | 94 | case GotOrderCancelled(as, arriveMs, send) => 95 | remove(send.offer.side, send.offer.id) 96 | sort(send.offer.side) 97 | as match { 98 | case As.ClearOpenOrders => if (buys.isEmpty && sels.isEmpty) self ! "init price" 99 | case _ => 100 | } 101 | 102 | case GotStartPrice(price, arriveMs, send) => gotStartPrice(price) 103 | 104 | case GotProvisionalOffer(newId, provisionalTs, noIdoffer) => 105 | val offer = noIdoffer.copy(id = newId, createdAt = provisionalTs) // provisionalTs(e.g server time) is needed to correctly remove duplicates 106 | addSort(offer) 107 | queue1(GetOrderInfo(offer.id , As.RoutineCheck)) 108 | 109 | case GotOrderInfo(offer, arriveMs, send) => 110 | scheduleMaintain(1) 111 | offer.status match { 112 | 113 | case Status.active => 114 | updateOffer(offer) 115 | val dupes = AbsOrder.getDuplicates(sortedBuys) ++ AbsOrder.getDuplicates(sortedSels) 116 | val trims = if (bot.isStrictLevels) trim(sortedBuys, sortedSels, Side.buy) ++ trim(sortedBuys, sortedSels, Side.sell) else Seq.empty[Offer] 117 | val cancels = AbsOrder.margeTrimAndDupes(trims, dupes) 118 | cancelOrders(cancels._1, As.Trim) 119 | cancelOrders(cancels._2, As.KillDupes) 120 | 121 | case Status.filled => 122 | get(offer.id, offer.side).foreach(cache => { //cache contains original order info 123 | val ctr = counter(cache) 124 | sendOrders(Seq(ctr), as = As.Counter) 125 | }) 126 | removeSort(offer) 127 | 128 | case Status.partiallyFilled => updateOffer(offer) 129 | 130 | case Status.cancelled => removeSort(offer) 131 | 132 | case _ => info(s"OrderbookActor_${bot.pair}#GotOrderInfo : unrecognized offer status") 133 | 134 | } 135 | } 136 | 137 | def gotStartPrice(price: Option[BigDecimal]) : Unit = { 138 | scheduleMaintain(Settings.getActiveSeconds) 139 | scheduleSeed(Settings.intervalSeedSeconds) 140 | price match { 141 | case Some(p) => 142 | info(s"Got initial price ${bot.exchange} ${bot.pair} : $price, starting operation") 143 | val seed = initialSeed(sortedBuys, sortedSels, p) 144 | sendOrders(seed, As.Seed) 145 | case _ => error(s"Orderbook#GotStartPrice : Starting price for ${bot.exchange} / ${bot.pair} not found. Try different startPrice in bot") 146 | } 147 | } 148 | 149 | def queue(reqs: Seq[SendRest]): Unit = op foreach(_ ! QueueRequest(reqs)) 150 | 151 | def queue1(req: SendRest): Unit = queue(Seq(req)) 152 | 153 | def sendOrders(offers: Seq[Offer], as: As): Unit = queue(offers.map(NewOrder(_, as))) 154 | 155 | def cancelOrders(offers: Seq[Offer], as: As): Unit = queue(offers.map(CancelOrder(_, as))) 156 | 157 | def reseed(side: Side): Seq[Offer] = side match { 158 | case Side.buy => grow(sortedBuys, sortedSels, Side.buy) 159 | case _ => grow(sortedBuys, sortedSels, Side.sell) 160 | } 161 | 162 | def scheduleSeed(s: Int): Unit = { 163 | seedCancellable.foreach(_.cancel) 164 | seedCancellable = Some(context.system.scheduler.schedule(initialDelay = 5 second, s second, self, "reseed")) 165 | } 166 | 167 | def scheduleMaintain(s: Int): Unit = { 168 | maintainCancellable.foreach(_.cancel) 169 | maintainCancellable = Some(context.system.scheduler.scheduleOnce(s second, self, "maintain")) 170 | } 171 | 172 | def addSort(offer: Offer):Unit = { 173 | add(offer) 174 | sort(offer.side) 175 | } 176 | 177 | def sort(side: Side): Unit = { 178 | side match { 179 | case Side.buy => sortedBuys = Offer.sortBuys(buys.values.toSeq) 180 | case _ => sortedSels = Offer.sortSels(sels.values.toSeq) 181 | } 182 | } 183 | 184 | def removeSort(offer: Offer): Unit = { 185 | remove(offer.side, offer.id) 186 | sort(offer.side) 187 | } 188 | 189 | def remove(side: Side, clientOrderId: String): Unit = { 190 | var l = side match { 191 | case Side.buy => buys 192 | case Side.sell => sels 193 | case _ => TrieMap.empty[String, Offer] 194 | } 195 | l -= clientOrderId 196 | } 197 | 198 | def sortBoth(): Unit = { 199 | sort(Side.buy) 200 | sort(Side.sell) 201 | } 202 | 203 | def add(offer: Offer): Unit = { 204 | var l = offer.side match { 205 | case Side.buy => buys 206 | case Side.sell => sels 207 | case _ => TrieMap.empty[String, Offer] 208 | } 209 | l += (offer.id -> offer) 210 | } 211 | 212 | def updateOffer(in: Offer): Unit = get(in.id, in.side) foreach(c => addSort(c.copy(updatedAt = in.updatedAt, cumQuantity = in.cumQuantity))) 213 | 214 | def get(id: String, side: Side): Option[Offer] = side match { 215 | case Side.buy => Some(buys(id)) 216 | case Side.sell => Some(sels(id)) 217 | case _ => None 218 | } 219 | 220 | } 221 | --------------------------------------------------------------------------------