diff --git a/src/Managing.Api/Controllers/BotController.cs b/src/Managing.Api/Controllers/BotController.cs index 3270cc7..98565ec 100644 --- a/src/Managing.Api/Controllers/BotController.cs +++ b/src/Managing.Api/Controllers/BotController.cs @@ -1,23 +1,13 @@ using Managing.Api.Models.Requests; -using Managing.Api.Models.Responses; using Managing.Application.Abstractions; using Managing.Application.Abstractions.Services; using Managing.Application.Hubs; using Managing.Application.ManageBot.Commands; -using Managing.Common; -using Managing.Domain.Bots; -using Managing.Domain.Users; +using Managing.Domain.Trades; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Managing.Domain.Trades; -using Microsoft.Extensions.DependencyInjection; using static Managing.Common.Enums; using ApplicationTradingBot = Managing.Application.Bots.TradingBot; using ApiTradingBot = Managing.Api.Models.Responses.TradingBot; @@ -464,4 +454,84 @@ public class BotController : BaseController return StatusCode(500, $"Error opening position: {ex.Message}"); } } + + /// + /// Closes a specific position for a trading bot + /// + /// The request containing the position close parameters + /// The closed position or an error + [HttpPost] + [Route("ClosePosition")] + public async Task> ClosePosition([FromBody] ClosePositionRequest request) + { + try + { + // Check if user owns the account + if (!await UserOwnsBotAccount(request.BotName)) + { + return Forbid("You don't have permission to close positions for this bot"); + } + + var activeBots = _botService.GetActiveBots(); + var bot = activeBots.FirstOrDefault(b => b.Name == request.BotName) as ApplicationTradingBot; + + if (bot == null) + { + return NotFound($"Bot {request.BotName} not found or is not a trading bot"); + } + + if (bot.GetStatus() != BotStatus.Up.ToString()) + { + return BadRequest($"Bot {request.BotName} is not running"); + } + + // Find the position to close + var position = bot.Positions.FirstOrDefault(p => p.Identifier == request.PositionId); + if (position == null) + { + return NotFound($"Position with ID {request.PositionId} not found for bot {request.BotName}"); + } + + // Find the signal associated with this position + var signal = bot.Signals.FirstOrDefault(s => s.Identifier == position.SignalIdentifier); + if (signal == null) + { + return NotFound($"Signal not found for position {request.PositionId}"); + } + + // Get current price + var lastCandle = bot.OptimizedCandles.LastOrDefault(); + if (lastCandle == null) + { + return BadRequest("Cannot get current price to close position"); + } + + // Close the position at market price + await bot.CloseTrade(signal, position, position.Open, lastCandle.Close, true); + + await NotifyBotSubscriberAsync(); + return Ok(position); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error closing position"); + return StatusCode(500, $"Error closing position: {ex.Message}"); + } + } +} + +/// +/// Request model for closing a position +/// +public class ClosePositionRequest +{ + /// + /// The name of the bot + /// + public string BotName { get; set; } + + /// + /// The ID of the position to close + /// + public string PositionId { get; set; } } \ No newline at end of file diff --git a/src/Managing.Application.Abstractions/Repositories/IEvmManager.cs b/src/Managing.Application.Abstractions/Repositories/IEvmManager.cs index bdcbf26..4d726b2 100644 --- a/src/Managing.Application.Abstractions/Repositories/IEvmManager.cs +++ b/src/Managing.Application.Abstractions/Repositories/IEvmManager.cs @@ -43,7 +43,7 @@ public interface IEvmManager Task DecreasePosition(Account account, Ticker ticker, TradeDirection direction, decimal price, decimal quantity, decimal? leverage); - Task QuantityInPosition(string chainName, string publicAddress, Ticker ticker); + Task QuantityInPosition(string chainName, Account account, Ticker ticker); Task DecreaseOrder(Account account, TradeType tradeType, Ticker ticker, TradeDirection direction, decimal price, decimal quantity, decimal? leverage, diff --git a/src/Managing.Application/Bots/TradingBot.cs b/src/Managing.Application/Bots/TradingBot.cs index 0373da8..32ba51b 100644 --- a/src/Managing.Application/Bots/TradingBot.cs +++ b/src/Managing.Application/Bots/TradingBot.cs @@ -605,7 +605,7 @@ public class TradingBot : Bot, ITradingBot return positionSignal.Date < tenCandleAgo.Date; } - private async Task CloseTrade(Signal signal, Position position, Trade tradeToClose, decimal lastPrice, + public async Task CloseTrade(Signal signal, Position position, Trade tradeToClose, decimal lastPrice, bool tradeClosingPosition = false) { if (position.TakeProfit2 != null && position.TakeProfit1.Status == TradeStatus.Filled && diff --git a/src/Managing.Infrastructure.Exchanges/Exchanges/EvmProcessor.cs b/src/Managing.Infrastructure.Exchanges/Exchanges/EvmProcessor.cs index a19f832..972b163 100644 --- a/src/Managing.Infrastructure.Exchanges/Exchanges/EvmProcessor.cs +++ b/src/Managing.Infrastructure.Exchanges/Exchanges/EvmProcessor.cs @@ -12,7 +12,7 @@ namespace Managing.Infrastructure.Exchanges.Exchanges; public class EvmProcessor : BaseProcessor { - public override Enums.TradingExchanges Exchange() => Enums.TradingExchanges.Evm; + public override TradingExchanges Exchange() => TradingExchanges.Evm; private ILogger _logger; private IEvmManager _evmManager; @@ -85,7 +85,7 @@ public class EvmProcessor : BaseProcessor public override async Task GetQuantityInPosition(Account account, Ticker ticker) { - return await _evmManager.QuantityInPosition(Constants.Chains.Arbitrum, account.Key, ticker); + return await _evmManager.QuantityInPosition(Constants.Chains.Arbitrum, account, ticker); } public override async Task GetTrade(Account account, string order, Ticker ticker) diff --git a/src/Managing.Infrastructure.Web3/EvmManager.cs b/src/Managing.Infrastructure.Web3/EvmManager.cs index 8ac4466..b2ef1ba 100644 --- a/src/Managing.Infrastructure.Web3/EvmManager.cs +++ b/src/Managing.Infrastructure.Web3/EvmManager.cs @@ -29,8 +29,6 @@ using BalanceOfFunction = Nethereum.Contracts.Standards.ERC20.ContractDefinition using BalanceOfOutputDTO = Nethereum.Contracts.Standards.ERC20.ContractDefinition.BalanceOfOutputDTO; using Chain = Managing.Domain.Evm.Chain; using TransferEventDTO = Nethereum.Contracts.Standards.ERC721.ContractDefinition.TransferEventDTO; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; namespace Managing.Infrastructure.Evm; @@ -750,11 +748,25 @@ public class EvmManager : IEvmManager return await _web3ProxyService.CallGmxServiceAsync>("/gmx/funding-rates", new { }); } - public async Task QuantityInPosition(string chainName, string publicAddress, Ticker ticker) + public async Task QuantityInPosition(string chainName, Account account, Ticker ticker) { + if (account.IsPrivyWallet) + { + var positions = await GetPositions(account); + var positionForTicker = positions.FirstOrDefault(p => p.Ticker == ticker); + if (positionForTicker != null) + { + return positionForTicker.Open.Quantity; + } + else + { + return 0; + } + } + var chain = ChainService.GetChain(chainName); var web3 = new Web3(chain.RpcUrl); - var quantity = await _gmxV2Service.QuantityInPosition(web3, publicAddress, ticker); + var quantity = await _gmxV2Service.QuantityInPosition(web3, account.Key, ticker); return quantity; } diff --git a/src/Managing.Web3Proxy/src/plugins/custom/gmx.ts b/src/Managing.Web3Proxy/src/plugins/custom/gmx.ts index 11424a2..3f7d50f 100644 --- a/src/Managing.Web3Proxy/src/plugins/custom/gmx.ts +++ b/src/Managing.Web3Proxy/src/plugins/custom/gmx.ts @@ -95,7 +95,7 @@ export async function getClientForAddress( oracleUrl: "https://arbitrum-api.gmxinfra.io", rpcUrl: "https://arb1.arbitrum.io/rpc", subsquidUrl: "https://gmx.squids.live/gmx-synthetics-arbitrum:live/api/graphql", - subgraphUrl: "https://subgraph.satsuma-prod.com/3b2ced13c8d9/gmx/synthetics-arbitrum-stats/api", + subgraphUrl: "https://subgraph.satsuma-prod.com/3b2ced13c8d9/gmx/synthetics-arbitrum-stats/api" }; const sdk = new GmxSdk(arbitrumSdkConfig) diff --git a/src/Managing.WebApp/src/components/mollecules/TradesModal/TradesModal.tsx b/src/Managing.WebApp/src/components/mollecules/TradesModal/TradesModal.tsx index 2f9b51e..390751b 100644 --- a/src/Managing.WebApp/src/components/mollecules/TradesModal/TradesModal.tsx +++ b/src/Managing.WebApp/src/components/mollecules/TradesModal/TradesModal.tsx @@ -1,8 +1,9 @@ import React, {useEffect, useState} from 'react' -import type {Position, UserStrategyDetailsViewModel} from '../../../generated/ManagingApi' -import {DataClient} from '../../../generated/ManagingApi' +import type {ClosePositionRequest, Position, UserStrategyDetailsViewModel} from '../../../generated/ManagingApi' +import {BotClient, DataClient} from '../../../generated/ManagingApi' import useApiUrlStore from '../../../app/store/apiStore' import Modal from '../Modal/Modal' +import Toast from '../Toast/Toast' interface TradesModalProps { showModal: boolean @@ -18,6 +19,7 @@ const TradesModal: React.FC = ({ const { apiUrl } = useApiUrlStore() const [strategyData, setStrategyData] = useState(null) const [loading, setLoading] = useState(false) + const [closingPosition, setClosingPosition] = useState(null) useEffect(() => { if (showModal && botName) { @@ -36,11 +38,42 @@ const TradesModal: React.FC = ({ setStrategyData(data) } catch (error) { console.error('Error fetching strategy data:', error) + const errorToast = new Toast('Failed to fetch strategy data', false) + errorToast.update('error', 'Failed to fetch strategy data') } finally { setLoading(false) } } + const closePosition = async (position: Position) => { + if (!botName) return + + try { + setClosingPosition(position.identifier) + + const loadingToast = new Toast(`Closing trade ${position.identifier}...`, true) + + // Use BotClient instead of fetch + const botClient = new BotClient({}, apiUrl) + const request: ClosePositionRequest = { + botName: botName, + positionId: position.identifier + } + + await botClient.bot_ClosePosition(request) + + loadingToast.update('success', 'Trade closed successfully!') + // Refresh data after closing + fetchStrategyData() + } catch (error) { + console.error('Error closing position:', error) + const errorToast = new Toast('Failed to close trade', false) + errorToast.update('error', `Failed to close trade: ${error instanceof Error ? error.message : 'Unknown error'}`) + } finally { + setClosingPosition(null) + } + } + return ( = ({ Entry Price Quantity PnL + Actions @@ -89,11 +123,26 @@ const TradesModal: React.FC = ({ 0 ? 'text-success' : 'text-error'}> {position.profitAndLoss?.realized?.toFixed(2) || '0.00'} $ + + {position.status !== 'Finished' && ( + + )} + )) ) : ( - No trades found + No trades found )} diff --git a/src/Managing.WebApp/src/generated/ManagingApi.ts b/src/Managing.WebApp/src/generated/ManagingApi.ts index c2dbd7d..7c9bbc9 100644 --- a/src/Managing.WebApp/src/generated/ManagingApi.ts +++ b/src/Managing.WebApp/src/generated/ManagingApi.ts @@ -804,6 +804,45 @@ export class BotClient extends AuthorizedApiBase { } return Promise.resolve(null as any); } + + bot_ClosePosition(request: ClosePositionRequest): Promise { + let url_ = this.baseUrl + "/Bot/ClosePosition"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(request); + + let options_: RequestInit = { + body: content_, + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + } + }; + + return this.transformOptions(options_).then(transformedOptions_ => { + return this.http.fetch(url_, transformedOptions_); + }).then((_response: Response) => { + return this.processBot_ClosePosition(_response); + }); + } + + protected processBot_ClosePosition(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as Position; + return result200; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } } export class DataClient extends AuthorizedApiBase { @@ -2827,6 +2866,11 @@ export interface OpenPositionManuallyRequest { direction?: TradeDirection; } +export interface ClosePositionRequest { + botName?: string | null; + positionId?: string | null; +} + export interface SpotlightOverview { spotlights: Spotlight[]; dateTime: Date;