Current section
Files
Jump to
Current section
Files
lib/ccxt/order/sanity.ex
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