fix(portfolio): tighten bootstrap and manual position handling
This commit is contained in:
parent
160920ea07
commit
0f74d7b292
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user