Add manual close for bot positions
This commit is contained in:
@@ -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; }
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user