Implement spot position history retrieval in SpotBot and related services

- Added CheckSpotPositionInExchangeHistory method to SpotBot for verifying closed positions against exchange history.
- Enhanced logging for Web3Proxy errors during position verification.
- Introduced GetSpotPositionHistory method in IEvmManager, IExchangeService, and IWeb3ProxyService interfaces.
- Implemented GetSpotPositionHistory in EvmManager and ExchangeService to fetch historical swap data.
- Updated GMX SDK integration to support fetching spot position history.
- Modified generated API types to include new trading type and position history structures.
This commit is contained in:
2025-12-07 19:20:47 +07:00
parent 15d8b38d8b
commit a2ed4edd32
17 changed files with 978 additions and 84 deletions

View File

@@ -94,6 +94,24 @@ public interface IEvmManager
int pageIndex = 0,
int pageSize = 20);
/// <summary>
/// Gets the spot position history (swap executions) for a specific ticker and account from GMX
/// </summary>
/// <param name="account">The trading account</param>
/// <param name="ticker">The ticker to get history for</param>
/// <param name="fromDate">Optional start date for filtering</param>
/// <param name="toDate">Optional end date for filtering</param>
/// <param name="pageIndex">Page index for pagination (default: 0)</param>
/// <param name="pageSize">Page size for pagination (default: 20)</param>
/// <returns>Spot position history response containing swap executions</returns>
Task<List<Position>> GetSpotPositionHistory(
Account account,
Ticker ticker,
DateTime? fromDate = null,
DateTime? toDate = null,
int pageIndex = 0,
int pageSize = 20);
/// <summary>
/// Gets the staked KUDAI balance for a specific address
/// </summary>

View File

@@ -73,4 +73,12 @@ public interface IExchangeService
Ticker ticker,
DateTime? fromDate = null,
DateTime? toDate = null);
Task<List<Position>> GetSpotPositionHistory(
Account account,
Ticker ticker,
DateTime? fromDate = null,
DateTime? toDate = null,
int pageIndex = 0,
int pageSize = 20);
}

View File

@@ -31,5 +31,13 @@ namespace Managing.Application.Abstractions.Services
string? ticker = null,
DateTime? fromDate = null,
DateTime? toDate = null);
Task<List<Position>> GetGmxSpotPositionHistoryAsync(
string account,
int pageIndex = 0,
int pageSize = 20,
string? ticker = null,
DateTime? fromDate = null,
DateTime? toDate = null);
}
}

View File

@@ -337,11 +337,31 @@ public class SpotBot : TradingBotBase, ITradingBot
// No token balance found - check if position was closed
if (internalPosition.Status == PositionStatus.Filled)
{
var (positionFoundInHistory, hadWeb3ProxyError) =
await CheckSpotPositionInExchangeHistory(internalPosition);
if (hadWeb3ProxyError)
{
await LogWarningAsync(
$"⏳ Web3Proxy Error During Spot Position Verification\n" +
$"Position: `{internalPosition.Identifier}`\n" +
$"Cannot verify if position is closed\n" +
$"Will retry on next execution cycle");
return;
}
if (positionFoundInHistory)
{
internalPosition.Status = PositionStatus.Finished;
await HandleClosedPosition(internalPosition);
return;
}
await LogDebugAsync(
$"⚠️ Position Status Check\n" +
$"Internal position `{internalPosition.Identifier}` shows Filled\n" +
$"But no token balance found on broker\n" +
$"Position may have been closed");
$"But no token balance found on broker or in history\n" +
$"Will retry verification on next cycle");
}
}
}
@@ -351,6 +371,56 @@ public class SpotBot : TradingBotBase, ITradingBot
}
}
private async Task<(bool found, bool hadError)> CheckSpotPositionInExchangeHistory(Position position)
{
try
{
await LogDebugAsync(
$"🔍 Checking Spot Position History for Position: `{position.Identifier}`\nTicker: `{Config.Ticker}`");
List<Position> positionHistory = null;
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
async exchangeService =>
{
var fromDate = DateTime.UtcNow.AddHours(-24);
var toDate = DateTime.UtcNow;
positionHistory =
await exchangeService.GetSpotPositionHistory(Account, Config.Ticker, fromDate, toDate);
});
if (positionHistory != null && positionHistory.Any())
{
var recentPosition = positionHistory
.OrderByDescending(p => p.Date)
.FirstOrDefault();
if (recentPosition != null)
{
await LogDebugAsync(
$"✅ Spot Position Found in History\n" +
$"Position: `{position.Identifier}`\n" +
$"Ticker: `{recentPosition.Ticker}`\n" +
$"Date: `{recentPosition.Date}`");
return (true, false);
}
}
await LogDebugAsync(
$"❌ No Spot Position Found in Exchange History\nPosition: `{position.Identifier}`\nPosition may still be open or data is delayed");
return (false, false);
}
catch (Exception ex)
{
Logger.LogError(ex, "Error checking spot position history for position {PositionId}", position.Identifier);
await LogWarningAsync(
$"⚠️ Web3Proxy Error During Spot Position History Check\n" +
$"Position: `{position.Identifier}`\n" +
$"Error: {ex.Message}\n" +
$"Will retry on next execution cycle");
return (false, true);
}
}
protected override async Task HandleOrderManagementAndPositionStatus(LightSignal signal, Position internalPosition,
Position positionForSignal)
{

View File

@@ -464,6 +464,7 @@ public class BundleBacktestHealthCheckWorker : BackgroundService
// Some jobs are still pending or running - bundle is genuinely stuck
// Reset any stale running jobs back to pending
var runningJobs = jobs.Where(j => j.Status == JobStatus.Running).ToList();
var resetJobCount = 0;
foreach (var job in runningJobs)
{
@@ -481,13 +482,14 @@ public class BundleBacktestHealthCheckWorker : BackgroundService
job.AssignedWorkerId = null;
job.LastHeartbeat = null;
await jobRepository.UpdateAsync(job);
resetJobCount++;
}
}
// Update bundle timestamp to give it another chance
bundle.UpdatedAt = DateTime.UtcNow;
bundle.ErrorMessage =
$"Bundle was stuck. Reset {runningJobs.Count(j => j.Status == JobStatus.Pending)} stale jobs to pending.";
$"Bundle was stuck. Reset {resetJobCount} stale jobs to pending.";
}
await backtestRepository.UpdateBundleBacktestRequestAsync(bundle);

