import logging
import datetime
import math
import warnings
from decimal import Decimal, ROUND_DOWN
from typing import Optional
import pandas as pd
from tradeexecutor.backtest.backtest_execution import BacktestExecution
from tradeexecutor.backtest.backtest_routing import BacktestRoutingModel
from tradeexecutor.ethereum.uniswap_v2.uniswap_v2_routing import UniswapV2Routing
from tradeexecutor.state.identifier import TradingPairIdentifier
from tradeexecutor.strategy.execution_model import ExecutionModel
from tradeexecutor.state.types import USDollarPrice, Percent, USDollarAmount, AnyTimestamp
from tradeexecutor.strategy.pricing_model import PricingModel
from tradeexecutor.strategy.trade_pricing import TradePricing
from tradeexecutor.strategy.routing import RoutingModel
from tradeexecutor.strategy.trading_strategy_universe import TradingStrategyUniverse, translate_trading_pair
from tradingstrategy.candle import GroupedCandleUniverse
from tradingstrategy.liquidity import GroupedLiquidityUniverse, LiquidityDataUnavailable
from tradingstrategy.timebucket import TimeBucket
logger = logging.getLogger(__name__)
[docs]class BacktestPricing(PricingModel):
"""Look up the historical prices.
- By default, assume we can get buy/sell at
open price of the timestamp
- Different pricing model can be used for rebalances (more coarse)
and stop losses (more granular)
- This is a simple model and does not use liquidity data
for the price impact estimation
- We provide `data_delay_tolerance` to deal with potential
gaps in price data
"""
[docs] def __init__(
self,
candle_universe: GroupedCandleUniverse,
routing_model: RoutingModel,
data_delay_tolerance=pd.Timedelta("2d"),
candle_timepoint_kind="open",
very_small_amount=Decimal("0.10"),
time_bucket: Optional[TimeBucket] = None,
allow_missing_fees=False,
trading_fee_override: float | None=None,
liquidity_universe: GroupedLiquidityUniverse | None = None,
):
"""
:param candle_universe:
Candles where our backtesing date comes from
:param routing_model:
How do we route trades between different pairs
TODO: Now ignored
:param data_delay_tolerance:
How long time gaps we allow in the backtesting data
before aborting the backtesting with an exception.
This is an safety check for bad data.
Sometimes there cannot be trades for days
if the blockchain has been halted,
and thus no price data available.
:param candle_timepoint_kind:
Do we use opening or closing price in backtesting
:param very_small_amount:
What kind o a test amount we do use for a trade
when we do not know the actual size of the trade.
:param time_bucket:
The granularity of the price data.
Currently used for diagnostics and debug only.
:param allow_missing_fees:
Allow trading pairs with missing fee information.
All trading pairs should have good fee information by default,
unless dealing with legacy tests.
:param trading_fee_override:
Override the trading fee with a custom fee.
See :py:meth:`set_trading_fee_override`.
:param liquidity_universe:
Used in TVL based position size limit.
See :py:mod:`tradeexecutor.strategy.tvl_size_risk`.
"""
# TODO: Remove later - now to support some old code111
if isinstance(candle_universe, TradingStrategyUniverse):
candle_universe = candle_universe.data_universe.candles
assert isinstance(candle_universe, GroupedCandleUniverse), f"Got candles in wrong format: {candle_universe.__class__}"
self.candle_universe = candle_universe
self.very_small_amount = very_small_amount
self.routing_model = routing_model
self.candle_timepoint_kind = candle_timepoint_kind
self.data_delay_tolerance = data_delay_tolerance
self.time_bucket = time_bucket
self.allow_missing_fees = allow_missing_fees
self.trading_fee_override = trading_fee_override
self.liquidity_universe = liquidity_universe
def __repr__(self):
return f"<BacktestSimplePricingModel bucket: {self.time_bucket}, candles: {self.candle_universe}>"
[docs] def get_pair_for_id(self, internal_id: int) -> Optional[TradingPairIdentifier]:
"""Look up a trading pair.
Useful if a strategy is only dealing with pair integer ids.
"""
warnings.warn("Do not use internal ids as they are not stable ids."
"Instead use chain id + address tuples")
pair = self.universe.pairs.get_pair_by_id(internal_id)
if not pair:
return None
return translate_trading_pair(pair)
def check_supported_quote_token(self, pair: TradingPairIdentifier):
assert pair.quote.address == self.routing_model.reserve_token_address, f"Quote token {self.routing_model.reserve_token_address} not supported for pair {pair}, pair tokens are {pair.base.address} - {pair.quote.address}"
[docs] def get_sell_price(
self,
ts: datetime.datetime,
pair: TradingPairIdentifier,
quantity: Optional[Decimal]
) -> TradePricing:
assert pair is not None, "Pair missing"
if quantity:
assert quantity > 0, f"Cannot sell negative amounts: {quantity} {pair}"
# TODO: Include price impact
pair_id = pair.internal_id
mid_price, delay = self.candle_universe.get_price_with_tolerance(
pair_id,
ts,
tolerance=self.data_delay_tolerance,
kind=self.candle_timepoint_kind,
pair_name_hint=pair.get_ticker(),
)
pair_fee = self.get_pair_fee(ts, pair)
if pair_fee:
reserve = float(quantity) * mid_price
lp_fee = float(reserve) * pair_fee
# Move price below mid price
price = mid_price * (1 - pair_fee)
assert lp_fee > 0, f"After simulating SELL trade, got non-positive LP fee: {pair} {quantity}: ${lp_fee}.\n"\
f"Mid price: {mid_price}, quantity: {quantity}, reserve: {reserve} , pair fee: {pair_fee}\n" \
else:
# Fee information not available
if not self.allow_missing_fees:
raise AssertionError(f"Pair lacks fee information: {pair}")
price = mid_price
lp_fee = None
return TradePricing(
price=float(price),
mid_price=float(mid_price),
lp_fee=lp_fee,
pair_fee=pair_fee,
market_feed_delay=delay.to_pytimedelta(),
side=False,
path=[pair]
)
[docs] def get_buy_price(self,
ts: datetime.datetime,
pair: TradingPairIdentifier,
reserve: Optional[Decimal]) -> TradePricing:
"""Get the price for a buy transaction."""
assert reserve is not None and reserve > 0, f"Tried to make a buy price estimation for zero or negative reserve amount.\n" \
f"Got reserve: {reserve} \n" \
f"For a buy estimation, please fill in the allocated reserve amount for: \n" \
f"{pair}.\n"
# TODO: Include price impact
pair_id = pair.internal_id
mid_price, delay = self.candle_universe.get_price_with_tolerance(
pair_id,
ts,
tolerance=self.data_delay_tolerance,
kind=self.candle_timepoint_kind,
)
assert mid_price not in (0, math.nan), f"Got bad mid price: {mid_price}"
pair_fee = self.get_pair_fee(ts, pair)
if pair_fee is not None:
lp_fee = float(reserve) * pair_fee
# Move price above mid price
price = mid_price * (1 + pair_fee)
assert lp_fee > 0, f"Got bad fee: {pair} {reserve}: {lp_fee}"
else:
# Fee information not available
if not self.allow_missing_fees:
raise AssertionError(f"Pair lacks fee information: {pair}")
lp_fee = None
price = mid_price
assert price not in (0, math.nan) and price > 0, f"Got bad price: {price}"
return TradePricing(
price=float(price),
mid_price=float(mid_price),
lp_fee=lp_fee,
pair_fee=pair_fee,
market_feed_delay=delay.to_pytimedelta(),
side=True,
path=[pair]
)
[docs] def get_mid_price(self,
ts: datetime.datetime,
pair: TradingPairIdentifier) -> USDollarPrice:
"""Get the mid price by the candle."""
pair_id = pair.internal_id
price, delay = self.candle_universe.get_price_with_tolerance(
pair_id,
ts,
tolerance=self.data_delay_tolerance,
kind=self.candle_timepoint_kind,
)
return float(price)
[docs] def quantize_base_quantity(self, pair: TradingPairIdentifier, quantity: Decimal, rounding=ROUND_DOWN) -> Decimal:
"""Convert any base token quantity to the native token units by its ERC-20 decimals."""
assert isinstance(pair, TradingPairIdentifier)
decimals = pair.base.decimals
return Decimal(quantity).quantize((Decimal(10) ** Decimal(-decimals)), rounding=ROUND_DOWN)
[docs] def get_pair_fee(
self,
ts: datetime.datetime,
pair: TradingPairIdentifier,
) -> Optional[float]:
"""Figure out the fee from a pair or a routing."""
if self.trading_fee_override is not None:
return self.trading_fee_override
if pair.fee:
return pair.fee
return self.routing_model.get_default_trading_fee()
[docs] def set_trading_fee_override(
self,
trading_fee_override: Percent | None
):
self.trading_fee_override = trading_fee_override
[docs] def get_usd_tvl(
self,
timestamp: AnyTimestamp | None,
pair: TradingPairIdentifier
) -> USDollarAmount:
"""Get the available liquidity at the opening of the day."""
assert self.liquidity_universe is not None, "liquidity_universe not passed to BacktestPricing constructor"
if isinstance(timestamp, datetime.datetime):
timestamp = pd.Timestamp(timestamp)
try:
tvl, when = self.liquidity_universe.get_liquidity_with_tolerance(
pair.internal_id,
timestamp,
tolerance=self.data_delay_tolerance,
kind="open"
)
except LiquidityDataUnavailable as e:
# Show the pair naem
raise LiquidityDataUnavailable(f"Could not read TVL/liquidity data for {pair} - see nested exception for details") from e
assert tvl is not None, "get_liquidity_with_tolerance() returned None: likely cause is that synthetic backtest data period mismatches backtest"
return tvl
[docs]def backtest_pricing_factory(
execution_model: ExecutionModel,
universe: TradingStrategyUniverse,
routing_model: UniswapV2Routing) -> BacktestPricing:
assert isinstance(universe, TradingStrategyUniverse)
assert isinstance(execution_model, BacktestExecution), f"Execution model not compatible with this execution model. Received {execution_model}"
assert isinstance(routing_model, (BacktestRoutingModel, UniswapV2Routing)), f"This pricing method only works with Uniswap routing model, we received {routing_model}"
return BacktestPricing(
universe.data_universe.candles,
routing_model)