Exploring your crypto with F#
This is part of F# Advent calendar 2021. Go and check out all the other great posts and thank you Sergey Tihon for organizing!
In this post we’re going to create a console application for exploring your crypto assets. The code can be found here.
Exploring the APIs
Let’s start off by exploring the API of a crypto exchange using .NET interactive notebooks in VSCode. If you haven’t already, install the VSCode extension now:
To create a new notebook, press Ctrl + Shift + p
and choose the following:
In the first cell we want to include some NuGet packages to work with:
#r "nuget: Plotly.NET, 2.0.0-preview.11"
#r "nuget: Plotly.NET.Interactive, 2.0.0-preview.11"
#r "nuget: FSharp.Data"
With the cell in focus, press Shift + Enter
to run it and create a new cell below. Here we we open some namespaces/modules:
open FSharp.Data
open Plotly.NET
Exploring the FIRI exchange’s API
Let’s look at a Norwegian exchange with a pretty simple API, Firi (previously known as MiraiEx). The API documentation can be found here.
There are some public API’s we could easily check using the JSON provider. Put the following into a cell in the notebook:
let [<Literal>] FiriUri = "https://api.miraiex.com/v2/markets"
type FiriMarkets = JsonProvider<FiriUri>
let markets = FiriMarkets.Load FiriUri
markets
|> Array.map (fun m -> (m.Id, m.Low, m.Last, m.High))
It will display something like this:
Next we could create a plot of some historic prices by choosing one of the market Ids loaded above.
let [<Literal>] HistoryUri = "https://api.miraiex.com/v2/markets/ETHNOK/history"
type FiriMarkets = JsonProvider<HistoryUri>
let history = FiriMarkets.Load $"https://api.miraiex.com/v2/markets/{markets.[0].Id}/history"
Chart.Line(
history |> Array.map (fun h -> h.CreatedAt.UtcDateTime),
history |> Array.map (fun h -> h.Price),
ShowMarkers=true)
|> Chart.withTitle $"Price history {markets.[0].Id}"
|> Chart.withXAxisStyle ("Date")
|> Chart.withYAxisStyle ("Price")
That’s all fine for the public API’s, but the interesting ones are the private. For that we have to work a bit harder.
Let’s try to make a typed API
First, let’s define a list of some of their endpoints:
type OrderType =
| Bid
| Ask
member this.AsString =
match this with
| Ask -> "Ask"
| Bid -> "Bid"
type Order =
{ Market: string
Type: OrderType
Price: double
Amount: double }
static member Create market orderType price amount =
{ Market = market
Type = orderType
Price = price
Amount = amount }
[<RequireQualifiedAccess>]
type Endpoint =
| ListMarkets
| ListBalances
| ListTransactions
| ListTrades
| ListDepositHistory
| ListOrders
| CreateOrder of Order
| DeleteAllOrders
with
member this.Uri =
match this with
| ListMarkets -> "markets"
| ListBalances -> "balances"
| ListTransactions -> "history/transactions"
| ListTrades -> "history/trades"
| ListDepositHistory -> "deposit/history"
| ListOrders -> "history/orders"
| CreateOrder _
| DeleteAllOrders -> "orders"
member this.HttpMethod =
match this with
| ListMarkets
| ListBalances
| ListTransactions
| ListTrades
| ListDepositHistory
| ListOrders ->
HttpMethod.Get
| CreateOrder _ ->
HttpMethod.Post
| DeleteAllOrders ->
HttpMethod.Delete
member this.Body =
match this with
| ListMarkets
| ListBalances
| ListTransactions
| ListTrades
| ListDepositHistory
| ListOrders
| DeleteAllOrders ->
None
| CreateOrder order ->
Encode.object
[
"market", Encode.string order.Market
"type", Encode.string order.Type.AsString
"price", Encode.string (string order.Price)
"amount", Encode.string (string order.Amount)
]
|> Encode.toString 0
|> Some
We also need some types to deserialize the responses into, using Thoth.Json:
open Thoth.Json.Net
type Balance =
{ Currency: string
Balance: float
Hold: float
Available: float }
static member Decoder : Decoder<Balance> =
Decode.object
(fun get ->
{ Currency = get.Required.Field "currency" Decode.string
Balance = get.Required.Field "balance" Decode.string |> float
Hold = get.Required.Field "hold" Decode.string |> float
Available = get.Required.Field "available" Decode.string |> float })
type Transaction =
{ Id: Guid
Amount: decimal
Currency: string
Type: string
Date: DateTimeOffset
Details: TransactionDetails }
static member Decoder : Decoder<Transaction> =
Decode.object
(fun get ->
{ Id = get.Required.Field "id" Decode.guid
Amount = get.Required.Field "amount" Decode.string |> decimal
Currency = get.Required.Field "currency" Decode.string
Type = get.Required.Field "type" Decode.string
Date = get.Required.Field "date" Decode.datetimeOffset
Details = get.Required.Field "details" TransactionDetails.Decoder })
and TransactionDetails =
{ MatchId: string option
DepositId: string option
DepositAddress: string option
DepositTxid: string option
WithdrawId: string option
WithdrawAddress: string option
WithdrawTxid: string option }
static member Decoder : Decoder<TransactionDetails> =
Decode.object
(fun get ->
{ MatchId = get.Optional.Field "match_id" Decode.string
DepositId = get.Optional.Field "deposit_id" Decode.string
DepositAddress = get.Optional.Field "deposit_address" Decode.string
DepositTxid = get.Optional.Field "deposit_txid" Decode.string
WithdrawId = get.Optional.Field "withdraw_id" Decode.string
WithdrawAddress = get.Optional.Field "withdraw_address" Decode.string
WithdrawTxid = get.Optional.Field "withdraw_txid" Decode.string })
//...not listing them all here
What about private endpoints?
To access private endpoints we need to create a ClientId and Secret in the Firi settings page and store them in a safe place. Then we have to compute the signature as specified here:
We need a function for computing the hmac
hash and return it as lower-case hex:
open System
open System.Text
open System.Security.Cryptography
let computeHash (secret:string) (data:string) =
use hmacsha256 =
secret
|> Encoding.UTF8.GetBytes
|> fun bs -> new HMACSHA256(bs)
data
|> Encoding.UTF8.GetBytes
|> hmacsha256.ComputeHash
|> Array.map (fun (x : byte) -> sprintf "%02x" x)
|> String.concat String.Empty
Now we can use this hashing function to compute the Firi signature:
let computeSignature secret =
// Oh-noes! Side-effect! Pass it in instead if you want a pure function
let timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() |> string
let validity = "2000"
// Use Thoth.Json.Net to encode the JSON content
let body =
Encode.object
[
"timestamp", Encode.string timestamp
"validity", Encode.string validity
]
|> Encode.toString 0
let signature = computeHash secret body
let queryParams = $"?timestamp={timestamp}&validity={validity}"
(signature, queryParams)
For each message we have to compute this hash and add the query parameters:
type FiriSecrets =
{ ClientId: string
Secret: string }
static member Create clientId secret =
{ ClientId = clientId
Secret = secret }
let private createRequestMessage (secrets:FiriSecrets) (endpoint:Endpoint) =
let (signature, queryParams) = getSignature secrets.Secret
let requestUri = $"{endpoint.Uri}{queryParams}"
let requestMessage = new HttpRequestMessage(endpoint.HttpMethod, requestUri)
requestMessage.Headers.Add("miraiex-user-clientid", secrets.ClientId);
requestMessage.Headers.Add("miraiex-user-signature", signature);
endpoint.Body
|> Option.iter (fun body ->
let content = new StringContent(body, Text.Encoding.UTF8, "application/json")
requestMessage.Content <- content)
requestMessage
let send (httpClient:HttpClient) (secrets:FiriSecrets) (endpoint:Endpoint) =
task { // Let's try the new task CE in F# 6
use requestMessage = createRequestMessage secrets endpoint
use! response = httpClient.SendAsync(requestMessage)
let! responseString = response.Content.ReadAsStringAsync()
return responseString
}
|> Async.AwaitTask
And now we can make a type for the API which holds the secrets and can dispose the internal HttpClient:
type FiriApi(secrets:FiriSecrets) =
let httpClient = new HttpClient(BaseAddress = Uri "https://api.miraiex.com/v2/")
let sendRequest = send httpClient secrets
interface System.IDisposable with
member this.Dispose() =
httpClient.Dispose()
member this.GetMarkets () =
Endpoint.ListMarkets
|> sendRequest
|> Async.map (Decode.fromString (Decode.list Market.Decoder))
member this.GetBalances () =
Endpoint.ListBalances
|> sendRequest
|> Async.map (Decode.fromString (Decode.list Balance.Decoder))
member this.GetAllTransactions () =
Endpoint.ListTransactions
|> sendRequest
|> Async.map (Decode.fromString (Decode.list Transaction.Decoder))
member this.GetAllTrades () =
Endpoint.ListTrades
|> sendRequest
|> Async.map (Decode.fromString (Decode.list Trade.Decoder))
member this.GetDepositHistory () =
Endpoint.ListDepositHistory
|> sendRequest
|> Async.map (Decode.fromString DepositHistory.Decoder)
member this.GetAllOrders () =
Endpoint.ListOrders
|> sendRequest
|> Async.map (Decode.fromString (Decode.list Order.Decoder))
member this.CreateOrder (order:Order) =
order
|> Endpoint.CreateOrder
|> sendRequest
|> Async.map (Decode.fromString CreateOrderResponse.Decoder)
member this.DeleteAllOrders () =
Endpoint.DeleteAllOrders
|> sendRequest
|> Async.Ignore
What if you’re using multiple crypto exchanges?
For e.g. Coinbase, if not using OAuth, we could reuse that same hashing function for the secrets headers and make something similar to the Firi implementation we did above:
type CoinbaseSecrets =
{ ApiKey : string
ApiSecret : string }
with
static member Create (apiKey, apiSecret) =
{ ApiKey = apiKey
ApiSecret = apiSecret }
let getSignature (secret:string) (timestamp:string) (method:HttpMethod) (url:string) (body:string) =
let body = timestamp + method.Method + url + body
computeSignature secret body
let createMessage (secrets:CoinbaseSecrets) (endpoint:Endpoint) =
let timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() |> string
let requestUri = $"/v2/{endpoint.Url}"
let signature =
endpoint.Body
|> Option.defaultValue ""
|> Signature.Coinbase.getSignature secrets.ApiSecret timestamp endpoint.HttpMethod requestUri
let requestMessage = new HttpRequestMessage(endpoint.HttpMethod, requestUri)
requestMessage.Headers.Add("CB-VERSION", "2021-12-11") // Some recent date
requestMessage.Headers.Add("CB-ACCESS-KEY", secrets.ApiKey)
requestMessage.Headers.Add("CB-ACCESS-SIGN", signature)
requestMessage.Headers.Add("CB-ACCESS-TIMESTAMP", timestamp)
endpoint.Body
|> Option.iter (fun body ->
let content = new StringContent(body, Text.Encoding.UTF8, "application/json")
requestMessage.Content <- content)
requestMessage
The Coinbase responses are a bit more verbose than the Firi ones. For example look at the transactions response. It’s quite big, but we can use Json2FSharp to create the types for us. Just copy the example into the left side of Json2FSharp
and it will create the matching F# types for you. You might have to modify them a bit though. Fortunately, there’s a .NET implementation already available here and we can see if we can use that one instead of rolling our own.
Separating the instructions from the side-effects
Now we have two different crypto clients with different ways of getting the data we’re after. We want to make some common instruction set hiding the specific implementations, but how? F# supports interfaces, so why not use that and create one implementation per exchange? Because, MONAAAADS! More specifically a Free Monad with one interpreter per client.
Creating a Free Monad
A Free Monad follows a very specific recipie which can be more or less copy/pasted every time, except for the actual instructions you want it to have. The different instructions are added to a discriminated union type and will look like this:
type MyInstructions<'a>
| DoSomething of (Args1 * (Out1 -> 'a))
| DoSomethingElse of (Args2 * (Out2 -> 'a))
...
Now let’s make one for showing your total balance and your transaction history. First we start by making some output types for the instructions:
type InstructionBalance =
{ Crypto: string
Amount: double }
static member Create crypto amount =
{ Crypto = crypto
Amount = amount }
type InstructionAmount =
{ Amount: double
Currency: string }
static member Create amount currency =
{ Amount = amount
Currency = currency }
[<RequireQualifiedAccess>]
type TransactionType =
| Deposit of Amount:InstructionAmount
| Transaction of Bought:InstructionAmount * Sold:InstructionAmount * Fee:InstructionAmount
| Withdrawal of Amount:InstructionAmount * Fee:InstructionAmount
type InstructionTransaction =
{ Exchange: string
TimeStamp: DateTimeOffset
Type: TransactionType }
static member Create exchange timeStamp transactionType =
{ Exchange = exchange
TimeStamp = timeStamp
Type = transactionType }
type InstructionMarket =
{ MarketPair: string
Last: decimal
High: decimal
Low: decimal }
static member Create marketPair last high low =
{ MarketPair = marketPair
Last = last
High = high
Low = low }
Then we create the different instructions and the required Free Monad functions:
// The avaiable instructions.
// First item in tuple is `unit`, since there are no arguments.
type CryptoInstruction<'a> =
| GetBalances of (unit * (InstructionBalance list -> 'a))
| GetTransactions of (unit * (InstructionTransaction list -> 'a))
| GetMarkets of unit * (InstructionMarket list -> 'a)
// Free Monad recipie below
// The instructions type has to be a functor, so we add a map function.
module CryptoInstruction =
let map f = function
| GetBalances (x, next) -> GetBalances (x, next >> f)
| GetTransactions (x, next) -> GetTransactions (x, next >> f)
| GetMarkets (x, next) -> GetMarkets (x, next >> f)
// This will always have this shape.
type CryptoProgram<'a> =
| Free of CryptoInstruction<CryptoProgram<'a>>
| Pure of 'a
// And it requires a bind function
module CryptoProgram =
let rec bind f = function
| Free x -> x |> CryptoInstruction.map (bind f) |> Free
| Pure x -> f x
// Computation expression to make it convenient to use
type CryptoBuilder () =
member this.Bind (x, f) = CryptoProgram.bind f x
member this.Return x = Pure x
member this.ReturnFrom x = x
member this.Zero () = Pure ()
let crypto = CryptoBuilder()
// Convenience functions for creating the instructions
let getBalances () = Free (GetBalances ((), Pure))
let getTransactions () = Free (GetTransactions ((), Pure))
let getMarkets () = Free (GetMarkets ((), Pure))
This lets us create a simple program for getting balances:
let getBalancesProgram = crypto {
let! markets = getMarkets ()
let! balances = getBalances ()
let result = // Combine them somehow...
return result
}
But the Free Monad follows a recipie you said
True that, and for that we can use FSharpPlus which has Free Monad helper functions. Using this library, all you need is the instructions functor with the map function as a static member (FSharpPlus uses SRTPs) and use Free.liftF
for the convenience functions:
open FSharpPlus.Data
type CryptoInstruction<'a> =
| GetBalances of (unit * (InstructionBalance list -> 'a))
| GetTransactions of (unit * (InstructionTransaction list -> 'a))
| GetMarkets of unit * (InstructionMarket list -> 'a)
static member Map (instruction, f) = // FSharpPlus expects static member 'Map'
match instruction with
| GetBalances (x, next) -> GetBalances (x, next >> f)
| GetTransactions (x, next) -> GetTransactions (x, next >> f)
| GetMarkets (x, next) -> GetMarkets (x, next >> f)
// Convenience functions for creating the instructions
let getBalances () = GetBalances ((), id) |> Free.liftF
let getTransactions () = GetTransactions ((), id) |> Free.liftF
let getMarkets () = GetMarkets ((), id) |> Free.liftF
And we can replace our crypto
computation expression with FSharpPlus’ monad
:
open FSharpPlus
let getBalancesProgram = monad {
let! markets = getMarkets ()
let! balances = getBalances ()
let result = // Combine them somehow...
return result
}
Now for some interpretation
The second part of a Free Monad is the interpreter, which is where the side-effects happen. First, we need functions for getting the API types into the instruction types, e.g. getting the Balance
response from the Firi API into the InstructionBalance
type:
// Calling the API is Async and Thoth.Json's decoders return Result,
// so we end up with a nested type.
open FsToolkit.ErrorHandling // Has convenient asyncResult CE
// FiriApi -> Async<Result<InstructionBalance list, string>>
let getBalancesApi firiApi =
asyncResult {
let! balances = firiApi.GetBalances()
return
balances
|> List.map (fun b ->
InstructionBalance.Create b.Currency b.Balance)
}
// FiriApi -> Async<Result<InstructionTransaction list, string>>
let getTransactionsApi firiApi =
asyncResult {
let! transactions = firiApi.GetTransactions ()
let instructionTransactions =
transactions
|> // Create InstructionTransactions from the response (see GitHub repo)
return instructionTransactions
}
let private getMarketsApi (firiApi:FiriApi) =
asyncResult {
let! markets = firiApi.GetMarkets()
return
markets
|> List.map (fun m ->
InstructionMarket.Create m.Id m.Last m.High m.Low)
}
then we make the actual interpret function:
// FiriApi -> CryptoInstruction<'a> -> Async<Result<'a, string>>
let interpret firiApi instruction =
match instruction with
| GetBalances (_, next) ->
asyncResult {
let! balances = getBalancesApi firiApi
return balances |> next
}
| GetTransactions (_, next) ->
asyncResult {
let! transactions = getTransactionsApi firiApi
return transactions |> next
}
| GetMarkets (_, next) ->
asyncResult {
let! markets = getMarketsApi firiApi
return markets |> next
}
To interpret a program, which might contain multiple instructions, we can use FSharpPlus Free.fold
and pass in our interpret function:
let balances =
getBalancesProgram
|> Free.fold (interpret firiApi) // Doesn't compile, since Async<Result<'a, 'b>> doesn't have static member (>>=)
Free.fold
requires the returned type to have two static members, bind (>>=)
and return
, but we have a nested type. What now?
Option 1: Make a wrapper type
// Has Async.bind, Async.singleton and Result.either
open FsToolkit.ErrorHandling
type AsyncResult<'a, 'b> =
AsyncResult of Async<Result<'a, 'b>>
with
static member (>>=) (AsyncResult x, f:'a -> AsyncResult<'c, 'b>) =
let f' a =
let (AsyncResult r) = f a
r
x
|> Async.bind (Result.either f' (Error >> Async.singleton))
|> AsyncResult
static member Return x = Ok x |> Async.singleton |> AsyncResult
And now we can do this:
let balances =
getBalancesProgram
|> Free.fold (interpret firiApi >> AsyncResult) // Wrap nested type
|> fun (AsyncResult ar) -> ar // Unwrap the final result
|> Async.RunSynchronously
|> function
| Ok bs ->
//...
| Error err ->
//...
Option 2: Monad transformers
Nested monads can be handled by Monad Transformers. Our inner monad is Result
so we need a transformer for that. Conveniently FSharpPlus
has one called ResultT
and we don’t have to make any wrapper types anymore:
let balances =
getBalancesProgram
|> Free.fold (interpret firiApi >> ResultT)
|> ResultT.run
|> Async.RunSynchronously
|> function
| Ok bs ->
//...
| Error err ->
//...
Let’s add an interpreter for Coinbase
We’re going to use an existing .NET client in the interpreter, since someone already did the hard work there. Now, let’s try to get our balances using this client.
First of all, the Coinbase API uses pagination, so we have to be able follow the links to the next pages to extract all the data. The API returns a PagedResponse<‘a> with a link to the next page and the data for the current page. Hence, we’re going to need an async loop, since we’re going to have to call the API multiple times until there are no pages left. To help us with this we’re going to use the FSharp.Control.AsyncSeq library.
open FSharp.Control
open Coinbase
open Coinbase.Models
/// Gets all the following pages for the given initial response
let getPages (client:CoinbaseClient) (initialResponse:PagedResponse<'a>) : AsyncSeq<'a array> =
initialResponse
|> AsyncSeq.unfoldAsync (fun x -> async {
if x.Pagination.NextUri |> isNull then
return None
else
let! nextPage = client.GetNextPageAsync x |> Async.AwaitTask
return Some (nextPage.Data, nextPage)
})
Now that we have a generic function to get all pages of a certain response, we can fetch all accounts where the amount is greater than zero quite conveniently using AsyncSeq
:
let getBalancesApi (client:CoinbaseClient) : Async<InstructionBalance list> =
asyncSeq {
let! response = client.Accounts.ListAccountsAsync() |> Async.AwaitTask
yield response.Data // Yield the initial response's data
yield! getPages client response // Yield all the following pages
}
|> AsyncSeq.concatSeq
|> AsyncSeq.filter (fun a -> a.Balance.Amount > 0.0M)
|> AsyncSeq.map (fun a ->
InstructionBalance.Create a.Balance.Currency a.Balance.Amount)
|> AsyncSeq.toListAsync
let getTransactionsApi (client:CoinbaseClient) =
// ... skipped for brevity (see GitHub repo)
// Could take currency as parameter if we change the instruction to this:
// | GetMarkets of (currency:string) * (InstructionMarket list -> 'a)
//
// and convenience function to this:
//
// let getMarkets (currency:string) = GetMarkets (currency, id) |> Free.liftF
let getMarketsApi (client:CoinbaseClient) =
async {
let! response =
client.Data.GetExchangeRatesAsync("NOK") |> Async.AwaitTask
return
response.Data.Rates
|> Seq.map (fun kvp ->
let rate = 1.0m / kvp.Value
InstructionMarket.Create kvp.Key rate rate rate)
|> Seq.toList
}
and we can create the interpret function:
// CoinbaseClient -> CryptoInstruction<'a> -> Async<'a>
let interpret coinbaseClient instruction =
match instruction with
| GetBalances (_, next) ->
async {
let! balances = getBalancesApi coinbaseClient
return balances |> next
}
| GetTransactions (_, next) ->
async {
let buys = getTransactionsApi coinbaseClient
return buys |> next
}
| GetMarkets (_, next) ->
async {
let! markets = getMarketsApi coinbaseClient
return markets |> next
}
let balances =
getBalancesProgram
|> Free.fold (interpret coinbaseClient) // No nested type here
We can also make a testing interpreter
Here we add some test data which we could use for testing our instructions:
let [<Literal>] private ExchangeName = "Testing"
let interpret instruction =
match instruction with
| GetBalances (_, next) -> async {
do! Async.Sleep 2000 // Pretend it takes time
let balances =
[
InstructionBalance.Create ExchangeName "BTC" 0.0175m
InstructionBalance.Create ExchangeName "ETH" 0.03m
InstructionBalance.Create ExchangeName "ADA" 200.123m
InstructionBalance.Create ExchangeName "ALU" 123.123m
InstructionBalance.Create ExchangeName "SHIB" 123456789.0m
]
return balances |> next
}
| GetTransactions (_, next) -> async {
do! Async.Sleep 4000 // Pretend it takes time
let transactions =
[
let startDate = DateTimeOffset(DateTime(2021, 1, 1, 12, 00, 00))
InstructionTransaction.Create ExchangeName (startDate.AddDays 1) (TransactionType.Deposit (InstructionAmount.Create 1000m "USD"))
InstructionTransaction.Create ExchangeName (startDate.AddDays 2) (TransactionType.Transaction ((InstructionAmount.Create 0.0348m "BTC"), (InstructionAmount.Create 1000m "USD"), (InstructionAmount.Create 0.5m "USD")))
InstructionTransaction.Create ExchangeName (startDate.AddDays 3) (TransactionType.Deposit (InstructionAmount.Create 1000m "USD"))
InstructionTransaction.Create ExchangeName (startDate.AddDays 4) (TransactionType.Transaction ((InstructionAmount.Create 0.03m "ETH"), (InstructionAmount.Create 1000m "USD"), (InstructionAmount.Create 1m "USD")))
InstructionTransaction.Create ExchangeName (startDate.AddMonths 11) (TransactionType.Transaction ((InstructionAmount.Create 932.4663m "USD"), (InstructionAmount.Create 0.0174m "BTC"), (InstructionAmount.Create 0.5m "USD")))
InstructionTransaction.Create ExchangeName (startDate.AddMonths 12) (TransactionType.Withdrawal ((InstructionAmount.Create 932.4663m "USD"), (InstructionAmount.Create 1m "USD")))
]
return transactions |> next
| GetMarkets (_, next) -> async {
do! Async.Sleep 1000 // Pretend it takes time
let markets =
[
InstructionMarket.Create "BTC" 500000m 500000m 500000m
InstructionMarket.Create "ETH" 35000m 35000m 35000m
InstructionMarket.Create "ADA" 8.25m 8.25m 8.25m
InstructionMarket.Create "ALU" 1.34m 1.34m 1.34m
InstructionMarket.Create "SHIB" 0.0003m 0.0003m 0.0003m
]
return markets |> next
}
let run (instructions:Free<CryptoInstruction<'a>, 'a>) =
instructions
|> Free.fold interpret
Aggregating the response from multiple interpreters
We have to somehow invoke both Firi and Coinbase when we want to interpret our programs. Say we have the following functions:
module Firi =
// let interpret ...
// ...
let run firiApi (instructions:Free<CryptoInstruction<'a>, 'a>) : Async<Result<'a, string>> =
instructions
|> Free.fold (interpret firiApi >> ResultT)
|> ResultT.run
module Coinbase =
// let interpret ...
// ...
let run coinbaseClient (instructions:Free<CryptoInstruction<'a>, 'a>) : Async<'a> =
instructions
|> Free.fold (interpret coinbaseClient)
Then we can run our program for both interpreters and concatenate the result using AsyncSeq
like this:
module Program =
open FSharp.Control
open FsToolkit.ErrorHandling
// Runs a program on both interpreteres and returns the concatenated result
// FiriApi -> CoinbaseClient -> Free<CryptoInstruction<'a list>, 'a list> -> Async<Result<'a list, string list>>
let run firiApi coinbaseClient program =
asyncSeq {
// yield (Testing.run >> Async.map Ok)
yield Firi.run firiApi
yield (Coinbase.run coinbaseClient >> Async.map Ok) // Must match return type from Firi.run
}
|> AsyncSeq.mapAsyncParallel (fun run -> run program)
|> AsyncSeq.toListAsync // Async<Result<'a list, string> list>
|> Async.map (List.sequenceResultA >> Result.map (List.collect id))
let getBalancesProgram = monad {
let! balances = getBalances ()
return balances
}
let balances =
getBalancesProgram
|> Program.run firiApi coinbaseClient
|> Async.RunSynchronously
I want to see the results in pretty tables
For this we can create a CLI and use Spectre.Console library to make it awesome.
First we make a function which combines the balances and current values in NOK in a table:
let spinner =
let spinner = AnsiConsole.Status()
spinner.Spinner <- Spinner.Known.Dots
spinner.SpinnerStyle <- Style.Parse("green bold")
spinner
let showBalances (firiApi:FiriApi) (coinbaseClient:CoinbaseClient) = async {
let getBalancesProgram = monad {
let! balances = getBalances ()
let! markets = getMarkets ()
let marketLookup =
("NOK", 1.0m) ::
(markets |> List.map (fun m -> (m.MarketPair, m.Last)))
|> Map.ofList
let nokPerCoin =
balances
|> List.map (fun b ->
let value =
marketLookup
|> Map.find b.Crypto
(b.Crypto, b.Amount * value))
|> Map.ofList
return
balances
|> List.map (fun b ->
{|
b with
Value = nokPerCoin.[b.Crypto]
Currency = "NOK"
|})
}
let! balances =
spinner.StartAsync(
"[green]Getting balances...[/]",
fun _ -> run getBalancesProgram |> Async.StartAsTask)
|> Async.AwaitTask
match balances with
| Ok xs ->
let table =
Table()
.AddColumn("[blue]Crypto[/]")
.AddColumn("[blue]Amount[/]")
.AddColumn("[blue]Value[/]")
.AddColumn("[blue]Currency[/]")
.AddColumn("[blue]Exchange[/]")
table.Border <- TableBorder.Rounded
table.Title <- TableTitle("[purple_1]Balances[/]")
table.Columns.[1].Alignment <- Justify.Right
table.Columns.[2].Alignment <- Justify.Right
xs
|> List.filter (fun t -> t.Amount > 0.0m)
|> List.iter (fun t ->
table.AddRow(
$"[orange1]{t.Crypto}[/]",
$"[green]%.4f{t.Amount}[/]",
$"[green]%.2f{t.Value}[/]",
$"[blue]{t.Currency}[/]",
$"[deepskyblue1]{t.Exchange}[/]")
|> ignore)
let sum = xs |> List.sumBy (fun t -> t.Value)
table.AddRow("[lightgreen]Sum[/]", "", $"[lightgreen]%.2f{sum}[/]", "[blue]NOK[/]") |> ignore
AnsiConsole.Write (table)
| Error err ->
AnsiConsole.MarkupLine($"[Red]Failed to get balances. Details: {err}[/]")
}
open Coinbase
open Spectre.Console
open ...
[<RequireQualifiedAccess>]
type Selection =
| Transactions
| Balances
| Quit
[<EntryPoint>]
let main argv =
// Get from environment var or args
let firiClientId = ""
let firiSecret = ""
let coinbaseApiKey = ""
let coinbaseSecret = ""
use firiApi = new FiriApi(FiriSecrets.Create clientId secret)
use coinbaseClient =
new CoinbaseClient(new ApiKeyConfig(ApiKey=coinbaseApiKey,
ApiSecret=coinbaseSecret))
let selectionPrompt =
let prompt = new SelectionPrompt<Selection>()
prompt.Title <- "[blue]What do you want to see?[/]"
prompt.AddChoices(
[|
Selection.Transactions
Selection.Balances
Selection.Quit
|]) |> ignore
prompt
let rec loop () = async {
let choice = AnsiConsole.Prompt(selectionPrompt)
match choice with
| Selection.Transactions ->
do! showTransactions firiApi coinbaseClient
return! loop ()
| Selection.Balances ->
do! showBalances firiApi coinbaseClient
return! loop ()
| Selection.Quit ->
()
}
loop ()
|> Async.RunSynchronously
0 // return an integer exit code
Now let’s try it out using our testing interpreter:
Profit!
Using the aggregated transaction history from multiple exchanges, we could calculate our gains and losses for, say, tax purposes, but that’s a task for another day.