diff --git a/app/urls.py b/app/urls.py index a93678b..75023cb 100644 --- a/app/urls.py +++ b/app/urls.py @@ -30,6 +30,7 @@ from core.views import ( limits, notifications, ordersettings, + policies, positions, profit, risk, @@ -306,4 +307,25 @@ urlpatterns = [ ordersettings.OrderSettingsDelete.as_view(), name="ordersettings_delete", ), + # Active Management Policies + path( + "ams//", + policies.ActiveManagementPolicyList.as_view(), + name="ams", + ), + path( + "ams//create/", + policies.ActiveManagementPolicyCreate.as_view(), + name="ams_create", + ), + path( + "ams//update//", + policies.ActiveManagementPolicyUpdate.as_view(), + name="ams_update", + ), + path( + "ams//delete//", + policies.ActiveManagementPolicyDelete.as_view(), + name="ams_delete", + ), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/core/exchanges/common.py b/core/exchanges/common.py index 6b48898..76c0f76 100644 --- a/core/exchanges/common.py +++ b/core/exchanges/common.py @@ -33,11 +33,12 @@ def get_pair(account, base, quote, invert=False): :param invert: Invert the pair :return: currency symbol, e.g. BTC_USD, BTC/USD, etc. """ - # Currently we only have two exchanges with different pair separators if account.exchange == "alpaca": separator = "/" elif account.exchange == "oanda": separator = "_" + else: + separator = "_" # Flip the pair if needed if invert: @@ -50,6 +51,16 @@ def get_pair(account, base, quote, invert=False): return symbol +def get_symbol_price(account, price_index, symbol): + try: + prices = account.client.get_currencies([symbol]) + except GenericAPIError as e: + log.error(f"Error getting currencies and inverted currencies: {e}") + return None + price = D(prices["prices"][0][price_index][0]["price"]) + return price + + def to_currency(direction, account, amount, from_currency, to_currency): """ Convert an amount from one currency to another. @@ -79,12 +90,7 @@ def to_currency(direction, account, amount, from_currency, to_currency): if not symbol: log.error(f"Could not find symbol for {from_currency} -> {to_currency}") raise Exception("Could not find symbol") - try: - prices = account.client.get_currencies([symbol]) - except GenericAPIError as e: - log.error(f"Error getting currencies and inverted currencies: {e}") - return None - price = D(prices["prices"][0][price_index][0]["price"]) + price = get_symbol_price(account, price_index, symbol) # If we had to flip base and quote, we need to use the reciprocal of the price if inverted: diff --git a/core/forms.py b/core/forms.py index d512033..2ab1273 100644 --- a/core/forms.py +++ b/core/forms.py @@ -6,6 +6,7 @@ from mixins.restrictions import RestrictedFormMixin from .models import ( # AssetRestriction, Account, + ActiveManagementPolicy, AssetGroup, AssetRule, Hook, @@ -132,6 +133,7 @@ class StrategyForm(RestrictedFormMixin, ModelForm): "trend_signals", "signal_trading_enabled", "active_management_enabled", + "active_management_policy", "enabled", ) @@ -148,6 +150,7 @@ class StrategyForm(RestrictedFormMixin, ModelForm): "trend_signals": "Callbacks received to these signals will limit the trading direction of the given symbol to the callback direction until further notice.", "signal_trading_enabled": "Whether the strategy will place trades based on signals.", "active_management_enabled": "Whether the strategy will amend/remove trades on the account that violate the rules.", + "active_management_policy": "The policy to use for active management.", "enabled": "Whether the strategy is enabled.", } @@ -174,9 +177,9 @@ class StrategyForm(RestrictedFormMixin, ModelForm): ) def clean(self): - super(StrategyForm, self).clean() - entry_signals = self.cleaned_data.get("entry_signals") - exit_signals = self.cleaned_data.get("exit_signals") + cleaned_data = super(StrategyForm, self).clean() + entry_signals = cleaned_data.get("entry_signals") + exit_signals = cleaned_data.get("exit_signals") for entry in entry_signals.all(): if entry in exit_signals.all(): self._errors["entry_signals"] = self.error_class( @@ -213,6 +216,14 @@ class StrategyForm(RestrictedFormMixin, ModelForm): "You cannot have entry and exit signals that are the same direction. At least one must be opposing." ] ) + if cleaned_data.get("active_management_enabled"): + if not cleaned_data.get("active_management_policy"): + self.add_error( + "active_management_policy", + "You must select an active management policy if active management is enabled.", + ) + return + return cleaned_data class TradeForm(RestrictedFormMixin, ModelForm): @@ -381,3 +392,34 @@ class OrderSettingsForm(RestrictedFormMixin, ModelForm): "trailing_stop_loss_percent": "The trailing stop loss will be set at this percentage above/below the entry price. A trailing stop loss will follow the price as it moves in your favor.", "trade_size_percent": "Percentage of the account balance to use for each trade.", } + + +class ActiveManagementPolicyForm(RestrictedFormMixin, ModelForm): + class Meta: + model = ActiveManagementPolicy + fields = ( + "name", + "when_trading_time_violated", + "when_trends_violated", + "when_position_size_violated", + "when_protection_violated", + "when_asset_groups_violated", + "when_max_open_trades_violated", + "when_max_open_trades_per_symbol_violated", + "when_max_loss_violated", + "when_max_risk_violated", + "when_crossfilter_violated", + ) + help_texts = { + "name": "Name of the active management policy. Informational only.", + "when_trading_time_violated": "The action to take when the trading time is violated.", + "when_trends_violated": "The action to take a trade against the trend is discovered.", + "when_position_size_violated": "The action to take when a trade exceeding the position size is discovered.", + "when_protection_violated": "The action to take when a trade violating/lacking defined TP/SL/TSL is discovered.", + "when_asset_groups_violated": "The action to take when a trade violating the asset group rules is discovered.", + "when_max_open_trades_violated": "The action to take when a trade puts the account above the maximum open trades.", + "when_max_open_trades_per_symbol_violated": "The action to take when a trade puts the account above the maximum open trades per symbol.", + "when_max_loss_violated": "The action to take when a trade puts the account above the maximum loss.", + "when_max_risk_violated": "The action to take when a trade exposes the account to more than the maximum risk.", + "when_crossfilter_violated": "The action to take when a trade is deemed to conflict with another -- e.g. a buy and sell on the same asset.", + } diff --git a/core/lib/schemas/oanda_s.py b/core/lib/schemas/oanda_s.py index 1d02c55..ffb3259 100644 --- a/core/lib/schemas/oanda_s.py +++ b/core/lib/schemas/oanda_s.py @@ -46,6 +46,20 @@ class OpenPositions(BaseModel): lastTransactionID: str +def parse_time(x): + """ + Parse the time from the Oanda API. + """ + if "openTime" in x: + ts_split = x["openTime"].split(".") + else: + ts_split = x["trade"]["openTime"].split(".") + microseconds = ts_split[1].replace("Z", "") + microseconds_6 = microseconds[:6] + new_ts = ts_split[0] + "." + microseconds_6 + "Z" + return new_ts + + def prevent_hedging(x): """ Our implementation breaks if a position has both. @@ -522,7 +536,7 @@ OpenTradesSchema = { "id": "id", "symbol": "instrument", "price": "price", - "openTime": "openTime", + "openTime": parse_time, "initialUnits": "initialUnits", "initialMarginRequired": "initialMarginRequired", "state": "state", @@ -680,7 +694,7 @@ TradeDetailsSchema = { "id": "trade.id", "symbol": "trade.instrument", "price": "trade.price", - "openTime": "trade.openTime", + "openTime": parse_time, "initialUnits": "trade.initialUnits", "initialMarginRequired": "trade.initialMarginRequired", "state": "trade.state", diff --git a/core/migrations/0071_alter_account_exchange_activemanagementpolicy.py b/core/migrations/0071_alter_account_exchange_activemanagementpolicy.py new file mode 100644 index 0000000..02edde2 --- /dev/null +++ b/core/migrations/0071_alter_account_exchange_activemanagementpolicy.py @@ -0,0 +1,30 @@ +# Generated by Django 4.1.7 on 2023-02-17 11:50 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0070_strategy_active_management_enabled_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='account', + name='exchange', + field=models.CharField(choices=[('alpaca', 'Alpaca'), ('oanda', 'OANDA'), ('fake', 'Fake')], max_length=255), + ), + migrations.CreateModel( + name='ActiveManagementPolicy', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, null=True)), + ('when_trading_time_violated', models.CharField(choices=[('none', 'None'), ('close', 'Close violating trades'), ('notify', 'Notify only')], default='none', max_length=255)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/core/migrations/0072_activemanagementpolicy_when_asset_groups_violated_and_more.py b/core/migrations/0072_activemanagementpolicy_when_asset_groups_violated_and_more.py new file mode 100644 index 0000000..21b24e8 --- /dev/null +++ b/core/migrations/0072_activemanagementpolicy_when_asset_groups_violated_and_more.py @@ -0,0 +1,58 @@ +# Generated by Django 4.1.7 on 2023-02-17 11:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0071_alter_account_exchange_activemanagementpolicy'), + ] + + operations = [ + migrations.AddField( + model_name='activemanagementpolicy', + name='when_asset_groups_violated', + field=models.CharField(choices=[('none', 'None'), ('close', 'Close violating trades'), ('notify', 'Notify only')], default='none', max_length=255), + ), + migrations.AddField( + model_name='activemanagementpolicy', + name='when_crossfilter_violated', + field=models.CharField(choices=[('none', 'None'), ('close', 'Close violating trades'), ('notify', 'Notify only')], default='none', max_length=255), + ), + migrations.AddField( + model_name='activemanagementpolicy', + name='when_max_loss_violated', + field=models.CharField(choices=[('none', 'None'), ('close', 'Close violating trades'), ('notify', 'Notify only')], default='none', max_length=255), + ), + migrations.AddField( + model_name='activemanagementpolicy', + name='when_max_open_trades_per_symbol_violated', + field=models.CharField(choices=[('none', 'None'), ('close', 'Close violating trades'), ('notify', 'Notify only')], default='none', max_length=255), + ), + migrations.AddField( + model_name='activemanagementpolicy', + name='when_max_open_trades_violated', + field=models.CharField(choices=[('none', 'None'), ('close', 'Close violating trades'), ('notify', 'Notify only')], default='none', max_length=255), + ), + migrations.AddField( + model_name='activemanagementpolicy', + name='when_max_risk_violated', + field=models.CharField(choices=[('none', 'None'), ('close', 'Close violating trades'), ('notify', 'Notify only')], default='none', max_length=255), + ), + migrations.AddField( + model_name='activemanagementpolicy', + name='when_position_size_violated', + field=models.CharField(choices=[('none', 'None'), ('close', 'Close violating trades'), ('notify', 'Notify only'), ('adjust', 'Adjust violating trades')], default='none', max_length=255), + ), + migrations.AddField( + model_name='activemanagementpolicy', + name='when_protection_violated', + field=models.CharField(choices=[('none', 'None'), ('close', 'Close violating trades'), ('notify', 'Notify only'), ('adjust', 'Adjust violating trades')], default='none', max_length=255), + ), + migrations.AddField( + model_name='activemanagementpolicy', + name='when_trends_violated', + field=models.CharField(choices=[('none', 'None'), ('close', 'Close violating trades'), ('notify', 'Notify only')], default='none', max_length=255), + ), + ] diff --git a/core/migrations/0073_strategy_active_management_policy.py b/core/migrations/0073_strategy_active_management_policy.py new file mode 100644 index 0000000..ef02b38 --- /dev/null +++ b/core/migrations/0073_strategy_active_management_policy.py @@ -0,0 +1,19 @@ +# Generated by Django 4.1.7 on 2023-02-17 13:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0072_activemanagementpolicy_when_asset_groups_violated_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='strategy', + name='active_management_policy', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='core.activemanagementpolicy'), + ), + ] diff --git a/core/models.py b/core/models.py index 2be9555..922d8de 100644 --- a/core/models.py +++ b/core/models.py @@ -65,6 +65,19 @@ MAPPING_CHOICES = ( (3, "Bearish"), ) +CLOSE_NOTIFY_CHOICES = ( + ("none", "None"), + ("close", "Close violating trades"), + ("notify", "Notify only"), +) + +ADJUST_CLOSE_NOTIFY_CHOICES = ( + ("none", "None"), + ("close", "Close violating trades"), + ("notify", "Notify only"), + ("adjust", "Adjust violating trades"), +) + class Plan(models.Model): name = models.CharField(max_length=255, unique=True) @@ -395,6 +408,12 @@ class Strategy(models.Model): "core.OrderSettings", on_delete=models.PROTECT, ) + active_management_policy = models.ForeignKey( + "core.ActiveManagementPolicy", + on_delete=models.PROTECT, + null=True, + blank=True, + ) class Meta: verbose_name_plural = "strategies" @@ -493,3 +512,42 @@ class OrderSettings(models.Model): def __str__(self): return self.name + + +class ActiveManagementPolicy(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + name = models.CharField(max_length=255) + description = models.TextField(null=True, blank=True) + when_trading_time_violated = models.CharField( + choices=CLOSE_NOTIFY_CHOICES, max_length=255, default="none" + ) + when_trends_violated = models.CharField( + choices=CLOSE_NOTIFY_CHOICES, max_length=255, default="none" + ) + when_position_size_violated = models.CharField( + choices=ADJUST_CLOSE_NOTIFY_CHOICES, max_length=255, default="none" + ) + when_protection_violated = models.CharField( + choices=ADJUST_CLOSE_NOTIFY_CHOICES, max_length=255, default="none" + ) + when_asset_groups_violated = models.CharField( + choices=CLOSE_NOTIFY_CHOICES, max_length=255, default="none" + ) + when_max_open_trades_violated = models.CharField( + choices=CLOSE_NOTIFY_CHOICES, max_length=255, default="none" + ) + when_max_open_trades_per_symbol_violated = models.CharField( + choices=CLOSE_NOTIFY_CHOICES, max_length=255, default="none" + ) + when_max_loss_violated = models.CharField( + choices=CLOSE_NOTIFY_CHOICES, max_length=255, default="none" + ) + when_max_risk_violated = models.CharField( + choices=CLOSE_NOTIFY_CHOICES, max_length=255, default="none" + ) + when_crossfilter_violated = models.CharField( + choices=CLOSE_NOTIFY_CHOICES, max_length=255, default="none" + ) + + def __str__(self): + return self.name diff --git a/core/templates/base.html b/core/templates/base.html index 1727959..a7b9fd6 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -267,6 +267,9 @@ Asset Groups + + Active Management +