diff --git a/config.yml b/config.yml index 15c280c..08720ac 100644 --- a/config.yml +++ b/config.yml @@ -6,22 +6,26 @@ interfaces: description: "Get a list of accounts." http_method: "GET" path: "/accounts" + response_schema: "ListAccounts" - function_name: "find" description: "Fetch an account." http_method: "GET" path: "/accounts/:account_id" + response_schema: "FindAccount" arguments: - "account_id" - function_name: "summary" description: "Get a summary of an account." http_method: "GET" path: "/accounts/:account_id/summary" + response_schema: "AccountSummary" arguments: - "account_id" - function_name: "list_instruments" description: "Get a list of tradeable instruments for the given account." http_method: "GET" path: "/accounts/:account_id/instruments" + response_schema: "AccountInstruments" arguments: - "account_id" parameters: @@ -42,7 +46,7 @@ interfaces: arguments: - "account_id" parameters: - - name: "sinceTransactionID" + - name: "since_transaction_id" type: "string" doc: "ID of the Transaction to get Account changes since." - module_name: "Instruments" @@ -77,19 +81,19 @@ interfaces: type: "boolean" doc: "A flag that controls whether the candlestick is smoothed or not." default: false - - name: "includeFirst" + - name: "include_first" type: "boolean" doc: "A flag that controls whether the candlestick that is covered by the from time should be included in the results." default: true - - name: "dailyAlignment" + - name: "daily_alignment" type: "non_neg_integer" doc: "The hour of the day (in the specified timezone) to use for granularities that have daily alignments, minimum 0, maximum 23." default: 17 - - name: "alignmentTimezone" + - name: "alignment_timezone" type: "string" doc: "The timezone to use for the dailyAlignment parameter, note that the returned times will still be represented in UTC." default: "America/New_York" - - name: "weeklyAlignment" + - name: "weekly_alignment" type: "string" doc: "The day of the week used for granularities that have weekly alignment." default: "Friday" @@ -143,7 +147,7 @@ interfaces: type: "non_neg_integer" doc: "The maximum number of Orders to return." default: 50 - - name: "beforeID" + - name: "before_id" type: "string" doc: "The maximum Order ID to return. If not provided the most recent Orders in the Account are returned." - function_name: "list_pending" @@ -205,7 +209,7 @@ interfaces: type: "non_neg_integer" doc: "The maximum number of Trades to return." default: 50 - - name: "beforeID" + - name: "before_id" type: "string" doc: "The maximum Trade ID to return. If not provided the most recent Trades in the Account are returned." - function_name: "list_open" @@ -291,7 +295,7 @@ interfaces: - name: "to" type: "string" doc: "The ending time (inclusive) of the time range for the Transactions being queried. Defaults to current time." - - name: "pageSize" + - name: "page_size" type: "non_neg_integer" doc: "The number of Transactions to include in each page. [default=100, maximum=1000]." default: 100 @@ -342,7 +346,7 @@ interfaces: arguments: - "account_id" parameters: - - name: "candleSpecifications" + - name: "candle_specifications" type: "string" doc: "The specification of the candles to fetch." required: true @@ -354,15 +358,15 @@ interfaces: type: "boolean" doc: "A flag that controls whether the candlestick is smoothed or not." default: false - - name: "dailyAlignment" + - name: "daily_alignment" type: "non_neg_integer" doc: "The hour of the day (in the specified timezone) to use for granularities that have daily alignments, minimum 0, maximum 23." default: 17 - - name: "alignmentTimezone" + - name: "alignment_timezone" type: "string" doc: "The timezone to use for the dailyAlignment parameter, note that the returned times will still be represented in UTC." default: "America/New_York" - - name: "weeklyAlignment" + - name: "weekly_alignment" type: "string" doc: "The day of the week used for granularities that have weekly alignment." default: "Friday" @@ -380,11 +384,11 @@ interfaces: - name: "since" type: "string" doc: "The time of the snapshot to fetch using either RFC 3339 or Unix format. If not specified, then the most recent snapshot is fetched." - - name: "includeUnitsAvailable" + - name: "include_units_available" type: "boolean" doc: "Flag that enables the inclusion of the unitsAvailable field in the returned Price objects." default: false - - name: "includeHomeConversion" + - name: "include_home_conversion" type: "boolean" doc: "Flag that enables the inclusion of the homeConversions field in the returned response." default: false @@ -418,35 +422,23 @@ interfaces: type: "boolean" doc: "A flag that controls whether the candlestick is smoothed or not." default: false - - name: "includeFirst" + - name: "include_first" type: "boolean" doc: "A flag that controls whether the candlestick that is covered by the from time should be included in the results." default: true - - name: "dailyAlignment" + - name: "daily_alignment" type: "non_neg_integer" doc: "The hour of the day (in the specified timezone) to use for granularities that have daily alignments, minimum 0, maximum 23." default: 17 - - name: "alignmentTimezone" + - name: "alignment_timezone" type: "string" doc: "The timezone to use for the dailyAlignment parameter, note that the returned times will still be represented in UTC." default: "America/New_York" - - name: "weeklyAlignment" + - name: "weekly_alignment" type: "string" doc: "The day of the week used for granularities that have weekly alignment." default: "Friday" - name: "units" type: "string" doc: "The number of units used to calculate the volume-weighted average bid and ask prices." - default: 1 - - -# schemas: -# - module_name: "Account" -# description: "Schema for Oanda account" -# properties: -# - name: "id" -# type: "string" -# description: "The account id" -# - name: "name" -# type: "string" -# description: "The account name" \ No newline at end of file + default: 1 \ No newline at end of file diff --git a/lib/code_gen/code_generator.ex b/lib/code_gen/code_generator.ex index 6ec26fa..39ff774 100644 --- a/lib/code_gen/code_generator.ex +++ b/lib/code_gen/code_generator.ex @@ -99,10 +99,11 @@ defmodule ExOanda.CodeGenerator do end defp generate_function(config) do - %{function_name: name, description: desc, http_method: method, path: path, arguments: args, parameters: parameters} = config + %{function_name: name, description: desc, http_method: method, path: path, arguments: args, parameters: parameters, response_schema: response_schema} = config formatted_args = format_args(args) formatted_params = format_params(parameters) arg_types = generate_arg_types(args) + model = generate_module_name(response_schema) quote do @doc""" @@ -130,11 +131,11 @@ defmodule ExOanda.CodeGenerator do path_params: path_params, method: unquote(method), headers: API.base_headers(), - params: params + params: ExOanda.CodeGenerator.convert_params(params) ) |> API.maybe_attach_telemetry(conn) |> Req.request(conn.options) - |> API.handle_response() + |> API.handle_response(unquote(model)) {:error, reason} -> {:error, reason} @@ -169,6 +170,19 @@ defmodule ExOanda.CodeGenerator do |> String.to_atom() end + @doc false + def convert_params(params) do + params + |> Enum.into(%{}) + |> Recase.Enumerable.convert_keys(&Recase.to_camel/1) + |> Enum.map(fn {k, v} -> + case String.ends_with?(k, "Id") do + true -> {String.replace(k, "Id", "ID"), v} + false -> {k, v} + end + end) + end + defp generate_module_name(module_name), do: Module.concat([ExOanda, module_name]) defp format_args(args) do @@ -177,11 +191,15 @@ defmodule ExOanda.CodeGenerator do defp format_params(params) do Enum.reduce(params, [], fn %{name: name, type: type, required: required, default: default, doc: doc}, acc -> - acc - |> Keyword.put( - String.to_atom(name), - [type: String.to_atom(type), required: required, default: default, doc: doc] - ) + params_list = [ + type: String.to_atom(type), + required: required, + default: default, + doc: doc + ] + + filtered_params = Enum.reject(params_list, fn {_, value} -> is_nil(value) end) + Keyword.put(acc, String.to_atom(name), filtered_params) end) end diff --git a/lib/code_gen/config.ex b/lib/code_gen/config.ex index 205366b..0bdf47c 100644 --- a/lib/code_gen/config.ex +++ b/lib/code_gen/config.ex @@ -18,6 +18,7 @@ defmodule ExOanda.Config do field(:http_method, :string) field(:path, :string) field(:arguments, {:array, :string}, default: []) + field(:response_schema, :string) embeds_many :parameters, Parameters, primary_key: false do field(:name, :string) @@ -54,7 +55,7 @@ defmodule ExOanda.Config do defp functions_changeset(struct, params) do struct - |> cast(params, [:function_name, :description, :http_method, :path, :arguments]) + |> cast(params, [:function_name, :description, :http_method, :path, :arguments, :response_schema]) |> validate_required([:function_name, :description, :http_method, :path]) |> cast_embed(:parameters, with: &embedded_changeset/2) end diff --git a/lib/models/response/accounts/account_changes.ex b/lib/models/response/accounts/account_changes.ex new file mode 100644 index 0000000..583fbb0 --- /dev/null +++ b/lib/models/response/accounts/account_changes.ex @@ -0,0 +1,136 @@ +defmodule ExOanda.AccountChanges do + @moduledoc """ + Schema for Oanda account changes response. + """ + + use TypedEctoSchema + import Ecto.Changeset + alias ExOanda.{ + Order, + Position, + TradeSummary, + Transaction + } + + @primary_key false + + typed_embedded_schema do + embeds_many :changes, Change, primary_key: false do + embeds_many :orders_created, Order + embeds_many :orders_cancelled, Order + embeds_many :orders_filled, Order + embeds_many :orders_triggered, Order + + embeds_many :trades_opened, TradeSummary + embeds_many :trades_reduced, TradeSummary + embeds_many :trades_closed, TradeSummary + + embeds_many :positions, Position + + embeds_many :transactions, Transaction + end + + embeds_one :state, AccountState, primary_key: false do + field(:unrealized_pl, :float) + field(:nav, :float) + field(:margin_used, :float) + field(:margin_available, :float) + field(:position_value, :float) + field(:margin_closeout_unrealized_pl, :float) + field(:margin_closeout_nav, :float) + field(:margin_closeout_margin_used, :float) + field(:margin_closeout_percent, :float) + field(:margin_closeout_position_value, :float) + field(:withdrawal_limit, :float) + field(:margin_call_margin_used, :float) + field(:margin_call_percent, :float) + field(:balance, :float) + field(:pl, :float) + field(:resettable_pl, :float) + field(:financing, :float) + field(:commission, :float) + field(:dividend_adjustment, :float) + field(:guaranteed_execution_fees, :float) + field(:margin_call_enter_time, :utc_datetime_usec) + field(:margin_call_extension_count, :integer) + field(:last_margin_call_extension_time, :utc_datetime_usec) + field(:last_transaction_id, :string) + + embeds_many :orders, OrderState, primary_key: false do + field(:id, :string) + field(:trailing_stop_value, :float) + field(:trigger_distance, :float) + field(:is_trigger_distance_exact, :boolean) + end + + embeds_many :trades, TradeState, primary_key: false do + field(:id, :string) + field(:unrealized_pl, :float) + field(:margin_used, :float) + end + + embeds_many :positions, PositionState, primary_key: false do + field(:instrument, :string) + field(:net_unrealized_pl, :float) + field(:long_unrealized_pl, :float) + field(:short_unrealized_pl, :float) + field(:margin_used, :float) + end + end + + field(:last_transaction_id, :string) + end + + @doc false + def changeset(struct, params) do + struct + |> cast(params, [:last_transaction_id]) + |> cast_embed(:changes, with: &changes_changeset/2) + |> cast_embed(:state, with: &state_changeset/2) + end + + defp changes_changeset(struct, params) do + struct + |> cast(params, []) + |> cast_embed(:orders_created) + |> cast_embed(:orders_cancelled) + |> cast_embed(:orders_filled) + |> cast_embed(:orders_triggered) + |> cast_embed(:trades_opened) + |> cast_embed(:trades_reduced) + |> cast_embed(:trades_closed) + |> cast_embed(:positions) + |> cast_embed(:transactions) + end + + defp state_changeset(struct, params) do + struct + |> cast(params, [ + :unrealized_pl, :nav, :margin_used, :margin_available, :position_value, + :margin_closeout_unrealized_pl, :margin_closeout_nav, :margin_closeout_margin_used, + :margin_closeout_percent, :margin_closeout_position_value, :withdrawal_limit, + :margin_call_margin_used, :margin_call_percent, :balance, :pl, :resettable_pl, + :financing, :commission, :dividend_adjustment, :guaranteed_execution_fees, + :margin_call_enter_time, :margin_call_extension_count, :last_margin_call_extension_time, + :last_transaction_id + ]) + |> cast_embed(:orders, with: &order_state_changeset/2) + |> cast_embed(:trades, with: &trade_state_changeset/2) + |> cast_embed(:positions, with: &position_state_changeset/2) + end + + defp order_state_changeset(struct, params) do + struct + |> cast(params, [:id, :trailing_stop_value, :trigger_distance, :is_trigger_distance_exact]) + end + + defp trade_state_changeset(struct, params) do + struct + |> cast(params, [:id, :unrealized_pl, :margin_used]) + end + + defp position_state_changeset(struct, params) do + struct + |> cast(params, [:instrument, :net_unrealized_pl, :long_unrealized_pl, :short_unrealized_pl, :margin_used]) + end +end diff --git a/lib/models/response/accounts/account_summary.ex b/lib/models/response/accounts/account_summary.ex new file mode 100644 index 0000000..a8b02f1 --- /dev/null +++ b/lib/models/response/accounts/account_summary.ex @@ -0,0 +1,86 @@ +defmodule ExOanda.AccountSummary do + @moduledoc """ + Schmea for Oanda account summary response. + """ + + use TypedEctoSchema + import Ecto.Changeset + + @primary_key false + + typed_embedded_schema do + embeds_one :account, Account, primary_key: false do + field(:id, :string) + field(:alias, :string) + field(:currency, :string) + field(:created_by_user_id, :integer) + field(:created_time, :utc_datetime_usec) + field(:resettabled_pl_time, :utc_datetime_usec) + field(:margin_rate, :float) + field(:open_trade_count, :integer) + field(:open_position_count, :integer) + field(:pending_order_count, :integer) + field(:hedging_enabled, :boolean) + field(:unrealized_pl, :float) + field(:nav, :float) + field(:margin_used, :float) + field(:margin_available, :float) + field(:position_value, :float) + field(:margin_closeout_unrealized_pl, :float) + field(:margin_closeout_nav, :float) + field(:margin_closeout_margin_used, :float) + field(:margin_closeout_percent, :float) + field(:margin_closeout_position_value, :float) + field(:withdrawal_limit, :float) + field(:margin_call_margin_used, :float) + field(:margin_call_percent, :float) + field(:balance, :float) + field(:pl, :float) + field(:resettable_pl, :float) + field(:financing, :float) + field(:commission, :float) + field(:dividend_adjustment, :float) + field(:guaranteed_execution_fees, :float) + field(:margin_call_enter_time, :utc_datetime_usec) + field(:margin_call_extension_count, :integer) + field(:last_margin_call_extension_time, :utc_datetime_usec) + field(:last_transaction_id, :string) + field(:guaranteed_stop_loss_order_mode, Ecto.Enum, values: [:DISABLED, :ALLOWED, :REQUIRED]) + + embeds_one :guaranteed_stop_loss_order_parameters, GuaranteedStopLossOrderParameters, primary_key: false do + field(:mutability_market_open, Ecto.Enum, values: [:FIXED, :REPLACEABLE, :CANCELABLE, :PRICE_WIDEN_ONLY]) + field(:mutability_market_halted, Ecto.Enum, values: [:FIXED, :REPLACEABLE, :CANCELABLE, :PRICE_WIDEN_ONLY]) + end + end + + field(:last_transaction_id, :string) + end + + @doc false + def changeset(struct, params) do + struct + |> cast(params, [:last_transaction_id]) + |> cast_embed(:account, with: &account_changeset/2) + end + + defp account_changeset(struct, params) do + struct + |> cast(params, [ + :id, :alias, :currency, :created_by_user_id, :created_time, :resettabled_pl_time, :margin_rate, + :open_trade_count, :open_position_count, :pending_order_count, :hedging_enabled, :unrealized_pl, :nav, + :margin_used, :margin_available, :position_value, :margin_closeout_unrealized_pl, :margin_closeout_nav, + :margin_closeout_margin_used, :margin_closeout_percent, :margin_closeout_position_value, :withdrawal_limit, + :margin_call_margin_used, :margin_call_percent, :balance, :pl, :resettable_pl, :financing, :commission, + :dividend_adjustment, :guaranteed_execution_fees, :margin_call_enter_time, :margin_call_extension_count, + :last_margin_call_extension_time, :last_transaction_id, :guaranteed_stop_loss_order_mode + ]) + |> validate_required([:id]) + |> cast_embed(:guaranteed_stop_loss_order_parameters, with: &guaranteed_stop_loss_order_parameters_changeset/2) + end + + defp guaranteed_stop_loss_order_parameters_changeset(struct, params) do + struct + |> cast(params, [:mutability_market_open, :mutability_market_halted]) + |> validate_required([:mutability_market_open, :mutability_market_halted]) + end +end diff --git a/lib/models/response/accounts/find_account.ex b/lib/models/response/accounts/find_account.ex new file mode 100644 index 0000000..6e02595 --- /dev/null +++ b/lib/models/response/accounts/find_account.ex @@ -0,0 +1,90 @@ +defmodule ExOanda.FindAccount do + @moduledoc """ + Schema for Oanda find account response. + """ + + use TypedEctoSchema + import Ecto.Changeset + + @primary_key false + + typed_embedded_schema do + embeds_one :account, Account, primary_key: false do + field(:id, :string) + field(:alias, :string) + field(:currency, :string) + field(:created_by_user_id, :integer) + field(:created_time, :utc_datetime_usec) + field(:resettabled_pl_time, :utc_datetime_usec) + field(:margin_rate, :float) + field(:open_trade_count, :integer) + field(:open_position_count, :integer) + field(:pending_order_count, :integer) + field(:hedging_enabled, :boolean) + field(:unrealized_pl, :float) + field(:nav, :float) + field(:margin_used, :float) + field(:margin_available, :float) + field(:position_value, :float) + field(:margin_closeout_unrealized_pl, :float) + field(:margin_closeout_nav, :float) + field(:margin_closeout_margin_used, :float) + field(:margin_closeout_percent, :float) + field(:margin_closeout_position_value, :float) + field(:withdrawal_limit, :float) + field(:margin_call_margin_used, :float) + field(:margin_call_percent, :float) + field(:balance, :float) + field(:pl, :float) + field(:resettable_pl, :float) + field(:financing, :float) + field(:commission, :float) + field(:dividend_adjustment, :float) + field(:guaranteed_execution_fees, :float) + field(:margin_call_enter_time, :utc_datetime_usec) + field(:margin_call_extension_count, :integer) + field(:last_margin_call_extension_time, :utc_datetime_usec) + field(:last_transaction_id, :string) + field(:guaranteed_stop_loss_order_mode, Ecto.Enum, values: [:DISABLED, :ALLOWED, :REQUIRED]) + + embeds_one :guaranteed_stop_loss_order_parameters, GuaranteedStopLossOrderParameters, primary_key: false do + field(:mutability_market_open, Ecto.Enum, values: [:FIXED, :REPLACEABLE, :CANCELABLE, :PRICE_WIDEN_ONLY]) + field(:mutability_market_halted, Ecto.Enum, values: [:FIXED, :REPLACEABLE, :CANCELABLE, :PRICE_WIDEN_ONLY]) + end + + # TODO + field(:trades, {:array, :map}, primary_key: false) + field(:positions, {:array, :map}, primary_key: false) + field(:orders, {:array, :map}, primary_key: false) + end + + field(:last_transaction_id, :string) + end + + @doc false + def changeset(struct, params) do + struct + |> cast(params, [:last_transaction_id]) + |> cast_embed(:account, with: &account_changeset/2) + |> validate_required([:last_transaction_id]) + end + + defp account_changeset(struct, params) do + struct + |> cast(params, [ + :id, :alias, :currency, :created_by_user_id, :created_time, :resettabled_pl_time, :margin_rate, + :open_trade_count, :open_position_count, :pending_order_count, :hedging_enabled, :unrealized_pl, :nav, + :margin_used, :margin_available, :position_value, :margin_closeout_unrealized_pl, :margin_closeout_nav, + :margin_closeout_margin_used, :margin_closeout_percent, :margin_closeout_position_value, :withdrawal_limit, + :margin_call_margin_used, :margin_call_percent, :balance, :pl, :resettable_pl, :financing, :commission, + :dividend_adjustment, :guaranteed_execution_fees, :margin_call_enter_time, :margin_call_extension_count, + :last_margin_call_extension_time, :last_transaction_id + ]) + |> cast_embed(:guaranteed_stop_loss_order_parameters, with: &guaranteed_stop_loss_order_parameters_changeset/2) + end + + defp guaranteed_stop_loss_order_parameters_changeset(struct, params) do + struct + |> cast(params, [:mutability_market_open, :mutability_market_halted]) + end +end diff --git a/lib/models/response/accounts/list_account_instruments.ex b/lib/models/response/accounts/list_account_instruments.ex new file mode 100644 index 0000000..981f6f8 --- /dev/null +++ b/lib/models/response/accounts/list_account_instruments.ex @@ -0,0 +1,89 @@ +defmodule ExOanda.AccountInstruments do + @moduledoc """ + Schema for Oanda list account instruments response. + """ + + use TypedEctoSchema + import Ecto.Changeset + + @primary_key false + + typed_embedded_schema do + embeds_many :instruments, Instrument, primary_key: false do + field(:name, :string) + field(:type, Ecto.Enum, values: [:CURRENCY, :CFD, :METAL]) + field(:display_name, :string) + field(:pip_location, :integer) + field(:display_precision, :integer) + field(:trade_units_precision, :integer) + field(:minimum_trade_size, :float) + field(:maximum_trailing_stop_distance, :float) + field(:minimum_trailing_stop_distance, :float) + field(:maximum_position_size, :float) + field(:maximum_order_units, :float) + field(:margin_rate, :float) + field(:guaranteed_stop_loss_order_mode, Ecto.Enum, values: [:DISABLED, :ALLOWED, :REQUIRED]) + field(:commission, :float) + + embeds_many :tags, Tag, primary_key: false do + field(:type, :string) + field(:name, :string) + end + + embeds_one :financing, Financing, primary_key: false do + field(:long_rate, :float) + field(:short_rate, :float) + + embeds_many :financing_days_of_week, FinancingDay, primary_key: false do + field(:day_of_week, Ecto.Enum, values: [:SUNDAY, :MONDAY, :TUESDAY, :WEDNESDAY, :THURSDAY, :FRIDAY, :SATURDAY]) + field(:days_charged, :integer) + end + end + end + + field(:last_transaction_id, :string) + end + + @doc false + def changeset(struct, params) do + struct + |> cast(params, [:last_transaction_id]) + |> cast_embed(:instruments, with: &instrument_changeset/2) + end + + defp instrument_changeset(struct, params) do + struct + |> cast(params, [ + :name, :type, :display_name, :pip_location, :display_precision, :trade_units_precision, + :minimum_trade_size, :maximum_trailing_stop_distance, :minimum_trailing_stop_distance, + :maximum_position_size, :maximum_order_units, :margin_rate, :guaranteed_stop_loss_order_mode, + :commission + ]) + |> validate_required([ + :name, :type, :display_name, :pip_location, :display_precision, :trade_units_precision, + :minimum_trade_size, :maximum_trailing_stop_distance, :minimum_trailing_stop_distance, + :maximum_position_size, :maximum_order_units, :margin_rate, :guaranteed_stop_loss_order_mode + ]) + |> cast_embed(:tags, with: &tag_changeset/2) + |> cast_embed(:financing, with: &financing_changeset/2) + end + + defp tag_changeset(struct, params) do + struct + |> cast(params, [:type, :name]) + |> validate_required([:type, :name]) + end + + defp financing_changeset(struct, params) do + struct + |> cast(params, [:long_rate, :short_rate]) + |> validate_required([:long_rate, :short_rate]) + |> cast_embed(:financing_days_of_week, with: &financing_day_changeset/2) + end + + defp financing_day_changeset(struct, params) do + struct + |> cast(params, [:day_of_week, :days_charged]) + |> validate_required([:day_of_week, :days_charged]) + end +end diff --git a/lib/models/response/accounts/list_accounts.ex b/lib/models/response/accounts/list_accounts.ex new file mode 100644 index 0000000..ed7a465 --- /dev/null +++ b/lib/models/response/accounts/list_accounts.ex @@ -0,0 +1,31 @@ +defmodule ExOanda.ListAccounts do + @moduledoc """ + Schema for Oanda list accounts response. + """ + + use TypedEctoSchema + import Ecto.Changeset + + @primary_key false + + typed_embedded_schema do + embeds_many :accounts, AccountProperties, primary_key: false do + field(:id, :string) + field(:mt4_account_id, :integer) + field(:tags, {:array, :string}, default: []) + end + end + + @doc false + def changeset(struct, params) do + struct + |> cast(params, []) + |> cast_embed(:accounts, with: &account_properties_changeset/2) + end + + defp account_properties_changeset(struct, params) do + struct + |> cast(params, [:id, :mt4_account_id, :tags]) + |> validate_required([:id]) + end +end diff --git a/lib/models/response/accounts/order.ex b/lib/models/response/accounts/order.ex new file mode 100644 index 0000000..4d9a912 --- /dev/null +++ b/lib/models/response/accounts/order.ex @@ -0,0 +1,36 @@ +defmodule ExOanda.Order do + @moduledoc """ + Schema for Oanda order. + """ + + use TypedEctoSchema + import Ecto.Changeset + + @primary_key false + + typed_embedded_schema do + field(:id, :string) + field(:creat_time, :utc_datetime_usec) + field(:state, :string) + + embeds_many :client_extensions, ClientExtensions, primary_key: false do + field(:id, :string) + field(:tag, :string) + field(:comment, :string) + end + end + + @doc false + def changeset(struct, params) do + struct + |> cast(params, [:id, :creat_time, :state]) + |> cast_embed(:client_extensions, with: &client_extensions_changeset/2) + |> validate_required([:id, :creat_time, :state]) + end + + defp client_extensions_changeset(struct, params) do + struct + |> cast(params, [:id, :tag, :comment]) + |> validate_required([:id]) + end +end diff --git a/lib/models/response/accounts/position.ex b/lib/models/response/accounts/position.ex new file mode 100644 index 0000000..4fc69c8 --- /dev/null +++ b/lib/models/response/accounts/position.ex @@ -0,0 +1,41 @@ +defmodule ExOanda.Position do + @moduledoc """ + Schema for position + """ + + use TypedEctoSchema + import Ecto.Changeset + alias ExOanda.PositionSide + + @primary_key false + + typed_embedded_schema do + field(:instrument, :string) + field(:pl, :float) + field(:unrealized_pl, :float) + field(:margin_used, :float) + field(:resettable_pl, :float) + field(:financing, :float) + field(:commission, :float) + field(:dividend_adjustment, :float) + field(:guaranteed_execution_fees, :float) + + embeds_one :long, PositionSide + embeds_one :short, PositionSide + end + + @doc false + def changeset(struct, params) do + struct + |> cast(params, [ + :instrument, :pl, :unrealized_pl, :margin_used, :resettable_pl, + :financing, :commission, :dividend_adjustment, :guaranteed_execution_fees + ]) + |> validate_required([ + :instrument, :pl, :unrealized_pl, :margin_used, :resettable_pl, + :financing, :commission, :dividend_adjustment, :guaranteed_execution_fees + ]) + |> cast_embed(:long) + |> cast_embed(:short) + end +end diff --git a/lib/models/response/accounts/position_side.ex b/lib/models/response/accounts/position_side.ex new file mode 100644 index 0000000..938de36 --- /dev/null +++ b/lib/models/response/accounts/position_side.ex @@ -0,0 +1,36 @@ +defmodule ExOanda.PositionSide do + @moduledoc """ + Schema for Oanda position side. + """ + + use TypedEctoSchema + import Ecto.Changeset + + @primary_key false + + typed_embedded_schema do + field(:units, :integer) + field(:average_price, :float) + field(:trade_ids, {:array, :string}, default: []) + field(:pl, :float) + field(:unrealized_pl, :float) + field(:resettable_pl, :float) + field(:financing, :float) + field(:commission, :float) + field(:dividend_adjustment, :float) + field(:guaranteed_execution_fees, :float) + end + + @doc false + def changeset(struct, params) do + struct + |> cast(params, [ + :units, :average_price, :trade_ids, :pl, :unrealized_pl, :resettable_pl, + :financing, :commission, :dividend_adjustment, :guaranteed_execution_fees + ]) + |> validate_required([ + :units, :average_price, :trade_ids, :pl, :unrealized_pl, :resettable_pl, + :financing, :commission, :dividend_adjustment, :guaranteed_execution_fees + ]) + end +end diff --git a/lib/models/response/accounts/trade_summary.ex b/lib/models/response/accounts/trade_summary.ex new file mode 100644 index 0000000..cc88697 --- /dev/null +++ b/lib/models/response/accounts/trade_summary.ex @@ -0,0 +1,65 @@ +defmodule ExOanda.TradeSummary do + @moduledoc """ + Schema and type definitions for trade summary. + """ + + use TypedEctoSchema + import Ecto.Changeset + + @primary_key false + + typed_embedded_schema do + field(:id, :string) + field(:instrument, :string) + field(:price, :float) + field(:open_time, :utc_datetime_usec) + field(:state, Ecto.Enum, values: [:OPEN, :CLOSED, :CLOSE_WHEN_TRADEABLE]) + field(:initial_units, :integer) + field(:initial_margin_required, :float) + field(:current_units, :integer) + field(:realized_pl, :float) + field(:unrealized_pl, :float) + field(:margin_used, :float) + field(:average_close_price, :float) + field(:closing_transaction_ids, {:array, :string}) + field(:financing, :float) + field(:dividend_adjustment, :float) + field(:close_time, :utc_datetime_usec) + field(:take_profit_order_id, :string) + field(:stop_loss_order_id, :string) + field(:guaranteed_stop_loss_order_id, :string) + field(:trailing_stop_loss_order_id, :string) + + embeds_many :client_extensions, ClientExtensions, primary_key: false do + field(:id, :string) + field(:tag, :string) + field(:comment, :string) + end + end + + @doc false + def changeset(struct, params) do + struct + |> cast(params, [ + :id, :instrument, :price, :open_time, :state, :initial_units, + :initial_margin_required, :current_units, :realized_pl, :unrealized_pl, + :margin_used, :average_close_price, :closing_transaction_ids, :financing, + :dividend_adjustment, :close_time, :take_profit_order_id, :stop_loss_order_id, + :guaranteed_stop_loss_order_id, :trailing_stop_loss_order_id + ]) + |> cast_embed(:client_extensions, with: &client_extensions_changeset/2) + |> validate_required([ + :id, :instrument, :price, :open_time, :state, :initial_units, + :initial_margin_required, :current_units, :realized_pl, :unrealized_pl, + :margin_used, :average_close_price, :closing_transaction_ids, :financing, + :dividend_adjustment, :close_time, :take_profit_order_id, :stop_loss_order_id, + :guaranteed_stop_loss_order_id, :trailing_stop_loss_order_id + ]) + end + + defp client_extensions_changeset(struct, params) do + struct + |> cast(params, [:id, :tag, :comment]) + |> validate_required([:id]) + end +end diff --git a/lib/models/response/accounts/transaction.ex b/lib/models/response/accounts/transaction.ex new file mode 100644 index 0000000..bef63f6 --- /dev/null +++ b/lib/models/response/accounts/transaction.ex @@ -0,0 +1,26 @@ +defmodule ExOanda.Transaction do + @moduledoc """ + Schema for Oanda transaction. + """ + + use TypedEctoSchema + import Ecto.Changeset + + @primary_key false + + typed_embedded_schema do + field(:id, :string) + field(:time, :utc_datetime_usec) + field(:user_id, :integer) + field(:account_id, :string) + field(:batch_id, :string) + field(:request_id, :string) + end + + @doc false + def changeset(struct, params) do + struct + |> cast(params, [:id, :time, :user_id, :account_id, :batch_id, :request_id]) + |> validate_required([:id, :time, :user_id, :account_id, :batch_id, :request_id]) + end +end diff --git a/lib/models/response/atom.ex b/lib/models/response/atom.ex new file mode 100644 index 0000000..c66dff3 --- /dev/null +++ b/lib/models/response/atom.ex @@ -0,0 +1,19 @@ +defmodule ExOanda.Type.Atom do + @moduledoc """ + Custom Ecto type for atom. + """ + + use Ecto.Type + + @type t :: atom() + + def type, do: :string + + def cast(value) when is_atom(value), do: {:ok, value} + def cast(_), do: :error + + def load(value), do: {:ok, String.to_existing_atom(value)} + + def dump(value) when is_atom(value), do: {:ok, Atom.to_string(value)} + def dump(_), do: :error +end diff --git a/lib/models/response/error.ex b/lib/models/response/error.ex new file mode 100644 index 0000000..8b951f4 --- /dev/null +++ b/lib/models/response/error.ex @@ -0,0 +1,21 @@ +defmodule ExOanda.Error do + @moduledoc """ + Standard error wrapper + """ + + use TypedEctoSchema + import Ecto.Changeset + + @primary_key false + + typed_embedded_schema do + field(:error_code, :string) + field(:error_message, :string) + end + + @doc false + def changeset(struct, params) do + struct + |> cast(params, [:error_code, :error_message]) + end +end diff --git a/lib/models/response/instruments/list_candles.ex b/lib/models/response/instruments/list_candles.ex new file mode 100644 index 0000000..293a67f --- /dev/null +++ b/lib/models/response/instruments/list_candles.ex @@ -0,0 +1,43 @@ +defmodule ExOanda.ListCandles do + @moduledoc """ + Schema for Oanda instruments response. + """ + + use TypedEctoSchema + import Ecto.Changeset + alias ExOanda.Price + + @primary_key false + + typed_embedded_schema do + field(:instrument, :string) + field(:granularity, :string) + + embeds_many :candles, Candlestick, primary_key: false do + field(:time, :utc_datetime_usec) + field(:volume, :integer) + field(:complete, :boolean) + + embeds_one :bid, Price + embeds_one :ask, Price + embeds_one :mid, Price + end + end + + @doc false + def changeset(struct, params) do + struct + |> cast(params, [:instrument, :granularity]) + |> cast_embed(:candles, with: &candlestick_changeset/2) + |> validate_required([:instrument, :granularity]) + end + + defp candlestick_changeset(struct, params) do + struct + |> cast(params, [:time, :volume, :complete]) + |> cast_embed(:bid) + |> cast_embed(:ask) + |> cast_embed(:mid) + |> validate_required([:time, :volume, :complete]) + end +end diff --git a/lib/models/response/instruments/price.ex b/lib/models/response/instruments/price.ex new file mode 100644 index 0000000..037dd18 --- /dev/null +++ b/lib/models/response/instruments/price.ex @@ -0,0 +1,24 @@ +defmodule ExOanda.Price do + @moduledoc """ + Schema for Oanda price. + """ + + use TypedEctoSchema + import Ecto.Changeset + + @primary_key false + + typed_embedded_schema do + field(:o, :float) + field(:h, :float) + field(:l, :float) + field(:c, :float) + end + + @doc false + def changeset(struct, params) do + struct + |> cast(params, [:o, :h, :l, :c]) + |> validate_required([:o, :h, :l, :c]) + end +end diff --git a/lib/models/response/map_or_list.ex b/lib/models/response/map_or_list.ex new file mode 100644 index 0000000..8c69643 --- /dev/null +++ b/lib/models/response/map_or_list.ex @@ -0,0 +1,18 @@ +defmodule ExOanda.Type.MapOrList do + @moduledoc """ + Custom Ecto type for map or list. + """ + + use Ecto.Type + + @type t :: map() | list() + + def type, do: :any + + def cast(data) when is_map(data) or is_list(data), do: {:ok, data} + def cast(_), do: :error + + def load(data) when is_map(data) or is_list(data), do: {:ok, data} + + def dump(data) when is_map(data) or is_list(data), do: {:ok, data} +end diff --git a/lib/models/response/response.ex b/lib/models/response/response.ex new file mode 100644 index 0000000..9999dd8 --- /dev/null +++ b/lib/models/response/response.ex @@ -0,0 +1,28 @@ +defmodule ExOanda.Response do + @moduledoc """ + Common response schema for Oanda API. + """ + + use TypedEctoSchema + import Ecto.Changeset + + alias ExOanda.{ + Type.Atom, + Type.MapOrList + } + + @primary_key false + + typed_embedded_schema do + field(:data, MapOrList) + field(:request_id, :string) + field(:status, Atom) + end + + @doc false + def changeset(struct, params) do + struct + |> cast(params, [:data, :request_id, :status]) + |> validate_required([:data, :request_id, :status]) + end +end diff --git a/lib/util/api.ex b/lib/util/api.ex index d6a5e81..bba1d19 100644 --- a/lib/util/api.ex +++ b/lib/util/api.ex @@ -2,6 +2,7 @@ defmodule ExOanda.API do @moduledoc false alias ExOanda.Connection, as: Conn + alias ExOanda.Transform, as: TF # Requests ################################################################### @@ -21,27 +22,23 @@ defmodule ExOanda.API do def base_headers(opts \\ []), do: Keyword.merge(@base_headers, opts) # Responses #################################################################### - @spec handle_response({atom(), Req.Response.t() | map()}) :: {:ok, any()} | {:error, any()} - def handle_response(res) do - case format_response(res) do - {:ok, body} -> {:ok, body} - {:error, body} -> {:error, body} - _ -> res - end - end - defp format_response({:ok, %{status: status, body: body}}) when status in @success_codes do - {:ok, body, status} - end + @spec handle_response({atom(), Req.Response.t() | map()}, atom() | nil) :: {:ok, any()} | {:error, any()} + def handle_response(res, transform_to \\ nil) do + case format_response(res) do + {:ok, r} -> {:ok, TF.transform(r, transform_to)} + {:error, r} -> {:error, TF.transform(r, transform_to)} - defp format_response({:ok, %{status: status, body: body}}) do - {:error, body, status} - end + # TODO: handle this one + # {:error, reason} -> {:error, reason} - defp format_response({:error, %{reason: reason}}) do - {:error, reason} + _ -> res + end end + defp format_response({:ok, %{status: status} = res}) when status in @success_codes, do: {:ok, res} + defp format_response({:ok, res}), do: {:error, res} + defp format_response({:error, %{reason: reason}}), do: {:error, reason} defp format_response(res), do: res # Telemetry ############################################################## diff --git a/lib/util/http_status.ex b/lib/util/http_status.ex new file mode 100644 index 0000000..fe97736 --- /dev/null +++ b/lib/util/http_status.ex @@ -0,0 +1,34 @@ +defmodule ExOanda.HttpStatus do + @moduledoc false + + @status_codes %{ + 200 => :ok, + 201 => :created, + 202 => :accepted, + 204 => :no_content, + 400 => :bad_request, + 401 => :unauthorized, + 403 => :forbidden, + 404 => :not_found, + 405 => :method_not_allowed, + 406 => :not_acceptable, + 409 => :conflict, + 410 => :gone, + 411 => :length_required, + 412 => :precondition_failed, + 413 => :payload_too_large, + 414 => :uri_too_long, + 415 => :unsupported_media_type, + 418 => :im_a_teapot, + 429 => :too_many_requests, + 500 => :internal_server_error, + 501 => :not_implemented, + 502 => :bad_gateway, + 503 => :service_unavailable, + 504 => :gateway_timeout + } + + @doc false + @spec status_to_atom(integer) :: atom() + def status_to_atom(status), do: Map.get(@status_codes, status, :unknown) +end diff --git a/lib/util/transform.ex b/lib/util/transform.ex new file mode 100644 index 0000000..978215a --- /dev/null +++ b/lib/util/transform.ex @@ -0,0 +1,52 @@ +defmodule ExOanda.Transform do + @moduledoc false + + import Ecto.Changeset + require Logger + alias ExOanda.{ + CodeGenerator, + HttpStatus, + Response + } + + @spec transform(map(), atom()) :: Response.t() + def transform(response, model) do + %Response{} + |> Response.changeset(preprocess_body(model, response)) + |> apply_changes() + end + + defp preprocess_body(model, response) do + %{ + "data" => preprocess_data(model, response.body), + "status" => HttpStatus.status_to_atom(response.status), + "request_id" => Map.get(response.headers, "requestid", []) |> List.first() + } + end + + @spec preprocess_data(nil | atom(), map()) :: [Ecto.Schema.t()] | [map()] | Ecto.Schema.t() | map() + def preprocess_data(nil, data), do: data + + def preprocess_data(model, data) when is_map(data) do + model.__struct__() + |> model.changeset(Recase.Enumerable.convert_keys(data, &Recase.to_snake/1)) + |> log_validations(model) + |> apply_changes() + end + + def preprocess_data(model, data) when is_list(data) do + Enum.map(data, &preprocess_data(model, &1)) + end + + def preprocess_data(_model, data), do: data + + defp log_validations(%{valid?: true} = changeset, _model), do: changeset + + defp log_validations(changeset, model) do + traverse_errors(changeset, fn _changeset, field, {msg, opts} -> + Logger.warning("Validation error while transforming #{CodeGenerator.format_module_name(model)}: #{field} #{msg} #{inspect(opts)}") + end) + + changeset + end +end diff --git a/mix.exs b/mix.exs index da76b25..722fa16 100644 --- a/mix.exs +++ b/mix.exs @@ -34,6 +34,7 @@ defmodule ExOanda.MixProject do {:excoveralls, "~> 0.18.1", only: :test}, {:ex_doc, "~> 0.31", only: :dev, runtime: false}, {:nimble_options, "~> 1.1"}, + {:recase, "~> 0.8.1"}, {:req, "~> 0.5.2"}, {:req_telemetry, "~> 0.1.1"}, {:typed_ecto_schema, "~> 0.4.1", runtime: false}, diff --git a/mix.lock b/mix.lock index 4035d6f..59899c5 100644 --- a/mix.lock +++ b/mix.lock @@ -29,6 +29,7 @@ "plug_cowboy": {:hex, :plug_cowboy, "2.7.1", "87677ffe3b765bc96a89be7960f81703223fe2e21efa42c125fcd0127dd9d6b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "02dbd5f9ab571b864ae39418db7811618506256f6d13b4a45037e5fe78dc5de3"}, "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "recase": {:hex, :recase, "0.8.1", "ab98cd35857a86fa5ca99036f575241d71d77d9c2ab0c39aacf1c9b61f6f7d1d", [:mix], [], "hexpm", "9fd8d63e7e43bd9ea385b12364e305778b2bbd92537e95c4b2e26fc507d5e4c2"}, "req": {:hex, :req, "0.5.6", "8fe1eead4a085510fe3d51ad854ca8f20a622aae46e97b302f499dfb84f726ac", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cfaa8e720945d46654853de39d368f40362c2641c4b2153c886418914b372185"}, "req_telemetry": {:hex, :req_telemetry, "0.1.1", "208663ff2402bc9278225db2e79ba467a78c39cf6b00814e056eb6312e9f4e95", [:mix], [{:req, "~> 0.5.0", [hex: :req, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a3f7427e44a6e8a48d387eff50ee504c662576592c2c2024e66adfec1c0c614e"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},