fisk/core/trading/market.py

503 lines
18 KiB
Python
Raw Normal View History

from decimal import Decimal as D
2023-01-11 21:12:43 +00:00
from core.exchanges import common
from core.exchanges.convert import get_price, side_to_direction
2022-12-18 16:55:09 +00:00
from core.lib.notify import sendmsg
from core.models import Account, Strategy, Trade
from core.trading import assetfilter, checks
2022-12-20 07:20:26 +00:00
from core.trading.crossfilter import crossfilter
from core.trading.risk import check_risk
from core.util import logs
log = logs.get_logger(__name__)
def convert_trades_to_usd(account, trades):
"""
2023-01-11 19:46:47 +00:00
Convert a list of trades to USD. Input will also be mutated.
:param account: Account object
:param trades: List of trades
:return: List of trades, with amount_usd added
"""
for trade in trades:
amount = trade["amount"]
symbol = trade["symbol"]
side = trade["side"]
direction = side_to_direction(side)
base, quote = get_base_quote(account.exchange, symbol)
2023-01-11 19:46:47 +00:00
amount_usd = common.to_currency(direction, account, amount, base, "USD")
trade["trade_amount_usd"] = amount_usd
if "stop_loss_percent" in trade:
trade["stop_loss_usd"] = (trade["stop_loss_percent"] / 100) * amount_usd
if "take_profit_percent" in trade:
trade["take_profit_usd"] = (trade["take_profit_percent"] / 100) * amount_usd
if "trailing_stop_loss_percent" in trade:
trade["trailing_stop_loss_usd"] = (
trade["trailing_stop_loss_percent"] / 100
) * amount_usd
2023-01-11 19:46:47 +00:00
return trades
def get_base_quote(exchange, symbol):
"""
Get the base and quote currencies from a symbol.
:param exchange: Exchange name
:param symbol: Symbol
:return: Tuple of base and quote currencies
"""
if exchange == "alpaca":
separator = "/"
elif exchange == "oanda":
separator = "_"
2023-02-17 07:20:15 +00:00
else:
separator = "_"
base, quote = symbol.split(separator)
return (base, quote)
def get_trade_size_in_base(direction, account, strategy, cash_balance, base):
"""
Get the trade size in the base currency.
:param direction: Direction of the trade
:param account: Account object
:param strategy: Strategy object
:param cash_balance: Cash balance in the Account's base currency
:param base: Base currency
:return: Trade size in the base currency
"""
# Convert the trade size in percent to a ratio
2023-02-15 18:41:08 +00:00
trade_size_as_ratio = D(strategy.order_settings.trade_size_percent) / D(100)
log.debug(f"Trade size as ratio: {trade_size_as_ratio}")
# Multiply with cash balance to get the trade size in the account's
# base currency
amount_fiat = D(trade_size_as_ratio) * D(cash_balance)
log.debug(f"Trade size: {amount_fiat}")
# Convert the trade size to the base currency
if account.currency.lower() == base.lower():
trade_size_in_base = amount_fiat
else:
2023-01-11 19:46:47 +00:00
trade_size_in_base = common.to_currency(
direction, account, amount_fiat, account.currency, base
)
log.debug(f"Trade size in base: {trade_size_in_base}")
return trade_size_in_base
2022-11-15 07:20:17 +00:00
def get_tp(direction, take_profit_percent, price):
"""
2022-11-15 07:20:17 +00:00
Get the take profit price.
:param direction: Direction of the trade
:param strategy: Strategy object
2022-11-15 07:20:17 +00:00
:param price: Entry price
"""
2022-11-15 07:20:17 +00:00
# Convert to ratio
take_profit_as_ratio = D(take_profit_percent) / D(100)
log.debug(f"Take profit as ratio: {take_profit_as_ratio}")
2022-11-15 07:20:17 +00:00
take_profit_var = D(price) * D(take_profit_as_ratio)
log.debug(f"Take profit var: {take_profit_var}")
if direction == "buy":
take_profit = D(price) + D(take_profit_var)
elif direction == "sell":
take_profit = D(price) - D(take_profit_var)
log.debug(f"Take profit: {take_profit}")
return take_profit
def get_sl(direction, stop_loss_percent, price, return_var=False):
"""
Get the stop loss price.
Also used for trailing stop loss.
:param direction: Direction of the trade
:param strategy: Strategy object
:param price: Entry price
"""
# Convert to ratio
stop_loss_as_ratio = D(stop_loss_percent) / D(100)
log.debug(f"Stop loss as ratio: {stop_loss_as_ratio}")
stop_loss_var = D(price) * D(stop_loss_as_ratio)
log.debug(f"Stop loss var: {stop_loss_var}")
2022-11-15 07:20:17 +00:00
if return_var:
return stop_loss_var
if direction == "buy":
stop_loss = D(price) - D(stop_loss_var)
elif direction == "sell":
stop_loss = D(price) + D(stop_loss_var)
2022-11-15 07:20:17 +00:00
log.debug(f"Stop loss: {stop_loss}")
2022-11-15 07:20:17 +00:00
return stop_loss
2022-11-15 07:20:17 +00:00
def get_tp_sl(direction, strategy, price, round_to=None):
2022-11-15 07:20:17 +00:00
"""
Get the take profit and stop loss prices.
:param direction: Direction of the trade
:param strategy: Strategy object
:param price: Price of the trade
:return: Take profit and stop loss prices
"""
cast = {}
2023-02-15 18:41:08 +00:00
if strategy.order_settings.take_profit_percent != 0:
cast["take_profit"] = get_tp(
direction, strategy.order_settings.take_profit_percent, price
)
2023-02-15 18:41:08 +00:00
if strategy.order_settings.stop_loss_percent != 0:
cast["stop_loss"] = get_sl(
direction, strategy.order_settings.stop_loss_percent, price
)
2022-11-15 07:20:17 +00:00
# Look up the TSL if required by the strategy
2023-02-15 18:41:08 +00:00
if strategy.order_settings.trailing_stop_loss_percent != 0:
cast["trailing_stop_loss"] = get_sl(
2023-02-15 18:41:08 +00:00
direction,
strategy.order_settings.trailing_stop_loss_percent,
price,
return_var=True,
2022-11-15 07:20:17 +00:00
)
if round_to:
for key in cast:
cast[key] = float(round(cast[key], round_to))
2022-11-15 07:20:17 +00:00
return cast
def get_price_bound(direction, strategy, current_price):
"""
Get the price bound for a given price using the slippage from the strategy.
* Check that the price of the callback is within the callback price deviation of the
current price
* Calculate the price bounds such that the maximum slippage should be within the
price slippage relative to the current price.
Note that the maximum actual slippage may be as high as the sum of these two values.
:param direction: Direction of the trade
:param strategy: Strategy object
:param price: Price of the trade
:param current_price: current price from the exchange
:return: Price bound
"""
# Convert the maximum price slippage to a ratio
2023-02-15 18:15:36 +00:00
if strategy.risk_model is not None:
price_slippage_as_ratio = D(strategy.risk_model.price_slippage_percent) / D(100)
else:
# Pretty liberal default
price_slippage_as_ratio = D(2.5) / D(100)
log.debug(f"Maximum price slippage as ratio: {price_slippage_as_ratio}")
# Calculate the price bound by multiplying with the price
# The price bound is the worst price we are willing to pay for the trade
price_slippage = D(current_price) * D(price_slippage_as_ratio)
log.debug(f"Maximum deviation from callback price: {price_slippage}")
current_price_slippage = D(current_price) * D(price_slippage_as_ratio)
log.debug(f"Maximum deviation from current price: {current_price_slippage}")
2022-11-22 08:10:38 +00:00
# Price bound is the worst price we are willing to pay for the trade
# For buys, a higher price is worse
if direction == "buy":
2022-11-22 08:10:38 +00:00
price_bound = D(current_price) + D(price_slippage)
2022-11-22 08:10:38 +00:00
# For sells, a lower price is worse
elif direction == "sell":
2022-11-22 08:10:38 +00:00
price_bound = D(current_price) - D(price_slippage)
log.debug(f"Price bound: {price_bound}")
return price_bound
def get_precision(account, symbol):
instruments = account.instruments
if not instruments:
2023-02-14 07:20:47 +00:00
log.error(f"No instruments found for {account}")
2023-02-14 07:20:47 +00:00
sendmsg(account.user, f"No instruments found for {account}", title="Error")
return (None, None)
# Extract the information for the symbol
instrument = account.client.extract_instrument(instruments, symbol)
if not instrument:
2023-02-14 07:20:47 +00:00
sendmsg(account.user, f"Symbol not found: {symbol}", title="Error")
log.error(f"Symbol not found: {symbol}")
return (None, None)
# Get the required precision
try:
trade_precision = instrument["tradeUnitsPrecision"]
display_precision = instrument["displayPrecision"]
return (trade_precision, display_precision)
except KeyError:
sendmsg(
account.user,
f"Precision not found for {symbol} from {instrument}",
title="Error",
)
2023-02-14 07:20:47 +00:00
log.error(f"Precision not found for {symbol} from {instrument}")
return (None, None)
# TODO: create_trade helper
2023-01-11 20:53:04 +00:00
# account, strategy, base, quote, direction
# pull all data to create the trade from the strategy
# complete all crossfilter and risk management checks, etc.
def execute_strategy(callback, strategy, func):
"""
Execute a strategy.
:param callback: Callback object
:param strategy: Strategy object
"""
2022-12-06 19:46:06 +00:00
# Only check times for entries. We can always exit trades and set trends.
if func == "entry":
within_trading_times = checks.within_trading_times(strategy)
if not within_trading_times:
return
2023-01-01 15:46:40 +00:00
# Don't touch the account if it's disabled.
# We still want to set trends, though.
if func in ("entry", "exit"):
if not strategy.account.enabled:
log.debug("Account is disabled, exiting")
return
# Instruments supported by the account
if not strategy.account.instruments:
strategy.account.update_info()
# Refresh account object
strategy.account = Account.objects.get(id=strategy.account.id)
# Shorten some hook, strategy and callback vars for convenience
user = strategy.user
account = strategy.account
hook = callback.hook
signal = callback.signal
base = callback.base
quote = callback.quote
direction = signal.direction
# Don't be silly
if callback.exchange != account.exchange:
log.error("Market exchange differs from account exchange.")
sendmsg(user, "Market exchange differs from account exchange.", title="Error")
return
# Get the pair we are trading
2023-01-11 19:46:47 +00:00
symbol = common.get_pair(account, base, quote)
if not symbol:
sendmsg(user, f"Symbol not supported by account: {symbol}", title="Error")
log.error(f"Symbol not supported by account: {symbol}")
return
# Get the precision for the symbol
trade_precision, display_precision = get_precision(account, symbol)
if trade_precision is None or display_precision is None:
2023-02-14 07:20:47 +00:00
# sendmsg(user, f"Precision not found for {symbol}", title="Error")
log.error(f"Market precision not found for {symbol} from {account}")
return
# Round the received price to the display precision
price = round(D(callback.price), display_precision)
log.debug(f"Extracted price of quote: {price}")
current_price = get_price(account, direction, symbol)
log.debug(f"Callback price: {price}")
log.debug(f"Current price: {current_price}")
# Check callback price deviation
within_callback_price_deviation = checks.within_callback_price_deviation(
strategy, price, current_price
)
if not within_callback_price_deviation:
log.debug("Not within callback price deviation")
return
# Calculate price bound and round to the display precision
# Also enforces max price slippage
price_bound = get_price_bound(direction, strategy, current_price)
if not price_bound:
return
price_bound = round(price_bound, display_precision)
# Callback now verified
2023-02-11 18:22:49 +00:00
# Check against the asset groups
2023-02-14 07:20:47 +00:00
if func == "entry" and strategy.asset_group is not None:
allowed = assetfilter.get_allowed(strategy.asset_group, base, quote, direction)
2023-02-14 07:20:47 +00:00
log.debug(f"Asset filter allowed for {strategy.asset_group}: {allowed}")
if not allowed:
log.debug(
2023-02-14 07:20:47 +00:00
f"Denied trading {symbol} due to asset filter {strategy.asset_group}"
)
sendmsg(
user,
2023-02-14 07:20:47 +00:00
f"Denied trading {symbol} due to asset filter {strategy.asset_group}",
title="Asset filter denied",
)
return
2023-02-11 18:22:49 +00:00
if func == "exit":
check_exit = crossfilter(account, symbol, direction, func)
if check_exit is None:
return
if not check_exit:
log.debug("Exit conditions not met.")
return
if check_exit["action"] == "close":
log.debug(f"Closing position on exit signal: {symbol}")
side = check_exit["side"]
response = account.client.close_position(side, symbol)
log.debug(f"Close position response: {response}")
sendmsg(
user,
f"Closing {side} position on exit signal: {symbol}",
title="Exit signal",
)
return
2022-12-06 19:46:06 +00:00
# Set the trend
elif func == "trend":
if strategy.trends is None:
strategy.trends = {}
strategy.trends[symbol] = direction
strategy.save()
log.debug(f"Set trend for {symbol}: {direction}")
return
# Check if we are trading against the trend
within_trends = checks.within_trends(strategy, symbol, direction)
if not within_trends:
return
2022-12-06 19:46:06 +00:00
2023-02-15 18:41:08 +00:00
type = strategy.order_settings.order_type
# Get the account's balance in the native account currency
cash_balance = strategy.account.client.get_balance()
log.debug(f"Cash balance: {cash_balance}")
# Convert the trade size, which is currently in the account's base currency,
# to the base currency of the pair we are trading
trade_size_in_base = get_trade_size_in_base(
direction, account, strategy, cash_balance, base
)
2022-11-15 07:20:17 +00:00
# Calculate TP/SL/TSL
protection = get_tp_sl(
direction, strategy, current_price, round_to=display_precision
)
# Create object, note that the amount is rounded to the trade precision
2022-12-18 16:55:09 +00:00
amount_rounded = float(round(trade_size_in_base, trade_precision))
new_trade = Trade.objects.create(
user=user,
account=account,
hook=hook,
signal=signal,
symbol=symbol,
type=type,
2023-02-15 18:41:08 +00:00
time_in_force=strategy.order_settings.time_in_force,
# amount_fiat=amount_fiat,
2022-12-18 16:55:09 +00:00
amount=amount_rounded,
# price=price_bound,
price=price_bound,
direction=direction,
**protection,
)
new_trade.save()
2023-02-15 18:15:36 +00:00
if strategy.risk_model is not None:
allowed = check_risk(strategy.risk_model, account, new_trade)
if not allowed["allowed"]:
new_trade.status = "rejected"
new_trade.information = allowed["reason"]
new_trade.save()
sendmsg(
user,
f"Trade rejected due to risk model: {allowed['reason']}",
title="Trade rejected",
)
return
# Run the crossfilter to ensure we don't trade the same pair in opposite directions
filtered = crossfilter(account, symbol, direction, func)
# TP/SL calculation and get_trade_size_in_base are wasted here, but it's important
# to record the decision in the Trade object. We can only get it after we do those.
# It shows what would be done.
if filtered:
log.debug(f"Trade filtered. Action: {filtered['action']}")
if filtered["action"] == "rejected":
new_trade.status = "rejected"
new_trade.save()
sendmsg(
user,
(
f"{direction} on {symbol} rejected due to conflicting position: "
f"{filtered['positions']}"
),
title="Trade rejected",
)
else:
info = new_trade.post()
log.debug(f"Posted trade: {info}")
2022-12-18 16:55:09 +00:00
# Send notification with limited number of fields
wanted_fields = ["requestID", "type", "symbol", "units", "reason"]
sendmsg(
2022-12-18 17:21:52 +00:00
user,
2022-12-18 16:55:09 +00:00
", ".join([str(v) for k, v in info.items() if k in wanted_fields]),
title=f"{direction} {amount_rounded} on {symbol}",
)
def process_callback(callback):
log.info(f"Received callback for {callback.hook} - {callback.signal}")
2022-12-06 19:46:06 +00:00
# Scan for trend
log.debug("Scanning for trend strategies...")
2023-02-15 20:02:38 +00:00
strategies = Strategy.objects.filter(
trend_signals=callback.signal, signal_trading_enabled=True, enabled=True
)
2022-12-06 19:46:06 +00:00
log.debug(f"Matched strategies: {strategies}")
for strategy in strategies:
log.debug(f"Executing strategy {strategy}")
if callback.hook.user != strategy.user:
log.error("Ownership differs between callback and strategy.")
continue
execute_strategy(callback, strategy, func="trend")
# Scan for entry
log.debug("Scanning for entry strategies...")
2023-02-15 20:02:38 +00:00
strategies = Strategy.objects.filter(
entry_signals=callback.signal, signal_trading_enabled=True, enabled=True
)
log.debug(f"Matched strategies: {strategies}")
for strategy in strategies:
log.debug(f"Executing strategy {strategy}")
if callback.hook.user != strategy.user:
log.error("Ownership differs between callback and strategy.")
continue
execute_strategy(callback, strategy, func="entry")
# Scan for exit
2022-12-01 21:13:21 +00:00
log.debug("Scanning for exit strategies...")
2023-02-15 20:02:38 +00:00
strategies = Strategy.objects.filter(
exit_signals=callback.signal, signal_trading_enabled=True, enabled=True
)
log.debug(f"Matched strategies: {strategies}")
for strategy in strategies:
log.debug(f"Executing strategy {strategy}")
if callback.hook.user != strategy.user:
log.error("Ownership differs between callback and strategy.")
continue
execute_strategy(callback, strategy, func="exit")