View File

@@ -47,4 +47,12 @@ public interface IExchangeProcessor
Ticker ticker,
DateTime? fromDate = null,
DateTime? toDate = null);
Task<List<Position>> GetSpotPositionHistory(
Account account,
Ticker ticker,
DateTime? fromDate = null,
DateTime? toDate = null,
int pageIndex = 0,
int pageSize = 20);
}

View File

@@ -193,6 +193,18 @@ namespace Managing.Infrastructure.Exchanges
return processor.GetPositionHistory(account, ticker, fromDate, toDate);
}
public Task<List<Position>> GetSpotPositionHistory(
Account account,
Ticker ticker,
DateTime? fromDate = null,
DateTime? toDate = null,
int pageIndex = 0,
int pageSize = 20)
{
var processor = GetProcessor(account);
return processor.GetSpotPositionHistory(account, ticker, fromDate, toDate, pageIndex, pageSize);
}
public async Task<List<Trade>> GetTrades(Account account, Ticker ticker)
{
var processor = GetProcessor(account);

View File

@@ -48,5 +48,13 @@ namespace Managing.Infrastructure.Exchanges.Exchanges
Ticker ticker,
DateTime? fromDate = null,
DateTime? toDate = null);
public abstract Task<List<Position>> GetSpotPositionHistory(
Account account,
Ticker ticker,
DateTime? fromDate = null,
DateTime? toDate = null,
int pageIndex = 0,
int pageSize = 20);
}
}

View File

@@ -217,6 +217,17 @@ public class EvmProcessor : BaseProcessor
return await _evmManager.GetPositionHistory(account, ticker, fromDate, toDate);
}
public override async Task<List<Position>> GetSpotPositionHistory(
Account account,
Ticker ticker,
DateTime? fromDate = null,
DateTime? toDate = null,
int pageIndex = 0,
int pageSize = 20)
{
return await _evmManager.GetSpotPositionHistory(account, ticker, fromDate, toDate, pageIndex, pageSize);
}
#region Not implemented
public override void LoadClient(Account account)

View File

@@ -1002,6 +1002,25 @@ public class EvmManager : IEvmManager
return result;
}
public async Task<List<Position>> GetSpotPositionHistory(
Account account,
Ticker ticker,
DateTime? fromDate = null,
DateTime? toDate = null,
int pageIndex = 0,
int pageSize = 20)
{
var result = await _web3ProxyService.GetGmxSpotPositionHistoryAsync(
account.Key,
pageIndex,
pageSize,
ticker.ToString(),
fromDate,
toDate);
return result;
}
public async Task<decimal> GetKudaiStakedBalance(string address)
{
try

View File

@@ -697,5 +697,98 @@ namespace Managing.Infrastructure.Evm.Services
return positions;
}
public async Task<List<Position>> GetGmxSpotPositionHistoryAsync(
string account,
int pageIndex = 0,
int pageSize = 20,
string? ticker = null,
DateTime? fromDate = null,
DateTime? toDate = null)
{
var payload = new
{
account,
pageIndex,
pageSize,
ticker,
fromDateTime = fromDate?.ToString("O"),
toDateTime = toDate?.ToString("O")
};
var response = await GetGmxServiceAsync<GetGmxPositionHistoryResponse>("/spot-position-history", payload);
if (response == null)
{
throw new Web3ProxyException("Spot position history response is null");
}
if (!response.Success)
{
throw new Web3ProxyException($"Spot position history request failed: {response.Error}");
}
var positions = new List<Position>();
if (response.Positions != null)
{
foreach (var g in response.Positions)
{
try
{
var tickerEnum = MiscExtensions.ParseEnum<Ticker>(g.Ticker);
var directionEnum = MiscExtensions.ParseEnum<TradeDirection>(g.Direction);
var position = new Position(
Guid.NewGuid(),
0,
directionEnum,
tickerEnum,
null,
PositionInitiator.Bot,
g.Date,
null
)
{
Status = MiscExtensions.ParseEnum<PositionStatus>(g.Status)
};
if (g.Open != null)
{
position.Open = new Trade(
g.Open.Date,
MiscExtensions.ParseEnum<TradeDirection>(g.Open.Direction),
MiscExtensions.ParseEnum<TradeStatus>(g.Open.Status),
TradeType.Market,
tickerEnum,
(decimal)g.Open.Quantity,
(decimal)g.Open.Price,
(decimal)g.Open.Leverage,
g.Open.ExchangeOrderId,
string.Empty
);
}
position.ProfitAndLoss = new ProfitAndLoss
{
Realized = g.ProfitAndLoss?.Realized != null
? (decimal)g.ProfitAndLoss.Realized
: (decimal)g.Pnl,
Net = g.ProfitAndLoss?.Net != null ? (decimal)g.ProfitAndLoss.Net : (decimal)g.Pnl
};
position.UiFees = g.UiFees.HasValue ? (decimal)g.UiFees.Value : 0;
position.GasFees = g.GasFees.HasValue ? (decimal)g.GasFees.Value : 0;
positions.Add(position);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to map GMX spot position history entry");
}
}
}
return positions;
}
}
}

View File

