diff --git a/lib/xtb_client/connection.ex b/lib/xtb_client/connection.ex new file mode 100644 index 0000000..0546ba3 --- /dev/null +++ b/lib/xtb_client/connection.ex @@ -0,0 +1,92 @@ +defmodule XtbClient.Connection do + @moduledoc """ + Module for handling connection to XTB Api. + + `Connection` module is responsible for handling connection to XTB Api. + It connects to the main socket and streaming socket and provides + functions for sending and receiving messages. + """ + use GenServer + + alias XtbClient.MainSocket + alias XtbClient.MainSocket.Config, as: MainSocketConfig + alias XtbClient.Messages + alias XtbClient.StreamingSocket + + import XtbClient.Messages + + defmodule State do + defstruct mpid: nil, + spid: nil + end + + @spec start_link(Keyword.t()) :: GenServer.on_start() + def start_link(args) do + {main_socket_config, opts} = Keyword.split(args, MainSocketConfig.keys()) + {stream_session_config, opts} = Keyword.split(opts, [:module]) + + GenServer.start_link( + __MODULE__, + %{main: main_socket_config, streaming: stream_session_config}, + opts + ) + end + + @impl GenServer + def init(args) do + with %{main: main_socket_config, streaming: stream_session_config} <- args, + {:ok, mpid} <- MainSocket.start_link(main_socket_config), + {:ok, stream_session_id} <- MainSocket.stream_session_id(mpid), + stream_session_config <- + Keyword.merge(stream_session_config, stream_session_id: stream_session_id), + stream_session_config <- Keyword.merge(main_socket_config, stream_session_config), + {:ok, spid} <- + StreamingSocket.start_link(stream_session_config) do + Process.flag(:trap_exit, true) + + state = %State{ + mpid: mpid, + spid: spid + } + + {:ok, state} + else + {:error, reason} -> {:stop, reason} + end + end + + @doc """ + Sends a synchronous message to the main socket. + """ + @spec sync_call(GenServer.server(), Messages.sync_message()) :: + {:ok, struct()} | {:error, term()} + def sync_call(server, %struct{} = query) when is_sync_message(struct) do + GenServer.call(server, {:sync_call, query}) + end + + @spec subscribe(GenServer.server(), Messages.streaming_message()) :: + {:ok, String.t()} | {:error, term()} + def subscribe(server, %struct{} = command) when is_streaming_message(struct) do + GenServer.call(server, {:subscribe, command}) + end + + @impl true + def handle_call( + {:sync_call, query}, + _from, + %State{mpid: mpid} = state + ) do + result = MainSocket.handle_query(mpid, query) + {:reply, result, state} + end + + def handle_call({:subscribe, command}, _from, %State{spid: spid} = state) do + result = StreamingSocket.subscribe(spid, command) + {:reply, result, state} + end + + @impl true + def handle_info({:EXIT, _pid, _reason}, state) do + {:stop, :shutdown, state} + end +end diff --git a/lib/xtb_client/main_socket.ex b/lib/xtb_client/main_socket.ex index 65f4dc3..0765d32 100644 --- a/lib/xtb_client/main_socket.ex +++ b/lib/xtb_client/main_socket.ex @@ -15,10 +15,12 @@ defmodule XtbClient.MainSocket do alias XtbClient.Messages alias XtbClient.RateLimit + import XtbClient.Messages + require Logger - @ping_interval 30 * 1000 - @default_query_timeout 10_000 + @ping_interval :timer.seconds(30) + @default_query_timeout :timer.seconds(10) defmodule Config do @type t :: [ @@ -40,14 +42,21 @@ defmodule XtbClient.MainSocket do end def parse(opts) do - type = AccountType.format_main(get_in(opts, [:type])) + url = get_in(opts, [:url]) || raise "Missing url in config" + type = get_in(opts, [:type]) || raise "Missing type in config" + + type = AccountType.format_main(type) + + user = get_in(opts, [:user]) || raise "Missing user in config" + password = get_in(opts, [:password]) || raise "Missing password in config" + app_name = get_in(opts, [:app_name]) || raise "Missing app_name in config" %{ - url: get_in(opts, [:url]) |> URI.merge(type) |> URI.to_string(), + url: 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]) + user: user, + password: password, + app_name: app_name } end end @@ -100,15 +109,16 @@ defmodule XtbClient.MainSocket do %{type: type, url: url, user: user, password: password, app_name: app_name} = Config.parse(conn_opts) - state = %State{ - url: url, - account_type: type, - user: user, - password: password, - app_name: app_name, - queries: %{}, - rate_limit: RateLimit.new(200) - } + state = + %State{ + url: url, + account_type: type, + user: user, + password: password, + app_name: app_name, + queries: %{}, + rate_limit: RateLimit.new(200) + } case WebSockex.start_link(url, __MODULE__, state, opts) do {:ok, pid} = result -> @@ -149,7 +159,7 @@ defmodule XtbClient.MainSocket do ping_command = encode_command("ping") ping_message = {:ping, {:text, ping_command}, @ping_interval} - schedule_work(ping_message, 1) + Process.send_after(self(), ping_message, 1) {:ok, state} end @@ -160,10 +170,6 @@ defmodule XtbClient.MainSocket do {:reconnect, state} end - defp schedule_work(message, interval) do - Process.send_after(self(), message, interval) - end - @doc """ Calls query to get streaming session ID. @@ -183,7 +189,7 @@ defmodule XtbClient.MainSocket do ) receive do - {:"$gen_cast", {:stream_session_id_reply, ^ref_string, response}} -> + {:"$gen_cast", {:ok, ^ref_string, response}} -> {:ok, response} after @default_query_timeout -> @@ -192,303 +198,21 @@ defmodule XtbClient.MainSocket do end @doc """ - Returns array of all symbols available for the user. - """ - @spec get_all_symbols(GenServer.server()) :: - {:ok, Messages.SymbolInfos.t()} | {:error, :timeout} | {:error, Error.t()} - def get_all_symbols(server) do - handle_query(server, "getAllSymbols") - end - - @doc """ - Returns calendar with market events. - """ - @spec get_calendar(GenServer.server()) :: - {:ok, Messages.CalendarInfos.t()} | {:error, :timeout} | {:error, Error.t()} - def get_calendar(server) do - handle_query(server, "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/2` which is the preferred way of retrieving current candle data.** - """ - @spec get_chart_last( - GenServer.server(), - Messages.ChartLast.Query.t() - ) :: {:ok, Messages.RateInfos.t()} | {:error, :timeout} | {:error, Error.t()} - def get_chart_last(server, %Messages.ChartLast.Query{} = params) do - %Messages.ChartLast.Query{symbol: symbol} = params - - case handle_query(server, "getChartLastRequest", %{info: params}) do - {:ok, %Messages.RateInfos{data: data} = response} -> - response = %Messages.RateInfos{ - response - | data: Enum.map(data, &%Messages.Candle{&1 | symbol: symbol}) - } - - {:ok, response} - - error -> - error - end - end - - @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/2` which is the preferred way of retrieving current candle data.** - """ - @spec get_chart_range(GenServer.server(), Messages.ChartRange.Query.t()) :: - {:ok, Messages.RateInfos.t()} | {:error, :timeout} | {:error, Error.t()} - def get_chart_range(server, %Messages.ChartRange.Query{} = params) do - %Messages.ChartRange.Query{symbol: symbol} = params - - case handle_query(server, "getChartRangeRequest", %{info: params}) do - {:ok, %Messages.RateInfos{data: data} = response} -> - response = %Messages.RateInfos{ - response - | data: Enum.map(data, &%Messages.Candle{&1 | symbol: symbol}) - } - - {:ok, response} - - error -> - error - end - end - - @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(GenServer.server(), Messages.SymbolVolume.t()) :: - {:ok, Messages.CommissionDefinition.t()} | {:error, :timeout} | {:error, Error.t()} - def get_commission_def(server, %Messages.SymbolVolume{} = params) do - handle_query(server, "getCommissionDef", params) - end - - @doc """ - Returns information about account currency and account leverage. - """ - @spec get_current_user_data(GenServer.server()) :: - {:ok, Messages.UserInfo.t()} | {:error, :timeout} | {:error, Error.t()} - def get_current_user_data(server) do - handle_query(server, "getCurrentUserData") - end - - @doc """ - Returns IBs data from the given time range. - """ - @spec get_ibs_history(GenServer.server(), Messages.DateRange.t()) :: - {:ok, map()} | {:error, :timeout} | {:error, Error.t()} - def get_ibs_history(server, %Messages.DateRange{} = params) do - handle_query(server, "getIbsHistory", params) - end - - @doc """ - Returns various account indicators. - - **Please note that this function can be usually replaced by its streaming equivalent `subscribe_get_balance/1` which is the preferred way of retrieving current account indicators.** - """ - @spec get_margin_level(GenServer.server()) :: - {:ok, Messages.BalanceInfo.t()} | {:error, :timeout} | {:error, Error.t()} - def get_margin_level(server) do - handle_query(server, "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(GenServer.server(), Messages.SymbolVolume.t()) :: - {:ok, Messages.MarginTrade.t()} | {:error, :timeout} | {:error, Error.t()} - def get_margin_trade(server, %Messages.SymbolVolume{} = params) do - handle_query(server, "getMarginTrade", params) - end - - @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/1` which is the preferred way of retrieving news data.** - """ - @spec get_news(GenServer.server(), Messages.DateRange.t()) :: - {:ok, Messages.NewsInfos.t()} | {:error, :timeout} | {:error, Error.t()} - def get_news(server, %Messages.DateRange{} = params) do - handle_query(server, "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(GenServer.server(), Messages.ProfitCalculation.Query.t()) :: - {:ok, Messages.ProfitCalculation.t()} | {:error, :timeout} | {:error, Error.t()} - def get_profit_calculation(server, %Messages.ProfitCalculation.Query{} = params) do - handle_query(server, "getProfitCalculation", params) - end - - @doc """ - Returns current time on trading server. + Handles the API query to the server. """ - @spec get_server_time(GenServer.server()) :: - {:ok, Messages.ServerTime.t()} | {:error, :timeout} | {:error, Error.t()} - def get_server_time(server) do - handle_query(server, "getServerTime") - end - - @doc """ - Returns a list of step rules for DMAs. - """ - @spec get_step_rules(GenServer.server()) :: - {:ok, Messages.StepRules.t()} | {:error, :timeout} | {:error, Error.t()} - def get_step_rules(server) do - handle_query(server, "getStepRules") - end - - @doc """ - Returns information about symbol available for the user. - """ - @spec get_symbol(GenServer.server(), Messages.SymbolInfo.Query.t()) :: - {:ok, Messages.SymbolInfo.t()} | {:error, :timeout} | {:error, Error.t()} - def get_symbol(server, %Messages.SymbolInfo.Query{} = params) do - handle_query(server, "getSymbol", params) - end - - @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/2` which is the preferred way of retrieving ticks data.** - """ - @spec get_tick_prices(GenServer.server(), Messages.TickPrices.Query.t()) :: - {:ok, Messages.TickPrices.t()} | {:error, :timeout} | {:error, Error.t()} - def get_tick_prices(server, %Messages.TickPrices.Query{} = params) do - handle_query(server, "getTickPrices", params) - end - - @doc """ - Returns array of trades listed in orders query. - """ - @spec get_trade_records(GenServer.server(), Messages.TradeInfos.Query.t()) :: - {:ok, Messages.TradeInfos.t()} | {:error, :timeout} | {:error, Error.t()} - def get_trade_records(server, %Messages.TradeInfos.Query{} = params) do - handle_query(server, "getTradeRecords", params) - end - - @doc """ - Returns array of user's trades. - - **Please note that this function can be usually replaced by its streaming equivalent `subscribe_get_trades/1` which is the preferred way of retrieving trades data.** - """ - @spec get_trades(GenServer.server(), Messages.Trades.Query.t()) :: - {:ok, Messages.TradeInfos.t()} | {:error, :timeout} | {:error, Error.t()} - def get_trades(server, %Messages.Trades.Query{} = params) do - handle_query(server, "getTrades", params) - end - - @doc """ - Returns array of user's trades which were closed within specified period of time. - """ - @spec get_trades_history(GenServer.server(), Messages.DateRange.t()) :: - {:ok, Messages.TradeInfos.t()} | {:error, :timeout} | {:error, Error.t()} - def get_trades_history(server, %Messages.DateRange{} = params) do - handle_query(server, "getTradesHistory", params) - end - - @doc """ - Returns quotes and trading times. - """ - @spec get_trading_hours(GenServer.server(), Messages.TradingHours.Query.t()) :: - {:ok, Messages.TradingHours.t()} | {:error, :timeout} | {:error, Error.t()} - def get_trading_hours(server, %Messages.TradingHours.Query{} = params) do - handle_query(server, "getTradingHours", params) - end - - @doc """ - Returns the current API version. - """ - @spec get_version(GenServer.server()) :: - {:ok, Messages.Version.t()} | {:error, :timeout} | {:error, Error.t()} - def get_version(server) do - handle_query(server, "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(GenServer.server(), Messages.TradeTransaction.Command.t()) :: - {:ok, Messages.TradeTransaction.t()} | {:error, :timeout} | {:error, Error.t()} - def trade_transaction(server, %Messages.TradeTransaction.Command{} = params) do - handle_query(server, "tradeTransaction", %{tradeTransInfo: params}) - end - - @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( - GenServer.server(), - Messages.TradeTransactionStatus.Query.t() - ) :: - {:ok, Messages.TradeTransactionStatus.t()} - | {:error, :timeout} - | {:error, Error.t()} - def trade_transaction_status(server, %Messages.TradeTransactionStatus.Query{} = params) do - handle_query(server, "tradeTransactionStatus", params) - end - - defp handle_query(server, method, params \\ nil) do + @spec handle_query(GenServer.server(), Messages.sync_message()) :: + {:ok, struct()} | {:error, term()} + def handle_query(server, %struct{} = query) when is_sync_message(struct) do ref_string = inspect(make_ref()) WebSockex.cast( server, - {:query, {self(), ref_string, {method, params}}} + {:query, {self(), ref_string, query}} ) receive do - {:"$gen_cast", {:response, ^ref_string, response}} -> + {:"$gen_cast", {:ok, ^ref_string, response}} -> + response = Messages.post_process_response(query, response) {:ok, response} {:"$gen_cast", {:error, ^ref_string, response}} -> @@ -504,20 +228,22 @@ defmodule XtbClient.MainSocket do {:stream_session_id, {caller, ref}}, %State{stream_session_id: result} = state ) do - GenServer.cast(caller, {:stream_session_id_reply, ref, result}) + GenServer.cast(caller, {:ok, ref, result}) {:ok, state} end @impl WebSockex def handle_cast( - {:query, {caller, ref, {method, params}}}, + {:query, {caller, ref, %message_struct{} = query}}, %State{queries: queries, rate_limit: rate_limit} = state ) do rate_limit = RateLimit.check_rate(rate_limit) - message = encode_command(method, params, ref) - queries = Map.put(queries, ref, {:query, caller, ref, method}) + method = Messages.operation(query) + query_params = Messages.encode(query) + message = encode_command(method, query_params, ref) + queries = Map.put(queries, ref, {:query, caller, ref, message_struct}) state = %State{ state @@ -533,16 +259,6 @@ defmodule XtbClient.MainSocket do {:reply, frame, state} end - defp encode_command(method, params \\ nil, ref \\ nil) when is_binary(method) do - %{ - command: method, - arguments: params, - customTag: ref - } - |> Map.filter(fn {_, value} -> value != nil end) - |> Jason.encode!() - end - @impl WebSockex def handle_frame({:text, msg}, state) do with {:ok, resp} <- Jason.decode(msg), @@ -559,16 +275,32 @@ defmodule XtbClient.MainSocket do end end + @impl WebSockex + def handle_info({:ping, {:text, _command} = frame, interval} = message, state) do + Process.send_after(self(), message, interval) + + {:reply, frame, state} + end + + defp encode_command(method, params \\ nil, ref \\ nil) when is_binary(method) do + %{ + command: method, + arguments: params, + customTag: ref + } + |> Map.filter(fn {_, value} -> value != nil end) + |> Jason.encode!() + end + defp handle_response( %{"status" => true, "returnData" => data, "customTag" => ref}, %State{queries: queries} = state ) do - {{:query, caller, ^ref, method}, queries} = Map.pop(queries, ref) - - result = Messages.decode_message(method, data) + {{:query, caller, ^ref, message_struct}, queries} = Map.pop(queries, ref) + result = Messages.decode(message_struct, data) state = %State{state | queries: queries} - {{:response, ref, result}, caller, state} + {{:ok, ref, result}, caller, state} end defp handle_response(%{"status" => true, "streamSessionId" => stream_session_id}, state) do @@ -584,7 +316,7 @@ defmodule XtbClient.MainSocket do %{"status" => false, "customTag" => ref} = response, %State{queries: queries} = state ) do - {{:query, caller, ^ref, _method}, queries} = Map.pop(queries, ref) + {{:query, caller, ^ref, _message_struct}, queries} = Map.pop(queries, ref) error = Error.new!(response) Logger.error("Socket received error: #{inspect(error)}") @@ -592,11 +324,4 @@ defmodule XtbClient.MainSocket do state = %State{state | queries: queries} {{:error, ref, error}, caller, state} end - - @impl WebSockex - def handle_info({:ping, {:text, _command} = frame, interval} = message, state) do - schedule_work(message, interval) - - {:reply, frame, state} - end end diff --git a/lib/xtb_client/message.ex b/lib/xtb_client/message.ex new file mode 100644 index 0000000..de0ceaa --- /dev/null +++ b/lib/xtb_client/message.ex @@ -0,0 +1,16 @@ +defmodule XtbClient.Message do + @moduledoc """ + Module for handling synchronous messages with XTB Api. + + This module provides functions for parsing messages from the XTB API. + """ + + @doc "Returns a string representation of the operation." + @callback operation(struct :: struct()) :: String.t() + + @doc "Encodes the message." + @callback encode(struct :: struct()) :: map() + + @doc "Decodes the message." + @callback decode(data :: term()) :: struct() +end diff --git a/lib/xtb_client/messages.ex b/lib/xtb_client/messages.ex index 2334ea6..db175a5 100644 --- a/lib/xtb_client/messages.ex +++ b/lib/xtb_client/messages.ex @@ -7,6 +7,9 @@ defmodule XtbClient.Messages do BalanceInfo, CalendarInfos, Candle, + Candles, + ChartLast, + ChartRange, CommissionDefinition, KeepAlive, MarginTrade, @@ -18,9 +21,9 @@ defmodule XtbClient.Messages do StepRules, SymbolInfo, SymbolInfos, - TickPrice, TickPrices, TradeInfos, + Trades, TradeStatus, TradeTransaction, TradeTransactionStatus, @@ -29,55 +32,120 @@ defmodule XtbClient.Messages do Version } - def decode_message("getBalance", data), do: BalanceInfo.new(data) - def decode_message("getMarginLevel", data), do: BalanceInfo.new(data) + @sync_messages [ + BalanceInfo.MarginLevelQuery, + CalendarInfos.Query, + ChartLast.Query, + ChartRange.Query, + CommissionDefinition.Query, + MarginTrade.Query, + NewsInfos.Query, + ProfitCalculation.Query, + ServerTime.Query, + StepRules.Query, + SymbolInfo.Query, + SymbolInfos.Query, + TickPrices.Query, + TradeInfos.Query, + TradeTransaction.Command, + TradeTransactionStatus.Query, + Trades.TradesHistoryQuery, + Trades.TradesQuery, + TradingHours.Query, + UserInfo.Query, + Version.Query + ] + + @type sync_message :: + BalanceInfo.MarginLevelQuery.t() + | CalendarInfos.Query.t() + | ChartLast.Query.t() + | ChartRange.Query.t() + | CommissionDefinition.Query.t() + | MarginTrade.Query.t() + | NewsInfos.Query.t() + | ProfitCalculation.Query.t() + | ServerTime.Query.t() + | StepRules.Query.t() + | SymbolInfo.Query.t() + | SymbolInfos.Query.t() + | TickPrices.Query.t() + | TradeInfos.Query.t() + | TradeTransaction.Command.t() + | TradeTransactionStatus.Query.t() + | Trades.TradesHistoryQuery.t() + | Trades.TradesQuery.t() + | TradingHours.Query.t() + | UserInfo.Query.t() + | Version.Query.t() + + @type streaming_message :: + Candles.SubscribeCandlesCommand.t() + | TradeStatus.SubscribeTradeStatusCommand.t() + | TradeStatus.UnsubscribeTradeStatusCommand.t() + + @streaming_messages [ + Candles.SubscribeCandlesCommand, + TradeStatus.SubscribeTradeStatusCommand, + TradeStatus.UnsubscribeTradeStatusCommand + ] + + @doc "Guards that module is a sync Message, query or command." + defguard is_sync_message(struct) when struct in @sync_messages + + @doc "Guards that module is a streaming Message." + defguard is_streaming_message(struct) when struct in @streaming_messages + + @doc "Returns the operation key of the Message struct." + @spec operation(sync_message() | streaming_message()) :: String.t() + def operation(%struct{} = query) + when is_sync_message(struct) or is_streaming_message(struct), + do: struct.operation(query) + + @doc "Encodes the message with `struct` module." + @spec encode(sync_message() | streaming_message()) :: map() + def encode(%struct{} = data) + when is_sync_message(struct) or is_streaming_message(struct), + do: struct.encode(data) + + @doc "Decodes the message with `struct` module." + @spec decode(module(), map()) :: struct() + def decode(struct, data) + when (is_sync_message(struct) or is_streaming_message(struct)) and not is_nil(data), + do: struct.decode(data) + + @doc "Calculates a unique hash for the message, which must be the same for related subscribe and unsubscribe messages." + @spec hash(streaming_message()) :: String.t() + def hash(%struct{} = data) when is_streaming_message(struct), + do: struct.hash(data) + + @doc "Returns metadata (provided by client) of a streaming Message." + @spec fetch_metadata(streaming_message()) :: map() | nil + def fetch_metadata(%struct{} = data) when is_streaming_message(struct), + do: struct.fetch_metadata(data) + + @doc "Some messages require post processing, eg. adding `symbol` to `Candle`." + @spec post_process_response(sync_message(), struct()) :: struct() + def post_process_response(%struct{} = query, %RateInfos{} = response) + when struct in [ChartRange.Query, ChartLast.Query] do + %{symbol: symbol} = query + %RateInfos{data: data} = response + + %RateInfos{ + response + | data: Enum.map(data, &%Candle{&1 | symbol: symbol}) + } + end + + def post_process_response(_query, response), do: response - def decode_message("getCalendar", data), do: CalendarInfos.new(data) + def decode_message("getBalance", data), do: BalanceInfo.new(data) def decode_message("getCandles", data), do: Candle.new(data) - def decode_message("getCommissionDef", data), do: CommissionDefinition.new(data) - def decode_message("getKeepAlive", data), do: KeepAlive.new(data) - def decode_message("getMarginTrade", data), do: MarginTrade.new(data) - - def decode_message("getNews", data), do: NewsInfos.new(data) - - def decode_message("getProfitCalculation", data), do: ProfitCalculation.new(data) - def decode_message("getProfits", data), do: ProfitInfo.new(data) - def decode_message("getChartLastRequest", data), do: RateInfos.new(data) - def decode_message("getChartRangeRequest", data), do: RateInfos.new(data) - - def decode_message("getServerTime", data), do: ServerTime.new(data) - - def decode_message("getStepRules", data), do: StepRules.new(data) - - def decode_message("getSymbol", data), do: SymbolInfo.new(data) - - def decode_message("getAllSymbols", data), do: SymbolInfos.new(data) - - def decode_message("getTickPrices", %{"quotations" => data}) when is_list(data), - do: TickPrices.new(data) - - def decode_message("getTickPrices", data) when is_map(data) and map_size(data) > 1, - do: TickPrice.new(data) - - def decode_message("getTradeRecords", data), do: TradeInfos.new(data) - def decode_message("getTrades", data), do: TradeInfos.new(data) - def decode_message("getTradesHistory", data), do: TradeInfos.new(data) - def decode_message("getTradeStatus", data), do: TradeStatus.new(data) - - def decode_message("tradeTransactionStatus", data), do: TradeTransactionStatus.new(data) - - def decode_message("tradeTransaction", data), do: TradeTransaction.new(data) - - def decode_message("getTradingHours", data), do: TradingHours.new(data) - - def decode_message("getCurrentUserData", data), do: UserInfo.new(data) - - def decode_message("getVersion", data), do: Version.new(data) end diff --git a/lib/xtb_client/messages/balance_info.ex b/lib/xtb_client/messages/balance_info.ex index 6db5ef6..87c1aa9 100644 --- a/lib/xtb_client/messages/balance_info.ex +++ b/lib/xtb_client/messages/balance_info.ex @@ -20,6 +20,36 @@ defmodule XtbClient.Messages.BalanceInfo do - `getMarginLevel` """ + defmodule MarginLevelQuery do + @moduledoc """ + Returns various account indicators. + + **Please note that this function can be usually replaced by its streaming equivalent `subscribe_get_balance/1` which is the preferred way of retrieving current account indicators.** + """ + alias XtbClient.Messages.BalanceInfo + + @behaviour XtbClient.Message + + @type t :: %__MODULE__{} + + @enforce_keys [] + @derive Jason.Encoder + defstruct [] + + def new do + %__MODULE__{} + end + + @impl XtbClient.Message + def operation(%__MODULE__{}), do: "getMarginLevel" + + @impl XtbClient.Message + def encode(%__MODULE__{} = data), do: data + + @impl XtbClient.Message + def decode(data), do: BalanceInfo.new(data) + end + @type t :: %__MODULE__{ balance: float(), cash_stock_value: float(), diff --git a/lib/xtb_client/messages/calendar_infos.ex b/lib/xtb_client/messages/calendar_infos.ex index bac8b04..6a56712 100644 --- a/lib/xtb_client/messages/calendar_infos.ex +++ b/lib/xtb_client/messages/calendar_infos.ex @@ -9,6 +9,34 @@ defmodule XtbClient.Messages.CalendarInfos do - `getCalendar` """ + defmodule Query do + @moduledoc """ + Returns calendar with market events. + """ + alias XtbClient.Messages.CalendarInfos + + @behaviour XtbClient.Message + + @type t :: %__MODULE__{} + + @enforce_keys [] + @derive Jason.Encoder + defstruct [] + + def new do + %__MODULE__{} + end + + @impl XtbClient.Message + def operation(%__MODULE__{}), do: "getCalendar" + + @impl XtbClient.Message + def encode(%__MODULE__{} = data), do: data + + @impl XtbClient.Message + def decode(data), do: CalendarInfos.new(data) + end + alias XtbClient.Messages.CalendarInfo @type t :: %__MODULE__{ diff --git a/lib/xtb_client/messages/candles.ex b/lib/xtb_client/messages/candles.ex index af8eb82..b6133a0 100644 --- a/lib/xtb_client/messages/candles.ex +++ b/lib/xtb_client/messages/candles.ex @@ -1,11 +1,56 @@ defmodule XtbClient.Messages.Candles do - defmodule Query do + defmodule SubscribeCandlesCommand do @moduledoc """ - Info about query for candles. + Command for subscribing to API chart candles. + The interval of every candle is 1 minute. A new candle arrives every minute. ## Parameters - - `symbol` symbol name. + - `symbol` - symbol name. """ + alias XtbClient.Messages.Candle + + @behaviour XtbClient.StreamingMessage + + @type t :: %__MODULE__{ + symbol: String.t(), + metadata: map() | nil + } + + @enforce_keys [:symbol] + @derive {Jason.Encoder, only: [:symbol]} + defstruct symbol: "", metadata: nil + + def new(symbol, metadata \\ nil) when is_binary(symbol) do + %__MODULE__{symbol: symbol, metadata: metadata} + end + + @impl XtbClient.StreamingMessage + def operation(%__MODULE__{}), do: "getCandles" + + @impl XtbClient.StreamingMessage + def response_path(%__MODULE__{}), do: "candle" + + @impl XtbClient.StreamingMessage + def encode(%__MODULE__{} = data), do: data + + @impl XtbClient.StreamingMessage + def decode(data), do: Candle.new(data) + + @impl XtbClient.StreamingMessage + def hash(%__MODULE__{symbol: symbol}), do: "candle:#{symbol}" + + @impl XtbClient.StreamingMessage + def fetch_metadata(%__MODULE__{} = data), do: data.metadata + end + + defmodule UnsubscribeCandlesCommand do + @moduledoc """ + Command for unsubscribing from API chart candles. + + ## Parameters + - `symbol` - symbol name. + """ + @behaviour XtbClient.StreamingMessage @type t :: %__MODULE__{ symbol: String.t() @@ -18,5 +63,23 @@ defmodule XtbClient.Messages.Candles do def new(symbol) when is_binary(symbol) do %__MODULE__{symbol: symbol} end + + @impl XtbClient.StreamingMessage + def operation(%__MODULE__{}), do: "stopCandles" + + @impl XtbClient.StreamingMessage + def response_path(%__MODULE__{}), do: "candle" + + @impl XtbClient.StreamingMessage + def encode(%__MODULE__{} = data), do: data + + @impl XtbClient.StreamingMessage + def decode(data), do: data + + @impl XtbClient.StreamingMessage + def hash(%__MODULE__{symbol: symbol}), do: "candle:#{symbol}" + + @impl XtbClient.StreamingMessage + def fetch_metadata(%__MODULE__{}), do: nil end end diff --git a/lib/xtb_client/messages/chart_last.ex b/lib/xtb_client/messages/chart_last.ex index 053dc91..8d09fdb 100644 --- a/lib/xtb_client/messages/chart_last.ex +++ b/lib/xtb_client/messages/chart_last.ex @@ -1,15 +1,36 @@ defmodule XtbClient.Messages.ChartLast do defmodule Query do @moduledoc """ - Parameters for last chart query. + 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/2` which is the preferred way of retrieving current candle data.** ## 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) - - `symbol` symbol name. + - `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) + - `symbol` - symbol name. """ - alias XtbClient.Messages.Period + alias XtbClient.Messages.RateInfos + + @behaviour XtbClient.Message @type t :: %__MODULE__{ period: Period.minute_period(), @@ -39,5 +60,14 @@ defmodule XtbClient.Messages.ChartLast do symbol: symbol } end + + @impl XtbClient.Message + def operation(%__MODULE__{}), do: "getChartLastRequest" + + @impl XtbClient.Message + def encode(%__MODULE__{} = data), do: %{info: data} + + @impl XtbClient.Message + def decode(data), do: RateInfos.new(data) end end diff --git a/lib/xtb_client/messages/chart_range.ex b/lib/xtb_client/messages/chart_range.ex index 9cfa539..ff65c04 100644 --- a/lib/xtb_client/messages/chart_range.ex +++ b/lib/xtb_client/messages/chart_range.ex @@ -1,14 +1,26 @@ defmodule XtbClient.Messages.ChartRange do defmodule Query do @moduledoc """ - Parameters for chart range query. + 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/2` which is the preferred way of retrieving current candle data.** ## 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. + - `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). @@ -17,11 +29,14 @@ defmodule XtbClient.Messages.ChartRange do If ticks `<0` then API returns `N` candles to time start. It is possible for API to return fewer chart candles than set in tick field. """ - - alias XtbClient.Messages.{DateRange, Period} + alias XtbClient.Messages.DateRange + alias XtbClient.Messages.Period + alias XtbClient.Messages.RateInfos import Period + @behaviour XtbClient.Message + @type t :: %__MODULE__{ start: integer(), end: integer(), @@ -59,5 +74,14 @@ defmodule XtbClient.Messages.ChartRange do ticks: 0 } end + + @impl XtbClient.Message + def operation(%__MODULE__{}), do: "getChartRangeRequest" + + @impl XtbClient.Message + def encode(%__MODULE__{} = data), do: %{info: data} + + @impl XtbClient.Message + def decode(data), do: RateInfos.new(data) end end diff --git a/lib/xtb_client/messages/commission_definition.ex b/lib/xtb_client/messages/commission_definition.ex index bb2098d..f1ecf34 100644 --- a/lib/xtb_client/messages/commission_definition.ex +++ b/lib/xtb_client/messages/commission_definition.ex @@ -10,6 +10,37 @@ defmodule XtbClient.Messages.CommissionDefinition do - `getCommissionDef` """ + defmodule Query do + @moduledoc """ + Returns calculation of commission and rate of exchange. + + The value is calculated as expected value and therefore might not be perfectly accurate. + """ + alias XtbClient.Messages.CommissionDefinition + alias XtbClient.Messages.SymbolVolume + + @behaviour XtbClient.Message + + @type t :: %__MODULE__{symbol_volume: SymbolVolume.t()} + + @enforce_keys [:symbol_volume] + @derive Jason.Encoder + defstruct symbol_volume: nil + + def new(%SymbolVolume{} = symbol_volume) do + %__MODULE__{symbol_volume: symbol_volume} + end + + @impl XtbClient.Message + def operation(%__MODULE__{}), do: "getCommissionDef" + + @impl XtbClient.Message + def encode(%__MODULE__{} = data), do: data.symbol_volume + + @impl XtbClient.Message + def decode(data), do: CommissionDefinition.new(data) + end + @type t :: %__MODULE__{ commission: float(), rate_of_exchange: float() diff --git a/lib/xtb_client/messages/margin_trade.ex b/lib/xtb_client/messages/margin_trade.ex index e1f725e..071c3e0 100644 --- a/lib/xtb_client/messages/margin_trade.ex +++ b/lib/xtb_client/messages/margin_trade.ex @@ -9,6 +9,37 @@ defmodule XtbClient.Messages.MarginTrade do - `getMarginTrade` """ + defmodule Query do + @moduledoc """ + Returns expected margin for given instrument and volume. + + The value is calculated as expected margin value and therefore might not be perfectly accurate. + """ + alias XtbClient.Messages.MarginTrade + alias XtbClient.Messages.SymbolVolume + + @behaviour XtbClient.Message + + @type t :: %__MODULE__{symbol_volume: SymbolVolume.t()} + + @enforce_keys [:symbol_volume] + @derive Jason.Encoder + defstruct symbol_volume: nil + + def new(%SymbolVolume{} = symbol_volume) do + %__MODULE__{symbol_volume: symbol_volume} + end + + @impl XtbClient.Message + def operation(%__MODULE__{}), do: "getMarginTrade" + + @impl XtbClient.Message + def encode(%__MODULE__{} = data), do: data.symbol_volume + + @impl XtbClient.Message + def decode(data), do: MarginTrade.new(data) + end + @type t :: %__MODULE__{ margin: float() } diff --git a/lib/xtb_client/messages/news_infos.ex b/lib/xtb_client/messages/news_infos.ex index 7dcf639..3838b44 100644 --- a/lib/xtb_client/messages/news_infos.ex +++ b/lib/xtb_client/messages/news_infos.ex @@ -9,6 +9,37 @@ defmodule XtbClient.Messages.NewsInfos do - `getNews` """ + defmodule Query do + @moduledoc """ + 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/1` which is the preferred way of retrieving news data.** + """ + alias XtbClient.Messages.DateRange + alias XtbClient.Messages.NewsInfos + + @behaviour XtbClient.Message + + @type t :: %__MODULE__{date_range: DateRange.t()} + + @enforce_keys [:date_range] + @derive Jason.Encoder + defstruct date_range: nil + + def new(%DateRange{} = date_range) do + %__MODULE__{date_range: date_range} + end + + @impl XtbClient.Message + def operation(%__MODULE__{}), do: "getNews" + + @impl XtbClient.Message + def encode(%__MODULE__{} = data), do: data.date_range + + @impl XtbClient.Message + def decode(data), do: NewsInfos.new(data) + end + alias XtbClient.Messages.NewsInfo @type t :: %__MODULE__{ diff --git a/lib/xtb_client/messages/profit_calculation.ex b/lib/xtb_client/messages/profit_calculation.ex index da0b8ae..ddd7f2c 100644 --- a/lib/xtb_client/messages/profit_calculation.ex +++ b/lib/xtb_client/messages/profit_calculation.ex @@ -11,7 +11,10 @@ defmodule XtbClient.Messages.ProfitCalculation do defmodule Query do @moduledoc """ - Info about query for calculation of profit. + 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. ## Parameters - `closePrice` theoretical close price of order, @@ -20,8 +23,10 @@ defmodule XtbClient.Messages.ProfitCalculation do - `symbol` symbol name, - `volume` volume in lots. """ - alias XtbClient.Messages.Operation + alias XtbClient.Messages.ProfitCalculation + + @behaviour XtbClient.Message @type t :: %__MODULE__{ closePrice: float(), @@ -56,6 +61,15 @@ defmodule XtbClient.Messages.ProfitCalculation do volume: volume } end + + @impl XtbClient.Message + def operation(%__MODULE__{}), do: "getProfitCalculation" + + @impl XtbClient.Message + def encode(%__MODULE__{} = data), do: data + + @impl XtbClient.Message + def decode(data), do: ProfitCalculation.new(data) end @type t :: %__MODULE__{ diff --git a/lib/xtb_client/messages/server_time.ex b/lib/xtb_client/messages/server_time.ex index ed9431d..ca1c223 100644 --- a/lib/xtb_client/messages/server_time.ex +++ b/lib/xtb_client/messages/server_time.ex @@ -10,6 +10,34 @@ defmodule XtbClient.Messages.ServerTime do - `getServerTime` """ + defmodule Query do + @moduledoc """ + Returns current time on trading server. + """ + alias XtbClient.Messages.ServerTime + + @behaviour XtbClient.Message + + @type t :: %__MODULE__{} + + @enforce_keys [] + @derive Jason.Encoder + defstruct [] + + def new do + %__MODULE__{} + end + + @impl XtbClient.Message + def operation(%__MODULE__{}), do: "getServerTime" + + @impl XtbClient.Message + def encode(%__MODULE__{} = data), do: data + + @impl XtbClient.Message + def decode(data), do: ServerTime.new(data) + end + @type t :: %__MODULE__{ time: DateTime.t(), time_string: String.t() diff --git a/lib/xtb_client/messages/step_rules.ex b/lib/xtb_client/messages/step_rules.ex index b4d8c12..106332c 100644 --- a/lib/xtb_client/messages/step_rules.ex +++ b/lib/xtb_client/messages/step_rules.ex @@ -11,6 +11,34 @@ defmodule XtbClient.Messages.StepRules do alias XtbClient.Messages.StepRule + defmodule Query do + @moduledoc """ + Returns a list of step rules for DMAs. + """ + alias XtbClient.Messages.StepRules + + @behaviour XtbClient.Message + + @type t :: %__MODULE__{} + + @enforce_keys [] + @derive Jason.Encoder + defstruct [] + + def new do + %__MODULE__{} + end + + @impl XtbClient.Message + def operation(%__MODULE__{}), do: "getStepRules" + + @impl XtbClient.Message + def encode(%__MODULE__{} = data), do: data + + @impl XtbClient.Message + def decode(data), do: StepRules.new(data) + end + @type t :: %__MODULE__{ data: [StepRule.t()] } diff --git a/lib/xtb_client/messages/symbol_info.ex b/lib/xtb_client/messages/symbol_info.ex index ee825e0..68ccec1 100644 --- a/lib/xtb_client/messages/symbol_info.ex +++ b/lib/xtb_client/messages/symbol_info.ex @@ -56,15 +56,16 @@ defmodule XtbClient.Messages.SymbolInfo do - `getSymbol` """ - alias XtbClient.Messages.{MarginMode, ProfitMode, QuoteId} - defmodule Query do @moduledoc """ - Info about the query for symbol info. + Returns information about symbol available for the user. ## Parameters - - `symbol` symbol name. + - `symbol` - symbol name. """ + alias XtbClient.Messages.SymbolInfo + + @behaviour XtbClient.Message @type t :: %__MODULE__{ symbol: String.t() @@ -80,8 +81,19 @@ defmodule XtbClient.Messages.SymbolInfo do symbol: symbol } end + + @impl XtbClient.Message + def operation(%__MODULE__{}), do: "getSymbol" + + @impl XtbClient.Message + def encode(%__MODULE__{} = data), do: data + + @impl XtbClient.Message + def decode(data), do: SymbolInfo.new(data) end + alias XtbClient.Messages.{MarginMode, ProfitMode, QuoteId} + @type t :: %__MODULE__{ ask: float(), bid: float(), diff --git a/lib/xtb_client/messages/symbol_infos.ex b/lib/xtb_client/messages/symbol_infos.ex index 77b2124..0404d22 100644 --- a/lib/xtb_client/messages/symbol_infos.ex +++ b/lib/xtb_client/messages/symbol_infos.ex @@ -9,6 +9,34 @@ defmodule XtbClient.Messages.SymbolInfos do - `getAllSymbols` """ + defmodule Query do + @moduledoc """ + Returns array of all symbols available for the user. + """ + alias XtbClient.Messages.SymbolInfos + + @behaviour XtbClient.Message + + @type t :: %__MODULE__{} + + @enforce_keys [] + @derive Jason.Encoder + defstruct [] + + def new do + %__MODULE__{} + end + + @impl XtbClient.Message + def operation(%__MODULE__{}), do: "getAllSymbols" + + @impl XtbClient.Message + def encode(%__MODULE__{} = data), do: data + + @impl XtbClient.Message + def decode(data), do: SymbolInfos.new(data) + end + alias XtbClient.Messages.SymbolInfo @type t :: %__MODULE__{ diff --git a/lib/xtb_client/messages/tick_prices.ex b/lib/xtb_client/messages/tick_prices.ex index 5003628..e06bcf5 100644 --- a/lib/xtb_client/messages/tick_prices.ex +++ b/lib/xtb_client/messages/tick_prices.ex @@ -9,17 +9,23 @@ defmodule XtbClient.Messages.TickPrices do - `getTickPrices` """ - alias XtbClient.Messages.TickPrice - defmodule Query do @moduledoc """ - Info about the query for tick prices. + 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/2` which is the preferred way of retrieving ticks data.** ## 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, - - `timestamp` the time from which the most recent tick should be looked for. Historical prices cannot be obtained using this parameter. It can only be used to verify whether a price has changed since the given time. + - `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, + - `timestamp` - the time from which the most recent tick should be looked for. Historical prices cannot be obtained using this parameter. It can only be used to verify whether a price has changed since the given time. """ + alias XtbClient.Messages.TickPrice + alias XtbClient.Messages.TickPrices + + @behaviour XtbClient.Message @type t :: %__MODULE__{ level: integer(), @@ -35,18 +41,30 @@ defmodule XtbClient.Messages.TickPrices do def new(%{ level: level, - symbols: symbols, - timestamp: timestamp + symbols: [_ | _] = symbols, + timestamp: %DateTime{} = timestamp }) - when is_integer(level) and is_list(symbols) and not is_nil(timestamp) do + when is_integer(level) do %__MODULE__{ level: level, symbols: symbols, timestamp: DateTime.to_unix(timestamp, :millisecond) } end + + @impl XtbClient.Message + def operation(%__MODULE__{}), do: "getTickPrices" + + @impl XtbClient.Message + def encode(%__MODULE__{} = data), do: data + + @impl XtbClient.Message + def decode(%{"quotations" => data}) when is_list(data), do: TickPrices.new(data) + def decode(data) when is_map(data) and map_size(data) > 1, do: TickPrice.new(data) end + alias XtbClient.Messages.TickPrice + @type t :: %__MODULE__{ data: [TickPrice.t()] } diff --git a/lib/xtb_client/messages/trade_info.ex b/lib/xtb_client/messages/trade_info.ex index 9ad8fe8..a53dcf7 100644 --- a/lib/xtb_client/messages/trade_info.ex +++ b/lib/xtb_client/messages/trade_info.ex @@ -42,7 +42,7 @@ defmodule XtbClient.Messages.TradeInfo do close_price: float(), close_time: DateTime.t() | nil, closed: boolean(), - operation: integer(), + operation: Operation.t(), comment: String.t(), commission: float() | nil, custom_comment: String.t() | nil, diff --git a/lib/xtb_client/messages/trade_infos.ex b/lib/xtb_client/messages/trade_infos.ex index 6f19053..42985fa 100644 --- a/lib/xtb_client/messages/trade_infos.ex +++ b/lib/xtb_client/messages/trade_infos.ex @@ -11,15 +11,16 @@ defmodule XtbClient.Messages.TradeInfos do - `getTradesHistory` """ - alias XtbClient.Messages.TradeInfo - defmodule Query do @moduledoc """ - Info about query for trade infos. + Returns array of trades listed in orders query. ## Parameters - - `orders` array of order IDs. + - `orders` - array of order IDs. """ + alias XtbClient.Messages.TradeInfos + + @behaviour XtbClient.Message @type t :: %__MODULE__{ orders: [String.t()] @@ -34,8 +35,19 @@ defmodule XtbClient.Messages.TradeInfos do orders: orders } end + + @impl XtbClient.Message + def operation(%__MODULE__{}), do: "getTradeRecords" + + @impl XtbClient.Message + def encode(%__MODULE__{} = data), do: data + + @impl XtbClient.Message + def decode(data), do: TradeInfos.new(data) end + alias XtbClient.Messages.TradeInfo + @type t :: %__MODULE__{ data: [TradeInfo.t()] } diff --git a/lib/xtb_client/messages/trade_status.ex b/lib/xtb_client/messages/trade_status.ex index 1b70d7a..e4d12e6 100644 --- a/lib/xtb_client/messages/trade_status.ex +++ b/lib/xtb_client/messages/trade_status.ex @@ -13,6 +13,74 @@ defmodule XtbClient.Messages.TradeStatus do - `getTradeStatus` """ + defmodule SubscribeTradeStatusCommand do + @moduledoc """ + Command for subscribing to trade status updates. + """ + alias XtbClient.Messages.TradeStatus + + @behaviour XtbClient.StreamingMessage + + @type t :: %__MODULE__{ + metadata: map() | nil + } + + @derive {Jason.Encoder, except: [:metadata]} + defstruct metadata: nil + + def new(metadata \\ nil), do: %__MODULE__{metadata: metadata} + + @impl XtbClient.StreamingMessage + def operation(%__MODULE__{}), do: "getTradeStatus" + + @impl XtbClient.StreamingMessage + def response_path(%__MODULE__{}), do: "tradeStatus" + + @impl XtbClient.StreamingMessage + def encode(%__MODULE__{} = data), do: data + + @impl XtbClient.StreamingMessage + def decode(data), do: TradeStatus.new(data) + + @impl XtbClient.StreamingMessage + def hash(%__MODULE__{}), do: "tradeStatus" + + @impl XtbClient.StreamingMessage + def fetch_metadata(%__MODULE__{} = data), do: data.metadata + end + + defmodule UnsubscribeTradeStatusCommand do + @moduledoc """ + Command for unsubscribing from trade status updates. + """ + @behaviour XtbClient.StreamingMessage + + @type t :: %__MODULE__{} + + @derive Jason.Encoder + defstruct [] + + def new, do: %__MODULE__{} + + @impl XtbClient.StreamingMessage + def operation(%__MODULE__{}), do: "stopTradeStatus" + + @impl XtbClient.StreamingMessage + def response_path(%__MODULE__{}), do: "tradeStatus" + + @impl XtbClient.StreamingMessage + def encode(%__MODULE__{} = data), do: data + + @impl XtbClient.StreamingMessage + def decode(data), do: data + + @impl XtbClient.StreamingMessage + def hash(%__MODULE__{}), do: "tradeStatus" + + @impl XtbClient.StreamingMessage + def fetch_metadata(%__MODULE__{}), do: nil + end + alias XtbClient.Messages.TransactionStatus @type t :: %__MODULE__{ diff --git a/lib/xtb_client/messages/trade_transaction.ex b/lib/xtb_client/messages/trade_transaction.ex index 6b71cb7..a4b7ad7 100644 --- a/lib/xtb_client/messages/trade_transaction.ex +++ b/lib/xtb_client/messages/trade_transaction.ex @@ -11,23 +11,32 @@ defmodule XtbClient.Messages.TradeTransaction do defmodule Command do @moduledoc """ - Info about command to trade the transaction. + 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 analyze 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. ## Parameters - - `cmd` operation code, see `XtbClient.Messages.Operation`, - - `customComment` the value the customer may provide in order to retrieve it later, - - `expiration` pending order expiration time, - - `offset` trailing offset, - - `order` `0` or position number for closing/modifications, - - `price` trade price, - - `sl` stop loss, - - `tp` take profit, - - `symbol` trade symbol, - - `type` trade transaction type, see `XtbClient.Messages.TradeType`, - - `volume` trade volume. + - `cmd` - operation code, see `XtbClient.Messages.Operation`, + - `customComment` - the value the customer may provide in order to retrieve it later, + - `expiration` - pending order expiration time, + - `offset` - trailing offset, + - `order` - `0` or position number for closing/modifications, + - `price` - trade price, + - `sl` - stop loss, + - `tp` - take profit, + - `symbol` - trade symbol, + - `type` - trade transaction type, see `XtbClient.Messages.TradeType`, + - `volume` - trade volume. """ + alias XtbClient.Messages.Operation + alias XtbClient.Messages.TradeTransaction + alias XtbClient.Messages.TradeType - alias XtbClient.Messages.{Operation, TradeType} + @behaviour XtbClient.Message @type t :: %__MODULE__{ cmd: integer(), @@ -43,6 +52,7 @@ defmodule XtbClient.Messages.TradeTransaction do volume: float() } + @enforce_keys [:cmd, :price, :symbol, :type, :volume] @derive Jason.Encoder defstruct cmd: nil, customComment: "", @@ -56,20 +66,47 @@ defmodule XtbClient.Messages.TradeTransaction do type: nil, volume: 0.0 - def new(%{} = params) do - Enum.reduce( - params, - %__MODULE__{}, - fn {key, value}, acc -> - apply(__MODULE__, key, [acc, value]) - end - ) + def new(%{ + operation: operation, + price: price, + symbol: symbol, + type: type, + volume: volume + }) + when is_atom(operation) and + is_number(price) and + is_binary(symbol) and + is_atom(type) and + is_number(volume) do + %__MODULE__{ + cmd: Operation.format(operation), + price: price, + symbol: symbol, + type: TradeType.format(type), + volume: volume + } end def operation(%__MODULE__{} = params, operation) when is_atom(operation) do %{params | cmd: Operation.format(operation)} end + def price(%__MODULE__{} = params, price) when is_number(price) do + %{params | price: price} + end + + def symbol(%__MODULE__{} = params, symbol) when is_binary(symbol) do + %{params | symbol: symbol} + end + + def type(%__MODULE__{} = params, type) when is_atom(type) do + %{params | type: TradeType.format(type)} + end + + def volume(%__MODULE__{} = params, volume) when is_number(volume) do + %{params | volume: volume} + end + def custom_comment(%__MODULE__{} = params, comment) when is_binary(comment) do %{params | customComment: comment} end @@ -86,10 +123,6 @@ defmodule XtbClient.Messages.TradeTransaction do %{params | order: order} end - def price(%__MODULE__{} = params, price) when is_number(price) do - %{params | price: price} - end - def stop_loss(%__MODULE__{} = params, sl) when is_number(sl) do %{params | sl: sl} end @@ -98,17 +131,14 @@ defmodule XtbClient.Messages.TradeTransaction do %{params | tp: tp} end - def symbol(%__MODULE__{} = params, symbol) when is_binary(symbol) do - %{params | symbol: symbol} - end + @impl XtbClient.Message + def operation(%__MODULE__{}), do: "tradeTransaction" - def type(%__MODULE__{} = params, type) when is_atom(type) do - %{params | type: TradeType.format(type)} - end + @impl XtbClient.Message + def encode(%__MODULE__{} = data), do: %{tradeTransInfo: data} - def volume(%__MODULE__{} = params, volume) when is_number(volume) do - %{params | volume: volume} - end + @impl XtbClient.Message + def decode(data), do: TradeTransaction.new(data) end @type t :: %__MODULE__{ diff --git a/lib/xtb_client/messages/trade_transaction_status.ex b/lib/xtb_client/messages/trade_transaction_status.ex index d4d77ef..a9ac239 100644 --- a/lib/xtb_client/messages/trade_transaction_status.ex +++ b/lib/xtb_client/messages/trade_transaction_status.ex @@ -14,15 +14,19 @@ defmodule XtbClient.Messages.TradeTransactionStatus do - `tradeTransactionStatus` """ - alias XtbClient.Messages.TransactionStatus - defmodule Query do @moduledoc """ - Info about query for trade transaction status. + 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. ## Parameters - - `order` unique order number. + - `order` - unique order number. """ + alias XtbClient.Messages.TradeTransactionStatus + + @behaviour XtbClient.Message @type t :: %__MODULE__{ order: integer() @@ -37,8 +41,19 @@ defmodule XtbClient.Messages.TradeTransactionStatus do order: order } end + + @impl XtbClient.Message + def operation(%__MODULE__{}), do: "tradeTransactionStatus" + + @impl XtbClient.Message + def encode(%__MODULE__{} = data), do: data + + @impl XtbClient.Message + def decode(data), do: TradeTransactionStatus.new(data) end + alias XtbClient.Messages.TransactionStatus + @type t :: %__MODULE__{ ask: float(), bid: float(), diff --git a/lib/xtb_client/messages/trades.ex b/lib/xtb_client/messages/trades.ex index 5a05149..402515d 100644 --- a/lib/xtb_client/messages/trades.ex +++ b/lib/xtb_client/messages/trades.ex @@ -1,11 +1,16 @@ defmodule XtbClient.Messages.Trades do - defmodule Query do + defmodule TradesQuery do @moduledoc """ - Info about the query for trades. + Returns array of user's trades. + + **Please note that this function can be usually replaced by its streaming equivalent `subscribe_get_trades/1` which is the preferred way of retrieving trades data.** ## Parameters - - `openedOnly` if true then only opened trades will be returned. + - `openedOnly` - if true then only opened trades will be returned. """ + alias XtbClient.Messages.TradeInfos + + @behaviour XtbClient.Message @type t :: %__MODULE__{ openedOnly: boolean() @@ -20,5 +25,50 @@ defmodule XtbClient.Messages.Trades do openedOnly: opened_only } end + + @impl XtbClient.Message + def operation(%__MODULE__{}), do: "getTrades" + + @impl XtbClient.Message + def encode(%__MODULE__{} = data), do: data + + @impl XtbClient.Message + def decode(data), do: TradeInfos.new(data) + end + + defmodule TradesHistoryQuery do + @moduledoc """ + Returns array of user's trades which were closed within specified period of time. + + ## Parameters + - `date_range` - date range for trades history. + """ + alias XtbClient.Messages.DateRange + alias XtbClient.Messages.TradeInfos + + @behaviour XtbClient.Message + + @type t :: %__MODULE__{ + date_range: DateRange.t() + } + + @enforce_keys [:date_range] + @derive Jason.Encoder + defstruct date_range: nil + + def new(%DateRange{} = date_range) do + %__MODULE__{ + date_range: date_range + } + end + + @impl XtbClient.Message + def operation(%__MODULE__{}), do: "getTradesHistory" + + @impl XtbClient.Message + def encode(%__MODULE__{} = data), do: data.date_range + + @impl XtbClient.Message + def decode(data), do: TradeInfos.new(data) end end diff --git a/lib/xtb_client/messages/trading_hours.ex b/lib/xtb_client/messages/trading_hours.ex index 6fd6066..48921da 100644 --- a/lib/xtb_client/messages/trading_hours.ex +++ b/lib/xtb_client/messages/trading_hours.ex @@ -9,15 +9,16 @@ defmodule XtbClient.Messages.TradingHours do - `getTradingHours` """ - alias XtbClient.Messages.TradingHour - defmodule Query do @moduledoc """ - Info about the query for trading hours. + Returns quotes and trading times. ## Parameters - - `symbols` array of symbol names. + - `symbols` - array of symbol names. """ + alias XtbClient.Messages.TradingHours + + @behaviour XtbClient.Message @type t :: %__MODULE__{ symbols: [String.t()] @@ -32,8 +33,19 @@ defmodule XtbClient.Messages.TradingHours do symbols: symbols } end + + @impl XtbClient.Message + def operation(%__MODULE__{}), do: "getTradingHours" + + @impl XtbClient.Message + def encode(%__MODULE__{} = data), do: data + + @impl XtbClient.Message + def decode(data), do: TradingHours.new(data) end + alias XtbClient.Messages.TradingHour + @type t :: %__MODULE__{ data: [TradingHour.t()] } diff --git a/lib/xtb_client/messages/user.ex b/lib/xtb_client/messages/user.ex index cd32ed5..df63c2b 100644 --- a/lib/xtb_client/messages/user.ex +++ b/lib/xtb_client/messages/user.ex @@ -15,6 +15,34 @@ defmodule XtbClient.Messages.UserInfo do - `getCurrentUserData` """ + defmodule Query do + @moduledoc """ + Returns information about account currency and account leverage. + """ + alias XtbClient.Messages.UserInfo + + @behaviour XtbClient.Message + + @type t :: %__MODULE__{} + + @enforce_keys [] + @derive Jason.Encoder + defstruct [] + + def new do + %__MODULE__{} + end + + @impl XtbClient.Message + def operation(%__MODULE__{}), do: "getCurrentUserData" + + @impl XtbClient.Message + def encode(%__MODULE__{} = data), do: data + + @impl XtbClient.Message + def decode(data), do: UserInfo.new(data) + end + @type t :: %__MODULE__{ company_unit: integer(), currency: String.t(), diff --git a/lib/xtb_client/messages/version.ex b/lib/xtb_client/messages/version.ex index 29e1f15..a846845 100644 --- a/lib/xtb_client/messages/version.ex +++ b/lib/xtb_client/messages/version.ex @@ -9,6 +9,33 @@ defmodule XtbClient.Messages.Version do - `getVersion` """ + defmodule Query do + @moduledoc """ + Returns the current API version. + """ + alias XtbClient.Messages.Version + + @behaviour XtbClient.Message + + @type t :: %__MODULE__{} + + @derive Jason.Encoder + defstruct [] + + def new do + %__MODULE__{} + end + + @impl XtbClient.Message + def operation(%__MODULE__{}), do: "getVersion" + + @impl XtbClient.Message + def encode(%__MODULE__{} = data), do: data + + @impl XtbClient.Message + def decode(data), do: Version.new(data) + end + @type t :: %__MODULE__{ version: String.t() } diff --git a/lib/xtb_client/streaming_message.ex b/lib/xtb_client/streaming_message.ex index 3eafc36..dbd0360 100644 --- a/lib/xtb_client/streaming_message.ex +++ b/lib/xtb_client/streaming_message.ex @@ -1,8 +1,27 @@ defmodule XtbClient.StreamingMessage do @moduledoc """ - Helper module for encoding and decoding streaming messages. + Module for handling streaming messages with XTB Api. + + This module provides functions for parsing messages from the XTB API. """ + @doc "Returns a string representation of the subscribe operation." + @callback operation(struct :: struct()) :: String.t() + + @doc "Returns a JSON path to get the response object." + @callback response_path(struct :: struct()) :: String.t() + + @doc "Encodes the message." + @callback encode(struct :: struct()) :: map() + + @doc "Decodes the message." + @callback decode(data :: term()) :: struct() + + @doc "Calculates a unique hash for the message, which must be the same for related subscribe and unsubscribe messages." + @callback hash(struct :: struct()) :: String.t() + + @callback fetch_metadata(struct :: struct()) :: map() | nil + @type t :: %__MODULE__{ method: String.t(), response_method: String.t(), diff --git a/lib/xtb_client/streaming_socket.ex b/lib/xtb_client/streaming_socket.ex index 1b0919b..8bd76c1 100644 --- a/lib/xtb_client/streaming_socket.ex +++ b/lib/xtb_client/streaming_socket.ex @@ -16,14 +16,17 @@ defmodule XtbClient.StreamingSocket do """ use WebSockex - alias XtbClient.{AccountType, StreamingMessage} + alias XtbClient.AccountType alias XtbClient.Error alias XtbClient.Messages alias XtbClient.RateLimit + alias XtbClient.StreamingMessage + + import XtbClient.Messages require Logger - @ping_interval 30 * 1000 + @ping_interval :timer.seconds(30) @type metadata :: map() @@ -47,13 +50,20 @@ defmodule XtbClient.StreamingSocket do end def parse(opts) do - type = AccountType.format_streaming(get_in(opts, [:type])) + url = get_in(opts, [:url]) || raise "Missing url in config" + type = get_in(opts, [:type]) || raise "Missing type in config" + type = AccountType.format_streaming(type) + + stream_session_id = + get_in(opts, [:stream_session_id]) || raise "Missing stream_session_id in config" + + module = get_in(opts, [:module]) || raise "Missing module in config" %{ - url: get_in(opts, [:url]) |> URI.merge(type) |> URI.to_string(), + url: url |> URI.merge(type) |> URI.to_string(), type: type, - stream_session_id: get_in(opts, [:stream_session_id]), - module: get_in(opts, [:module]) + stream_session_id: stream_session_id, + module: module } end end @@ -79,13 +89,13 @@ defmodule XtbClient.StreamingSocket do Callback invoked when message from WebSocket is received. ## Params: - - `token` - unique token of the subscribed method & params, + - `hash` - unique hash of the subscribed method & params, - `message` - struct with response data - `metadata` - map with additional context data attached to subscription """ @callback handle_message( - token :: StreamingMessage.t(), + hash :: String.t(), message :: struct(), metadata :: metadata() ) :: :ok @@ -105,8 +115,15 @@ defmodule XtbClient.StreamingSocket do quote location: :keep do @behaviour XtbClient.StreamingSocket + def child_spec(opts) do + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, [opts]} + } + end + @doc false - def handle_message(token, message, _metadata) do + def handle_message(_hash, message, _metadata) do raise "No handle_message/2 clause in #{__MODULE__} provided for #{inspect(message)}" end @@ -146,15 +163,25 @@ defmodule XtbClient.StreamingSocket do _conn, %State{stream_session_id: stream_session_id} = state ) do - ping_command = encode_streaming_command({"ping", nil}, stream_session_id) + ping_command = encode_streaming_command("ping", nil, stream_session_id) ping_message = {:ping, {:text, ping_command}, @ping_interval} - schedule_work(ping_message, 1) + Process.send_after(self(), ping_message, 1) {:ok, state} end - defp schedule_work(message, interval) do - Process.send_after(self(), message, interval) + @doc """ + Handles the subscribing to the API messages. + """ + @spec subscribe(GenServer.server(), Messages.streaming_message()) :: + {:ok, String.t()} | {:error, term()} + def subscribe(server, %struct{} = command) when is_streaming_message(struct) do + with hash <- Messages.hash(command), + :ok <- WebSockex.cast(server, {:subscribe, command}) do + {:ok, hash} + else + err -> {:error, err} + end end @doc """ @@ -188,42 +215,42 @@ defmodule XtbClient.StreamingSocket do end end - @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, token}` tuple, where token is a unique hash of subscribed operation. - When the new data are available, the `XtbClient.Messages.Candle` struct is sent via `handle_message/2` callback. - """ - @spec subscribe_get_candles( - GenServer.server(), - XtbClient.Messages.Candles.Query.t(), - metadata :: metadata() - ) :: {:ok, StreamingMessage.t()} | {:error, term()} - def subscribe_get_candles(socket, %Messages.Candles.Query{} = params, metadata \\ %{}) do - with message <- StreamingMessage.new("getCandles", "candle", metadata, params), - :ok <- WebSockex.cast(socket, {:subscribe, message}) do - {:ok, message} - else - err -> {:error, err} - end - end - - @doc """ - Unsubscribes from stream of chart candles. - """ - @spec unsubscribe_get_candles( - GenServer.server(), - XtbClient.Messages.Candles.Query.t() - ) :: {:ok, StreamingMessage.t()} | {:error, term()} - def unsubscribe_get_candles(socket, %Messages.Candles.Query{} = params) do - with message <- StreamingMessage.new("stopCandles", "candle", %{}, params), - :ok <- WebSockex.cast(socket, {:unsubscribe, message}) do - {:ok, message} - else - err -> {:error, err} - end - end + # @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, token}` tuple, where token is a unique hash of subscribed operation. + # When the new data are available, the `XtbClient.Messages.Candle` struct is sent via `handle_message/2` callback. + # """ + # @spec subscribe_get_candles( + # GenServer.server(), + # XtbClient.Messages.Candles.Query.t(), + # metadata :: metadata() + # ) :: {:ok, StreamingMessage.t()} | {:error, term()} + # def subscribe_get_candles(socket, %Messages.Candles.Query{} = params, metadata \\ %{}) do + # with message <- StreamingMessage.new("getCandles", "candle", metadata, params), + # :ok <- WebSockex.cast(socket, {:subscribe, message}) do + # {:ok, message} + # else + # err -> {:error, err} + # end + # end + + # @doc """ + # Unsubscribes from stream of chart candles. + # """ + # @spec unsubscribe_get_candles( + # GenServer.server(), + # XtbClient.Messages.Candles.Query.t() + # ) :: {:ok, StreamingMessage.t()} | {:error, term()} + # def unsubscribe_get_candles(socket, %Messages.Candles.Query{} = params) do + # with message <- StreamingMessage.new("stopCandles", "candle", %{}, params), + # :ok <- WebSockex.cast(socket, {:unsubscribe, message}) do + # {:ok, message} + # else + # err -> {:error, err} + # end + # end @doc """ Subscribes for 'keep alive' messages. @@ -427,11 +454,7 @@ defmodule XtbClient.StreamingSocket do def handle_cast( { :subscribe, - %StreamingMessage{ - method: method, - response_method: response_method, - params: params - } = message + command }, %State{ subscriptions: subscriptions, @@ -441,16 +464,21 @@ defmodule XtbClient.StreamingSocket do ) do rate_limit = RateLimit.check_rate(rate_limit) + hash = Messages.hash(command) + subscriptions = Map.put( subscriptions, - response_method, - message + hash, + command ) - encoded_message = encode_streaming_command({method, params}, session_id) state = %{state | subscriptions: subscriptions, rate_limit: rate_limit} + operation = Messages.operation(command) + params = Messages.encode(command) + encoded_message = encode_streaming_command(operation, params, session_id) + {:reply, {:text, encoded_message}, state} end @@ -458,11 +486,7 @@ defmodule XtbClient.StreamingSocket do def handle_cast( { :unsubscribe, - %StreamingMessage{ - method: method, - response_method: response_method, - params: params - } + command }, %State{ subscriptions: subscriptions, @@ -472,37 +496,28 @@ defmodule XtbClient.StreamingSocket do ) do rate_limit = RateLimit.check_rate(rate_limit) + hash = Messages.hash(command) + subscriptions = Map.delete( subscriptions, - response_method + hash ) - encoded_message = - encode_streaming_command({method, params}, session_id) - state = %{state | subscriptions: subscriptions, rate_limit: rate_limit} - {:reply, {:text, encoded_message}, state} - end - - defp encode_streaming_command({method, params}, streaming_session_id) - when is_binary(method) and is_binary(streaming_session_id) do - params = if params == nil, do: %{}, else: Map.from_struct(params) + operation = Messages.operation(command) + params = Messages.encode(command) + encoded_message = encode_streaming_command(operation, params, session_id) - %{ - command: method, - streamSessionId: streaming_session_id - } - |> Map.merge(params) - |> Jason.encode!() + {:reply, {:text, encoded_message}, state} end @impl WebSockex def handle_frame({:text, msg}, %State{module: module} = state) do with {:ok, resp} <- Jason.decode(msg), - {:ok, {token, message, metadata}} <- handle_response(resp, state), - :ok <- module.handle_message(token, message, metadata) do + {:ok, {hash, message, metadata}} <- handle_response(resp, state), + :ok <- module.handle_message(hash, message, metadata) do {:ok, state} else {:ok, _} = result -> @@ -514,6 +529,26 @@ defmodule XtbClient.StreamingSocket do end end + @impl WebSockex + def handle_info({:ping, {:text, _command} = frame, interval} = message, state) do + Process.send_after(self(), message, interval) + + {:reply, frame, state} + end + + defp encode_streaming_command(method, params, streaming_session_id) + when is_binary(method) and is_binary(streaming_session_id) do + params = if params == nil, do: %{}, else: Map.from_struct(params) + + %{ + command: method, + streamSessionId: streaming_session_id + } + |> Map.merge(params) + |> Map.filter(fn {_, value} -> value != nil end) + |> Jason.encode!() + end + defp handle_response( %{"command" => response_method, "data" => data}, %State{subscriptions: subscriptions} = _state @@ -521,7 +556,7 @@ defmodule XtbClient.StreamingSocket do with token <- Map.get(subscriptions, response_method), method <- StreamingMessage.get_method_name(token), metadata <- StreamingMessage.get_metadata(token), - result <- Messages.decode_message(method, data) do + result <- Messages.decode(method, data) do {:ok, {token, result, metadata}} end end @@ -539,11 +574,4 @@ defmodule XtbClient.StreamingSocket do {:error, error} end - - @impl WebSockex - def handle_info({:ping, {:text, _command} = frame, interval} = message, state) do - schedule_work(message, interval) - - {:reply, frame, state} - end end diff --git a/mix.exs b/mix.exs index 9baa5b7..26c936f 100644 --- a/mix.exs +++ b/mix.exs @@ -39,7 +39,6 @@ defmodule XtbClient.MixProject do # Dev & test only {:ex_doc, "~> 0.27", only: :dev, runtime: false}, - {:dotenvy, "~> 0.6.0", only: [:dev, :test]}, {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} ] diff --git a/mix.lock b/mix.lock index 266cb30..6508661 100644 --- a/mix.lock +++ b/mix.lock @@ -2,8 +2,6 @@ "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"}, diff --git a/test/support/fixtures/main_socket_e2e_fixtures.ex b/test/support/fixtures/main_socket_e2e_fixtures.ex index 4c38736..9f2296f 100644 --- a/test/support/fixtures/main_socket_e2e_fixtures.ex +++ b/test/support/fixtures/main_socket_e2e_fixtures.ex @@ -5,14 +5,14 @@ defmodule XtbClient.MainSocket.E2EFixtures do alias XtbClient.Messages.{Trades, TradeTransaction} def open_trade(pid, buy_args) do - buy = TradeTransaction.Command.new(buy_args) - MainSocket.trade_transaction(pid, buy) + buy_command = TradeTransaction.Command.new(buy_args) + MainSocket.handle_query(pid, buy_command) end def close_trade(pid, open_order_id, close_args) do # 1. way - get all opened only trades - trades_query = Trades.Query.new(true) - {:ok, result} = MainSocket.get_trades(pid, trades_query) + trades_query = Trades.TradesQuery.new(true) + {:ok, result} = MainSocket.handle_query(pid, trades_query) position_to_close = Enum.find(result.data, &(&1.order_closed == open_order_id)) @@ -25,7 +25,7 @@ defmodule XtbClient.MainSocket.E2EFixtures do } ) - close = TradeTransaction.Command.new(close_args) - MainSocket.trade_transaction(pid, close) + close_command = TradeTransaction.Command.new(close_args) + MainSocket.handle_query(pid, close_command) end end diff --git a/test/xtb_client/main_socket_test.exs b/test/xtb_client/main_socket_test.exs index aa0c634..78a4e77 100644 --- a/test/xtb_client/main_socket_test.exs +++ b/test/xtb_client/main_socket_test.exs @@ -41,15 +41,9 @@ defmodule XtbClient.MainSocketTest do } setup do - Dotenvy.source([ - ".env.#{Mix.env()}", - ".env.#{Mix.env()}.override", - System.get_env() - ]) - - url = Dotenvy.env!("XTB_API_URL", :string!) - user = Dotenvy.env!("XTB_API_USERNAME", :string!) - passwd = Dotenvy.env!("XTB_API_PASSWORD", :string!) + url = System.get_env("XTB_API_URL") + user = System.get_env("XTB_API_USERNAME") + passwd = System.get_env("XTB_API_PASSWORD") params = [ url: url, @@ -115,13 +109,17 @@ defmodule XtbClient.MainSocketTest do end test "get all symbols", %{pid: pid} do - assert {:ok, %SymbolInfos{data: data}} = MainSocket.get_all_symbols(pid) + assert {:ok, %SymbolInfos{data: data}} = + MainSocket.handle_query(pid, SymbolInfos.Query.new()) + assert [elem | _] = data assert %SymbolInfo{} = elem end test "get calendar", %{pid: pid} do - assert {:ok, %CalendarInfos{data: data}} = MainSocket.get_calendar(pid) + assert {:ok, %CalendarInfos{data: data}} = + MainSocket.handle_query(pid, CalendarInfos.Query.new()) + assert [elem | _] = data assert %CalendarInfo{} = elem end @@ -137,7 +135,7 @@ defmodule XtbClient.MainSocketTest do query = ChartLast.Query.new(args) - assert {:ok, %RateInfos{data: data, digits: digits}} = MainSocket.get_chart_last(pid, query) + assert {:ok, %RateInfos{data: data, digits: digits}} = MainSocket.handle_query(pid, query) assert is_number(digits) assert [elem | _] = data @@ -180,7 +178,7 @@ defmodule XtbClient.MainSocketTest do query = ChartRange.Query.new(args) assert {:ok, %RateInfos{data: data, digits: digits}} = - MainSocket.get_chart_range(pid, query) + MainSocket.handle_query(pid, query) assert is_number(digits) assert [elem | _] = data @@ -209,60 +207,65 @@ defmodule XtbClient.MainSocketTest do end test "get commission definition", %{pid: pid} do - args = %{symbol: "EURPLN", volume: 1} - query = SymbolVolume.new(args) + query = + %{symbol: "EURPLN", volume: 1} + |> SymbolVolume.new() + |> CommissionDefinition.Query.new() - assert {:ok, %CommissionDefinition{}} = MainSocket.get_commission_def(pid, query) + assert {:ok, %CommissionDefinition{}} = MainSocket.handle_query(pid, query) end test "get current user data", %{pid: pid} do - assert {:ok, %UserInfo{}} = MainSocket.get_current_user_data(pid) + assert {:ok, %UserInfo{}} = MainSocket.handle_query(pid, UserInfo.Query.new()) end test "get margin level", %{pid: pid} do - assert {:ok, %BalanceInfo{}} = MainSocket.get_margin_level(pid) + assert {:ok, %BalanceInfo{}} = + MainSocket.handle_query(pid, BalanceInfo.MarginLevelQuery.new()) end test "get margin trade", %{pid: pid} do - args = %{symbol: "EURPLN", volume: 1} - query = SymbolVolume.new(args) + query = + %{symbol: "EURPLN", volume: 1} + |> SymbolVolume.new() + |> MarginTrade.Query.new() - assert {:ok, %MarginTrade{}} = MainSocket.get_margin_trade(pid, query) + assert {:ok, %MarginTrade{}} = MainSocket.handle_query(pid, query) end test "get news", %{pid: pid} do - args = %{ - from: DateTime.add(DateTime.utc_now(), -2 * 30 * 24 * 60 * 60), - to: DateTime.utc_now() - } - - query = DateRange.new(args) - - assert {:ok, %NewsInfos{data: data}} = MainSocket.get_news(pid, query) + query = + %{ + from: DateTime.add(DateTime.utc_now(), -2 * 30 * 24 * 60 * 60), + to: DateTime.utc_now() + } + |> DateRange.new() + |> NewsInfos.Query.new() + + assert {:ok, %NewsInfos{data: data}} = MainSocket.handle_query(pid, query) assert [elem | _] = data assert %NewsInfo{} = elem end test "get profit calculation", %{pid: pid} do - args = %{ - open_price: 1.2233, - close_price: 1.3, - operation: :buy, - symbol: "EURPLN", - volume: 1.0 - } - - query = ProfitCalculation.Query.new(args) - - assert {:ok, %ProfitCalculation{}} = MainSocket.get_profit_calculation(pid, query) + query = + ProfitCalculation.Query.new(%{ + open_price: 1.2233, + close_price: 1.3, + operation: :buy, + symbol: "EURPLN", + volume: 1.0 + }) + + assert {:ok, %ProfitCalculation{}} = MainSocket.handle_query(pid, query) end test "get server time", %{pid: pid} do - assert {:ok, %ServerTime{}} = MainSocket.get_server_time(pid) + assert {:ok, %ServerTime{}} = MainSocket.handle_query(pid, ServerTime.Query.new()) end test "get step rules", %{pid: pid} do - assert {:ok, %StepRules{data: data}} = MainSocket.get_step_rules(pid) + assert {:ok, %StepRules{data: data}} = MainSocket.handle_query(pid, StepRules.Query.new()) assert [elem | _] = data assert %StepRule{steps: [step | _]} = elem assert %Step{} = step @@ -271,41 +274,40 @@ defmodule XtbClient.MainSocketTest do test "get symbol", %{pid: pid} do query = SymbolInfo.Query.new("BHW.PL_9") - assert {:ok, %SymbolInfo{}} = MainSocket.get_symbol(pid, query) + assert {:ok, %SymbolInfo{}} = MainSocket.handle_query(pid, query) end test "get tick prices", %{pid: pid} do - args = %{ - level: 0, - symbols: ["LITECOIN"], - timestamp: DateTime.add(DateTime.utc_now(), -2 * 60) - } - - query = TickPrices.Query.new(args) - - assert {:ok, %TickPrices{data: data}} = MainSocket.get_tick_prices(pid, query) + query = + TickPrices.Query.new(%{ + level: 0, + symbols: ["LITECOIN"], + timestamp: DateTime.add(DateTime.utc_now(), -2 * 60) + }) + + assert {:ok, %TickPrices{data: data}} = MainSocket.handle_query(pid, query) assert [elem | _] = data assert %TickPrice{} = elem end test "get trades history", %{pid: pid} do - args = %{ - from: DateTime.add(DateTime.utc_now(), -3 * 31 * 24 * 60 * 60), - to: DateTime.utc_now() - } - - query = DateRange.new(args) - - assert {:ok, %TradeInfos{data: data}} = MainSocket.get_trades_history(pid, query) + query = + %{ + from: DateTime.add(DateTime.utc_now(), -3 * 31 * 24 * 60 * 60), + to: DateTime.utc_now() + } + |> DateRange.new() + |> Trades.TradesHistoryQuery.new() + + assert {:ok, %TradeInfos{data: data}} = MainSocket.handle_query(pid, query) assert [elem | _] = data assert %TradeInfo{} = elem end test "get trading hours", %{pid: pid} do - args = ["EURPLN", "AGO.PL_9"] - query = TradingHours.Query.new(args) + query = TradingHours.Query.new(["EURPLN", "AGO.PL_9"]) - assert {:ok, %TradingHours{data: data}} = MainSocket.get_trading_hours(pid, query) + assert {:ok, %TradingHours{data: data}} = MainSocket.handle_query(pid, query) assert [elem | _] = data assert %TradingHour{} = elem assert [qu | _] = elem.quotes @@ -315,30 +317,36 @@ defmodule XtbClient.MainSocketTest do end test "get version", %{pid: pid} do - assert {:ok, %Version{}} = MainSocket.get_version(pid) + assert {:ok, %Version{}} = MainSocket.handle_query(pid, Version.Query.new()) end test "trade transaction - open and close transaction", %{pid: pid} do - buy_args = %{ - operation: :buy, - custom_comment: "Buy transaction", - price: 1200.0, - symbol: "LITECOIN", - type: :open, - volume: 1.0 - } - - buy = TradeTransaction.Command.new(buy_args) + buy_command = + %{ + operation: :buy, + price: 9999.0, + symbol: "LITECOIN", + type: :open, + volume: 1.0 + } + |> TradeTransaction.Command.new() + |> TradeTransaction.Command.custom_comment("Buy transaction") assert {:ok, %TradeTransaction{order: open_order_id}} = - MainSocket.trade_transaction(pid, buy) + MainSocket.handle_query(pid, buy_command) - status = TradeTransactionStatus.Query.new(open_order_id) - assert {:ok, %TradeTransactionStatus{}} = MainSocket.trade_transaction_status(pid, status) + status_query = TradeTransactionStatus.Query.new(open_order_id) + assert {:ok, %TradeTransactionStatus{}} = MainSocket.handle_query(pid, status_query) - # get all opened only trades - trades_query = Trades.Query.new(true) - assert {:ok, %TradeInfos{data: data}} = MainSocket.get_trades(pid, trades_query) + # get trade records + # trade_records_query = TradeInfos.Query.new([open_order_id]) + # assert {:ok, %TradeInfos{data: data}} = MainSocket.handle_query(pid, trade_records_query) + # assert [elem | _] = data + # assert %TradeInfo{} = elem + + # get trades (opened only) + trades_query = Trades.TradesQuery.new(true) + assert {:ok, %TradeInfos{data: data}} = MainSocket.handle_query(pid, trades_query) position_to_close = Enum.find( @@ -346,25 +354,25 @@ defmodule XtbClient.MainSocketTest do &(&1.order_closed == open_order_id) ) - close_args = %{ - operation: :buy, - custom_comment: "Close transaction", - price: position_to_close.open_price - 0.01, - symbol: "LITECOIN", - order: position_to_close.order_opened, - type: :close, - volume: 1.0 - } - - close = TradeTransaction.Command.new(close_args) + close_command = + %{ + operation: :buy, + price: position_to_close.open_price - 0.01, + symbol: "LITECOIN", + type: :close, + volume: position_to_close.volume + } + |> TradeTransaction.Command.new() + |> TradeTransaction.Command.order(position_to_close.order_opened) + |> TradeTransaction.Command.custom_comment("Close transaction") assert {:ok, %TradeTransaction{order: close_order_id}} = - MainSocket.trade_transaction(pid, close) + MainSocket.handle_query(pid, close_command) - status = TradeTransactionStatus.Query.new(close_order_id) + status_query = TradeTransactionStatus.Query.new(close_order_id) assert {:ok, %TradeTransactionStatus{status: :accepted}} = - MainSocket.trade_transaction_status(pid, status) + MainSocket.handle_query(pid, status_query) end end diff --git a/test/xtb_client/streaming_socket_test.exs b/test/xtb_client/streaming_socket_test.exs index 19dce11..bc302df 100644 --- a/test/xtb_client/streaming_socket_test.exs +++ b/test/xtb_client/streaming_socket_test.exs @@ -11,19 +11,12 @@ defmodule XtbClient.StreamingSocketTest do import XtbClient.MainSocket.E2EFixtures - @default_wait_time 60 * 1000 + @default_wait_time :timer.seconds(1) setup do - Dotenvy.source([ - ".env.#{Mix.env()}", - ".env.#{Mix.env()}.override", - System.get_env() - ]) - - url = Dotenvy.env!("XTB_API_URL", :string!) - user = Dotenvy.env!("XTB_API_USERNAME", :string!) - passwd = Dotenvy.env!("XTB_API_PASSWORD", :string!) - type = :demo + url = System.get_env("XTB_API_URL") + user = System.get_env("XTB_API_USERNAME") + passwd = System.get_env("XTB_API_PASSWORD") params = [ url: url, @@ -40,7 +33,7 @@ defmodule XtbClient.StreamingSocketTest do %{ params: [ url: url, - type: type, + type: :demo, stream_session_id: stream_session_id, module: StreamingSocketMock ], @@ -49,11 +42,11 @@ defmodule XtbClient.StreamingSocketTest do end describe "session management" do - @tag timeout: 40 * 1000 + @tag timeout: :timer.seconds(40) test "sends ping after login", %{params: params} do {:ok, pid} = StreamingSocket.start_link(params) - Process.sleep(30 * 1000 + 1) + Process.sleep(:timer.seconds(30) + 1) assert Process.alive?(pid) == true end