From 0f74d7b29208f2a4acc39574bac5cf1d8418f2fc Mon Sep 17 00:00:00 2001 From: root Date: Tue, 5 May 2026 23:31:33 +0000 Subject: [PATCH] fix(portfolio): tighten bootstrap and manual position handling --- backend/src/services/apiServer.ts | 3 +- backend/src/services/manualEntryRepository.ts | 15 ++++ web/src/tabs/PositionsTab.dom.test.tsx | 85 +++++++++++++++++++ web/src/tabs/PositionsTab.tsx | 57 ++++++++----- 4 files changed, 136 insertions(+), 24 deletions(-) diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts index cc8d161..612a04c 100644 --- a/backend/src/services/apiServer.ts +++ b/backend/src/services/apiServer.ts @@ -32,6 +32,7 @@ import { } from './profileRepository.js'; import { deleteManualEntryForUser, + listManualEntries, listManualEntriesForUser, saveManualEntryForUser } from './manualEntryRepository.js'; @@ -1972,7 +1973,7 @@ export class ApiServer { const orderLimit = Math.max(1, Math.min(5000, parseInt(String(req.query.limit || '5000'), 10) || 5000)); const [entries, orders, historyTradeKeys, profiles] = await Promise.all([ - listManualEntriesForUser(authUserId), + listManualEntries({ userId: wantsAll ? undefined : authUserId }), listRecentOrders({ userId: wantsAll ? undefined : authUserId, limit: orderLimit diff --git a/backend/src/services/manualEntryRepository.ts b/backend/src/services/manualEntryRepository.ts index 250dee5..4488ff6 100644 --- a/backend/src/services/manualEntryRepository.ts +++ b/backend/src/services/manualEntryRepository.ts @@ -87,6 +87,14 @@ async function listManualEntryDocuments(userId: string): Promise { + const query = 'SELECT * FROM c WHERE c.productId = @productId AND c.type = @type ORDER BY c.created_at DESC'; + return await queryDocuments(MANUAL_ENTRY_CONTAINER, query, [ + { name: '@productId', value: config.PRODUCT_ID }, + { name: '@type', value: 'manual_entry' }, + ]); +} + async function findManualEntryDocument(userId: string, entryId: string): Promise { const rows = await queryDocuments(MANUAL_ENTRY_CONTAINER, 'SELECT TOP 1 * FROM c WHERE c.productId = @productId AND c.type = @type AND c.user_id = @userId AND c.stock_instance_id = @entryId', [ { name: '@productId', value: config.PRODUCT_ID }, @@ -127,6 +135,13 @@ export async function listManualEntriesForUser(userId: string): Promise { + const rows = options?.userId + ? await listManualEntryDocuments(options.userId) + : await listAllManualEntryDocuments(); + return rows as ManualEntryRecord[]; +} + export async function saveManualEntryForUser(userId: string, input: Partial): Promise { const entryId = String(input.stock_instance_id || '').trim(); const existing = entryId ? await findManualEntryDocument(userId, entryId) : null; diff --git a/web/src/tabs/PositionsTab.dom.test.tsx b/web/src/tabs/PositionsTab.dom.test.tsx index 3ccd220..bcdeba7 100644 --- a/web/src/tabs/PositionsTab.dom.test.tsx +++ b/web/src/tabs/PositionsTab.dom.test.tsx @@ -382,4 +382,89 @@ describe('PositionsTab DOM behavior', () => { expect(fetchPositionsBootstrapMock).toHaveBeenCalledWith({ scope: 'user', limit: 5000 }); expect(errorSpy).toHaveBeenCalledWith('[PositionsTab] Failed loading positions bootstrap:', 'bootstrap failed'); }); + + it('does not render fake market price or breach badges for manual entries without live pricing', async () => { + canonicalLifecycleState.snapshot = null; + fetchPositionsBootstrapMock.mockResolvedValue({ + entries: [ + { + stock_instance_id: 'manual-live', + symbol: 'BTC/USDT', + quantity: 1, + buy_price: 100, + drop_threshold_for_buy: 95, + gain_threshold_for_sell: 115, + active: true, + status: 'active' + } + ], + orders: [], + historyTradeKeys: [], + profiles: [] + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('BTC/USDT')).toBeInTheDocument(); + }); + + expect(screen.queryByText('SL breached')).not.toBeInTheDocument(); + expect(screen.queryByText('$0')).not.toBeInTheDocument(); + }); + + it('does not refetch bootstrap just because websocket order/history counts change', async () => { + const now = Date.now(); + fetchPositionsBootstrapMock.mockResolvedValue({ + entries: [], + orders: [], + historyTradeKeys: [], + profiles: [] + }); + + const { rerender } = render(); + + await waitFor(() => { + expect(fetchPositionsBootstrapMock).toHaveBeenCalledTimes(1); + }); + + const nextBotState = buildBotState(now); + nextBotState.orders = [...nextBotState.orders, { + id: 'new-order', + order_id: 'new-order', + profileId: 'p1', + symbol: 'BTC/USDT', + type: 'Market', + side: 'BUY', + qty: 1, + price: 101, + status: 'filled', + timestamp: now + 1_000, + trade_id: 'TRD-NEW', + action: 'ENTRY', + source: 'BOT' + } as any]; + nextBotState.history = [{ id: 'history-1' } as any]; + + rerender(); + + await waitFor(() => { + expect(fetchPositionsBootstrapMock).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/web/src/tabs/PositionsTab.tsx b/web/src/tabs/PositionsTab.tsx index 8e4b5b8..60077b4 100644 --- a/web/src/tabs/PositionsTab.tsx +++ b/web/src/tabs/PositionsTab.tsx @@ -12,16 +12,16 @@ interface PositionsTabProps { botState: BotState; } -interface HybridPosition { - source: 'BOT' | 'MANUAL'; - id: string; - symbol: string; - side: 'BUY' | 'SELL'; - size: number; - entryPrice: number; - currentPrice: number; - pnl: number; - pnlPercent: number; +interface HybridPosition { + source: 'BOT' | 'MANUAL'; + id: string; + symbol: string; + side: 'BUY' | 'SELL'; + size: number; + entryPrice: number; + currentPrice?: number | null; + pnl?: number | null; + pnlPercent?: number | null; stopLoss?: number; takeProfit?: number; profileId?: string; @@ -181,6 +181,10 @@ export const formatDisplayQty = (value: number): string => { return text || '0'; }; +export const hasFiniteNumber = (value: unknown): value is number => { + return typeof value === 'number' && Number.isFinite(value); +}; + export const isLifecycleFilledStatus = (status?: string): boolean => { const normalized = (status || '').toLowerCase().replace(/-/g, '_'); return normalized === 'filled' || normalized === 'partially_filled'; @@ -490,9 +494,9 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => { side: 'BUY' as const, size: entry.quantity || 0, entryPrice: entry.buy_price || 0, - currentPrice: 0, - pnl: 0, - pnlPercent: 0, + currentPrice: null, + pnl: null, + pnlPercent: null, stopLoss: entry.drop_threshold_for_buy, takeProfit: entry.gain_threshold_for_sell })).filter((p: { size: number; entryPrice: number; }) => p.size > 0 && p.entryPrice > 0); @@ -511,7 +515,7 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => { cancelled = true; window.clearInterval(refreshTimer); }; - }, [user, profile?.role, botState.history.length, botState.orders.length]); + }, [user, profile?.role]); // 2. Build bot positions from real-time Socket.IO data const botPositionsRaw: HybridPosition[] = useMemo(() => { @@ -1289,17 +1293,20 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => { allPositions.map(pos => { const entryRisk = resolveEntryOrder(pos.tradeId, pos.profileId); const positionTruth = getTruthSourceForPosition(entryRisk, hasCanonicalLifecycle); + const hasCurrentPrice = hasFiniteNumber(pos.currentPrice); + const hasPnl = hasFiniteNumber(pos.pnl); + const hasPnlPercent = hasFiniteNumber(pos.pnlPercent); const displayStopLoss = (pos.stopLoss && pos.stopLoss > 0) ? pos.stopLoss : (entryRisk?.stopLoss || 0); const displayTakeProfit = (pos.takeProfit && pos.takeProfit > 0) ? pos.takeProfit : (entryRisk?.takeProfit || 0); - const slBreached = displayStopLoss > 0 && ( + const slBreached = hasCurrentPrice && displayStopLoss > 0 && ( (pos.side === 'BUY' && pos.currentPrice <= displayStopLoss) || (pos.side === 'SELL' && pos.currentPrice >= displayStopLoss) ); - const tpHit = displayTakeProfit > 0 && ( + const tpHit = hasCurrentPrice && displayTakeProfit > 0 && ( (pos.side === 'BUY' && pos.currentPrice >= displayTakeProfit) || (pos.side === 'SELL' && pos.currentPrice <= displayTakeProfit) ); @@ -1335,7 +1342,7 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => { ${pos.entryPrice.toLocaleString()} - ${pos.currentPrice.toLocaleString()} + {hasCurrentPrice ? `$${pos.currentPrice.toLocaleString()}` : '-'}
{slBreached && ( @@ -1355,12 +1362,16 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => { {displayTakeProfit ? `$${displayTakeProfit.toLocaleString()}` : '-'} - -
= 0 ? 'text-green-400' : 'text-red-400'}`}> - {pos.pnl >= 0 ? '+' : ''}{pos.pnlPercent.toFixed(2)}% -
${pos.pnl.toFixed(2)}
-
- + + {hasPnl && hasPnlPercent ? ( +
= 0 ? 'text-green-400' : 'text-red-400'}`}> + {pos.pnl >= 0 ? '+' : ''}{pos.pnlPercent.toFixed(2)}% +
${pos.pnl.toFixed(2)}
+
+ ) : ( +
-
+ )} + {pos.source === 'BOT' && (