Swap tokens

This commit is contained in:
2025-07-06 14:00:44 +07:00
parent 8096db495a
commit c7dec76809
21 changed files with 1124 additions and 173 deletions

View File

@@ -89,7 +89,6 @@ Key Principles
- Place static content and interfaces at file end. - Place static content and interfaces at file end.
- Use content variables for static content outside render functions. - Use content variables for static content outside render functions.
- Minimize 'use client', 'useEffect', and 'setState'. Favor RSC. - Minimize 'use client', 'useEffect', and 'setState'. Favor RSC.
- Use Zod for form validation.
- Wrap client components in Suspense with fallback. - Wrap client components in Suspense with fallback.
- Use dynamic loading for non-critical components. - Use dynamic loading for non-critical components.
- Optimize images: WebP format, size data, lazy loading. - Optimize images: WebP format, size data, lazy loading.
@@ -105,5 +104,6 @@ Key Principles
- When you think its necessary update all the code from the database to the front end - When you think its necessary update all the code from the database to the front end
- Do not update ManagingApi.ts, once you made a change on the backend endpoint, execute the command to regenerate ManagingApi.ts on the frontend; cd src/Managing.Nswag && dotnet build - Do not update ManagingApi.ts, once you made a change on the backend endpoint, execute the command to regenerate ManagingApi.ts on the frontend; cd src/Managing.Nswag && dotnet build
- Do not reference new react library if a component already exist in mollecules or atoms - Do not reference new react library if a component already exist in mollecules or atoms
- After finishing the editing, build the project
Follow the official Microsoft documentation and ASP.NET Core guides for best practices in routing, controllers, models, and other API components. Follow the official Microsoft documentation and ASP.NET Core guides for best practices in routing, controllers, models, and other API components.

View File

@@ -1,4 +1,5 @@
using Managing.Application.Abstractions.Services; using Managing.Api.Models.Requests;
using Managing.Application.Abstractions.Services;
using Managing.Domain.Accounts; using Managing.Domain.Accounts;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -89,6 +90,30 @@ namespace Managing.Api.Controllers
return Ok(result); return Ok(result);
} }
/// <summary>
/// Swaps tokens on GMX for a specific account.
/// </summary>
/// <param name="name">The name of the account to perform the swap for.</param>
/// <param name="request">The swap request containing ticker symbols, amount, and order parameters.</param>
/// <returns>The swap response with transaction details.</returns>
[HttpPost]
[Route("{name}/gmx-swap")]
public async Task<ActionResult<SwapInfos>> SwapGmxTokens(string name, [FromBody] SwapTokensRequest request)
{
var user = await GetUser();
var result = await _AccountService.SwapGmxTokensAsync(
user,
name,
request.FromTicker,
request.ToTicker,
request.Amount,
request.OrderType,
request.TriggerRatio,
request.AllowedSlippage
);
return Ok(result);
}
/// <summary> /// <summary>
/// Deletes a specific account by name for the authenticated user. /// Deletes a specific account by name for the authenticated user.
/// </summary> /// </summary>

View File

@@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
using static Managing.Common.Enums;
namespace Managing.Api.Models.Requests;
/// <summary>
/// Request model for GMX token swap operations
/// </summary>
public class SwapTokensRequest
{
/// <summary>
/// The ticker symbol of the token to swap from
/// </summary>
[Required]
public Ticker FromTicker { get; set; }
/// <summary>
/// The ticker symbol of the token to swap to
/// </summary>
[Required]
public Ticker ToTicker { get; set; }
/// <summary>
/// The amount to swap
/// </summary>
[Required]
[Range(0.000001, double.MaxValue, ErrorMessage = "Amount must be greater than 0")]
public double Amount { get; set; }
/// <summary>
/// The order type (market or limit)
/// </summary>
public string OrderType { get; set; } = "market";
/// <summary>
/// The trigger ratio for limit orders (optional)
/// </summary>
public double? TriggerRatio { get; set; }
/// <summary>
/// The allowed slippage percentage (default 0.5%)
/// </summary>
[Range(0, 100, ErrorMessage = "Allowed slippage must be between 0 and 100")]
public double AllowedSlippage { get; set; } = 0.5;
}

View File

@@ -1,5 +1,6 @@
using Managing.Domain.Accounts; using Managing.Domain.Accounts;
using Managing.Domain.Users; using Managing.Domain.Users;
using static Managing.Common.Enums;
namespace Managing.Application.Abstractions.Services; namespace Managing.Application.Abstractions.Services;
@@ -14,4 +15,7 @@ public interface IAccountService
Task<Account> GetAccountByKey(string key, bool hideSecrets, bool getBalance); Task<Account> GetAccountByKey(string key, bool hideSecrets, bool getBalance);
IEnumerable<Account> GetAccountsBalancesByUser(User user, bool hideSecrets = true); IEnumerable<Account> GetAccountsBalancesByUser(User user, bool hideSecrets = true);
Task<GmxClaimableSummary> GetGmxClaimableSummaryAsync(User user, string accountName); Task<GmxClaimableSummary> GetGmxClaimableSummaryAsync(User user, string accountName);
Task<SwapInfos> SwapGmxTokensAsync(User user, string accountName, Ticker fromTicker, Ticker toTicker,
double amount, string orderType = "market", double? triggerRatio = null, double allowedSlippage = 0.5);
} }

View File

