defmodule CCXT.Order.Sanity do @moduledoc """ Pre-submit order validation that catches common mistakes before they hit the exchange. Runs pure checks against market metadata (limits, precision, features, `has` map) so users discover invalid orders locally instead of after exchange rejection. ## Usage market = hd(CCXT.Bybit.fetch_markets!()) CCXT.Order.Sanity.validate( %{symbol: "BTC/USDT", type: "limit", side: "buy", amount: 0.001, price: 50_000}, market, precision_mode: CCXT.Bybit.__ccxt_precision_mode__() ) ## Return Values - `{:ok, order_params}` — all checks pass, no warnings - `{:ok, order_params, warnings}` — checks pass but price deviation triggered - `{:error, {:sanity_check, reasons}}` — one or more hard failures Where `reasons` and `warnings` are lists of `{check_name_atom, message_string}`. ## Individual Checks Each `check_*` function returns `:ok | {:error, String.t()}` (except `check_price_deviation` which returns `:ok | {:warning, String.t()}`). """ use Descripex, namespace: "/order/sanity" alias CCXT.MarketPrecision alias CCXT.Order.Builder # Maps order type strings to the corresponding `has` capability key @type_to_has %{ "limit" => :create_limit_order, "market" => :create_market_order, "stop" => :create_stop_order, "stop_limit" => :create_stop_limit_order, "stop_market" => :create_stop_market_order, "stop_loss" => :create_stop_loss_order, "trigger" => :create_trigger_order } @valid_sides ~w(buy sell) @valid_sides_atoms [:buy, :sell] # Default deviation threshold: 10% from reference price @default_deviation_threshold 0.10 # Epsilon multiplier for floating-point precision comparisons @epsilon_multiplier 1.0e-6 # Atom equivalents of limit category/bound strings for dual-key map lookups @limit_key_atoms %{ "amount" => :amount, "price" => :price, "cost" => :cost, "min" => :min, "max" => :max } # ============================================================================ # Main entry point # ============================================================================ api(:validate, "Run all applicable sanity checks on order parameters.", params: [ order_params: [ kind: :value, description: "Plain map or %CCXT.Order.Builder{} with symbol, type/order_type, side, amount, price" ], market: [kind: :exchange_data, description: "Market map from fetch_markets (or nil to skip market checks)"], opts: [ kind: :value, default: [], description: "Options: reference_price, deviation_threshold, has, features, precision_mode, market_type, warnings (:normal | :strict)" ] ], returns: %{ type: :result_tuple, description: "{:ok, params} | {:ok, params, warnings} | {:error, {:sanity_check, [{check, msg}, ...]}}" }, errors: [:sanity_check] ) @doc """ Runs all applicable checks and collects ALL failures (not short-circuit). Accepts either a plain map or `%CCXT.Order.Builder{}` struct. ## Options - `:reference_price` — reference price for deviation check - `:deviation_threshold` — threshold for price deviation (default 0.10 = 10%) - `:has` — exchange `has` map for order type capability check - `:features` — full exchange `features`, market-specific wrapper, or flat `create_order` flags - `:precision_mode` — integer precision mode (required for raw market maps) - `:market_type` — market type override when resolving nested `features` without a market - `:warnings` — `:normal` (default) or `:strict` (promotes warnings to errors) """ @spec validate(map() | Builder.t(), map() | nil, keyword()) :: {:ok, map()} | {:ok, map(), [{atom(), String.t()}]} | {:error, {:sanity_check, [{atom(), String.t()}]}} def validate(order_params, market, opts \\ []) do params = normalize_order_params(order_params) runtime_opts = normalize_runtime_opts(opts, market) {errors, warnings} = run_checks(params, market, runtime_opts) format_result(params, errors, warnings, runtime_opts) end @doc false # Builds and runs all applicable checks, collecting errors and warnings defp run_checks(params, market, opts) do side = params[:side] order_type = params[:type] amount = params[:amount] price = params[:price] checks = [ {:check_side, fn -> check_side(side) end}, {:check_order_type, fn -> check_order_type(order_type, opts) end}, {:check_symbol, fn -> run_symbol_check(params[:symbol], market) end}, {:check_amount, fn -> run_amount_check(amount, order_type, side, market, opts) end}, {:check_price, fn -> check_price(price, order_type, side, market, opts) end}, {:check_cost, fn -> check_cost(amount, price, order_type, side, market, opts) end}, {:check_price_deviation, fn -> run_deviation_check(price, opts) end} ] {errors, warnings} = Enum.reduce(checks, {[], []}, fn {name, check_fn}, {errs, warns} -> case check_fn.() do :ok -> {errs, warns} :skip -> {errs, warns} {:error, msg} -> {[{name, msg} | errs], warns} {:warning, msg} -> {errs, [{name, msg} | warns]} end end) {Enum.reverse(errors), Enum.reverse(warnings)} end @doc false # Applies strict mode promotion and selects the appropriate return shape defp format_result(params, errors, warnings, opts) do {errors, warnings} = if opts[:warnings] == :strict do {errors ++ warnings, []} else {errors, warnings} end cond do errors != [] -> {:error, {:sanity_check, errors}} warnings != [] -> {:ok, params, warnings} true -> {:ok, params} end end # ============================================================================ # Individual checks # ============================================================================ api(:check_side, ~s(Validate order side is :buy/:sell or "buy"/"sell".), params: [side: [kind: :value, description: "Order side — atom or string"]], returns: %{type: :ok_or_error, description: ":ok | {:error, message}"}, errors: [:invalid_side] ) @spec check_side(any()) :: :ok | {:error, String.t()} def check_side(side) when side in @valid_sides or side in @valid_sides_atoms, do: :ok def check_side(side), do: {:error, "Invalid side: #{inspect(side)}. Must be :buy, :sell, \"buy\", or \"sell\""} api(:check_order_type, "Validate order type is known and supported by the exchange.", params: [ type: [kind: :value, description: ~s{Order type string (e.g., "limit", "market")}], opts: [kind: :value, default: [], description: "Options: has"] ], returns: %{type: :ok_or_error, description: ":ok | {:error, message}"}, errors: [:unknown_type, :unsupported_type] ) @spec check_order_type(any(), keyword()) :: :ok | {:error, String.t()} def check_order_type(type, opts \\ []) def check_order_type(type, opts) when is_binary(type) do case Map.get(@type_to_has, type) do nil -> known = @type_to_has |> Map.keys() |> Enum.join(", ") {:error, "Unknown order type: #{inspect(type)}. Known types: #{known}"} has_key -> check_has_capability(type, has_key, opts) end end def check_order_type(type, _opts), do: {:error, "Order type must be a string, got: #{inspect(type)}"} api(:check_symbol, "Validate order symbol matches market and market is active.", params: [ order_symbol: [kind: :value, description: "Symbol from order params"], market: [kind: :exchange_data, description: "Market map with symbol and active fields"] ], returns: %{type: :ok_or_error, description: ":ok | {:error, message}"}, errors: [:symbol_mismatch, :inactive_market] ) @spec check_symbol(String.t(), map()) :: :ok | {:error, String.t()} def check_symbol(order_symbol, market) when is_binary(order_symbol) and is_map(market) do market_symbol = get_field(market, "symbol", :symbol) cond do market_symbol != order_symbol -> {:error, "Symbol mismatch: order has #{inspect(order_symbol)} but market is #{inspect(market_symbol)}"} get_field(market, "active", :active) == false -> {:error, "Market #{inspect(order_symbol)} is inactive (active: false)"} true -> :ok end end def check_symbol(order_symbol, _market), do: {:error, "Invalid symbol: #{inspect(order_symbol)}"} api(:check_amount, "Validate order amount against limits and precision.", params: [ amount: [kind: :value, description: "Order amount (positive number)"], market: [kind: :exchange_data, description: "Market map or %MarketPrecision{} (or nil)"], opts: [kind: :value, default: [], description: "Options: precision_mode"] ], returns: %{type: :ok_or_error, description: ":ok | {:error, message}"}, errors: [:invalid_amount, :below_min, :above_max, :precision_violation] ) @spec check_amount(any(), map() | MarketPrecision.t() | nil, keyword()) :: :ok | {:error, String.t()} def check_amount(amount, market, opts \\ []) def check_amount(amount, _market, _opts) when not is_number(amount) or amount <= 0 do {:error, "Amount must be a positive number, got: #{inspect(amount)}"} end def check_amount(_amount, nil, _opts), do: :ok def check_amount(amount, market, opts) do with :ok <- check_limit(amount, "amount", "min", market, :>=), :ok <- check_limit(amount, "amount", "max", market, :<=) do check_precision(amount, "amount", market, opts) end end api(:check_price, "Validate order price against type requirements, limits, and precision.", params: [ price: [kind: :value, description: "Order price (or nil for market orders)"], order_type: [kind: :value, description: "Order type string"], side: [kind: :value, description: "Order side (atom or string)"], market: [kind: :exchange_data, description: "Market map or %MarketPrecision{} (or nil)"], opts: [ kind: :value, default: [], description: "Options: precision_mode, features, market_type" ] ], returns: %{type: :ok_or_error, description: ":ok | {:error, message}"}, errors: [:missing_price, :invalid_price, :below_min, :above_max, :precision_violation] ) @spec check_price(any(), String.t() | nil, any(), map() | MarketPrecision.t() | nil, keyword()) :: :ok | {:error, String.t()} def check_price(price, order_type, side, market, opts \\ []) def check_price(nil, "limit", _side, _market, _opts) do {:error, "Limit orders require a price"} end def check_price(nil, "market", side, market, opts) do features = normalize_create_order_features(opts[:features], market, opts) requires_price = get_in_map(features, "market_buy_requires_price", :market_buy_requires_price) if requires_price && side_is_buy?(side) do {:error, "This exchange requires a price for market buy orders (market_buy_requires_price)"} else :ok end end def check_price(nil, _type, _side, _market, _opts), do: :ok def check_price(price, _type, _side, _market, _opts) when not is_number(price) or price <= 0 do {:error, "Price must be a positive number, got: #{inspect(price)}"} end def check_price(price, _type, _side, nil, _opts) when is_number(price), do: :ok def check_price(price, _type, _side, market, opts) when is_number(price) do with :ok <- check_limit(price, "price", "min", market, :>=), :ok <- check_limit(price, "price", "max", market, :<=) do check_precision(price, "price", market, opts) end end api(:check_cost, "Validate order notional or quote-cost against cost limits.", params: [ amount: [kind: :value, description: "Order amount"], price: [kind: :value, description: "Order price (required unless market_buy_by_cost applies)"], order_type: [kind: :value, description: "Order type string"], side: [kind: :value, description: "Order side (atom or string)"], market: [kind: :exchange_data, description: "Market map (or nil)"], opts: [ kind: :value, default: [], description: "Options: features, market_type" ] ], returns: %{type: :ok_or_error, description: ":ok | {:error, message}"}, errors: [:below_min_cost, :above_max_cost] ) @spec check_cost(number() | nil, number() | nil, String.t() | nil, any(), map() | nil, keyword()) :: :ok | {:error, String.t()} def check_cost(amount, price, order_type, side, market, opts \\ []) def check_cost(_amount, _price, _order_type, _side, nil, _opts), do: :ok def check_cost(nil, _price, _order_type, _side, _market, _opts), do: :ok def check_cost(amount, price, order_type, side, market, opts) when is_number(amount) and is_map(market) and is_binary(order_type) do cond do market_buy_by_cost_order?(order_type, side, market, opts) -> with :ok <- check_cost_limit(amount, "min", market, :>=) do check_cost_limit(amount, "max", market, :<=) end is_number(price) -> notional = calculate_notional(amount, price, market) with :ok <- check_cost_limit(notional, "min", market, :>=) do check_cost_limit(notional, "max", market, :<=) end true -> :ok end end def check_cost(_amount, _price, _order_type, _side, _market, _opts), do: :ok api(:check_price_deviation, "Check if price deviates significantly from a reference price.", params: [ price: [kind: :value, description: "Order price"], reference_price: [kind: :value, description: "Reference/market price for comparison"], opts: [kind: :value, default: [], description: "Options: deviation_threshold (default 0.10 = 10%)"] ], returns: %{type: :ok_or_warning, description: ":ok | {:warning, message}"}, errors: [] ) @spec check_price_deviation(number(), number(), keyword()) :: :ok | {:warning, String.t()} def check_price_deviation(price, reference_price, opts \\ []) def check_price_deviation(price, reference_price, opts) when is_number(price) and is_number(reference_price) and reference_price > 0 do threshold = opts[:deviation_threshold] || @default_deviation_threshold deviation = abs(price - reference_price) / reference_price if deviation > threshold do pct = Float.round(deviation * 100, 2) {:warning, "Price #{price} deviates #{pct}% from reference #{reference_price} (threshold: #{Float.round(threshold * 100, 2)}%)"} else :ok end end def check_price_deviation(_price, _reference_price, _opts), do: :ok # ============================================================================ # Input normalization # ============================================================================ @doc false # Normalizes order params from Builder struct or plain map to a canonical map @spec normalize_order_params(Builder.t() | map()) :: map() defp normalize_order_params(%Builder{} = b) do %{ symbol: b.symbol, type: to_string_safe(b.order_type), side: to_string_safe(b.side), amount: b.amount, price: b.price } end defp normalize_order_params(params) when is_map(params) do type = params[:type] || params[:order_type] || params["type"] || params["order_type"] side = params[:side] || params["side"] %{ symbol: params[:symbol] || params["symbol"], type: to_string_safe(type), side: to_string_safe(side), amount: params[:amount] || params["amount"], price: params[:price] || params["price"] } end # ============================================================================ # Private helpers # ============================================================================ @doc false # Safely converts atoms/strings to strings, passes nil through defp to_string_safe(nil), do: nil defp to_string_safe(val) when is_atom(val), do: Atom.to_string(val) defp to_string_safe(val) when is_binary(val), do: val @doc false # Checks if side is a buy defp side_is_buy?(:buy), do: true defp side_is_buy?("buy"), do: true defp side_is_buy?(_), do: false @doc false # Runs symbol check only when market is provided defp run_symbol_check(_symbol, nil), do: :skip defp run_symbol_check(symbol, market), do: check_symbol(symbol, market) @doc false # Runs amount checks only when `amount` is a base amount, not a quote cost. defp run_amount_check(amount, order_type, side, market, opts) do cond do not is_number(amount) or amount <= 0 -> check_amount(amount, market, opts) market_buy_by_cost_order?(order_type, side, market, opts) -> :skip true -> check_amount(amount, market, opts) end end @doc false # Runs deviation check only when reference_price is provided defp run_deviation_check(price, opts) do case opts[:reference_price] do nil -> :skip ref when is_number(price) -> check_price_deviation(price, ref, opts) _ref -> :skip end end @doc false # Checks if exchange `has` map supports the order type defp check_has_capability(type, has_key, opts) do has = opts[:has] case has do nil -> :ok %{} -> val = map_get_dual(has, has_key, Atom.to_string(has_key)) if val == false do {:error, "Exchange does not support #{inspect(type)} orders (has.#{has_key} == false)"} else :ok end end end @doc false # Normalizes runtime opts so downstream checks see flattened create_order flags. defp normalize_runtime_opts(opts, market) do normalized_features = normalize_create_order_features(opts[:features], market, opts) Keyword.put(opts, :features, normalized_features) end @doc false # Extracts create_order feature flags from full `spec.features`, market wrappers, # subtype wrappers, or already-flattened maps. defp normalize_create_order_features(nil, _market, _opts), do: nil defp normalize_create_order_features(features, _market, _opts) when not is_map(features), do: nil defp normalize_create_order_features(features, market, opts) do market_type = resolve_market_type(market, opts) market_features = get_market_feature_map(features, market_type) default_features = get_in_map(features, "default", :default) cond do create_order_feature_map?(features) -> features resolved = resolve_create_order_features(features, features, market, opts) -> resolved resolved = resolve_create_order_features(market_features, features, market, opts) -> resolved resolved = resolve_create_order_features(default_features, features, market, opts) -> resolved true -> nil end end @doc false defp resolve_create_order_features(nil, _root_features, _market, _opts), do: nil defp resolve_create_order_features(features, root_features, market, opts) when is_map(features) do if create_order_feature_map?(features) do features else create_order = get_in_map(features, "create_order", :create_order) sub_type_features = get_sub_type_feature_map(features, market, opts) extends = get_in_map(features, "extends", :extends) cond do is_map(create_order) -> resolve_create_order_features(create_order, root_features, market, opts) is_map(sub_type_features) -> resolve_create_order_features(sub_type_features, root_features, market, opts) is_binary(extends) -> root_features |> get_feature_alias_target(extends) |> resolve_create_order_features(root_features, market, opts) true -> nil end end end @doc false defp create_order_feature_map?(features) when is_map(features) do Enum.any?( [ "market_buy_requires_price", :market_buy_requires_price, "market_buy_by_cost", :market_buy_by_cost, "time_in_force", :time_in_force ], &Map.has_key?(features, &1) ) end @doc false defp get_market_feature_map(features, nil), do: features defp get_market_feature_map(features, market_type) when is_map(features) and is_atom(market_type) do map_get_dual(features, market_type, Atom.to_string(market_type)) end @doc false defp get_sub_type_feature_map(features, market, opts) when is_map(features) do case resolve_sub_type(market, opts) do nil -> nil sub_type -> map_get_dual(features, sub_type, Atom.to_string(sub_type)) end end @doc false defp get_feature_alias_target(features, extends) when is_map(features) and is_binary(extends) do underscored = Macro.underscore(extends) Enum.find_value( [extends, safe_to_existing_atom(extends), underscored, safe_to_existing_atom(underscored)], fn key -> if is_nil(key), do: nil, else: Map.get(features, key) end ) end @doc false defp resolve_market_type(market, opts) do opts[:market_type] |> normalize_market_type() |> case do nil -> cond do market_type = normalize_market_type(get_field(market, "type", :type)) -> market_type get_field(market, "spot", :spot) == true -> :spot get_field(market, "swap", :swap) == true -> :swap get_field(market, "future", :future) == true -> :future get_field(market, "option", :option) == true -> :option true -> nil end market_type -> market_type end end @doc false defp resolve_sub_type(market, opts) do opts[:sub_type] |> normalize_market_type() |> case do nil -> cond do sub_type = normalize_market_type(get_field(market, "sub_type", :sub_type)) -> sub_type get_field(market, "linear", :linear) == true -> :linear get_field(market, "inverse", :inverse) == true -> :inverse true -> nil end sub_type -> sub_type end end @doc false defp normalize_market_type(nil), do: nil defp normalize_market_type(type) when is_atom(type), do: type defp normalize_market_type(type) when is_binary(type) do case Macro.underscore(type) do "spot" -> :spot "swap" -> :swap "future" -> :future "option" -> :option "linear" -> :linear "inverse" -> :inverse _ -> nil end end @doc false defp safe_to_existing_atom(key) when is_binary(key) do String.to_existing_atom(key) rescue ArgumentError -> nil end @doc false defp market_buy_by_cost_order?(order_type, side, market, opts) do features = normalize_create_order_features(opts[:features], market, opts) market_buy_by_cost = get_in_map(features, "market_buy_by_cost", :market_buy_by_cost) order_type == "market" and side_is_buy?(side) and market_buy_by_cost == true end @doc false # Gets a field from either a string-keyed or atom-keyed map (or struct) defp get_field(%{__struct__: _} = struct, _string_key, atom_key) do Map.get(struct, atom_key) end defp get_field(map, string_key, atom_key) when is_map(map) do map_get_dual(map, string_key, atom_key) end defp get_field(_, _, _), do: nil @doc false # Gets a value from a map trying both string and atom keys defp get_in_map(nil, _string_key, _atom_key), do: nil defp get_in_map(map, string_key, atom_key) when is_map(map) do map_get_dual(map, string_key, atom_key) end @doc false # Gets a value from a map trying primary key first, then fallback key. # Unlike `||`, correctly handles `false` as a valid value. defp map_get_dual(map, primary_key, fallback_key) do case Map.fetch(map, primary_key) do {:ok, val} -> val :error -> Map.get(map, fallback_key) end end @doc false # Checks a value against a limit from the market defp check_limit(value, category, bound, %MarketPrecision{} = mp, op) do limit = get_mp_limit(mp, category, bound) compare_limit(value, limit, category, bound, op) end defp check_limit(value, category, bound, market, op) when is_map(market) do limit = get_nested_limit(market, category, bound) compare_limit(value, limit, category, bound, op) end @doc false defp compare_limit(_value, nil, _category, _bound, _op), do: :ok defp compare_limit(value, limit, category, _bound, :>=) when is_number(limit) do if value >= limit do :ok else {:error, "#{category} #{value} is below minimum #{limit}"} end end defp compare_limit(value, limit, category, _bound, :<=) when is_number(limit) do if value <= limit do :ok else {:error, "#{category} #{value} exceeds maximum #{limit}"} end end @doc false # Gets limit from MarketPrecision struct defp get_mp_limit(%MarketPrecision{} = mp, "amount", "min"), do: mp.amount_min defp get_mp_limit(%MarketPrecision{} = mp, "amount", "max"), do: mp.amount_max defp get_mp_limit(%MarketPrecision{} = mp, "price", "min"), do: mp.price_min defp get_mp_limit(%MarketPrecision{} = mp, "price", "max"), do: mp.price_max defp get_mp_limit(%MarketPrecision{} = mp, "cost", "min"), do: mp.cost_min defp get_mp_limit(%MarketPrecision{} = mp, "cost", "max"), do: mp.cost_max defp get_mp_limit(_, _, _), do: nil @doc false # Gets a nested limit from a raw market map: limits["amount"]["min"] # Uses @limit_key_atoms for dual-key lookups (string and atom) without rescue. defp get_nested_limit(market, category, bound) do limits = get_field(market, "limits", :limits) with %{} <- limits, %{} = cat <- map_get_dual(limits, category, @limit_key_atoms[category]) do map_get_dual(cat, bound, @limit_key_atoms[bound]) else _ -> nil end end @doc false # Checks cost against limits (cost is nested under "cost" in limits) defp check_cost_limit(notional, bound, %MarketPrecision{} = mp, op) do limit = get_mp_limit(mp, "cost", bound) compare_limit(notional, limit, "cost", bound, op) end defp check_cost_limit(notional, bound, market, op) do limit = get_nested_limit(market, "cost", bound) compare_limit(notional, limit, "cost", bound, op) end @doc false # Calculates notional value, handling contracts defp calculate_notional(amount, price, market) do contract? = get_field(market, "contract", :contract) contract_size = get_field(market, "contractSize", :contract_size) || 1 inverse? = get_field(market, "inverse", :inverse) cond do contract? && inverse? -> amount * contract_size / price contract? -> amount * contract_size * price true -> amount * price end end @doc false # Checks precision (increment validation) defp check_precision(value, field, %MarketPrecision{} = mp, _opts) do increment = get_mp_increment(mp, field) validate_increment(value, increment, field) end defp check_precision(value, field, market, opts) when is_map(market) do case opts[:precision_mode] do nil -> :ok mode -> case MarketPrecision.from_market(market, mode) do {:error, :unsupported_precision_mode} -> :ok %MarketPrecision{} = mp -> check_precision(value, field, mp, []) end end end @doc false defp get_mp_increment(%MarketPrecision{} = mp, "price"), do: mp.price_increment defp get_mp_increment(%MarketPrecision{} = mp, "amount"), do: mp.amount_increment defp get_mp_increment(_, _), do: nil @doc false defp validate_increment(_value, nil, _field), do: :ok defp validate_increment(value, increment, field) when is_number(increment) and increment > 0 do remainder = :math.fmod(value, increment) epsilon = increment * @epsilon_multiplier if remainder < epsilon or increment - remainder < epsilon do :ok else {:error, "#{field} #{value} does not respect increment #{increment}"} end end defp validate_increment(_value, _increment, _field), do: :ok end