diff --git a/backend/src/index.ts b/backend/src/index.ts index 0fe5de7..b22e553 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -308,7 +308,11 @@ async function main() { entry.user_id, undefined, undefined, - { allowExistingPosition: true } + { + allowExistingPosition: true, + tradeIdHint: String(entry.linked_trade_id || '').trim() || `TRD-SIMPLE-${String(entry.stock_instance_id || '').replace(/[^A-Za-z0-9]/g, '').slice(0, 24)}`, + concurrencyKey: String(entry.stock_instance_id || '').trim() || symbol + } ); if (!result.success) { logger.warn(`[SimpleWorker] Buy trigger failed for ${symbol}: ${result.error || 'unknown error'}`); diff --git a/backend/src/services/ManualTrader.ts b/backend/src/services/ManualTrader.ts index 1fbc6a8..821e68e 100644 --- a/backend/src/services/ManualTrader.ts +++ b/backend/src/services/ManualTrader.ts @@ -67,6 +67,8 @@ export class ManualTrader { tp?: number, options?: { allowExistingPosition?: boolean; + tradeIdHint?: string; + concurrencyKey?: string; } ): Promise<{ success: boolean; orderId?: string; tradeId?: string; error?: string; adjustedQty?: number; requestedQty?: number; remainingCapitalUsd?: number }> { const signalSide = (side.toLowerCase() === 'buy') ? SignalDirection.BUY : SignalDirection.SELL; diff --git a/backend/src/services/TradeExecutor.ts b/backend/src/services/TradeExecutor.ts index 46b3c98..3623555 100644 --- a/backend/src/services/TradeExecutor.ts +++ b/backend/src/services/TradeExecutor.ts @@ -64,7 +64,7 @@ export interface PositionState { tradeId?: string; } -export interface PendingOrder { +export interface PendingOrder { orderId: string; symbol: string; side: SignalDirection; @@ -77,10 +77,11 @@ export interface PendingOrder { userId?: string; profileId?: string; subTag?: string; - placedAt: number; - action: 'ENTRY' | 'EXIT'; - reservedAmount?: number; -} + placedAt: number; + action: 'ENTRY' | 'EXIT'; + reservedAmount?: number; + concurrencyKey?: string; +} export type ExitLifecycleState = | 'idle' @@ -113,12 +114,20 @@ export class TradeExecutor { private pendingOrders: Map = new Map(); // orderId -> PendingOrder private exitLifecycle: Map = new Map(); private entryAutoReduceLastAlertAt: Map = new Map(); - private tradeSequence = 0; + private tradeSequence = 0; private notifier: Notifier; - private static readonly POSITION_KEY_SEPARATOR = '::'; + private static readonly POSITION_KEY_SEPARATOR = '::'; private accountSnapshotTimer?: NodeJS.Timeout; private static warnedCapabilities = new Set(); - private profileSettings?: any; + private profileSettings?: any; + + private getDustQtyThreshold(): number { + const configured = Number(config.MIN_POSITION_QTY || 0.0001); + if (Number.isFinite(configured) && configured > 0) { + return configured; + } + return 0.0001; + } constructor( private exchange: IExchangeConnector, @@ -943,6 +952,8 @@ export class TradeExecutor { userIdOverride?: string, options?: { allowExistingPosition?: boolean; + tradeIdHint?: string; + concurrencyKey?: string; } ): Promise<{ success: boolean, orderId?: string, tradeId?: string, error?: string }> { if (healthTracker.isPaused()) { @@ -951,19 +962,20 @@ export class TradeExecutor { } console.log('[TradeExecutor] openPosition called', { symbol, side, qty, type, price, userIdOverride }); let executionQty = this.roundDownQty(Number(qty)); - if (!(executionQty > 0)) { - return { success: false, error: 'Invalid order quantity' }; - } - const ledgerProfileId = this.getLedgerProfileId(); - const tradeId = this.buildDeterministicTradeId(symbol, side); - const finalUserId = userIdOverride || this.userId; + if (!(executionQty > 0)) { + return { success: false, error: 'Invalid order quantity' }; + } + const ledgerProfileId = this.getLedgerProfileId(); + const normalizedSymbol = String(symbol || '').trim(); + const tradeId = String(options?.tradeIdHint || '').trim() || this.buildDeterministicTradeId(symbol, side); + const concurrencyKey = String(options?.concurrencyKey || '').trim() || normalizedSymbol; + const finalUserId = userIdOverride || this.userId; let reservedEstimate = ledgerProfileId ? this.estimateOrderCost(symbol, executionQty, price, type) : 0; let activeOrderId: string | undefined; let pendingCaptured = false; let capitalReserved = false; let capitalReservationAmount = reservedEstimate; - const normalizedSymbol = String(symbol || '').trim(); - let lockAcquired = false; + let lockAcquired = false; let lockOwner: string | undefined; let orderSubTag: string | undefined; @@ -1053,12 +1065,18 @@ export class TradeExecutor { capitalReserved = true; capitalReservationAmount = reservedEstimate; } - if (this.hasPendingAction(symbol, 'ENTRY')) { - logger.warn(`[Executor] Duplicate ENTRY request blocked for ${symbol}`); - await releaseCapitalOnAbort(); - await releaseLockIfHeld(); - return { success: false, error: 'Duplicate entry request blocked (pending action)' }; - } + const hasConflictingPendingEntry = Array.from(this.pendingOrders.values()).some((pending) => { + if (pending.symbol !== symbol) return false; + if ((pending.action || '').toUpperCase() !== 'ENTRY') return false; + const pendingKey = String(pending.concurrencyKey || pending.tradeId || pending.symbol || '').trim(); + return pendingKey === concurrencyKey; + }); + if (hasConflictingPendingEntry) { + logger.warn(`[Executor] Duplicate ENTRY request blocked for ${symbol}`); + await releaseCapitalOnAbort(); + await releaseLockIfHeld(); + return { success: false, error: 'Duplicate entry request blocked (pending action)' }; + } const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER); const clientOrderId = `bytelyst-${ledgerProfileId || this.profileId || 'global'}-${tradeId}`; @@ -1220,10 +1238,11 @@ export class TradeExecutor { userId: finalUserId, profileId: ledgerProfileId, subTag: orderSubTag, - reservedAmount: pendingReservationAmount, - placedAt: Date.now(), - action: 'ENTRY' - }); + reservedAmount: pendingReservationAmount, + placedAt: Date.now(), + action: 'ENTRY', + concurrencyKey + }); pendingCaptured = true; activeOrderId = order.id; await releaseLockIfHeld(); @@ -1498,7 +1517,7 @@ export class TradeExecutor { const resolvedPrice = Number.isFinite(fillPrice) && fillPrice > 0 ? fillPrice : getReconciliationFillPrice(order); if (resolvedQty <= 0 || resolvedPrice <= 0 || !resolvedSymbol) return; - const pending: PendingOrder = { + const pending: PendingOrder = { orderId, symbol: resolvedSymbol, side: this.normalizeSignalDirection(order.side), @@ -1509,12 +1528,13 @@ export class TradeExecutor { takeProfit: Number(order.take_profit || 0), tradeId: order.trade_id || order.tradeId, userId: order.user_id || this.userId, - profileId: order.profile_id || this.profileId, - subTag: extractOrderSubTag(order) || undefined, - placedAt: Number(order.timestamp || Date.now()), - action: 'ENTRY', - reservedAmount: Math.max(resolvedQty * resolvedPrice, this.computeReservedAmount(order, resolvedPrice)) - }; + profileId: order.profile_id || this.profileId, + subTag: extractOrderSubTag(order) || undefined, + placedAt: Number(order.timestamp || Date.now()), + action: 'ENTRY', + reservedAmount: Math.max(resolvedQty * resolvedPrice, this.computeReservedAmount(order, resolvedPrice)), + concurrencyKey: String(order.trade_id || order.tradeId || orderId || resolvedSymbol).trim() || resolvedSymbol + }; this.pendingOrders.set(orderId, pending); try { @@ -1563,11 +1583,12 @@ export class TradeExecutor { /** * Closes an existing position. */ - public async closePosition( - symbol: string, - reason: string = 'Exit Signal', - tradeId?: string - ): Promise<{ success: boolean, exitPrice?: number, error?: string }> { + public async closePosition( + symbol: string, + reason: string = 'Exit Signal', + tradeId?: string, + currentPriceHint?: number + ): Promise<{ success: boolean, exitPrice?: number, error?: string }> { const selected = this.resolvePositionSelection(symbol, tradeId); if (!selected) return { success: false, error: "No active position" }; const pos = selected.position; @@ -1620,6 +1641,16 @@ export class TradeExecutor { // We do NOT mutate pos.size here to avoid capital leaks in finalizeTrade. // We use a local variable for the order volume. } else { + const dustThreshold = this.getDustQtyThreshold(); + if (pos.size <= dustThreshold) { + const fallbackExitPrice = Number.isFinite(Number(currentPriceHint)) && Number(currentPriceHint) > 0 + ? Number(currentPriceHint) + : (this.apiServer?.getState?.().symbols?.[symbol]?.price || pos.entryPrice); + logger.warn(`[Hardening] Auto-finalizing dust remainder for ${symbol}: local=${pos.size}, exchange=${exchangeQty}, threshold=${dustThreshold}.`); + await this.finalizeTrade(symbol, fallbackExitPrice, `${reason} (dust auto-close)`, positionTradeId); + this.setExitLifecycle(symbol, 'filled', reason, `dust_autoclose_threshold=${dustThreshold}`); + return { success: true, exitPrice: fallbackExitPrice }; + } logger.error(`[Guardrail] Aborting SELL Exit for ${symbol}: Insufficient exchange balance (Requested: ${pos.size}, Exchange: ${exchangeQty}).`); if (config.REQUIRE_EXCHANGE_FILL_EVIDENCE_FOR_AUTO_CLOSE) { this.setExitLifecycle(symbol, 'quarantined', 'EXCHANGE_STATE_MISMATCH', `Exchange inventory: ${exchangeQty}`); @@ -1950,10 +1981,10 @@ export class TradeExecutor { } // --- Aliases for Compatibility/Clarity --- - public async executeExit(symbol: string, currentPrice: number, reason: string, tradeId?: string) { - // We ignore currentPrice for market order, but could log it - return this.closePosition(symbol, reason, tradeId); - } + public async executeExit(symbol: string, currentPrice: number, reason: string, tradeId?: string) { + // We ignore currentPrice for market order, but could log it + return this.closePosition(symbol, reason, tradeId, currentPrice); + } public async markTradeComplete(symbol: string, exitPrice: number, reason: string = 'Target Reached', tradeId?: string) { return await this.finalizeTrade(symbol, exitPrice, reason, tradeId); @@ -2472,7 +2503,7 @@ export class TradeExecutor { const normalizedSide = normalizeTradeSide(p.side || 'BUY'); const resolvedAction = normalizeOrderAction(p.action || undefined) || (normalizedSide === SignalDirection.SELL ? 'EXIT' : 'ENTRY'); - this.pendingOrders.set(pendingOrderId, { + this.pendingOrders.set(pendingOrderId, { orderId: pendingOrderId, symbol: p.symbol, side: normalizedSide as SignalDirection, @@ -2482,11 +2513,12 @@ export class TradeExecutor { stopLoss: p.stop_loss || 0, takeProfit: p.take_profit || 0, tradeId: p.trade_id || '', - userId: p.user_id, - subTag: extractOrderSubTag(p) || undefined, - placedAt: new Date(p.created_at || Date.now()).getTime(), - action: resolvedAction - }); + userId: p.user_id, + subTag: extractOrderSubTag(p) || undefined, + placedAt: new Date(p.created_at || Date.now()).getTime(), + action: resolvedAction, + concurrencyKey: String(p.trade_id || p.order_id || p.symbol || '').trim() || p.symbol + }); logger.info(`[Executor] 🔄 Recovered pending order ${p.order_id} for ${p.symbol} into monitoring map.`); } } diff --git a/backend/src/services/profileRepository.ts b/backend/src/services/profileRepository.ts index aededca..a64ef4c 100644 --- a/backend/src/services/profileRepository.ts +++ b/backend/src/services/profileRepository.ts @@ -69,6 +69,51 @@ function getLegacyClient() { return getLegacySupabaseClient(); } +function coalesceString(...values: Array): string { + for (const value of values) { + const normalized = String(value || '').trim(); + if (normalized) return normalized; + } + return ''; +} + +function coalesceNumber(...values: Array): number { + for (const value of values) { + if (value === null || value === undefined || value === '') continue; + const normalized = Number(value); + if (Number.isFinite(normalized)) return normalized; + } + return 0; +} + +function mergeTradingUserProfiles( + primary: Partial | null | undefined, + fallback: Partial | null | undefined, + explicitUserId?: string +): TradingUserProfile | null { + const userId = coalesceString(primary?.user_id, fallback?.user_id, explicitUserId); + if (!userId) { + return null; + } + + return { + user_id: userId, + first_name: coalesceString(primary?.first_name, fallback?.first_name), + last_name: coalesceString(primary?.last_name, fallback?.last_name), + email: coalesceString(primary?.email, fallback?.email), + role: coalesceString(primary?.role, fallback?.role, 'member'), + trade_enable: Boolean(primary?.trade_enable ?? fallback?.trade_enable ?? true), + FMP_API_KEY: coalesceString(primary?.FMP_API_KEY, fallback?.FMP_API_KEY), + ALPACA_API_KEY: coalesceString(primary?.ALPACA_API_KEY, fallback?.ALPACA_API_KEY), + ALPACA_SECRET_KEY: coalesceString(primary?.ALPACA_SECRET_KEY, fallback?.ALPACA_SECRET_KEY), + REAL_ALPACA_API_KEY: coalesceString(primary?.REAL_ALPACA_API_KEY, fallback?.REAL_ALPACA_API_KEY), + REAL_ALPACA_SECRET_KEY: coalesceString(primary?.REAL_ALPACA_SECRET_KEY, fallback?.REAL_ALPACA_SECRET_KEY), + drop_threshold_for_buy: coalesceNumber(primary?.drop_threshold_for_buy, fallback?.drop_threshold_for_buy, 0), + gain_threshold_for_sell: coalesceNumber(primary?.gain_threshold_for_sell, fallback?.gain_threshold_for_sell, 0), + market_poll_interval_in_seconds: coalesceNumber(primary?.market_poll_interval_in_seconds, fallback?.market_poll_interval_in_seconds, 0), + }; +} + function normalizeProfile(row: Partial | null | undefined): TradeProfileRecord | null { const id = String(row?.id || '').trim(); const userId = String(row?.user_id || '').trim(); @@ -616,7 +661,7 @@ export async function getCurrentUserProfile( try { const cosmosProfile = await getTradingUserProfileFromCosmos(userId); if (cosmosProfile) { - return cosmosProfile; + return mergeTradingUserProfiles(cosmosProfile, fallback, userId) || cosmosProfile; } } catch (error) { logger.warn(`[Profiles] Cosmos user profile read failed for ${userId}: ${error instanceof Error ? error.message : 'unknown error'}`); @@ -633,22 +678,10 @@ export async function getCurrentUserProfile( .maybeSingle(); if (!error && data) { - const normalized = { - user_id: String((data as any).user_id || userId), - first_name: String((data as any).first_name || fallback.first_name || ''), - last_name: String((data as any).last_name || fallback.last_name || ''), - email: String((data as any).email || fallback.email || ''), - role: String((data as any).role || fallback.role || 'member'), - trade_enable: Boolean((data as any).trade_enable ?? fallback.trade_enable ?? true), - FMP_API_KEY: (data as any).FMP_API_KEY || fallback.FMP_API_KEY, - ALPACA_API_KEY: (data as any).ALPACA_API_KEY || fallback.ALPACA_API_KEY, - ALPACA_SECRET_KEY: (data as any).ALPACA_SECRET_KEY || fallback.ALPACA_SECRET_KEY, - REAL_ALPACA_API_KEY: (data as any).REAL_ALPACA_API_KEY || fallback.REAL_ALPACA_API_KEY, - REAL_ALPACA_SECRET_KEY: (data as any).REAL_ALPACA_SECRET_KEY || fallback.REAL_ALPACA_SECRET_KEY, - drop_threshold_for_buy: Number((data as any).drop_threshold_for_buy ?? fallback.drop_threshold_for_buy ?? 0), - gain_threshold_for_sell: Number((data as any).gain_threshold_for_sell ?? fallback.gain_threshold_for_sell ?? 0), - market_poll_interval_in_seconds: Number((data as any).market_poll_interval_in_seconds ?? fallback.market_poll_interval_in_seconds ?? 0), - }; + const normalized = mergeTradingUserProfiles(data as any, fallback, userId); + if (!normalized) { + throw new Error(`Invalid user profile for ${userId}`); + } await upsertTradingUserProfileToCosmos(normalized); return normalized; } @@ -657,21 +690,21 @@ export async function getCurrentUserProfile( } } - return { + return mergeTradingUserProfiles({}, fallback, userId) || { user_id: userId, - first_name: String(fallback.first_name || ''), - last_name: String(fallback.last_name || ''), - email: String(fallback.email || ''), - role: String(fallback.role || 'member'), + first_name: '', + last_name: '', + email: '', + role: 'member', trade_enable: Boolean(fallback.trade_enable ?? true), - FMP_API_KEY: fallback.FMP_API_KEY, - ALPACA_API_KEY: fallback.ALPACA_API_KEY, - ALPACA_SECRET_KEY: fallback.ALPACA_SECRET_KEY, - REAL_ALPACA_API_KEY: fallback.REAL_ALPACA_API_KEY, - REAL_ALPACA_SECRET_KEY: fallback.REAL_ALPACA_SECRET_KEY, - drop_threshold_for_buy: Number(fallback.drop_threshold_for_buy ?? 0), - gain_threshold_for_sell: Number(fallback.gain_threshold_for_sell ?? 0), - market_poll_interval_in_seconds: Number(fallback.market_poll_interval_in_seconds ?? 0), + FMP_API_KEY: '', + ALPACA_API_KEY: '', + ALPACA_SECRET_KEY: '', + REAL_ALPACA_API_KEY: '', + REAL_ALPACA_SECRET_KEY: '', + drop_threshold_for_buy: 0, + gain_threshold_for_sell: 0, + market_poll_interval_in_seconds: 0, }; } diff --git a/backend/src/services/runtimeOrderRepository.ts b/backend/src/services/runtimeOrderRepository.ts index 8941951..b5b1a70 100644 --- a/backend/src/services/runtimeOrderRepository.ts +++ b/backend/src/services/runtimeOrderRepository.ts @@ -84,6 +84,14 @@ const OPEN_ORDER_STATUSES = ['pending_new', 'accepted', 'pending', 'new', 'parti const CLOSED_ORDER_STATUSES = ['filled', 'canceled', 'expired', 'rejected', 'unknown']; const FILLED_ORDER_STATUSES = ['filled', 'partially_filled', 'partially-filled']; +function dustThresholdQty(): number { + const configured = Number(config.MIN_POSITION_QTY || 0.0001); + if (Number.isFinite(configured) && configured > 0) { + return configured; + } + return 0.0001; +} + function cosmosEnabled(): boolean { return isCosmosConfigured(); } @@ -1209,7 +1217,7 @@ export async function getVirtualOpenPosition(profileId: string, symbol: string): const aggregateBySide = new Map<'BUY' | 'SELL', SideAggregate>(); for (const tradeLedger of ledgerByTrade.values()) { const remainingQty = tradeLedger.entryQty - tradeLedger.exitQty; - if (remainingQty <= 1e-8) continue; + if (remainingQty <= dustThresholdQty()) continue; const weightedEntryPrice = tradeLedger.entryQty > 0 && tradeLedger.entryNotional > 0 ? tradeLedger.entryNotional / tradeLedger.entryQty : tradeLedger.entryLastPrice; @@ -1317,7 +1325,7 @@ export async function getVirtualOpenPositionForTrade(profileId: string, symbol: } const remainingQty = entryQty - exitQty; - if (!(remainingQty > 1e-8) || !(entryNotional > 0) || !entrySide) return null; + if (!(remainingQty > dustThresholdQty()) || !(entryNotional > 0) || !entrySide) return null; return { profileId, diff --git a/backend/src/services/userRepository.ts b/backend/src/services/userRepository.ts index a90cc9e..d4ecc7a 100644 --- a/backend/src/services/userRepository.ts +++ b/backend/src/services/userRepository.ts @@ -40,6 +40,47 @@ function normalizeUser(row: Partial | null | undefined): UserConfig }; } +function mergeUsers(primary: Partial | null | undefined, fallback: Partial | null | undefined): UserConfig | null { + const userId = String(primary?.user_id || fallback?.user_id || '').trim(); + if (!userId) { + return null; + } + + const take = (...values: Array): string => { + for (const value of values) { + const normalized = String(value || '').trim(); + if (normalized) return normalized; + } + return ''; + }; + + const takeNumber = (...values: Array): number => { + for (const value of values) { + if (value === null || value === undefined || value === '') continue; + const normalized = Number(value); + if (Number.isFinite(normalized)) return normalized; + } + return 0; + }; + + return { + user_id: userId, + first_name: take(primary?.first_name, fallback?.first_name), + last_name: take(primary?.last_name, fallback?.last_name), + email: take(primary?.email, fallback?.email), + FMP_API_KEY: take(primary?.FMP_API_KEY, fallback?.FMP_API_KEY), + ALPACA_API_KEY: take(primary?.ALPACA_API_KEY, fallback?.ALPACA_API_KEY), + ALPACA_SECRET_KEY: take(primary?.ALPACA_SECRET_KEY, fallback?.ALPACA_SECRET_KEY), + REAL_ALPACA_API_KEY: take(primary?.REAL_ALPACA_API_KEY, fallback?.REAL_ALPACA_API_KEY), + REAL_ALPACA_SECRET_KEY: take(primary?.REAL_ALPACA_SECRET_KEY, fallback?.REAL_ALPACA_SECRET_KEY), + role: take(primary?.role, fallback?.role, 'member'), + trade_enable: Boolean(primary?.trade_enable ?? fallback?.trade_enable ?? true), + drop_threshold_for_buy: takeNumber(primary?.drop_threshold_for_buy, fallback?.drop_threshold_for_buy, 0), + gain_threshold_for_sell: takeNumber(primary?.gain_threshold_for_sell, fallback?.gain_threshold_for_sell, 0), + market_poll_interval_in_seconds: takeNumber(primary?.market_poll_interval_in_seconds, fallback?.market_poll_interval_in_seconds, 0), + }; +} + export async function listActiveTradingUsers(): Promise { if (isCosmosConfigured()) { try { @@ -56,7 +97,33 @@ export async function listActiveTradingUsers(): Promise { .map((row) => normalizeUser(row as UserConfig)) .filter((user): user is UserConfig => Boolean(user)); if (normalized.length > 0) { - return normalized; + const client = getLegacySupabaseClient(); + if (!client) { + return normalized; + } + + try { + const { data, error } = await client + .from('users') + .select('*') + .eq('trade_enable', true); + + if (error || !Array.isArray(data) || data.length === 0) { + return normalized; + } + + const legacyById = new Map( + data + .map((row) => normalizeUser(row as UserConfig)) + .filter((user): user is UserConfig => Boolean(user)) + .map((user) => [user.user_id, user]) + ); + + return normalized.map((user) => mergeUsers(user, legacyById.get(user.user_id)) || user); + } catch (error) { + logger.warn(`[Users] Legacy user merge failed: ${error instanceof Error ? error.message : 'unknown error'}`); + return normalized; + } } } catch (error) { logger.warn(`[Users] Cosmos active trading user lookup failed: ${error instanceof Error ? error.message : 'unknown error'}`); diff --git a/web/src/tabs/PositionsTab.tsx b/web/src/tabs/PositionsTab.tsx index 8791057..819b784 100644 --- a/web/src/tabs/PositionsTab.tsx +++ b/web/src/tabs/PositionsTab.tsx @@ -694,12 +694,44 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => { setOrdersPage((current) => Math.min(current, ordersTotalPages)); }, [ordersTotalPages]); - const finalOrders = useMemo(() => { - const startIndex = (ordersPage - 1) * ORDER_ACTIVITY_PAGE_SIZE; - return sortedOrdersForActivity.slice(startIndex, startIndex + ORDER_ACTIVITY_PAGE_SIZE); - }, [sortedOrdersForActivity, ordersPage, ORDER_ACTIVITY_PAGE_SIZE]); - - const historyTradeKeySet = useMemo(() => new Set(historyTradeKeys), [historyTradeKeys]); + const finalOrders = useMemo(() => { + const startIndex = (ordersPage - 1) * ORDER_ACTIVITY_PAGE_SIZE; + return sortedOrdersForActivity.slice(startIndex, startIndex + ORDER_ACTIVITY_PAGE_SIZE); + }, [sortedOrdersForActivity, ordersPage, ORDER_ACTIVITY_PAGE_SIZE]); + + const historyTradeKeySet = useMemo(() => new Set(historyTradeKeys), [historyTradeKeys]); + + const activePositionTradeKeys = useMemo(() => { + const keys = new Set(); + for (const position of filteredBotPositions) { + const tradeId = String(position.tradeId || '').trim(); + if (!tradeId) continue; + keys.add(`${position.profileId || 'global'}|${tradeId}`); + keys.add(`global|${tradeId}`); + } + return keys; + }, [filteredBotPositions]); + + const staleWarningOrders = useMemo(() => { + return resolvedOrders.filter((order) => { + if (!isPendingLikeStatus(order.status)) return false; + const orderAge = order.timestamp ? Date.now() - order.timestamp : 0; + if (orderAge <= 5 * 60 * 1000) return false; + + const tradeId = String(order.tradeId || '').trim(); + if (tradeId) { + const scopedTradeKey = `${order.profileId || 'global'}|${tradeId}`; + if (activePositionTradeKeys.has(scopedTradeKey) || activePositionTradeKeys.has(`global|${tradeId}`)) { + return false; + } + if (historyTradeKeySet.has(scopedTradeKey) || historyTradeKeySet.has(`global|${tradeId}`)) { + return false; + } + } + + return true; + }); + }, [resolvedOrders, activePositionTradeKeys, historyTradeKeySet]); const entryOrdersLookup = useMemo(() => { const byScopedTrade = new Map(); @@ -1165,7 +1197,7 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {

Positions & Orders

-

Real-time isolation by strategy profile.

+

Live positions, orders, and lifecycle status scoped by strategy profile.

@@ -1205,31 +1237,21 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
)} - {/* Stale Orders Warning Banner */} - {(() => { - const staleOrders = finalOrders.filter((o) => { - const isPendingNew = isPendingLikeStatus(o.status); - const orderAge = o.timestamp ? Date.now() - o.timestamp : 0; - return isPendingNew && orderAge > 5 * 60 * 1000; - }); - - if (staleOrders.length === 0) return null; - - return ( -
- ⚠️ -
-

- {staleOrders.length} Stale Order{staleOrders.length > 1 ? 's' : ''} Detected -

-

- Some orders have been in pending_new status for more than 5 minutes. - The background sync service is checking their actual status with the exchange. -

-
-
- ); - })()} + {/* Stale Orders Warning Banner */} + {staleWarningOrders.length > 0 && ( +
+ ⚠️ +
+

+ {staleWarningOrders.length} Stale Order{staleWarningOrders.length > 1 ? 's' : ''} Detected +

+

+ Some orders have remained in pending_new for more than 5 minutes + without stronger fill or position evidence. The background sync service is re-checking their exchange status. +

+
+
+ )} {positionMismatches.length > 0 && (