@@ -1,4 +1,5 @@
using Managing.Domain.Accounts; using Managing.Domain.Accounts;
using static Managing.Common.Enums;
namespace Managing.Application.Abstractions.Services namespace Managing.Application.Abstractions.Services
{ {
@@ -9,5 +10,8 @@ namespace Managing.Application.Abstractions.Services
Task<T> CallGmxServiceAsync<T>(string endpoint, object payload); Task<T> CallGmxServiceAsync<T>(string endpoint, object payload);
Task<T> GetGmxServiceAsync<T>(string endpoint, object payload = null); Task<T> GetGmxServiceAsync<T>(string endpoint, object payload = null);
Task<GmxClaimableSummary> GetGmxClaimableSummaryAsync(string account); Task<GmxClaimableSummary> GetGmxClaimableSummaryAsync(string account);
Task<SwapInfos> SwapGmxTokensAsync(string account, Ticker fromTicker, Ticker toTicker, double amount,
string orderType = "market", double? triggerRatio = null, double allowedSlippage = 0.5);
} }
} }

View File

@@ -1,9 +1,9 @@
using Managing.Application.Abstractions.Repositories; using Managing.Application.Abstractions.Repositories;
using Managing.Application.Abstractions.Services; using Managing.Application.Abstractions.Services;
using Managing.Common;
using Managing.Domain.Accounts; using Managing.Domain.Accounts;
using Managing.Domain.Users; using Managing.Domain.Users;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using static Managing.Common.Enums;
namespace Managing.Application.Accounts; namespace Managing.Application.Accounts;
@@ -47,15 +47,15 @@ public class AccountService : IAccountService
{ {
request.User = user; request.User = user;
if (request.Exchange == Enums.TradingExchanges.Evm if (request.Exchange == TradingExchanges.Evm
&& request.Type == Enums.AccountType.Trader) && request.Type == AccountType.Trader)
{ {
var keys = _evmManager.GenerateAddress(); var keys = _evmManager.GenerateAddress();
request.Key = keys.Key; request.Key = keys.Key;
request.Secret = keys.Secret; request.Secret = keys.Secret;
} }
else if (request.Exchange == Enums.TradingExchanges.Evm else if (request.Exchange == TradingExchanges.Evm
&& request.Type == Enums.AccountType.Privy) && request.Type == AccountType.Privy)
{ {
if (string.IsNullOrEmpty(request.Key)) if (string.IsNullOrEmpty(request.Key))
{ {
@@ -200,6 +200,45 @@ public class AccountService : IAccountService
} }
} }
public async Task<SwapInfos> SwapGmxTokensAsync(User user, string accountName, Ticker fromTicker, Ticker toTicker, double amount, string orderType = "market", double? triggerRatio = null, double allowedSlippage = 0.5)
{
// Get the account for the user
var account = await GetAccountByUser(user, accountName, true, false);
if (account == null)
{
throw new ArgumentException($"Account '{accountName}' not found for user '{user.Name}'");
}
// Ensure the account has a valid address/key
if (string.IsNullOrEmpty(account.Key))
{
throw new ArgumentException($"Account '{accountName}' does not have a valid address");
}
try
{
// Call the Web3ProxyService to swap GMX tokens
var swapInfos = await _web3ProxyService.SwapGmxTokensAsync(
account.Key,
fromTicker,
toTicker,
amount,
orderType,
triggerRatio,
allowedSlippage
);
return swapInfos;
}
catch (Exception ex) when (!(ex is ArgumentException || ex is InvalidOperationException))
{
_logger.LogError(ex, "Error swapping GMX tokens for account {AccountName} and user {UserName}",
accountName, user.Name);
throw new InvalidOperationException($"Failed to swap GMX tokens: {ex.Message}", ex);
}
}
private void ManageProperties(bool hideSecrets, bool getBalance, Account account) private void ManageProperties(bool hideSecrets, bool getBalance, Account account)
{ {
if (account != null) if (account != null)

View File

@@ -0,0 +1,37 @@
namespace Managing.Domain.Accounts;
/// <summary>
/// Domain model for swap operation information
/// </summary>
public class SwapInfos
{
/// <summary>
/// Whether the swap operation was successful
/// </summary>
public bool Success { get; set; }
/// <summary>
/// Transaction hash if successful
/// </summary>
public string Hash { get; set; }
/// <summary>
/// Success message
/// </summary>
public string Message { get; set; }
/// <summary>
/// Error message if failed
/// </summary>
public string Error { get; set; }
/// <summary>
/// Error type if failed
/// </summary>
public string ErrorType { get; set; }
/// <summary>
/// Suggestion for error resolution
/// </summary>
public string Suggestion { get; set; }
}

View File

@@ -0,0 +1,38 @@
using System.Text.Json.Serialization;
using Newtonsoft.Json;
namespace Managing.Infrastructure.Evm.Models.Proxy;
/// <summary>
/// Response model for GMX swap operations
/// </summary>
public class GmxSwapResponse : Web3ProxyBaseResponse
{
/// <summary>
/// Transaction hash if successful
/// </summary>
[JsonProperty("hash")]
[JsonPropertyName("hash")]
public string Hash { get; set; }
/// <summary>
/// Success message
/// </summary>
[JsonProperty("message")]
[JsonPropertyName("message")]
public string Message { get; set; }
/// <summary>
/// Error type if failed
/// </summary>
[JsonProperty("errorType")]
[JsonPropertyName("errorType")]
public string ErrorType { get; set; }
/// <summary>
/// Suggestion for error resolution
/// </summary>
[JsonProperty("suggestion")]
[JsonPropertyName("suggestion")]
public string Suggestion { get; set; }
}

View File

@@ -6,6 +6,7 @@ using Managing.Application.Abstractions.Services;
using Managing.Domain.Accounts; using Managing.Domain.Accounts;
using Managing.Infrastructure.Evm.Models.Proxy; using Managing.Infrastructure.Evm.Models.Proxy;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using static Managing.Common.Enums;
namespace Managing.Infrastructure.Evm.Services namespace Managing.Infrastructure.Evm.Services
{ {
@@ -180,6 +181,38 @@ namespace Managing.Infrastructure.Evm.Services
}; };
} }
public async Task<SwapInfos> SwapGmxTokensAsync(string account, Ticker fromTicker, Ticker toTicker, double amount, string orderType = "market", double? triggerRatio = null, double allowedSlippage = 0.5)
{
var payload = new
{
account,
fromTicker = fromTicker.ToString(),
toTicker = toTicker.ToString(),
amount,
orderType,
triggerRatio,
allowedSlippage
};
var response = await CallGmxServiceAsync<GmxSwapResponse>("/swap-tokens", payload);
if (response == null)
{
throw new Web3ProxyException("GMX swap response is null");
}
// Map from infrastructure model to domain model
return new SwapInfos
{
Success = response.Success,
Hash = response.Hash,
Message = response.Message,
Error = null, // GmxSwapResponse doesn't have Error property
ErrorType = response.ErrorType,
Suggestion = response.Suggestion
};
}
private async Task HandleErrorResponse(HttpResponseMessage response) private async Task HandleErrorResponse(HttpResponseMessage response)
{ {
var statusCode = (int)response.StatusCode; var statusCode = (int)response.StatusCode;

View File

@@ -113,6 +113,7 @@ declare module 'fastify' {
closeGmxPosition: typeof closeGmxPosition; closeGmxPosition: typeof closeGmxPosition;
getGmxTrade: typeof getGmxTrade; getGmxTrade: typeof getGmxTrade;
getGmxPositions: typeof getGmxPositions; getGmxPositions: typeof getGmxPositions;
swapGmxTokens: typeof swapGmxTokens;
} }
} }

View File

@@ -101,6 +101,45 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
) )
}) })
// Define route to swap tokens
fastify.post('/swap-tokens', {
schema: {
body: Type.Object({
account: Type.String(),
fromTicker: Type.String(),
toTicker: Type.String(),
amount: Type.Number(),
orderType: Type.Optional(Type.Union([Type.Literal('market'), Type.Literal('limit')])),
triggerRatio: Type.Optional(Type.Number()),
allowedSlippage: Type.Optional(Type.Number())
}),
response: {
200: Type.Object({
success: Type.Boolean(),
hash: Type.Optional(Type.String()),
message: Type.Optional(Type.String()),
error: Type.Optional(Type.String()),
errorType: Type.Optional(Type.String()),
suggestion: Type.Optional(Type.String())
})
}
}
}, async (request, reply) => {
const { account, fromTicker, toTicker, amount, orderType, triggerRatio, allowedSlippage } = request.body
// Call the swapGmxTokens method from the GMX plugin
return request.swapGmxTokens(
reply,
account,
fromTicker,
toTicker,
amount,
orderType || 'market',
triggerRatio,
allowedSlippage || 0.5
)
})
// Define route to get a trade // Define route to get a trade
fastify.get('/trades', { fastify.get('/trades', {
schema: { schema: {

View File

@@ -14,7 +14,21 @@ const Modal: React.FC<IModalProps> = ({
return ( return (
<div className="container mx-auto"> <div className="container mx-auto">
{showModal ? ( {showModal ? (
<form onSubmit={onSubmit}> onSubmit ? (
<form onSubmit={onSubmit}>
<div className="modal modal-bottom sm:modal-middle modal-open">
<div className="modal-box !max-w-4xl !w-11/12">
<ModalHeader
titleHeader={titleHeader}
onClose={onClose}
onSubmit={onSubmit}
showModal={showModal}
/>
{children}
</div>
</div>
</form>
) : (
<div className="modal modal-bottom sm:modal-middle modal-open"> <div className="modal modal-bottom sm:modal-middle modal-open">
<div className="modal-box !max-w-4xl !w-11/12"> <div className="modal-box !max-w-4xl !w-11/12">
<ModalHeader <ModalHeader
@@ -26,7 +40,7 @@ const Modal: React.FC<IModalProps> = ({
{children} {children}
</div> </div>
</div> </div>
</form> )
) : null} ) : null}
</div> </div>
) )

View File

@@ -247,6 +247,49 @@ export class AccountClient extends AuthorizedApiBase {
} }
return Promise.resolve<GmxClaimableSummary>(null as any); return Promise.resolve<GmxClaimableSummary>(null as any);
} }
account_SwapGmxTokens(name: string, request: SwapTokensRequest): Promise<SwapInfos> {
let url_ = this.baseUrl + "/Account/{name}/gmx-swap";
if (name === undefined || name === null)
throw new Error("The parameter 'name' must be defined.");
url_ = url_.replace("{name}", encodeURIComponent("" + name));
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.processAccount_SwapGmxTokens(_response);
});
}
protected processAccount_SwapGmxTokens(response: Response): Promise<SwapInfos> {
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 SwapInfos;
return result200;
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
console.log(_responseText)
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<SwapInfos>(null as any);
}
} }
export class BacktestClient extends AuthorizedApiBase { export class BacktestClient extends AuthorizedApiBase {
@@ -2871,68 +2914,22 @@ export interface RebateStatsData {
discountFactor?: number; discountFactor?: number;
} }
export interface Backtest { export interface SwapInfos {
id: string; success?: boolean;
finalPnl: number; hash?: string | null;
winRate: number; message?: string | null;
growthPercentage: number; error?: string | null;
hodlPercentage: number; errorType?: string | null;
config: TradingBotConfig; suggestion?: string | null;
positions: Position[];
signals: Signal[];
candles: Candle[];
startDate: Date;
endDate: Date;
statistics: PerformanceMetrics;
fees: number;
walletBalances: KeyValuePairOfDateTimeAndDecimal[];
optimizedMoneyManagement: MoneyManagement;
user: User;
indicatorsValues: { [key in keyof typeof IndicatorType]?: IndicatorsResultBase; };
score: number;
} }
export interface TradingBotConfig { export interface SwapTokensRequest {
accountName: string; fromTicker: Ticker;
moneyManagement: MoneyManagement; toTicker: Ticker;
ticker: Ticker; amount: number;
timeframe: Timeframe; orderType?: string | null;
isForWatchingOnly: boolean; triggerRatio?: number | null;
botTradingBalance: number; allowedSlippage?: number;
isForBacktest: boolean;
cooldownPeriod: number;
maxLossStreak: number;
flipPosition: boolean;
name: string;
riskManagement?: RiskManagement | null;
scenario?: Scenario | null;
scenarioName?: string | null;
maxPositionTimeHours?: number | null;
closeEarlyWhenProfitable?: boolean;
flipOnlyWhenInProfit: boolean;
useSynthApi?: boolean;
useForPositionSizing?: boolean;
useForSignalFiltering?: boolean;
useForDynamicStopLoss?: boolean;
}
export interface MoneyManagement {
name: string;
timeframe: Timeframe;
stopLoss: number;
takeProfit: number;
leverage: number;
user?: User | null;
}
export enum Timeframe {
FiveMinutes = "FiveMinutes",
FifteenMinutes = "FifteenMinutes",
ThirtyMinutes = "ThirtyMinutes",
OneHour = "OneHour",
FourHour = "FourHour",
OneDay = "OneDay",
OneMinute = "OneMinute",
} }
export enum Ticker { export enum Ticker {
@@ -3044,6 +3041,70 @@ export enum Ticker {
Unknown = "Unknown", Unknown = "Unknown",
} }
export interface Backtest {
id: string;
finalPnl: number;
winRate: number;
growthPercentage: number;
hodlPercentage: number;
config: TradingBotConfig;
positions: Position[];
signals: Signal[];
candles: Candle[];
startDate: Date;
endDate: Date;
statistics: PerformanceMetrics;
fees: number;
walletBalances: KeyValuePairOfDateTimeAndDecimal[];
optimizedMoneyManagement: MoneyManagement;
user: User;
indicatorsValues: { [key in keyof typeof IndicatorType]?: IndicatorsResultBase; };
score: number;
}
export interface TradingBotConfig {
accountName: string;
moneyManagement: MoneyManagement;
ticker: Ticker;
timeframe: Timeframe;
isForWatchingOnly: boolean;
botTradingBalance: number;
isForBacktest: boolean;
cooldownPeriod: number;
maxLossStreak: number;
flipPosition: boolean;
name: string;
riskManagement?: RiskManagement | null;
scenario?: Scenario | null;
scenarioName?: string | null;
maxPositionTimeHours?: number | null;
closeEarlyWhenProfitable?: boolean;
flipOnlyWhenInProfit: boolean;
useSynthApi?: boolean;
useForPositionSizing?: boolean;
useForSignalFiltering?: boolean;
useForDynamicStopLoss?: boolean;
}
export interface MoneyManagement {
name: string;
timeframe: Timeframe;
stopLoss: number;
takeProfit: number;
leverage: number;
user?: User | null;
}
export enum Timeframe {
FiveMinutes = "FiveMinutes",
FifteenMinutes = "FifteenMinutes",
ThirtyMinutes = "ThirtyMinutes",
OneHour = "OneHour",
FourHour = "FourHour",
OneDay = "OneDay",
OneMinute = "OneMinute",
}
export interface RiskManagement { export interface RiskManagement {
adverseProbabilityThreshold: number; adverseProbabilityThreshold: number;
favorableProbabilityThreshold: number; favorableProbabilityThreshold: number;

View File

@@ -83,68 +83,22 @@ export interface RebateStatsData {
discountFactor?: number; discountFactor?: number;
} }
export interface Backtest { export interface SwapInfos {
id: string; success?: boolean;
finalPnl: number; hash?: string | null;
winRate: number; message?: string | null;
growthPercentage: number; error?: string | null;
hodlPercentage: number; errorType?: string | null;
config: TradingBotConfig; suggestion?: string | null;
positions: Position[];
signals: Signal[];
candles: Candle[];
startDate: Date;
endDate: Date;
statistics: PerformanceMetrics;
fees: number;
walletBalances: KeyValuePairOfDateTimeAndDecimal[];
optimizedMoneyManagement: MoneyManagement;
user: User;
indicatorsValues: { [key in keyof typeof IndicatorType]?: IndicatorsResultBase; };
score: number;
} }
export interface TradingBotConfig { export interface SwapTokensRequest {
accountName: string; fromTicker: Ticker;
moneyManagement: MoneyManagement; toTicker: Ticker;
ticker: Ticker; amount: number;
timeframe: Timeframe; orderType?: string | null;
isForWatchingOnly: boolean; triggerRatio?: number | null;
botTradingBalance: number; allowedSlippage?: number;
isForBacktest: boolean;
cooldownPeriod: number;
maxLossStreak: number;
flipPosition: boolean;
name: string;
riskManagement?: RiskManagement | null;
scenario?: Scenario | null;
scenarioName?: string | null;
maxPositionTimeHours?: number | null;
closeEarlyWhenProfitable?: boolean;
flipOnlyWhenInProfit: boolean;
useSynthApi?: boolean;
useForPositionSizing?: boolean;
useForSignalFiltering?: boolean;
useForDynamicStopLoss?: boolean;
}
export interface MoneyManagement {
name: string;
timeframe: Timeframe;
stopLoss: number;
takeProfit: number;
leverage: number;
user?: User | null;
}
export enum Timeframe {
FiveMinutes = "FiveMinutes",
FifteenMinutes = "FifteenMinutes",
ThirtyMinutes = "ThirtyMinutes",
OneHour = "OneHour",
FourHour = "FourHour",
OneDay = "OneDay",
OneMinute = "OneMinute",
} }
export enum Ticker { export enum Ticker {
@@ -256,6 +210,70 @@ export enum Ticker {
Unknown = "Unknown", Unknown = "Unknown",
} }
export interface Backtest {
id: string;
finalPnl: number;
winRate: number;
growthPercentage: number;
hodlPercentage: number;
config: TradingBotConfig;
positions: Position[];
signals: Signal[];
candles: Candle[];
startDate: Date;
endDate: Date;
statistics: PerformanceMetrics;
fees: number;
walletBalances: KeyValuePairOfDateTimeAndDecimal[];
optimizedMoneyManagement: MoneyManagement;
user: User;
indicatorsValues: { [key in keyof typeof IndicatorType]?: IndicatorsResultBase; };
score: number;
}
export interface TradingBotConfig {
accountName: string;
moneyManagement: MoneyManagement;
ticker: Ticker;
timeframe: Timeframe;
isForWatchingOnly: boolean;
botTradingBalance: number;
isForBacktest: boolean;
cooldownPeriod: number;
maxLossStreak: number;
flipPosition: boolean;
name: string;
riskManagement?: RiskManagement | null;
scenario?: Scenario | null;
scenarioName?: string | null;
maxPositionTimeHours?: number | null;
closeEarlyWhenProfitable?: boolean;
flipOnlyWhenInProfit: boolean;
useSynthApi?: boolean;
useForPositionSizing?: boolean;
useForSignalFiltering?: boolean;
useForDynamicStopLoss?: boolean;
}
export interface MoneyManagement {
name: string;
timeframe: Timeframe;
stopLoss: number;
takeProfit: number;
leverage: number;
user?: User | null;
}
export enum Timeframe {
FiveMinutes = "FiveMinutes",
FifteenMinutes = "FifteenMinutes",
ThirtyMinutes = "ThirtyMinutes",
OneHour = "OneHour",
FourHour = "FourHour",
OneDay = "OneDay",
OneMinute = "OneMinute",
}
export interface RiskManagement { export interface RiskManagement {
adverseProbabilityThreshold: number; adverseProbabilityThreshold: number;
favorableProbabilityThreshold: number; favorableProbabilityThreshold: number;

View File

@@ -299,6 +299,7 @@ export type ICardPositionFlipped = {
export type IAccountRowDetail = { export type IAccountRowDetail = {
balances: Balance[] balances: Balance[]
showTotal?: boolean showTotal?: boolean
account?: Account
} }
export type IGridTile = { export type IGridTile = {

View File

@@ -0,0 +1,42 @@
import {useCallback, useState} from 'react'
import {extractErrorMessage} from '../utils/apiErrorHandler'
interface UseApiErrorReturn {
error: string | null
setError: (error: string | null) => void
clearError: () => void
handleError: (err: unknown) => string
handleApiErrorWithToast: (err: unknown, toast: any) => void
}
/**
* Custom hook for handling API errors consistently across components
* @returns Error handling utilities
*/
export function useApiError(): UseApiErrorReturn {
const [error, setError] = useState<string | null>(null)
const clearError = useCallback(() => {
setError(null)
}, [])
const handleError = useCallback((err: unknown): string => {
const errorMessage = extractErrorMessage(err)
setError(errorMessage)
return errorMessage
}, [])
const handleApiErrorWithToast = useCallback((err: unknown, toast: any) => {
const errorMessage = extractErrorMessage(err)
setError(errorMessage)
toast.update('error', `Error: ${errorMessage}`)
}, [])
return {
error,
setError,
clearError,
handleError,
handleApiErrorWithToast
}
}

View File

@@ -0,0 +1,263 @@
import React, {useState} from 'react'
import type {SubmitHandler} from 'react-hook-form'
import {useForm} from 'react-hook-form'
import {Account, AccountClient, Ticker,} from '../../../generated/ManagingApi'
import Modal from '../../../components/mollecules/Modal/Modal'
import useApiUrlStore from '../../../app/store/apiStore'
import {FormInput, Toast} from '../../../components/mollecules'
import {useApiError} from '../../../hooks/useApiError'
interface SwapModalProps {
isOpen: boolean
onClose: () => void
account: Account
fromTicker: Ticker
availableAmount: number
}
interface SwapFormInput {
fromTicker: Ticker
toTicker: Ticker
amount: number
orderType: string
triggerRatio?: number
allowedSlippage: number
}
const SwapModal: React.FC<SwapModalProps> = ({
isOpen,
onClose,
account,
fromTicker,
availableAmount,
}) => {
const [isLoading, setIsLoading] = useState(false)
const { error, setError, handleApiErrorWithToast } = useApiError()
const { apiUrl } = useApiUrlStore()
const client = new AccountClient({}, apiUrl)
const [selectedToTicker, setSelectedToTicker] = useState<Ticker>(Ticker.USDC)
const [selectedOrderType, setSelectedOrderType] = useState<string>('market')
const { register, handleSubmit, watch, setValue } = useForm<SwapFormInput>({
defaultValues: {
fromTicker: fromTicker,
toTicker: Ticker.USDC,
amount: availableAmount * 0.1, // Start with 10% of available amount
orderType: 'market',
allowedSlippage: 0.5,
}
})
const watchedAmount = watch('amount')
function setSelectedToTickerEvent(e: React.ChangeEvent<HTMLSelectElement>) {
setSelectedToTicker(e.target.value as Ticker)
}
function setSelectedOrderTypeEvent(e: React.ChangeEvent<HTMLSelectElement>) {
setSelectedOrderType(e.target.value)
}
const onSubmit: SubmitHandler<SwapFormInput> = async (form) => {
const t = new Toast(`Swapping ${form.amount} ${form.fromTicker} to ${form.toTicker} on ${account.name}`)
setIsLoading(true)
setError(null)
try {
const result = await client.account_SwapGmxTokens(
account.name,
{
fromTicker: form.fromTicker,
toTicker: form.toTicker,
amount: form.amount,
orderType: form.orderType,
triggerRatio: form.triggerRatio,
allowedSlippage: form.allowedSlippage,
}
)
if (result.success) {
t.update('success', `Swap successful! Hash: ${result.hash}`)
onClose()
} else {
console.log(result)
const errorMessage = result.error || result.message || 'Swap failed'
setError(errorMessage)
t.update('error', `Swap failed: ${errorMessage}`)
}
} catch (err) {
handleApiErrorWithToast(err, t)
} finally {
setIsLoading(false)
}
}
const handleFormSubmit = (e: React.FormEvent) => {
e.preventDefault()
handleSubmit(onSubmit)(e)
}
const modalContent = (
<>
{isLoading ? (
<div className="text-center py-4">
<span className="loading loading-spinner loading-md"></span>
<p>Processing swap...</p>
</div>
) : error ? (
<div className="alert alert-error mb-4">
<p>{error}</p>
</div>
) : (
<>
<div className="mb-4">
<p className="mb-2">
<strong>Account:</strong> {account.name}
</p>
<p className="mb-2">
<strong>From:</strong> {fromTicker}
</p>
</div>
<form onSubmit={handleFormSubmit}>
<div className="space-y-4 mb-4">
<FormInput label="To Ticker" htmlFor="toTicker">
<select
className="select select-bordered w-full"
{...register('toTicker', {
onChange(e) {
setSelectedToTickerEvent(e)
},
value: selectedToTicker,
})}
>
{Object.values(Ticker)
.filter(ticker => ticker !== fromTicker) // Exclude the from ticker
.map((ticker) => (
<option key={ticker} value={ticker}>
{ticker}
</option>
))}
</select>
</FormInput>
<FormInput label="Amount" htmlFor="amount">
<div className="w-full">
<input
type="number"
step="any"
placeholder="Enter amount to swap"
className="input input-bordered w-full mb-2"
{...register('amount', {
valueAsNumber: true,
min: 0.0001,
max: availableAmount,
required: true
})}
/>
<div className="w-full">
<input
type="range"
min="0"
max={availableAmount}
step={availableAmount / 100}
className="range range-primary w-full"
value={watchedAmount || 0}
onChange={(e) => {
const value = parseFloat(e.target.value)
setValue('amount', value)
}}
/>
<div className="text-center text-xs text-gray-500 mt-1">
{watchedAmount && availableAmount > 0 ? (
<span>{((watchedAmount / availableAmount) * 100).toFixed(1)}% of available balance</span>
) : (
<span>0% of available balance</span>
)}
</div>
</div>
</div>
</FormInput>
<FormInput label="Order Type" htmlFor="orderType">
<select
className="select select-bordered w-full"
{...register('orderType', {
onChange(e) {
setSelectedOrderTypeEvent(e)
},
value: selectedOrderType,
})}
>
<option value="market">Market</option>
<option value="limit">Limit</option>
<option value="stop">Stop</option>
</select>
</FormInput>
<FormInput label="Allowed Slippage (%)" htmlFor="allowedSlippage">
<input
type="number"
step="0.1"
placeholder="0.5"
className="input input-bordered w-full"
{...register('allowedSlippage', {
valueAsNumber: true,
min: 0.1,
max: 10,
value: 0.5
})}
/>
</FormInput>
{selectedOrderType === 'limit' && (
<FormInput label="Trigger Ratio" htmlFor="triggerRatio">
<input
type="number"
step="any"
placeholder="Enter trigger ratio"
className="input input-bordered w-full"
{...register('triggerRatio', { valueAsNumber: true })}
/>
</FormInput>
)}
<button
type="submit"
className="btn btn-primary w-full mt-2"
disabled={isLoading || !watchedAmount || watchedAmount <= 0}
>
{isLoading ? (
<span className="loading loading-spinner"></span>
) : (
`Swap ${watchedAmount || 0} ${fromTicker} to ${selectedToTicker}`
)}
</button>
</div>
</form>
<div className="mt-4">
<div className="alert alert-info">
<p className="text-sm">
<strong>Note:</strong> Ensure account has sufficient balance for the swap.
</p>
</div>
</div>
</>
)}
</>
)
return (
<Modal
showModal={isOpen}
onClose={onClose}
titleHeader="Swap Tokens on GMX"
>
{modalContent}
</Modal>
)
}
export default SwapModal

View File

@@ -1,53 +1,118 @@
import React from 'react' import React, {useState} from 'react'
import {FiRefreshCw} from 'react-icons/fi'
import { SelectColumnFilter, Table } from '../../../components/mollecules' import {SelectColumnFilter, Table} from '../../../components/mollecules'
import { IAccountRowDetail } from '../../../global/type' import type {IAccountRowDetail} from '../../../global/type.tsx'
import type {Account, Balance} from '../../../generated/ManagingApi'
import {Ticker} from '../../../generated/ManagingApi'
import SwapModal from './SwapModal'
const columns = [ interface IAccountRowDetailProps extends IAccountRowDetail {
{ account: Account
Header: 'Chain', }
accessor: 'chain.name',
disableFilters: true,
disableSortBy: true,
},
{
Filter: SelectColumnFilter,
Header: 'Assets',
accessor: 'tokenName',
disableFilters: true,
disableSortBy: true,
},
{
Cell: ({ cell }: any) => (
<>
<div className="tooltip" data-tip={cell.row.tokenName}>
{cell.row.values.amount.toFixed(4)}
</div>
</>
),
Header: 'Quantity',
accessor: 'amount',
disableFilters: true,
},
{
Cell: ({ cell }: any) => <>{cell.row.values.value.toFixed(2)} $</>,
Header: 'USD',
accessor: 'value',
disableFilters: true,
disableSortBy: true,
},
{
Cell: ({ cell }: any) => <> {cell.row.values.price} $</>,
Header: 'Price',
accessor: 'price',
disableFilters: true,
},
]
const AccountRowDetails: React.FC<IAccountRowDetail> = ({ const AccountRowDetails: React.FC<IAccountRowDetailProps> = ({
balances, balances,
showTotal, showTotal,
account,
}) => { }) => {
const [swapModalState, setSwapModalState] = useState<{
isOpen: boolean
fromTicker: Ticker | null
availableAmount: number
}>({
isOpen: false,
fromTicker: null,
availableAmount: 0,
})
const handleSwapClick = (balance: Balance) => {
if (balance.tokenName && balance.amount) {
// Convert tokenName to Ticker enum
const ticker = balance.tokenName.toUpperCase() as Ticker
if (Object.values(Ticker).includes(ticker)) {
setSwapModalState({
isOpen: true,
fromTicker: ticker,
availableAmount: balance.amount,
})
}
}
}
const closeSwapModal = () => {
setSwapModalState({
isOpen: false,
fromTicker: null,
availableAmount: 0,
})
}
const columns = [
{
Header: 'Chain',
accessor: 'chain.name',
disableFilters: true,
disableSortBy: true,
},
{
Filter: SelectColumnFilter,
Header: 'Assets',
accessor: 'tokenName',
disableFilters: true,
disableSortBy: true,
},
{
Cell: ({ cell }: any) => (
<>
<div className="tooltip" data-tip={cell.row.tokenName}>
{cell.row.values.amount.toFixed(4)}
</div>
</>
),
Header: 'Quantity',
accessor: 'amount',
disableFilters: true,
},
{
Cell: ({ cell }: any) => <>{cell.row.values.value.toFixed(2)} $</>,
Header: 'USD',
accessor: 'value',
disableFilters: true,
disableSortBy: true,
},
{
Cell: ({ cell }: any) => <> {cell.row.values.price} $</>,
Header: 'Price',
accessor: 'price',
disableFilters: true,
},
{
Cell: ({ cell }: any) => {
const balance = cell.row.original as Balance
return (
<div className="flex justify-center">
{balance.tokenName && balance.amount && balance.amount > 0 && Object.values(Ticker).includes(balance.tokenName.toUpperCase() as Ticker) && (
<button
className="btn btn-xs btn-outline btn-info"
onClick={() => handleSwapClick(balance)}
title={`Swap ${balance.tokenName}`}
>
<FiRefreshCw className="h-3 w-3" />
<span className="ml-1">Swap</span>
</button>
)}
</div>
)
},
Header: 'Actions',
accessor: 'actions',
disableFilters: true,
disableSortBy: true,
},
]
return ( return (
<> <>
<Table <Table
@@ -56,6 +121,16 @@ const AccountRowDetails: React.FC<IAccountRowDetail> = ({
showTotal={showTotal} showTotal={showTotal}
showPagination={false} showPagination={false}
/> />
{swapModalState.isOpen && swapModalState.fromTicker && (
<SwapModal
isOpen={swapModalState.isOpen}
onClose={closeSwapModal}
account={account}
fromTicker={swapModalState.fromTicker}
availableAmount={swapModalState.availableAmount}
/>
)}
</> </>
) )
} }

View File

@@ -196,6 +196,7 @@ const AccountTable: React.FC<IAccountList> = ({ list, isFetching }) => {
<AccountRowDetails <AccountRowDetails
balances={balances} balances={balances}
showTotal={true} showTotal={true}
account={row.original}
></AccountRowDetails> ></AccountRowDetails>
) : ( ) : (
<div>No balances</div> <div>No balances</div>

View File

@@ -0,0 +1,161 @@
# API Error Handling Utilities
This module provides utilities for handling API errors consistently across the application.
## Functions
### `extractErrorMessage(err: unknown): string`
Extracts meaningful error messages from API exceptions and other errors.
**Parameters:**
- `err` - The error object caught from API calls
**Returns:**
- A user-friendly error message string
**Usage:**
```typescript
import { extractErrorMessage } from '../utils/apiErrorHandler'
try {
const result = await apiCall()
} catch (err) {
const errorMessage = extractErrorMessage(err)
console.log(errorMessage) // "Insufficient balance for swap"
}
```
### `handleApiError(err: unknown)`
Handles API errors and returns a standardized error object with additional context.
**Parameters:**
- `err` - The error object caught from API calls
**Returns:**
```typescript
{
message: string
isApiException: boolean
status?: number
response?: string
}
```
**Usage:**
```typescript
import { handleApiError } from '../utils/apiErrorHandler'
try {
const result = await apiCall()
} catch (err) {
const errorInfo = handleApiError(err)
console.log(errorInfo.message) // "Insufficient balance for swap"
console.log(errorInfo.status) // 400
console.log(errorInfo.isApiException) // true
}
```
## Custom Hook
### `useApiError()`
A React hook that provides error handling utilities for API calls.
**Returns:**
```typescript
{
error: string | null
setError: (error: string | null) => void
clearError: () => void
handleError: (err: unknown) => string
handleApiErrorWithToast: (err: unknown, toast: any) => void
}
```
**Usage:**
```typescript
import { useApiError } from '../hooks/useApiError'
function MyComponent() {
const { error, setError, clearError, handleError, handleApiErrorWithToast } = useApiError()
const handleApiCall = async () => {
try {
const result = await apiCall()
clearError() // Clear any previous errors
} catch (err) {
// Option 1: Simple error handling
const errorMessage = handleError(err)
// Option 2: Error handling with toast
handleApiErrorWithToast(err, toast)
}
}
return (
<div>
{error && <div className="alert alert-error">{error}</div>}
<button onClick={handleApiCall}>Make API Call</button>
</div>
)
}
```
## Error Types Handled
1. **ApiException** - Generated client exceptions with server response details
2. **Error** - Standard JavaScript Error objects
3. **Unknown** - Any other error types
## Response Parsing
The utilities attempt to parse JSON responses in this order:
1. `message` - Standard error message field
2. `error` - Alternative error field
3. `detail` - ASP.NET Core validation error field
4. Raw response text if JSON parsing fails
5. Exception message as final fallback
## Examples
### Basic Usage
```typescript
try {
const result = await client.someApiCall()
} catch (err) {
const errorMessage = extractErrorMessage(err)
setError(errorMessage)
}
```
### With Toast Notifications
```typescript
const { handleApiErrorWithToast } = useApiError()
try {
const result = await client.someApiCall()
toast.update('success', 'Operation successful!')
} catch (err) {
handleApiErrorWithToast(err, toast)
}
```
### With Custom Error Handling
```typescript
const { handleError } = useApiError()
try {
const result = await client.someApiCall()
} catch (err) {
const errorMessage = handleError(err)
// Custom logic based on error type
if (errorMessage.includes('insufficient')) {
showBalanceWarning()
} else if (errorMessage.includes('network')) {
showRetryButton()
}
}
```

View File

@@ -0,0 +1,50 @@
/**
* Extracts meaningful error messages from API exceptions and other errors
* @param err - The error object caught from API calls
* @returns A user-friendly error message
*/
export function extractErrorMessage(err: unknown): string {
// Handle ApiException specifically to extract the actual error message
if (err && typeof err === 'object' && 'isApiException' in err) {
const apiException = err as any
try {
// Try to parse the response as JSON to get the actual error message
const responseData = JSON.parse(apiException.response)
return responseData.message || responseData.error || responseData.detail || apiException.message
} catch {
// If parsing fails, use the response text directly
return apiException.response || apiException.message
}
} else if (err instanceof Error) {
return err.message
}
return 'An unknown error occurred'
}
/**
* Handles API errors and returns a standardized error object
* @param err - The error object caught from API calls
* @returns An object containing the error message and additional context
*/
export function handleApiError(err: unknown): {
message: string
isApiException: boolean
status?: number
response?: string
} {
if (err && typeof err === 'object' && 'isApiException' in err) {
const apiException = err as any
return {
message: extractErrorMessage(err),
isApiException: true,
status: apiException.status,
response: apiException.response
}
}
return {
message: extractErrorMessage(err),
isApiException: false
}
}