Current section
Files
Jump to
Current section
Files
lib/ccxt/trading/basis.ex
defmodule CCXT.Trading.Basis do
@moduledoc """
Cash-and-carry basis calculations for spot/perpetual arbitrage.
Pure functions for calculating basis between spot and derivative prices,
annualized yields, and futures curve analysis.
## Terminology
* **Basis** - Price difference between derivative and spot
* **Contango** - Futures trading above spot (positive basis)
* **Backwardation** - Futures trading below spot (negative basis)
## Example
CCXT.Trading.Basis.spot_perp(50_000, 50_100)
# => %{absolute: 100.0, percent: 0.2, direction: :contango}
CCXT.Trading.Basis.annualized(50_000, 50_100, 30)
# => 0.0243 (2.43% annualized yield)
"""
@days_per_year 365
@typedoc "Market structure direction relative to spot price"
@type direction :: :contango | :backwardation | :flat
@typedoc "Basis calculation result"
@type basis_result :: %{absolute: float(), percent: float(), direction: direction()}
@typedoc "Enriched futures contract with basis metrics"
@type futures_point :: %{
expiry: Date.t(),
price: number(),
days_to_expiry: integer(),
basis: number(),
basis_pct: float(),
annualized: float()
}
@typedoc "Exchange basis comparison result"
@type exchange_basis :: %{
exchange: atom() | String.t(),
basis: number(),
basis_pct: float(),
implied_apr: float()
}
@doc """
Calculate basis between spot and perpetual/futures price.
## Parameters
* `spot_price` - Current spot price
* `derivative_price` - Perpetual or futures price
## Returns
Map with:
* `:absolute` - Absolute difference (derivative - spot)
* `:percent` - Percentage difference
* `:direction` - `:contango` (positive) or `:backwardation` (negative)
## Example
CCXT.Trading.Basis.spot_perp(50_000, 50_100)
# => %{absolute: 100.0, percent: 0.2, direction: :contango}
CCXT.Trading.Basis.spot_perp(50_000, 49_900)
# => %{absolute: -100.0, percent: -0.2, direction: :backwardation}
"""
@spec spot_perp(number(), number()) :: basis_result()
def spot_perp(spot_price, derivative_price)
when is_number(spot_price) and spot_price > 0 and is_number(derivative_price) and derivative_price > 0 do
absolute = derivative_price - spot_price
percent = absolute / spot_price * 100
direction =
cond do
absolute > 0 -> :contango
absolute < 0 -> :backwardation
true -> :flat
end
%{
absolute: absolute * 1.0,
percent: percent,
direction: direction
}
end
@doc """
Calculate annualized basis yield.
For a futures contract with known expiry, calculates the annualized
return from a cash-and-carry trade.
## Parameters
* `spot_price` - Current spot price
* `futures_price` - Futures price
* `days_to_expiry` - Days until futures expiration
## Returns
Annualized yield as decimal (e.g., 0.05 = 5% APY).
## Example
# 0.2% basis over 30 days annualizes to ~2.43%
CCXT.Trading.Basis.annualized(50_000, 50_100, 30)
# => 0.0243
"""
@spec annualized(number(), number(), pos_integer()) :: float()
def annualized(spot_price, futures_price, days_to_expiry)
when is_number(spot_price) and spot_price > 0 and is_number(futures_price) and futures_price > 0 and
is_integer(days_to_expiry) and days_to_expiry > 0 do
basis_pct = (futures_price - spot_price) / spot_price
basis_pct * (@days_per_year / days_to_expiry)
end
@doc """
Build futures curve from multiple contracts.
Takes a list of futures with expiry dates and prices, returns the
term structure sorted by expiry.
## Parameters
* `spot_price` - Current spot price
* `futures` - List of maps with `:expiry` (Date) and `:price` fields
## Returns
List of maps sorted by expiry with basis and annualized yield added.
## Example
futures = [
%{expiry: ~D[2026-03-28], price: 51_000},
%{expiry: ~D[2026-01-31], price: 50_500}
]
CCXT.Trading.Basis.futures_curve(50_000, futures)
# => [
# %{expiry: ~D[2026-01-31], price: 50_500, basis: 500, basis_pct: 1.0, annualized: 0.122},
# %{expiry: ~D[2026-03-28], price: 51_000, basis: 1000, basis_pct: 2.0, annualized: 0.085}
# ]
"""
@spec futures_curve(number(), [map()]) :: [futures_point()]
def futures_curve(spot_price, futures) when is_number(spot_price) and spot_price > 0 and is_list(futures) do
today = Date.utc_today()
futures
|> Enum.map(fn future -> enrich_future(future, spot_price, today) end)
|> Enum.filter(fn f -> f.days_to_expiry >= 0 end)
|> Enum.sort_by(& &1.expiry, Date)
end
@doc false
# Adds calculated basis metrics (days to expiry, basis, annualized yield) to a future contract
defp enrich_future(future, spot_price, today) do
expiry = future[:expiry] || future["expiry"]
price = future[:price] || future["price"]
days = Date.diff(expiry, today)
basis = price - spot_price
basis_pct = basis / spot_price * 100
annualized_yield =
if days > 0 do
basis / spot_price * (@days_per_year / days)
else
0.0
end
%{
expiry: expiry,
price: price,
days_to_expiry: days,
basis: basis,
basis_pct: basis_pct,
annualized: annualized_yield
}
end
@doc """
Calculate implied funding rate from basis.
For perpetuals, basis tends to converge to spot via funding payments.
This estimates the implied 8-hour funding rate.
## Parameters
* `spot_price` - Current spot price
* `perp_price` - Perpetual price
* `funding_interval_hours` - Hours between funding (default: 8)
## Returns
Implied funding rate for one period.
## Example
CCXT.Trading.Basis.implied_funding(50_000, 50_050)
# => 0.000333 (0.033% per 8 hours)
"""
@spec implied_funding(number(), number(), pos_integer()) :: float()
def implied_funding(spot_price, perp_price, funding_interval_hours \\ 8)
when is_number(spot_price) and spot_price > 0 and is_number(perp_price) and perp_price > 0 and
is_integer(funding_interval_hours) and funding_interval_hours > 0 do
# Basis as a percentage
basis_pct = (perp_price - spot_price) / spot_price
# Number of funding periods per day
periods_per_day = 24 / funding_interval_hours
# Implied rate = basis / periods per day (assuming basis converges in ~1 day)
basis_pct / periods_per_day
end
@doc """
Compare basis across multiple exchanges.
## Parameters
* `exchanges` - List of maps with `:exchange`, `:spot`, and `:perp` fields
## Returns
List sorted by basis (highest first) with calculated metrics.
## Example
exchanges = [
%{exchange: :binance, spot: 50_000, perp: 50_100},
%{exchange: :okx, spot: 50_000, perp: 50_150}
]
CCXT.Trading.Basis.compare(exchanges)
# => [
# %{exchange: :okx, basis: 150, basis_pct: 0.3, implied_apr: 109.5},
# %{exchange: :binance, basis: 100, basis_pct: 0.2, implied_apr: 73.0}
# ]
"""
@spec compare([map()]) :: [exchange_basis()]
def compare(exchanges) when is_list(exchanges) do
exchanges
|> Enum.map(&calculate_exchange_basis/1)
|> Enum.sort_by(& &1.basis, :desc)
end
@doc false
# Calculates basis metrics for a single exchange entry (spot/perp spread and implied APR)
defp calculate_exchange_basis(exchange) do
spot = exchange[:spot] || exchange["spot"]
perp = exchange[:perp] || exchange["perp"]
name = exchange[:exchange] || exchange["exchange"]
basis = perp - spot
basis_pct = basis / spot * 100
# Assume basis converges in ~1 day, annualized by days per year
# Use decimal rate (basis/spot), not percentage (basis_pct)
implied_apr = basis / spot * @days_per_year * 100
%{
exchange: name,
basis: basis,
basis_pct: basis_pct,
implied_apr: implied_apr
}
end
@doc """
Check if basis is at arbitrage-worthy levels.
## Parameters
* `spot_price` - Current spot price
* `derivative_price` - Perpetual or futures price
* `threshold_pct` - Minimum basis percentage to consider (default: 0.1 = 0.1%)
## Example
CCXT.Trading.Basis.arbitrage_opportunity?(50_000, 50_100, 0.1)
# => true (0.2% basis > 0.1% threshold)
"""
@spec arbitrage_opportunity?(number(), number(), float()) :: boolean()
def arbitrage_opportunity?(spot_price, derivative_price, threshold_pct \\ 0.1)
when is_number(spot_price) and spot_price > 0 and is_number(derivative_price) and derivative_price > 0 and
is_float(threshold_pct) do
basis_pct = abs(derivative_price - spot_price) / spot_price * 100
basis_pct >= threshold_pct
end
end