From dd3b3521d94493e58865547433ed1eecba00392a Mon Sep 17 00:00:00 2001 From: Mark Veidemanis Date: Fri, 17 Feb 2023 07:20:28 +0000 Subject: [PATCH] Move more checks from market into checks library --- core/exchanges/fake.py | 57 +++++++++++++++ core/management/commands/scheduling.py | 5 +- core/models.py | 8 ++- core/tests/trading/test_checks.py | 98 +++++++++++++++++++++++--- core/trading/active_management.py | 13 ++++ core/trading/checks.py | 91 +++++++++++++++++++++++- core/trading/market.py | 87 +++++------------------ requirements.txt | 2 + 8 files changed, 274 insertions(+), 87 deletions(-) create mode 100644 core/exchanges/fake.py diff --git a/core/exchanges/fake.py b/core/exchanges/fake.py new file mode 100644 index 0000000..344ad19 --- /dev/null +++ b/core/exchanges/fake.py @@ -0,0 +1,57 @@ +from core.exchanges import BaseExchange + + +class FakeExchange(BaseExchange): + def call_method(self, request): + pass + + def connect(self): + pass + + def get_account(self): + pass + + def get_instruments(self): + pass + + def get_currencies(self, currencies): + pass + + def get_supported_assets(self, response=None): + pass + + def get_balance(self, return_usd=False): + pass + + def get_market_value(self, symbol): + pass + + def post_trade(self, trade): + pass + + def close_trade(self, trade_id): + pass + + def get_trade(self, trade_id): + pass + + def update_trade(self, trade): + pass + + def cancel_trade(self, trade_id): + pass + + def get_position_info(self, symbol): + pass + + def get_all_positions(self): + pass + + def get_all_open_trades(self): + pass + + def close_position(self, side, symbol): + pass + + def close_all_positions(self): + pass diff --git a/core/management/commands/scheduling.py b/core/management/commands/scheduling.py index 024abc2..dd919ca 100644 --- a/core/management/commands/scheduling.py +++ b/core/management/commands/scheduling.py @@ -5,8 +5,8 @@ from asgiref.sync import sync_to_async from django.core.management.base import BaseCommand from core.models import Strategy -from core.util import logs from core.trading import active_management +from core.util import logs log = logs.get_logger("scheduling") @@ -24,8 +24,7 @@ async def job(): log.debug(f"Found {len(strategies)} strategies") for strategy in strategies: log.debug(f"Running strategy {strategy.name}") - ams = active_management.ActiveManagement(strategy) - + ams = active_management.ActiveManagement(strategy) # noqa class Command(BaseCommand): diff --git a/core/models.py b/core/models.py index 3d09d0b..2be9555 100644 --- a/core/models.py +++ b/core/models.py @@ -7,12 +7,13 @@ from django.contrib.auth.models import AbstractUser from django.db import models from core.exchanges.alpaca import AlpacaExchange +from core.exchanges.fake import FakeExchange from core.exchanges.oanda import OANDAExchange from core.lib.customers import get_or_create, update_customer_fields from core.util import logs log = logs.get_logger(__name__) -EXCHANGE_MAP = {"alpaca": AlpacaExchange, "oanda": OANDAExchange} +EXCHANGE_MAP = {"alpaca": AlpacaExchange, "oanda": OANDAExchange, "fake": FakeExchange} TYPE_CHOICES = ( ("market", "Market"), ("limit", "Limit"), @@ -125,7 +126,7 @@ class User(AbstractUser): class Account(models.Model): - EXCHANGE_CHOICES = (("alpaca", "Alpaca"), ("oanda", "OANDA")) + EXCHANGE_CHOICES = (("alpaca", "Alpaca"), ("oanda", "OANDA"), ("fake", "Fake")) user = models.ForeignKey(User, on_delete=models.CASCADE) name = models.CharField(max_length=255) exchange = models.CharField(choices=EXCHANGE_CHOICES, max_length=255) @@ -164,7 +165,8 @@ class Account(models.Model): """ Override the save function to update supported symbols. """ - self.update_info(save=False) + if self.exchange != "fake": + self.update_info(save=False) super().save(*args, **kwargs) def get_client(self): diff --git a/core/tests/trading/test_checks.py b/core/tests/trading/test_checks.py index 23fba53..4c7e626 100644 --- a/core/tests/trading/test_checks.py +++ b/core/tests/trading/test_checks.py @@ -1,26 +1,106 @@ +from datetime import time + +import freezegun from django.test import TestCase +from core.models import ( + Account, + Hook, + OrderSettings, + RiskModel, + Signal, + Strategy, + TradingTime, + User, +) from core.trading import checks -from core.models import TradingTime, Strategy, OrderSettings, User class ChecksTestCase(TestCase): def setUp(self): + self.time_8 = time(8, 0, 0) + self.time_16 = time(16, 0, 0) self.user = User.objects.create_user( username="testuser", email="test@example.com", password="test" ) - self.order_settings = OrderSettings.objects.create(user=self.user, name="Default") + self.account = Account.objects.create( + user=self.user, + name="Test Account", + exchange="fake", + ) + self.order_settings = OrderSettings.objects.create( + user=self.user, name="Default" + ) self.trading_time_now = TradingTime.objects.create( user=self.user, name="Test Trading Time", - start_day=1, # Monday - start_time="08:00", - end_day=1, # Monday - end_time="16:00", + start_day=1, # Monday + start_time=self.time_8, + end_day=1, # Monday + end_time=self.time_16, + ) + self.risk_model = RiskModel.objects.create( + user=self.user, + name="Test Risk Model", + max_loss_percent=50, + max_risk_percent=10, + max_open_trades=10, + max_open_trades_per_symbol=5, + ) + + self.strategy = Strategy.objects.create( + user=self.user, + name="Test Strategy", + account=self.account, + order_settings=self.order_settings, + risk_model=self.risk_model, + active_management_enabled=True, ) + self.strategy.trading_times.set([self.trading_time_now]) + self.strategy.save() + @freezegun.freeze_time("2023-02-13T09:00:00") # Monday at 09:00 + def test_within_trading_times_pass(self): + self.assertTrue(checks.within_trading_times(self.strategy)) - self.strategy = Strategy.objects.create(user=self.user, name="Test Strategy", ) + @freezegun.freeze_time("2023-02-13T17:00:00") # Monday at 17:00 + def test_within_trading_times_fail(self): + self.assertFalse(checks.within_trading_times(self.strategy)) + + def test_within_callback_price_deviation_fail(self): + price_callback = 100 + current_price = 200 + self.assertFalse( + checks.within_callback_price_deviation( + self.strategy, price_callback, current_price + ) + ) + + def test_within_callback_price_deviation_pass(self): + price_callback = 100 + current_price = 100.5 + self.assertTrue( + checks.within_callback_price_deviation( + self.strategy, price_callback, current_price + ) + ) + + def test_within_trends(self): + hook = Hook.objects.create( + user=self.user, + name="Test Hook", + ) + signal = Signal.objects.create( + user=self.user, + name="Test Signal", + hook=hook, + type="trend", + ) + self.strategy.trend_signals.set([signal]) + self.strategy.trends = {"EUR_USD": "buy"} + self.strategy.save() + self.assertTrue(checks.within_trends(self.strategy, "EUR_USD", "buy")) + self.assertFalse(checks.within_trends(self.strategy, "EUR_USD", "sell")) - def test_within_trading_times(self): - pass \ No newline at end of file + self.assertIsNone(checks.within_trends(self.strategy, "EUR_XXX", "buy")) + self.assertIsNone(checks.within_trends(self.strategy, "EUR_XXX", "sell")) diff --git a/core/trading/active_management.py b/core/trading/active_management.py index ed6f5cc..9f2b032 100644 --- a/core/trading/active_management.py +++ b/core/trading/active_management.py @@ -1,3 +1,16 @@ class ActiveManagement(object): def __init__(self, strategy): self.strategy = strategy + + def run_checks(self): + pass + # Trading Time + # Max loss + # Trends + # Asset Groups + # Position Size + # Protection + # Max open positions + # Max open positions per asset + # Max risk + # Crossfilter diff --git a/core/trading/checks.py b/core/trading/checks.py index 4048afd..b73995c 100644 --- a/core/trading/checks.py +++ b/core/trading/checks.py @@ -1,9 +1,16 @@ from datetime import datetime +from decimal import Decimal as D + +from core.lib.notify import sendmsg from core.util import logs log = logs.get_logger("checks") +def run_checks(strategy, x): + pass + + def within_trading_times(strategy, ts=None): if not ts: ts = datetime.utcnow() @@ -16,4 +23,86 @@ def within_trading_times(strategy, ts=None): if not any(matches): log.debug("Not within trading time range") return False - return True \ No newline at end of file + return True + + +def within_callback_price_deviation(strategy, price, current_price): + # Convert the callback price deviation to a ratio + if strategy.risk_model is not None: + callback_price_deviation_as_ratio = D( + strategy.risk_model.callback_price_deviation_percent + ) / D(100) + else: + callback_price_deviation_as_ratio = D(0.5) / D(100) + log.debug(f"Callback price deviation as ratio: {callback_price_deviation_as_ratio}") + + maximum_price_deviation = D(current_price) * D(callback_price_deviation_as_ratio) + + # Ensure the current price is within price_slippage_as_ratio of the callback price + if abs(current_price - price) <= maximum_price_deviation: + log.debug("Current price is within price deviation of callback price") + return True + else: + log.error("Current price is not within price deviation of callback price") + log.debug(f"Difference: {abs(current_price - price)}") + return False + + +def within_max_loss(strategy): + pass + + +def within_trends(strategy, symbol, direction): + if strategy.trend_signals.exists(): + if strategy.trends is None: + log.debug("Refusing to trade with no trend signals received") + sendmsg( + strategy.user, + f"Refusing to trade {symbol} with no trend signals received", + title="Trend not ready", + ) + return None + if symbol not in strategy.trends: + log.debug("Refusing to trade asset without established trend") + sendmsg( + strategy.user, + f"Refusing to trade {symbol} without established trend", + title="Trend not ready", + ) + return None + else: + if strategy.trends[symbol] != direction: + log.debug("Refusing to trade against the trend") + sendmsg( + strategy.user, + f"Refusing to trade {symbol} against the trend", + title="Trend rejection", + ) + return False + else: + log.debug(f"Trend check passed for {symbol} - {direction}") + return True + + +def within_position_size(strategy): + pass + + +def within_protection(strategy): + pass + + +def within_max_open_trades(strategy): + pass + + +def within_max_open_trades_per_asset(strategy): + pass + + +def within_max_risk(strategy): + pass + + +def within_crossfilter(strategy): + pass diff --git a/core/trading/market.py b/core/trading/market.py index cd69d56..95a8515 100644 --- a/core/trading/market.py +++ b/core/trading/market.py @@ -1,4 +1,3 @@ -from datetime import datetime from decimal import Decimal as D from core.exchanges import common @@ -172,7 +171,7 @@ def get_tp_sl(direction, strategy, price, round_to=None): return cast -def get_price_bound(direction, strategy, price, current_price): +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 @@ -187,25 +186,6 @@ def get_price_bound(direction, strategy, price, current_price): :return: Price bound """ - # Convert the callback price deviation to a ratio - if strategy.risk_model is not None: - callback_price_deviation_as_ratio = D( - strategy.risk_model.callback_price_deviation_percent - ) / D(100) - else: - callback_price_deviation_as_ratio = D(0.5) / D(100) - log.debug(f"Callback price deviation as ratio: {callback_price_deviation_as_ratio}") - - maximum_price_deviation = D(current_price) * D(callback_price_deviation_as_ratio) - - # Ensure the current price is within price_slippage_as_ratio of the callback price - if abs(current_price - price) <= maximum_price_deviation: - log.debug("Current price is within price deviation of callback price") - else: - log.error("Current price is not within price deviation of callback price") - log.debug(f"Difference: {abs(current_price - price)}") - return None - # Convert the maximum price slippage to a ratio if strategy.risk_model is not None: price_slippage_as_ratio = D(strategy.risk_model.price_slippage_percent) / D(100) @@ -277,15 +257,8 @@ def execute_strategy(callback, strategy, func): # Only check times for entries. We can always exit trades and set trends. if func == "entry": - # Check if we can trade now! - now_utc = datetime.utcnow() - trading_times = strategy.trading_times.all() - if not trading_times: - log.error("No trading times set for strategy") - return - matches = [x.within_range(now_utc) for x in trading_times] - if not any(matches): - log.debug("Not within trading time range") + within_trading_times = checks.within_trading_times(strategy) + if not within_trading_times: return # Don't touch the account if it's disabled. @@ -338,8 +311,17 @@ def execute_strategy(callback, strategy, func): 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 - price_bound = get_price_bound(direction, strategy, price, current_price) + # 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) @@ -390,34 +372,9 @@ def execute_strategy(callback, strategy, func): return # Check if we are trading against the trend - if strategy.trend_signals.exists(): - if strategy.trends is None: - log.debug("Refusing to trade with no trend signals received") - sendmsg( - user, - f"Refusing to trade {symbol} with no trend signals received", - title="Trend not ready", - ) - return - if symbol not in strategy.trends: - log.debug("Refusing to trade asset without established trend") - sendmsg( - user, - f"Refusing to trade {symbol} without established trend", - title="Trend not ready", - ) - return - else: - if strategy.trends[symbol] != direction: - log.debug("Refusing to trade against the trend") - sendmsg( - user, - f"Refusing to trade {symbol} against the trend", - title="Trend rejection", - ) - return - else: - log.debug(f"Trend check passed for {symbol} - {direction}") + within_trends = checks.within_trends(strategy, symbol, direction) + if not within_trends: + return type = strategy.order_settings.order_type @@ -435,18 +392,6 @@ def execute_strategy(callback, strategy, func): protection = get_tp_sl( direction, strategy, current_price, round_to=display_precision ) - # protection_cast = {} - # if "sl" in protection: - # protection_cast["stop_loss"] = float(round(protect - # ion["sl"], display_precision)) - # if "tp" in protection: - # protection_cast["take_profit"] = float( - # round(protection["tp"], display_precision) - # ) - # if "tsl" in protection: - # protection_cast["trailing_stop_loss"] = float( - # round(protection["tsl"], display_precision) - # ) # Create object, note that the amount is rounded to the trade precision amount_rounded = float(round(trade_size_in_base, trade_precision)) diff --git a/requirements.txt b/requirements.txt index 38f8b46..56d0ffb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,6 +22,8 @@ oandapyV20 glom elasticsearch apscheduler +# For trading time checks +freezegun git+https://git.zm.is/XF/django-crud-mixins # pyroscope-io # For caching