Add manual close for bot positions
This commit is contained in:
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <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; }
|
||||
}
|
||||
@@ -43,7 +43,7 @@ public interface IEvmManager
|
||||
Task<Trade> DecreasePosition(Account account, Ticker ticker, TradeDirection direction, decimal price,
|
||||
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,
|
||||
decimal price, decimal quantity, decimal? leverage,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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<EvmProcessor> _logger;
|
||||
private IEvmManager _evmManager;
|
||||
@@ -85,7 +85,7 @@ public class EvmProcessor : BaseProcessor
|
||||
|
||||
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)
|
||||
|
||||
@@ -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<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 web3 = new Web3(chain.RpcUrl);
|
||||
var quantity = await _gmxV2Service.QuantityInPosition(web3, publicAddress, ticker);
|
||||
var quantity = await _gmxV2Service.QuantityInPosition(web3, account.Key, ticker);
|
||||
return quantity;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<TradesModalProps> = ({
|
||||
const { apiUrl } = useApiUrlStore()
|
||||
const [strategyData, setStrategyData] = useState<UserStrategyDetailsViewModel | null>(null)
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [closingPosition, setClosingPosition] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal && botName) {
|
||||
@@ -36,11 +38,42 @@ const TradesModal: React.FC<TradesModalProps> = ({
|
||||
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 (
|
||||
<Modal
|
||||
showModal={showModal}
|
||||
@@ -73,6 +106,7 @@ const TradesModal: React.FC<TradesModalProps> = ({
|
||||
<th>Entry Price</th>
|
||||
<th>Quantity</th>
|
||||
<th>PnL</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -89,11 +123,26 @@ const TradesModal: React.FC<TradesModalProps> = ({
|
||||
<td className={position.profitAndLoss?.realized && position.profitAndLoss.realized > 0 ? 'text-success' : 'text-error'}>
|
||||
{position.profitAndLoss?.realized?.toFixed(2) || '0.00'} $
|
||||
</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>
|
||||
<td colSpan={6} className="text-center">No trades found</td>
|
||||
<td colSpan={7} className="text-center">No trades found</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
|
||||
@@ -804,6 +804,45 @@ export class BotClient extends AuthorizedApiBase {
|
||||
}
|
||||
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 {
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user