diff --git a/.cursor/rules/fullstack.mdc b/.cursor/rules/fullstack.mdc index e080172..806d53c 100644 --- a/.cursor/rules/fullstack.mdc +++ b/.cursor/rules/fullstack.mdc @@ -89,7 +89,6 @@ Key Principles - Place static content and interfaces at file end. - Use content variables for static content outside render functions. - Minimize 'use client', 'useEffect', and 'setState'. Favor RSC. - - Use Zod for form validation. - Wrap client components in Suspense with fallback. - Use dynamic loading for non-critical components. - 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 - 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 + - 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. diff --git a/src/Managing.Api/Controllers/AccountController.cs b/src/Managing.Api/Controllers/AccountController.cs index 97744c6..be94f26 100644 --- a/src/Managing.Api/Controllers/AccountController.cs +++ b/src/Managing.Api/Controllers/AccountController.cs @@ -1,4 +1,5 @@ -using Managing.Application.Abstractions.Services; +using Managing.Api.Models.Requests; +using Managing.Application.Abstractions.Services; using Managing.Domain.Accounts; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -89,6 +90,30 @@ namespace Managing.Api.Controllers return Ok(result); } + /// + /// Swaps tokens on GMX for a specific account. + /// + /// The name of the account to perform the swap for. + /// The swap request containing ticker symbols, amount, and order parameters. + /// The swap response with transaction details. + [HttpPost] + [Route("{name}/gmx-swap")] + public async Task> 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); + } + /// /// Deletes a specific account by name for the authenticated user. /// diff --git a/src/Managing.Api/Models/Requests/SwapTokensRequest.cs b/src/Managing.Api/Models/Requests/SwapTokensRequest.cs new file mode 100644 index 0000000..20036f7 --- /dev/null +++ b/src/Managing.Api/Models/Requests/SwapTokensRequest.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations; +using static Managing.Common.Enums; + +namespace Managing.Api.Models.Requests; + +/// +/// Request model for GMX token swap operations +/// +public class SwapTokensRequest +{ + /// + /// The ticker symbol of the token to swap from + /// + [Required] + public Ticker FromTicker { get; set; } + + /// + /// The ticker symbol of the token to swap to + /// + [Required] + public Ticker ToTicker { get; set; } + + /// + /// The amount to swap + /// + [Required] + [Range(0.000001, double.MaxValue, ErrorMessage = "Amount must be greater than 0")] + public double Amount { get; set; } + + /// + /// The order type (market or limit) + /// + public string OrderType { get; set; } = "market"; + + /// + /// The trigger ratio for limit orders (optional) + /// + public double? TriggerRatio { get; set; } + + /// + /// The allowed slippage percentage (default 0.5%) + /// + [Range(0, 100, ErrorMessage = "Allowed slippage must be between 0 and 100")] + public double AllowedSlippage { get; set; } = 0.5; +} \ No newline at end of file diff --git a/src/Managing.Application.Abstractions/Services/IAccountService.cs b/src/Managing.Application.Abstractions/Services/IAccountService.cs index e26bf67..e71a083 100644 --- a/src/Managing.Application.Abstractions/Services/IAccountService.cs +++ b/src/Managing.Application.Abstractions/Services/IAccountService.cs @@ -1,5 +1,6 @@ using Managing.Domain.Accounts; using Managing.Domain.Users; +using static Managing.Common.Enums; namespace Managing.Application.Abstractions.Services; @@ -14,4 +15,7 @@ public interface IAccountService Task GetAccountByKey(string key, bool hideSecrets, bool getBalance); IEnumerable GetAccountsBalancesByUser(User user, bool hideSecrets = true); Task GetGmxClaimableSummaryAsync(User user, string accountName); + + Task SwapGmxTokensAsync(User user, string accountName, Ticker fromTicker, Ticker toTicker, + double amount, string orderType = "market", double? triggerRatio = null, double allowedSlippage = 0.5); } \ No newline at end of file diff --git a/src/Managing.Application.Abstractions/Services/IWeb3ProxyService.cs b/src/Managing.Application.Abstractions/Services/IWeb3ProxyService.cs index 9285792..cbfe73e 100644 --- a/src/Managing.Application.Abstractions/Services/IWeb3ProxyService.cs +++ b/src/Managing.Application.Abstractions/Services/IWeb3ProxyService.cs @@ -1,4 +1,5 @@ using Managing.Domain.Accounts; +using static Managing.Common.Enums; namespace Managing.Application.Abstractions.Services { @@ -9,5 +10,8 @@ namespace Managing.Application.Abstractions.Services Task CallGmxServiceAsync(string endpoint, object payload); Task GetGmxServiceAsync(string endpoint, object payload = null); Task GetGmxClaimableSummaryAsync(string account); + + Task SwapGmxTokensAsync(string account, Ticker fromTicker, Ticker toTicker, double amount, + string orderType = "market", double? triggerRatio = null, double allowedSlippage = 0.5); } } \ No newline at end of file diff --git a/src/Managing.Application/Accounts/AccountService.cs b/src/Managing.Application/Accounts/AccountService.cs index 0ec9216..cb9416f 100644 --- a/src/Managing.Application/Accounts/AccountService.cs +++ b/src/Managing.Application/Accounts/AccountService.cs @@ -1,9 +1,9 @@ using Managing.Application.Abstractions.Repositories; using Managing.Application.Abstractions.Services; -using Managing.Common; using Managing.Domain.Accounts; using Managing.Domain.Users; using Microsoft.Extensions.Logging; +using static Managing.Common.Enums; namespace Managing.Application.Accounts; @@ -47,15 +47,15 @@ public class AccountService : IAccountService { request.User = user; - if (request.Exchange == Enums.TradingExchanges.Evm - && request.Type == Enums.AccountType.Trader) + if (request.Exchange == TradingExchanges.Evm + && request.Type == AccountType.Trader) { var keys = _evmManager.GenerateAddress(); request.Key = keys.Key; request.Secret = keys.Secret; } - else if (request.Exchange == Enums.TradingExchanges.Evm - && request.Type == Enums.AccountType.Privy) + else if (request.Exchange == TradingExchanges.Evm + && request.Type == AccountType.Privy) { if (string.IsNullOrEmpty(request.Key)) { @@ -200,6 +200,45 @@ public class AccountService : IAccountService } } + public async Task 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) { if (account != null) diff --git a/src/Managing.Domain/Accounts/SwapInfos.cs b/src/Managing.Domain/Accounts/SwapInfos.cs new file mode 100644 index 0000000..4f8e268 --- /dev/null +++ b/src/Managing.Domain/Accounts/SwapInfos.cs @@ -0,0 +1,37 @@ +namespace Managing.Domain.Accounts; + +/// +/// Domain model for swap operation information +/// +public class SwapInfos +{ + /// + /// Whether the swap operation was successful + /// + public bool Success { get; set; } + + /// + /// Transaction hash if successful + /// + public string Hash { get; set; } + + /// + /// Success message + /// + public string Message { get; set; } + + /// + /// Error message if failed + /// + public string Error { get; set; } + + /// + /// Error type if failed + /// + public string ErrorType { get; set; } + + /// + /// Suggestion for error resolution + /// + public string Suggestion { get; set; } +} \ No newline at end of file diff --git a/src/Managing.Infrastructure.Web3/Models/Proxy/GmxSwapResponse.cs b/src/Managing.Infrastructure.Web3/Models/Proxy/GmxSwapResponse.cs new file mode 100644 index 0000000..c0a9c11 --- /dev/null +++ b/src/Managing.Infrastructure.Web3/Models/Proxy/GmxSwapResponse.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace Managing.Infrastructure.Evm.Models.Proxy; + +/// +/// Response model for GMX swap operations +/// +public class GmxSwapResponse : Web3ProxyBaseResponse +{ + /// + /// Transaction hash if successful + /// + [JsonProperty("hash")] + [JsonPropertyName("hash")] + public string Hash { get; set; } + + /// + /// Success message + /// + [JsonProperty("message")] + [JsonPropertyName("message")] + public string Message { get; set; } + + /// + /// Error type if failed + /// + [JsonProperty("errorType")] + [JsonPropertyName("errorType")] + public string ErrorType { get; set; } + + /// + /// Suggestion for error resolution + /// + [JsonProperty("suggestion")] + [JsonPropertyName("suggestion")] + public string Suggestion { get; set; } +} \ No newline at end of file diff --git a/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs b/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs index f90028d..48dbfee 100644 --- a/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs +++ b/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs @@ -6,6 +6,7 @@ using Managing.Application.Abstractions.Services; using Managing.Domain.Accounts; using Managing.Infrastructure.Evm.Models.Proxy; using Microsoft.Extensions.Options; +using static Managing.Common.Enums; namespace Managing.Infrastructure.Evm.Services { @@ -180,6 +181,38 @@ namespace Managing.Infrastructure.Evm.Services }; } + public async Task 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("/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) { var statusCode = (int)response.StatusCode; diff --git a/src/Managing.Web3Proxy/src/plugins/custom/gmx.ts b/src/Managing.Web3Proxy/src/plugins/custom/gmx.ts index 4cc6377..409fc7a 100644 --- a/src/Managing.Web3Proxy/src/plugins/custom/gmx.ts +++ b/src/Managing.Web3Proxy/src/plugins/custom/gmx.ts @@ -113,6 +113,7 @@ declare module 'fastify' { closeGmxPosition: typeof closeGmxPosition; getGmxTrade: typeof getGmxTrade; getGmxPositions: typeof getGmxPositions; + swapGmxTokens: typeof swapGmxTokens; } } diff --git a/src/Managing.Web3Proxy/src/routes/api/gmx/index.ts b/src/Managing.Web3Proxy/src/routes/api/gmx/index.ts index 7ad066f..0cff837 100644 --- a/src/Managing.Web3Proxy/src/routes/api/gmx/index.ts +++ b/src/Managing.Web3Proxy/src/routes/api/gmx/index.ts @@ -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 fastify.get('/trades', { schema: { diff --git a/src/Managing.WebApp/src/components/mollecules/Modal/Modal.tsx b/src/Managing.WebApp/src/components/mollecules/Modal/Modal.tsx index e1e20b0..ca9932f 100644 --- a/src/Managing.WebApp/src/components/mollecules/Modal/Modal.tsx +++ b/src/Managing.WebApp/src/components/mollecules/Modal/Modal.tsx @@ -14,7 +14,21 @@ const Modal: React.FC = ({ return ( {showModal ? ( - + onSubmit ? ( + + + + + {children} + + + + ) : ( = ({ {children} - + ) ) : null} ) diff --git a/src/Managing.WebApp/src/generated/ManagingApi.ts b/src/Managing.WebApp/src/generated/ManagingApi.ts index 90977d2..37aa499 100644 --- a/src/Managing.WebApp/src/generated/ManagingApi.ts +++ b/src/Managing.WebApp/src/generated/ManagingApi.ts @@ -247,6 +247,49 @@ export class AccountClient extends AuthorizedApiBase { } return Promise.resolve(null as any); } + + account_SwapGmxTokens(name: string, request: SwapTokensRequest): Promise { + 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 { + 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(null as any); + } } export class BacktestClient extends AuthorizedApiBase { @@ -2871,68 +2914,22 @@ export interface RebateStatsData { discountFactor?: number; } -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 SwapInfos { + success?: boolean; + hash?: string | null; + message?: string | null; + error?: string | null; + errorType?: string | null; + suggestion?: string | null; } -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 SwapTokensRequest { + fromTicker: Ticker; + toTicker: Ticker; + amount: number; + orderType?: string | null; + triggerRatio?: number | null; + allowedSlippage?: number; } export enum Ticker { @@ -3044,6 +3041,70 @@ export enum Ticker { 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 { adverseProbabilityThreshold: number; favorableProbabilityThreshold: number; diff --git a/src/Managing.WebApp/src/generated/ManagingApiTypes.ts b/src/Managing.WebApp/src/generated/ManagingApiTypes.ts index 7ecccf7..f22cac3 100644 --- a/src/Managing.WebApp/src/generated/ManagingApiTypes.ts +++ b/src/Managing.WebApp/src/generated/ManagingApiTypes.ts @@ -83,68 +83,22 @@ export interface RebateStatsData { discountFactor?: number; } -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 SwapInfos { + success?: boolean; + hash?: string | null; + message?: string | null; + error?: string | null; + errorType?: string | null; + suggestion?: string | null; } -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 SwapTokensRequest { + fromTicker: Ticker; + toTicker: Ticker; + amount: number; + orderType?: string | null; + triggerRatio?: number | null; + allowedSlippage?: number; } export enum Ticker { @@ -256,6 +210,70 @@ export enum Ticker { 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 { adverseProbabilityThreshold: number; favorableProbabilityThreshold: number; diff --git a/src/Managing.WebApp/src/global/type.tsx b/src/Managing.WebApp/src/global/type.tsx index 262d64d..5b94d08 100644 --- a/src/Managing.WebApp/src/global/type.tsx +++ b/src/Managing.WebApp/src/global/type.tsx @@ -299,6 +299,7 @@ export type ICardPositionFlipped = { export type IAccountRowDetail = { balances: Balance[] showTotal?: boolean + account?: Account } export type IGridTile = { diff --git a/src/Managing.WebApp/src/hooks/useApiError.ts b/src/Managing.WebApp/src/hooks/useApiError.ts new file mode 100644 index 0000000..f2b74c5 --- /dev/null +++ b/src/Managing.WebApp/src/hooks/useApiError.ts @@ -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(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 + } +} \ No newline at end of file diff --git a/src/Managing.WebApp/src/pages/settingsPage/account/SwapModal.tsx b/src/Managing.WebApp/src/pages/settingsPage/account/SwapModal.tsx new file mode 100644 index 0000000..97ad9a0 --- /dev/null +++ b/src/Managing.WebApp/src/pages/settingsPage/account/SwapModal.tsx @@ -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 = ({ + 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.USDC) + const [selectedOrderType, setSelectedOrderType] = useState('market') + + const { register, handleSubmit, watch, setValue } = useForm({ + 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) { + setSelectedToTicker(e.target.value as Ticker) + } + + function setSelectedOrderTypeEvent(e: React.ChangeEvent) { + setSelectedOrderType(e.target.value) + } + + const onSubmit: SubmitHandler = 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 ? ( + + + Processing swap... + + ) : error ? ( + + {error} + + ) : ( + <> + + + Account: {account.name} + + + From: {fromTicker} + + + + + + + + {Object.values(Ticker) + .filter(ticker => ticker !== fromTicker) // Exclude the from ticker + .map((ticker) => ( + + {ticker} + + ))} + + + + + + + + { + const value = parseFloat(e.target.value) + setValue('amount', value) + }} + /> + + + {watchedAmount && availableAmount > 0 ? ( + {((watchedAmount / availableAmount) * 100).toFixed(1)}% of available balance + ) : ( + 0% of available balance + )} + + + + + + + + Market + Limit + Stop + + + + + + + + {selectedOrderType === 'limit' && ( + + + + )} + + + {isLoading ? ( + + ) : ( + `Swap ${watchedAmount || 0} ${fromTicker} to ${selectedToTicker}` + )} + + + + + + + + Note: Ensure account has sufficient balance for the swap. + + + + > + )} + > + ) + + return ( + + {modalContent} + + ) +} + +export default SwapModal \ No newline at end of file diff --git a/src/Managing.WebApp/src/pages/settingsPage/account/accountRowDetails.tsx b/src/Managing.WebApp/src/pages/settingsPage/account/accountRowDetails.tsx index 47d8415..437de17 100644 --- a/src/Managing.WebApp/src/pages/settingsPage/account/accountRowDetails.tsx +++ b/src/Managing.WebApp/src/pages/settingsPage/account/accountRowDetails.tsx @@ -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 { IAccountRowDetail } from '../../../global/type' +import {SelectColumnFilter, Table} from '../../../components/mollecules' +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 = [ - { - Header: 'Chain', - accessor: 'chain.name', - disableFilters: true, - disableSortBy: true, - }, - { - Filter: SelectColumnFilter, - Header: 'Assets', - accessor: 'tokenName', - disableFilters: true, - disableSortBy: true, - }, - { - Cell: ({ cell }: any) => ( - <> - - {cell.row.values.amount.toFixed(4)} - - > - ), - 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, - }, -] +interface IAccountRowDetailProps extends IAccountRowDetail { + account: Account +} -const AccountRowDetails: React.FC = ({ +const AccountRowDetails: React.FC = ({ balances, 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) => ( + <> + + {cell.row.values.amount.toFixed(4)} + + > + ), + 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 ( + + {balance.tokenName && balance.amount && balance.amount > 0 && Object.values(Ticker).includes(balance.tokenName.toUpperCase() as Ticker) && ( + handleSwapClick(balance)} + title={`Swap ${balance.tokenName}`} + > + + Swap + + )} + + ) + }, + Header: 'Actions', + accessor: 'actions', + disableFilters: true, + disableSortBy: true, + }, + ] + return ( <> = ({ showTotal={showTotal} showPagination={false} /> + + {swapModalState.isOpen && swapModalState.fromTicker && ( + + )} > ) } diff --git a/src/Managing.WebApp/src/pages/settingsPage/account/accountTable.tsx b/src/Managing.WebApp/src/pages/settingsPage/account/accountTable.tsx index c57c9b3..5176ef1 100644 --- a/src/Managing.WebApp/src/pages/settingsPage/account/accountTable.tsx +++ b/src/Managing.WebApp/src/pages/settingsPage/account/accountTable.tsx @@ -196,6 +196,7 @@ const AccountTable: React.FC = ({ list, isFetching }) => { ) : ( No balances diff --git a/src/Managing.WebApp/src/utils/apiErrorHandler.md b/src/Managing.WebApp/src/utils/apiErrorHandler.md new file mode 100644 index 0000000..5544777 --- /dev/null +++ b/src/Managing.WebApp/src/utils/apiErrorHandler.md @@ -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 ( + + {error && {error}} + Make API Call + + ) +} +``` + +## 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() + } +} +``` \ No newline at end of file diff --git a/src/Managing.WebApp/src/utils/apiErrorHandler.ts b/src/Managing.WebApp/src/utils/apiErrorHandler.ts new file mode 100644 index 0000000..450d77a --- /dev/null +++ b/src/Managing.WebApp/src/utils/apiErrorHandler.ts @@ -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 + } +} \ No newline at end of file
Processing swap...
{error}
+ Account: {account.name} +
+ From: {fromTicker} +
+ Note: Ensure account has sufficient balance for the swap. +