From 1c59c70fb66e026b4c6ac71af287ececd5a81797 Mon Sep 17 00:00:00 2001 From: Olivier Milla Date: Tue, 23 Jan 2024 21:38:08 +0100 Subject: [PATCH] First Public Version --- LocalPreferences.toml | 8 ++ Project.toml | 23 +++- README.md | 6 + ext/RocketExt.jl | 9 ++ src/Constants.jl | 29 +++++ src/Data.jl | 231 +++++++++++++++++++++++++++++++++++++ src/DydxV3.jl | 23 +++- src/Private.jl | 136 ++++++++++++++++++++++ src/Private/Api.jl | 83 +++++++++++++ src/Private/Wrappers.jl | 42 +++++++ src/Public.jl | 37 ++++++ src/Public/Wrappers.jl | 12 ++ src/Samplers.jl | 40 +++++++ src/UsesPreferences.jl | 15 +++ src/WebSockets.jl | 138 ++++++++++++++++++++++ src/WebSockets/Wrappers.jl | 103 +++++++++++++++++ test/Private/test_api.jl | 4 + test/runtests.jl | 10 +- test/test_private.jl | 42 +++++++ test/test_public.jl | 5 + test/test_samplers.jl | 9 ++ test/test_websockets.jl | 46 ++++++++ 22 files changed, 1040 insertions(+), 11 deletions(-) create mode 100644 LocalPreferences.toml create mode 100644 ext/RocketExt.jl create mode 100644 src/Constants.jl create mode 100644 src/Data.jl create mode 100644 src/Private.jl create mode 100644 src/Private/Api.jl create mode 100644 src/Private/Wrappers.jl create mode 100644 src/Public.jl create mode 100644 src/Public/Wrappers.jl create mode 100644 src/Samplers.jl create mode 100644 src/UsesPreferences.jl create mode 100644 src/WebSockets.jl create mode 100644 src/WebSockets/Wrappers.jl create mode 100644 test/Private/test_api.jl create mode 100644 test/test_private.jl create mode 100644 test/test_public.jl create mode 100644 test/test_samplers.jl create mode 100644 test/test_websockets.jl diff --git a/LocalPreferences.toml b/LocalPreferences.toml new file mode 100644 index 0000000..7dbd5b8 --- /dev/null +++ b/LocalPreferences.toml @@ -0,0 +1,8 @@ +[DydxV3] +apiKey = "YourKey" +apiPassPhrase = "Your Passphrase" +apiSecret = "Your Secret" +starkPrivateKey = "Your PrivateKey" +starkPublicKey = "Your PublicKey" +starkPublicKeyYCoordinate = "Your Public Key Coordinate" +walletAddress = "Your 0xAddress" diff --git a/Project.toml b/Project.toml index 1a8c8d3..86ce11c 100644 --- a/Project.toml +++ b/Project.toml @@ -1,10 +1,23 @@ name = "DydxV3" -uuid = "aeb184e1-2eba-4dfa-950e-27d85795a490" -authors = ["Olivier Milla and contributors"] -version = "1.0.0-DEV" +uuid = "81fc79e4-7aed-40c1-9951-444e465e3245" +authors = ["Olivier Milla "] +version = "0.7.1" -[compat] -julia = "1.9" +[deps] +Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" +Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" +HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" +JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" +Preferences = "21216c6a-2e73-6563-6e65-726566657250" +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce" +URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" + +[weakdeps] +Rocket = "df971d30-c9d6-4b37-b8ff-e965b2cb3a40" + +[extensions] +RocketExt = "Rocket" [extras] Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" diff --git a/README.md b/README.md index 283bda2..49eb1ee 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,9 @@ # DydxV3 [![Build Status](https://github.com/oliviermilla/DydxV3.jl/actions/workflows/CI.yml/badge.svg?branch=main)](https://github.com/oliviermilla/DydxV3.jl/actions/workflows/CI.yml?query=branch%3Amain) + +API integration of [dydxV3 exchange](https://dydx.exchange/) in Julia. + +See the [provided](https://github.com/oliviermilla/DydxV3/blob/main/LocalPreferences.toml) [Preferences.jl](https://github.com/JuliaPackaging/Preferences.jl) file for the expected API keys. + +Enjoy! \ No newline at end of file diff --git a/ext/RocketExt.jl b/ext/RocketExt.jl new file mode 100644 index 0000000..ebc4cf9 --- /dev/null +++ b/ext/RocketExt.jl @@ -0,0 +1,9 @@ +module RocketExt + +import DydxV3 + +using Rocket + +Rocket.scalarness(::Type{DydxV3.Trade}) = Rocket.Scalar() + +end \ No newline at end of file diff --git a/src/Constants.jl b/src/Constants.jl new file mode 100644 index 0000000..0cbc27e --- /dev/null +++ b/src/Constants.jl @@ -0,0 +1,29 @@ +module Constants + +export + DATE_FORMAT, + HTTP_PRODUCTION_URL, + HTTP_STAGING_URL, + TRADE_SIDES, + WEBSOCKET_PRODUCTION_URL, + WEBSOCKET_STAGING_URL, + WEBSOCKET_CHANNELS, ACCOUNT, ORDERBOOK, TRADES, MARKETS, + ENVIRONNMENT_TYPE, PRODUCTION, STAGING + +using Dates + +const DATE_FORMAT = dateformat"YYYY-mm-ddTHH:MM:SS.sZ" + +@enum ENVIRONNMENT_TYPE PRODUCTION STAGING + +const HTTP_PRODUCTION_URL = "https://api.dydx.exchange/v3/" +const HTTP_STAGING_URL = "https://api.stage.dydx.exchange" + +const WEBSOCKET_PRODUCTION_URL = "wss://api.dydx.exchange/v3/ws" +const WEBSOCKET_STAGING_URL = "wss://api.stage.dydx.exchange/v3/ws" + +@enum WEBSOCKET_CHANNELS ACCOUNT ORDERBOOK TRADES MARKETS + +const TRADE_SIDES = ["BUY", "SELL"] + +end \ No newline at end of file diff --git a/src/Data.jl b/src/Data.jl new file mode 100644 index 0000000..68e03dc --- /dev/null +++ b/src/Data.jl @@ -0,0 +1,231 @@ +module Data + +export + Account, + Fill, + FundingPayment, + HistoricalPnl, + Market, + Order, + OrderBook, + OrderBookLevel, + Position, + Trade, + Transfer, + User, + UserData, + UserPreferences, + UserTradeOption, + UserWarnings + +using Dates +using JSON3 + +struct Position + market::String + status::String + side::String + size::Float64 + maxSize::Float64 + entryPrice::Float64 + exitPrice::Float64 + unrealizedPnl::Float64 + realizedPnl::Float64 + createdAt::DateTime + closedAt::Union{Nothing,DateTime} + sumOpen::Float64 + sumClose::Float64 + netFunding::Float64 +end + +struct Account + starkKey::String + positionId::UInt32 + equity::Float64 + freeCollateral::Float64 + pendingDeposits::Float64 + pendingWithdrawals::Float64 + openPositions::Dict{String,Position} + accountNumber::UInt16 + id::String + quoteBalance::Float64 + createdAt::DateTime +end + +struct Fill + id::String + side::String + liquidity::String + type::String + market::String + orderId::String + price::Float64 + size::Float64 + fee::Float32 + createdAt::DateTime +end + +struct FundingPayment + market::String + payment::Float64 + rate::Float64 + positionSize::Float64 + price::Float64 + effectiveAt::DateTime +end + +struct HistoricalPnl + equity::Float64 + totalPnl::Float64 + createdAt::DateTime + netTransfers::Float64 + accountId::String +end + +struct Market + market::Union{Nothing, String} + status::Union{Nothing, String} + baseAsset::Union{Nothing, String} + quoteAsset::Union{Nothing, String} + stepSize::Union{Nothing, Float16} + tickSize::Union{Nothing, Float16} + indexPrice::Union{Nothing, Float32} + oraclePrice::Union{Nothing, Float32} + priceChange24H::Union{Nothing, Float32} + nextFundingRate::Union{Nothing, Float32} + nextFundingAt::Union{Nothing, DateTime} + minOrderSize::Union{Nothing, Float16} + type::Union{Nothing, String} + initialMarginFraction::Union{Nothing, Float16} + maintenanceMarginFraction::Union{Nothing, Float16} + baselinePositionSize::Union{Nothing, UInt32} + incrementalPositionSize::Union{Nothing, UInt32} + incrementalInitialMarginFraction::Union{Nothing, Float16} + transferMarginFraction::Union{Nothing, Float32} + maxPositionSize::Union{Nothing, UInt64} + volume24H::Union{Nothing, Float32} + trades24H::Union{Nothing, UInt32} + openInterest::Union{Nothing, Float32} + assetResolution::Union{Nothing, UInt64} + syntheticAssetId::Union{Nothing, String} +end + +struct Order + accountId::String + market::String + side::String + id::String + remainingSize::Float64 + price::Float64 +end + +struct OrderBookLevel + size::Float32 + price::Float32 +end + +struct OrderBook + bids::Vector{OrderBookLevel} + asks::Vector{OrderBookLevel} +end + +struct Trade + side::String + size::Float64 + price::Float64 + createdAt::DateTime + liquidation::Bool +end + +struct Transfer + # id::String + # type::String + # debitAsset::String + # creditAsset::String + # debitAmount::Float64 + # creditAmount::Float64 + # transactionHash::String + # status::String + # createdAt::DateTime + # confirmedAt::DateTime + # clientId::Union{String,Nothing} + # fromAddress::Union{String,Nothing} + # toAddress::Union{String,Nothing} + # accountId::String + # transferAccountId::Union{String,Nothing} +end + +struct UserTradeOption + postOnlyChecked::Bool + goodTilTimeInput::UInt8 + reduceOnlyChecked::Bool + goodTilTimeTimescale::String + selectedTimeInForceOption::String +end + +struct UserWarnings + enableWarning::Bool + disableWarning::Bool +end + +struct UserPreferences + userTradeOptions::Dict{String,Union{UserTradeOption,String}} + popUpNotifications::Bool + orderbookAnimations::Bool + latestConcludedEpoch::UInt16 + oneTimeNotifications::Vector{String} + leaguesCurrentStartDate::String + hasSeenReduceOnlyWarning::UserWarnings +end + +struct UserData + walletType::String + preferences::UserPreferences + starredMarkets::Vector{String} +end + +struct User + publicId::String + ethereumAddress::String + isRegistered::Bool + email::Union{String,Nothing} + username::Union{String,Nothing} + userData::UserData + makerFeeRate::Float64 + takerFeeRate::Float64 + referralDiscountRate::Union{String,Nothing} + makerVolume30D::Float64 + takerVolume30D::Float64 + fees30D::Float32 + referredByAffiliateLink::Union{String,Nothing} + isSharingUsername::Union{Nothing,Bool} + isSharingAddress::Union{Nothing,Bool} + dydxTokenBalance::Float32 + stakedDydxTokenBalance::Float32 + activeStakedDydxTokenBalance::Float32 + isEmailVerified::Bool + country::Union{String,Nothing} + languageCode::String + hedgiesHeld::Vector{String} + livenessVerified::Union{Nothing,Bool} + livenessVerifiedAt::Union{Nothing,String} + syntheticId::String +end + +JSON3.StructTypes.StructType(::Type{Account}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{Fill}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{FundingPayment}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{HistoricalPnl}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{Market}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{Order}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{OrderBook}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{OrderBookLevel}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{Position}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{Trade}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{Transfer}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{User}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{UserData}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{UserPreferences}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{UserTradeOption}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{UserWarnings}) = JSON3.StructTypes.Struct() +end \ No newline at end of file diff --git a/src/DydxV3.jl b/src/DydxV3.jl index c032ac5..4a411c9 100644 --- a/src/DydxV3.jl +++ b/src/DydxV3.jl @@ -1,5 +1,24 @@ module DydxV3 -# Write your package code here. +include("Constants.jl") +include("UsesPreferences.jl") +import .UsesPreferences as Preferences -end +include("Data.jl") + +include("Public.jl") +include("Private.jl") + +include("WebSockets.jl") + +import .Constants +using .Data + +using .Public +using .Private + +import .WebSockets + +include("Samplers.jl") + +end # module DydxV3 diff --git a/src/Private.jl b/src/Private.jl new file mode 100644 index 0000000..90071ad --- /dev/null +++ b/src/Private.jl @@ -0,0 +1,136 @@ +module Private + +export getaccounts +export getactiveorders +export getfills, getfillsfororder +export getpositions +export gettransfers +export getuser +export gethistoricalpnl +export getorders, cancelorders, putorder + +using Dates +using JSON3 + +using ..Constants +using ..Data + +include(joinpath("Private", "Api.jl")) +include(joinpath("Private", "Wrappers.jl")) +using .Api +using .Wrappers + +function getaccount(ethWalletAddress::String) + # TODO + # r =Api.get("accounts", "ethereumAddress" => ethWalletAddress) + # println(String(r.body)) + # accounts = JSON3.read(r.body, Accounts, parsequoted=true) + # return accounts.account +end + +function getaccounts() + r = Api.get("accounts") + #println(String(r.body)) + accounts = JSON3.read(r.body, Wrappers.Accounts, parsequoted=true, dateformat=DATE_FORMAT) + return accounts.accounts +end + +function getactiveorders(market::String; side::Union{String,Nothing}=nothing, id::Union{String,Nothing}=nothing) + if (!isnothing(id) && isnothing(side)) + throw(ArgumentError("side is required when the order id is specified.")) + end + r = Api.get("active-orders", "market" => market, "side" => side, "id" => id) + #println(String(r.body)) + orders = JSON3.read(r.body, Wrappers.Orders, parsequoted=true) + return orders.orders +end + +function getfills(market::Union{String,Nothing}=nothing; limit::Integer=UInt8(100), createdBeforeOrAt::Union{DateTime,Nothing}=nothing) + if (limit > 100) + throw(ArgumentError("limit cannot be greater than 100.")) + end + r = Api.get("fills", "market" => market, "limit" => limit, "createdBeforeOrAt" => createdBeforeOrAt) + #println(String(r.body)) + fills = JSON3.read(r.body, Wrappers.Fills, parsequoted=true, dateformat=DATE_FORMAT) + return fills.fills +end + +function getfillsfororder(orderId::String; limit::Integer=UInt8(100), createdBeforeOrAt::Union{DateTime,Nothing}=nothing) + # TODO +end + +function getpositions(market::Union{String,Nothing}=nothing; status::Union{String,Nothing}=nothing, limit::Integer=UInt8(100), createdBeforeOrAt::Union{DateTime,Nothing}=nothing) + if (limit > 100) + throw(ArgumentError("limit cannot be greater than 100.")) + end + r = Api.get("positions", "market" => market, "status" => status, "limit" => limit, "createdBeforeOrAt" => createdBeforeOrAt) + #println(String(r.body)) + positions = JSON3.read(r.body, Wrappers.Positions, parsequoted=true, dateformat=DATE_FORMAT) + return positions.positions +end + +function gettransfers(; transferType::Union{Nothing, String}=nothing, limit::Integer=UInt8(100), createdBeforeOrAt::Union{DateTime,Nothing} = nothing) + if (limit > 100) + throw(ArgumentError("limit cannot be greater than 100.")) + end + r = Api.get("transfers", "transferType"=> transferType, "limit" => limit, "createdBeforeOrAt" => createdBeforeOrAt) + #println(String(r.body)) + transfers = JSON3.read(r.body, Wrappers.Transfers, parsequoted=true, dateformat=DATE_FORMAT) + return transfers.transfers +end + +function getuser() + r = Api.get("users") + #println(String(r.body)) + userWrapper = JSON3.read(r.body, Wrappers.UserWrapper, parsequoted=true, dateformat=DATE_FORMAT) + return userWrapper.user +end + +function gethistoricalpnl(; createdBeforeOrAt::Union{DateTime,Nothing}=nothing, createdOnOrAfter::Union{DateTime,Nothing}=nothing) + if (!isnothing(createdBeforeOrAt) && !isnothing(createdOnOrAfter)) + # duration = abs(createdBeforeOrAt - createdOnOrAfter) + # one_month = + # TODO raise if duration is greater than a month + end + r = Api.get("historical-pnl", "createdBeforeOrAt" => createdBeforeOrAt, "createdOnOrAfter" => createdOnOrAfter) + #println(String(r.body)) + pnl = JSON3.read(r.body, Wrappers.HistoricalPnls, parsequoted=true, dateformat=DATE_FORMAT) + return pnl.historicalPnl +end + +function getorders(market::Union{Nothing,AbstractString}=nothing; status::Union{Nothing,AbstractString}=nothing, + side::Union{Nothing,AbstractString}=nothing, type::Union{Nothing,AbstractString}=nothing, limit::Integer=UInt8(100), + createdBeforeOrAt::Union{Nothing,DateTime}=nothing, returnLatestOrders::Bool=false) + if (limit > 100) + throw(ArgumentError("limit cannot be greater than 100.")) + end + r = Api.get("orders", "market" => market, "status" => status, + "side" => side, "type" => type, "limit" => limit, + "createdBeforeOrAt" => createdBeforeOrAt, "returnLatestOrders" => returnLatestOrders) + #println(String(r.body)) + orders = JSON3.read(r.body, Wrappers.Orders, parsequoted=true, dateformat=DATE_FORMAT) + return orders.orders +end + +function cancelorders(market::String; side::Union{String,Nothing}=nothing, id::Union{String,Nothing}=nothing) + if(!isnothing(id) && isnothing(side)) + throw(ArgumentError("The order side is required when order id is provided.")) + end + r = Api.delete("active-osers", "side" => side, "id" => id) + orders = JSON3.read(r.body, Wrappers.Orders, parsequoted=true, dateformat=DATE_FORMAT) + return orders.orders +end + +function putorder(market::String; side::String, type::String, postOnly::Bool, + size::Float64, price::Float64, limitFee::Float16, expiration::DateTime,timeInForce::String="GTT", + cancelId::String,triggerPrice::Float64,trailingPercent::Float64,reduceOnly::Union{Nothing,Bool}=nothing, + clientId::String, signature::String) + if(!(timeInForce in ["GTT, FOK, IOC"])) + throw(ArgumentError("Unsupported timeInForce $(timeInForce).")) + end + if(reduceOnly && !(timeInForce in ["FOK", "IOC"])) + throw(ArgumentError("ReduceOnly is only supported for FOK and IOC orders.")) + end + # TODO logic for clientId && signature generation if(isnothing(clientId)) +end +end diff --git a/src/Private/Api.jl b/src/Private/Api.jl new file mode 100644 index 0000000..190a003 --- /dev/null +++ b/src/Private/Api.jl @@ -0,0 +1,83 @@ +module Api + +export timestamp, signature +export get, put, delete, post + +using DydxV3.Constants +import DydxV3.Preferences + +using Base64 +using Dates +using HTTP +using SHA +using URIs + +function generatequerypath(requestPath::String, data::Vararg{Pair}) + query = Dict{String, String}() + for(k, v) in data + isnothing(v) && continue + query[string(k)] = string(v) + end + return string(URI(URI(requestPath), query=query)) +end + +function get(requestPath::String, data::Vararg{Pair}) + return request("GET", generatequerypath(requestPath, data...)) +end + +function put(requestPath::String, data::Vararg{Pair}) + return request("PUT",requestPath, data...) +end + +function post(requestPath::String, data::Vararg{Pair}) + return request("POST",requestPath, data...) +end + +function delete(requestPath::String, data::Vararg{Pair}) + return request("DELETE", generatequerypath(requestPath, data...)) +end + +@inline timestamp(utc::DateTime=now(Dates.UTC)) = Dates.format(utc, DATE_FORMAT) # Python: datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.%f',)[:-3] + 'Z' OK + +function signature(timestamp::String, method::String, requestPath::String, data::Vararg{Pair}) + signatureKey = Vector{UInt8}(Preferences.apiSecret) + decodedSignatureKey = base64UrlsafeDecode(String(signatureKey)) + + signatureMsg = "$(timestamp)$(method)$(requestPath)" + if (length(data) > 0) + signatureMsg = "$(signatureMsg)$(JSON3.write(data))" + end + + # Python: base64.urlsafe_b64encode(hashed.digest()).decode() + dydxSignature = hmac_sha256(decodedSignatureKey, signatureMsg) + return base64UrlSafeEncode(String(dydxSignature)) +end + +# https://github.com/dydxprotocol/dydx-v3-python/blob/master/dydx3/modules/private.py +function request(method::String, requestPath::String, data::Vararg{Pair}) + stamp = timestamp() + sig = signature(stamp, method, "/v3/$(requestPath)", data...) + + return HTTP.request(method, + URIs.resolvereference(HTTP_PRODUCTION_URL, requestPath), + [ + "DYDX-SIGNATURE" => sig, + "DYDX-API-KEY" => Preferences.apiKey, + "DYDX-TIMESTAMP" => stamp, + "DYDX-PASSPHRASE" => Preferences.apiPassPhrase + ]) +end + +# https://discourse.julialang.org/t/support-for-urlsafe-alphabets-in-base64/4065 + +function base64UrlsafeDecode(string::String) + s = replace(string, "-" => "+", "_" => "/") + return base64decode(s) +end + +function base64UrlSafeEncode(string::String) + s = base64encode(string) + return replace(s, "+" => "-", "/" => "_") +end + +end \ No newline at end of file diff --git a/src/Private/Wrappers.jl b/src/Private/Wrappers.jl new file mode 100644 index 0000000..8736db3 --- /dev/null +++ b/src/Private/Wrappers.jl @@ -0,0 +1,42 @@ +module Wrappers + +using JSON3 + +using ...Data + +struct Accounts + accounts::Vector{Account} +end + +struct Fills + fills::Vector{Fill} +end + +struct HistoricalPnls + historicalPnl::Vector{HistoricalPnl} +end + +struct Orders + orders::Vector{Order} +end + +struct Positions + positions::Vector{Position} +end + +struct Transfers + transfers::Vector{Transfer} +end + +struct UserWrapper + user::User +end + +JSON3.StructTypes.StructType(::Type{Accounts}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{Fills}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{HistoricalPnls}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{Orders}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{Positions}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{Transfers}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{UserWrapper}) = JSON3.StructTypes.Struct() +end \ No newline at end of file diff --git a/src/Public.jl b/src/Public.jl new file mode 100644 index 0000000..7e07b3c --- /dev/null +++ b/src/Public.jl @@ -0,0 +1,37 @@ +module Public + +export + getmarket, + getmarkets, + getorderbook + +using HTTP, JSON3, URIs +using ..Constants +using ..Data + +include(joinpath("Public","Wrappers.jl")) +using .Wrappers + +function getmarkets() + r = get("markets") + markets = JSON3.read(r.body, Wrappers.Markets, parsequoted=true, dateformat=DATE_FORMAT) + return markets.markets +end + +function getmarket(market::String) + r = get(string(URI(URI("markets"); query=Dict("market" => market)))) + markets = JSON3.read(r.body, Wrappers.Markets, parsequoted=true, dateformat=DATE_FORMAT) + return markets.markets[market] +end + +function getorderbook(market::String) + r = get("orderbook/$(market)") + return JSON3.read(r.body, Wrappers.OrderBook, parsequoted=true, dateformat=DATE_FORMAT) +end + +function get(urlTail::String) + url = URIs.resolvereference(HTTP_PRODUCTION_URL, urlTail) + return HTTP.get(url) +end + +end \ No newline at end of file diff --git a/src/Public/Wrappers.jl b/src/Public/Wrappers.jl new file mode 100644 index 0000000..f224614 --- /dev/null +++ b/src/Public/Wrappers.jl @@ -0,0 +1,12 @@ +module Wrappers +using JSON3 + +using ...Data + +struct Markets + markets::Dict{String,Market} +end + +JSON3.StructTypes.StructType(::Type{Markets}) = JSON3.StructTypes.Struct() + +end \ No newline at end of file diff --git a/src/Samplers.jl b/src/Samplers.jl new file mode 100644 index 0000000..a54a211 --- /dev/null +++ b/src/Samplers.jl @@ -0,0 +1,40 @@ +module Samplers + +import DydxV3 + +using Dates +using Random + +function Random.rand(rng::AbstractRNG, ::Type{DydxV3.Trade}) + return DydxV3.Trade( + rand(rng, DydxV3.Constants.TRADE_SIDES), + rand(rng, 100.1:0.1:500.1), + rand(rng, 100.0:200.0), + rand(rng, DateTime(2000, 1, 1):Second(1):DateTime(2010, 12, 31)), + rand(rng, Bool) + ) +end + +function Random.rand(rng::AbstractRNG, previous::DydxV3.Trade, period::Dates.Period) + return DydxV3.Trade( + rand(rng, DydxV3.Constants.TRADE_SIDES), + previous.size * rand(rng, -0.99:0.01:5.00), + previous.price + rand(rng, -1.0:0.01:1.0), + previous.createdAt + period, + rand(rng, Bool) + ) +end + +function Random.rand(rng::AbstractRNG, ::Type{DydxV3.Trade}, period::Dates.Period, n::Int) + res = Vector{DydxV3.Trade}() + push!(res, rand(rng, DydxV3.Trade)) + n == 1 && return res + for i in 2:n + push!(res, rand(rng, res[end], period)) + end + return res +end + +Random.rand(d::Type{DydxV3.Trade}, period::Dates.Period, n::Int) = rand(Random.default_rng(), d, period, n) + +end \ No newline at end of file diff --git a/src/UsesPreferences.jl b/src/UsesPreferences.jl new file mode 100644 index 0000000..979c65e --- /dev/null +++ b/src/UsesPreferences.jl @@ -0,0 +1,15 @@ +module UsesPreferences + +using Preferences + +const walletAddress = @load_preference("walletAddress") + +const starkPrivateKey = @load_preference("starkPrivateKey") +const starkPublicKey = @load_preference("starkPublicKey") +const starkPublicKeyYCoordinate = @load_preference("starkPublicKeyYCoordinate") + +const apiKey = @load_preference("apiKey") +const apiPassPhrase = @load_preference("apiPassPhrase") +const apiSecret = @load_preference("apiSecret") + +end \ No newline at end of file diff --git a/src/WebSockets.jl b/src/WebSockets.jl new file mode 100644 index 0000000..c5bb6a4 --- /dev/null +++ b/src/WebSockets.jl @@ -0,0 +1,138 @@ +module WebSockets + +export Client +export open +export subscribeToAccount, subscribeToMarkets, subscribeToTrades + +using DydxV3 +using DydxV3.Data +using DydxV3.Constants +using DydxV3.Private.Api + +include(joinpath("WebSockets", "Wrappers.jl")) +using .Wrappers + +export AccountWrapper +export Connected + +import DydxV3.Preferences as Preferences +import HTTP +using JSON3 + +@inline string_to_json(string::AbstractString) = JSON3.write(JSON3.read(string)) + +#const PONG_MESSAGE = string_to_json("""{"type":"ping"}""") +function subscribeToAccountMessage(accountNumber::UInt16) + apiKey = Preferences.apiKey + stamp = timestamp() + sign = signature(stamp, "GET", "/ws/accounts") + passphrase = Preferences.passphrase + + return string_to_json("""{"type":"subscribe", "channel":"v3_accounts", "accountNumber":"$(accountNumber)", "apiKey":"$(apiKey)", "signature":"$(sign)", "timestamp":"$(stamp)", "passphrase":"$(passphrase)"}""") +end + +subscribeToTradesMessage(market::String) = string_to_json("""{"type":"subscribe", "channel":"v3_trades", "id":"$(market)"}""") +unsubscribeFromTradesMessage(market::String) = string_to_json("""{"type":"unsubscribe", "channel":"v3_trades", "id":"$(market)"}""") + +subscribeToMarketsMessage() = string_to_json("""{"type":"subscribe", "channel":"v3_markets"}""") +unsubscribeToMarketsMessage() = string_to_json("""{"type":"unsubscribe", "channel":"v3_markets"}""") + +function connectionUrl(type::ENVIRONNMENT_TYPE) + type == PRODUCTION && return WEBSOCKET_PRODUCTION_URL + type == STAGING && return WEBSOCKET_STAGING_URL + throw(DomainError(type)) +end + +mutable struct Client + send::Vector{String} + + onAccount::Dict{UInt16,Function} + + onMarkets::Union{Function,Nothing} + + onTrade::Dict{String,Function} + onOrderBook::Dict{String,Function} + + type::ENVIRONNMENT_TYPE + ws::Union{HTTP.WebSockets.WebSocket,Nothing} + + toClose::Bool + + Client(type::ENVIRONNMENT_TYPE) = new(Vector{String}(), Dict{UInt16,Function}(), nothing, Dict{String,Function}(), Dict{String,Function}(), type, nothing, false) +end + +function open(onConnect::Union{Nothing,Function}, client::Client) + HTTP.WebSockets.open(connectionUrl(client.type)) do ws + client.ws = ws + for msg in ws + client.toClose && break + resp = JSON3.read(msg, Wrappers.Response) + if resp.type == "channel_data" + if resp.channel == "v3_trades" + resp = JSON3.read(msg, Wrappers.TradeWrapper, parsequoted=true, dateformat=DATE_FORMAT) + callback = client.onTrade[resp.id] + isnothing(callback) || callback(client, resp) + elseif resp.channel == "v3_markets" + isnothing(client.onMarkets) || client.onMarkets(client, JSON3.read(msg, Wrappers.MarketWrapper, parsequoted=true, dateformat=DATE_FORMAT)) + elseif resp.channel == "v3_accounts" + throw(DomainError(resp)) # TODO + else + throw(DomainError(resp)) + end + elseif resp.type == "subscribed" + if resp.channel == "v3_trades" + resp = JSON3.read(msg, Wrappers.TradeWrapper, parsequoted=true, dateformat=DATE_FORMAT) + callback = client.onTrade[resp.id] + isnothing(callback) || callback(client, resp) + elseif resp.channel == "v3_markets" + resp = JSON3.read(msg, Wrappers.MarketsSubscribedWrapper, parsequoted=true, dateformat=DATE_FORMAT) + resp = MarketWrapper(resp) + isnothing(client.onMarkets) || client.onMarkets(client, resp) + elseif resp.channel == "v3_accounts" + isnothing(client.onAccount) && continue + resp = JSON3.read(msg, Wrappers.AccountWrapper, parsequoted=true, dateformat=DATE_FORMAT) + callback = client.onAccount[resp.contents.account.accountNumber] + isnothing(callback) || callback(client, resp) + else + throw(DomainError(resp)) + end + elseif resp.type == "connected" + isnothing(onConnect) || onConnect(client, JSON3.read(msg, Connected, parsequoted=true)) + elseif resp.type == "error" + throw(JSON3.read(msg, Wrappers.Error, parsequoted=true, dateformat=DATE_FORMAT)) + else + throw(DomainError(resp)) + end + while !isempty(client.send) + s = popfirst!(client.send) + HTTP.WebSockets.Sockets.send(ws, s) + end + end + end +end + +open(client::Client) = open(nothing, client) + +function Base.close(client::Client) + isnothing(client.ws) && return + client.toClose = true +end + +function subscribeToAccount(func::Function, accountNumber::UInt16, client::Client) + client.onAccount[accountNumber] = func + push!(client.send, subscribeToAccountMessage(accountNumber)) +end + +subscribeToAccount(func::Function, account::Account, client::Client) = subscribeToAccount(client, func, account.accountNumber) + +function subscribeToMarkets(func::Function, client::Client) + client.onMarkets = func + push!(client.send, subscribeToMarketsMessage()) +end + +function subscribeToTrades(func::Function, id::String, client::Client) + client.onTrade[id] = func + push!(client.send, subscribeToTradesMessage(id)) +end + +end diff --git a/src/WebSockets/Wrappers.jl b/src/WebSockets/Wrappers.jl new file mode 100644 index 0000000..1f04bb1 --- /dev/null +++ b/src/WebSockets/Wrappers.jl @@ -0,0 +1,103 @@ +module Wrappers + +export AccountContentWrapper, + AccountWrapper, + Connected, + Error, + Markets, + MarketsSubscribedWrapper, + MarketWrapper, + Response, + Trades, + TradeWrapper + +using DydxV3.Data +using JSON3 + +struct AccountContentWrapper + orders::Vector{Data.Order} + account::Data.Account + transfers::Vector{Data.Transfer} + fundingPayments::Vector{Data.FundingPayment} +end + +struct AccountWrapper + type::String + connection_id::String + message_id::UInt32 + channel::String + contents::AccountContentWrapper +end + +struct Connected + type::String + connection_id::String + message_id::UInt32 +end + +struct Error + type::String + message::String + connection_id::String + message_id::UInt32 +end + +struct Markets + markets::Dict{String, Market} +end + +struct MarketsSubscribedWrapper + type::String + connection_id::String + message_id::UInt32 + channel::String + contents::Markets +end + +struct MarketWrapper + type::String + connection_id::String + message_id::UInt32 + channel::String + contents::Dict{String, Market} +end +MarketWrapper(sub::MarketsSubscribedWrapper) = MarketWrapper(sub.type, sub.connection_id, sub.message_id, sub.channel, sub.contents.markets) + +struct Response + type::String # subscribed || connected || channel_data || error + channel::Union{String, Nothing} # v3_markets || v3_trades +end + +struct Trades + trades::Vector{Trade} +end + +struct TradeWrapper + type::String + connection_id::String + message_id::UInt32 + channel::String + id::String + contents::Trades +end + +struct MarketsWrapper + type::String + connection_id::String + message_id::UInt32 + channel::String + id::String + contents::Vector{Data.Market} +end + +JSON3.StructTypes.StructType(::Type{AccountContentWrapper}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{AccountWrapper}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{Connected}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{Error}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{Markets}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{MarketsSubscribedWrapper}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{MarketWrapper}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{Response}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{Trades}) = JSON3.StructTypes.Struct() +JSON3.StructTypes.StructType(::Type{TradeWrapper}) = JSON3.StructTypes.Struct() +end \ No newline at end of file diff --git a/test/Private/test_api.jl b/test/Private/test_api.jl new file mode 100644 index 0000000..f6ad4d9 --- /dev/null +++ b/test/Private/test_api.jl @@ -0,0 +1,4 @@ +using Test +using DydxV3 + +@test DydxV3.Private.Api.generatequerypath("api","a" => "b", "c" => nothing) == "api?a=b" \ No newline at end of file diff --git a/test/runtests.jl b/test/runtests.jl index cabe16f..64e7ca3 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,6 +1,8 @@ -using DydxV3 using Test +using DydxV3 -@testset "DydxV3.jl" begin - # Write your tests here. -end +include("test_samplers.jl") +include("test_public.jl") +include("Private/test_api.jl") +include("test_private.jl") +include("test_websockets.jl") \ No newline at end of file diff --git a/test/test_private.jl b/test/test_private.jl new file mode 100644 index 0000000..7c95e35 --- /dev/null +++ b/test/test_private.jl @@ -0,0 +1,42 @@ +@testset "getuser" begin + @test isa(DydxV3.getuser(), DydxV3.User) +end + +@testset "getaccounts" begin + @test isa(DydxV3.getaccounts(), Vector{DydxV3.Account}) +end + +@testset "getaccount" begin + @test_skip isa(DydxV3.getaccount(getcredential("walletAddress","dydx")), DydxV3.Account) +end + +@testset "gettransfers" begin + @test_throws ArgumentError DydxV3.getpositions(limit=101) + @test isa(DydxV3.gettransfers(), Vector{DydxV3.Transfer}) +end + +@testset "getpositions" begin + @test_throws ArgumentError DydxV3.getpositions(limit=101) + @test isa(DydxV3.getpositions(), Vector{DydxV3.Position}) +end + +@testset "getfills" begin + @test_throws ArgumentError DydxV3.getfills(limit=101) + @test isa(DydxV3.getfills(), Vector{DydxV3.Fill}) +end + +@testset "getfillsfororder" begin end + +@testset "getactiveorders" begin + @test_throws ArgumentError DydxV3.getactiveorders("BTC-USD", side=nothing, id="id") + @test isa(DydxV3.getactiveorders("BTC-USD"), Vector{DydxV3.Order}) +end + +@testset "gethistoricalpnl" begin + @test isa(DydxV3.gethistoricalpnl(), Vector{DydxV3.HistoricalPnl}) +end + +@testset "getorders" begin + @test_throws ArgumentError DydxV3.getorders(limit=101) + @test isa(DydxV3.getorders(), Vector{DydxV3.Order}) +end \ No newline at end of file diff --git a/test/test_public.jl b/test/test_public.jl new file mode 100644 index 0000000..fd10d3c --- /dev/null +++ b/test/test_public.jl @@ -0,0 +1,5 @@ +@testset "Public API" begin + @test DydxV3.getmarket("BTC-USD") isa DydxV3.Market + @test DydxV3.getmarkets() isa Dict{String,DydxV3.Market} + @test DydxV3.getorderbook("BTC-USD") isa DydxV3.OrderBook +end \ No newline at end of file diff --git a/test/test_samplers.jl b/test/test_samplers.jl new file mode 100644 index 0000000..97cf028 --- /dev/null +++ b/test/test_samplers.jl @@ -0,0 +1,9 @@ + +using Dates + +@testset "rand(DydxV3.Trade)" begin + trades = rand(DydxV3.Trade, Minute(2), 3) + + @test trades[2].createdAt == trades[1].createdAt + Dates.Minute(2) + @test trades[3].createdAt == trades[2].createdAt + Dates.Minute(2) +end \ No newline at end of file diff --git a/test/test_websockets.jl b/test/test_websockets.jl new file mode 100644 index 0000000..b6b1122 --- /dev/null +++ b/test/test_websockets.jl @@ -0,0 +1,46 @@ +# @testset "open" begin +# client = DydxV3.WebSockets.Client(DydxV3.Constants.PRODUCTION) +# DydxV3.WebSockets.open(client) do cli, obj +# @test isa(obj, DydxV3.WebSockets.Connected) +# close(cli) +# end +# end + +# @testset "account" begin +# client = DydxV3.WebSockets.Client(DydxV3.Constants.PRODUCTION) +# accounts = DydxV3.getaccounts() +# function testaccount(cli, obj) +# @test isa(obj, DydxV3.WebSockets.AccountWrapper) +# close(cli) +# end +# DydxV3.WebSockets.subscribeToAccount(client, testaccount, accounts[1].accountNumber) +# DydxV3.WebSockets.open(client) +# end + +@testset "trades" begin + client = DydxV3.WebSockets.Client(DydxV3.Constants.PRODUCTION) + + i = 1 + DydxV3.WebSockets.subscribeToTrades("BTC-USD", client) do cli, obj + println(obj) + @test isa(obj, DydxV3.WebSockets.TradeWrapper) + i == 2 && close(cli) # Test subscribed and channel_data before closing + i += 1 + end + + DydxV3.WebSockets.open(client) +end + +@testset "markets" begin + client = DydxV3.WebSockets.Client(DydxV3.Constants.PRODUCTION) + + i = 1 + DydxV3.WebSockets.subscribeToMarkets(client) do cli, obj + # println(obj) + @test isa(obj, DydxV3.WebSockets.MarketWrapper) + i == 2 && close(cli) # Test subscribed and channel_data before closing + i += 1 + end + + DydxV3.WebSockets.open(client) +end