Add manual close for bot positions

This commit is contained in:
2025-04-25 17:33:38 +07:00
parent d5dead3d8f
commit 6579bbc06f
8 changed files with 198 additions and 23 deletions

View File

@@ -1,23 +1,13 @@
using Managing.Api.Models.Requests; using Managing.Api.Models.Requests;
using Managing.Api.Models.Responses;
using Managing.Application.Abstractions; using Managing.Application.Abstractions;
using Managing.Application.Abstractions.Services; using Managing.Application.Abstractions.Services;
using Managing.Application.Hubs; using Managing.Application.Hubs;
using Managing.Application.ManageBot.Commands; using Managing.Application.ManageBot.Commands;
using Managing.Common; using Managing.Domain.Trades;
using Managing.Domain.Bots;
using Managing.Domain.Users;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR; 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 static Managing.Common.Enums;
using ApplicationTradingBot = Managing.Application.Bots.TradingBot; using ApplicationTradingBot = Managing.Application.Bots.TradingBot;
using ApiTradingBot = Managing.Api.Models.Responses.TradingBot; using ApiTradingBot = Managing.Api.Models.Responses.TradingBot;
@@ -464,4 +454,84 @@ public class BotController : BaseController
return StatusCode(500, $"Error opening position: {ex.Message}"); return StatusCode(500, $"Error opening position: {ex.Message}");
} }
} }
/// <summary>
/// Closes a specific position for a trading bot
/// </summary>
/// <param name="request">The request containing the position close parameters</param>
/// <returns>The closed position or an error</returns>
[HttpPost]
[Route("ClosePosition")]
public async Task<ActionResult<Position>> 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}");
}
}
}
/// <summary>
/// Request model for closing a position
/// </summary>
public class ClosePositionRequest
{
/// <summary>
/// The name of the bot
/// </summary>
public string BotName { get; set; }
/// <summary>
/// The ID of the position to close
/// </summary>
public string PositionId { get; set; }
} }

View File

