Update bot config on front and back
This commit is contained in:
@@ -178,39 +178,21 @@ public class BacktestController : BaseController
|
|||||||
break;
|
break;
|
||||||
case BotType.ScalpingBot:
|
case BotType.ScalpingBot:
|
||||||
backtestResult = await _backtester.RunScalpingBotBacktest(
|
backtestResult = await _backtester.RunScalpingBotBacktest(
|
||||||
account,
|
backtestConfig,
|
||||||
moneyManagement,
|
|
||||||
request.Config.Ticker,
|
|
||||||
scenario,
|
|
||||||
request.Config.Timeframe,
|
|
||||||
request.Balance,
|
|
||||||
request.StartDate,
|
request.StartDate,
|
||||||
request.EndDate,
|
request.EndDate,
|
||||||
user,
|
user,
|
||||||
request.WatchOnly,
|
|
||||||
request.Save,
|
request.Save,
|
||||||
cooldownPeriod: request.Config.CooldownPeriod,
|
null);
|
||||||
maxLossStreak: request.Config.MaxLossStreak,
|
|
||||||
maxPositionTimeHours: request.Config.MaxPositionTimeHours,
|
|
||||||
flipOnlyWhenInProfit: request.Config.FlipOnlyWhenInProfit);
|
|
||||||
break;
|
break;
|
||||||
case BotType.FlippingBot:
|
case BotType.FlippingBot:
|
||||||
backtestResult = await _backtester.RunFlippingBotBacktest(
|
backtestResult = await _backtester.RunFlippingBotBacktest(
|
||||||
account,
|
backtestConfig,
|
||||||
moneyManagement,
|
|
||||||
request.Config.Ticker,
|
|
||||||
scenario,
|
|
||||||
request.Config.Timeframe,
|
|
||||||
request.Balance,
|
|
||||||
request.StartDate,
|
request.StartDate,
|
||||||
request.EndDate,
|
request.EndDate,
|
||||||
user,
|
user,
|
||||||
request.WatchOnly,
|
|
||||||
request.Save,
|
request.Save,
|
||||||
cooldownPeriod: request.Config.CooldownPeriod,
|
null);
|
||||||
maxLossStreak: request.Config.MaxLossStreak,
|
|
||||||
maxPositionTimeHours: request.Config.MaxPositionTimeHours,
|
|
||||||
flipOnlyWhenInProfit: request.Config.FlipOnlyWhenInProfit);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,52 +105,64 @@ public class BotController : BaseController
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Check if user owns the account
|
if (request.Config == null)
|
||||||
if (!await UserOwnsBotAccount(request.Identifier, request.AccountName))
|
|
||||||
{
|
{
|
||||||
return Forbid("You don't have permission to start this bot");
|
return BadRequest("Bot configuration is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user owns the account specified in the request
|
||||||
|
if (!await UserOwnsBotAccount(null, request.Config.AccountName))
|
||||||
|
{
|
||||||
|
return Forbid("You don't have permission to start a bot with this account");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger error if money management is not provided
|
// Trigger error if money management is not provided
|
||||||
if (string.IsNullOrEmpty(request.MoneyManagementName))
|
if (string.IsNullOrEmpty(request.MoneyManagementName) && request.Config.MoneyManagement == null)
|
||||||
{
|
{
|
||||||
return BadRequest("Money management name is required");
|
return BadRequest("Money management name or money management object is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = await GetUser();
|
var user = await GetUser();
|
||||||
var moneyManagement = await _moneyManagementService.GetMoneyMangement(user, request.MoneyManagementName);
|
|
||||||
|
// Get money management if name is provided
|
||||||
|
MoneyManagement moneyManagement = request.Config.MoneyManagement;
|
||||||
|
if (!string.IsNullOrEmpty(request.MoneyManagementName))
|
||||||
|
{
|
||||||
|
moneyManagement = await _moneyManagementService.GetMoneyMangement(user, request.MoneyManagementName);
|
||||||
if (moneyManagement == null)
|
if (moneyManagement == null)
|
||||||
{
|
{
|
||||||
return BadRequest("Money management not found");
|
return BadRequest("Money management not found");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate initialTradingBalance
|
// Validate initialTradingBalance
|
||||||
if (request.InitialTradingBalance <= Constants.GMX.Config.MinimumPositionAmount)
|
if (request.Config.BotTradingBalance <= Constants.GMX.Config.MinimumPositionAmount)
|
||||||
{
|
{
|
||||||
return BadRequest(
|
return BadRequest(
|
||||||
$"Initial trading balance must be greater than {Constants.GMX.Config.MinimumPositionAmount}");
|
$"Initial trading balance must be greater than {Constants.GMX.Config.MinimumPositionAmount}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the config with final money management
|
||||||
var config = new TradingBotConfig
|
var config = new TradingBotConfig
|
||||||
{
|
{
|
||||||
AccountName = request.AccountName,
|
AccountName = request.Config.AccountName,
|
||||||
MoneyManagement = moneyManagement,
|
MoneyManagement = moneyManagement,
|
||||||
Ticker = request.Ticker,
|
Ticker = request.Config.Ticker,
|
||||||
ScenarioName = request.Scenario,
|
ScenarioName = request.Config.ScenarioName,
|
||||||
Timeframe = request.Timeframe,
|
Timeframe = request.Config.Timeframe,
|
||||||
IsForWatchingOnly = request.IsForWatchOnly,
|
IsForWatchingOnly = request.Config.IsForWatchingOnly,
|
||||||
BotTradingBalance = request.InitialTradingBalance,
|
BotTradingBalance = request.Config.BotTradingBalance,
|
||||||
BotType = request.BotType,
|
BotType = request.Config.BotType,
|
||||||
CooldownPeriod = request.CooldownPeriod,
|
CooldownPeriod = request.Config.CooldownPeriod,
|
||||||
MaxLossStreak = request.MaxLossStreak,
|
MaxLossStreak = request.Config.MaxLossStreak,
|
||||||
MaxPositionTimeHours = request.MaxPositionTimeHours,
|
MaxPositionTimeHours = request.Config.MaxPositionTimeHours,
|
||||||
FlipOnlyWhenInProfit = request.FlipOnlyWhenInProfit,
|
FlipOnlyWhenInProfit = request.Config.FlipOnlyWhenInProfit,
|
||||||
IsForBacktest = false,
|
IsForBacktest = false,
|
||||||
FlipPosition = request.BotType == BotType.FlippingBot,
|
FlipPosition = request.Config.BotType == BotType.FlippingBot,
|
||||||
Name = request.Name
|
Name = request.Config.Name
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = await _mediator.Send(new StartBotCommand(config, request.Name, user));
|
var result = await _mediator.Send(new StartBotCommand(config, request.Config.Name, user));
|
||||||
|
|
||||||
await NotifyBotSubscriberAsync();
|
await NotifyBotSubscriberAsync();
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
@@ -684,77 +696,14 @@ public class ClosePositionRequest
|
|||||||
public class StartBotRequest
|
public class StartBotRequest
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The type of bot to start
|
/// The trading bot configuration
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public BotType BotType { get; set; }
|
public TradingBotConfig Config { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The identifier of the bot
|
/// Optional money management name (if not included in Config.MoneyManagement)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Identifier { get; set; }
|
public string? MoneyManagementName { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The ticker to trade
|
|
||||||
/// </summary>
|
|
||||||
public Ticker Ticker { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The scenario to use
|
|
||||||
/// </summary>
|
|
||||||
public string Scenario { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The timeframe to use
|
|
||||||
/// </summary>
|
|
||||||
public Timeframe Timeframe { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The account name to use
|
|
||||||
/// </summary>
|
|
||||||
public string AccountName { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The money management name to use
|
|
||||||
/// </summary>
|
|
||||||
public string MoneyManagementName { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether the bot is for watching only
|
|
||||||
/// </summary>
|
|
||||||
public bool IsForWatchOnly { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The initial trading balance
|
|
||||||
/// </summary>
|
|
||||||
public decimal InitialTradingBalance { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The cooldown period in candles between positions
|
|
||||||
/// </summary>
|
|
||||||
public int CooldownPeriod { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The maximum number of consecutive losses before stopping
|
|
||||||
/// </summary>
|
|
||||||
public int MaxLossStreak { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The name of the bot
|
|
||||||
/// </summary>
|
|
||||||
public string Name { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maximum time in hours that a position can remain open before being automatically closed.
|
|
||||||
/// Supports fractional values (e.g., 2.5 for 2 hours and 30 minutes).
|
|
||||||
/// If null, time-based position closure is disabled.
|
|
||||||
/// </summary>
|
|
||||||
public decimal? MaxPositionTimeHours { get; set; } = null;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// If true, positions will only be flipped when the current position is in profit.
|
|
||||||
/// If false, positions will be flipped regardless of profit status.
|
|
||||||
/// </summary>
|
|
||||||
public bool FlipOnlyWhenInProfit { get; set; } = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,79 +1,40 @@
|
|||||||
using Managing.Domain.Accounts;
|
using Managing.Domain.Backtests;
|
||||||
using Managing.Domain.Backtests;
|
using Managing.Domain.Bots;
|
||||||
using Managing.Domain.Candles;
|
using Managing.Domain.Candles;
|
||||||
using Managing.Domain.MoneyManagements;
|
|
||||||
using Managing.Domain.Scenarios;
|
|
||||||
using Managing.Domain.Users;
|
using Managing.Domain.Users;
|
||||||
using static Managing.Common.Enums;
|
|
||||||
|
|
||||||
namespace Managing.Application.Abstractions.Services
|
namespace Managing.Application.Abstractions.Services
|
||||||
{
|
{
|
||||||
public interface IBacktester
|
public interface IBacktester
|
||||||
{
|
{
|
||||||
Task<Backtest> RunScalpingBotBacktest(
|
Task<Backtest> RunScalpingBotBacktest(
|
||||||
Account account,
|
TradingBotConfig config,
|
||||||
MoneyManagement moneyManagement,
|
|
||||||
Ticker ticker,
|
|
||||||
Scenario scenario,
|
|
||||||
Timeframe timeframe,
|
|
||||||
decimal balance,
|
|
||||||
DateTime startDate,
|
DateTime startDate,
|
||||||
DateTime endDate,
|
DateTime endDate,
|
||||||
User user = null,
|
User user = null,
|
||||||
bool isForWatchingOnly = false,
|
|
||||||
bool save = false,
|
bool save = false,
|
||||||
List<Candle>? initialCandles = null,
|
List<Candle>? initialCandles = null);
|
||||||
int cooldownPeriod = 1,
|
|
||||||
int maxLossStreak = 0,
|
|
||||||
decimal? maxPositionTimeHours = null,
|
|
||||||
bool flipOnlyWhenInProfit = true);
|
|
||||||
|
|
||||||
Task<Backtest> RunFlippingBotBacktest(
|
Task<Backtest> RunFlippingBotBacktest(
|
||||||
Account account,
|
TradingBotConfig config,
|
||||||
MoneyManagement moneyManagement,
|
|
||||||
Ticker ticker,
|
|
||||||
Scenario scenario,
|
|
||||||
Timeframe timeframe,
|
|
||||||
decimal balance,
|
|
||||||
DateTime startDate,
|
DateTime startDate,
|
||||||
DateTime endDate,
|
DateTime endDate,
|
||||||
User user = null,
|
User user = null,
|
||||||
bool isForWatchingOnly = false,
|
|
||||||
bool save = false,
|
bool save = false,
|
||||||
List<Candle>? initialCandles = null,
|
List<Candle>? initialCandles = null);
|
||||||
int cooldownPeriod = 1,
|
|
||||||
int maxLossStreak = 0,
|
|
||||||
decimal? maxPositionTimeHours = null,
|
|
||||||
bool flipOnlyWhenInProfit = true);
|
|
||||||
|
|
||||||
bool DeleteBacktest(string id);
|
bool DeleteBacktest(string id);
|
||||||
bool DeleteBacktests();
|
bool DeleteBacktests();
|
||||||
|
|
||||||
Task<Backtest> RunScalpingBotBacktest(
|
Task<Backtest> RunScalpingBotBacktest(
|
||||||
Account account,
|
TradingBotConfig config,
|
||||||
MoneyManagement moneyManagement,
|
|
||||||
Scenario scenario,
|
|
||||||
Timeframe timeframe,
|
|
||||||
List<Candle> candles,
|
List<Candle> candles,
|
||||||
decimal balance,
|
User user = null);
|
||||||
User user = null,
|
|
||||||
int cooldownPeriod = 1,
|
|
||||||
int maxLossStreak = 0,
|
|
||||||
decimal? maxPositionTimeHours = null,
|
|
||||||
bool flipOnlyWhenInProfit = true);
|
|
||||||
|
|
||||||
Task<Backtest> RunFlippingBotBacktest(
|
Task<Backtest> RunFlippingBotBacktest(
|
||||||
Account account,
|
TradingBotConfig config,
|
||||||
MoneyManagement moneyManagement,
|
|
||||||
Scenario scenario,
|
|
||||||
Timeframe timeframe,
|
|
||||||
List<Candle> candles,
|
List<Candle> candles,
|
||||||
decimal balance,
|
User user = null);
|
||||||
User user = null,
|
|
||||||
int cooldownPeriod = 1,
|
|
||||||
int maxLossStreak = 0,
|
|
||||||
decimal? maxPositionTimeHours = null,
|
|
||||||
bool flipOnlyWhenInProfit = true);
|
|
||||||
|
|
||||||
// User-specific operations
|
// User-specific operations
|
||||||
Task<IEnumerable<Backtest>> GetBacktestsByUser(User user);
|
Task<IEnumerable<Backtest>> GetBacktestsByUser(User user);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Managing.Application.Abstractions.Services;
|
|||||||
using Managing.Application.Backtesting;
|
using Managing.Application.Backtesting;
|
||||||
using Managing.Application.Bots.Base;
|
using Managing.Application.Bots.Base;
|
||||||
using Managing.Core;
|
using Managing.Core;
|
||||||
|
using Managing.Domain.Bots;
|
||||||
using Managing.Domain.Candles;
|
using Managing.Domain.Candles;
|
||||||
using Managing.Domain.MoneyManagements;
|
using Managing.Domain.MoneyManagements;
|
||||||
using Managing.Domain.Scenarios;
|
using Managing.Domain.Scenarios;
|
||||||
@@ -42,7 +43,7 @@ namespace Managing.Application.Tests
|
|||||||
_tradingService.Object,
|
_tradingService.Object,
|
||||||
botService);
|
botService);
|
||||||
_backtester = new Backtester(_exchangeService, _botFactory, backtestRepository, backtestLogger,
|
_backtester = new Backtester(_exchangeService, _botFactory, backtestRepository, backtestLogger,
|
||||||
scenarioService);
|
scenarioService, _accountService.Object);
|
||||||
_elapsedTimes = new List<double>();
|
_elapsedTimes = new List<double>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,10 +59,25 @@ namespace Managing.Application.Tests
|
|||||||
var localCandles =
|
var localCandles =
|
||||||
FileHelpers.ReadJson<List<Candle>>($"{ticker.ToString()}-{timeframe.ToString()}-candles.json");
|
FileHelpers.ReadJson<List<Candle>>($"{ticker.ToString()}-{timeframe.ToString()}-candles.json");
|
||||||
|
|
||||||
|
var config = new TradingBotConfig
|
||||||
|
{
|
||||||
|
AccountName = _account.Name,
|
||||||
|
MoneyManagement = MoneyManagement,
|
||||||
|
Ticker = ticker,
|
||||||
|
ScenarioName = scenario.Name,
|
||||||
|
Timeframe = timeframe,
|
||||||
|
IsForWatchingOnly = false,
|
||||||
|
BotTradingBalance = 1000,
|
||||||
|
BotType = BotType.FlippingBot,
|
||||||
|
IsForBacktest = true,
|
||||||
|
CooldownPeriod = 1,
|
||||||
|
MaxLossStreak = 0,
|
||||||
|
FlipPosition = true,
|
||||||
|
Name = "Test"
|
||||||
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var backtestResult = await _backtester.RunFlippingBotBacktest(_account, MoneyManagement, ticker, scenario,
|
var backtestResult = await _backtester.RunFlippingBotBacktest(config, localCandles.TakeLast(500).ToList());
|
||||||
timeframe, 1000, new DateTime().AddDays(-3), DateTime.UtcNow,
|
|
||||||
initialCandles: localCandles.TakeLast(500).ToList());
|
|
||||||
|
|
||||||
var json = JsonConvert.SerializeObject(backtestResult, Formatting.None);
|
var json = JsonConvert.SerializeObject(backtestResult, Formatting.None);
|
||||||
File.WriteAllText($"{ticker.ToString()}-{timeframe.ToString()}-{Guid.NewGuid()}.json", json);
|
File.WriteAllText($"{ticker.ToString()}-{timeframe.ToString()}-{Guid.NewGuid()}.json", json);
|
||||||
@@ -90,9 +106,25 @@ namespace Managing.Application.Tests
|
|||||||
var strategy = ScenarioHelpers.BuildStrategy(StrategyType.RsiDivergence, "RsiDiv", period: 5);
|
var strategy = ScenarioHelpers.BuildStrategy(StrategyType.RsiDivergence, "RsiDiv", period: 5);
|
||||||
scenario.AddStrategy(strategy);
|
scenario.AddStrategy(strategy);
|
||||||
|
|
||||||
|
var config = new TradingBotConfig
|
||||||
|
{
|
||||||
|
AccountName = _account.Name,
|
||||||
|
MoneyManagement = MoneyManagement,
|
||||||
|
Ticker = ticker,
|
||||||
|
ScenarioName = scenario.Name,
|
||||||
|
Timeframe = timeframe,
|
||||||
|
IsForWatchingOnly = false,
|
||||||
|
BotTradingBalance = 1000,
|
||||||
|
BotType = BotType.ScalpingBot,
|
||||||
|
IsForBacktest = true,
|
||||||
|
CooldownPeriod = 1,
|
||||||
|
MaxLossStreak = 0,
|
||||||
|
FlipPosition = false,
|
||||||
|
Name = "Test"
|
||||||
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var backtestResult = await _backtester.RunScalpingBotBacktest(_account, MoneyManagement, ticker, scenario,
|
var backtestResult = await _backtester.RunScalpingBotBacktest(config, DateTime.UtcNow.AddDays(-6), DateTime.UtcNow, null, false, null);
|
||||||
timeframe, 1000, DateTime.UtcNow.AddDays(-6), DateTime.UtcNow, null);
|
|
||||||
//WriteCsvReport(backtestResult.GetStringReport());
|
//WriteCsvReport(backtestResult.GetStringReport());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -120,9 +152,25 @@ namespace Managing.Application.Tests
|
|||||||
TakeProfit = 0.02m
|
TakeProfit = 0.02m
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var config = new TradingBotConfig
|
||||||
|
{
|
||||||
|
AccountName = _account.Name,
|
||||||
|
MoneyManagement = moneyManagement,
|
||||||
|
Ticker = ticker,
|
||||||
|
ScenarioName = scenario.Name,
|
||||||
|
Timeframe = timeframe,
|
||||||
|
IsForWatchingOnly = false,
|
||||||
|
BotTradingBalance = 1000,
|
||||||
|
BotType = BotType.ScalpingBot,
|
||||||
|
IsForBacktest = true,
|
||||||
|
CooldownPeriod = 1,
|
||||||
|
MaxLossStreak = 0,
|
||||||
|
FlipPosition = false,
|
||||||
|
Name = "Test"
|
||||||
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var backtestResult = await _backtester.RunScalpingBotBacktest(_account, moneyManagement, ticker, scenario,
|
var backtestResult = await _backtester.RunScalpingBotBacktest(config, DateTime.UtcNow.AddDays(-6), DateTime.UtcNow, null, false, null);
|
||||||
timeframe, 1000, DateTime.UtcNow.AddDays(-6), DateTime.UtcNow, null);
|
|
||||||
WriteCsvReport(backtestResult.GetStringReport());
|
WriteCsvReport(backtestResult.GetStringReport());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -191,10 +239,38 @@ namespace Managing.Application.Tests
|
|||||||
var backtestResult = botType switch
|
var backtestResult = botType switch
|
||||||
{
|
{
|
||||||
BotType.SimpleBot => throw new NotImplementedException(),
|
BotType.SimpleBot => throw new NotImplementedException(),
|
||||||
BotType.ScalpingBot => _backtester.RunScalpingBotBacktest(_account, moneyManagement,
|
BotType.ScalpingBot => _backtester.RunScalpingBotBacktest(new TradingBotConfig
|
||||||
scenario, timeframe, candles, 1000, null).Result,
|
{
|
||||||
BotType.FlippingBot => _backtester.RunFlippingBotBacktest(_account, moneyManagement,
|
AccountName = _account.Name,
|
||||||
scenario, timeframe, candles, 1000, null).Result,
|
MoneyManagement = moneyManagement,
|
||||||
|
Ticker = ticker,
|
||||||
|
ScenarioName = scenario.Name,
|
||||||
|
Timeframe = timeframe,
|
||||||
|
IsForWatchingOnly = false,
|
||||||
|
BotTradingBalance = 1000,
|
||||||
|
BotType = BotType.ScalpingBot,
|
||||||
|
IsForBacktest = true,
|
||||||
|
CooldownPeriod = 1,
|
||||||
|
MaxLossStreak = 0,
|
||||||
|
FlipPosition = false,
|
||||||
|
Name = "Test"
|
||||||
|
}, candles, null).Result,
|
||||||
|
BotType.FlippingBot => _backtester.RunFlippingBotBacktest(new TradingBotConfig
|
||||||
|
{
|
||||||
|
AccountName = _account.Name,
|
||||||
|
MoneyManagement = moneyManagement,
|
||||||
|
Ticker = ticker,
|
||||||
|
ScenarioName = scenario.Name,
|
||||||
|
Timeframe = timeframe,
|
||||||
|
IsForWatchingOnly = false,
|
||||||
|
BotTradingBalance = 1000,
|
||||||
|
BotType = BotType.FlippingBot,
|
||||||
|
IsForBacktest = true,
|
||||||
|
CooldownPeriod = 1,
|
||||||
|
MaxLossStreak = 0,
|
||||||
|
FlipPosition = true,
|
||||||
|
Name = "Test"
|
||||||
|
}, candles, null).Result,
|
||||||
_ => throw new NotImplementedException(),
|
_ => throw new NotImplementedException(),
|
||||||
};
|
};
|
||||||
timer.Stop();
|
timer.Stop();
|
||||||
@@ -299,10 +375,38 @@ namespace Managing.Application.Tests
|
|||||||
var backtestResult = botType switch
|
var backtestResult = botType switch
|
||||||
{
|
{
|
||||||
BotType.SimpleBot => throw new NotImplementedException(),
|
BotType.SimpleBot => throw new NotImplementedException(),
|
||||||
BotType.ScalpingBot => _backtester.RunScalpingBotBacktest(_account, moneyManagement,
|
BotType.ScalpingBot => _backtester.RunScalpingBotBacktest(new TradingBotConfig
|
||||||
scenario, timeframe, candles, 1000, null).Result,
|
{
|
||||||
BotType.FlippingBot => _backtester.RunFlippingBotBacktest(_account, moneyManagement,
|
AccountName = _account.Name,
|
||||||
scenario, timeframe, candles, 1000, null).Result,
|
MoneyManagement = moneyManagement,
|
||||||
|
Ticker = ticker,
|
||||||
|
ScenarioName = scenario.Name,
|
||||||
|
Timeframe = timeframe,
|
||||||
|
IsForWatchingOnly = false,
|
||||||
|
BotTradingBalance = 1000,
|
||||||
|
BotType = BotType.ScalpingBot,
|
||||||
|
IsForBacktest = true,
|
||||||
|
CooldownPeriod = 1,
|
||||||
|
MaxLossStreak = 0,
|
||||||
|
FlipPosition = false,
|
||||||
|
Name = "Test"
|
||||||
|
}, candles, null).Result,
|
||||||
|
BotType.FlippingBot => _backtester.RunFlippingBotBacktest(new TradingBotConfig
|
||||||
|
{
|
||||||
|
AccountName = _account.Name,
|
||||||
|
MoneyManagement = moneyManagement,
|
||||||
|
Ticker = ticker,
|
||||||
|
ScenarioName = scenario.Name,
|
||||||
|
Timeframe = timeframe,
|
||||||
|
IsForWatchingOnly = false,
|
||||||
|
BotTradingBalance = 1000,
|
||||||
|
BotType = BotType.FlippingBot,
|
||||||
|
IsForBacktest = true,
|
||||||
|
CooldownPeriod = 1,
|
||||||
|
MaxLossStreak = 0,
|
||||||
|
FlipPosition = true,
|
||||||
|
Name = "Test"
|
||||||
|
}, candles, null).Result,
|
||||||
_ => throw new NotImplementedException(),
|
_ => throw new NotImplementedException(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Managing.Application.Abstractions.Repositories;
|
using Managing.Application.Abstractions.Repositories;
|
||||||
using Managing.Application.Abstractions.Services;
|
using Managing.Application.Abstractions.Services;
|
||||||
using Managing.Domain.Accounts;
|
using Managing.Domain.Accounts;
|
||||||
|
using Managing.Domain.Bots;
|
||||||
using Managing.Domain.MoneyManagements;
|
using Managing.Domain.MoneyManagements;
|
||||||
using Managing.Domain.Scenarios;
|
using Managing.Domain.Scenarios;
|
||||||
using Managing.Domain.Shared.Helpers;
|
using Managing.Domain.Shared.Helpers;
|
||||||
@@ -262,16 +263,30 @@ public class StatisticService : IStatisticService
|
|||||||
TakeProfit = 0.02m
|
TakeProfit = 0.02m
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var config = new TradingBotConfig
|
||||||
|
{
|
||||||
|
AccountName = account.Name,
|
||||||
|
MoneyManagement = moneyManagement,
|
||||||
|
Ticker = ticker,
|
||||||
|
ScenarioName = scenario.Name,
|
||||||
|
Timeframe = timeframe,
|
||||||
|
IsForWatchingOnly = true,
|
||||||
|
BotTradingBalance = 1000,
|
||||||
|
BotType = BotType.ScalpingBot,
|
||||||
|
IsForBacktest = true,
|
||||||
|
CooldownPeriod = 1,
|
||||||
|
MaxLossStreak = 0,
|
||||||
|
FlipPosition = false,
|
||||||
|
Name = "StatisticsBacktest"
|
||||||
|
};
|
||||||
|
|
||||||
var backtest = await _backtester.RunScalpingBotBacktest(
|
var backtest = await _backtester.RunScalpingBotBacktest(
|
||||||
account,
|
config,
|
||||||
moneyManagement,
|
|
||||||
ticker,
|
|
||||||
scenario,
|
|
||||||
timeframe,
|
|
||||||
1000,
|
|
||||||
DateTime.Now.AddDays(-7),
|
DateTime.Now.AddDays(-7),
|
||||||
DateTime.Now,
|
DateTime.Now,
|
||||||
isForWatchingOnly: true);
|
null,
|
||||||
|
false,
|
||||||
|
null);
|
||||||
|
|
||||||
return backtest.Signals;
|
return backtest.Signals;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
using Managing.Application.Abstractions;
|
using Managing.Application.Abstractions;
|
||||||
using Managing.Application.Abstractions.Repositories;
|
using Managing.Application.Abstractions.Repositories;
|
||||||
using Managing.Application.Abstractions.Services;
|
using Managing.Application.Abstractions.Services;
|
||||||
using Managing.Core;
|
|
||||||
using Managing.Core.FixedSizedQueue;
|
using Managing.Core.FixedSizedQueue;
|
||||||
using Managing.Domain.Accounts;
|
using Managing.Domain.Accounts;
|
||||||
using Managing.Domain.Backtests;
|
using Managing.Domain.Backtests;
|
||||||
using Managing.Domain.Bots;
|
using Managing.Domain.Bots;
|
||||||
using Managing.Domain.Candles;
|
using Managing.Domain.Candles;
|
||||||
using Managing.Domain.MoneyManagements;
|
|
||||||
using Managing.Domain.Scenarios;
|
using Managing.Domain.Scenarios;
|
||||||
using Managing.Domain.Shared.Helpers;
|
using Managing.Domain.Shared.Helpers;
|
||||||
using Managing.Domain.Strategies;
|
using Managing.Domain.Strategies;
|
||||||
@@ -26,18 +24,22 @@ namespace Managing.Application.Backtesting
|
|||||||
private readonly IExchangeService _exchangeService;
|
private readonly IExchangeService _exchangeService;
|
||||||
private readonly IBotFactory _botFactory;
|
private readonly IBotFactory _botFactory;
|
||||||
private readonly IScenarioService _scenarioService;
|
private readonly IScenarioService _scenarioService;
|
||||||
|
private readonly IAccountService _accountService;
|
||||||
|
|
||||||
public Backtester(
|
public Backtester(
|
||||||
IExchangeService exchangeService,
|
IExchangeService exchangeService,
|
||||||
IBotFactory botFactory,
|
IBotFactory botFactory,
|
||||||
IBacktestRepository backtestRepository,
|
IBacktestRepository backtestRepository,
|
||||||
ILogger<Backtester> logger, IScenarioService scenarioService)
|
ILogger<Backtester> logger,
|
||||||
|
IScenarioService scenarioService,
|
||||||
|
IAccountService accountService)
|
||||||
{
|
{
|
||||||
_exchangeService = exchangeService;
|
_exchangeService = exchangeService;
|
||||||
_botFactory = botFactory;
|
_botFactory = botFactory;
|
||||||
_backtestRepository = backtestRepository;
|
_backtestRepository = backtestRepository;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_scenarioService = scenarioService;
|
_scenarioService = scenarioService;
|
||||||
|
_accountService = accountService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Backtest RunSimpleBotBacktest(Workflow workflow, bool save = false)
|
public Backtest RunSimpleBotBacktest(Workflow workflow, bool save = false)
|
||||||
@@ -54,45 +56,19 @@ namespace Managing.Application.Backtesting
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Backtest> RunScalpingBotBacktest(
|
public async Task<Backtest> RunScalpingBotBacktest(
|
||||||
Account account,
|
TradingBotConfig config,
|
||||||
MoneyManagement moneyManagement,
|
|
||||||
Ticker ticker,
|
|
||||||
Scenario scenario,
|
|
||||||
Timeframe timeframe,
|
|
||||||
decimal balance,
|
|
||||||
DateTime startDate,
|
DateTime startDate,
|
||||||
DateTime endDate,
|
DateTime endDate,
|
||||||
User user = null,
|
User user = null,
|
||||||
bool isForWatchingOnly = false,
|
|
||||||
bool save = false,
|
bool save = false,
|
||||||
List<Candle>? initialCandles = null,
|
List<Candle>? initialCandles = null)
|
||||||
int cooldownPeriod = 1,
|
|
||||||
int maxLossStreak = 0,
|
|
||||||
decimal? maxPositionTimeHours = null,
|
|
||||||
bool flipOnlyWhenInProfit = true)
|
|
||||||
{
|
{
|
||||||
var config = new TradingBotConfig
|
var account = await GetAccountFromConfig(config);
|
||||||
{
|
|
||||||
AccountName = account.Name,
|
|
||||||
MoneyManagement = moneyManagement,
|
|
||||||
Ticker = ticker,
|
|
||||||
ScenarioName = scenario.Name,
|
|
||||||
Timeframe = timeframe,
|
|
||||||
IsForWatchingOnly = isForWatchingOnly,
|
|
||||||
BotTradingBalance = balance,
|
|
||||||
BotType = BotType.ScalpingBot,
|
|
||||||
IsForBacktest = true,
|
|
||||||
CooldownPeriod = cooldownPeriod,
|
|
||||||
MaxLossStreak = maxLossStreak,
|
|
||||||
MaxPositionTimeHours = maxPositionTimeHours,
|
|
||||||
FlipOnlyWhenInProfit = flipOnlyWhenInProfit
|
|
||||||
};
|
|
||||||
|
|
||||||
var scalpingBot = _botFactory.CreateBacktestScalpingBot(config);
|
var scalpingBot = _botFactory.CreateBacktestScalpingBot(config);
|
||||||
scalpingBot.LoadScenario(scenario.Name);
|
scalpingBot.LoadScenario(config.ScenarioName);
|
||||||
scalpingBot.User = user;
|
scalpingBot.User = user;
|
||||||
await scalpingBot.LoadAccount();
|
await scalpingBot.LoadAccount();
|
||||||
var candles = initialCandles ?? GetCandles(account, ticker, timeframe, startDate, endDate);
|
var candles = initialCandles ?? GetCandles(account, config.Ticker, config.Timeframe, startDate, endDate);
|
||||||
var result = GetBacktestingResult(config, scalpingBot, candles);
|
var result = GetBacktestingResult(config, scalpingBot, candles);
|
||||||
|
|
||||||
if (user != null)
|
if (user != null)
|
||||||
@@ -113,46 +89,20 @@ namespace Managing.Application.Backtesting
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Backtest> RunFlippingBotBacktest(
|
public async Task<Backtest> RunFlippingBotBacktest(
|
||||||
Account account,
|
TradingBotConfig config,
|
||||||
MoneyManagement moneyManagement,
|
|
||||||
Ticker ticker,
|
|
||||||
Scenario scenario,
|
|
||||||
Timeframe timeframe,
|
|
||||||
decimal balance,
|
|
||||||
DateTime startDate,
|
DateTime startDate,
|
||||||
DateTime endDate,
|
DateTime endDate,
|
||||||
User user = null,
|
User user = null,
|
||||||
bool isForWatchingOnly = false,
|
|
||||||
bool save = false,
|
bool save = false,
|
||||||
List<Candle>? initialCandles = null,
|
List<Candle>? initialCandles = null)
|
||||||
int cooldownPeriod = 1,
|
|
||||||
int maxLossStreak = 0,
|
|
||||||
decimal? maxPositionTimeHours = null,
|
|
||||||
bool flipOnlyWhenInProfit = true)
|
|
||||||
{
|
{
|
||||||
var config = new TradingBotConfig
|
var account = await GetAccountFromConfig(config);
|
||||||
{
|
|
||||||
AccountName = account.Name,
|
|
||||||
MoneyManagement = moneyManagement,
|
|
||||||
Ticker = ticker,
|
|
||||||
ScenarioName = scenario.Name,
|
|
||||||
Timeframe = timeframe,
|
|
||||||
IsForWatchingOnly = isForWatchingOnly,
|
|
||||||
BotTradingBalance = balance,
|
|
||||||
BotType = BotType.FlippingBot,
|
|
||||||
IsForBacktest = true,
|
|
||||||
CooldownPeriod = cooldownPeriod,
|
|
||||||
MaxLossStreak = maxLossStreak,
|
|
||||||
MaxPositionTimeHours = maxPositionTimeHours,
|
|
||||||
FlipOnlyWhenInProfit = flipOnlyWhenInProfit
|
|
||||||
};
|
|
||||||
|
|
||||||
var flippingBot = _botFactory.CreateBacktestFlippingBot(config);
|
var flippingBot = _botFactory.CreateBacktestFlippingBot(config);
|
||||||
flippingBot.LoadScenario(scenario.Name);
|
flippingBot.LoadScenario(config.ScenarioName);
|
||||||
flippingBot.User = user;
|
flippingBot.User = user;
|
||||||
await flippingBot.LoadAccount();
|
await flippingBot.LoadAccount();
|
||||||
|
|
||||||
var candles = initialCandles ?? GetCandles(account, ticker, timeframe, startDate, endDate);
|
var candles = initialCandles ?? GetCandles(account, config.Ticker, config.Timeframe, startDate, endDate);
|
||||||
var result = GetBacktestingResult(config, flippingBot, candles);
|
var result = GetBacktestingResult(config, flippingBot, candles);
|
||||||
|
|
||||||
if (user != null)
|
if (user != null)
|
||||||
@@ -173,38 +123,13 @@ namespace Managing.Application.Backtesting
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Backtest> RunScalpingBotBacktest(
|
public async Task<Backtest> RunScalpingBotBacktest(
|
||||||
Account account,
|
TradingBotConfig config,
|
||||||
MoneyManagement moneyManagement,
|
|
||||||
Scenario scenario,
|
|
||||||
Timeframe timeframe,
|
|
||||||
List<Candle> candles,
|
List<Candle> candles,
|
||||||
decimal balance,
|
User user = null)
|
||||||
User user = null,
|
|
||||||
int cooldownPeriod = 1,
|
|
||||||
int maxLossStreak = 0,
|
|
||||||
decimal? maxPositionTimeHours = null,
|
|
||||||
bool flipOnlyWhenInProfit = true)
|
|
||||||
{
|
{
|
||||||
var ticker = MiscExtensions.ParseEnum<Ticker>(candles.FirstOrDefault().Ticker);
|
var account = await GetAccountFromConfig(config);
|
||||||
var config = new TradingBotConfig
|
|
||||||
{
|
|
||||||
AccountName = account.Name,
|
|
||||||
MoneyManagement = moneyManagement,
|
|
||||||
Ticker = ticker,
|
|
||||||
ScenarioName = scenario.Name,
|
|
||||||
Timeframe = timeframe,
|
|
||||||
IsForWatchingOnly = false,
|
|
||||||
BotTradingBalance = balance,
|
|
||||||
BotType = BotType.ScalpingBot,
|
|
||||||
IsForBacktest = true,
|
|
||||||
CooldownPeriod = cooldownPeriod,
|
|
||||||
MaxLossStreak = maxLossStreak,
|
|
||||||
MaxPositionTimeHours = maxPositionTimeHours,
|
|
||||||
FlipOnlyWhenInProfit = flipOnlyWhenInProfit
|
|
||||||
};
|
|
||||||
|
|
||||||
var bot = _botFactory.CreateBacktestScalpingBot(config);
|
var bot = _botFactory.CreateBacktestScalpingBot(config);
|
||||||
bot.LoadScenario(scenario.Name);
|
bot.LoadScenario(config.ScenarioName);
|
||||||
bot.User = user;
|
bot.User = user;
|
||||||
await bot.LoadAccount();
|
await bot.LoadAccount();
|
||||||
|
|
||||||
@@ -219,38 +144,13 @@ namespace Managing.Application.Backtesting
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Backtest> RunFlippingBotBacktest(
|
public async Task<Backtest> RunFlippingBotBacktest(
|
||||||
Account account,
|
TradingBotConfig config,
|
||||||
MoneyManagement moneyManagement,
|
|
||||||
Scenario scenario,
|
|
||||||
Timeframe timeframe,
|
|
||||||
List<Candle> candles,
|
List<Candle> candles,
|
||||||
decimal balance,
|
User user = null)
|
||||||
User user = null,
|
|
||||||
int cooldownPeriod = 1,
|
|
||||||
int maxLossStreak = 0,
|
|
||||||
decimal? maxPositionTimeHours = null,
|
|
||||||
bool flipOnlyWhenInProfit = true)
|
|
||||||
{
|
{
|
||||||
var ticker = MiscExtensions.ParseEnum<Ticker>(candles.FirstOrDefault().Ticker);
|
var account = await GetAccountFromConfig(config);
|
||||||
var config = new TradingBotConfig
|
|
||||||
{
|
|
||||||
AccountName = account.Name,
|
|
||||||
MoneyManagement = moneyManagement,
|
|
||||||
Ticker = ticker,
|
|
||||||
ScenarioName = scenario.Name,
|
|
||||||
Timeframe = timeframe,
|
|
||||||
IsForWatchingOnly = false,
|
|
||||||
BotTradingBalance = balance,
|
|
||||||
BotType = BotType.FlippingBot,
|
|
||||||
IsForBacktest = true,
|
|
||||||
CooldownPeriod = cooldownPeriod,
|
|
||||||
MaxLossStreak = maxLossStreak,
|
|
||||||
MaxPositionTimeHours = maxPositionTimeHours,
|
|
||||||
FlipOnlyWhenInProfit = flipOnlyWhenInProfit
|
|
||||||
};
|
|
||||||
|
|
||||||
var bot = _botFactory.CreateBacktestFlippingBot(config);
|
var bot = _botFactory.CreateBacktestFlippingBot(config);
|
||||||
bot.LoadScenario(scenario.Name);
|
bot.LoadScenario(config.ScenarioName);
|
||||||
bot.User = user;
|
bot.User = user;
|
||||||
await bot.LoadAccount();
|
await bot.LoadAccount();
|
||||||
|
|
||||||
@@ -264,6 +164,23 @@ namespace Managing.Application.Backtesting
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<Account> GetAccountFromConfig(TradingBotConfig config)
|
||||||
|
{
|
||||||
|
// Use the account service to get the actual account
|
||||||
|
var account = await _accountService.GetAccount(config.AccountName, false, false);
|
||||||
|
if (account != null)
|
||||||
|
{
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: create a basic account structure if not found
|
||||||
|
return new Account
|
||||||
|
{
|
||||||
|
Name = config.AccountName,
|
||||||
|
Exchange = TradingExchanges.GmxV2 // Default exchange, should be configurable
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private List<Candle> GetCandles(Account account, Ticker ticker, Timeframe timeframe,
|
private List<Candle> GetCandles(Account account, Ticker ticker, Timeframe timeframe,
|
||||||
DateTime startDate, DateTime endDate)
|
DateTime startDate, DateTime endDate)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -468,19 +468,44 @@ public class TradingBot : Bot, ITradingBot
|
|||||||
|
|
||||||
var currentTime = Config.IsForBacktest ? lastCandle.Date : DateTime.UtcNow;
|
var currentTime = Config.IsForBacktest ? lastCandle.Date : DateTime.UtcNow;
|
||||||
|
|
||||||
// Check if position has exceeded maximum time limit (only if MaxPositionTimeHours is set)
|
// Check time-based position management (only if MaxPositionTimeHours is set)
|
||||||
if (Config.MaxPositionTimeHours.HasValue && HasPositionExceededTimeLimit(positionForSignal, currentTime))
|
if (Config.MaxPositionTimeHours.HasValue)
|
||||||
{
|
{
|
||||||
// Check if position is in profit or at breakeven before closing
|
|
||||||
var isPositionInProfit = await IsPositionInProfit(positionForSignal, lastCandle.Close);
|
var isPositionInProfit = await IsPositionInProfit(positionForSignal, lastCandle.Close);
|
||||||
var isAtBreakeven = Math.Abs(lastCandle.Close - positionForSignal.Open.Price) < 0.01m; // Small tolerance for breakeven
|
var isAtBreakeven = Math.Abs(lastCandle.Close - positionForSignal.Open.Price) < 0.01m; // Small tolerance for breakeven
|
||||||
|
var hasExceededTimeLimit = HasPositionExceededTimeLimit(positionForSignal, currentTime);
|
||||||
|
|
||||||
if (isPositionInProfit || isAtBreakeven)
|
// Calculate current unrealized PNL for logging
|
||||||
|
var currentPnl = CalculateUnrealizedPnl(positionForSignal, lastCandle.Close);
|
||||||
|
var pnlPercentage = Math.Round((currentPnl / (positionForSignal.Open.Price * positionForSignal.Open.Quantity)) * 100, 2);
|
||||||
|
|
||||||
|
// Early closure logic when CloseEarlyWhenProfitable is enabled
|
||||||
|
if (Config.CloseEarlyWhenProfitable && (isPositionInProfit || isAtBreakeven))
|
||||||
{
|
{
|
||||||
|
await LogInformation(
|
||||||
|
$"Closing position early due to profitability - Position opened at {positionForSignal.Open.Date}, " +
|
||||||
|
$"current time {currentTime}. Position is {(isPositionInProfit ? "in profit" : "at breakeven")} " +
|
||||||
|
$"(entry: {positionForSignal.Open.Price}, current: {lastCandle.Close}). " +
|
||||||
|
$"Current PNL: ${currentPnl:F2} ({pnlPercentage:F2}%). " +
|
||||||
|
$"CloseEarlyWhenProfitable is enabled.");
|
||||||
|
await CloseTrade(signal, positionForSignal, positionForSignal.Open, lastCandle.Close, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time limit exceeded logic
|
||||||
|
if (hasExceededTimeLimit)
|
||||||
|
{
|
||||||
|
if (Config.CloseEarlyWhenProfitable || isPositionInProfit || isAtBreakeven)
|
||||||
|
{
|
||||||
|
// Close when time limit is reached if:
|
||||||
|
// 1. CloseEarlyWhenProfitable is enabled (safety net), OR
|
||||||
|
// 2. Position is in profit/breakeven (when CloseEarlyWhenProfitable is disabled)
|
||||||
await LogInformation(
|
await LogInformation(
|
||||||
$"Closing position due to time limit - Position opened at {positionForSignal.Open.Date}, " +
|
$"Closing position due to time limit - Position opened at {positionForSignal.Open.Date}, " +
|
||||||
$"current time {currentTime}, max time limit {Config.MaxPositionTimeHours} hours. " +
|
$"current time {currentTime}, max time limit {Config.MaxPositionTimeHours} hours. " +
|
||||||
$"Position is {(isPositionInProfit ? "in profit" : "at breakeven")} (entry: {positionForSignal.Open.Price}, current: {lastCandle.Close})");
|
$"Position is {(isPositionInProfit ? "in profit" : isAtBreakeven ? "at breakeven" : "at a loss")} " +
|
||||||
|
$"(entry: {positionForSignal.Open.Price}, current: {lastCandle.Close}). " +
|
||||||
|
$"Current PNL: ${currentPnl:F2} ({pnlPercentage:F2}%)");
|
||||||
await CloseTrade(signal, positionForSignal, positionForSignal.Open, lastCandle.Close, true);
|
await CloseTrade(signal, positionForSignal, positionForSignal.Open, lastCandle.Close, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -489,7 +514,9 @@ public class TradingBot : Bot, ITradingBot
|
|||||||
await LogInformation(
|
await LogInformation(
|
||||||
$"Position has exceeded time limit ({Config.MaxPositionTimeHours} hours) but is at a loss " +
|
$"Position has exceeded time limit ({Config.MaxPositionTimeHours} hours) but is at a loss " +
|
||||||
$"(entry: {positionForSignal.Open.Price}, current: {lastCandle.Close}). " +
|
$"(entry: {positionForSignal.Open.Price}, current: {lastCandle.Close}). " +
|
||||||
$"Waiting for profit or breakeven before closing.");
|
$"Current PNL: ${currentPnl:F2} ({pnlPercentage:F2}%). " +
|
||||||
|
$"CloseEarlyWhenProfitable is disabled - waiting for profit or breakeven before closing.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1269,6 +1296,28 @@ public class TradingBot : Bot, ITradingBot
|
|||||||
return timeOpen >= maxTimeAllowed;
|
return timeOpen >= maxTimeAllowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates the current unrealized PNL for a position
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="position">The position to calculate PNL for</param>
|
||||||
|
/// <param name="currentPrice">The current market price</param>
|
||||||
|
/// <returns>The current unrealized PNL</returns>
|
||||||
|
private decimal CalculateUnrealizedPnl(Position position, decimal currentPrice)
|
||||||
|
{
|
||||||
|
if (position.OriginDirection == TradeDirection.Long)
|
||||||
|
{
|
||||||
|
return currentPrice - position.Open.Price;
|
||||||
|
}
|
||||||
|
else if (position.OriginDirection == TradeDirection.Short)
|
||||||
|
{
|
||||||
|
return position.Open.Price - currentPrice;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Invalid position direction");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates the trading bot configuration with new settings.
|
/// Updates the trading bot configuration with new settings.
|
||||||
/// This method validates the new configuration and applies it to the running bot.
|
/// This method validates the new configuration and applies it to the running bot.
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ public class TradingBotConfig
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal? MaxPositionTimeHours { get; set; }
|
public decimal? MaxPositionTimeHours { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When MaxPositionTimeHours is set and this is true, the position will be closed as soon as
|
||||||
|
/// it reaches breakeven or profit, rather than waiting for the full time duration.
|
||||||
|
/// If false, the position will only be closed when MaxPositionTimeHours is reached.
|
||||||
|
/// Default is false to maintain existing behavior.
|
||||||
|
/// </summary>
|
||||||
|
public bool CloseEarlyWhenProfitable { get; set; } = false;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// If true, positions will only be flipped when the current position is in profit.
|
/// If true, positions will only be flipped when the current position is in profit.
|
||||||
/// If false, positions will be flipped regardless of profit status.
|
/// If false, positions will be flipped regardless of profit status.
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type {
|
|||||||
MoneyManagement,
|
MoneyManagement,
|
||||||
RunBacktestRequest,
|
RunBacktestRequest,
|
||||||
StartBotRequest,
|
StartBotRequest,
|
||||||
Ticker,
|
|
||||||
TradingBotConfig
|
TradingBotConfig
|
||||||
} from '../../../generated/ManagingApi'
|
} from '../../../generated/ManagingApi'
|
||||||
import {BacktestClient, BotClient, MoneyManagementClient} from '../../../generated/ManagingApi'
|
import {BacktestClient, BotClient, MoneyManagementClient} from '../../../generated/ManagingApi'
|
||||||
@@ -90,23 +89,39 @@ const BacktestCards: React.FC<IBacktestCards> = ({ list, setBacktests }) => {
|
|||||||
moneyManagementName.toLowerCase() === 'custom' ||
|
moneyManagementName.toLowerCase() === 'custom' ||
|
||||||
moneyManagementName.toLowerCase().includes('custom');
|
moneyManagementName.toLowerCase().includes('custom');
|
||||||
|
|
||||||
const request: StartBotRequest = {
|
// Create TradingBotConfig from the backtest configuration
|
||||||
|
const tradingBotConfig: TradingBotConfig = {
|
||||||
accountName: backtest.config.accountName,
|
accountName: backtest.config.accountName,
|
||||||
name: botName,
|
ticker: backtest.config.ticker,
|
||||||
botType: backtest.config.botType,
|
scenarioName: backtest.config.scenarioName,
|
||||||
isForWatchOnly: isForWatchOnly,
|
|
||||||
// Only use the money management name if it's not a custom money management, otherwise use optimized
|
|
||||||
moneyManagementName: isCustomMoneyManagement ?
|
|
||||||
(backtest.optimizedMoneyManagement?.name || backtest.config.moneyManagement?.name) :
|
|
||||||
moneyManagementName,
|
|
||||||
scenario: backtest.config.scenarioName,
|
|
||||||
ticker: backtest.config.ticker as Ticker,
|
|
||||||
timeframe: backtest.config.timeframe,
|
timeframe: backtest.config.timeframe,
|
||||||
initialTradingBalance: initialTradingBalance,
|
botType: backtest.config.botType,
|
||||||
|
isForWatchingOnly: isForWatchOnly,
|
||||||
|
isForBacktest: false, // This is for running a live bot
|
||||||
cooldownPeriod: backtest.config.cooldownPeriod,
|
cooldownPeriod: backtest.config.cooldownPeriod,
|
||||||
maxLossStreak: backtest.config.maxLossStreak,
|
maxLossStreak: backtest.config.maxLossStreak,
|
||||||
maxPositionTimeHours: backtest.config.maxPositionTimeHours,
|
maxPositionTimeHours: backtest.config.maxPositionTimeHours,
|
||||||
flipOnlyWhenInProfit: backtest.config.flipOnlyWhenInProfit
|
flipOnlyWhenInProfit: backtest.config.flipOnlyWhenInProfit,
|
||||||
|
flipPosition: backtest.config.flipPosition,
|
||||||
|
name: botName,
|
||||||
|
botTradingBalance: initialTradingBalance,
|
||||||
|
// Use the optimized or original money management from backtest if it's custom
|
||||||
|
moneyManagement: isCustomMoneyManagement ?
|
||||||
|
(backtest.optimizedMoneyManagement || backtest.config.moneyManagement || {
|
||||||
|
name: 'default',
|
||||||
|
leverage: 1,
|
||||||
|
stopLoss: 0.01,
|
||||||
|
takeProfit: 0.02,
|
||||||
|
timeframe: backtest.config.timeframe
|
||||||
|
}) :
|
||||||
|
backtest.config.moneyManagement, // Always provide a valid MoneyManagement object
|
||||||
|
closeEarlyWhenProfitable: backtest.config.closeEarlyWhenProfitable || false
|
||||||
|
};
|
||||||
|
|
||||||
|
const request: StartBotRequest = {
|
||||||
|
config: tradingBotConfig,
|
||||||
|
// Only use the money management name if it's not a custom money management
|
||||||
|
moneyManagementName: isCustomMoneyManagement ? undefined : moneyManagementName
|
||||||
}
|
}
|
||||||
|
|
||||||
await client
|
await client
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ const BacktestModal: React.FC<BacktestModalProps> = ({
|
|||||||
cooldownPeriod: 10, // Default cooldown period of 10 minutes
|
cooldownPeriod: 10, // Default cooldown period of 10 minutes
|
||||||
maxLossStreak: 0, // Default max loss streak of 0 (no limit)
|
maxLossStreak: 0, // Default max loss streak of 0 (no limit)
|
||||||
maxPositionTimeHours: null, // Default to null (disabled)
|
maxPositionTimeHours: null, // Default to null (disabled)
|
||||||
flipOnlyWhenInProfit: true // Default to true
|
flipOnlyWhenInProfit: true, // Default to true
|
||||||
|
balance: 10000, // Default balance
|
||||||
|
closeEarlyWhenProfitable: false // Default to false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const [selectedAccount, setSelectedAccount] = useState<string>('')
|
const [selectedAccount, setSelectedAccount] = useState<string>('')
|
||||||
@@ -123,14 +125,15 @@ const BacktestModal: React.FC<BacktestModalProps> = ({
|
|||||||
flipOnlyWhenInProfit: form.flipOnlyWhenInProfit ?? true,
|
flipOnlyWhenInProfit: form.flipOnlyWhenInProfit ?? true,
|
||||||
flipPosition: form.botType === BotType.FlippingBot, // Set based on bot type
|
flipPosition: form.botType === BotType.FlippingBot, // Set based on bot type
|
||||||
name: `Backtest-${scenarioName}-${ticker}-${new Date().toISOString()}`,
|
name: `Backtest-${scenarioName}-${ticker}-${new Date().toISOString()}`,
|
||||||
botTradingBalance: 0, // Will be set in the request
|
botTradingBalance: form.balance,
|
||||||
moneyManagement: customMoneyManagement || moneyManagements?.find(m => m.name === selectedMoneyManagement) || moneyManagements?.[0] || {
|
moneyManagement: customMoneyManagement || moneyManagements?.find(m => m.name === selectedMoneyManagement) || moneyManagements?.[0] || {
|
||||||
name: 'placeholder',
|
name: 'placeholder',
|
||||||
leverage: 1,
|
leverage: 1,
|
||||||
stopLoss: 0.01,
|
stopLoss: 0.01,
|
||||||
takeProfit: 0.02,
|
takeProfit: 0.02,
|
||||||
timeframe: form.timeframe
|
timeframe: form.timeframe
|
||||||
}
|
},
|
||||||
|
closeEarlyWhenProfitable: form.closeEarlyWhenProfitable ?? false
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create the RunBacktestRequest
|
// Create the RunBacktestRequest
|
||||||
@@ -138,7 +141,7 @@ const BacktestModal: React.FC<BacktestModalProps> = ({
|
|||||||
config: tradingBotConfig,
|
config: tradingBotConfig,
|
||||||
startDate: new Date(form.startDate),
|
startDate: new Date(form.startDate),
|
||||||
endDate: new Date(form.endDate),
|
endDate: new Date(form.endDate),
|
||||||
balance: balance,
|
balance: form.balance,
|
||||||
watchOnly: false,
|
watchOnly: false,
|
||||||
save: form.save || false,
|
save: form.save || false,
|
||||||
moneyManagementName: customMoneyManagement ? undefined : selectedMoneyManagement,
|
moneyManagementName: customMoneyManagement ? undefined : selectedMoneyManagement,
|
||||||
@@ -335,8 +338,8 @@ const BacktestModal: React.FC<BacktestModalProps> = ({
|
|||||||
} else if (selectedMoneyManagement) {
|
} else if (selectedMoneyManagement) {
|
||||||
mm = moneyManagements.find((m) => m.name === selectedMoneyManagement);
|
mm = moneyManagements.find((m) => m.name === selectedMoneyManagement);
|
||||||
}
|
}
|
||||||
// Use actual initial balance and a minimum threshold
|
// Use form balance if available, otherwise fall back to state balance
|
||||||
const initialBalance = balance;
|
const initialBalance = balance; // This state is kept in sync with form
|
||||||
const minBalance = 10; // You can make this configurable if needed
|
const minBalance = 10; // You can make this configurable if needed
|
||||||
if (mm && mm.leverage && mm.stopLoss && initialBalance > minBalance) {
|
if (mm && mm.leverage && mm.stopLoss && initialBalance > minBalance) {
|
||||||
const perLoss = mm.leverage * mm.stopLoss;
|
const perLoss = mm.leverage * mm.stopLoss;
|
||||||
@@ -400,8 +403,11 @@ const BacktestModal: React.FC<BacktestModalProps> = ({
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="input input-bordered w-full"
|
className="input input-bordered w-full"
|
||||||
value={balance}
|
{...register('balance', { valueAsNumber: true })}
|
||||||
onChange={(e) => setBalance(Number(e.target.value))}
|
onChange={(e) => {
|
||||||
|
setValue('balance', Number(e.target.value));
|
||||||
|
setBalance(Number(e.target.value)); // Keep state in sync for UI calculations
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormInput>
|
</FormInput>
|
||||||
|
|
||||||
@@ -443,7 +449,7 @@ const BacktestModal: React.FC<BacktestModalProps> = ({
|
|||||||
</FormInput>
|
</FormInput>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sixth Row: Flip Only When In Profit & Save */}
|
{/* Sixth Row: Flip Only When In Profit & Close Early When Profitable */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormInput label="Flip Only When In Profit" htmlFor="flipOnlyWhenInProfit">
|
<FormInput label="Flip Only When In Profit" htmlFor="flipOnlyWhenInProfit">
|
||||||
<input
|
<input
|
||||||
@@ -456,6 +462,20 @@ const BacktestModal: React.FC<BacktestModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</FormInput>
|
</FormInput>
|
||||||
|
|
||||||
|
<FormInput label="Close Early When Profitable" htmlFor="closeEarlyWhenProfitable">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="toggle toggle-primary"
|
||||||
|
{...register('closeEarlyWhenProfitable')}
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
If enabled, positions will close early when they become profitable
|
||||||
|
</div>
|
||||||
|
</FormInput>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Seventh Row: Save */}
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
<FormInput label="Save" htmlFor="save">
|
<FormInput label="Save" htmlFor="save">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -465,7 +485,7 @@ const BacktestModal: React.FC<BacktestModalProps> = ({
|
|||||||
</FormInput>
|
</FormInput>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Seventh Row: Start Date & End Date */}
|
{/* Eighth Row: Start Date & End Date */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormInput label="Start Date" htmlFor="startDate">
|
<FormInput label="Start Date" htmlFor="startDate">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -226,6 +226,25 @@ const BacktestRowDetails: React.FC<IBacktestRowDetailsProps> = ({
|
|||||||
|
|
||||||
const cooldownRecommendations = getCooldownRecommendations();
|
const cooldownRecommendations = getCooldownRecommendations();
|
||||||
|
|
||||||
|
// Calculate average trades per day
|
||||||
|
const getAverageTradesPerDay = () => {
|
||||||
|
if (positions.length === 0) return "0.00";
|
||||||
|
|
||||||
|
// Get all trade dates and sort them
|
||||||
|
const tradeDates = positions.map(position => new Date(position.open.date)).sort((a, b) => a.getTime() - b.getTime());
|
||||||
|
|
||||||
|
if (tradeDates.length < 2) return positions.length.toString();
|
||||||
|
|
||||||
|
// Calculate the date range in days
|
||||||
|
const firstTradeDate = tradeDates[0];
|
||||||
|
const lastTradeDate = tradeDates[tradeDates.length - 1];
|
||||||
|
const diffInMs = lastTradeDate.getTime() - firstTradeDate.getTime();
|
||||||
|
const diffInDays = Math.max(1, diffInMs / (1000 * 60 * 60 * 24)); // Ensure at least 1 day
|
||||||
|
|
||||||
|
const averageTradesPerDay = positions.length / diffInDays;
|
||||||
|
return averageTradesPerDay.toFixed(2);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-flow-row">
|
<div className="grid grid-flow-row">
|
||||||
@@ -325,6 +344,10 @@ const BacktestRowDetails: React.FC<IBacktestRowDetailsProps> = ({
|
|||||||
title="Median Cooldown"
|
title="Median Cooldown"
|
||||||
content={cooldownRecommendations.median + " candles"}
|
content={cooldownRecommendations.median + " candles"}
|
||||||
></CardText>
|
></CardText>
|
||||||
|
<CardText
|
||||||
|
title="Avg Trades Per Day"
|
||||||
|
content={getAverageTradesPerDay() + " trades/day"}
|
||||||
|
></CardText>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<figure>
|
<figure>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React, {useEffect, useState} from 'react'
|
|||||||
import {useQuery} from '@tanstack/react-query'
|
import {useQuery} from '@tanstack/react-query'
|
||||||
|
|
||||||
import useApiUrlStore from '../../../app/store/apiStore'
|
import useApiUrlStore from '../../../app/store/apiStore'
|
||||||
import type {Backtest, StartBotRequest, Ticker,} from '../../../generated/ManagingApi'
|
import type {Backtest, StartBotRequest, Ticker, TradingBotConfig} from '../../../generated/ManagingApi'
|
||||||
import {BacktestClient, BotClient, MoneyManagementClient} from '../../../generated/ManagingApi'
|
import {BacktestClient, BotClient, MoneyManagementClient} from '../../../generated/ManagingApi'
|
||||||
import type {IBacktestCards} from '../../../global/type'
|
import type {IBacktestCards} from '../../../global/type'
|
||||||
import {CardText, SelectColumnFilter, Table, Toast} from '../../mollecules'
|
import {CardText, SelectColumnFilter, Table, Toast} from '../../mollecules'
|
||||||
@@ -58,19 +58,39 @@ const BacktestTable: React.FC<IBacktestCards> = ({ list, isFetching, setBacktest
|
|||||||
moneyManagementName.toLowerCase() === 'custom' ||
|
moneyManagementName.toLowerCase() === 'custom' ||
|
||||||
moneyManagementName.toLowerCase().includes('custom');
|
moneyManagementName.toLowerCase().includes('custom');
|
||||||
|
|
||||||
const request: StartBotRequest = {
|
// Create TradingBotConfig from the backtest configuration
|
||||||
|
const tradingBotConfig: TradingBotConfig = {
|
||||||
accountName: backtest.config.accountName,
|
accountName: backtest.config.accountName,
|
||||||
botType: backtest.config.botType,
|
ticker: backtest.config.ticker,
|
||||||
isForWatchOnly: isForWatchOnly,
|
scenarioName: backtest.config.scenarioName,
|
||||||
// Only use the money management name if it's not a custom money management
|
|
||||||
moneyManagementName: isCustomMoneyManagement ? '' : moneyManagementName,
|
|
||||||
scenario: backtest.config.scenarioName,
|
|
||||||
ticker: backtest.config.ticker as Ticker,
|
|
||||||
timeframe: backtest.config.timeframe,
|
timeframe: backtest.config.timeframe,
|
||||||
initialTradingBalance: initialTradingBalance,
|
botType: backtest.config.botType,
|
||||||
|
isForWatchingOnly: isForWatchOnly,
|
||||||
|
isForBacktest: false, // This is for running a live bot
|
||||||
cooldownPeriod: backtest.config.cooldownPeriod,
|
cooldownPeriod: backtest.config.cooldownPeriod,
|
||||||
maxLossStreak: backtest.config.maxLossStreak,
|
maxLossStreak: backtest.config.maxLossStreak,
|
||||||
|
maxPositionTimeHours: backtest.config.maxPositionTimeHours,
|
||||||
|
flipOnlyWhenInProfit: backtest.config.flipOnlyWhenInProfit,
|
||||||
|
flipPosition: backtest.config.flipPosition,
|
||||||
name: botName,
|
name: botName,
|
||||||
|
botTradingBalance: initialTradingBalance,
|
||||||
|
// Use the money management from backtest if it's custom, otherwise leave null and use moneyManagementName
|
||||||
|
moneyManagement: isCustomMoneyManagement ?
|
||||||
|
(backtest.config.moneyManagement || {
|
||||||
|
name: 'default',
|
||||||
|
leverage: 1,
|
||||||
|
stopLoss: 0.01,
|
||||||
|
takeProfit: 0.02,
|
||||||
|
timeframe: backtest.config.timeframe
|
||||||
|
}) :
|
||||||
|
backtest.config.moneyManagement, // Always provide a valid MoneyManagement object
|
||||||
|
closeEarlyWhenProfitable: backtest.config.closeEarlyWhenProfitable || false
|
||||||
|
};
|
||||||
|
|
||||||
|
const request: StartBotRequest = {
|
||||||
|
config: tradingBotConfig,
|
||||||
|
// Only use the money management name if it's not a custom money management
|
||||||
|
moneyManagementName: isCustomMoneyManagement ? undefined : moneyManagementName
|
||||||
}
|
}
|
||||||
|
|
||||||
await client
|
await client
|
||||||
|
|||||||
@@ -2699,6 +2699,7 @@ export interface TradingBotConfig {
|
|||||||
flipPosition: boolean;
|
flipPosition: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
maxPositionTimeHours?: number | null;
|
maxPositionTimeHours?: number | null;
|
||||||
|
closeEarlyWhenProfitable?: boolean;
|
||||||
flipOnlyWhenInProfit: boolean;
|
flipOnlyWhenInProfit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3113,20 +3114,8 @@ export interface RunBacktestRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StartBotRequest {
|
export interface StartBotRequest {
|
||||||
botType?: BotType;
|
config?: TradingBotConfig | null;
|
||||||
identifier?: string | null;
|
|
||||||
ticker?: Ticker;
|
|
||||||
scenario?: string | null;
|
|
||||||
timeframe?: Timeframe;
|
|
||||||
accountName?: string | null;
|
|
||||||
moneyManagementName?: string | null;
|
moneyManagementName?: string | null;
|
||||||
isForWatchOnly?: boolean;
|
|
||||||
initialTradingBalance?: number;
|
|
||||||
cooldownPeriod?: number;
|
|
||||||
maxLossStreak?: number;
|
|
||||||
name?: string | null;
|
|
||||||
maxPositionTimeHours?: number | null;
|
|
||||||
flipOnlyWhenInProfit?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TradingBot {
|
export interface TradingBot {
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export type IBacktestsFormInput = {
|
|||||||
maxLossStreak: number
|
maxLossStreak: number
|
||||||
maxPositionTimeHours?: number | null
|
maxPositionTimeHours?: number | null
|
||||||
flipOnlyWhenInProfit?: boolean
|
flipOnlyWhenInProfit?: boolean
|
||||||
|
closeEarlyWhenProfitable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IBacktestCards = {
|
export type IBacktestCards = {
|
||||||
|
|||||||
Reference in New Issue
Block a user