@@ -1,4 +1,4 @@
// See https://aka.ms/new-console-template for more information
// See https://aka.ms/new-console-template for more information
using NJsonSchema.CodeGeneration.TypeScript;
using NSwag;
@@ -23,8 +23,11 @@ for (int i = 0; i < 10; i++)
solutionDirectory = parent.FullName;
}
var targetDirectory = Path.Combine(solutionDirectory, "src", "Managing.WebApp", "src", "generated");
Directory.CreateDirectory(targetDirectory); // Ensure the directory exists
var targetWebAppDirectory = Path.Combine(solutionDirectory, "src", "Managing.WebApp", "src", "generated");
Directory.CreateDirectory(targetWebAppDirectory); // Ensure the directory exists
var targetWeb3ProxyDirectory = Path.Combine(solutionDirectory, "src", "Managing.Web3Proxy", "src", "generated");
Directory.CreateDirectory(targetWeb3ProxyDirectory);
var settings = new TypeScriptClientGeneratorSettings
{
@@ -69,7 +72,7 @@ if (autoGeneratedEndIndex != -1)
}
}
File.WriteAllText(Path.Combine(targetDirectory, "ManagingApi.ts"), codeApiClient);
File.WriteAllText(Path.Combine(targetWebAppDirectory, "ManagingApi.ts"), codeApiClient);
var settingsTypes = new TypeScriptClientGeneratorSettings
{
@@ -92,4 +95,6 @@ var settingsTypes = new TypeScriptClientGeneratorSettings
var generatorTypes = new TypeScriptClientGenerator(document, settingsTypes);
var codeTypes = generatorTypes.GenerateFile();
File.WriteAllText(Path.Combine(targetDirectory, "ManagingApiTypes.ts"), codeTypes);
File.WriteAllText(Path.Combine(targetWebAppDirectory, "ManagingApiTypes.ts"), codeTypes);
File.WriteAllText(Path.Combine(targetWeb3ProxyDirectory, "ManagingApiTypes.ts"), codeTypes);

View File

@@ -11,6 +11,7 @@
export interface Account {
id?: number;
name: string;
exchange: TradingExchanges;
type: AccountType;
@@ -45,6 +46,9 @@ export interface User {
agentName?: string | null;
avatarUrl?: string | null;
telegramChannel?: string | null;
ownerWalletAddress?: string | null;
isAdmin?: boolean;
lastConnectionDate?: Date | null;
}
export interface Balance {
@@ -219,9 +223,100 @@ export interface SendTokenRequest {
chainId?: number | null;
}
export interface ExchangeApprovalStatus {
export interface ExchangeInitializedStatus {
exchange?: TradingExchanges;
isApproved?: boolean;
isInitialized?: boolean;
}
export interface PaginatedBundleBacktestRequestsResponse {
bundleRequests?: BundleBacktestRequestListItemResponse[];
totalCount?: number;
currentPage?: number;
pageSize?: number;
totalPages?: number;
hasNextPage?: boolean;
hasPreviousPage?: boolean;
}
export interface BundleBacktestRequestListItemResponse {
requestId?: string;
name?: string;
version?: number;
status?: string;
createdAt?: Date;
completedAt?: Date | null;
updatedAt?: Date;
totalBacktests?: number;
completedBacktests?: number;
failedBacktests?: number;
progressPercentage?: number;
userId?: number | null;
userName?: string | null;
errorMessage?: string | null;
currentBacktest?: string | null;
estimatedTimeRemainingSeconds?: number | null;
}
export enum BundleBacktestRequestSortableColumn {
RequestId = "RequestId",
Name = "Name",
Status = "Status",
CreatedAt = "CreatedAt",
CompletedAt = "CompletedAt",
TotalBacktests = "TotalBacktests",
CompletedBacktests = "CompletedBacktests",
FailedBacktests = "FailedBacktests",
ProgressPercentage = "ProgressPercentage",
UserId = "UserId",
UserName = "UserName",
UpdatedAt = "UpdatedAt",
}
export enum BundleBacktestRequestStatus {
Pending = "Pending",
Running = "Running",
Completed = "Completed",
Failed = "Failed",
Cancelled = "Cancelled",
Saved = "Saved",
}
export interface BundleBacktestRequestSummaryResponse {
statusSummary?: BundleBacktestRequestStatusSummary[];
totalRequests?: number;
}
export interface BundleBacktestRequestStatusSummary {
status?: string;
count?: number;
}
export interface PaginatedUsersResponse {
users?: UserListItemResponse[];
totalCount?: number;
currentPage?: number;
pageSize?: number;
totalPages?: number;
hasNextPage?: boolean;
hasPreviousPage?: boolean;
}
export interface UserListItemResponse {
id?: number;
name?: string;
agentName?: string;
avatarUrl?: string;
telegramChannel?: string;
ownerWalletAddress?: string;
isAdmin?: boolean;
lastConnectionDate?: Date | null;
}
export enum UserSortableColumn {
Id = "Id",
Name = "Name",
OwnerWalletAddress = "OwnerWalletAddress",
AgentName = "AgentName",
}
export interface Backtest {
@@ -233,17 +328,18 @@ export interface Backtest {
config: TradingBotConfig;
positions: { [key: string]: Position; };
signals: { [key: string]: LightSignal; };
candles: Candle[];
startDate: Date;
endDate: Date;
statistics: PerformanceMetrics;
fees: number;
walletBalances: KeyValuePairOfDateTimeAndDecimal[];
user: User;
score: number;
requestId?: string;
metadata?: any | null;
scoreMessage?: string;
initialBalance: number;
netPnl: number;
positionCount: number;
}
export interface TradingBotConfig {
@@ -253,7 +349,7 @@ export interface TradingBotConfig {
timeframe: Timeframe;
isForWatchingOnly: boolean;
botTradingBalance: number;
isForBacktest: boolean;
tradingType: TradingType;
cooldownPeriod: number;
maxLossStreak: number;
flipPosition: boolean;
@@ -268,6 +364,9 @@ export interface TradingBotConfig {
useForPositionSizing?: boolean;
useForSignalFiltering?: boolean;
useForDynamicStopLoss?: boolean;
isForCopyTrading?: boolean;
masterBotIdentifier?: string | null;
masterBotUserId?: number | null;
}
export interface LightMoneyManagement {
@@ -288,6 +387,13 @@ export enum Timeframe {
OneMinute = "OneMinute",
}
export enum TradingType {
Futures = "Futures",
BacktestFutures = "BacktestFutures",
BacktestSpot = "BacktestSpot",
Spot = "Spot",
}
export interface RiskManagement {
adverseProbabilityThreshold: number;
favorableProbabilityThreshold: number;
@@ -327,9 +433,18 @@ export interface LightIndicator {
slowPeriods?: number | null;
signalPeriods?: number | null;
multiplier?: number | null;
stDev?: number | null;
smoothPeriods?: number | null;
stochPeriods?: number | null;
cyclePeriods?: number | null;
kFactor?: number | null;
dFactor?: number | null;
tenkanPeriods?: number | null;
kijunPeriods?: number | null;
senkouBPeriods?: number | null;
offsetPeriods?: number | null;
senkouOffset?: number | null;
chikouOffset?: number | null;
}
export enum IndicatorType {
@@ -343,11 +458,15 @@ export enum IndicatorType {
EmaTrend = "EmaTrend",
Composite = "Composite",
StochRsiTrend = "StochRsiTrend",
StochasticCross = "StochasticCross",
Stc = "Stc",
StDev = "StDev",
LaggingStc = "LaggingStc",
SuperTrendCrossEma = "SuperTrendCrossEma",
DualEmaCross = "DualEmaCross",
BollingerBandsPercentBMomentumBreakout = "BollingerBandsPercentBMomentumBreakout",
BollingerBandsVolatilityProtection = "BollingerBandsVolatilityProtection",
IchimokuKumoTrend = "IchimokuKumoTrend",
}
export enum SignalType {
@@ -357,8 +476,8 @@ export enum SignalType {
}
export interface Position {
accountName: string;
date: Date;
accountId: number;
originDirection: TradeDirection;
ticker: Ticker;
moneyManagement: LightMoneyManagement;
@@ -367,12 +486,16 @@ export interface Position {
TakeProfit1: Trade;
TakeProfit2?: Trade | null;
ProfitAndLoss?: ProfitAndLoss | null;
uiFees?: number;
gasFees?: number;
status: PositionStatus;
signalIdentifier?: string | null;
identifier: string;
initiator: PositionInitiator;
user: User;
initiatorIdentifier: string;
recoveryAttempted?: boolean;
tradingType?: TradingType;
}
export enum TradeDirection {
@@ -430,7 +553,6 @@ export enum PositionStatus {
Canceled = "Canceled",
Rejected = "Rejected",
Updating = "Updating",
PartiallyFilled = "PartiallyFilled",
Filled = "Filled",
Flipped = "Flipped",
Finished = "Finished",
@@ -495,25 +617,10 @@ export interface PerformanceMetrics {
totalPnL?: number;
}
export interface KeyValuePairOfDateTimeAndDecimal {
key?: Date;
value?: number;
}
export interface DeleteBacktestsRequest {
backtestIds: string[];
}
export interface PaginatedBacktestsResponse {
backtests?: LightBacktestResponse[] | null;
totalCount?: number;
currentPage?: number;
pageSize?: number;
totalPages?: number;
hasNextPage?: boolean;
hasPreviousPage?: boolean;
}
export interface LightBacktestResponse {
id: string;
config: TradingBotConfig;
@@ -528,6 +635,36 @@ export interface LightBacktestResponse {
sharpeRatio: number;
score: number;
scoreMessage: string;
initialBalance: number;
netPnl: number;
positionCount: number;
}
export interface PaginatedBacktestsResponse {
backtests?: LightBacktestResponse[] | null;
totalCount?: number;
currentPage?: number;
pageSize?: number;
totalPages?: number;
hasNextPage?: boolean;
hasPreviousPage?: boolean;
}
export enum BacktestSortableColumn {
Score = "Score",
FinalPnl = "FinalPnl",
NetPnl = "NetPnl",
WinRate = "WinRate",
GrowthPercentage = "GrowthPercentage",
HodlPercentage = "HodlPercentage",
Duration = "Duration",
Timeframe = "Timeframe",
IndicatorsCount = "IndicatorsCount",
MaxDrawdown = "MaxDrawdown",
Fees = "Fees",
SharpeRatio = "SharpeRatio",
Ticker = "Ticker",
Name = "Name",
}
export interface LightBacktest {
@@ -544,18 +681,27 @@ export interface LightBacktest {
sharpeRatio?: number | null;
score?: number;
scoreMessage?: string | null;
metadata?: any | null;
ticker?: string | null;
initialBalance?: number;
netPnl?: number;
positionCount?: number;
}
export interface RunBacktestRequest {
config?: TradingBotConfigRequest | null;
startDate?: Date;
endDate?: Date;
balance?: number;
watchOnly?: boolean;
save?: boolean;
withCandles?: boolean;
moneyManagementName?: string | null;
moneyManagement?: MoneyManagement | null;
}
export interface TradingBotConfigRequest {
accountName: string;
accountName?: string | null;
ticker: Ticker;
timeframe: Timeframe;
isForWatchingOnly: boolean;
@@ -575,6 +721,7 @@ export interface TradingBotConfigRequest {
useForPositionSizing?: boolean;
useForSignalFiltering?: boolean;
useForDynamicStopLoss?: boolean;
tradingType?: TradingType;
}
export interface ScenarioRequest {
@@ -593,9 +740,18 @@ export interface IndicatorRequest {
slowPeriods?: number | null;
signalPeriods?: number | null;
multiplier?: number | null;
stDev?: number | null;
smoothPeriods?: number | null;
stochPeriods?: number | null;
cyclePeriods?: number | null;
kFactor?: number | null;
dFactor?: number | null;
tenkanPeriods?: number | null;
kijunPeriods?: number | null;
senkouBPeriods?: number | null;
offsetPeriods?: number | null;
senkouOffset?: number | null;
chikouOffset?: number | null;
}
export interface MoneyManagementRequest {
@@ -606,6 +762,10 @@ export interface MoneyManagementRequest {
leverage: number;
}
export interface MoneyManagement extends LightMoneyManagement {
user?: User | null;
}
export interface BundleBacktestRequest {
requestId: string;
user: User;
@@ -613,8 +773,76 @@ export interface BundleBacktestRequest {
completedAt?: Date | null;
status: BundleBacktestRequestStatus;
name: string;
backtestRequestsJson: string;
results?: string[] | null;
version: number;
universalConfigJson: string;
dateTimeRangesJson: string;
moneyManagementVariantsJson: string;
tickerVariantsJson: string;
results?: string[];
totalBacktests: number;
completedBacktests: number;
failedBacktests: number;
progressPercentage?: number;
errorMessage?: string | null;
progressInfo?: string | null;
currentBacktest?: string | null;
estimatedTimeRemainingSeconds?: number | null;
updatedAt: Date;
}
export interface RunBundleBacktestRequest {
name: string;
universalConfig: BundleBacktestUniversalConfig;
dateTimeRanges: DateTimeRange[];
moneyManagementVariants: MoneyManagementVariant[];
tickerVariants: Ticker[];
saveAsTemplate: boolean;
}
export interface BundleBacktestUniversalConfig {
timeframe: Timeframe;
isForWatchingOnly: boolean;
botTradingBalance: number;
botName: string;
tradingType: TradingType;
flipPosition: boolean;
cooldownPeriod?: number | null;
maxLossStreak?: number;
scenario?: ScenarioRequest | null;
scenarioName?: string | null;
maxPositionTimeHours?: number | null;
closeEarlyWhenProfitable?: boolean;
flipOnlyWhenInProfit?: boolean;
useSynthApi?: boolean;
useForPositionSizing?: boolean;
useForSignalFiltering?: boolean;
useForDynamicStopLoss?: boolean;
watchOnly?: boolean;
save?: boolean;
withCandles?: boolean;
}
export interface DateTimeRange {
startDate: Date;
endDate: Date;
}
export interface MoneyManagementVariant {
moneyManagement?: MoneyManagementRequest;
}
export interface BundleBacktestRequestViewModel {
requestId: string;
createdAt: Date;
completedAt?: Date | null;
status: BundleBacktestRequestStatus;
name: string;
version: number;
universalConfig: BundleBacktestUniversalConfig;
dateTimeRanges: DateTimeRange[];
moneyManagementVariants: MoneyManagementVariant[];
tickerVariants: Ticker[];
results?: string[];
totalBacktests: number;
completedBacktests: number;
failedBacktests: number;
@@ -625,17 +853,18 @@ export interface BundleBacktestRequest {
estimatedTimeRemainingSeconds?: number | null;
}
export enum BundleBacktestRequestStatus {
Pending = "Pending",
Running = "Running",
Completed = "Completed",
Failed = "Failed",
Cancelled = "Cancelled",
}
export interface RunBundleBacktestRequest {
name: string;
requests: RunBacktestRequest[];
export interface BundleBacktestStatusResponse {
bundleRequestId?: string;
status?: string | null;
totalJobs?: number;
completedJobs?: number;
failedJobs?: number;
runningJobs?: number;
pendingJobs?: number;
progressPercentage?: number;
createdAt?: Date;
completedAt?: Date | null;
errorMessage?: string | null;
}
export interface GeneticRequest {
@@ -726,14 +955,15 @@ export interface RunGeneticRequest {
eligibleIndicators?: IndicatorType[] | null;
}
export interface MoneyManagement extends LightMoneyManagement {
user?: User | null;
}
export interface StartBotRequest {
config?: TradingBotConfigRequest | null;
}
export interface StartCopyTradingRequest {
masterBotIdentifier?: string;
botTradingBalance?: number;
}
export interface SaveBotRequest extends StartBotRequest {
}
@@ -750,12 +980,14 @@ export interface TradingBotResponse {
candles: Candle[];
winRate: number;
profitAndLoss: number;
roi: number;
identifier: string;
agentName: string;
createDate: Date;
startupTime: Date;
name: string;
ticker: Ticker;
masterAgentName?: string | null;
}
export interface PaginatedResponseOfTradingBotResponse {
@@ -768,7 +1000,19 @@ export interface PaginatedResponseOfTradingBotResponse {
hasNextPage?: boolean;
}
export interface OpenPositionManuallyRequest {
export enum BotSortableColumn {
CreateDate = "CreateDate",
Name = "Name",
Ticker = "Ticker",
Status = "Status",
StartupTime = "StartupTime",
Roi = "Roi",
Pnl = "Pnl",
WinRate = "WinRate",
AgentName = "AgentName",
}
export interface CreateManualSignalRequest {
identifier?: string;
direction?: TradeDirection;
}
@@ -788,6 +1032,7 @@ export interface UpdateBotConfigRequest {
export interface TickerInfos {
ticker?: Ticker;
imageUrl?: string | null;
name?: string | null;
}
export interface SpotlightOverview {
@@ -819,9 +1064,18 @@ export interface IndicatorBase {
slowPeriods?: number | null;
signalPeriods?: number | null;
multiplier?: number | null;
stDev?: number | null;
smoothPeriods?: number | null;
stochPeriods?: number | null;
cyclePeriods?: number | null;
kFactor?: number | null;
dFactor?: number | null;
tenkanPeriods?: number | null;
kijunPeriods?: number | null;
senkouBPeriods?: number | null;
offsetPeriods?: number | null;
senkouOffset?: number | null;
chikouOffset?: number | null;
user?: User | null;
}
@@ -853,6 +1107,7 @@ export interface IndicatorsResultBase {
stdDev?: StdDevResult[] | null;
superTrend?: SuperTrendResult[] | null;
chandelierLong?: ChandelierResult[] | null;
ichimoku?: IchimokuResult[] | null;
}
export interface ResultBase {
@@ -929,6 +1184,14 @@ export interface SuperTrendResult extends ResultBase {
lowerBand?: number | null;
}
export interface IchimokuResult extends ResultBase {
tenkanSen?: number | null;
kijunSen?: number | null;
senkouSpanA?: number | null;
senkouSpanB?: number | null;
chikouSpan?: number | null;
}
export interface GetCandlesWithIndicatorsRequest {
ticker?: Ticker;
startDate?: Date;
@@ -949,6 +1212,7 @@ export interface TopStrategiesViewModel {
export interface StrategyPerformance {
strategyName?: string | null;
pnL?: number;
netPnL?: number;
agentName?: string | null;
}
@@ -960,65 +1224,75 @@ export interface StrategyRoiPerformance {
strategyName?: string | null;
roi?: number;
pnL?: number;
netPnL?: number;
volume?: number;
}
export interface TopAgentsByPnLViewModel {
topAgentsByPnL?: AgentPerformance[] | null;
}
export interface AgentPerformance {
agentName?: string | null;
pnL?: number;
totalROI?: number;
totalVolume?: number;
activeStrategiesCount?: number;
totalBalance?: number;
}
export interface UserStrategyDetailsViewModel {
name?: string | null;
state?: BotStatus;
pnL?: number;
netPnL?: number;
roiPercentage?: number;
roiLast24H?: number;
runtime?: Date;
runtime?: Date | null;
totalRuntimeSeconds?: number;
lastStartTime?: Date | null;
lastStopTime?: Date | null;
accumulatedRunTimeSeconds?: number;
winRate?: number;
totalVolumeTraded?: number;
volumeLast24H?: number;
wins?: number;
losses?: number;
positions?: Position[] | null;
positions?: PositionViewModel[] | null;
identifier?: string;
walletBalances?: { [key: string]: number; } | null;
ticker?: Ticker;
masterAgentName?: string | null;
}
export interface PositionViewModel {
date: Date;
accountId: number;
originDirection: TradeDirection;
ticker: Ticker;
Open: Trade;
StopLoss: Trade;
TakeProfit1: Trade;
ProfitAndLoss?: ProfitAndLoss | null;
uiFees?: number;
gasFees?: number;
status: PositionStatus;
signalIdentifier?: string | null;
identifier: string;
}
export interface PlatformSummaryViewModel {
lastUpdated?: Date;
lastSnapshot?: Date;
hasPendingChanges?: boolean;
totalAgents?: number;
totalActiveStrategies?: number;
totalPlatformPnL?: number;
totalPlatformVolume?: number;
totalPlatformVolumeLast24h?: number;
totalOpenInterest?: number;
openInterest?: number;
totalPositionCount?: number;
agentsChange24h?: number;
strategiesChange24h?: number;
pnLChange24h?: number;
volumeChange24h?: number;
openInterestChange24h?: number;
positionCountChange24h?: number;
totalPlatformFees?: number;
dailySnapshots?: DailySnapshot[] | null;
volumeByAsset?: { [key in keyof typeof Ticker]?: number; } | null;
positionCountByAsset?: { [key in keyof typeof Ticker]?: number; } | null;
positionCountByDirection?: { [key in keyof typeof TradeDirection]?: number; } | null;
lastUpdated?: Date;
last24HourSnapshot?: Date;
volumeHistory?: VolumeHistoryPoint[] | null;
}
export interface VolumeHistoryPoint {
export interface DailySnapshot {
date?: Date;
volume?: number;
totalAgents?: number;
totalStrategies?: number;
totalVolume?: number;
totalPnL?: number;
netPnL?: number;
totalOpenInterest?: number;
totalPositionCount?: number;
}
export interface PaginatedAgentIndexResponse {
@@ -1038,16 +1312,19 @@ export interface PaginatedAgentIndexResponse {
export interface AgentSummaryViewModel {
agentName?: string | null;
totalPnL?: number;
netPnL?: number;
totalROI?: number;
wins?: number;
losses?: number;
activeStrategiesCount?: number;
totalVolume?: number;
totalBalance?: number;
totalFees?: number;
backtestCount?: number;
}
export enum SortableFields {
TotalPnL = "TotalPnL",
NetPnL = "NetPnL",
TotalROI = "TotalROI",
Wins = "Wins",
Losses = "Losses",
@@ -1059,25 +1336,82 @@ export enum SortableFields {
}
export interface AgentBalanceHistory {
userId?: number;
agentName?: string | null;
agentBalances?: AgentBalance[] | null;
}
export interface AgentBalance {
agentName?: string | null;
totalValue?: number;
totalAccountUsdValue?: number;
userId?: number;
totalBalanceValue?: number;
usdcWalletValue?: number;
usdcInPositionsValue?: number;
botsAllocationUsdValue?: number;
pnL?: number;
time?: Date;
}
export interface BestAgentsResponse {
agents?: AgentBalanceHistory[] | null;
export interface JobStatusResponse {
jobId?: string;
status?: string | null;
progressPercentage?: number;
createdAt?: Date;
startedAt?: Date | null;
completedAt?: Date | null;
errorMessage?: string | null;
result?: LightBacktest | null;
}
export interface PaginatedJobsResponse {
jobs?: JobListItemResponse[];
totalCount?: number;
currentPage?: number;
pageSize?: number;
totalPages?: number;
hasNextPage?: boolean;
hasPreviousPage?: boolean;
}
export interface JobListItemResponse {
jobId?: string;
status?: string;
jobType?: string;
progressPercentage?: number;
priority?: number;
userId?: number;
bundleRequestId?: string | null;
geneticRequestId?: string | null;
assignedWorkerId?: string | null;
createdAt?: Date;
startedAt?: Date | null;
completedAt?: Date | null;
lastHeartbeat?: Date | null;
errorMessage?: string | null;
startDate?: Date;
endDate?: Date;
}
export interface JobSummaryResponse {
statusSummary?: JobStatusSummary[];
jobTypeSummary?: JobTypeSummary[];
statusTypeSummary?: JobStatusTypeSummary[];
totalJobs?: number;
}
export interface JobStatusSummary {
status?: string;
count?: number;
}
export interface JobTypeSummary {
jobType?: string;
count?: number;
}
export interface JobStatusTypeSummary {
status?: string;
jobType?: string;
count?: number;
}
export interface ScenarioViewModel {
@@ -1120,11 +1454,69 @@ export interface PrivyInitAddressResponse {
isAlreadyInitialized?: boolean;
}
export interface IndicatorRequestDto {
indicatorName: string;
strategyDescription: string;
documentationUrl?: string | null;
imageUrl?: string | null;
requesterName: string;
}
export interface LoginRequest {
name: string;
address: string;
signature: string;
message: string;
ownerWalletAddress?: string | null;
}
export interface PaginatedWhitelistAccountsResponse {
accounts?: WhitelistAccount[] | null;
totalCount?: number;
pageNumber?: number;
pageSize?: number;
totalPages?: number;
}
export interface WhitelistAccount {
id?: number;
privyId?: string | null;
privyCreationDate?: Date;
embeddedWallet?: string | null;
externalEthereumAccount?: string | null;
twitterAccount?: string | null;
isWhitelisted?: boolean;
createdAt?: Date;
updatedAt?: Date | null;
}
export interface PrivyWebhookDto {
type?: string | null;
user?: PrivyUserDto | null;
wallet?: PrivyWalletDto | null;
}
export interface PrivyUserDto {
created_at?: number;
has_accepted_terms?: boolean;
id?: string | null;
is_guest?: boolean;
linked_accounts?: PrivyLinkedAccountDto[] | null;
mfa_methods?: any[] | null;
}
export interface PrivyLinkedAccountDto {
address?: string | null;
first_verified_at?: number | null;
latest_verified_at?: number | null;
type?: string | null;
verified_at?: number | null;
}
export interface PrivyWalletDto {
type?: string | null;
address?: string | null;
chain_type?: string | null;
}
export interface FileResponse {

View File

@@ -40,6 +40,7 @@ import {
TradeStatus,
TradeType
} from '../../generated/ManagingApiTypes.js';
import {TradeActionType} from '../../generated/gmxsdk/types/tradeHistory.js';
// Cache implementation for markets info data
interface CacheEntry {
@@ -1532,6 +1533,134 @@ export const getPositionHistoryImpl = async (
);
};
/**
* Implementation function to get spot position (swap) history on GMX
* This returns swap executions (MarketSwap) so we can reconcile spot positions.
*/
export const getSpotPositionHistoryImpl = async (
sdk: GmxSdk,
pageIndex: number = 0,
pageSize: number = 20,
ticker?: string,
fromDateTime?: string,
toDateTime?: string
): Promise<Position[]> => {
return executeWithFallback(
async (sdk, retryCount) => {
const fromTimestamp = fromDateTime ? Math.floor(new Date(fromDateTime).getTime() / 1000) : undefined;
const toTimestamp = toDateTime ? Math.floor(new Date(toDateTime).getTime() / 1000) : undefined;
const {marketsInfoData, tokensData} = await getMarketsInfoWithCache(sdk);
if (!marketsInfoData || !tokensData) {
throw new Error("No markets or tokens info data");
}
const tradeActions = await sdk.trades.getTradeHistory({
pageIndex,
pageSize,
fromTxTimestamp: fromTimestamp,
toTxTimestamp: toTimestamp,
marketsInfoData,
tokensData,
marketsDirectionsFilter: undefined,
orderEventCombinations: [{
eventName: TradeActionType.OrderExecuted,
orderType: OrderType.MarketSwap,
}],
});
let positions: Position[] = [];
for (const action of tradeActions) {
console.log(`📊 Action:`, action);
// Some swap actions don't carry marketInfo; derive from target/initial collateral token instead.
const initialToken = (action as any).initialCollateralToken;
const targetToken = (action as any).targetCollateralToken || initialToken;
if (!targetToken && !initialToken) {
continue;
}
const targetSymbol = targetToken?.symbol || 'UNKNOWN';
const initialSymbol = initialToken?.symbol || targetSymbol;
const isSellToUsdc = (targetSymbol?.toUpperCase?.() === 'USDC');
const direction = isSellToUsdc ? TradeDirection.Short : TradeDirection.Long;
const targetDecimals = targetToken?.decimals ?? 18;
const initialDecimals = initialToken?.decimals ?? 18;
const executionAmountOut = (action as any).executionAmountOut ?? 0n; // output (target token)
const initialAmount = (action as any).initialCollateralDeltaAmount ?? 0n; // input (initial token)
const outputQty = Number(executionAmountOut) / Math.pow(10, targetDecimals);
const inputQty = Number(initialAmount) / Math.pow(10, initialDecimals);
// Base ticker & quantity: Long -> target token received; Short -> initial token sold
const baseSymbol = direction === TradeDirection.Short ? initialSymbol : targetSymbol;
const tickerSymbol = (ticker as any) || baseSymbol;
const quantity = direction === TradeDirection.Short ? inputQty : outputQty;
let price = 0;
if (quantity > 0) {
// Quote per base: input value / base qty (Long: USDC/BTC; Short: USDC/BTC)
const numerator = direction === TradeDirection.Short ? outputQty : inputQty;
price = numerator / quantity;
}
const pnlUsd = (action as any).pnlUsd ? Number((action as any).pnlUsd) / 1e30 : 0;
const timestampSec = (action as any).timestamp || 0;
const date = new Date(Number(timestampSec) * 1000);
const exchangeOrderId = action.transaction?.hash || (action as any).id;
const position: Position = {
ticker: tickerSymbol as any,
direction,
price,
quantity,
leverage: 1,
status: PositionStatus.Finished,
tradeType: TradeType.Market,
date,
exchangeOrderId,
pnl: pnlUsd,
ProfitAndLoss: {
realized: pnlUsd,
net: pnlUsd,
averageOpenPrice: undefined
},
Open: {
ticker: tickerSymbol as any,
direction,
price,
quantity,
leverage: 1,
status: TradeStatus.Filled,
tradeType: TradeType.Market,
date,
exchangeOrderId,
fee: 0,
message: "Spot swap execution"
} as Trade
} as any;
positions.push(position);
}
console.log(`📊 Positions:`, positions);
if (ticker) {
positions = positions.filter(p =>
p.ticker === (ticker as any) ||
p.Open?.ticker === (ticker as any)
);
}
return positions;
}, sdk, 0
);
};
/**
* Implementation function to get positions on GMX with fallback RPC support
* @param sdk The GMX SDK client
@@ -1816,6 +1945,61 @@ export async function getPositionHistory(
}
}
/**
* Gets spot position (swap) history on GMX
* @param this The FastifyRequest instance
* @param reply The FastifyReply instance
* @param account The wallet address of the user
* @param pageIndex The page index for pagination (default: 0)
* @param pageSize The number of items per page (default: 20)
* @param ticker Optional ticker filter
* @param fromDateTime Optional start datetime (ISO 8601 format)
* @param toDateTime Optional end datetime (ISO 8601 format)
* @returns The response object with success status and positions array
*/
export async function getSpotPositionHistory(
this: FastifyRequest,
reply: FastifyReply,
account: string,
pageIndex?: number,
pageSize?: number,
ticker?: string,
fromDateTime?: string,
toDateTime?: string
) {
try {
getPositionHistorySchema.parse({
account,
pageIndex: pageIndex ?? 0,
pageSize: pageSize ?? 20,
ticker,
fromDateTime,
toDateTime
});
const sdk = await this.getClientForAddress(account);
const positions = await getSpotPositionHistoryImpl(
sdk,
pageIndex ?? 0,
pageSize ?? 20,
ticker,
fromDateTime,
toDateTime
);
return {
success: true,
positions,
pageIndex: pageIndex ?? 0,
pageSize: pageSize ?? 20,
count: positions.length
};
} catch (error) {
return handleError(this, reply, error, 'gmx/spot-position-history');
}
}
// Helper to pre-populate and refresh the markets cache
async function getMarketsData() {
// Use a dummy zero address for the account
@@ -1849,6 +2033,7 @@ export default fp(async (fastify) => {
fastify.decorateRequest('getGmxTrade', getGmxTrade)
fastify.decorateRequest('getGmxPositions', getGmxPositions)
fastify.decorateRequest('getPositionHistory', getPositionHistory)
fastify.decorateRequest('getSpotPositionHistory', getSpotPositionHistory)
fastify.decorateRequest('getGmxRebateStats', getGmxRebateStats)
fastify.decorateRequest('getClaimableFundingFees', getClaimableFundingFees)
fastify.decorateRequest('claimGmxFundingFees', claimGmxFundingFees)

View File

@@ -0,0 +1,53 @@
import {test} from 'node:test'
import assert from 'node:assert'
import {getClientForAddress, getSpotPositionHistoryImpl} from '../../src/plugins/custom/gmx.js'
import {Ticker} from '../../src/generated/ManagingApiTypes.js'
test('GMX get spot position history - Market swaps', async (t) => {
await t.test('should get spot swap executions', async () => {
const sdk = await getClientForAddress('0x932167388dD9aad41149b3cA23eBD489E2E2DD78')
const result = await getSpotPositionHistoryImpl(
sdk,
0, // pageIndex
100, // pageSize
Ticker.BTC, // ticker
'2025-12-04T00:00:00.000Z', // fromDateTime
'2025-12-07T00:00:00.000Z' // toDateTime
)
console.log('\n📊 Spot Swap History Summary:')
console.log(`Total swaps: ${result.length}`)
console.log(`📊 Result:`, result);
assert.ok(result, 'Spot position history result should be defined')
assert.ok(Array.isArray(result), 'Spot position history should be an array')
})
await t.test('should get spot swaps within date range', async () => {
const sdk = await getClientForAddress('0x932167388dD9aad41149b3cA23eBD489E2E2DD78')
const toDate = new Date()
const fromDate = new Date(toDate.getTime() - (60 * 60 * 1000)) // last 1 hour
const fromDateTime = fromDate.toISOString()
const toDateTime = toDate.toISOString()
const result = await getSpotPositionHistoryImpl(
sdk,
0,
50,
Ticker.BTC,
fromDateTime,
toDateTime
)
console.log(`\n📅 Spot swaps in last 1 hour: ${result.length}`)
console.log(`From: ${fromDateTime}`)
console.log(`To: ${toDateTime}`)
assert.ok(result, 'Spot position history result should be defined')
assert.ok(Array.isArray(result), 'Spot position history should be an array')
})
})

View File

@@ -5216,6 +5216,7 @@ export interface TradingBotConfigRequest {
useForPositionSizing?: boolean;
useForSignalFiltering?: boolean;
useForDynamicStopLoss?: boolean;
tradingType?: TradingType;
}
export interface ScenarioRequest {

View File

@@ -721,6 +721,7 @@ export interface TradingBotConfigRequest {
useForPositionSizing?: boolean;
useForSignalFiltering?: boolean;
useForDynamicStopLoss?: boolean;
tradingType?: TradingType;
}
export interface ScenarioRequest {