fisk/core/models.py

454 lines
16 KiB
Python

import uuid
from datetime import timedelta
import stripe
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.db import models
from core.exchanges.alpaca import AlpacaExchange
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}
TYPE_CHOICES = (
("market", "Market"),
("limit", "Limit"),
)
DIRECTION_CHOICES = (
("buy", "Buy"),
("sell", "Sell"),
)
TIF_CHOICES = (
("gtc", "GTC (Good Til Cancelled)"),
("gfd", "GFD (Good For Day)"),
("fok", "FOK (Fill Or Kill)"),
("ioc", "IOC (Immediate Or Cancel)"),
)
DAY_CHOICES = (
(1, "Monday"),
(2, "Tuesday"),
(3, "Wednesday"),
(4, "Thursday"),
(5, "Friday"),
(6, "Saturday"),
(7, "Sunday"),
)
SIGNAL_TYPE_CHOICES = (
("entry", "Entry"),
("exit", "Exit"),
("trend", "Trend"),
)
class Plan(models.Model):
name = models.CharField(max_length=255, unique=True)
description = models.CharField(max_length=1024, null=True, blank=True)
cost = models.IntegerField()
product_id = models.CharField(max_length=255, unique=True, null=True, blank=True)
image = models.CharField(max_length=1024, null=True, blank=True)
def __str__(self):
return f"{self.name}{self.cost})"
class User(AbstractUser):
# Stripe customer ID
stripe_id = models.CharField(max_length=255, null=True, blank=True)
last_payment = models.DateTimeField(null=True, blank=True)
plans = models.ManyToManyField(Plan, blank=True)
email = models.EmailField(unique=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._original = self
def save(self, *args, **kwargs):
"""
Override the save function to create a Stripe customer.
"""
if settings.STRIPE_ENABLED:
if not self.stripe_id: # stripe ID not stored
self.stripe_id = get_or_create(
self.email, self.first_name, self.last_name
)
to_update = {}
if self.email != self._original.email:
to_update["email"] = self.email
if self.first_name != self._original.first_name:
to_update["first_name"] = self.first_name
if self.last_name != self._original.last_name:
to_update["last_name"] = self.last_name
update_customer_fields(self.stripe_id, **to_update)
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
if settings.STRIPE_ENABLED:
if self.stripe_id:
stripe.Customer.delete(self.stripe_id)
log.info(f"Deleted Stripe customer {self.stripe_id}")
super().delete(*args, **kwargs)
def has_plan(self, plan):
plan_list = [plan.name for plan in self.plans.all()]
return plan in plan_list
def get_notification_settings(self):
return NotificationSettings.objects.get_or_create(user=self)[0]
class Account(models.Model):
EXCHANGE_CHOICES = (("alpaca", "Alpaca"), ("oanda", "OANDA"))
user = models.ForeignKey(User, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
exchange = models.CharField(choices=EXCHANGE_CHOICES, max_length=255)
api_key = models.CharField(max_length=255)
api_secret = models.CharField(max_length=255)
sandbox = models.BooleanField(default=False)
enabled = models.BooleanField(default=True)
supported_symbols = models.JSONField(default=list)
instruments = models.JSONField(default=list)
currency = models.CharField(max_length=255, null=True, blank=True)
risk_model = models.ForeignKey(
"core.RiskModel", on_delete=models.SET_NULL, null=True, blank=True
)
initial_balance = models.FloatField(default=0)
def __str__(self):
name = f"{self.name} ({self.exchange})"
if self.sandbox:
name += " (sandbox)"
return name
def update_info(self, save=True):
client = self.get_client()
if client:
response = client.get_instruments()
supported_symbols = client.get_supported_assets(response)
currency = client.get_account()["currency"]
log.debug(f"Supported symbols for {self.name}: {supported_symbols}")
self.supported_symbols = supported_symbols
self.instruments = response
self.currency = currency
if save:
self.save()
def save(self, *args, **kwargs):
"""
Override the save function to update supported symbols.
"""
self.update_info(save=False)
super().save(*args, **kwargs)
def get_client(self):
if self.exchange in EXCHANGE_MAP:
return EXCHANGE_MAP[self.exchange](self)
else:
raise Exception(f"Exchange not supported : {self.exchange}")
@property
def client(self):
"""
Convenience property for one-off API calls.
"""
return self.get_client()
@property
def rawclient(self):
"""
Convenience property for one-off API calls.
"""
return self.get_client().client
@classmethod
def get_by_id(cls, account_id, user):
return cls.objects.get(id=account_id, user=user)
@classmethod
def get_by_id_no_user_check(cls, account_id):
return cls.objects.get(id=account_id)
class Session(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
request = models.CharField(max_length=255, null=True, blank=True)
session = models.CharField(max_length=255, null=True, blank=True)
subscription_id = models.CharField(max_length=255, null=True, blank=True)
plan = models.ForeignKey(Plan, null=True, blank=True, on_delete=models.CASCADE)
class Hook(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
name = models.CharField(max_length=1024)
hook = models.CharField(max_length=255, unique=True) # hook URL
received = models.IntegerField(default=0)
def __str__(self):
return f"{self.name} ({self.hook})"
class Signal(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
name = models.CharField(max_length=1024)
signal = models.CharField(max_length=256) # signal name
hook = models.ForeignKey(Hook, on_delete=models.CASCADE)
direction = models.CharField(choices=DIRECTION_CHOICES, max_length=255)
received = models.IntegerField(default=0)
type = models.CharField(choices=SIGNAL_TYPE_CHOICES, max_length=255)
def __str__(self):
return f"{self.name} ({self.hook.name}) - {self.direction}"
class Trade(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
account = models.ForeignKey(Account, on_delete=models.CASCADE)
hook = models.ForeignKey(Hook, on_delete=models.CASCADE, null=True, blank=True)
signal = models.ForeignKey(Signal, on_delete=models.CASCADE, null=True, blank=True)
symbol = models.CharField(max_length=255)
time_in_force = models.CharField(choices=TIF_CHOICES, max_length=255, default="gtc")
type = models.CharField(choices=TYPE_CHOICES, max_length=255)
amount = models.FloatField(null=True, blank=True)
amount_usd = models.FloatField(null=True, blank=True)
price = models.FloatField(null=True, blank=True)
stop_loss = models.FloatField(null=True, blank=True)
trailing_stop_loss = models.FloatField(null=True, blank=True)
take_profit = models.FloatField(null=True, blank=True)
status = models.CharField(max_length=255, null=True, blank=True)
information = models.JSONField(null=True, blank=True)
direction = models.CharField(choices=DIRECTION_CHOICES, max_length=255)
# To populate from the trade
order_id = models.CharField(max_length=255, null=True, blank=True)
client_order_id = models.CharField(max_length=255, null=True, blank=True)
response = models.JSONField(null=True, blank=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._original = self
def post(self):
if self.status in ["rejected", "close"]:
log.debug(f"Trade {self.id} rejected. Not posting.")
log.debug(f"Trade {self.id} information: {self.information}")
else:
return self.account.client.post_trade(self)
def delete(self, *args, **kwargs):
# close the trade
super().delete(*args, **kwargs)
@classmethod
def get_by_id(cls, trade_id, user):
return cls.objects.get(id=trade_id, user=user)
@classmethod
def get_by_id_or_order(cls, trade_id, account_id, user):
try:
account = Account.objects.get(id=account_id, user=user)
except Account.DoesNotExist:
return None
try:
return cls.objects.get(id=trade_id, account=account, user=user)
except cls.DoesNotExist:
try:
return cls.objects.get(order_id=trade_id, account=account, user=user)
except cls.DoesNotExist:
return None
class Callback(models.Model):
hook = models.ForeignKey(Hook, on_delete=models.CASCADE)
signal = models.ForeignKey(Signal, on_delete=models.CASCADE)
title = models.CharField(max_length=1024, null=True, blank=True)
message = models.CharField(max_length=1024, null=True, blank=True)
period = models.CharField(max_length=255, null=True, blank=True)
sent = models.BigIntegerField(null=True, blank=True)
trade = models.BigIntegerField(null=True, blank=True)
exchange = models.CharField(max_length=255, null=True, blank=True)
base = models.CharField(max_length=255, null=True, blank=True)
quote = models.CharField(max_length=255, null=True, blank=True)
contract = models.CharField(max_length=255, null=True, blank=True)
price = models.FloatField(null=True, blank=True)
symbol = models.CharField(max_length=255)
class TradingTime(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
description = models.TextField(null=True, blank=True)
start_day = models.IntegerField(choices=DAY_CHOICES)
end_day = models.IntegerField(choices=DAY_CHOICES)
start_time = models.TimeField()
end_time = models.TimeField()
def within_range(self, ts):
"""
Check if the specified time is within the configured trading times.
:param ts: Timestamp
:type ts: datetime
:return: whether or not the time is within the trading range
:rtype: bool
"""
start_day = self.start_day
end_day = self.end_day
# Check the day is between the start and end day
if not start_day <= ts.weekday() + 1 <= end_day:
return False
start_time = self.start_time
end_time = self.end_time
# Get what the start time would be this week
ts_monday = ts - timedelta(days=ts.weekday())
# Now we need to add our day of week to monday
# Let's set the offset now since it's off by one
offset_start = start_day - 1
# Datetime: monday=0, tuesday=1, us: monday=1, tuesday=2, so we need to subtract
# one from ours to not be off by one
offset_end = end_day - 1
# Now we can add the offset to the monday
start = ts_monday + timedelta(days=offset_start)
start = start.replace(
hour=start_time.hour,
minute=start_time.minute,
second=start_time.second,
microsecond=start_time.microsecond,
)
end = ts_monday + timedelta(days=offset_end)
end = end.replace(
hour=end_time.hour,
minute=end_time.minute,
second=end_time.second,
microsecond=end_time.microsecond,
)
# Check if the ts is between the start and end times
# ts must be more than start and less than end
return ts >= start and ts <= end
return True
def __str__(self):
return (
f"{self.name} ({self.get_start_day_display()} at {self.start_time} - "
f"{self.get_end_day_display()} at {self.end_time})"
)
class Strategy(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
description = models.TextField(null=True, blank=True)
account = models.ForeignKey(Account, on_delete=models.CASCADE)
trading_times = models.ManyToManyField(TradingTime)
order_type = models.CharField(
choices=TYPE_CHOICES, max_length=255, default="market"
)
time_in_force = models.CharField(choices=TIF_CHOICES, max_length=255, default="gtc")
entry_signals = models.ManyToManyField(
Signal, related_name="entry_strategies", blank=True
)
exit_signals = models.ManyToManyField(
Signal, related_name="exit_strategies", blank=True
)
trend_signals = models.ManyToManyField(
Signal, related_name="trend_strategies", blank=True
)
enabled = models.BooleanField(default=False)
take_profit_percent = models.FloatField(default=1.5)
stop_loss_percent = models.FloatField(default=1.0)
trailing_stop_loss_percent = models.FloatField(default=1.0, null=True, blank=True)
price_slippage_percent = models.FloatField(default=2.5)
callback_price_deviation_percent = models.FloatField(default=0.5)
trade_size_percent = models.FloatField(default=0.5)
trends = models.JSONField(null=True, blank=True)
asset_group = models.ForeignKey(
"core.AssetGroup", on_delete=models.PROTECT, null=True, blank=True
)
class Meta:
verbose_name_plural = "strategies"
def __str__(self):
return self.name
class NotificationSettings(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
ntfy_topic = models.CharField(max_length=255, null=True, blank=True)
ntfy_url = models.CharField(max_length=255, null=True, blank=True)
def __str__(self):
return f"Notification settings for {self.user}"
class RiskModel(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
description = models.TextField(null=True, blank=True)
# Maximum amount of money to have lost from the initial balance to stop trading
max_loss_percent = models.FloatField(default=0.05)
# Maximum amount of money to risk on all open trades
max_risk_percent = models.FloatField(default=0.05)
# Maximum number of trades
max_open_trades = models.IntegerField(default=10)
# Maximum number of trades per symbol
max_open_trades_per_symbol = models.IntegerField(default=2)
def __str__(self):
return self.name
class AssetGroup(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
description = models.TextField(null=True, blank=True)
# Account for checking pairs on children if specified
account = models.ForeignKey(
Account, on_delete=models.PROTECT, null=True, blank=True
)
# Dict like {"RUB": True, "USD": False}
allowed = models.JSONField(null=True, blank=True, default=dict)
def __str__(self):
return f"{self.name} ({self.restrictions})"
@property
def matches(self):
"""
Get the total number of matches for this group.
"""
if isinstance(self.allowed, dict):
truthy_values = [x for x in self.allowed.values() if x is True]
return f"{len(truthy_values)}/{len(self.allowed)}"
@property
def restrictions(self):
"""
Get the total number of restrictions for this group.
"""
return self.assetrestriction_set.count()
class AssetRestriction(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
description = models.TextField(null=True, blank=True)
pairs = models.CharField(max_length=4096, null=True, blank=True)
pairs_parsed = models.JSONField(null=True, blank=True, default=list)
webhook_id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
group = models.ForeignKey(
AssetGroup, on_delete=models.CASCADE, null=True, blank=True
)