@@ -43,7 +43,7 @@ public interface IEvmManager
Task<Trade> DecreasePosition(Account account, Ticker ticker, TradeDirection direction, decimal price, Task<Trade> DecreasePosition(Account account, Ticker ticker, TradeDirection direction, decimal price,
decimal quantity, decimal? leverage); decimal quantity, decimal? leverage);
Task<decimal> QuantityInPosition(string chainName, string publicAddress, Ticker ticker); Task<decimal> QuantityInPosition(string chainName, Account account, Ticker ticker);
Task<Trade> DecreaseOrder(Account account, TradeType tradeType, Ticker ticker, TradeDirection direction, Task<Trade> DecreaseOrder(Account account, TradeType tradeType, Ticker ticker, TradeDirection direction,
decimal price, decimal quantity, decimal? leverage, decimal price, decimal quantity, decimal? leverage,

View File

@@ -605,7 +605,7 @@ public class TradingBot : Bot, ITradingBot
return positionSignal.Date < tenCandleAgo.Date; 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) bool tradeClosingPosition = false)
{ {
if (position.TakeProfit2 != null && position.TakeProfit1.Status == TradeStatus.Filled && if (position.TakeProfit2 != null && position.TakeProfit1.Status == TradeStatus.Filled &&

View File

@@ -12,7 +12,7 @@ namespace Managing.Infrastructure.Exchanges.Exchanges;
public class EvmProcessor : BaseProcessor public class EvmProcessor : BaseProcessor
{ {
public override Enums.TradingExchanges Exchange() => Enums.TradingExchanges.Evm; public override TradingExchanges Exchange() => TradingExchanges.Evm;
private ILogger<EvmProcessor> _logger; private ILogger<EvmProcessor> _logger;
private IEvmManager _evmManager; private IEvmManager _evmManager;
@@ -85,7 +85,7 @@ public class EvmProcessor : BaseProcessor
public override async Task<decimal> GetQuantityInPosition(Account account, Ticker ticker) public override async Task<decimal> 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<Trade> GetTrade(Account account, string order, Ticker ticker) public override async Task<Trade> GetTrade(Account account, string order, Ticker ticker)

View File

@@ -29,8 +29,6 @@ using BalanceOfFunction = Nethereum.Contracts.Standards.ERC20.ContractDefinition
using BalanceOfOutputDTO = Nethereum.Contracts.Standards.ERC20.ContractDefinition.BalanceOfOutputDTO; using BalanceOfOutputDTO = Nethereum.Contracts.Standards.ERC20.ContractDefinition.BalanceOfOutputDTO;
using Chain = Managing.Domain.Evm.Chain; using Chain = Managing.Domain.Evm.Chain;
using TransferEventDTO = Nethereum.Contracts.Standards.ERC721.ContractDefinition.TransferEventDTO; using TransferEventDTO = Nethereum.Contracts.Standards.ERC721.ContractDefinition.TransferEventDTO;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Managing.Infrastructure.Evm; namespace Managing.Infrastructure.Evm;
@@ -750,11 +748,25 @@ public class EvmManager : IEvmManager
return await _web3ProxyService.CallGmxServiceAsync<List<FundingRate>>("/gmx/funding-rates", new { }); return await _web3ProxyService.CallGmxServiceAsync<List<FundingRate>>("/gmx/funding-rates", new { });
} }
public async Task<decimal> QuantityInPosition(string chainName, string publicAddress, Ticker ticker) public async Task<decimal> 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 chain = ChainService.GetChain(chainName);
var web3 = new Web3(chain.RpcUrl); 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; return quantity;
} }

View File

@@ -95,7 +95,7 @@ export async function getClientForAddress(
oracleUrl: "https://arbitrum-api.gmxinfra.io", oracleUrl: "https://arbitrum-api.gmxinfra.io",
rpcUrl: "https://arb1.arbitrum.io/rpc", rpcUrl: "https://arb1.arbitrum.io/rpc",
subsquidUrl: "https://gmx.squids.live/gmx-synthetics-arbitrum:live/api/graphql", 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) const sdk = new GmxSdk(arbitrumSdkConfig)

View File

@@ -1,8 +1,9 @@
import React, {useEffect, useState} from 'react' import React, {useEffect, useState} from 'react'
import type {Position, UserStrategyDetailsViewModel} from '../../../generated/ManagingApi' import type {ClosePositionRequest, Position, UserStrategyDetailsViewModel} from '../../../generated/ManagingApi'
import {DataClient} from '../../../generated/ManagingApi' import {BotClient, DataClient} from '../../../generated/ManagingApi'
import useApiUrlStore from '../../../app/store/apiStore' import useApiUrlStore from '../../../app/store/apiStore'
import Modal from '../Modal/Modal' import Modal from '../Modal/Modal'
import Toast from '../Toast/Toast'
interface TradesModalProps { interface TradesModalProps {
showModal: boolean showModal: boolean
@@ -18,6 +19,7 @@ const TradesModal: React.FC<TradesModalProps> = ({
const { apiUrl } = useApiUrlStore() const { apiUrl } = useApiUrlStore()
const [strategyData, setStrategyData] = useState<UserStrategyDetailsViewModel | null>(null) const [strategyData, setStrategyData] = useState<UserStrategyDetailsViewModel | null>(null)
const [loading, setLoading] = useState<boolean>(false) const [loading, setLoading] = useState<boolean>(false)
const [closingPosition, setClosingPosition] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
if (showModal && botName) { if (showModal && botName) {
@@ -36,11 +38,42 @@ const TradesModal: React.FC<TradesModalProps> = ({
setStrategyData(data) setStrategyData(data)
} catch (error) { } catch (error) {
console.error('Error fetching strategy data:', 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 { } finally {
setLoading(false) 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 ( return (
<Modal <Modal
showModal={showModal} showModal={showModal}
@@ -73,6 +106,7 @@ const TradesModal: React.FC<TradesModalProps> = ({
<th>Entry Price</th> <th>Entry Price</th>
<th>Quantity</th> <th>Quantity</th>
<th>PnL</th> <th>PnL</th>
<th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -89,11 +123,26 @@ const TradesModal: React.FC<TradesModalProps> = ({
<td className={position.profitAndLoss?.realized && position.profitAndLoss.realized > 0 ? 'text-success' : 'text-error'}> <td className={position.profitAndLoss?.realized && position.profitAndLoss.realized > 0 ? 'text-success' : 'text-error'}>
{position.profitAndLoss?.realized?.toFixed(2) || '0.00'} $ {position.profitAndLoss?.realized?.toFixed(2) || '0.00'} $
</td> </td>
<td>
{position.status !== 'Finished' && (
<button
className="btn btn-xs btn-error"
onClick={() => closePosition(position)}
disabled={closingPosition === position.identifier}
>
{closingPosition === position.identifier ? (
<span className="loading loading-spinner loading-xs"></span>
) : (
'Close'
)}
</button>
)}
</td>
</tr> </tr>
)) ))
) : ( ) : (
<tr> <tr>
<td colSpan={6} className="text-center">No trades found</td> <td colSpan={7} className="text-center">No trades found</td>
</tr> </tr>
)} )}
</tbody> </tbody>

View File

@@ -804,6 +804,45 @@ export class BotClient extends AuthorizedApiBase {
} }
return Promise.resolve<Position>(null as any); return Promise.resolve<Position>(null as any);
} }
bot_ClosePosition(request: ClosePositionRequest): Promise<Position> {
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<Position> {
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<Position>(null as any);
}
} }
export class DataClient extends AuthorizedApiBase { export class DataClient extends AuthorizedApiBase {
@@ -2827,6 +2866,11 @@ export interface OpenPositionManuallyRequest {
direction?: TradeDirection; direction?: TradeDirection;
} }
export interface ClosePositionRequest {
botName?: string | null;
positionId?: string | null;
}
export interface SpotlightOverview { export interface SpotlightOverview {
spotlights: Spotlight[]; spotlights: Spotlight[];
dateTime: Date; dateTime: Date;