Current section
Files
Jump to
Current section
Files
lib/ccxt/recovery.ex
defmodule CCXT.Recovery do
@moduledoc """
Order recovery helpers for finding orders after timeout/error.
Essential for market makers after timeout/error - did my order execute?
Provides methods to search recent orders by time window, symbol, side,
amount, and client order ID.
## Usage
# Find recent orders after a timeout
{:ok, orders} = Recovery.find_recent_orders(CCXT.Bybit, credentials,
symbol: "BTC/USDT",
since: System.os_time(:millisecond) - 60_000,
side: :buy
)
# Find order by client_order_id (most reliable)
{:ok, order} = Recovery.find_by_client_id(CCXT.Bybit, credentials,
"my-order-123",
symbol: "BTC/USDT"
)
## Recovery Strategy
1. **Best**: Use `client_order_id` when creating orders, then recover with `find_by_client_id/4`
2. **Good**: Search by symbol + side + time window with `find_recent_orders/3`
3. **Fallback**: Search by amount tolerance if you know the expected order size
## Notes
- Always set `client_order_id` on order creation for reliable recovery
- Time windows should account for network latency and exchange processing
- Amount tolerance matching is fuzzy - use with caution
"""
alias CCXT.Credentials
@default_time_window_ms 60_000
@typedoc "Order status filter"
@type status_filter :: :open | :closed | :all
@typedoc "Order side filter"
@type side_filter :: :buy | :sell
@doc """
Find orders matching criteria within a time window.
Essential for market makers after timeout/error - did my order execute?
## Parameters
- `exchange_module` - Exchange module (e.g., `CCXT.Bybit`)
- `credentials` - API credentials
- `opts` - Filter options:
- `:symbol` - Filter by symbol (required)
- `:side` - Filter by side (`:buy` or `:sell`)
- `:since` - Start time in ms timestamp or DateTime (default: 60s ago)
- `:until` - End time in ms timestamp or DateTime (default: now)
- `:status` - Filter by status (`:open`, `:closed`, `:all`) (default: `:all`)
- `:amount` - Expected order amount for fuzzy matching
- `:amount_tolerance` - Match orders within X% of expected amount (default: 0.01 = 1%)
## Examples
# Find recent buy orders for BTC/USDT
{:ok, orders} = CCXT.Recovery.find_recent_orders(CCXT.Bybit, credentials,
symbol: "BTC/USDT",
side: :buy,
since: System.os_time(:millisecond) - 60_000
)
# Find orders matching expected amount (within 1%)
{:ok, orders} = CCXT.Recovery.find_recent_orders(CCXT.Bybit, credentials,
symbol: "BTC/USDT",
amount: 0.5,
amount_tolerance: 0.01
)
"""
@spec find_recent_orders(module(), Credentials.t(), keyword()) ::
{:ok, [map()]} | {:error, term()}
def find_recent_orders(exchange_module, credentials, opts \\ []) do
symbol = Keyword.fetch!(opts, :symbol)
side = Keyword.get(opts, :side)
status = Keyword.get(opts, :status, :all)
since = normalize_timestamp(Keyword.get(opts, :since, default_since()))
until_time = normalize_timestamp(Keyword.get(opts, :until, System.os_time(:millisecond)))
amount = Keyword.get(opts, :amount)
amount_tolerance = Keyword.get(opts, :amount_tolerance, 0.01)
with {:ok, orders} <- fetch_orders_by_status(exchange_module, credentials, symbol, status, since) do
filtered =
orders
|> filter_by_time(since, until_time)
|> filter_by_side(side)
|> filter_by_amount(amount, amount_tolerance)
{:ok, filtered}
end
end
@doc """
Check if an order with given client_order_id exists.
Most reliable recovery method if you set client_order_id on create.
Exchange support varies - some exchanges require symbol, others don't.
## Parameters
- `exchange_module` - Exchange module (e.g., `CCXT.Bybit`)
- `credentials` - API credentials
- `client_order_id` - The client order ID you set when creating the order
- `opts` - Options:
- `:symbol` - Symbol to search (may be required by some exchanges)
- `:since` - Start time to limit search (optional)
## Examples
# Find order by client ID
{:ok, order} = CCXT.Recovery.find_by_client_id(CCXT.Bybit, credentials,
"my-order-123",
symbol: "BTC/USDT"
)
# Handle all possible outcomes
case CCXT.Recovery.find_by_client_id(CCXT.Bybit, credentials, "my-order-123") do
{:ok, order} -> Logger.info("Order found: \#{order["status"]}")
{:error, :not_found} -> Logger.warning("Order not found")
{:error, reason} -> Logger.error("Recovery failed: \#{inspect(reason)}")
end
"""
@spec find_by_client_id(module(), Credentials.t(), String.t(), keyword()) ::
{:ok, map()} | {:error, :not_found | term()}
def find_by_client_id(exchange_module, credentials, client_order_id, opts \\ []) do
symbol = Keyword.get(opts, :symbol)
since = normalize_timestamp(Keyword.get(opts, :since, default_since()))
# First try fetch_order if exchange supports fetching by client ID
# Fall back to searching recent orders
case try_fetch_by_client_id(exchange_module, credentials, client_order_id, symbol) do
{:ok, order} ->
{:ok, order}
{:error, :not_supported} ->
# Fall back to searching through recent orders
search_orders_for_client_id(exchange_module, credentials, client_order_id, symbol, since)
{:error, reason} ->
{:error, reason}
end
end
# ===========================================================================
# Private Helpers
# ===========================================================================
@doc false
@spec default_since() :: integer()
defp default_since do
System.os_time(:millisecond) - @default_time_window_ms
end
@doc false
@spec normalize_timestamp(integer() | DateTime.t() | nil) :: integer()
defp normalize_timestamp(nil), do: default_since()
defp normalize_timestamp(ts) when is_integer(ts), do: ts
defp normalize_timestamp(%DateTime{} = dt) do
DateTime.to_unix(dt, :millisecond)
end
@doc false
@spec fetch_orders_by_status(module(), Credentials.t(), String.t(), status_filter(), integer()) ::
{:ok, [map()]} | {:error, term()}
defp fetch_orders_by_status(exchange_module, credentials, symbol, status, since) do
case status do
:open ->
call_if_exported(exchange_module, :fetch_open_orders, [credentials, symbol, since: since])
:closed ->
call_if_exported(exchange_module, :fetch_closed_orders, [credentials, symbol, since: since])
:all ->
# Try fetch_orders first, fall back to combining open + closed
case call_if_exported(exchange_module, :fetch_orders, [credentials, symbol, since: since]) do
{:ok, orders} ->
{:ok, orders}
{:error, {:function_not_exported, _}} ->
combine_open_and_closed(exchange_module, credentials, symbol, since)
{:error, reason} ->
{:error, reason}
end
end
end
@doc false
@spec combine_open_and_closed(module(), Credentials.t(), String.t(), integer()) ::
{:ok, [map()]} | {:error, term()}
defp combine_open_and_closed(exchange_module, credentials, symbol, since) do
with {:ok, open} <-
call_if_exported(exchange_module, :fetch_open_orders, [credentials, symbol, since: since]),
{:ok, closed} <-
call_if_exported(exchange_module, :fetch_closed_orders, [credentials, symbol, since: since]) do
{:ok, open ++ closed}
end
end
@doc false
@spec call_if_exported(module(), atom(), list()) :: {:ok, term()} | {:error, term()}
defp call_if_exported(module, function, args) do
if function_exported?(module, function, length(args)) do
apply(module, function, args)
else
{:error, {:function_not_exported, {module, function, length(args)}}}
end
rescue
e -> {:error, {:exception, Exception.message(e)}}
catch
kind, reason -> {:error, {kind, reason}}
end
@doc false
@spec filter_by_time([map()], integer(), integer()) :: [map()]
defp filter_by_time(orders, since, until_time) do
Enum.filter(orders, fn order ->
timestamp = get_order_timestamp(order)
timestamp >= since and timestamp <= until_time
end)
end
@doc false
@spec get_order_timestamp(map()) :: integer()
defp get_order_timestamp(order) do
# Orders may have timestamp as integer or DateTime
case Map.get(order, "timestamp") || Map.get(order, :timestamp) do
nil -> 0
ts when is_integer(ts) -> ts
%DateTime{} = dt -> DateTime.to_unix(dt, :millisecond)
_ -> 0
end
end
@doc false
@spec filter_by_side([map()], side_filter() | nil) :: [map()]
defp filter_by_side(orders, nil), do: orders
defp filter_by_side(orders, side) do
side_string = Atom.to_string(side)
Enum.filter(orders, fn order ->
order_side = Map.get(order, "side") || Map.get(order, :side)
order_side == side_string or order_side == side
end)
end
@doc false
@spec filter_by_amount([map()], number() | nil, number()) :: [map()]
defp filter_by_amount(orders, nil, _tolerance), do: orders
defp filter_by_amount(orders, expected_amount, tolerance) do
min_amount = expected_amount * (1 - tolerance)
max_amount = expected_amount * (1 + tolerance)
Enum.filter(orders, fn order ->
amount = get_order_amount(order)
amount >= min_amount and amount <= max_amount
end)
end
@doc false
@spec get_order_amount(map()) :: number()
defp get_order_amount(order) do
case Map.get(order, "amount") || Map.get(order, :amount) do
nil -> 0
amount when is_number(amount) -> amount
_ -> 0
end
end
@doc false
@spec try_fetch_by_client_id(module(), Credentials.t(), String.t(), String.t() | nil) ::
{:ok, map()} | {:error, :not_supported | term()}
defp try_fetch_by_client_id(exchange_module, credentials, client_order_id, symbol) do
# Some exchanges support fetching order directly by client ID
# This is exchange-specific - most use fetch_order with the client ID
cond do
function_exported?(exchange_module, :fetch_order_by_client_id, 3) ->
exchange_module.fetch_order_by_client_id(credentials, client_order_id, symbol)
function_exported?(exchange_module, :fetch_order, 3) ->
# Try using client_order_id as the order ID (some exchanges support this)
case exchange_module.fetch_order(credentials, client_order_id, symbol) do
{:ok, order} -> {:ok, order}
{:error, _} -> {:error, :not_supported}
end
true ->
{:error, :not_supported}
end
rescue
_ -> {:error, :not_supported}
end
@doc false
@spec search_orders_for_client_id(module(), Credentials.t(), String.t(), String.t() | nil, integer()) ::
{:ok, map()} | {:error, :not_found | term()}
defp search_orders_for_client_id(_exchange_module, _credentials, _client_order_id, nil, _since) do
{:error, {:symbol_required, "Symbol is required to search orders on this exchange"}}
end
defp search_orders_for_client_id(exchange_module, credentials, client_order_id, symbol, since) do
search_opts = [symbol: symbol, since: since, status: :all]
with {:ok, orders} <- find_recent_orders(exchange_module, credentials, search_opts) do
case find_order_with_client_id(orders, client_order_id) do
nil -> {:error, :not_found}
order -> {:ok, order}
end
end
end
@doc false
@spec find_order_with_client_id([map()], String.t()) :: map() | nil
defp find_order_with_client_id(orders, client_order_id) do
Enum.find(orders, fn order ->
order_client_id =
Map.get(order, "clientOrderId") ||
Map.get(order, :clientOrderId) ||
Map.get(order, "client_order_id") ||
Map.get(order, :client_order_id)
order_client_id == client_order_id
end)
end
end