diff --git a/.gitignore b/.gitignore index 4913d48..5188c2e 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ npm-debug.log # Dotenv files .env* + +# VSCode +.vscode/ diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..164f9d5 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,3 @@ +elixir 1.15.6-otp-26 +erlang 26.1.1 +direnv 2.32.2 diff --git a/README.md b/README.md index 0955618..f71d5ad 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,14 @@ Find more examples in the folder `examples/`. ### Starting client connection ```elixir -params = %{app_name: "XtbClient", type: :demo, url: "wss://ws.xtb.com", user: "<>", password: "<>"} +params = [ + connection: %{ + app_name: "XtbClient", + type: :demo, + url: "wss://ws.xtb.com", + user: "<>", + password: "<>"} + ] {:ok, pid} = XtbClient.Connection.start_link(params) ``` @@ -34,7 +41,14 @@ params = %{app_name: "XtbClient", type: :demo, url: "wss://ws.xtb.com", user: "< ```elixir Code.require_file("./examples/stream_listener.ex") -params = %{app_name: "XtbClient", type: :demo, url: "wss://ws.xtb.com", user: "<>", password: "<>"} +params = [ + connection: %{ + app_name: "XtbClient", + type: :demo, + url: "wss://ws.xtb.com", + user: "<>", + password: "<>"} + ] {:ok, cpid} = XtbClient.Connection.start_link(params) args = %{symbol: "LITECOIN"} @@ -81,7 +95,14 @@ Listener handle info: {:ok, ```elixir Code.require_file("./examples/stream_listener.ex") -params = %{app_name: "XtbClient", type: :demo, url: "wss://ws.xtb.com", user: "<>", password: "<>"} +params = [ + connection: %{ + app_name: "XtbClient", + type: :demo, + url: "wss://ws.xtb.com", + user: "<>", + password: "<>"} + ] {:ok, cpid} = XtbClient.Connection.start_link(params) args = "LITECOIN" diff --git a/lib/xtb_client/account_type.ex b/lib/xtb_client/account_type.ex index 5591a6e..c0063ff 100644 --- a/lib/xtb_client/account_type.ex +++ b/lib/xtb_client/account_type.ex @@ -1,6 +1,6 @@ defmodule XtbClient.AccountType do @moduledoc """ - Atoms representing type of account. + Helper module for handling with type of account. """ @type t :: :demo | :real @@ -8,22 +8,16 @@ defmodule XtbClient.AccountType do @doc """ Format an atom representing main type of the account to string. """ - @spec format_main(t()) :: binary() - def format_main(account_type) when is_atom(account_type) do - case account_type do - :demo -> "demo" - :real -> "real" - end - end + @spec format_main(t()) :: String.t() + def format_main(:demo), do: "demo" + def format_main(:real), do: "real" + def format_main(other), do: raise("Unknown account type: #{inspect(other)}") @doc """ Format and atom representing streaming type of the account to string. """ - @spec format_streaming(t()) :: binary() - def format_streaming(account_type) when is_atom(account_type) do - case account_type do - :demo -> "demoStream" - :real -> "realStream" - end - end + @spec format_streaming(t()) :: String.t() + def format_streaming(:demo), do: "demoStream" + def format_streaming(:real), do: "realStream" + def format_streaming(other), do: raise("Unknown account type: #{inspect(other)}") end diff --git a/lib/xtb_client/connection.ex b/lib/xtb_client/connection.ex index c8db74b..6961a7e 100644 --- a/lib/xtb_client/connection.ex +++ b/lib/xtb_client/connection.ex @@ -1,80 +1,55 @@ defmodule XtbClient.Connection do - use GenServer - - alias XtbClient.{MainSocket, StreamingSocket, StreamingMessage} - - alias XtbClient.Messages.{ - Candles, - ChartLast, - ChartRange, - DateRange, - ProfitCalculation, - Quotations, - SymbolInfo, - SymbolVolume, - TickPrices, - TradeInfos, - Trades, - TradeTransaction, - TradeTransactionStatus, - TradingHours - } - - require Logger - - @type client :: atom | pid | {atom, any} | {:via, atom, any} - @moduledoc """ `GenServer` which handles all commands and queries issued to XTB platform. - + Acts as a proxy process between the client and the underlying main and streaming socket. - + After successful initialization the process should hold references to both `XtbClient.MainSocket` and `XtbClient.StreamingSocket` processes, so it can mediate all commands and queries from the caller to the connected socket. - + For the synchronous operations clients should expect to get results as the function returned value. - + ## Example of synchronous call - + ``` params = %{app_name: "XtbClient", type: :demo, url: "wss://ws.xtb.com", user: "<>", password: "<>"} {:ok, pid} = XtbClient.Connection.start_link(params) - + version = XtbClient.Connection.get_version(pid) # expect to see the actual version of the backend server ``` - + Asynchronous operations, mainly `subscribe_` functions, returns immediately and stores the `pid` of the subscriber, so later it can send the message there. Note that each `subscribe_` function expects the `subscriber` as an argument, so `XtbClient.Connection` could serve different types of events to different subscribers. Only limitation is that each `subscribe_` function handles only one subscriber. - + ## Example of asynchronous call - + ``` defmodule StreamListener do use GenServer - + def start_link(args) do name = Map.get(args, "name") |> String.to_atom() GenServer.start_link(__MODULE__, args, name: name) end - + @impl true def init(state) do {:ok, state} end - + @impl true def handle_info(message, state) do IO.inspect({self(), message}, label: "Listener handle info") {:noreply, state} end end - - + + params = %{app_name: "XtbClient", type: :demo, url: "wss://ws.xtb.com", user: "<>", password: "<>"} {:ok, cpid} = XtbClient.Connection.start_link(params) - + args = %{symbol: "LITECOIN"} query = XtbClient.Messages.Quotations.Query.new(args) {:ok, lpid} = StreamListener.start_link(%{"name" => args.symbol}) @@ -82,34 +57,82 @@ defmodule XtbClient.Connection do # expect to see logs from StreamListener process with tick pricess logged ``` """ + use GenServer + + alias XtbClient.{MainSocket, StreamingSocket, StreamingMessage} + + alias XtbClient.Messages.{ + Candles, + ChartLast, + ChartRange, + DateRange, + ProfitCalculation, + Quotations, + SymbolInfo, + SymbolVolume, + TickPrices, + TradeInfos, + Trades, + TradeTransaction, + TradeTransactionStatus, + TradingHours + } + + require Logger + + defmodule State do + @moduledoc false + @enforce_keys [ + :type, + :url, + :clients, + :subscribers + ] + defstruct type: nil, + url: nil, + mpid: nil, + spid: nil, + clients: %{}, + subscribers: %{}, + stream_session_id: nil + end @doc """ Starts a `XtbClient.Connection` process linked to the calling process. """ - @spec start_link(map) :: GenServer.on_start() - def start_link(args) do - state = - args - |> Map.put(:clients, %{}) - |> Map.put(:subscribers, %{}) + @spec start_link(any(), GenServer.options()) :: GenServer.on_start() + def start_link(_args, opts), do: start_link(opts) - options = Map.get(args, :options, []) - GenServer.start_link(__MODULE__, state, options) + @doc """ + Starts a `XtbClient.Connection` process linked to the calling process. + """ + @spec start_link(GenServer.options()) :: GenServer.on_start() + def start_link(opts) do + {connection_opts, conn_opts} = Keyword.split(opts, [:connection]) + init_opts = Keyword.fetch!(connection_opts, :connection) + GenServer.start_link(__MODULE__, init_opts, conn_opts) end @impl true - def init(state) do - {:ok, mpid} = MainSocket.start_link(state) - Process.sleep(1_000) + def init(opts) do + {:ok, mpid} = MainSocket.start_link(opts) + + Process.sleep(500) MainSocket.stream_session_id(mpid, self()) Process.flag(:trap_exit, true) - state = - state - |> Map.put(:mpid, mpid) - |> Map.delete(:user) - |> Map.delete(:password) + type = get_in(opts, [:type]) + url = get_in(opts, [:url]) + + state = %State{ + mpid: mpid, + spid: nil, + type: type, + url: url, + clients: %{}, + subscribers: %{} + } {:ok, state} end @@ -117,7 +140,7 @@ defmodule XtbClient.Connection do @doc """ Returns array of all symbols available for the user. """ - @spec get_all_symbols(client()) :: XtbClient.Messages.SymbolInfos.t() + @spec get_all_symbols(GenServer.server()) :: XtbClient.Messages.SymbolInfos.t() def get_all_symbols(pid) do GenServer.call(pid, {"getAllSymbols"}) end @@ -125,35 +148,35 @@ defmodule XtbClient.Connection do @doc """ Returns calendar with market events. """ - @spec get_calendar(client()) :: XtbClient.Messages.CalendarInfos.t() + @spec get_calendar(GenServer.server()) :: XtbClient.Messages.CalendarInfos.t() def get_calendar(pid) do GenServer.call(pid, {"getCalendar"}) end @doc """ Returns chart info from start date to the current time. - + If the chosen period of `XtbClient.Messages.ChartLast.Query` is greater than 1 minute, the last candle returned by the API can change until the end of the period (the candle is being automatically updated every minute). - + Limitations: there are limitations in charts data availability. Detailed ranges for charts data, what can be accessed with specific period, are as follows: - + - PERIOD_M1 --- <0-1) month, i.e. one month time - PERIOD_M30 --- <1-7) month, six months time - PERIOD_H4 --- <7-13) month, six months time - PERIOD_D1 --- 13 month, and earlier on - + Note, that specific PERIOD_ is the lowest (i.e. the most detailed) period, accessible in listed range. For instance, in months range <1-7) you can access periods: PERIOD_M30, PERIOD_H1, PERIOD_H4, PERIOD_D1, PERIOD_W1, PERIOD_MN1. Specific data ranges availability is guaranteed, however those ranges may be wider, e.g.: PERIOD_M1 may be accessible for 1.5 months back from now, where 1.0 months is guaranteed. - + ## Example scenario: - + * request charts of 5 minutes period, for 3 months time span, back from now; * response: you are guaranteed to get 1 month of 5 minutes charts; because, 5 minutes period charts are not accessible 2 months and 3 months back from now - + **Please note that this function can be usually replaced by its streaming equivalent `subscribe_get_candles/3` which is the preferred way of retrieving current candle data.** """ @spec get_chart_last( - client(), + GenServer.server(), XtbClient.Messages.ChartLast.Query.t() ) :: XtbClient.Messages.RateInfos.t() def get_chart_last(pid, %ChartLast.Query{} = params) do @@ -162,20 +185,20 @@ defmodule XtbClient.Connection do @doc """ Returns chart info with data between given start and end dates. - + Limitations: there are limitations in charts data availability. Detailed ranges for charts data, what can be accessed with specific period, are as follows: - + - PERIOD_M1 --- <0-1) month, i.e. one month time - PERIOD_M30 --- <1-7) month, six months time - PERIOD_H4 --- <7-13) month, six months time - PERIOD_D1 --- 13 month, and earlier on - + Note, that specific PERIOD_ is the lowest (i.e. the most detailed) period, accessible in listed range. For instance, in months range <1-7) you can access periods: PERIOD_M30, PERIOD_H1, PERIOD_H4, PERIOD_D1, PERIOD_W1, PERIOD_MN1. Specific data ranges availability is guaranteed, however those ranges may be wider, e.g.: PERIOD_M1 may be accessible for 1.5 months back from now, where 1.0 months is guaranteed. - + **Please note that this function can be usually replaced by its streaming equivalent `subscribe_get_candles/3` which is the preferred way of retrieving current candle data.** """ - @spec get_chart_range(client(), XtbClient.Messages.ChartRange.Query.t()) :: + @spec get_chart_range(GenServer.server(), XtbClient.Messages.ChartRange.Query.t()) :: XtbClient.Messages.RateInfos.t() def get_chart_range(pid, %ChartRange.Query{} = params) do GenServer.call(pid, {"getChartRangeRequest", %{info: params}}) @@ -183,10 +206,10 @@ defmodule XtbClient.Connection do @doc """ Returns calculation of commission and rate of exchange. - + The value is calculated as expected value and therefore might not be perfectly accurate. """ - @spec get_commission_def(client(), XtbClient.Messages.SymbolVolume.t()) :: + @spec get_commission_def(GenServer.server(), XtbClient.Messages.SymbolVolume.t()) :: XtbClient.Messages.CommissionDefinition.t() def get_commission_def(pid, %SymbolVolume{} = params) do GenServer.call(pid, {"getCommissionDef", params}) @@ -195,7 +218,7 @@ defmodule XtbClient.Connection do @doc """ Returns information about account currency and account leverage. """ - @spec get_current_user_data(client()) :: XtbClient.Messages.UserInfo.t() + @spec get_current_user_data(GenServer.server()) :: XtbClient.Messages.UserInfo.t() def get_current_user_data(pid) do GenServer.call(pid, {"getCurrentUserData"}) end @@ -203,27 +226,27 @@ defmodule XtbClient.Connection do @doc """ Returns IBs data from the given time range. """ - @spec get_ibs_history(client(), XtbClient.Messages.DateRange.t()) :: any() + @spec get_ibs_history(GenServer.server(), XtbClient.Messages.DateRange.t()) :: any() def get_ibs_history(pid, %DateRange{} = params) do GenServer.call(pid, {"getIbsHistory", params}) end @doc """ Returns various account indicators. - + **Please note that this function can be usually replaced by its streaming equivalent `subscribe_get_balance/2` which is the preferred way of retrieving current account indicators.** """ - @spec get_margin_level(client()) :: XtbClient.Messages.BalanceInfo.t() + @spec get_margin_level(GenServer.server()) :: XtbClient.Messages.BalanceInfo.t() def get_margin_level(pid) do GenServer.call(pid, {"getMarginLevel"}) end @doc """ Returns expected margin for given instrument and volume. - + The value is calculated as expected margin value and therefore might not be perfectly accurate. """ - @spec get_margin_trade(client(), XtbClient.Messages.SymbolVolume.t()) :: + @spec get_margin_trade(GenServer.server(), XtbClient.Messages.SymbolVolume.t()) :: XtbClient.Messages.MarginTrade.t() def get_margin_trade(pid, %SymbolVolume{} = params) do GenServer.call(pid, {"getMarginTrade", params}) @@ -231,21 +254,22 @@ defmodule XtbClient.Connection do @doc """ Returns news from trading server which were sent within specified period of time. - + **Please note that this function can be usually replaced by its streaming equivalent `subscribe_get_news/2` which is the preferred way of retrieving news data.** """ - @spec get_news(client(), XtbClient.Messages.DateRange.t()) :: XtbClient.Messages.NewsInfos.t() + @spec get_news(GenServer.server(), XtbClient.Messages.DateRange.t()) :: + XtbClient.Messages.NewsInfos.t() def get_news(pid, %DateRange{} = params) do GenServer.call(pid, {"getNews", params}) end @doc """ Calculates estimated profit for given deal data. - + Should be used for calculator-like apps only. Profit for opened transactions should be taken from server, due to higher precision of server calculation. """ - @spec get_profit_calculation(client(), XtbClient.Messages.ProfitCalculation.Query.t()) :: + @spec get_profit_calculation(GenServer.server(), XtbClient.Messages.ProfitCalculation.Query.t()) :: XtbClient.Messages.ProfitCalculation.t() def get_profit_calculation(pid, %ProfitCalculation.Query{} = params) do GenServer.call(pid, {"getProfitCalculation", params}) @@ -254,7 +278,7 @@ defmodule XtbClient.Connection do @doc """ Returns current time on trading server. """ - @spec get_server_time(client()) :: XtbClient.Messages.ServerTime.t() + @spec get_server_time(GenServer.server()) :: XtbClient.Messages.ServerTime.t() def get_server_time(pid) do GenServer.call(pid, {"getServerTime"}) end @@ -262,7 +286,7 @@ defmodule XtbClient.Connection do @doc """ Returns a list of step rules for DMAs. """ - @spec get_step_rules(client()) :: XtbClient.Messages.StepRules.t() + @spec get_step_rules(GenServer.server()) :: XtbClient.Messages.StepRules.t() def get_step_rules(pid) do GenServer.call(pid, {"getStepRules"}) end @@ -270,7 +294,7 @@ defmodule XtbClient.Connection do @doc """ Returns information about symbol available for the user. """ - @spec get_symbol(client(), XtbClient.Messages.SymbolInfo.Query.t()) :: + @spec get_symbol(GenServer.server(), XtbClient.Messages.SymbolInfo.Query.t()) :: XtbClient.Messages.SymbolInfo.t() def get_symbol(pid, %SymbolInfo.Query{} = params) do GenServer.call(pid, {"getSymbol", params}) @@ -278,12 +302,12 @@ defmodule XtbClient.Connection do @doc """ Returns array of current quotations for given symbols, only quotations that changed from given timestamp are returned. - + New timestamp obtained from output will be used as an argument of the next call of this command. - + **Please note that this function can be usually replaced by its streaming equivalent `subscribe_get_tick_prices/3` which is the preferred way of retrieving ticks data.** """ - @spec get_tick_prices(client(), XtbClient.Messages.TickPrices.Query.t()) :: + @spec get_tick_prices(GenServer.server(), XtbClient.Messages.TickPrices.Query.t()) :: XtbClient.Messages.TickPrices.t() def get_tick_prices(pid, %TickPrices.Query{} = params) do GenServer.call(pid, {"getTickPrices", params}) @@ -292,7 +316,7 @@ defmodule XtbClient.Connection do @doc """ Returns array of trades listed in orders query. """ - @spec get_trade_records(client(), XtbClient.Messages.TradeInfos.Query.t()) :: + @spec get_trade_records(GenServer.server(), XtbClient.Messages.TradeInfos.Query.t()) :: XtbClient.Messages.TradeInfos.t() def get_trade_records(pid, %TradeInfos.Query{} = params) do GenServer.call(pid, {"getTradeRecords", params}) @@ -300,10 +324,10 @@ defmodule XtbClient.Connection do @doc """ Returns array of user's trades. - + **Please note that this function can be usually replaced by its streaming equivalent `subscribe_get_trades/2` which is the preferred way of retrieving trades data.** """ - @spec get_trades(client(), XtbClient.Messages.Trades.Query.t()) :: + @spec get_trades(GenServer.server(), XtbClient.Messages.Trades.Query.t()) :: XtbClient.Messages.TradeInfos.t() def get_trades(pid, %Trades.Query{} = params) do GenServer.call(pid, {"getTrades", params}) @@ -312,7 +336,7 @@ defmodule XtbClient.Connection do @doc """ Returns array of user's trades which were closed within specified period of time. """ - @spec get_trades_history(client(), XtbClient.Messages.DateRange.t()) :: + @spec get_trades_history(GenServer.server(), XtbClient.Messages.DateRange.t()) :: XtbClient.Messages.TradeInfos.t() def get_trades_history(pid, %DateRange{} = params) do GenServer.call(pid, {"getTradesHistory", params}) @@ -321,7 +345,7 @@ defmodule XtbClient.Connection do @doc """ Returns quotes and trading times. """ - @spec get_trading_hours(client(), XtbClient.Messages.TradingHours.Query.t()) :: + @spec get_trading_hours(GenServer.server(), XtbClient.Messages.TradingHours.Query.t()) :: XtbClient.Messages.TradingHours.t() def get_trading_hours(pid, %TradingHours.Query{} = params) do GenServer.call(pid, {"getTradingHours", params}) @@ -330,21 +354,21 @@ defmodule XtbClient.Connection do @doc """ Returns the current API version. """ - @spec get_version(client()) :: XtbClient.Messages.Version.t() + @spec get_version(GenServer.server()) :: XtbClient.Messages.Version.t() def get_version(pid) do GenServer.call(pid, {"getVersion"}) end @doc """ Starts trade transaction. - + `trade_transaction/2` sends main transaction information to the server. - + ## How to verify that the trade request was accepted? The status field set to 'true' does not imply that the transaction was accepted. It only means, that the server acquired your request and began to process it. To analyse the status of the transaction (for example to verify if it was accepted or rejected) use the `trade_transaction_status/2` command with the order number that came back with the response of the `trade_transaction/2` command. """ - @spec trade_transaction(client(), XtbClient.Messages.TradeTransaction.Command.t()) :: + @spec trade_transaction(GenServer.server(), XtbClient.Messages.TradeTransaction.Command.t()) :: XtbClient.Messages.TradeTransaction.t() def trade_transaction(pid, %TradeTransaction.Command{} = params) do GenServer.call(pid, {"tradeTransaction", %{tradeTransInfo: params}}) @@ -352,11 +376,14 @@ defmodule XtbClient.Connection do @doc """ Returns current transaction status. - + At any time of transaction processing client might check the status of transaction on server side. In order to do that client must provide unique order taken from `trade_transaction/2` invocation. """ - @spec trade_transaction_status(client(), XtbClient.Messages.TradeTransactionStatus.Query.t()) :: + @spec trade_transaction_status( + GenServer.server(), + XtbClient.Messages.TradeTransactionStatus.Query.t() + ) :: XtbClient.Messages.TradeTransactionStatus.t() def trade_transaction_status(pid, %TradeTransactionStatus.Query{} = params) do GenServer.call(pid, {"tradeTransactionStatus", params}) @@ -364,11 +391,11 @@ defmodule XtbClient.Connection do @doc """ Allows to get actual account indicators values in real-time, as soon as they are available in the system. - + Operation is asynchronous, so the immediate response is an `:ok` atom. When the new data are available, the `XtbClient.Messages.BalanceInfo` struct is sent to the `subscriber` process. """ - @spec subscribe_get_balance(client(), client()) :: :ok + @spec subscribe_get_balance(GenServer.server(), GenServer.server()) :: :ok def subscribe_get_balance(pid, subscriber) do GenServer.cast(pid, {:subscribe, {subscriber, StreamingMessage.new("getBalance", "balance")}}) end @@ -376,11 +403,15 @@ defmodule XtbClient.Connection do @doc """ Subscribes for API chart candles. The interval of every candle is 1 minute. A new candle arrives every minute. - + Operation is asynchronous, so the immediate response is an `:ok` atom. When the new data are available, the `XtbClient.Messages.Candle` struct is sent to the `subscriber` process. """ - @spec subscribe_get_candles(client(), client(), XtbClient.Messages.Candles.Query.t()) :: :ok + @spec subscribe_get_candles( + GenServer.server(), + GenServer.server(), + XtbClient.Messages.Candles.Query.t() + ) :: :ok def subscribe_get_candles(pid, subscriber, %Candles.Query{} = params) do GenServer.cast( pid, @@ -391,11 +422,11 @@ defmodule XtbClient.Connection do @doc """ Subscribes for 'keep alive' messages. A new 'keep alive' message is sent by the API every 3 seconds. - + Operation is asynchronous, so the immediate response is an `:ok` atom. When the new data are available, the `XtbClient.Messages.KeepAlive` struct is sent to the `subscriber` process. """ - @spec subscribe_keep_alive(client(), client()) :: :ok + @spec subscribe_keep_alive(GenServer.server(), GenServer.server()) :: :ok def subscribe_keep_alive(pid, subscriber) do GenServer.cast( pid, @@ -405,22 +436,22 @@ defmodule XtbClient.Connection do @doc """ Subscribes for news. - + Operation is asynchronous, so the immediate response is an `:ok` atom. When the new data are available, the `XtbClient.Messages.NewsInfo` struct is sent to the `subscriber` process. """ - @spec subscribe_get_news(client(), client()) :: :ok + @spec subscribe_get_news(GenServer.server(), GenServer.server()) :: :ok def subscribe_get_news(pid, subscriber) do GenServer.cast(pid, {:subscribe, {subscriber, StreamingMessage.new("getNews", "news")}}) end @doc """ Subscribes for profits. - + Operation is asynchronous, so the immediate response is an `:ok` atom. When the new data are available, the `XtbClient.Messages.ProfitInfo` struct is sent to the `subscriber` process. """ - @spec subscribe_get_profits(client(), client()) :: :ok + @spec subscribe_get_profits(GenServer.server(), GenServer.server()) :: :ok def subscribe_get_profits(pid, subscriber) do GenServer.cast(pid, {:subscribe, {subscriber, StreamingMessage.new("getProfits", "profit")}}) end @@ -429,11 +460,15 @@ defmodule XtbClient.Connection do Establishes subscription for quotations and allows to obtain the relevant information in real-time, as soon as it is available in the system. The `subscribe_get_tick_prices/3` command can be invoked many times for the same symbol, but only one subscription for a given symbol will be created. Please beware that when multiple records are available, the order in which they are received is not guaranteed. - + Operation is asynchronous, so the immediate response is an `:ok` atom. When the new data are available, the `XtbClient.Messages.TickPrice` struct is sent to the `subscriber` process. """ - @spec subscribe_get_tick_prices(client(), client(), XtbClient.Messages.Quotations.Query.t()) :: + @spec subscribe_get_tick_prices( + GenServer.server(), + GenServer.server(), + XtbClient.Messages.Quotations.Query.t() + ) :: :ok def subscribe_get_tick_prices(pid, subscriber, %Quotations.Query{} = params) do GenServer.cast( @@ -445,11 +480,11 @@ defmodule XtbClient.Connection do @doc """ Establishes subscription for user trade status data and allows to obtain the relevant information in real-time, as soon as it is available in the system. Please beware that when multiple records are available, the order in which they are received is not guaranteed. - + Operation is asynchronous, so the immediate response is an `:ok` atom. When the new data are available, the `XtbClient.Messages.TradeInfo` struct is sent to the `subscriber` process. """ - @spec subscribe_get_trades(client(), client()) :: :ok + @spec subscribe_get_trades(GenServer.server(), GenServer.server()) :: :ok def subscribe_get_trades(pid, subscriber) do GenServer.cast(pid, {:subscribe, {subscriber, StreamingMessage.new("getTrades", "trade")}}) end @@ -457,11 +492,11 @@ defmodule XtbClient.Connection do @doc """ Allows to get status for sent trade requests in real-time, as soon as it is available in the system. Please beware that when multiple records are available, the order in which they are received is not guaranteed. - + Operation is asynchronous, so the immediate response is an `:ok` atom. When the new data are available, the `XtbClient.Messages.TradeStatus` struct is sent to the `subscriber` process. """ - @spec subscribe_get_trade_status(client(), client()) :: :ok + @spec subscribe_get_trade_status(GenServer.server(), GenServer.server()) :: :ok def subscribe_get_trade_status(pid, subscriber) do GenServer.cast( pid, @@ -470,42 +505,53 @@ defmodule XtbClient.Connection do end @impl true - def handle_call({method}, {_pid, ref} = from, %{mpid: mpid, clients: clients} = state) do + def handle_call({method}, {_pid, ref} = from, %State{mpid: mpid, clients: clients} = state) do ref_string = inspect(ref) MainSocket.query(mpid, self(), ref_string, method) clients = Map.put(clients, ref_string, from) - state = %{state | clients: clients} + state = %State{state | clients: clients} {:noreply, state} end @impl true - def handle_call({method, params}, {_pid, ref} = from, %{mpid: mpid, clients: clients} = state) do + def handle_call( + {method, params}, + {_pid, ref} = from, + %State{mpid: mpid, clients: clients} = state + ) do ref_string = inspect(ref) MainSocket.query(mpid, self(), ref_string, method, params) clients = Map.put(clients, ref_string, from) - state = %{state | clients: clients} + state = %State{state | clients: clients} {:noreply, state} end @impl true - def handle_cast({:response, ref, resp} = _message, %{clients: clients} = state) do + def handle_cast({:response, ref, resp} = _message, %State{clients: clients} = state) do {client, clients} = Map.pop!(clients, ref) GenServer.reply(client, resp) - state = %{state | clients: clients} + state = %State{state | clients: clients} {:noreply, state} end @impl true - def handle_cast({:stream_session_id, session_id} = _message, state) do - args = Map.put(state, :stream_session_id, session_id) - {:ok, spid} = StreamingSocket.start_link(args) + def handle_cast( + {:stream_session_id, session_id} = _message, + %State{type: type, url: url} = state + ) do + args = %{ + stream_session_id: session_id, + type: type, + url: url + } - state = Map.put(state, :spid, spid) + {:ok, spid} = StreamingSocket.start_link(args) + state = %{state | spid: spid} {:noreply, state} end @@ -513,13 +559,13 @@ defmodule XtbClient.Connection do @impl true def handle_cast( {:subscribe, {subscriber, %StreamingMessage{} = streaming_message}} = _message, - %{spid: spid, subscribers: subscribers} = state + %State{spid: spid, subscribers: subscribers} = state ) do StreamingSocket.subscribe(spid, self(), streaming_message) token = StreamingMessage.encode_token(streaming_message) subscribers = Map.put(subscribers, token, subscriber) - state = %{state | subscribers: subscribers} + state = %State{state | subscribers: subscribers} {:noreply, state} end @@ -527,7 +573,7 @@ defmodule XtbClient.Connection do @impl true def handle_cast( {:stream_result, {token, result}} = _message, - %{subscribers: subscribers} = state + %State{subscribers: subscribers} = state ) do subscriber = Map.get(subscribers, token) send(subscriber, {:ok, result}) diff --git a/lib/xtb_client/main_socket.ex b/lib/xtb_client/main_socket.ex index b9727bd..d0a6c1d 100644 --- a/lib/xtb_client/main_socket.ex +++ b/lib/xtb_client/main_socket.ex @@ -1,53 +1,88 @@ defmodule XtbClient.MainSocket do + @moduledoc """ + WebSocket server used for synchronous communication. + + `MainSocket` is being used like standard `GenServer` - could be started with `start_link/1` and supervised. + + After successful connection to WebSocket the flow is: + - process casts `login` command to obtain session with backend server, + - process schedules to itself the `ping` command (with recurring interval) - to maintain persistent connection with backend. + """ use WebSockex alias XtbClient.{AccountType} alias XtbClient.Messages + alias XtbClient.RateLimit require Logger @ping_interval 30 * 1000 - @rate_limit_interval 200 - @type client :: atom | pid | {atom, any} | {:via, atom, any} + defmodule Config do + @type t :: %{ + :url => String.t() | URI.t(), + :type => AccountType.t(), + :user => String.t(), + :password => String.t(), + :app_name => String.t() + } + + def parse(opts) do + type = AccountType.format_main(get_in(opts, [:type])) + + %{ + url: get_in(opts, [:url]) |> URI.merge(type) |> URI.to_string(), + type: type, + user: get_in(opts, [:user]), + password: get_in(opts, [:password]), + app_name: get_in(opts, [:app_name]) + } + end + end - @moduledoc """ - WebSocket server used for synchronous communication. - - `MainSocket` is being used like standard `GenServer` - could be started with `start_link/1` and supervised. - - After successful connection to WebSocket the flow is: - - process casts `login` command to obtain session with backend server, - - process schedules to itself the `ping` command (with recurring interval) - to maintain persistent connection with backend. - """ + defmodule State do + @enforce_keys [ + :url, + :account_type, + :user, + :password, + :app_name, + :queries, + :rate_limit + ] + defstruct url: nil, + account_type: nil, + user: nil, + password: nil, + app_name: nil, + stream_session_id: nil, + queries: %{}, + rate_limit: nil + end @doc """ Starts a `XtbClient.MainSocket` process linked to the calling process. """ - @spec start_link(%{ - :app_name => binary(), - :password => binary(), - :type => AccountType.t(), - :url => binary | URI.t(), - :user => binary(), - optional(any) => any - }) :: GenServer.on_start() - def start_link( - %{url: url, type: type, user: _user, password: _password, app_name: _app_name} = state - ) do - account_type = AccountType.format_main(type) - uri = URI.merge(url, account_type) |> URI.to_string() - - state = - state - |> Map.put(:queries, %{}) - |> Map.put(:last_query, actual_rate()) + @spec start_link(Config.t()) :: GenServer.on_start() + def start_link(opts) do + %{type: type, url: url, user: user, password: password, app_name: app_name} = + Config.parse(opts) + + state = %State{ + url: url, + account_type: type, + user: user, + password: password, + app_name: app_name, + queries: %{}, + rate_limit: RateLimit.new(200) + } - WebSockex.start_link(uri, __MODULE__, state) + WebSockex.start_link(url, __MODULE__, state) end @impl WebSockex - def handle_connect(_conn, %{user: user, password: password, app_name: app_name} = state) do + def handle_connect(_conn, %State{user: user, password: password, app_name: app_name} = state) do login_args = %{ "userId" => user, "password" => password, @@ -61,52 +96,52 @@ defmodule XtbClient.MainSocket do ping_message = {:ping, {:text, ping_command}, @ping_interval} schedule_work(ping_message, 1) - state = - state - |> Map.delete(:user) - |> Map.delete(:password) - {:ok, state} end + @impl WebSockex + def handle_disconnect(_connection_status_map, state) do + {:reconnect, state} + end + defp schedule_work(message, interval) do Process.send_after(self(), message, interval) end @doc """ Casts query to get streaming session ID. - + ## Arguments - `server` pid of the main socket process, - `caller` pid of the caller awaiting for the result. - + Result of the query will be delivered to message mailbox of the `caller` process. """ - @spec stream_session_id(client(), client()) :: :ok + @spec stream_session_id(GenServer.server(), GenServer.server()) :: :ok def stream_session_id(server, caller) do WebSockex.cast(server, {:stream_session_id, caller}) end @doc """ Casts query to get data from the backend server. - + Might be also used to send command to the backend server. - + ## Arguments - `server` pid of the main socket process, - `caller` pid of the caller awaiting for the result, - `ref` unique reference of the query, - `method` name of the query method, - `params` [optional] arguments of the `method`. - + Result of the query will be delivered to message mailbox of the `caller` process. """ - @spec query(client(), client(), term(), binary()) :: :ok + @spec query(GenServer.server(), GenServer.server(), term(), String.t()) :: :ok def query(server, caller, ref, method) do WebSockex.cast(server, {:query, {caller, ref, method}}) end - @spec query(client(), client(), term(), binary(), map()) :: :ok + @spec query(GenServer.server(), GenServer.server(), term(), String.t(), map()) :: :ok def query(server, caller, ref, method, params) do WebSockex.cast(server, {:query, {caller, ref, method, params}}) end @@ -114,7 +149,7 @@ defmodule XtbClient.MainSocket do @impl WebSockex def handle_cast( {:stream_session_id, caller}, - %{stream_session_id: result} = state + %State{stream_session_id: result} = state ) do GenServer.cast(caller, {:stream_session_id, result}) @@ -124,17 +159,18 @@ defmodule XtbClient.MainSocket do @impl WebSockex def handle_cast( {:query, {caller, ref, method}}, - %{queries: queries, last_query: last_query} = state + %State{queries: queries, rate_limit: rate_limit} = state ) do - last_query = check_rate(last_query, actual_rate()) + rate_limit = RateLimit.check_rate(rate_limit) message = encode_command(method, ref) queries = Map.put(queries, ref, {:query, caller, ref, method}) - state = + state = %{ state - |> Map.put(:queries, queries) - |> Map.put(:last_query, last_query) + | queries: queries, + rate_limit: rate_limit + } {:reply, {:text, message}, state} end @@ -142,17 +178,18 @@ defmodule XtbClient.MainSocket do @impl WebSockex def handle_cast( {:query, {caller, ref, method, params}}, - %{queries: queries, last_query: last_query} = state + %State{queries: queries, rate_limit: rate_limit} = state ) do - last_query = check_rate(last_query, actual_rate()) + rate_limit = RateLimit.check_rate(rate_limit) message = encode_command(method, params, ref) queries = Map.put(queries, ref, {:query, caller, ref, method}) - state = + state = %{ state - |> Map.put(:queries, queries) - |> Map.put(:last_query, last_query) + | queries: queries, + rate_limit: rate_limit + } {:reply, {:text, message}, state} end @@ -162,24 +199,6 @@ defmodule XtbClient.MainSocket do {:reply, frame, state} end - defp check_rate(prev_rate_ms, actual_rate_ms) do - rate_diff = actual_rate_ms - prev_rate_ms - - case rate_diff > @rate_limit_interval do - true -> - actual_rate_ms - - false -> - Process.sleep(rate_diff) - actual_rate() - end - end - - defp actual_rate() do - DateTime.utc_now() - |> DateTime.to_unix(:millisecond) - end - defp encode_command(type) do Jason.encode!(%{ command: type @@ -216,19 +235,19 @@ defmodule XtbClient.MainSocket do defp handle_response( %{"status" => true, "returnData" => data, "customTag" => ref}, - %{queries: queries} = state + %State{queries: queries} = state ) do {{:query, caller, ^ref, method}, queries} = Map.pop(queries, ref) result = Messages.decode_message(method, data) GenServer.cast(caller, {:response, ref, result}) - state = Map.put(state, :queries, queries) + state = %{state | queries: queries} {:ok, state} end defp handle_response(%{"status" => true, "streamSessionId" => stream_session_id}, state) do - state = Map.put_new(state, :stream_session_id, stream_session_id) + state = %{state | stream_session_id: stream_session_id} {:ok, state} end diff --git a/lib/xtb_client/messages/balance_info.ex b/lib/xtb_client/messages/balance_info.ex index 4ef94f4..15f36b9 100644 --- a/lib/xtb_client/messages/balance_info.ex +++ b/lib/xtb_client/messages/balance_info.ex @@ -1,7 +1,7 @@ defmodule XtbClient.Messages.BalanceInfo do @moduledoc """ Info about current account indicators. - + ## Parameters - `balance` balance in account currency, - `cash_stock_value` value of stock in cash, @@ -14,7 +14,7 @@ defmodule XtbClient.Messages.BalanceInfo do - `margin_level` margin level percentage, - `stock_lock` stock lock, - `stock_value` stock value. - + ## Handled Api methods - `getBalance` - `getMarginLevel` @@ -24,7 +24,7 @@ defmodule XtbClient.Messages.BalanceInfo do balance: float(), cash_stock_value: float(), credit: float(), - currency: binary(), + currency: String.t(), equity: float(), equity_fx: float(), margin: float(), diff --git a/lib/xtb_client/messages/calendar_info.ex b/lib/xtb_client/messages/calendar_info.ex index e5ba807..734b931 100644 --- a/lib/xtb_client/messages/calendar_info.ex +++ b/lib/xtb_client/messages/calendar_info.ex @@ -1,7 +1,7 @@ defmodule XtbClient.Messages.CalendarInfo do @moduledoc """ Calendar event. - + ## Parameters - `country` two letter country code, - `current` market value (current), empty before time of release of this value (time from "time" record), @@ -14,14 +14,14 @@ defmodule XtbClient.Messages.CalendarInfo do """ @type t :: %__MODULE__{ - country: binary(), - current: binary(), - forecast: binary(), - impact: binary(), - period: binary(), - previous: binary(), + country: String.t(), + current: String.t(), + forecast: String.t(), + impact: String.t(), + period: String.t(), + previous: String.t(), time: DateTime.t(), - title: binary() + title: String.t() } @enforce_keys [:country, :current, :forecast, :impact, :period, :previous, :time, :title] diff --git a/lib/xtb_client/messages/candle.ex b/lib/xtb_client/messages/candle.ex index 1af7257..33309b0 100644 --- a/lib/xtb_client/messages/candle.ex +++ b/lib/xtb_client/messages/candle.ex @@ -3,9 +3,9 @@ defmodule XtbClient.Messages.Candle do @moduledoc """ Info representing aggregated price & volume values for candle. - + Default interval for one candle is one minute. - + ## Parameters - `open` open price in base currency, - `high` highest value in the given period in base currency, @@ -25,9 +25,9 @@ defmodule XtbClient.Messages.Candle do close: float(), vol: float(), ctm: DateTime.t(), - ctm_string: binary(), + ctm_string: String.t(), quote_id: QuoteId.t(), - symbol: binary() + symbol: String.t() } @enforce_keys [ diff --git a/lib/xtb_client/messages/candles.ex b/lib/xtb_client/messages/candles.ex index d19710b..70689e3 100644 --- a/lib/xtb_client/messages/candles.ex +++ b/lib/xtb_client/messages/candles.ex @@ -2,13 +2,13 @@ defmodule XtbClient.Messages.Candles do defmodule Query do @moduledoc """ Info about query for candles. - + ## Parameters - `symbol` symbol name. """ @type t :: %__MODULE__{ - symbol: binary() + symbol: String.t() } @enforce_keys [:symbol] @@ -25,9 +25,9 @@ defmodule XtbClient.Messages.Candles do @moduledoc """ Query result for `XtbClient.Messages.Candle`s. - + Returns one `XtbClient.Messages.Candle` at a time. - + ## Handled Api methods - `getCandles` """ diff --git a/lib/xtb_client/messages/chart_last.ex b/lib/xtb_client/messages/chart_last.ex index 6a126af..bc9d21d 100644 --- a/lib/xtb_client/messages/chart_last.ex +++ b/lib/xtb_client/messages/chart_last.ex @@ -4,7 +4,7 @@ defmodule XtbClient.Messages.ChartLast do @moduledoc """ Parameters for last chart query. - + ## Parameters - `period` an atom of `XtbClient.Messages.Period` type, describing the time interval for the query - `start` start of chart block (rounded down to the nearest interval and excluding) @@ -14,7 +14,7 @@ defmodule XtbClient.Messages.ChartLast do @type t :: %__MODULE__{ period: Period.minute_period(), start: integer(), - symbol: binary() + symbol: String.t() } @enforce_keys [:period, :start, :symbol] diff --git a/lib/xtb_client/messages/chart_range.ex b/lib/xtb_client/messages/chart_range.ex index 6646c88..2e7a9c6 100644 --- a/lib/xtb_client/messages/chart_range.ex +++ b/lib/xtb_client/messages/chart_range.ex @@ -4,14 +4,14 @@ defmodule XtbClient.Messages.ChartRange do @moduledoc """ Parameters for chart range query. - + ## Parameters - `start` start of chart block (rounded down to the nearest interval and excluding), - `end` end of chart block (rounded down to the nearest interval and excluding), - `period` period, see `XtbClient.Messages.Period`, - `symbol` symbol name, - `ticks` number of ticks needed, this field is optional, please read the description below. - + ## Ticks Ticks field - if ticks is not set or value is `0`, `getChartRangeRequest` works as before (you must send valid start and end time fields). If ticks value is not equal to `0`, field end is ignored. @@ -24,7 +24,7 @@ defmodule XtbClient.Messages.ChartRange do start: integer(), end: integer(), period: Period.t(), - symbol: binary(), + symbol: String.t(), ticks: integer() } diff --git a/lib/xtb_client/messages/news_info.ex b/lib/xtb_client/messages/news_info.ex index 24489a6..e3e777b 100644 --- a/lib/xtb_client/messages/news_info.ex +++ b/lib/xtb_client/messages/news_info.ex @@ -1,7 +1,7 @@ defmodule XtbClient.Messages.NewsInfo do @moduledoc """ Info about recent news. - + ## Properties - `body` body of message, - `body_length` body length, @@ -12,12 +12,12 @@ defmodule XtbClient.Messages.NewsInfo do """ @type t :: %__MODULE__{ - body: binary(), + body: String.t(), body_length: integer(), - key: binary(), + key: String.t(), time: DateTime.t(), - time_string: binary(), - title: binary() + time_string: String.t(), + title: String.t() } @enforce_keys [:body, :body_length, :key, :time, :time_string, :title] diff --git a/lib/xtb_client/messages/profit_calculation.ex b/lib/xtb_client/messages/profit_calculation.ex index 15f32d6..812b63d 100644 --- a/lib/xtb_client/messages/profit_calculation.ex +++ b/lib/xtb_client/messages/profit_calculation.ex @@ -4,7 +4,7 @@ defmodule XtbClient.Messages.ProfitCalculation do @moduledoc """ Info about query for calculation of profit. - + ## Parameters - `closePrice` theoretical close price of order, - `cmd` operation code, see `XtbClient.Messages.Operation`, @@ -17,7 +17,7 @@ defmodule XtbClient.Messages.ProfitCalculation do closePrice: float(), cmd: Operation.t(), openPrice: float(), - symbol: binary(), + symbol: String.t(), volume: float() } @@ -51,10 +51,10 @@ defmodule XtbClient.Messages.ProfitCalculation do @moduledoc """ Query result for profit calculation. - + ## Parameters - `profit` profit in account currency. - + ## Handled Api methods - `getProfitCalculation` """ diff --git a/lib/xtb_client/messages/quotations.ex b/lib/xtb_client/messages/quotations.ex index 5e78259..6a03e2d 100644 --- a/lib/xtb_client/messages/quotations.ex +++ b/lib/xtb_client/messages/quotations.ex @@ -2,7 +2,7 @@ defmodule XtbClient.Messages.Quotations do defmodule Query do @moduledoc """ Info about query for tick prices. - + ## Parameters - `symbol` symbol name, - `minArrivalTime` this field is optional and defines the minimal interval in milliseconds between any two consecutive updates. @@ -13,7 +13,7 @@ defmodule XtbClient.Messages.Quotations do """ @type t :: %__MODULE__{ - symbol: binary(), + symbol: String.t(), minArrivalTime: integer(), maxLevel: integer() } @@ -52,7 +52,7 @@ defmodule XtbClient.Messages.Quotations do @moduledoc """ Query result for list of `XtbClient.Messages.TickPrice`s. - + ## Handled Api methods - `getTickPrices` """ diff --git a/lib/xtb_client/messages/server_time.ex b/lib/xtb_client/messages/server_time.ex index 741a0f8..c2d3db6 100644 --- a/lib/xtb_client/messages/server_time.ex +++ b/lib/xtb_client/messages/server_time.ex @@ -1,18 +1,18 @@ defmodule XtbClient.Messages.ServerTime do @moduledoc """ Info about current time on trading server. - + ## Parameters - `time` actual time on server, - `time_string` string version of `time` value. - + ## Handled Api methods - `getServerTime` """ @type t :: %__MODULE__{ time: DateTime.t(), - time_string: binary() + time_string: String.t() } @enforce_keys [:time, :time_string] diff --git a/lib/xtb_client/messages/step_rule.ex b/lib/xtb_client/messages/step_rule.ex index 3a5bcc5..1dfb1ac 100644 --- a/lib/xtb_client/messages/step_rule.ex +++ b/lib/xtb_client/messages/step_rule.ex @@ -1,7 +1,7 @@ defmodule XtbClient.Messages.StepRule do @moduledoc """ Info about step rule definition. - + ## Parameters - `id` step rule ID, - `name` step rule name, @@ -12,7 +12,7 @@ defmodule XtbClient.Messages.StepRule do @type t :: %__MODULE__{ id: integer(), - name: binary(), + name: String.t(), steps: [Step.t()] } diff --git a/lib/xtb_client/messages/symbol_info.ex b/lib/xtb_client/messages/symbol_info.ex index 851a517..2598311 100644 --- a/lib/xtb_client/messages/symbol_info.ex +++ b/lib/xtb_client/messages/symbol_info.ex @@ -2,13 +2,13 @@ defmodule XtbClient.Messages.SymbolInfo do defmodule Query do @moduledoc """ Info about the query for symbol info. - + ## Parameters - `symbol` symbol name. """ @type t :: %__MODULE__{ - symbol: binary() + symbol: String.t() } @enforce_keys [:symbol] @@ -28,9 +28,9 @@ defmodule XtbClient.Messages.SymbolInfo do @moduledoc """ Information relevant to the symbol of security. - + Please be advised that result values for profit and margin calculation can be used optionally, because server is able to perform all profit/margin calculations for Client application by commands described later in this document. - + ## Parameters - `ask` ask price in base currency, - `bid` bid price in base currency, @@ -78,7 +78,7 @@ defmodule XtbClient.Messages.SymbolInfo do - `time_string` time in string, - `trailing_enabled` indicates whether trailing stop (offset) is applicable to the instrument, - `type` instrument class number. - + ## Handled Api methods - `getSymbol` """ @@ -86,14 +86,14 @@ defmodule XtbClient.Messages.SymbolInfo do @type t :: %__MODULE__{ ask: float(), bid: float(), - category_name: binary(), + category_name: String.t(), contract_size: integer(), - currency: binary(), + currency: String.t(), currency_pair: true | false, - currency_profit: binary(), - description: binary(), + currency_profit: String.t(), + description: String.t(), expiration: DateTime.t() | nil, - group_name: binary(), + group_name: String.t(), high: float(), initial_margin: integer(), instant_max_volume: integer(), @@ -123,11 +123,11 @@ defmodule XtbClient.Messages.SymbolInfo do swap_long: float(), swap_short: float(), swap_type: integer(), - symbol: binary(), + symbol: String.t(), tick_size: float(), tick_value: float(), time: DateTime.t(), - time_string: binary(), + time_string: String.t(), trailing_enabled: true | false, type: integer() } diff --git a/lib/xtb_client/messages/symbol_volume.ex b/lib/xtb_client/messages/symbol_volume.ex index 97c8b9e..2dd5dc8 100644 --- a/lib/xtb_client/messages/symbol_volume.ex +++ b/lib/xtb_client/messages/symbol_volume.ex @@ -1,14 +1,14 @@ defmodule XtbClient.Messages.SymbolVolume do @moduledoc """ Info about symbol ticker + volume. - + ## Parameters - `symbol` symbol name, - `volume` volume in lots. """ @type t :: %__MODULE__{ - symbol: binary(), + symbol: String.t(), volume: float() } diff --git a/lib/xtb_client/messages/tick_price.ex b/lib/xtb_client/messages/tick_price.ex index a74d788..cd7ca65 100644 --- a/lib/xtb_client/messages/tick_price.ex +++ b/lib/xtb_client/messages/tick_price.ex @@ -3,7 +3,7 @@ defmodule XtbClient.Messages.TickPrice do @moduledoc """ Info about one tick of price. - + ## Parameters - `ask` ask price in base currency, - `ask_volume` number of available lots to buy at given price or `null` if not applicable @@ -32,7 +32,7 @@ defmodule XtbClient.Messages.TickPrice do quote_id: QuoteId.t() | nil, spread_raw: float(), spread_table: float(), - symbol: binary(), + symbol: String.t(), timestamp: DateTime.t() } diff --git a/lib/xtb_client/messages/tick_prices.ex b/lib/xtb_client/messages/tick_prices.ex index 1d998d6..91dafc6 100644 --- a/lib/xtb_client/messages/tick_prices.ex +++ b/lib/xtb_client/messages/tick_prices.ex @@ -2,7 +2,7 @@ defmodule XtbClient.Messages.TickPrices do defmodule Query do @moduledoc """ Info about the query for tick prices. - + ## Parameters - `level` price level (possible values of level field: -1 => all levels, 0 => base level bid and ask price for instrument, >0 => specified level), - `symbols` array of symbol names, @@ -11,7 +11,7 @@ defmodule XtbClient.Messages.TickPrices do @type t :: %__MODULE__{ level: integer(), - symbols: [binary()], + symbols: [String.t()], timestamp: integer() } @@ -40,10 +40,10 @@ defmodule XtbClient.Messages.TickPrices do @moduledoc """ Query result for list of `XtbClient.Messages.TickPrice`s. - + ## Parameters - `data` array or results. - + ## Handled Api methods - `getTickPrices` """ diff --git a/lib/xtb_client/messages/trade_info.ex b/lib/xtb_client/messages/trade_info.ex index bdb2c44..ae3a5f8 100644 --- a/lib/xtb_client/messages/trade_info.ex +++ b/lib/xtb_client/messages/trade_info.ex @@ -3,7 +3,7 @@ defmodule XtbClient.Messages.TradeInfo do @moduledoc """ Info about the trade that has happened. - + ## Parameters - `close_price` close price in base currency, - `close_time` `null` if order is not closed, @@ -40,9 +40,9 @@ defmodule XtbClient.Messages.TradeInfo do close_time: DateTime.t() | nil, closed: boolean(), operation: integer(), - comment: binary(), + comment: String.t(), commission: float() | nil, - custom_comment: binary() | nil, + custom_comment: String.t() | nil, digits: integer(), expiration: DateTime.t() | nil, margin_rate: float(), @@ -58,7 +58,7 @@ defmodule XtbClient.Messages.TradeInfo do spread: float() | nil, state: integer() | nil, storage: float(), - symbol: binary() | nil, + symbol: String.t() | nil, taxes: float() | nil, timestamp: DateTime.t() | nil, take_profit: float(), diff --git a/lib/xtb_client/messages/trade_infos.ex b/lib/xtb_client/messages/trade_infos.ex index 490c0ce..a690f9b 100644 --- a/lib/xtb_client/messages/trade_infos.ex +++ b/lib/xtb_client/messages/trade_infos.ex @@ -2,13 +2,13 @@ defmodule XtbClient.Messages.TradeInfos do defmodule Query do @moduledoc """ Info about query for trade infos. - + ## Parameters - `orders` array of order IDs. """ @type t :: %__MODULE__{ - orders: [binary()] + orders: [String.t()] } @enforce_keys [:orders] @@ -27,10 +27,10 @@ defmodule XtbClient.Messages.TradeInfos do @moduledoc """ Query result for list of `XtbClient.Messages.TradeInfo`s. - + ## Parameters - `data` array or results. - + ## Handled Api methods - `getTradeRecords` - `getTrades` diff --git a/lib/xtb_client/messages/trade_status.ex b/lib/xtb_client/messages/trade_status.ex index 2782643..8a79c8b 100644 --- a/lib/xtb_client/messages/trade_status.ex +++ b/lib/xtb_client/messages/trade_status.ex @@ -3,21 +3,21 @@ defmodule XtbClient.Messages.TradeStatus do @moduledoc """ Info about the actual status of sent trade request. - + ## Parameters - `custom_comment` the value the customer may provide in order to retrieve it later, - `message` message, can be `null`, - `order` unique order number, - `price` price in base currency, - `status` request status code, see `XtbClient.Messages.TransactionStatus`. - + ## Handled Api methods - `getTradeStatus` """ @type t :: %__MODULE__{ - custom_comment: binary(), - message: binary(), + custom_comment: String.t(), + message: String.t(), order: integer(), price: float(), status: TransactionStatus.t() diff --git a/lib/xtb_client/messages/trade_transaction.ex b/lib/xtb_client/messages/trade_transaction.ex index 76882ff..265551f 100644 --- a/lib/xtb_client/messages/trade_transaction.ex +++ b/lib/xtb_client/messages/trade_transaction.ex @@ -4,7 +4,7 @@ defmodule XtbClient.Messages.TradeTransaction do @moduledoc """ Info about command to trade the transaction. - + ## Parameters - `cmd` operation code, see `XtbClient.Messages.Operation`, - `customComment` the value the customer may provide in order to retrieve it later, @@ -21,14 +21,14 @@ defmodule XtbClient.Messages.TradeTransaction do @type t :: %__MODULE__{ cmd: integer(), - customComment: binary(), + customComment: String.t(), expiration: integer(), offset: integer(), order: integer(), price: float(), sl: float(), tp: float(), - symbol: binary(), + symbol: String.t(), type: integer(), volume: float() } @@ -100,10 +100,10 @@ defmodule XtbClient.Messages.TradeTransaction do @moduledoc """ Info about realized trade transaction. - + ## Parameters - `order` holds info about order number, needed later for verification about order status. - + ## Handled Api methods - `tradeTransaction` """ diff --git a/lib/xtb_client/messages/trade_transaction_status.ex b/lib/xtb_client/messages/trade_transaction_status.ex index b1c41ed..859e1b3 100644 --- a/lib/xtb_client/messages/trade_transaction_status.ex +++ b/lib/xtb_client/messages/trade_transaction_status.ex @@ -2,7 +2,7 @@ defmodule XtbClient.Messages.TradeTransactionStatus do defmodule Query do @moduledoc """ Info about query for trade transaction status. - + ## Parameters - `order` unique order number. """ @@ -27,7 +27,7 @@ defmodule XtbClient.Messages.TradeTransactionStatus do @moduledoc """ Info about the status of particular transaction. - + ## Parameters - `ask` price in base currency, - `bid` price in base currency, @@ -35,7 +35,7 @@ defmodule XtbClient.Messages.TradeTransactionStatus do - `message` can be `null`, - `order` unique order number, - `status` request status code, see `XtbClient.Messages.TradeStatus`. - + ## Handled Api methods - `tradeTransactionStatus` """ @@ -43,8 +43,8 @@ defmodule XtbClient.Messages.TradeTransactionStatus do @type t :: %__MODULE__{ ask: float(), bid: float(), - custom_comment: binary(), - message: binary() | nil, + custom_comment: String.t(), + message: String.t() | nil, order: integer(), status: TransactionStatus.t() } diff --git a/lib/xtb_client/messages/trading_hour.ex b/lib/xtb_client/messages/trading_hour.ex index 72406ed..56f7a64 100644 --- a/lib/xtb_client/messages/trading_hour.ex +++ b/lib/xtb_client/messages/trading_hour.ex @@ -3,7 +3,7 @@ defmodule XtbClient.Messages.TradingHour do @moduledoc """ Info about one available trading hour. - + ## Parameters - `quotes` array of `XtbClient.Messages.Quote`s representing available quotes hours, - `symbol` symbol name, @@ -12,7 +12,7 @@ defmodule XtbClient.Messages.TradingHour do @type t :: %__MODULE__{ quotes: [Quote.t()], - symbol: binary(), + symbol: String.t(), trading: [Quote.t()] } diff --git a/lib/xtb_client/messages/trading_hours.ex b/lib/xtb_client/messages/trading_hours.ex index a5e359e..0f08291 100644 --- a/lib/xtb_client/messages/trading_hours.ex +++ b/lib/xtb_client/messages/trading_hours.ex @@ -2,13 +2,13 @@ defmodule XtbClient.Messages.TradingHours do defmodule Query do @moduledoc """ Info about the query for trading hours. - + ## Parameters - `symbols` array of symbol names. """ @type t :: %__MODULE__{ - symbols: [binary()] + symbols: [String.t()] } @enforce_keys [:symbols] @@ -27,10 +27,10 @@ defmodule XtbClient.Messages.TradingHours do @moduledoc """ Query result for list of `XtbClient.Messages.TradingHour`s. - + ## Parameters - `data` array or results. - + ## Handled Api methods - `getTradingHours` """ diff --git a/lib/xtb_client/messages/user.ex b/lib/xtb_client/messages/user.ex index a230633..22a1915 100644 --- a/lib/xtb_client/messages/user.ex +++ b/lib/xtb_client/messages/user.ex @@ -1,7 +1,7 @@ defmodule XtbClient.Messages.UserInfo do @moduledoc """ Info about the current user. - + ## Parameters - `company_unit` unit the account is assigned to, - `currency` account currency, @@ -10,18 +10,18 @@ defmodule XtbClient.Messages.UserInfo do - `leverage_mult` the factor used for margin calculations, - `spread_type` spread type, `null` if not applicable, - `trailing_stop` indicates whether this account is enabled to use trailing stop. - + ## Handled Api methods - `getCurrentUserData` """ @type t :: %__MODULE__{ company_unit: integer(), - currency: binary(), - group: binary(), + currency: String.t(), + group: String.t(), ib_account: true | false, leverage_mult: float(), - spread_type: binary() | nil, + spread_type: String.t() | nil, trailing_stop: boolean() } diff --git a/lib/xtb_client/messages/version.ex b/lib/xtb_client/messages/version.ex index cbb22ee..4b6529e 100644 --- a/lib/xtb_client/messages/version.ex +++ b/lib/xtb_client/messages/version.ex @@ -1,16 +1,16 @@ defmodule XtbClient.Messages.Version do @moduledoc """ Info about actual version of Api. - + ## Parameters - `version` string version of Api. - + ## Handled Api methods - `getVersion` """ @type t :: %__MODULE__{ - version: binary() + version: String.t() } @enforce_keys [:version] diff --git a/lib/xtb_client/rate_limit.ex b/lib/xtb_client/rate_limit.ex new file mode 100644 index 0000000..74a565f --- /dev/null +++ b/lib/xtb_client/rate_limit.ex @@ -0,0 +1,47 @@ +defmodule XtbClient.RateLimit do + @moduledoc """ + Helper module for handling with rate limits. + """ + + @type t :: %__MODULE__{ + limit_interval: integer(), + time_stamp: integer() + } + + @enforce_keys [:limit_interval, :time_stamp] + defstruct limit_interval: 200, + time_stamp: 0 + + @doc """ + Creates a new rate limit with given limit interval. + """ + @spec new(integer()) :: t() + def new(limit_interval) when is_integer(limit_interval) and limit_interval > 0 do + %__MODULE__{ + limit_interval: limit_interval, + time_stamp: 0 + } + end + + @doc """ + Checks if the rate limit is exceeded and if so, sleeps for the difference. + """ + @spec check_rate(t()) :: t() + def check_rate(%__MODULE__{limit_interval: limit_interval, time_stamp: previous_stamp} = limit) do + current_stamp = actual_rate() + rate_diff = current_stamp - previous_stamp + + case rate_diff > limit_interval do + true -> + %{limit | time_stamp: current_stamp} + + false -> + Process.sleep(rate_diff) + %{limit | time_stamp: actual_rate()} + end + end + + defp actual_rate do + DateTime.to_unix(DateTime.utc_now(), :millisecond) + end +end diff --git a/lib/xtb_client/streaming_message.ex b/lib/xtb_client/streaming_message.ex index c102719..5398f76 100644 --- a/lib/xtb_client/streaming_message.ex +++ b/lib/xtb_client/streaming_message.ex @@ -1,11 +1,16 @@ defmodule XtbClient.StreamingMessage do + @moduledoc """ + Helper module for encoding and decoding streaming messages. + """ + @type t :: %__MODULE__{ - method: binary(), - response_method: binary(), + method: String.t(), + response_method: String.t(), params: map() | nil } - @type token :: {:method, binary()} | {:hashed_params, binary(), binary()} + @type token :: {:method, String.t()} | {:hashed_params, String.t(), String.t()} + @enforce_keys [:method, :response_method, :params] defstruct method: "", response_method: "", params: nil diff --git a/lib/xtb_client/streaming_socket.ex b/lib/xtb_client/streaming_socket.ex index 50c31d8..40b945b 100644 --- a/lib/xtb_client/streaming_socket.ex +++ b/lib/xtb_client/streaming_socket.ex @@ -1,48 +1,73 @@ defmodule XtbClient.StreamingSocket do + @moduledoc """ + WebSocket server used for asynchronous communication. + + `StreamingSocket` is being used like standard `GenServer` - could be started with `start_link/1` and supervised. + + After successful connection to WebSocket the flow is: + - process schedules to itself the `ping` command (with recurring interval) - to maintain persistent connection with backend. + """ use WebSockex alias XtbClient.{AccountType, StreamingMessage} alias XtbClient.Messages + alias XtbClient.RateLimit require Logger @ping_interval 30 * 1000 - @rate_limit_interval 200 - @type client :: atom | pid | {atom, any} | {:via, atom, any} + defmodule Config do + @type t :: %{ + :url => String.t() | URI.t(), + :type => AccountType.t(), + :stream_session_id => String.t() + } + + def parse(opts) do + type = AccountType.format_streaming(get_in(opts, [:type])) + + %{ + url: get_in(opts, [:url]) |> URI.merge(type) |> URI.to_string(), + type: type, + stream_session_id: get_in(opts, [:stream_session_id]) + } + end + end - @moduledoc """ - WebSocket server used for asynchronous communication. - - `StreamingSocket` is being used like standard `GenServer` - could be started with `start_link/1` and supervised. - - After successful connection to WebSocket the flow is: - - process schedules to itself the `ping` command (with recurring interval) - to maintain persistent connection with backend. - """ + defmodule State do + @enforce_keys [ + :url, + :stream_session_id, + :subscriptions, + :rate_limit + ] + defstruct url: nil, + stream_session_id: nil, + subscriptions: %{}, + rate_limit: nil + end @doc """ Starts a `XtbClient.StreamingSocket` process linked to the calling process. """ - @spec start_link(%{ - :stream_session_id => binary(), - :type => AccountType.t(), - :url => binary | URI.t(), - optional(any) => any - }) :: GenServer.on_start() - def start_link(%{url: url, type: type, stream_session_id: _stream_session_id} = state) do - account_type = AccountType.format_streaming(type) - uri = URI.merge(url, account_type) |> URI.to_string() - - state = - state - |> Map.put(:last_sub, actual_rate()) - |> Map.put(:subscriptions, %{}) - - WebSockex.start_link(uri, __MODULE__, state) + @spec start_link(Config.t()) :: GenServer.on_start() + def start_link(opts) do + %{url: url, stream_session_id: stream_session_id} = + Config.parse(opts) + + state = %State{ + url: url, + stream_session_id: stream_session_id, + subscriptions: %{}, + rate_limit: RateLimit.new(200) + } + + WebSockex.start_link(url, __MODULE__, state) end @impl WebSockex - def handle_connect(_conn, %{stream_session_id: stream_session_id} = state) do + def handle_connect(_conn, %State{stream_session_id: stream_session_id} = state) do ping_command = encode_streaming_command({"ping", nil}, stream_session_id) ping_message = {:ping, {:text, ping_command}, @ping_interval} schedule_work(ping_message, 1) @@ -56,15 +81,15 @@ defmodule XtbClient.StreamingSocket do @doc """ Subscribes `pid` process for messages from `method` query. - + ## Arguments - `server` pid of the streaming socket process, - `caller` pid of the caller awaiting for the result, - `message` struct with call context, see `XtbClient.StreamingMessage`. - + Result of the query will be delivered to message mailbox of the `caller` process. """ - @spec subscribe(client(), client(), StreamingMessage.t()) :: :ok + @spec subscribe(GenServer.server(), GenServer.server(), StreamingMessage.t()) :: :ok def subscribe( server, caller, @@ -82,9 +107,14 @@ defmodule XtbClient.StreamingSocket do response_method: response_method, params: params } = message}}, - %{subscriptions: subscriptions, last_sub: last_sub, stream_session_id: session_id} = state + %State{ + subscriptions: subscriptions, + rate_limit: rate_limit, + stream_session_id: session_id + } = + state ) do - last_sub = check_rate(last_sub, actual_rate()) + rate_limit = RateLimit.check_rate(rate_limit) token = StreamingMessage.encode_token(message) @@ -98,34 +128,12 @@ defmodule XtbClient.StreamingSocket do end ) - state = - state - |> Map.put(:subscriptions, subscriptions) - |> Map.put(:last_sub, last_sub) - encoded_message = encode_streaming_command({method, params}, session_id) + state = %{state | subscriptions: subscriptions, rate_limit: rate_limit} {:reply, {:text, encoded_message}, state} end - defp check_rate(prev_rate_ms, actual_rate_ms) do - rate_diff = actual_rate_ms - prev_rate_ms - - case rate_diff > @rate_limit_interval do - true -> - actual_rate_ms - - false -> - Process.sleep(rate_diff) - actual_rate() - end - end - - defp actual_rate() do - DateTime.utc_now() - |> DateTime.to_unix(:millisecond) - end - defp encode_streaming_command({method, nil}, streaming_session_id) do Jason.encode!(%{ command: method, @@ -150,13 +158,14 @@ defmodule XtbClient.StreamingSocket do defp handle_response( %{"command" => response_method, "data" => data}, - %{subscriptions: subscriptions} = state + %State{subscriptions: subscriptions} = state ) do {method, method_subs} = Map.get(subscriptions, response_method) result = Messages.decode_message(method, data) token = - StreamingMessage.new(method, response_method, result) + method + |> StreamingMessage.new(response_method, result) |> StreamingMessage.encode_token() caller = Map.get(method_subs, token) diff --git a/mix.exs b/mix.exs index f53cbbe..211b4bb 100644 --- a/mix.exs +++ b/mix.exs @@ -7,16 +7,26 @@ defmodule XtbClient.MixProject do name: "XtbClient", version: "0.1.1", elixir: "~> 1.12", + elixirc_paths: elixirc_paths(Mix.env()), description: "Elixir client for the XTB trading platform", source_url: "https://github.com/dsienkiewicz/xtb_client_ex", start_permanent: Mix.env() == :prod, package: package(), aliases: aliases(), + dialyzer: dialyzer(), deps: deps(), docs: docs() ] end + defp dialyzer do + [ + plt_add_apps: [:mix, :ex_unit], + plt_core_path: "priv/plts", + plt_file: {:no_warn, "priv/plts/dialyzer.plt"} + ] + end + # Run "mix help compile.app" to learn about applications. def application do [ @@ -25,13 +35,21 @@ defmodule XtbClient.MixProject do ] end + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + # Run "mix help deps" to learn about dependencies. defp deps do [ {:jason, "~> 1.3"}, {:websockex, "~> 0.4.3"}, + + # Dev & test only {:ex_doc, "~> 0.27", only: :dev, runtime: false}, - {:dotenvy, "~> 0.6.0", only: [:dev, :test]} + {:dotenvy, "~> 0.6.0", only: [:dev, :test]}, + {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} ] end diff --git a/mix.lock b/mix.lock index 21fc83e..266cb30 100644 --- a/mix.lock +++ b/mix.lock @@ -1,8 +1,13 @@ %{ + "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, + "credo": {:hex, :credo, "1.7.1", "6e26bbcc9e22eefbff7e43188e69924e78818e2fe6282487d0703652bc20fd62", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e9871c6095a4c0381c89b6aa98bc6260a8ba6addccf7f6a53da8849c748a58a2"}, + "dialyxir": {:hex, :dialyxir, "1.4.2", "764a6e8e7a354f0ba95d58418178d486065ead1f69ad89782817c296d0d746a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "516603d8067b2fd585319e4b13d3674ad4f314a5902ba8130cd97dc902ce6bbd"}, "dotenv": {:hex, :dotenv, "3.0.0", "52a28976955070d8312a81d59105b57ecf5d6a755c728b49c70a7e2120e6cb40", [:mix], [], "hexpm", "f8a7d800b6b419a8d8a8bc5b5cd820a181c2b713aab7621794febe934f7bd84e"}, "dotenvy": {:hex, :dotenvy, "0.6.0", "3a724a214e246a3390a40faa2b49f84d37cc399a9a7d49e6e1d8c8d0167e9905", [:mix], [], "hexpm", "342034a70c85eb21301d8e3e08a4483517f7329879b9112e546057857f938d7d"}, "earmark_parser": {:hex, :earmark_parser, "1.4.25", "2024618731c55ebfcc5439d756852ec4e85978a39d0d58593763924d9a15916f", [:mix], [], "hexpm", "56749c5e1c59447f7b7a23ddb235e4b3defe276afc220a6227237f3efe83f51e"}, + "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ex_doc": {:hex, :ex_doc, "0.28.3", "6eea2f69995f5fba94cd6dd398df369fe4e777a47cd887714a0976930615c9e6", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "05387a6a2655b5f9820f3f627450ed20b4325c25977b2ee69bed90af6688e718"}, + "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, diff --git a/test/xtb_client/transaction_helper.exs b/test/support/fixtures/transaction_helper.ex similarity index 97% rename from test/xtb_client/transaction_helper.exs rename to test/support/fixtures/transaction_helper.ex index f471aa7..b68b240 100644 --- a/test/xtb_client/transaction_helper.exs +++ b/test/support/fixtures/transaction_helper.ex @@ -1,4 +1,5 @@ defmodule XtbClient.TransactionHelper do + @moduledoc false alias XtbClient.Connection alias XtbClient.Messages.{ diff --git a/test/xtb_client/connection_test.exs b/test/xtb_client/connection_test.exs index 25e2e0b..cb1e085 100644 --- a/test/xtb_client/connection_test.exs +++ b/test/xtb_client/connection_test.exs @@ -1,6 +1,5 @@ -Code.require_file("transaction_helper.exs", __DIR__) - defmodule XtbClient.ConnectionTest do + @moduledoc false use ExUnit.Case, async: true doctest XtbClient.Connection @@ -46,11 +45,11 @@ defmodule XtbClient.ConnectionTest do Version } - alias XtbClient.TransactionHelper + import XtbClient.TransactionHelper @default_wait_time 60 * 1000 - setup_all do + setup do Dotenvy.source([ ".env.#{Mix.env()}", ".env.#{Mix.env()}.override", @@ -61,18 +60,26 @@ defmodule XtbClient.ConnectionTest do user = Dotenvy.env!("XTB_API_USERNAME", :string!) passwd = Dotenvy.env!("XTB_API_PASSWORD", :string!) - params = %{ - url: url, - user: user, - password: passwd, - type: :demo, - app_name: "XtbClient" - } + params = [ + connection: %{ + url: url, + user: user, + password: passwd, + type: :demo, + app_name: "XtbClient" + } + ] + + {:ok, pid} = start_supervised({Connection, params}) + + {:ok, %{params: params, pid: pid}} + end + test "starts new connection with a name", %{params: params} do + params = params |> Keyword.put(:name, :test_connection) {:ok, pid} = Connection.start_link(params) - # :sys.trace(pid, true) - {:ok, %{pid: pid}} + assert Process.whereis(:test_connection) == pid end test "get all symbols", %{pid: pid} do @@ -283,7 +290,7 @@ defmodule XtbClient.ConnectionTest do # needs some time for server to process order correctly Process.sleep(100) - # 1. way - get all opened only trades + # get all opened only trades trades_query = Trades.Query.new(true) result = Connection.get_trades(pid, trades_query) @@ -293,12 +300,6 @@ defmodule XtbClient.ConnectionTest do result.data |> Enum.find(&(&1.order_closed == open_order_id)) - # 2. way - get trades by position IDs - trades_records_query = TradeInfos.Query.new([position_to_close.position]) - result = Connection.get_trade_records(pid, trades_records_query) - - assert %TradeInfos{} = result - close_args = %{ operation: :buy, custom_comment: "Close transaction", @@ -314,9 +315,6 @@ defmodule XtbClient.ConnectionTest do assert %TradeTransaction{} = result - # needs some time for server to process order correctly - Process.sleep(100) - close_order_id = result.order status = TradeTransactionStatus.Query.new(close_order_id) result = Connection.trade_transaction_status(pid, status) @@ -341,13 +339,12 @@ defmodule XtbClient.ConnectionTest do open_order_id = result.order # needs some time for server to process order correctly - Process.sleep(1000) + Process.sleep(100) - # real test scneario + # real test scenario Connection.subscribe_get_balance(pid, self()) - Process.sleep(2 * 1000) - # 1. way - get all opened only trades + # get all opened only trades trades_query = Trades.Query.new(true) result = Connection.get_trades(pid, trades_query) @@ -373,13 +370,13 @@ defmodule XtbClient.ConnectionTest do assert %TradeTransaction{} = result end - @tag timeout: 2 * @default_wait_time + @tag timeout: @default_wait_time test "subscribe to get candles", %{pid: pid} do - args = "BITCOIN" + args = "LITECOIN" query = Candles.Query.new(args) Connection.subscribe_get_candles(pid, self(), query) - assert_receive {:ok, %Candle{}}, 2 * @default_wait_time + assert_receive {:ok, %Candle{}}, @default_wait_time end test "subscribe to keep alive", %{pid: pid} do @@ -388,16 +385,15 @@ defmodule XtbClient.ConnectionTest do assert_receive {:ok, %KeepAlive{}}, @default_wait_time end - @tag timeout: 2 * @default_wait_time, skip: true + @tag skip: true test "subscribe to get news", %{pid: pid} do Connection.subscribe_get_news(pid, self()) - assert_receive {:ok, %NewsInfo{}}, 2 * @default_wait_time + assert_receive {:ok, %NewsInfo{}}, @default_wait_time end test "subscribe to get profits", %{pid: pid} do Connection.subscribe_get_profits(pid, self()) - Process.sleep(2 * 1000) buy_args = %{ operation: :buy, @@ -408,7 +404,7 @@ defmodule XtbClient.ConnectionTest do volume: 0.5 } - order_id = TransactionHelper.open_trade(pid, buy_args) + order_id = open_trade(pid, buy_args) assert_receive {:ok, %ProfitInfo{}}, @default_wait_time @@ -420,10 +416,7 @@ defmodule XtbClient.ConnectionTest do volume: 0.5 } - # wait for some ticks - Process.sleep(2 * 1000) - - TransactionHelper.close_trade(pid, order_id, close_args) + close_trade(pid, order_id, close_args) assert_receive {:ok, %ProfitInfo{}}, @default_wait_time end @@ -438,7 +431,6 @@ defmodule XtbClient.ConnectionTest do test "subscribe to get trades", %{pid: pid} do Connection.subscribe_get_trades(pid, self()) - Process.sleep(2 * 1000) buy_args = %{ operation: :buy, @@ -449,7 +441,7 @@ defmodule XtbClient.ConnectionTest do volume: 0.5 } - order_id = TransactionHelper.open_trade(pid, buy_args) + order_id = open_trade(pid, buy_args) assert_receive {:ok, %TradeInfo{}}, @default_wait_time @@ -461,17 +453,13 @@ defmodule XtbClient.ConnectionTest do volume: 0.5 } - # wait for some ticks - Process.sleep(2 * 1000) - - TransactionHelper.close_trade(pid, order_id, close_args) + close_trade(pid, order_id, close_args) assert_receive {:ok, %TradeInfo{}}, @default_wait_time end test "subscribe to trade status", %{pid: pid} do Connection.subscribe_get_trade_status(pid, self()) - Process.sleep(2 * 1000) buy_args = %{ operation: :buy, @@ -482,7 +470,7 @@ defmodule XtbClient.ConnectionTest do volume: 0.5 } - order_id = TransactionHelper.open_trade(pid, buy_args) + order_id = open_trade(pid, buy_args) assert_receive {:ok, %TradeStatus{}}, @default_wait_time @@ -494,10 +482,7 @@ defmodule XtbClient.ConnectionTest do volume: 0.5 } - # wait for some ticks - Process.sleep(2 * 1000) - - TransactionHelper.close_trade(pid, order_id, close_args) + close_trade(pid, order_id, close_args) assert_receive {:ok, %TradeStatus{}}, @default_wait_time end diff --git a/test/xtb_client/main_socket_test.exs b/test/xtb_client/main_socket_test.exs index 92db859..cac4ed0 100644 --- a/test/xtb_client/main_socket_test.exs +++ b/test/xtb_client/main_socket_test.exs @@ -4,7 +4,7 @@ defmodule XtbClient.MainSocketTest do alias XtbClient.MainSocket - setup_all do + setup do Dotenvy.source([ ".env.#{Mix.env()}", ".env.#{Mix.env()}.override", @@ -23,23 +23,23 @@ defmodule XtbClient.MainSocketTest do app_name: "XtbClient" } - {:ok, params} + {:ok, %{params: params}} end - test "logs in to account", context do - {:ok, pid} = MainSocket.start_link(context) + test "logs in to account", %{params: params} do + {:ok, pid} = MainSocket.start_link(params) - Process.sleep(1_000) + Process.sleep(100) MainSocket.stream_session_id(pid, self()) assert_receive {:"$gen_cast", {:stream_session_id, _}} end - @tag timeout: 2 * 30 * 1000 - test "sends ping after login", context do - {:ok, pid} = MainSocket.start_link(context) + @tag timeout: 40 * 1000 + test "sends ping after login", %{params: params} do + {:ok, pid} = MainSocket.start_link(params) - Process.sleep(2 * 29 * 1000) + Process.sleep(30 * 1000 + 1) assert Process.alive?(pid) == true end diff --git a/test/xtb_client/rate_limit_test.exs b/test/xtb_client/rate_limit_test.exs new file mode 100644 index 0000000..f6889b0 --- /dev/null +++ b/test/xtb_client/rate_limit_test.exs @@ -0,0 +1,44 @@ +defmodule XtbClient.RateLimitTest do + @moduledoc false + use ExUnit.Case, async: true + + alias XtbClient.RateLimit + + test "creates limit from positive integer interval" do + sut = RateLimit.new(200) + assert %{limit_interval: 200, time_stamp: 0} = sut + + assert_raise FunctionClauseError, fn -> + RateLimit.new(-1) + end + end + + test "checks rate" do + sut = + RateLimit.new(200) + |> Map.put(:time_stamp, DateTime.to_unix(DateTime.utc_now(), :millisecond)) + + Process.sleep(250) + + current_stamp = DateTime.to_unix(DateTime.utc_now(), :millisecond) + sut = RateLimit.check_rate(sut) + + assert %{limit_interval: 200, time_stamp: time_stamp} = sut + assert time_stamp >= current_stamp + assert time_stamp - current_stamp <= 50 + end + + test "checks rate and sleeps" do + sut = + RateLimit.new(200) + |> Map.put(:time_stamp, DateTime.to_unix(DateTime.utc_now(), :millisecond)) + + current_stamp = DateTime.to_unix(DateTime.utc_now(), :millisecond) + sut = RateLimit.check_rate(sut) + finish_stamp = DateTime.to_unix(DateTime.utc_now(), :millisecond) + + assert %{limit_interval: 200, time_stamp: time_stamp} = sut + assert time_stamp >= current_stamp + assert finish_stamp - current_stamp <= 200 + end +end diff --git a/test/xtb_client/streaming_socket_test.exs b/test/xtb_client/streaming_socket_test.exs index 55b767a..f6e189e 100644 --- a/test/xtb_client/streaming_socket_test.exs +++ b/test/xtb_client/streaming_socket_test.exs @@ -1,11 +1,12 @@ defmodule XtbClient.StreamingSocketTest do + @moduledoc false use ExUnit.Case, async: true doctest XtbClient.StreamingSocket alias XtbClient.MainSocket alias XtbClient.StreamingSocket - setup_all do + setup do Dotenvy.source([ ".env.#{Mix.env()}", ".env.#{Mix.env()}.override", @@ -25,22 +26,21 @@ defmodule XtbClient.StreamingSocketTest do app_name: "XtbClient" } - {:ok, mpid} = MainSocket.start_link(params) - - Process.sleep(1_000) + {:ok, mpid} = start_supervised({MainSocket, params}) + Process.sleep(100) MainSocket.stream_session_id(mpid, self()) receive do {:"$gen_cast", {:stream_session_id, session_id}} -> - {:ok, %{url: url, type: type, stream_session_id: session_id}} + {:ok, %{params: %{url: url, type: type, stream_session_id: session_id}}} end end - @tag timeout: 2 * 30 * 1000 - test "sends ping after login", context do - {:ok, pid} = StreamingSocket.start_link(context) + @tag timeout: 40 * 1000 + test "sends ping after login", %{params: params} do + {:ok, pid} = StreamingSocket.start_link(params) - Process.sleep(2 * 29 * 1000) + Process.sleep(30 * 1000 + 1) assert Process.alive?(pid) == true end