992 lines
38 KiB
C#
992 lines
38 KiB
C#
using Managing.Application.Abstractions;
|
|
using Managing.Application.Abstractions.Services;
|
|
using Managing.Application.Trading;
|
|
using Managing.Application.Trading.Commands;
|
|
using Managing.Common;
|
|
using Managing.Core.FixedSizedQueue;
|
|
using Managing.Domain.Accounts;
|
|
using Managing.Domain.Bots;
|
|
using Managing.Domain.Candles;
|
|
using Managing.Domain.MoneyManagements;
|
|
using Managing.Domain.Scenarios;
|
|
using Managing.Domain.Shared.Helpers;
|
|
using Managing.Domain.Strategies;
|
|
using Managing.Domain.Strategies.Base;
|
|
using Managing.Domain.Trades;
|
|
using Microsoft.Extensions.Logging;
|
|
using Newtonsoft.Json;
|
|
using static Managing.Common.Enums;
|
|
|
|
namespace Managing.Application.Bots;
|
|
|
|
public class TradingBot : Bot, ITradingBot
|
|
{
|
|
public readonly ILogger<TradingBot> Logger;
|
|
public readonly IExchangeService ExchangeService;
|
|
public readonly IMessengerService MessengerService;
|
|
public readonly IAccountService AccountService;
|
|
private readonly ITradingService TradingService;
|
|
private readonly IBotService BotService;
|
|
|
|
public Account Account { get; set; }
|
|
public HashSet<IStrategy> Strategies { get; set; }
|
|
public FixedSizeQueue<Candle> OptimizedCandles { get; set; }
|
|
public HashSet<Candle> Candles { get; set; }
|
|
public HashSet<Signal> Signals { get; set; }
|
|
public List<Position> Positions { get; set; }
|
|
public Ticker Ticker { get; set; }
|
|
public string ScenarioName { get; set; }
|
|
public string AccountName { get; set; }
|
|
public MoneyManagement MoneyManagement { get; set; }
|
|
public Timeframe Timeframe { get; set; }
|
|
public bool IsForBacktest { get; set; }
|
|
public DateTime PreloadSince { get; set; }
|
|
public bool IsForWatchingOnly { get; set; }
|
|
public bool FlipPosition { get; set; }
|
|
public int PreloadedCandlesCount { get; set; }
|
|
public BotType BotType { get; set; }
|
|
public decimal Fee { get; set; }
|
|
public Scenario Scenario { get; set; }
|
|
public Dictionary<DateTime, decimal> WalletBalances { get; set; }
|
|
public Dictionary<StrategyType, StrategiesResultBase> StrategiesValues { get; set; }
|
|
public DateTime StartupTime { get; set; }
|
|
|
|
/// <summary>
|
|
/// The dedicated trading balance for this bot in USD
|
|
/// </summary>
|
|
public decimal BotTradingBalance { get; set; }
|
|
|
|
public TradingBot(
|
|
string accountName,
|
|
MoneyManagement moneyManagement,
|
|
string name,
|
|
Ticker ticker,
|
|
string scenarioName,
|
|
IExchangeService exchangeService,
|
|
ILogger<TradingBot> logger,
|
|
ITradingService tradingService,
|
|
Timeframe timeframe,
|
|
IAccountService accountService,
|
|
IMessengerService messengerService,
|
|
IBotService botService,
|
|
decimal initialTradingBalance,
|
|
bool isForBacktest = false,
|
|
bool isForWatchingOnly = false,
|
|
bool flipPosition = false)
|
|
: base(name)
|
|
{
|
|
ExchangeService = exchangeService;
|
|
AccountService = accountService;
|
|
MessengerService = messengerService;
|
|
TradingService = tradingService;
|
|
BotService = botService;
|
|
|
|
if (initialTradingBalance <= Constants.GMX.Config.MinimumPositionAmount)
|
|
{
|
|
throw new ArgumentException(
|
|
$"Initial trading balance must be greater than {Constants.GMX.Config.MinimumPositionAmount}",
|
|
nameof(initialTradingBalance));
|
|
}
|
|
|
|
IsForWatchingOnly = isForWatchingOnly;
|
|
FlipPosition = flipPosition;
|
|
AccountName = accountName;
|
|
MoneyManagement = moneyManagement;
|
|
Ticker = ticker;
|
|
ScenarioName = scenarioName;
|
|
Timeframe = timeframe;
|
|
IsForBacktest = isForBacktest;
|
|
Logger = logger;
|
|
BotTradingBalance = initialTradingBalance;
|
|
|
|
Strategies = new HashSet<IStrategy>();
|
|
Signals = new HashSet<Signal>();
|
|
OptimizedCandles = new FixedSizeQueue<Candle>(600);
|
|
Candles = new HashSet<Candle>();
|
|
Positions = new List<Position>();
|
|
WalletBalances = new Dictionary<DateTime, decimal>();
|
|
StrategiesValues = new Dictionary<StrategyType, StrategiesResultBase>();
|
|
|
|
if (!isForBacktest)
|
|
{
|
|
Interval = CandleExtensions.GetIntervalFromTimeframe(timeframe);
|
|
PreloadSince = CandleExtensions.GetBotPreloadSinceFromTimeframe(timeframe);
|
|
}
|
|
}
|
|
|
|
public override async void Start()
|
|
{
|
|
base.Start();
|
|
// Load account synchronously
|
|
await LoadAccount();
|
|
|
|
if (!IsForBacktest)
|
|
{
|
|
LoadScenario(ScenarioName);
|
|
await PreloadCandles();
|
|
await CancelAllOrders();
|
|
|
|
try
|
|
{
|
|
// await MessengerService.SendMessage(
|
|
// $"Hey everyone! I'm about to start {Name}. 🚀\n" +
|
|
// $"I'll post an update here each time a signal is triggered by the following strategies: {string.Join(", ", Strategies.Select(s => s.Name))}."
|
|
// );
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, ex.Message);
|
|
}
|
|
|
|
await InitWorker(Run);
|
|
}
|
|
|
|
// Fee = TradingService.GetFee(Account, IsForBacktest);
|
|
}
|
|
|
|
public async Task LoadAccount()
|
|
{
|
|
var account = await AccountService.GetAccount(AccountName, false, false);
|
|
if (account == null)
|
|
{
|
|
Logger.LogWarning($"No account found for this {AccountName}");
|
|
Stop();
|
|
}
|
|
else
|
|
{
|
|
Account = account;
|
|
// Set the User property from the account
|
|
User = account.User;
|
|
}
|
|
}
|
|
|
|
public void LoadScenario(string scenarioName)
|
|
{
|
|
var scenario = TradingService.GetScenarioByName(scenarioName);
|
|
if (scenario == null)
|
|
{
|
|
Logger.LogWarning("No scenario found for this scenario name");
|
|
Stop();
|
|
}
|
|
else
|
|
{
|
|
Scenario = scenario;
|
|
LoadStrategies(ScenarioHelpers.GetStrategiesFromScenario(scenario));
|
|
}
|
|
}
|
|
|
|
public void LoadStrategies(IEnumerable<IStrategy> strategies)
|
|
{
|
|
foreach (var strategy in strategies)
|
|
{
|
|
Strategies.Add(strategy);
|
|
}
|
|
}
|
|
|
|
public async Task Run()
|
|
{
|
|
if (!IsForBacktest)
|
|
{
|
|
// Check broker balance before running
|
|
var balance = await ExchangeService.GetBalance(Account, false);
|
|
if (balance < Constants.GMX.Config.MinimumPositionAmount)
|
|
{
|
|
await LogWarning(
|
|
$"Balance on broker is below {Constants.GMX.Config.MinimumPositionAmount} USD (actual: {balance}). Stopping bot {Identifier} and saving backup.");
|
|
SaveBackup();
|
|
Stop();
|
|
return;
|
|
}
|
|
|
|
Logger.LogInformation($"____________________{Name}____________________");
|
|
Logger.LogInformation(
|
|
$"Time : {DateTime.Now} - Server time {DateTime.Now.ToUniversalTime()} - Last candle : {OptimizedCandles.Last().Date} - Bot : {Name} - Type {BotType} - Ticker : {Ticker}");
|
|
}
|
|
|
|
var previousLastCandle = OptimizedCandles.LastOrDefault();
|
|
|
|
if (!IsForBacktest)
|
|
await UpdateCandles();
|
|
|
|
var currentLastCandle = OptimizedCandles.LastOrDefault();
|
|
|
|
if (currentLastCandle != previousLastCandle || IsForBacktest)
|
|
await UpdateSignals(OptimizedCandles);
|
|
else
|
|
Logger.LogInformation($"No need to update signals for {Ticker}");
|
|
|
|
if (!IsForWatchingOnly)
|
|
await ManagePositions();
|
|
|
|
if (!IsForBacktest)
|
|
{
|
|
SaveBackup();
|
|
UpdateStrategiesValues();
|
|
}
|
|
|
|
await UpdateWalletBalances();
|
|
if (OptimizedCandles.Count % 100 == 0) // Log every 10th execution
|
|
{
|
|
Logger.LogInformation($"Candle date : {OptimizedCandles.Last().Date:u}");
|
|
Logger.LogInformation($"Signals : {Signals.Count}");
|
|
Logger.LogInformation($"ExecutionCount : {ExecutionCount}");
|
|
Logger.LogInformation($"Positions : {Positions.Count}");
|
|
Logger.LogInformation("__________________________________________________");
|
|
}
|
|
}
|
|
|
|
public void UpdateStrategiesValues()
|
|
{
|
|
foreach (var strategy in Strategies)
|
|
{
|
|
StrategiesValues[strategy.Type] = ((Strategy)strategy).GetStrategyValues();
|
|
}
|
|
}
|
|
|
|
private async Task PreloadCandles()
|
|
{
|
|
if (OptimizedCandles.Any())
|
|
return;
|
|
|
|
var candles = await ExchangeService.GetCandlesInflux(Account.Exchange, Ticker, PreloadSince, Timeframe);
|
|
|
|
foreach (var candle in candles.Where(c => c.Date < DateTime.Now.ToUniversalTime()))
|
|
{
|
|
if (!OptimizedCandles.Any(c => c.Date == candle.Date))
|
|
{
|
|
OptimizedCandles.Enqueue(candle);
|
|
Candles.Add(candle);
|
|
await UpdateSignals(OptimizedCandles);
|
|
}
|
|
}
|
|
|
|
PreloadedCandlesCount = OptimizedCandles.Count();
|
|
}
|
|
|
|
private async Task UpdateSignals(FixedSizeQueue<Candle> candles)
|
|
{
|
|
var signal = TradingBox.GetSignal(candles.ToHashSet(), Strategies, Signals, Scenario.LoopbackPeriod);
|
|
if (signal == null) return;
|
|
|
|
signal.User = Account.User;
|
|
await AddSignal(signal);
|
|
}
|
|
|
|
|
|
private async Task AddSignal(Signal signal)
|
|
{
|
|
// if (!IsForBacktest)
|
|
// TradingService.InsertSignal(signal);
|
|
|
|
if (IsForWatchingOnly || (ExecutionCount < 1 && !IsForBacktest))
|
|
signal.Status = SignalStatus.Expired;
|
|
|
|
Signals.Add(signal);
|
|
|
|
var signalText = $"{ScenarioName} trigger a signal. Signal told you " +
|
|
$"to {signal.Direction} {Ticker} on {Timeframe}. The confidence in this signal is {signal.Confidence}. Identifier : {signal.Identifier}";
|
|
|
|
Logger.LogInformation(signalText);
|
|
|
|
if (IsForWatchingOnly && !IsForBacktest && ExecutionCount > 0)
|
|
{
|
|
await MessengerService.SendSignal(signalText, Account.Exchange, Ticker, signal.Direction, Timeframe);
|
|
}
|
|
}
|
|
|
|
protected async Task UpdateCandles()
|
|
{
|
|
if (OptimizedCandles.Count == 0 || ExecutionCount == 0)
|
|
return;
|
|
|
|
var lastCandle = OptimizedCandles.Last();
|
|
var newCandle = await ExchangeService.GetCandlesInflux(Account.Exchange, Ticker, lastCandle.Date, Timeframe);
|
|
|
|
foreach (var candle in newCandle.Where(c => c.Date < DateTime.Now.ToUniversalTime()))
|
|
{
|
|
OptimizedCandles.Enqueue(candle);
|
|
Candles.Add(candle);
|
|
}
|
|
}
|
|
|
|
private async Task ManagePositions()
|
|
{
|
|
// Update position
|
|
foreach (var signal in Signals.Where(s => s.Status == SignalStatus.PositionOpen))
|
|
{
|
|
var positionForSignal = Positions.FirstOrDefault(p => p.SignalIdentifier == signal.Identifier);
|
|
if (positionForSignal == null)
|
|
{
|
|
await LogInformation($"Cannot find position for signal {signal.Identifier}");
|
|
}
|
|
else
|
|
{
|
|
await UpdatePosition(signal, positionForSignal);
|
|
}
|
|
}
|
|
|
|
// Open position for signal waiting for a position open
|
|
foreach (var signal in Signals.Where(s => s.Status == SignalStatus.WaitingForPosition))
|
|
{
|
|
Task.Run(() => OpenPosition(signal)).GetAwaiter().GetResult();
|
|
}
|
|
}
|
|
|
|
private async Task UpdateWalletBalances()
|
|
{
|
|
var lastCandle = OptimizedCandles.LastOrDefault();
|
|
if (lastCandle == null) return;
|
|
|
|
var date = lastCandle.Date;
|
|
|
|
if (WalletBalances.Count == 0)
|
|
{
|
|
// WalletBalances[date] = await ExchangeService.GetBalance(Account, IsForBacktest);
|
|
WalletBalances[date] = BotTradingBalance;
|
|
return;
|
|
}
|
|
|
|
if (!WalletBalances.ContainsKey(date))
|
|
{
|
|
var previousBalance = WalletBalances.First().Value;
|
|
WalletBalances[date] = previousBalance + GetProfitAndLoss();
|
|
}
|
|
}
|
|
|
|
|
|
private async Task UpdatePosition(Signal signal, Position positionForSignal)
|
|
{
|
|
try
|
|
{
|
|
Logger.LogInformation($"Updating position {positionForSignal.SignalIdentifier}");
|
|
|
|
var position = IsForBacktest
|
|
? positionForSignal
|
|
: TradingService.GetPositionByIdentifier(positionForSignal.Identifier);
|
|
|
|
var positionsExchange = IsForBacktest
|
|
? new List<Position> { position }
|
|
: await TradingService.GetBrokerPositions(Account);
|
|
|
|
if (!IsForBacktest)
|
|
{
|
|
var brokerPosition = positionsExchange.FirstOrDefault(p => p.Ticker == Ticker);
|
|
if (brokerPosition != null)
|
|
{
|
|
UpdatePositionPnl(positionForSignal.Identifier, brokerPosition.ProfitAndLoss.Realized);
|
|
|
|
if (position.Status.Equals(PositionStatus.New))
|
|
{
|
|
await SetPositionStatus(position.SignalIdentifier, PositionStatus.Filled);
|
|
}
|
|
|
|
position = brokerPosition;
|
|
}
|
|
else
|
|
{
|
|
// No position, position close on the broker
|
|
if (!position.Status.Equals(PositionStatus.New))
|
|
{
|
|
// Setup the previous status of the position
|
|
position.Status = PositionStatus.Filled;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (position.Status == PositionStatus.New)
|
|
{
|
|
var orders = await ExchangeService.GetOpenOrders(Account, Ticker);
|
|
if (orders.Any())
|
|
{
|
|
await LogInformation(
|
|
$"Cannot update Position. Position is still waiting for opening. There is {orders.Count()} open orders.");
|
|
}
|
|
else
|
|
{
|
|
await LogWarning(
|
|
$"Cannot update Position. No position on exchange and no orders. Position {signal.Identifier} might be closed already.");
|
|
await HandleClosedPosition(positionForSignal);
|
|
}
|
|
}
|
|
else if (position.Status == (PositionStatus.Finished | PositionStatus.Flipped))
|
|
{
|
|
await HandleClosedPosition(positionForSignal);
|
|
}
|
|
else if (position.Status == (PositionStatus.Filled | PositionStatus.PartiallyFilled))
|
|
{
|
|
// For backtesting or force close if not executed on exchange :
|
|
// check if position is still open
|
|
// Check status, if still open update the status of the position
|
|
|
|
// Position might be partially filled, meaning that TPSL havent been sended yet
|
|
// But the position might already been closed by the exchange so we have to check should be closed
|
|
var lastCandle = IsForBacktest
|
|
? OptimizedCandles.Last()
|
|
: ExchangeService.GetCandle(Account, Ticker, DateTime.UtcNow);
|
|
|
|
if (positionForSignal.OriginDirection == TradeDirection.Long)
|
|
{
|
|
if (positionForSignal.StopLoss.Price >= lastCandle.Low)
|
|
{
|
|
await LogInformation(
|
|
$"Closing position - SL {positionForSignal.StopLoss.Price} >= Price {lastCandle.Low}");
|
|
await CloseTrade(signal, positionForSignal, positionForSignal.StopLoss,
|
|
positionForSignal.StopLoss.Price, true);
|
|
positionForSignal.StopLoss.SetStatus(TradeStatus.Filled);
|
|
}
|
|
else if (positionForSignal.TakeProfit1.Price <= lastCandle.High
|
|
&& positionForSignal.TakeProfit1.Status != TradeStatus.Filled)
|
|
{
|
|
await LogInformation(
|
|
$"Closing position - TP1 {positionForSignal.TakeProfit1.Price} <= Price {lastCandle.High}");
|
|
await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit1,
|
|
positionForSignal.TakeProfit1.Price, positionForSignal.TakeProfit2 == null);
|
|
positionForSignal.TakeProfit1.SetStatus(TradeStatus.Filled);
|
|
}
|
|
else if (positionForSignal.TakeProfit2?.Price <= lastCandle.High)
|
|
{
|
|
await LogInformation(
|
|
$"Closing position - TP2 {positionForSignal.TakeProfit2.Price} <= Price {lastCandle.High}");
|
|
await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit2,
|
|
positionForSignal.TakeProfit2.Price, true);
|
|
positionForSignal.TakeProfit2.SetStatus(TradeStatus.Filled);
|
|
}
|
|
else
|
|
{
|
|
Logger.LogInformation(
|
|
$"Position {signal.Identifier} don't need to be update. Position still opened");
|
|
}
|
|
}
|
|
|
|
if (positionForSignal.OriginDirection == TradeDirection.Short)
|
|
{
|
|
if (positionForSignal.StopLoss.Price <= lastCandle.High)
|
|
{
|
|
await LogInformation(
|
|
$"Closing position - SL {positionForSignal.StopLoss.Price} <= Price {lastCandle.High}");
|
|
await CloseTrade(signal, positionForSignal, positionForSignal.StopLoss,
|
|
positionForSignal.StopLoss.Price, true);
|
|
positionForSignal.StopLoss.SetStatus(TradeStatus.Filled);
|
|
}
|
|
else if (positionForSignal.TakeProfit1.Price >= lastCandle.Low
|
|
&& positionForSignal.TakeProfit1.Status != TradeStatus.Filled)
|
|
{
|
|
await LogInformation(
|
|
$"Closing position - TP1 {positionForSignal.TakeProfit1.Price} >= Price {lastCandle.Low}");
|
|
await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit1,
|
|
positionForSignal.TakeProfit1.Price, positionForSignal.TakeProfit2 == null);
|
|
positionForSignal.TakeProfit1.SetStatus(TradeStatus.Filled);
|
|
}
|
|
else if (positionForSignal.TakeProfit2?.Price >= lastCandle.Low)
|
|
{
|
|
await LogInformation(
|
|
$"Closing position - TP2 {positionForSignal.TakeProfit2.Price} >= Price {lastCandle.Low}");
|
|
await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit2,
|
|
positionForSignal.TakeProfit2.Price, true);
|
|
positionForSignal.TakeProfit2.SetStatus(TradeStatus.Filled);
|
|
}
|
|
else
|
|
{
|
|
Logger.LogInformation(
|
|
$"Position {signal.Identifier} don't need to be update. Position still opened");
|
|
}
|
|
}
|
|
}
|
|
else if (position.Status == (PositionStatus.Rejected | PositionStatus.Canceled))
|
|
{
|
|
await LogWarning($"Open position trade is rejected for signal {signal.Identifier}");
|
|
// if position is not open
|
|
// Re-open the trade for the signal only if signal still up
|
|
if (signal.Status == SignalStatus.PositionOpen)
|
|
{
|
|
Logger.LogInformation($"Try to re-open position");
|
|
await OpenPosition(signal);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await LogWarning($"Cannot update position {positionForSignal.Identifier}: {ex.Message}");
|
|
SentrySdk.CaptureException(ex);
|
|
return;
|
|
}
|
|
}
|
|
|
|
|
|
private async Task OpenPosition(Signal signal)
|
|
{
|
|
// Check if a position is already open
|
|
Logger.LogInformation($"Opening position for {signal.Identifier}");
|
|
|
|
var openedPosition = Positions.FirstOrDefault(p => p.Status == PositionStatus.Filled
|
|
&& p.SignalIdentifier != signal.Identifier);
|
|
|
|
var lastPrice = IsForBacktest
|
|
? OptimizedCandles.Last().Close
|
|
: ExchangeService.GetPrice(Account, Ticker, DateTime.UtcNow);
|
|
|
|
// If position open
|
|
if (openedPosition != null)
|
|
{
|
|
var previousSignal = Signals.First(s => s.Identifier == openedPosition.SignalIdentifier);
|
|
|
|
// Check if signal is the opposite side => flip the position
|
|
if (openedPosition.OriginDirection == signal.Direction)
|
|
{
|
|
// An operation is already open for the same direction
|
|
await LogInformation(
|
|
$"Signal {signal.Identifier} try to open a position but {previousSignal.Identifier} is already open for the same direction");
|
|
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
|
|
}
|
|
else
|
|
{
|
|
// An operation is already open for the opposite direction
|
|
// ==> Flip the position
|
|
if (FlipPosition)
|
|
{
|
|
await LogInformation("Try to flip the position because of an opposite direction signal");
|
|
await CloseTrade(previousSignal, openedPosition, openedPosition.Open, lastPrice, true);
|
|
await SetPositionStatus(previousSignal.Identifier, PositionStatus.Flipped);
|
|
await OpenPosition(signal);
|
|
await LogInformation(
|
|
$"Position {previousSignal.Identifier} flipped by {signal.Identifier} at {lastPrice}$");
|
|
}
|
|
else
|
|
{
|
|
await LogInformation(
|
|
$"A position is already open for signal {previousSignal.Identifier}. Position flipping is currently not enable, the position will not be flipped.");
|
|
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (!CanOpenPosition(signal))
|
|
{
|
|
await LogInformation(
|
|
"Tried to open position but last position was a loss. Wait for an opposition direction side or wait x candles to open a new position");
|
|
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
|
|
return;
|
|
}
|
|
|
|
await LogInformation(
|
|
$"Open position - Date: {signal.Date:T} - SignalIdentifier : {signal.Identifier}");
|
|
|
|
try
|
|
{
|
|
var command = new OpenPositionRequest(
|
|
AccountName,
|
|
MoneyManagement,
|
|
signal.Direction,
|
|
Ticker,
|
|
PositionInitiator.Bot,
|
|
signal.Date,
|
|
User,
|
|
BotTradingBalance,
|
|
IsForBacktest,
|
|
lastPrice,
|
|
signalIdentifier: signal.Identifier);
|
|
|
|
var position = (new OpenPositionCommandHandler(ExchangeService, AccountService, TradingService)
|
|
.Handle(command)).Result;
|
|
|
|
if (position != null)
|
|
{
|
|
Positions.Add(position);
|
|
|
|
if (position.Open.Status != TradeStatus.Cancelled)
|
|
{
|
|
SetSignalStatus(signal.Identifier, SignalStatus.PositionOpen);
|
|
|
|
if (!IsForBacktest)
|
|
{
|
|
await MessengerService.SendPosition(position);
|
|
}
|
|
|
|
Logger.LogInformation($"Position requested");
|
|
}
|
|
else
|
|
{
|
|
await SetPositionStatus(signal.Identifier, PositionStatus.Rejected);
|
|
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Keep signal open for debug purpose
|
|
//SetSignalStatus(signal.Identifier, SignalStatus.Expired);
|
|
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
|
|
await LogWarning($"Cannot open trade : {ex.Message}, stackTrace : {ex.StackTrace}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private bool CanOpenPosition(Signal signal)
|
|
{
|
|
if (!IsForBacktest && ExecutionCount < 1)
|
|
return false;
|
|
|
|
if (Positions.Count == 0)
|
|
return true;
|
|
|
|
var lastPosition = Positions.LastOrDefault(p => p.IsFinished()
|
|
&& p.SignalIdentifier != signal.Identifier
|
|
&& p.OriginDirection == signal.Direction);
|
|
|
|
if (lastPosition == null)
|
|
return true;
|
|
|
|
var tenCandleAgo = OptimizedCandles.TakeLast(10).First();
|
|
var positionSignal = Signals.FirstOrDefault(s => s.Identifier == lastPosition.SignalIdentifier);
|
|
|
|
return positionSignal.Date < tenCandleAgo.Date;
|
|
}
|
|
|
|
public async Task CloseTrade(Signal signal, Position position, Trade tradeToClose, decimal lastPrice,
|
|
bool tradeClosingPosition = false)
|
|
{
|
|
if (position.TakeProfit2 != null && position.TakeProfit1.Status == TradeStatus.Filled &&
|
|
tradeToClose.TradeType == TradeType.StopMarket)
|
|
{
|
|
// If trade is the 2nd Take profit
|
|
tradeToClose.Quantity = position.TakeProfit2.Quantity;
|
|
}
|
|
|
|
await LogInformation(
|
|
$"Trying to close trade {Ticker} at {lastPrice} - Type : {tradeToClose.TradeType} - Quantity : {tradeToClose.Quantity} " +
|
|
$"- Closing Position : {tradeClosingPosition}");
|
|
|
|
// Get status of position before closing it. The position might be already close by the exchange
|
|
if (!IsForBacktest && await ExchangeService.GetQuantityInPosition(Account, Ticker) == 0)
|
|
{
|
|
Logger.LogInformation($"Trade already close on exchange");
|
|
await HandleClosedPosition(position);
|
|
}
|
|
else
|
|
{
|
|
var command = new ClosePositionCommand(position, lastPrice, isForBacktest: IsForBacktest);
|
|
try
|
|
{
|
|
var closedPosition = (new ClosePositionCommandHandler(ExchangeService, AccountService, TradingService)
|
|
.Handle(command)).Result;
|
|
|
|
if (closedPosition.Status == (PositionStatus.Finished | PositionStatus.Flipped))
|
|
{
|
|
if (tradeClosingPosition)
|
|
{
|
|
await SetPositionStatus(signal.Identifier, PositionStatus.Finished);
|
|
}
|
|
|
|
await HandleClosedPosition(closedPosition);
|
|
}
|
|
else
|
|
{
|
|
throw new Exception($"Wrong position status : {closedPosition.Status}");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await LogWarning($"Position {signal.Identifier} not closed : {ex.Message}");
|
|
|
|
if (position.Status == (PositionStatus.Canceled | PositionStatus.Rejected))
|
|
{
|
|
// Trade close on exchange => Should close trade manually
|
|
await SetPositionStatus(signal.Identifier, PositionStatus.Finished);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task HandleClosedPosition(Position position)
|
|
{
|
|
if (Positions.Any(p => p.Identifier == position.Identifier))
|
|
{
|
|
await SetPositionStatus(position.SignalIdentifier, PositionStatus.Finished);
|
|
Logger.LogInformation(
|
|
$"Position {position.SignalIdentifier} type correctly close. Pnl on position : {position.ProfitAndLoss?.Realized}");
|
|
|
|
// Update the bot's trading balance after position is closed
|
|
if (position.ProfitAndLoss != null)
|
|
{
|
|
// Add PnL (could be positive or negative)
|
|
BotTradingBalance += position.ProfitAndLoss.Realized;
|
|
|
|
// Subtract fees
|
|
BotTradingBalance -= GetPositionFees(position);
|
|
|
|
Logger.LogInformation($"Updated bot trading balance to: {BotTradingBalance}");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
await LogWarning("Weird things happen - Trying to update position status, but no position found");
|
|
}
|
|
|
|
if (!IsForBacktest)
|
|
{
|
|
await MessengerService.SendClosingPosition(position);
|
|
}
|
|
|
|
await CancelAllOrders();
|
|
}
|
|
|
|
private async Task CancelAllOrders()
|
|
{
|
|
if (!IsForBacktest && !IsForWatchingOnly)
|
|
{
|
|
try
|
|
{
|
|
var openOrders = await ExchangeService.GetOpenOrders(Account, Ticker);
|
|
if (openOrders.Any())
|
|
{
|
|
var openPositions = (await ExchangeService.GetBrokerPositions(Account))
|
|
.Where(p => p.Ticker == Ticker);
|
|
var cancelClose = openPositions.Any();
|
|
|
|
if (cancelClose)
|
|
{
|
|
Logger.LogInformation($"Position still open, cancel close orders&");
|
|
}
|
|
else
|
|
{
|
|
Logger.LogInformation($"Canceling all orders for {Ticker}");
|
|
await ExchangeService.CancelOrder(Account, Ticker);
|
|
var closePendingOrderStatus = await ExchangeService.CancelOrder(Account, Ticker);
|
|
Logger.LogInformation($"Closing all {Ticker} orders status : {closePendingOrderStatus}");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Logger.LogInformation($"No need to cancel orders for {Ticker}");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Error during cancelOrders");
|
|
SentrySdk.CaptureException(ex);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task SetPositionStatus(string signalIdentifier, PositionStatus positionStatus)
|
|
{
|
|
if (!Positions.First(p => p.SignalIdentifier == signalIdentifier).Status.Equals(positionStatus))
|
|
{
|
|
await LogInformation($"Position {signalIdentifier} is now {positionStatus}");
|
|
Positions.First(p => p.SignalIdentifier == signalIdentifier).Status = positionStatus;
|
|
}
|
|
|
|
SetSignalStatus(signalIdentifier,
|
|
positionStatus == PositionStatus.Filled ? SignalStatus.PositionOpen : SignalStatus.Expired);
|
|
}
|
|
|
|
private void UpdatePositionPnl(string identifier, decimal realized)
|
|
{
|
|
Positions.First(p => p.Identifier == identifier).ProfitAndLoss = new ProfitAndLoss()
|
|
{
|
|
Realized = realized
|
|
};
|
|
}
|
|
|
|
private void SetSignalStatus(string signalIdentifier, SignalStatus signalStatus)
|
|
{
|
|
if (Signals.Any(s => s.Identifier == signalIdentifier))
|
|
{
|
|
Signals.First(s => s.Identifier == signalIdentifier).Status = signalStatus;
|
|
Logger.LogInformation($"Signal {signalIdentifier} is now {signalStatus}");
|
|
}
|
|
}
|
|
|
|
public int GetWinRate()
|
|
{
|
|
var succeededPositions = Positions.Where(p => p.IsFinished()).Count(p => p.ProfitAndLoss?.Realized > 0);
|
|
var total = Positions.Where(p => p.IsFinished()).Count();
|
|
|
|
if (total == 0)
|
|
return 0;
|
|
|
|
return (succeededPositions * 100) / total;
|
|
}
|
|
|
|
public decimal GetProfitAndLoss()
|
|
{
|
|
var pnl = Positions.Where(p => p.ProfitAndLoss != null).Sum(p => p.ProfitAndLoss.Realized);
|
|
return pnl - GetTotalFees();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates the total fees paid by the trading bot for each position.
|
|
/// </summary>
|
|
/// <returns>Returns the total fees paid as a decimal value.</returns>
|
|
public decimal GetTotalFees()
|
|
{
|
|
decimal fees = 0;
|
|
foreach (var position in Positions.Where(p => p.Open.Fee > 0))
|
|
{
|
|
fees += position.Open.Fee;
|
|
fees += position.StopLoss.Status == TradeStatus.Filled ? position.StopLoss.Fee : 0;
|
|
fees += position.TakeProfit1.Status == TradeStatus.Filled ? position.TakeProfit1.Fee : 0;
|
|
|
|
if (position.IsFinished() &&
|
|
position.StopLoss.Status != TradeStatus.Filled && position.TakeProfit1.Status != TradeStatus.Filled)
|
|
fees += position.Open.Fee;
|
|
|
|
if (position.TakeProfit2 != null)
|
|
fees += position.TakeProfit2.Fee;
|
|
}
|
|
|
|
return fees;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates the total fees for a specific position
|
|
/// </summary>
|
|
/// <param name="position">The position to calculate fees for</param>
|
|
/// <returns>The total fees for the position</returns>
|
|
private decimal GetPositionFees(Position position)
|
|
{
|
|
decimal fees = 0;
|
|
|
|
fees += position.Open.Fee;
|
|
fees += position.StopLoss.Status == TradeStatus.Filled ? position.StopLoss.Fee : 0;
|
|
fees += position.TakeProfit1.Status == TradeStatus.Filled ? position.TakeProfit1.Fee : 0;
|
|
|
|
if (position.IsFinished() &&
|
|
position.StopLoss.Status != TradeStatus.Filled && position.TakeProfit1.Status != TradeStatus.Filled)
|
|
fees += position.Open.Fee;
|
|
|
|
if (position.TakeProfit2 != null)
|
|
fees += position.TakeProfit2.Status == TradeStatus.Filled ? position.TakeProfit2.Fee : 0;
|
|
|
|
return fees;
|
|
}
|
|
|
|
public async Task ToggleIsForWatchOnly()
|
|
{
|
|
IsForWatchingOnly = (!IsForWatchingOnly);
|
|
await LogInformation($"Watch only toggle for bot : {Name} - Watch only : {IsForWatchingOnly}");
|
|
}
|
|
|
|
private async Task LogInformation(string message)
|
|
{
|
|
Logger.LogInformation(message);
|
|
await SendTradeMessage(message);
|
|
}
|
|
|
|
private async Task LogWarning(string message)
|
|
{
|
|
message = $"[{Identifier}] {message}";
|
|
SentrySdk.CaptureException(new Exception(message));
|
|
await SendTradeMessage(message, true);
|
|
}
|
|
|
|
private async Task SendTradeMessage(string message, bool isBadBehavior = false)
|
|
{
|
|
if (!IsForBacktest)
|
|
{
|
|
await MessengerService.SendTradeMessage(message, isBadBehavior);
|
|
}
|
|
}
|
|
|
|
public override void SaveBackup()
|
|
{
|
|
var data = new TradingBotBackup
|
|
{
|
|
Name = Name,
|
|
BotType = BotType,
|
|
Signals = Signals,
|
|
Positions = Positions,
|
|
Timeframe = Timeframe,
|
|
Ticker = Ticker,
|
|
ScenarioName = ScenarioName,
|
|
AccountName = AccountName,
|
|
IsForWatchingOnly = IsForWatchingOnly,
|
|
WalletBalances = WalletBalances,
|
|
MoneyManagement = MoneyManagement,
|
|
BotTradingBalance = BotTradingBalance,
|
|
StartupTime = StartupTime,
|
|
};
|
|
BotService.SaveOrUpdateBotBackup(User, Identifier, BotType, JsonConvert.SerializeObject(data));
|
|
}
|
|
|
|
public override void LoadBackup(BotBackup backup)
|
|
{
|
|
var data = JsonConvert.DeserializeObject<TradingBotBackup>(backup.Data);
|
|
Signals = data.Signals;
|
|
Positions = data.Positions;
|
|
WalletBalances = data.WalletBalances;
|
|
// MoneyManagement = data.MoneyManagement; => loaded from database
|
|
Timeframe = data.Timeframe;
|
|
Ticker = data.Ticker;
|
|
ScenarioName = data.ScenarioName;
|
|
AccountName = data.AccountName;
|
|
IsForWatchingOnly = data.IsForWatchingOnly;
|
|
BotTradingBalance = data.BotTradingBalance;
|
|
Identifier = backup.Identifier;
|
|
User = backup.User;
|
|
|
|
// Restore the startup time if it was previously saved
|
|
if (data.StartupTime != DateTime.MinValue)
|
|
{
|
|
StartupTime = data.StartupTime;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Manually opens a position using the bot's settings and a generated signal.
|
|
/// Relies on the bot's MoneyManagement for Stop Loss and Take Profit placement.
|
|
/// </summary>
|
|
/// <param name="direction">The direction of the trade (Long/Short).</param>
|
|
/// <returns>The created Position object.</returns>
|
|
/// <exception cref="Exception">Throws if no candles are available or position opening fails.</exception>
|
|
public async Task<Position> OpenPositionManually(TradeDirection direction)
|
|
{
|
|
var lastCandle = OptimizedCandles.LastOrDefault();
|
|
if (lastCandle == null)
|
|
{
|
|
throw new Exception("No candles available to open position");
|
|
}
|
|
|
|
// Create a fake signal for manual position opening
|
|
var signal = new Signal(Ticker, direction, Confidence.Low, lastCandle, lastCandle.Date, TradingExchanges.GmxV2,
|
|
StrategyType.Stc, SignalType.Signal);
|
|
signal.Status = SignalStatus.WaitingForPosition; // Ensure status is correct
|
|
signal.User = Account.User; // Assign user
|
|
|
|
// Add the signal to our collection
|
|
await AddSignal(signal);
|
|
|
|
// Open the position using the generated signal (SL/TP handled by MoneyManagement)
|
|
await OpenPosition(signal);
|
|
|
|
// Get the opened position
|
|
var position = Positions.FirstOrDefault(p => p.SignalIdentifier == signal.Identifier);
|
|
if (position == null)
|
|
{
|
|
// Clean up the signal if position creation failed
|
|
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
|
|
throw new Exception("Failed to open position");
|
|
}
|
|
|
|
Logger.LogInformation($"Manually opened position {position.Identifier} for signal {signal.Identifier}");
|
|
return position;
|
|
}
|
|
}
|
|
|
|
public class TradingBotBackup
|
|
{
|
|
public string Name { get; set; }
|
|
public BotType BotType { get; set; }
|
|
public HashSet<Signal> Signals { get; set; }
|
|
public List<Position> Positions { get; set; }
|
|
public Timeframe Timeframe { get; set; }
|
|
public Ticker Ticker { get; set; }
|
|
public string ScenarioName { get; set; }
|
|
public string AccountName { get; set; }
|
|
public bool IsForWatchingOnly { get; set; }
|
|
public Dictionary<DateTime, decimal> WalletBalances { get; set; }
|
|
public MoneyManagement MoneyManagement { get; set; }
|
|
public DateTime StartupTime { get; set; }
|
|
public decimal BotTradingBalance { get; set; }
|
|
} |