Move more checks from market into checks library
This commit is contained in:
parent
da67177a18
commit
dd3b3521d9
|
@ -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
|
|
@ -5,8 +5,8 @@ from asgiref.sync import sync_to_async
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from core.models import Strategy
|
from core.models import Strategy
|
||||||
from core.util import logs
|
|
||||||
from core.trading import active_management
|
from core.trading import active_management
|
||||||
|
from core.util import logs
|
||||||
|
|
||||||
log = logs.get_logger("scheduling")
|
log = logs.get_logger("scheduling")
|
||||||
|
|
||||||
|
@ -24,8 +24,7 @@ async def job():
|
||||||
log.debug(f"Found {len(strategies)} strategies")
|
log.debug(f"Found {len(strategies)} strategies")
|
||||||
for strategy in strategies:
|
for strategy in strategies:
|
||||||
log.debug(f"Running strategy {strategy.name}")
|
log.debug(f"Running strategy {strategy.name}")
|
||||||
ams = active_management.ActiveManagement(strategy)
|
ams = active_management.ActiveManagement(strategy) # noqa
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
|
|
@ -7,12 +7,13 @@ from django.contrib.auth.models import AbstractUser
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from core.exchanges.alpaca import AlpacaExchange
|
from core.exchanges.alpaca import AlpacaExchange
|
||||||
|
from core.exchanges.fake import FakeExchange
|
||||||
from core.exchanges.oanda import OANDAExchange
|
from core.exchanges.oanda import OANDAExchange
|
||||||
from core.lib.customers import get_or_create, update_customer_fields
|
from core.lib.customers import get_or_create, update_customer_fields
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
EXCHANGE_MAP = {"alpaca": AlpacaExchange, "oanda": OANDAExchange}
|
EXCHANGE_MAP = {"alpaca": AlpacaExchange, "oanda": OANDAExchange, "fake": FakeExchange}
|
||||||
TYPE_CHOICES = (
|
TYPE_CHOICES = (
|
||||||
("market", "Market"),
|
("market", "Market"),
|
||||||
("limit", "Limit"),
|
("limit", "Limit"),
|
||||||
|
@ -125,7 +126,7 @@ class User(AbstractUser):
|
||||||
|
|
||||||
|
|
||||||
class Account(models.Model):
|
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)
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
exchange = models.CharField(choices=EXCHANGE_CHOICES, 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.
|
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)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def get_client(self):
|
def get_client(self):
|
||||||
|
|
|
@ -1,26 +1,106 @@
|
||||||
|
from datetime import time
|
||||||
|
|
||||||
|
import freezegun
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from core.models import (
|
||||||
|
Account,
|
||||||
|
Hook,
|
||||||
|
OrderSettings,
|
||||||
|
RiskModel,
|
||||||
|
Signal,
|
||||||
|
Strategy,
|
||||||
|
TradingTime,
|
||||||
|
User,
|
||||||
|
)
|
||||||
from core.trading import checks
|
from core.trading import checks
|
||||||
|
|
||||||
from core.models import TradingTime, Strategy, OrderSettings, User
|
|
||||||
|
|
||||||
class ChecksTestCase(TestCase):
|
class ChecksTestCase(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
self.time_8 = time(8, 0, 0)
|
||||||
|
self.time_16 = time(16, 0, 0)
|
||||||
self.user = User.objects.create_user(
|
self.user = User.objects.create_user(
|
||||||
username="testuser", email="test@example.com", password="test"
|
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(
|
self.trading_time_now = TradingTime.objects.create(
|
||||||
user=self.user,
|
user=self.user,
|
||||||
name="Test Trading Time",
|
name="Test Trading Time",
|
||||||
start_day=1, # Monday
|
start_day=1, # Monday
|
||||||
start_time="08:00",
|
start_time=self.time_8,
|
||||||
end_day=1, # Monday
|
end_day=1, # Monday
|
||||||
end_time="16:00",
|
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()
|
||||||
|
|
||||||
self.strategy = Strategy.objects.create(user=self.user, name="Test Strategy", )
|
@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))
|
||||||
|
|
||||||
def test_within_trading_times(self):
|
@freezegun.freeze_time("2023-02-13T17:00:00") # Monday at 17:00
|
||||||
pass
|
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"))
|
||||||
|
|
||||||
|
self.assertIsNone(checks.within_trends(self.strategy, "EUR_XXX", "buy"))
|
||||||
|
self.assertIsNone(checks.within_trends(self.strategy, "EUR_XXX", "sell"))
|
||||||
|
|
|
@ -1,3 +1,16 @@
|
||||||
class ActiveManagement(object):
|
class ActiveManagement(object):
|
||||||
def __init__(self, strategy):
|
def __init__(self, strategy):
|
||||||
self.strategy = 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
|
||||||
|
|
|
@ -1,9 +1,16 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from decimal import Decimal as D
|
||||||
|
|
||||||
|
from core.lib.notify import sendmsg
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
|
|
||||||
log = logs.get_logger("checks")
|
log = logs.get_logger("checks")
|
||||||
|
|
||||||
|
|
||||||
|
def run_checks(strategy, x):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def within_trading_times(strategy, ts=None):
|
def within_trading_times(strategy, ts=None):
|
||||||
if not ts:
|
if not ts:
|
||||||
ts = datetime.utcnow()
|
ts = datetime.utcnow()
|
||||||
|
@ -16,4 +23,86 @@ def within_trading_times(strategy, ts=None):
|
||||||
if not any(matches):
|
if not any(matches):
|
||||||
log.debug("Not within trading time range")
|
log.debug("Not within trading time range")
|
||||||
return False
|
return False
|
||||||
return True
|
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
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
from datetime import datetime
|
|
||||||
from decimal import Decimal as D
|
from decimal import Decimal as D
|
||||||
|
|
||||||
from core.exchanges import common
|
from core.exchanges import common
|
||||||
|
@ -172,7 +171,7 @@ def get_tp_sl(direction, strategy, price, round_to=None):
|
||||||
return cast
|
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.
|
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
|
* 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
|
: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
|
# Convert the maximum price slippage to a ratio
|
||||||
if strategy.risk_model is not None:
|
if strategy.risk_model is not None:
|
||||||
price_slippage_as_ratio = D(strategy.risk_model.price_slippage_percent) / D(100)
|
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.
|
# Only check times for entries. We can always exit trades and set trends.
|
||||||
if func == "entry":
|
if func == "entry":
|
||||||
# Check if we can trade now!
|
within_trading_times = checks.within_trading_times(strategy)
|
||||||
now_utc = datetime.utcnow()
|
if not within_trading_times:
|
||||||
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")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Don't touch the account if it's disabled.
|
# 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"Callback price: {price}")
|
||||||
log.debug(f"Current price: {current_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
|
# 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:
|
if not price_bound:
|
||||||
return
|
return
|
||||||
price_bound = round(price_bound, display_precision)
|
price_bound = round(price_bound, display_precision)
|
||||||
|
@ -390,34 +372,9 @@ def execute_strategy(callback, strategy, func):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if we are trading against the trend
|
# Check if we are trading against the trend
|
||||||
if strategy.trend_signals.exists():
|
within_trends = checks.within_trends(strategy, symbol, direction)
|
||||||
if strategy.trends is None:
|
if not within_trends:
|
||||||
log.debug("Refusing to trade with no trend signals received")
|
return
|
||||||
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}")
|
|
||||||
|
|
||||||
type = strategy.order_settings.order_type
|
type = strategy.order_settings.order_type
|
||||||
|
|
||||||
|
@ -435,18 +392,6 @@ def execute_strategy(callback, strategy, func):
|
||||||
protection = get_tp_sl(
|
protection = get_tp_sl(
|
||||||
direction, strategy, current_price, round_to=display_precision
|
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
|
# Create object, note that the amount is rounded to the trade precision
|
||||||
amount_rounded = float(round(trade_size_in_base, trade_precision))
|
amount_rounded = float(round(trade_size_in_base, trade_precision))
|
||||||
|
|
|
@ -22,6 +22,8 @@ oandapyV20
|
||||||
glom
|
glom
|
||||||
elasticsearch
|
elasticsearch
|
||||||
apscheduler
|
apscheduler
|
||||||
|
# For trading time checks
|
||||||
|
freezegun
|
||||||
git+https://git.zm.is/XF/django-crud-mixins
|
git+https://git.zm.is/XF/django-crud-mixins
|
||||||
# pyroscope-io
|
# pyroscope-io
|
||||||
# For caching
|
# For caching
|
||||||
|
|
Loading…
Reference in New Issue