fix(portfolio): tighten bootstrap and manual position handling

This commit is contained in:
root 2026-05-05 23:31:33 +00:00
parent 160920ea07
commit 0f74d7b292
4 changed files with 136 additions and 24 deletions

View File

@ -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

View File

@ -87,6 +87,14 @@ async function listManualEntryDocuments(userId: string): Promise<ManualEntryDocu
]);
}
async function listAllManualEntryDocuments(): Promise<ManualEntryDocument[]> {
const query = 'SELECT * FROM c WHERE c.productId = @productId AND c.type = @type ORDER BY c.created_at DESC';
return await queryDocuments<ManualEntryDocument>(MANUAL_ENTRY_CONTAINER, query, [
{ name: '@productId', value: config.PRODUCT_ID },
{ name: '@type', value: 'manual_entry' },
]);
}
async function findManualEntryDocument(userId: string, entryId: string): Promise<ManualEntryDocument | null> {
const rows = await queryDocuments<ManualEntryDocument>(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<ManualEn
return rows as ManualEntryRecord[];
}
export async function listManualEntries(options?: { userId?: string }): Promise<ManualEntryRecord[]> {
const rows = options?.userId
? await listManualEntryDocuments(options.userId)
: await listAllManualEntryDocuments();
return rows as ManualEntryRecord[];
}
export async function saveManualEntryForUser(userId: string, input: Partial<ManualEntryRecord>): Promise<ManualEntryRecord> {
const entryId = String(input.stock_instance_id || '').trim();
const existing = entryId ? await findManualEntryDocument(userId, entryId) : null;

View File

@ -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(<PositionsTab botState={{
symbols: {},
alerts: [],
positions: [],
orders: [],
history: [],
settings: {
executionMode: 'Paper',
riskPerTrade: 0.01,
totalCapital: 1000,
maxOpenTrades: 3,
isAlgoEnabled: true,
enabledRules: []
},
uptime: 1000
}} />);
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(<PositionsTab botState={buildBotState(now)} />);
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(<PositionsTab botState={nextBotState} />);
await waitFor(() => {
expect(fetchPositionsBootstrapMock).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -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) => {
</td>
<td className="px-6 py-4 text-xs font-mono text-gray-400">${pos.entryPrice.toLocaleString()}</td>
<td className="px-6 py-4 text-xs font-mono text-white font-bold">
${pos.currentPrice.toLocaleString()}
{hasCurrentPrice ? `$${pos.currentPrice.toLocaleString()}` : '-'}
<div className="mt-1 flex flex-wrap gap-1">
{slBreached && (
<span className="px-1.5 py-0.5 rounded text-[9px] font-black uppercase tracking-wider bg-red-500/20 text-red-300 border border-red-500/20">
@ -1355,12 +1362,16 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
<td className="px-6 py-4 text-xs font-mono text-green-400/80">
{displayTakeProfit ? `$${displayTakeProfit.toLocaleString()}` : '-'}
</td>
<td className="px-6 py-4 text-right">
<div className={`text-xs font-mono font-black ${pos.pnl >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{pos.pnl >= 0 ? '+' : ''}{pos.pnlPercent.toFixed(2)}%
<div className="text-[10px] opacity-60">${pos.pnl.toFixed(2)}</div>
</div>
</td>
<td className="px-6 py-4 text-right">
{hasPnl && hasPnlPercent ? (
<div className={`text-xs font-mono font-black ${pos.pnl >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{pos.pnl >= 0 ? '+' : ''}{pos.pnlPercent.toFixed(2)}%
<div className="text-[10px] opacity-60">${pos.pnl.toFixed(2)}</div>
</div>
) : (
<div className="text-xs font-mono text-gray-500">-</div>
)}
</td>
<td className="px-6 py-4 text-right">
{pos.source === 'BOT' && (
<button