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';
|
} from './profileRepository.js';
|
||||||
import {
|
import {
|
||||||
deleteManualEntryForUser,
|
deleteManualEntryForUser,
|
||||||
|
listManualEntries,
|
||||||
listManualEntriesForUser,
|
listManualEntriesForUser,
|
||||||
saveManualEntryForUser
|
saveManualEntryForUser
|
||||||
} from './manualEntryRepository.js';
|
} 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 orderLimit = Math.max(1, Math.min(5000, parseInt(String(req.query.limit || '5000'), 10) || 5000));
|
||||||
|
|
||||||
const [entries, orders, historyTradeKeys, profiles] = await Promise.all([
|
const [entries, orders, historyTradeKeys, profiles] = await Promise.all([
|
||||||
listManualEntriesForUser(authUserId),
|
listManualEntries({ userId: wantsAll ? undefined : authUserId }),
|
||||||
listRecentOrders({
|
listRecentOrders({
|
||||||
userId: wantsAll ? undefined : authUserId,
|
userId: wantsAll ? undefined : authUserId,
|
||||||
limit: orderLimit
|
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> {
|
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', [
|
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 },
|
{ name: '@productId', value: config.PRODUCT_ID },
|
||||||
@ -127,6 +135,13 @@ export async function listManualEntriesForUser(userId: string): Promise<ManualEn
|
|||||||
return rows as ManualEntryRecord[];
|
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> {
|
export async function saveManualEntryForUser(userId: string, input: Partial<ManualEntryRecord>): Promise<ManualEntryRecord> {
|
||||||
const entryId = String(input.stock_instance_id || '').trim();
|
const entryId = String(input.stock_instance_id || '').trim();
|
||||||
const existing = entryId ? await findManualEntryDocument(userId, entryId) : null;
|
const existing = entryId ? await findManualEntryDocument(userId, entryId) : null;
|
||||||
|
|||||||
@ -382,4 +382,89 @@ describe('PositionsTab DOM behavior', () => {
|
|||||||
expect(fetchPositionsBootstrapMock).toHaveBeenCalledWith({ scope: 'user', limit: 5000 });
|
expect(fetchPositionsBootstrapMock).toHaveBeenCalledWith({ scope: 'user', limit: 5000 });
|
||||||
expect(errorSpy).toHaveBeenCalledWith('[PositionsTab] Failed loading positions bootstrap:', 'bootstrap failed');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -19,9 +19,9 @@ interface HybridPosition {
|
|||||||
side: 'BUY' | 'SELL';
|
side: 'BUY' | 'SELL';
|
||||||
size: number;
|
size: number;
|
||||||
entryPrice: number;
|
entryPrice: number;
|
||||||
currentPrice: number;
|
currentPrice?: number | null;
|
||||||
pnl: number;
|
pnl?: number | null;
|
||||||
pnlPercent: number;
|
pnlPercent?: number | null;
|
||||||
stopLoss?: number;
|
stopLoss?: number;
|
||||||
takeProfit?: number;
|
takeProfit?: number;
|
||||||
profileId?: string;
|
profileId?: string;
|
||||||
@ -181,6 +181,10 @@ export const formatDisplayQty = (value: number): string => {
|
|||||||
return text || '0';
|
return text || '0';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const hasFiniteNumber = (value: unknown): value is number => {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value);
|
||||||
|
};
|
||||||
|
|
||||||
export const isLifecycleFilledStatus = (status?: string): boolean => {
|
export const isLifecycleFilledStatus = (status?: string): boolean => {
|
||||||
const normalized = (status || '').toLowerCase().replace(/-/g, '_');
|
const normalized = (status || '').toLowerCase().replace(/-/g, '_');
|
||||||
return normalized === 'filled' || normalized === 'partially_filled';
|
return normalized === 'filled' || normalized === 'partially_filled';
|
||||||
@ -490,9 +494,9 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
|
|||||||
side: 'BUY' as const,
|
side: 'BUY' as const,
|
||||||
size: entry.quantity || 0,
|
size: entry.quantity || 0,
|
||||||
entryPrice: entry.buy_price || 0,
|
entryPrice: entry.buy_price || 0,
|
||||||
currentPrice: 0,
|
currentPrice: null,
|
||||||
pnl: 0,
|
pnl: null,
|
||||||
pnlPercent: 0,
|
pnlPercent: null,
|
||||||
stopLoss: entry.drop_threshold_for_buy,
|
stopLoss: entry.drop_threshold_for_buy,
|
||||||
takeProfit: entry.gain_threshold_for_sell
|
takeProfit: entry.gain_threshold_for_sell
|
||||||
})).filter((p: { size: number; entryPrice: number; }) => p.size > 0 && p.entryPrice > 0);
|
})).filter((p: { size: number; entryPrice: number; }) => p.size > 0 && p.entryPrice > 0);
|
||||||
@ -511,7 +515,7 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearInterval(refreshTimer);
|
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
|
// 2. Build bot positions from real-time Socket.IO data
|
||||||
const botPositionsRaw: HybridPosition[] = useMemo(() => {
|
const botPositionsRaw: HybridPosition[] = useMemo(() => {
|
||||||
@ -1289,17 +1293,20 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
|
|||||||
allPositions.map(pos => {
|
allPositions.map(pos => {
|
||||||
const entryRisk = resolveEntryOrder(pos.tradeId, pos.profileId);
|
const entryRisk = resolveEntryOrder(pos.tradeId, pos.profileId);
|
||||||
const positionTruth = getTruthSourceForPosition(entryRisk, hasCanonicalLifecycle);
|
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)
|
const displayStopLoss = (pos.stopLoss && pos.stopLoss > 0)
|
||||||
? pos.stopLoss
|
? pos.stopLoss
|
||||||
: (entryRisk?.stopLoss || 0);
|
: (entryRisk?.stopLoss || 0);
|
||||||
const displayTakeProfit = (pos.takeProfit && pos.takeProfit > 0)
|
const displayTakeProfit = (pos.takeProfit && pos.takeProfit > 0)
|
||||||
? pos.takeProfit
|
? pos.takeProfit
|
||||||
: (entryRisk?.takeProfit || 0);
|
: (entryRisk?.takeProfit || 0);
|
||||||
const slBreached = displayStopLoss > 0 && (
|
const slBreached = hasCurrentPrice && displayStopLoss > 0 && (
|
||||||
(pos.side === 'BUY' && pos.currentPrice <= displayStopLoss)
|
(pos.side === 'BUY' && pos.currentPrice <= displayStopLoss)
|
||||||
|| (pos.side === 'SELL' && 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 === 'BUY' && pos.currentPrice >= displayTakeProfit)
|
||||||
|| (pos.side === 'SELL' && pos.currentPrice <= displayTakeProfit)
|
|| (pos.side === 'SELL' && pos.currentPrice <= displayTakeProfit)
|
||||||
);
|
);
|
||||||
@ -1335,7 +1342,7 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
|
|||||||
</td>
|
</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-gray-400">${pos.entryPrice.toLocaleString()}</td>
|
||||||
<td className="px-6 py-4 text-xs font-mono text-white font-bold">
|
<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">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{slBreached && (
|
{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">
|
<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">
|
||||||
@ -1356,10 +1363,14 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
|
|||||||
{displayTakeProfit ? `$${displayTakeProfit.toLocaleString()}` : '-'}
|
{displayTakeProfit ? `$${displayTakeProfit.toLocaleString()}` : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right">
|
<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'}`}>
|
{hasPnl && hasPnlPercent ? (
|
||||||
{pos.pnl >= 0 ? '+' : ''}{pos.pnlPercent.toFixed(2)}%
|
<div className={`text-xs font-mono font-black ${pos.pnl >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
<div className="text-[10px] opacity-60">${pos.pnl.toFixed(2)}</div>
|
{pos.pnl >= 0 ? '+' : ''}{pos.pnlPercent.toFixed(2)}%
|
||||||
</div>
|
<div className="text-[10px] opacity-60">${pos.pnl.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs font-mono text-gray-500">-</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right">
|
<td className="px-6 py-4 text-right">
|
||||||
{pos.source === 'BOT' && (
|
{pos.source === 'BOT' && (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user