1877 lines
105 KiB
TypeScript
1877 lines
105 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import type { BotState } from '../hooks/useWebSocket';
|
||
import { getPlatformAccessToken } from '../lib/authSession';
|
||
import { tradingRuntime } from '../lib/runtime';
|
||
import { useAuth } from '../components/AuthContext';
|
||
import { createRequestId } from '../../../shared/request-id.js';
|
||
import { Layers, ListFilter, Link2, GitBranch, AlertTriangle, Lock, RefreshCw, CheckCircle, XCircle } from 'lucide-react';
|
||
import { useCanonicalLifecycle } from '../hooks/useCanonicalLifecycle';
|
||
import { fetchPositionsBootstrap } from '../lib/positionsApi';
|
||
|
||
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;
|
||
stopLoss?: number;
|
||
takeProfit?: number;
|
||
profileId?: string;
|
||
profileName?: string;
|
||
tradeId?: string;
|
||
}
|
||
|
||
interface Profile {
|
||
id: string;
|
||
name: string;
|
||
}
|
||
|
||
interface RawHistoryRecord {
|
||
trade_id?: string;
|
||
profile_id?: string;
|
||
}
|
||
|
||
type OrderSource = 'BOT' | 'MANUAL';
|
||
type OrderAction = 'ENTRY' | 'EXIT';
|
||
|
||
interface RawOrderRecord {
|
||
id?: string;
|
||
order_id?: string;
|
||
profile_id?: string;
|
||
profileId?: string;
|
||
symbol?: string;
|
||
type?: string;
|
||
side?: string;
|
||
qty?: number;
|
||
quantity?: number;
|
||
price?: number;
|
||
status?: string;
|
||
timestamp?: number | string;
|
||
created_at?: string;
|
||
trade_id?: string;
|
||
tradeId?: string;
|
||
action?: string;
|
||
source?: OrderSource;
|
||
stop_loss?: number;
|
||
take_profit?: number;
|
||
stopLoss?: number;
|
||
takeProfit?: number;
|
||
subTag?: string;
|
||
subtag?: string;
|
||
sub_tag?: string;
|
||
}
|
||
|
||
interface NormalizedOrder {
|
||
id: string;
|
||
orderId?: string;
|
||
symbol: string;
|
||
type: string;
|
||
side: 'BUY' | 'SELL';
|
||
qty: number;
|
||
price: number;
|
||
status: string;
|
||
timestamp: number;
|
||
profileId?: string;
|
||
tradeId?: string;
|
||
action?: OrderAction;
|
||
source: OrderSource;
|
||
stopLoss?: number;
|
||
takeProfit?: number;
|
||
subTag?: string;
|
||
}
|
||
|
||
interface LifecycleTrace {
|
||
traceKey: string;
|
||
tradeId: string;
|
||
profileId?: string;
|
||
profileName: string;
|
||
symbol: string;
|
||
side: 'BUY' | 'SELL';
|
||
source: OrderSource;
|
||
entryOrder?: NormalizedOrder;
|
||
exitOrders: NormalizedOrder[];
|
||
orderedEvents: NormalizedOrder[];
|
||
entryFilledQty: number;
|
||
exitFilledQty: number;
|
||
openQty: number;
|
||
entryAvgPrice: number;
|
||
entryUsedUsd: number;
|
||
state: 'OPEN' | 'PARTIAL_EXIT' | 'CLOSED' | 'ORPHAN_EXIT' | 'EXIT_PENDING';
|
||
stateReason: string;
|
||
lastTimestamp: number;
|
||
hasHistoryMatch: boolean;
|
||
hasCancel: boolean;
|
||
}
|
||
|
||
export const normalizeSide = (side?: string): 'BUY' | 'SELL' => {
|
||
const value = (side || '').toUpperCase();
|
||
return value === 'SELL' || value === 'SHORT' ? 'SELL' : 'BUY';
|
||
};
|
||
|
||
export const normalizeAction = (action?: string): OrderAction | undefined => {
|
||
const value = (action || '').toUpperCase();
|
||
if (value === 'ENTRY' || value === 'EXIT') return value;
|
||
return undefined;
|
||
};
|
||
|
||
export const normalizeSource = (source?: string, profileId?: string): OrderSource => {
|
||
const value = (source || '').toUpperCase();
|
||
if (value === 'BOT' || value === 'MANUAL') return value as OrderSource;
|
||
return profileId ? 'BOT' : 'MANUAL';
|
||
};
|
||
|
||
export const toEpoch = (value?: number | string): number => {
|
||
if (typeof value === 'number') return value;
|
||
if (typeof value === 'string') {
|
||
const parsed = new Date(value).getTime();
|
||
return Number.isFinite(parsed) ? parsed : 0;
|
||
}
|
||
return 0;
|
||
};
|
||
|
||
export const parseDateStart = (value: string): number | null => {
|
||
if (!value) return null;
|
||
const parsed = new Date(`${value}T00:00:00`).getTime();
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
};
|
||
|
||
export const parseDateEnd = (value: string): number | null => {
|
||
if (!value) return null;
|
||
const parsed = new Date(`${value}T23:59:59.999`).getTime();
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
};
|
||
|
||
export const toPositiveNumber = (value: unknown): number => {
|
||
const num = Number(value);
|
||
return Number.isFinite(num) && num > 0 ? num : 0;
|
||
};
|
||
|
||
export const normalizeLifecycleSymbolToken = (symbol?: string): string => {
|
||
const raw = String(symbol || '').trim().toUpperCase();
|
||
if (!raw) return '';
|
||
const compact = raw.replace(/[^A-Z0-9]/g, '');
|
||
if (!compact) return '';
|
||
if (compact.endsWith('USDT')) {
|
||
return `${compact.slice(0, -4)}USD`;
|
||
}
|
||
if (compact.endsWith('USDC')) {
|
||
return `${compact.slice(0, -4)}USD`;
|
||
}
|
||
return compact;
|
||
};
|
||
|
||
export const symbolsMatchForLifecycle = (left?: string, right?: string): boolean => {
|
||
const leftToken = normalizeLifecycleSymbolToken(left);
|
||
const rightToken = normalizeLifecycleSymbolToken(right);
|
||
return !!leftToken && !!rightToken && leftToken === rightToken;
|
||
};
|
||
|
||
export const formatDisplayQty = (value: number): string => {
|
||
const qty = Number(value);
|
||
if (!Number.isFinite(qty)) return '0';
|
||
const text = qty.toFixed(8).replace(/\.?0+$/, '');
|
||
return text || '0';
|
||
};
|
||
|
||
export const isLifecycleFilledStatus = (status?: string): boolean => {
|
||
const normalized = (status || '').toLowerCase().replace(/-/g, '_');
|
||
return normalized === 'filled' || normalized === 'partially_filled';
|
||
};
|
||
|
||
export const isPendingLikeStatus = (status?: string): boolean => {
|
||
const normalized = (status || '').toLowerCase().replace(/-/g, '_');
|
||
return normalized === 'pending_new'
|
||
|| normalized === 'pending'
|
||
|| normalized === 'accepted'
|
||
|| normalized === 'new';
|
||
};
|
||
|
||
export const statusRank = (status?: string): number => {
|
||
const normalized = (status || '').toLowerCase().replace(/-/g, '_');
|
||
if (normalized === 'filled') return 6;
|
||
if (normalized === 'partially_filled') return 5;
|
||
if (normalized === 'canceled' || normalized === 'cancelled' || normalized === 'rejected' || normalized === 'expired') return 4;
|
||
if (normalized === 'pending_new' || normalized === 'pending' || normalized === 'accepted' || normalized === 'new') return 2;
|
||
if (normalized === 'unknown') return 1;
|
||
return 2;
|
||
};
|
||
|
||
export const pickMostReliableStatus = (base: NormalizedOrder, incoming: NormalizedOrder): string => {
|
||
const baseStatus = (base.status || '').toLowerCase();
|
||
const incomingStatus = (incoming.status || '').toLowerCase();
|
||
if (!baseStatus) return incomingStatus || 'unknown';
|
||
if (!incomingStatus) return baseStatus || 'unknown';
|
||
|
||
const baseRank = statusRank(baseStatus);
|
||
const incomingRank = statusRank(incomingStatus);
|
||
if (baseRank !== incomingRank) {
|
||
return incomingRank > baseRank ? incomingStatus : baseStatus;
|
||
}
|
||
|
||
return incoming.timestamp >= base.timestamp ? incomingStatus : baseStatus;
|
||
};
|
||
|
||
export const normalizeOrder = (record: RawOrderRecord, fallbackSource?: OrderSource): NormalizedOrder | null => {
|
||
const orderId = record.order_id || record.id;
|
||
const profileId = record.profileId || record.profile_id;
|
||
const symbol = record.symbol || '';
|
||
if (!orderId || !symbol) return null;
|
||
|
||
return {
|
||
id: orderId,
|
||
orderId,
|
||
symbol,
|
||
type: record.type || 'Market',
|
||
side: normalizeSide(record.side),
|
||
qty: toPositiveNumber(record.qty) || toPositiveNumber(record.quantity),
|
||
price: toPositiveNumber(record.price),
|
||
status: (record.status || 'unknown').toLowerCase(),
|
||
timestamp: toEpoch(record.timestamp || record.created_at),
|
||
profileId,
|
||
tradeId: record.trade_id || record.tradeId,
|
||
action: normalizeAction(record.action),
|
||
source: normalizeSource(record.source || fallbackSource, profileId),
|
||
stopLoss: toPositiveNumber(record.stop_loss) || toPositiveNumber(record.stopLoss),
|
||
takeProfit: toPositiveNumber(record.take_profit) || toPositiveNumber(record.takeProfit),
|
||
subTag: String(record.subTag || record.subtag || record.sub_tag || '').trim() || undefined
|
||
};
|
||
};
|
||
|
||
const SourceTruthVariants: Record<'exchange' | 'db' | 'reconciled' | 'unknown', { label: string; className: string }> = {
|
||
exchange: { label: 'Exchange', className: 'truth-pill exchange' },
|
||
db: { label: 'DB', className: 'truth-pill db' },
|
||
reconciled: { label: 'Reconciled', className: 'truth-pill reconciled' },
|
||
unknown: { label: 'Unknown', className: 'truth-pill unknown' }
|
||
};
|
||
|
||
const getTruthSourceForPosition = (entryOrder?: NormalizedOrder, canonicalAvailable: boolean = true) => {
|
||
if (!entryOrder) return SourceTruthVariants.db;
|
||
const status = entryOrder.status || '';
|
||
if (status.includes('pending') || status.includes('new') || status.includes('accepted')) {
|
||
return SourceTruthVariants.exchange;
|
||
}
|
||
if (!canonicalAvailable) {
|
||
return SourceTruthVariants.db;
|
||
}
|
||
return SourceTruthVariants.reconciled;
|
||
};
|
||
|
||
const getTruthSourceForOrder = (order: NormalizedOrder, historyKeys: Set<string>, canonicalAvailable: boolean = true) => {
|
||
const status = order.status || '';
|
||
if (status.includes('pending') || status.includes('new') || status.includes('accepted')) {
|
||
return SourceTruthVariants.exchange;
|
||
}
|
||
if (!canonicalAvailable) {
|
||
return SourceTruthVariants.db;
|
||
}
|
||
const scopedKey = `${order.profileId || 'global'}|${order.tradeId}`;
|
||
if (order.tradeId && (historyKeys.has(scopedKey) || historyKeys.has(`global|${order.tradeId}`))) {
|
||
return SourceTruthVariants.reconciled;
|
||
}
|
||
return SourceTruthVariants.db;
|
||
};
|
||
|
||
export const orderKey = (order: NormalizedOrder): string =>
|
||
`id:${order.orderId || order.id}`;
|
||
|
||
export const mergeOrders = (base: NormalizedOrder, incoming: NormalizedOrder): NormalizedOrder => {
|
||
const newer = incoming.timestamp >= base.timestamp ? incoming : base;
|
||
const older = newer === incoming ? base : incoming;
|
||
|
||
return {
|
||
id: newer.id || older.id,
|
||
orderId: newer.orderId || older.orderId,
|
||
symbol: newer.symbol || older.symbol,
|
||
type: newer.type || older.type,
|
||
side: newer.side || older.side,
|
||
qty: newer.qty > 0 ? newer.qty : older.qty,
|
||
price: newer.price > 0 ? newer.price : older.price,
|
||
status: pickMostReliableStatus(base, incoming),
|
||
timestamp: Math.max(base.timestamp, incoming.timestamp),
|
||
profileId: newer.profileId || older.profileId,
|
||
tradeId: newer.tradeId || older.tradeId,
|
||
action: newer.action || older.action,
|
||
source: newer.source || older.source,
|
||
stopLoss: newer.stopLoss || older.stopLoss,
|
||
takeProfit: newer.takeProfit || older.takeProfit,
|
||
subTag: newer.subTag || older.subTag
|
||
};
|
||
};
|
||
|
||
export const assignLifecycleTradeIds = (
|
||
orders: NormalizedOrder[],
|
||
livePositions: BotState['positions']
|
||
): NormalizedOrder[] => {
|
||
const positionTradeIds = new Map<string, string>();
|
||
for (const pos of livePositions || []) {
|
||
if (!pos?.tradeId) continue;
|
||
const key = `${pos.profileId || 'global'}|${pos.symbol}|${pos.side}`;
|
||
positionTradeIds.set(key, pos.tradeId);
|
||
}
|
||
|
||
const queuesByProfileSymbol = new Map<string, { BUY: string[]; SELL: string[]; counter: number }>();
|
||
const entrySideByScopedTradeId = new Map<string, 'BUY' | 'SELL'>();
|
||
const getQueue = (key: string) => {
|
||
let queue = queuesByProfileSymbol.get(key);
|
||
if (!queue) {
|
||
queue = { BUY: [], SELL: [], counter: 0 };
|
||
queuesByProfileSymbol.set(key, queue);
|
||
}
|
||
return queue;
|
||
};
|
||
|
||
const enriched = [...orders]
|
||
.map((order) => ({ ...order }))
|
||
.sort((a, b) => (a.timestamp - b.timestamp) || a.id.localeCompare(b.id));
|
||
|
||
for (const order of enriched) {
|
||
const profileKey = order.profileId || 'global';
|
||
const queueKey = `${profileKey}|${order.symbol}`;
|
||
const queue = getQueue(queueKey);
|
||
const scopedTradeKey = (tradeId: string) => `${profileKey}|${tradeId}`;
|
||
const side = order.side;
|
||
const oppositeSide: 'BUY' | 'SELL' = side === 'BUY' ? 'SELL' : 'BUY';
|
||
const explicitAction = order.action;
|
||
let resolvedAction: OrderAction | undefined = explicitAction;
|
||
|
||
if (!order.tradeId) {
|
||
const positionKey = `${profileKey}|${order.symbol}|${side}`;
|
||
const positionTradeId = positionTradeIds.get(positionKey);
|
||
if (positionTradeId && (explicitAction === 'ENTRY' || !explicitAction)) {
|
||
order.tradeId = positionTradeId;
|
||
resolvedAction = resolvedAction || 'ENTRY';
|
||
}
|
||
}
|
||
|
||
if (order.tradeId && !resolvedAction) {
|
||
const scopedKey = scopedTradeKey(order.tradeId);
|
||
const knownEntrySide = entrySideByScopedTradeId.get(scopedKey);
|
||
|
||
if (knownEntrySide) {
|
||
resolvedAction = side === knownEntrySide ? 'ENTRY' : 'EXIT';
|
||
} else {
|
||
const queuedOnSameSide = queue[side].includes(order.tradeId);
|
||
const queuedOnOppositeSide = queue[oppositeSide].includes(order.tradeId);
|
||
resolvedAction = queuedOnOppositeSide && !queuedOnSameSide ? 'EXIT' : 'ENTRY';
|
||
}
|
||
}
|
||
|
||
if (!order.tradeId) {
|
||
if (!resolvedAction) {
|
||
resolvedAction = queue[oppositeSide].length > 0 ? 'EXIT' : 'ENTRY';
|
||
}
|
||
|
||
if (resolvedAction === 'ENTRY') {
|
||
order.tradeId = `TRD-LEGACY-${profileKey}-${order.symbol}-${order.timestamp}-${queue.counter++}`;
|
||
} else {
|
||
order.tradeId = queue[oppositeSide].length > 0
|
||
? queue[oppositeSide][0]
|
||
: `TRD-LEGACY-${profileKey}-${order.symbol}-EXIT-${order.timestamp}-${queue.counter++}`;
|
||
}
|
||
}
|
||
|
||
if (!order.action && resolvedAction) {
|
||
order.action = resolvedAction;
|
||
}
|
||
|
||
if (order.tradeId && order.action === 'ENTRY') {
|
||
entrySideByScopedTradeId.set(scopedTradeKey(order.tradeId), order.side);
|
||
} else if (order.tradeId && order.action === 'EXIT') {
|
||
const scopedKey = scopedTradeKey(order.tradeId);
|
||
if (!entrySideByScopedTradeId.has(scopedKey)) {
|
||
entrySideByScopedTradeId.set(scopedKey, oppositeSide);
|
||
}
|
||
}
|
||
|
||
if (order.action === 'ENTRY') {
|
||
if (!queue[side].includes(order.tradeId)) {
|
||
queue[side].push(order.tradeId);
|
||
}
|
||
} else if (order.action === 'EXIT') {
|
||
const matchedIndex = queue[oppositeSide].findIndex((id) => id === order.tradeId);
|
||
if (matchedIndex >= 0) {
|
||
queue[oppositeSide].splice(matchedIndex, 1);
|
||
} else if (queue[oppositeSide].length > 0) {
|
||
queue[oppositeSide].shift();
|
||
}
|
||
}
|
||
}
|
||
|
||
return enriched;
|
||
};
|
||
|
||
export const PositionsTab = ({ botState }: PositionsTabProps) => {
|
||
const { user, profile } = useAuth();
|
||
const [manualPositions, setManualPositions] = useState<HybridPosition[]>([]);
|
||
const [dbOrders, setDbOrders] = useState<RawOrderRecord[]>([]);
|
||
const [historyTradeKeys, setHistoryTradeKeys] = useState<string[]>([]);
|
||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||
const [selectedProfileId, setSelectedProfileId] = useState<string>('all');
|
||
const {
|
||
snapshot: canonicalSnapshot,
|
||
loading: canonicalLoading,
|
||
error: canonicalError
|
||
} = useCanonicalLifecycle(
|
||
selectedProfileId === 'all' ? undefined : selectedProfileId
|
||
);
|
||
const [ordersDateFrom, setOrdersDateFrom] = useState<string>('');
|
||
const [ordersDateTo, setOrdersDateTo] = useState<string>('');
|
||
const [ordersSortDirection, setOrdersSortDirection] = useState<'desc' | 'asc'>('desc');
|
||
const [ordersPage, setOrdersPage] = useState(1);
|
||
const [lifecycleDateFrom, setLifecycleDateFrom] = useState<string>('');
|
||
const [lifecycleDateTo, setLifecycleDateTo] = useState<string>('');
|
||
const [lifecycleSortDirection, setLifecycleSortDirection] = useState<'desc' | 'asc'>('desc');
|
||
const [lifecyclePage, setLifecyclePage] = useState(1);
|
||
const ORDER_FETCH_LIMIT = 5000;
|
||
const ORDER_ACTIVITY_PAGE_SIZE = 20;
|
||
const TRACE_PAGE_SIZE = 10;
|
||
const EPSILON = 1e-8;
|
||
const REFRESH_INTERVAL_MS = 30_000;
|
||
const hasCanonicalLifecycle = Boolean(
|
||
canonicalSnapshot
|
||
&& !canonicalSnapshot.diagnostics?.truncated
|
||
&& canonicalSnapshot.lifecycleRows.length > 0
|
||
);
|
||
|
||
// 1. Fetch Data
|
||
useEffect(() => {
|
||
if (!user) return;
|
||
let cancelled = false;
|
||
|
||
const fetchData = async () => {
|
||
if (cancelled) return;
|
||
let posData: any[] = [];
|
||
let ordData: RawOrderRecord[] | null = [];
|
||
let histData: RawHistoryRecord[] | null = [];
|
||
let profData: Array<{ id: string; name: string }> = [];
|
||
let bootstrapError: Error | null = null;
|
||
try {
|
||
const bootstrap = await fetchPositionsBootstrap({
|
||
scope: profile?.role === 'admin' ? 'all' : 'user',
|
||
limit: ORDER_FETCH_LIMIT
|
||
});
|
||
posData = Array.isArray(bootstrap.entries) ? bootstrap.entries : [];
|
||
ordData = Array.isArray(bootstrap.orders) ? (bootstrap.orders as RawOrderRecord[]) : [];
|
||
histData = Array.isArray(bootstrap.historyTradeKeys) ? (bootstrap.historyTradeKeys as RawHistoryRecord[]) : [];
|
||
profData = (Array.isArray(bootstrap.profiles) ? bootstrap.profiles : []).map((item: any) => ({
|
||
id: String(item.id),
|
||
name: String(item.name || item.id || 'Unnamed Profile')
|
||
}));
|
||
} catch (error) {
|
||
bootstrapError = error as Error;
|
||
}
|
||
if (bootstrapError) {
|
||
console.error('[PositionsTab] Failed loading positions bootstrap:', bootstrapError.message);
|
||
}
|
||
|
||
const tradeKeys = Array.from(new Set(
|
||
((histData as RawHistoryRecord[] | null) || [])
|
||
.map((row) => {
|
||
const tradeId = String(row.trade_id || '').trim();
|
||
if (!tradeId) return '';
|
||
return `${row.profile_id || 'global'}|${tradeId}`;
|
||
})
|
||
.filter(Boolean)
|
||
));
|
||
|
||
if (posData) {
|
||
const positions: HybridPosition[] = posData.map((entry: any) => ({
|
||
source: 'MANUAL' as const,
|
||
id: entry.stock_instance_id,
|
||
symbol: entry.symbol,
|
||
side: 'BUY' as const,
|
||
size: entry.quantity || 0,
|
||
entryPrice: entry.buy_price || 0,
|
||
currentPrice: 0,
|
||
pnl: 0,
|
||
pnlPercent: 0,
|
||
stopLoss: entry.drop_threshold_for_buy,
|
||
takeProfit: entry.gain_threshold_for_sell
|
||
})).filter((p: { size: number; entryPrice: number; }) => p.size > 0 && p.entryPrice > 0);
|
||
setManualPositions(positions);
|
||
}
|
||
|
||
setDbOrders(ordData || []);
|
||
setHistoryTradeKeys(tradeKeys);
|
||
setProfiles((profData as Profile[]) || []);
|
||
};
|
||
|
||
fetchData();
|
||
const refreshTimer = window.setInterval(fetchData, REFRESH_INTERVAL_MS);
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
window.clearInterval(refreshTimer);
|
||
};
|
||
}, [user, profile?.role, botState.history.length, botState.orders.length]);
|
||
|
||
// 2. Build bot positions from real-time Socket.IO data
|
||
const botPositionsRaw: HybridPosition[] = useMemo(() => {
|
||
const deduped = new Map<string, HybridPosition>();
|
||
const score = (position: HybridPosition): number => {
|
||
const tradeScore = position.tradeId ? 4 : 0;
|
||
const profileScore = position.profileId ? 3 : 0;
|
||
const nameScore = position.profileName ? 1 : 0;
|
||
const notional = Math.abs(Number(position.entryPrice || 0) * Number(position.size || 0));
|
||
return tradeScore + profileScore + nameScore + Math.min(notional, 100_000);
|
||
};
|
||
|
||
for (const p of (botState.positions || [])) {
|
||
const normalized: HybridPosition = {
|
||
source: 'BOT' as const,
|
||
id: p.id || `bot-${p.profileId}-${p.symbol}`,
|
||
symbol: p.symbol,
|
||
side: p.side as 'BUY' | 'SELL',
|
||
size: p.size,
|
||
entryPrice: p.entryPrice,
|
||
currentPrice: p.currentPrice || 0,
|
||
pnl: p.unrealizedPnl || 0,
|
||
pnlPercent: p.unrealizedPnlPercent || 0,
|
||
stopLoss: p.stopLoss,
|
||
takeProfit: p.takeProfit,
|
||
profileId: p.profileId,
|
||
profileName: p.profileName,
|
||
tradeId: p.tradeId,
|
||
};
|
||
|
||
const tradeId = String(normalized.tradeId || '').trim();
|
||
const dedupeKey = tradeId
|
||
? `trade:${tradeId}`
|
||
: `${normalized.profileId || 'global'}|${normalized.symbol}|${normalized.side}`;
|
||
const existing = deduped.get(dedupeKey);
|
||
if (!existing) {
|
||
deduped.set(dedupeKey, normalized);
|
||
continue;
|
||
}
|
||
|
||
const preferred = score(normalized) >= score(existing) ? normalized : existing;
|
||
const fallback = preferred === normalized ? existing : normalized;
|
||
deduped.set(dedupeKey, {
|
||
...fallback,
|
||
...preferred,
|
||
profileId: preferred.profileId || fallback.profileId,
|
||
profileName: preferred.profileName || fallback.profileName,
|
||
tradeId: preferred.tradeId || fallback.tradeId
|
||
});
|
||
}
|
||
return Array.from(deduped.values());
|
||
}, [botState.positions]);
|
||
|
||
const managedSymbols = useMemo(() => {
|
||
return new Set(Object.keys(botState.symbols || {}).map((symbol) => String(symbol).toUpperCase()));
|
||
}, [botState.symbols]);
|
||
const hasManagedSymbolScope = managedSymbols.size > 0;
|
||
const managedSymbolTokens = useMemo(() => {
|
||
return new Set(
|
||
Array.from(managedSymbols)
|
||
.map((symbol) => normalizeLifecycleSymbolToken(symbol))
|
||
.filter(Boolean)
|
||
);
|
||
}, [managedSymbols]);
|
||
|
||
// Filter by Profile
|
||
const filteredBotPositions = selectedProfileId === 'all'
|
||
? botPositionsRaw
|
||
: botPositionsRaw.filter(p => p.profileId === selectedProfileId);
|
||
|
||
const filteredManualPositions = selectedProfileId === 'all' ? manualPositions : [];
|
||
|
||
const resolvedOrders = useMemo(() => {
|
||
const normalizedBotOrders = (botState.orders || [])
|
||
.map((order) => normalizeOrder(order as RawOrderRecord, 'BOT'))
|
||
.filter((order): order is NormalizedOrder => !!order);
|
||
|
||
const normalizedDbOrders = dbOrders
|
||
.map((order) => normalizeOrder(order, order.profile_id ? 'BOT' : 'MANUAL'))
|
||
.filter((order): order is NormalizedOrder => !!order);
|
||
|
||
const mergedByKey = new Map<string, NormalizedOrder>();
|
||
|
||
for (const order of normalizedDbOrders) {
|
||
const key = orderKey(order);
|
||
const existing = mergedByKey.get(key);
|
||
if (!existing) {
|
||
mergedByKey.set(key, order);
|
||
continue;
|
||
}
|
||
mergedByKey.set(key, mergeOrders(existing, order));
|
||
}
|
||
|
||
// Keep DB rows authoritative for lifecycle state; runtime rows only enrich missing metadata.
|
||
for (const order of normalizedBotOrders) {
|
||
const key = orderKey(order);
|
||
const existing = mergedByKey.get(key);
|
||
if (!existing) {
|
||
mergedByKey.set(key, order);
|
||
continue;
|
||
}
|
||
|
||
mergedByKey.set(key, {
|
||
...existing,
|
||
profileId: existing.profileId || order.profileId,
|
||
tradeId: existing.tradeId || order.tradeId,
|
||
action: existing.action || order.action,
|
||
stopLoss: existing.stopLoss || order.stopLoss,
|
||
takeProfit: existing.takeProfit || order.takeProfit,
|
||
subTag: existing.subTag || order.subTag
|
||
});
|
||
}
|
||
|
||
const withLifecycleIds = assignLifecycleTradeIds(
|
||
Array.from(mergedByKey.values()),
|
||
botState.positions || []
|
||
);
|
||
|
||
const historyTradeKeySet = new Set(historyTradeKeys);
|
||
const staleResolvedOrders = withLifecycleIds.map((order) => {
|
||
const isPendingLike = isPendingLikeStatus(order.status);
|
||
if (!isPendingLike || order.action !== 'EXIT' || !order.tradeId) {
|
||
return order;
|
||
}
|
||
|
||
const scopedTradeKey = `${order.profileId || 'global'}|${order.tradeId}`;
|
||
const hasClosedHistory = historyTradeKeySet.has(scopedTradeKey) || historyTradeKeySet.has(`global|${order.tradeId}`);
|
||
if (!hasClosedHistory) {
|
||
return order;
|
||
}
|
||
|
||
return {
|
||
...order,
|
||
status: 'canceled'
|
||
};
|
||
});
|
||
|
||
return staleResolvedOrders
|
||
.filter((order) => selectedProfileId === 'all' || order.profileId === selectedProfileId)
|
||
.sort((a, b) => (b.timestamp - a.timestamp) || b.id.localeCompare(a.id));
|
||
}, [botState.orders, botState.positions, dbOrders, historyTradeKeys, selectedProfileId]);
|
||
|
||
const ordersDateBounds = useMemo(() => ({
|
||
from: parseDateStart(ordersDateFrom),
|
||
to: parseDateEnd(ordersDateTo)
|
||
}), [ordersDateFrom, ordersDateTo]);
|
||
|
||
const filteredOrdersForActivity = useMemo(() => {
|
||
return resolvedOrders.filter((order) => {
|
||
if (ordersDateBounds.from !== null && order.timestamp < ordersDateBounds.from) return false;
|
||
if (ordersDateBounds.to !== null && order.timestamp > ordersDateBounds.to) return false;
|
||
return true;
|
||
});
|
||
}, [resolvedOrders, ordersDateBounds.from, ordersDateBounds.to]);
|
||
|
||
const sortedOrdersForActivity = useMemo(() => {
|
||
const direction = ordersSortDirection === 'asc' ? 1 : -1;
|
||
return [...filteredOrdersForActivity].sort((a, b) => {
|
||
if (a.timestamp !== b.timestamp) {
|
||
return direction * (a.timestamp - b.timestamp);
|
||
}
|
||
return direction * a.id.localeCompare(b.id);
|
||
});
|
||
}, [filteredOrdersForActivity, ordersSortDirection]);
|
||
|
||
const ordersTotalPages = useMemo(
|
||
() => Math.max(1, Math.ceil(sortedOrdersForActivity.length / ORDER_ACTIVITY_PAGE_SIZE)),
|
||
[sortedOrdersForActivity.length, ORDER_ACTIVITY_PAGE_SIZE]
|
||
);
|
||
|
||
useEffect(() => {
|
||
setOrdersPage(1);
|
||
}, [selectedProfileId, ordersDateFrom, ordersDateTo]);
|
||
|
||
useEffect(() => {
|
||
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 entryOrdersLookup = useMemo(() => {
|
||
const byScopedTrade = new Map<string, NormalizedOrder>();
|
||
const byTrade = new Map<string, NormalizedOrder>();
|
||
for (const order of resolvedOrders) {
|
||
if (order.action !== 'ENTRY' || !order.tradeId) continue;
|
||
const scopedKey = `${order.profileId || 'global'}|${order.tradeId}`;
|
||
const scopedExisting = byScopedTrade.get(scopedKey);
|
||
if (!scopedExisting || order.timestamp < scopedExisting.timestamp) {
|
||
byScopedTrade.set(scopedKey, order);
|
||
}
|
||
const globalExisting = byTrade.get(order.tradeId);
|
||
if (!globalExisting || order.timestamp < globalExisting.timestamp) {
|
||
byTrade.set(order.tradeId, order);
|
||
}
|
||
}
|
||
return { byScopedTrade, byTrade };
|
||
}, [resolvedOrders]);
|
||
|
||
const resolveEntryOrder = (tradeId?: string, profileId?: string): NormalizedOrder | undefined => {
|
||
if (!tradeId) return undefined;
|
||
const scopedKey = `${profileId || 'global'}|${tradeId}`;
|
||
return entryOrdersLookup.byScopedTrade.get(scopedKey) || entryOrdersLookup.byTrade.get(tradeId);
|
||
};
|
||
|
||
const lifecycleTraces = useMemo(() => {
|
||
if (canonicalSnapshot && canonicalSnapshot.lifecycleRows.length > 0) {
|
||
return canonicalSnapshot.lifecycleRows
|
||
.filter((row) => selectedProfileId === 'all' || row.profileId === selectedProfileId)
|
||
.map((row) => {
|
||
const entryOrder: NormalizedOrder | undefined = row.entryQty > EPSILON ? {
|
||
id: `entry:${row.id}`,
|
||
orderId: undefined,
|
||
symbol: row.symbol,
|
||
type: 'Market',
|
||
side: row.side,
|
||
qty: Number(row.entryQty || 0),
|
||
price: Number(row.entryAvgPrice || row.openEntryAvgPrice || 0),
|
||
status: 'filled',
|
||
timestamp: Number(row.lastEventAt || 0),
|
||
profileId: row.profileId,
|
||
tradeId: row.tradeId,
|
||
action: 'ENTRY',
|
||
source: 'BOT',
|
||
stopLoss: Number(row.stopLoss || 0) || undefined,
|
||
takeProfit: Number(row.takeProfit || 0) || undefined,
|
||
subTag: String(row.subTag || '').trim() || undefined
|
||
} : undefined;
|
||
|
||
const exitOrders: NormalizedOrder[] = Number(row.exitQty || 0) > EPSILON ? [{
|
||
id: `exit:${row.id}`,
|
||
orderId: undefined,
|
||
symbol: row.symbol,
|
||
type: 'Market',
|
||
side: row.side === 'BUY' ? 'SELL' : 'BUY',
|
||
qty: Number(row.exitQty || 0),
|
||
price: Number(row.exitAvgPrice || 0),
|
||
status: 'filled',
|
||
timestamp: Number(row.lastEventAt || 0),
|
||
profileId: row.profileId,
|
||
tradeId: row.tradeId,
|
||
action: 'EXIT',
|
||
source: 'BOT',
|
||
subTag: String(row.subTag || '').trim() || undefined
|
||
}] : [];
|
||
|
||
const orderedEvents: NormalizedOrder[] = [
|
||
...(entryOrder ? [entryOrder] : []),
|
||
...exitOrders
|
||
];
|
||
|
||
const stateReason = row.state === 'PARTIAL_EXIT'
|
||
? `Partial exit: ${Number(row.exitQty || 0).toFixed(4)} closed of ${Number(row.entryQty || 0).toFixed(4)}. Remaining quantity stays active in Open Positions.`
|
||
: row.state === 'CLOSED'
|
||
? 'Entry and exit fills are fully matched.'
|
||
: row.state === 'ORPHAN_EXIT'
|
||
? 'Exit fill found but entry leg is missing from current order data window.'
|
||
: 'Entry filled and waiting for matching exit.';
|
||
|
||
return {
|
||
traceKey: `${row.profileId}|${row.tradeId}`,
|
||
tradeId: row.tradeId,
|
||
profileId: row.profileId,
|
||
profileName: row.profileName,
|
||
symbol: row.symbol,
|
||
side: row.side,
|
||
source: 'BOT' as const,
|
||
entryOrder,
|
||
exitOrders,
|
||
orderedEvents,
|
||
entryFilledQty: Number(row.entryQty || 0),
|
||
exitFilledQty: Number(row.exitQty || 0),
|
||
openQty: Number(row.openQty || 0),
|
||
entryAvgPrice: Number(row.entryAvgPrice || row.openEntryAvgPrice || 0),
|
||
entryUsedUsd: Number((Number(row.entryQty || 0) * Number(row.entryAvgPrice || 0)).toFixed(8)),
|
||
state: row.state as LifecycleTrace['state'],
|
||
stateReason,
|
||
lastTimestamp: Number(row.lastEventAt || 0),
|
||
hasHistoryMatch: row.state === 'CLOSED',
|
||
hasCancel: false
|
||
} as LifecycleTrace;
|
||
})
|
||
.sort((a, b) => b.lastTimestamp - a.lastTimestamp);
|
||
}
|
||
|
||
const grouped = new Map<string, { tradeId: string; profileId?: string; orders: NormalizedOrder[] }>();
|
||
for (const order of resolvedOrders) {
|
||
if (!order.tradeId) continue;
|
||
const traceKey = `${order.profileId || 'global'}|${order.tradeId}`;
|
||
if (!grouped.has(traceKey)) {
|
||
grouped.set(traceKey, { tradeId: order.tradeId, profileId: order.profileId, orders: [] });
|
||
}
|
||
grouped.get(traceKey)!.orders.push(order);
|
||
}
|
||
|
||
const traces: LifecycleTrace[] = [];
|
||
for (const [traceKey, group] of grouped.entries()) {
|
||
const orderedBase = [...group.orders].sort((a, b) => (a.timestamp - b.timestamp) || a.id.localeCompare(b.id));
|
||
const explicitEntry = orderedBase.find((event) => event.action === 'ENTRY');
|
||
const explicitExit = orderedBase.find((event) => event.action === 'EXIT');
|
||
const lifecycleSide: 'BUY' | 'SELL' = explicitEntry?.side
|
||
|| (explicitExit ? (explicitExit.side === 'BUY' ? 'SELL' : 'BUY') : orderedBase[0]?.side || 'BUY');
|
||
|
||
const orderedEvents = orderedBase.map((event) => ({
|
||
...event,
|
||
action: event.action || (event.side === lifecycleSide ? 'ENTRY' : 'EXIT')
|
||
}));
|
||
|
||
const entryOrders = orderedEvents.filter((o) => o.action === 'ENTRY');
|
||
const exitOrders = orderedEvents.filter((o) => o.action === 'EXIT');
|
||
const entryOrder = entryOrders[0];
|
||
const representative = entryOrder || orderedEvents[0];
|
||
if (!representative) continue;
|
||
|
||
const entryFilledQty = entryOrders.reduce((sum, order) => (
|
||
isLifecycleFilledStatus(order.status) ? sum + (order.qty || 0) : sum
|
||
), 0);
|
||
const exitFilledQty = exitOrders.reduce((sum, order) => (
|
||
isLifecycleFilledStatus(order.status) ? sum + (order.qty || 0) : sum
|
||
), 0);
|
||
const openQty = Math.max(entryFilledQty - exitFilledQty, 0);
|
||
const entryUsedUsd = entryOrders.reduce((sum, order) => (
|
||
isLifecycleFilledStatus(order.status) ? sum + ((order.qty || 0) * (order.price || 0)) : sum
|
||
), 0);
|
||
const entryAvgPrice = entryFilledQty > EPSILON
|
||
? (entryUsedUsd > 0 ? (entryUsedUsd / entryFilledQty) : (entryOrder?.price || 0))
|
||
: (entryOrder?.price || 0);
|
||
|
||
const hasScopedHistoryMatch = historyTradeKeySet.has(traceKey);
|
||
const hasGlobalHistoryMatch = historyTradeKeySet.has(`global|${group.tradeId}`);
|
||
const hasHistoryMatch = hasScopedHistoryMatch || hasGlobalHistoryMatch;
|
||
const hasCancel = orderedEvents.some((event) => (event.status || '').includes('cancel'));
|
||
|
||
let state: LifecycleTrace['state'] = 'OPEN';
|
||
if (entryOrders.length === 0 && exitOrders.length > 0) {
|
||
if (hasHistoryMatch || exitFilledQty > EPSILON) {
|
||
state = hasHistoryMatch ? 'CLOSED' : 'ORPHAN_EXIT';
|
||
} else {
|
||
state = 'EXIT_PENDING';
|
||
}
|
||
} else if (openQty <= EPSILON && (exitFilledQty > EPSILON || hasHistoryMatch)) {
|
||
state = 'CLOSED';
|
||
} else if (exitFilledQty > EPSILON && openQty > EPSILON) {
|
||
state = 'PARTIAL_EXIT';
|
||
}
|
||
|
||
let stateReason = 'Entry filled and waiting for matching exit.';
|
||
if (state === 'PARTIAL_EXIT') {
|
||
stateReason = `Partial exit: ${exitFilledQty.toFixed(4)} closed of ${entryFilledQty.toFixed(4)}. Remaining quantity stays active in Open Positions.`;
|
||
} else if (state === 'CLOSED') {
|
||
stateReason = hasHistoryMatch
|
||
? 'Lifecycle closed and recorded in trade history.'
|
||
: 'Entry and exit fills are fully matched.';
|
||
} else if (state === 'ORPHAN_EXIT') {
|
||
stateReason = 'Exit fill found but entry leg is missing from current order data window.';
|
||
} else if (state === 'EXIT_PENDING') {
|
||
stateReason = 'Exit submitted but not filled yet. Waiting for exchange sync.';
|
||
}
|
||
|
||
const profileName = representative.profileId
|
||
? (profiles.find((p) => p.id === representative.profileId)?.name || representative.profileId)
|
||
: representative.source;
|
||
|
||
traces.push({
|
||
traceKey,
|
||
tradeId: group.tradeId,
|
||
profileId: representative.profileId,
|
||
profileName,
|
||
symbol: representative.symbol,
|
||
side: lifecycleSide,
|
||
source: representative.source,
|
||
entryOrder,
|
||
exitOrders,
|
||
orderedEvents,
|
||
entryFilledQty,
|
||
exitFilledQty,
|
||
openQty,
|
||
entryAvgPrice,
|
||
entryUsedUsd,
|
||
state,
|
||
stateReason,
|
||
lastTimestamp: orderedEvents[orderedEvents.length - 1]?.timestamp || representative.timestamp,
|
||
hasHistoryMatch,
|
||
hasCancel,
|
||
});
|
||
}
|
||
|
||
return traces
|
||
.sort((a, b) => b.lastTimestamp - a.lastTimestamp);
|
||
}, [hasCanonicalLifecycle, canonicalSnapshot, selectedProfileId, resolvedOrders, profiles, historyTradeKeySet, EPSILON]);
|
||
|
||
const lifecycleDateBounds = useMemo(() => ({
|
||
from: parseDateStart(lifecycleDateFrom),
|
||
to: parseDateEnd(lifecycleDateTo)
|
||
}), [lifecycleDateFrom, lifecycleDateTo]);
|
||
|
||
const filteredLifecycleTraces = useMemo(() => {
|
||
return lifecycleTraces.filter((trace) => {
|
||
if (lifecycleDateBounds.from !== null && trace.lastTimestamp < lifecycleDateBounds.from) return false;
|
||
if (lifecycleDateBounds.to !== null && trace.lastTimestamp > lifecycleDateBounds.to) return false;
|
||
return true;
|
||
});
|
||
}, [lifecycleTraces, lifecycleDateBounds.from, lifecycleDateBounds.to]);
|
||
|
||
const sortedLifecycleTraces = useMemo(() => {
|
||
const direction = lifecycleSortDirection === 'asc' ? 1 : -1;
|
||
return [...filteredLifecycleTraces].sort((a, b) => {
|
||
if (a.lastTimestamp !== b.lastTimestamp) {
|
||
return direction * (a.lastTimestamp - b.lastTimestamp);
|
||
}
|
||
return direction * a.traceKey.localeCompare(b.traceKey);
|
||
});
|
||
}, [filteredLifecycleTraces, lifecycleSortDirection]);
|
||
|
||
const lifecycleTotalPages = useMemo(
|
||
() => Math.max(1, Math.ceil(sortedLifecycleTraces.length / TRACE_PAGE_SIZE)),
|
||
[sortedLifecycleTraces.length, TRACE_PAGE_SIZE]
|
||
);
|
||
|
||
useEffect(() => {
|
||
setLifecyclePage(1);
|
||
}, [selectedProfileId, lifecycleDateFrom, lifecycleDateTo]);
|
||
|
||
useEffect(() => {
|
||
setLifecyclePage((current) => Math.min(current, lifecycleTotalPages));
|
||
}, [lifecycleTotalPages]);
|
||
|
||
const paginatedLifecycleTraces = useMemo(() => {
|
||
const startIndex = (lifecyclePage - 1) * TRACE_PAGE_SIZE;
|
||
return sortedLifecycleTraces.slice(startIndex, startIndex + TRACE_PAGE_SIZE);
|
||
}, [sortedLifecycleTraces, lifecyclePage, TRACE_PAGE_SIZE]);
|
||
|
||
const lifecycleSummary = useMemo(() => {
|
||
const summary = {
|
||
OPEN: 0,
|
||
PARTIAL_EXIT: 0,
|
||
CLOSED: 0,
|
||
ORPHAN_EXIT: 0,
|
||
EXIT_PENDING: 0
|
||
};
|
||
|
||
for (const trace of filteredLifecycleTraces) {
|
||
summary[trace.state] += 1;
|
||
}
|
||
|
||
return summary;
|
||
}, [filteredLifecycleTraces]);
|
||
|
||
const allPositions = useMemo(() => {
|
||
if (!hasCanonicalLifecycle) {
|
||
const deduped = new Map<string, HybridPosition>();
|
||
for (const position of [...filteredBotPositions, ...filteredManualPositions]) {
|
||
const symbol = String(position.symbol || '').trim();
|
||
if (!symbol) continue;
|
||
if (
|
||
hasManagedSymbolScope
|
||
&& position.source === 'BOT'
|
||
&& !managedSymbolTokens.has(normalizeLifecycleSymbolToken(symbol))
|
||
) {
|
||
continue;
|
||
}
|
||
const key = position.tradeId
|
||
? `trade:${position.profileId || 'global'}|${position.tradeId}`
|
||
: `${position.source}:${position.profileId || 'global'}|${symbol}|${position.side}`;
|
||
if (!deduped.has(key)) {
|
||
deduped.set(key, position);
|
||
}
|
||
}
|
||
return Array.from(deduped.values()).sort((a, b) => {
|
||
const sourceRankA = a.source === 'BOT' ? 0 : 1;
|
||
const sourceRankB = b.source === 'BOT' ? 0 : 1;
|
||
if (sourceRankA !== sourceRankB) return sourceRankA - sourceRankB;
|
||
const notionalA = Math.abs(Number(a.entryPrice || 0) * Number(a.size || 0));
|
||
const notionalB = Math.abs(Number(b.entryPrice || 0) * Number(b.size || 0));
|
||
return notionalB - notionalA;
|
||
});
|
||
}
|
||
if (canonicalSnapshot && canonicalSnapshot.lifecycleRows.length > 0) {
|
||
const canonicalOpen = canonicalSnapshot.openPositions
|
||
.filter((position) => selectedProfileId === 'all' || position.profileId === selectedProfileId)
|
||
.filter((position) => {
|
||
const symbol = String(position.symbol || '').trim();
|
||
if (!symbol) return false;
|
||
if (hasManagedSymbolScope && !managedSymbolTokens.has(normalizeLifecycleSymbolToken(symbol))) return false;
|
||
return Number(position.size || 0) > EPSILON;
|
||
})
|
||
.map((position) => ({
|
||
source: 'BOT' as const,
|
||
id: position.id,
|
||
symbol: position.symbol,
|
||
side: position.side,
|
||
size: Number(position.size || 0),
|
||
entryPrice: Number(position.entryPrice || 0),
|
||
currentPrice: Number(position.currentPrice || 0),
|
||
pnl: Number(position.pnl || 0),
|
||
pnlPercent: Number(position.pnlPercent || 0),
|
||
stopLoss: Number(position.stopLoss || 0) || undefined,
|
||
takeProfit: Number(position.takeProfit || 0) || undefined,
|
||
profileId: position.profileId,
|
||
profileName: position.profileName,
|
||
tradeId: position.tradeId
|
||
} as HybridPosition));
|
||
|
||
const deduped = new Map<string, HybridPosition>();
|
||
for (const position of [...canonicalOpen, ...filteredManualPositions]) {
|
||
const symbol = String(position.symbol || '').trim();
|
||
if (!symbol) continue;
|
||
const key = position.tradeId
|
||
? `trade:${position.profileId || 'global'}|${position.tradeId}`
|
||
: `${position.source}:${position.profileId || 'global'}|${symbol}|${position.side}`;
|
||
if (!deduped.has(key)) {
|
||
deduped.set(key, position);
|
||
}
|
||
}
|
||
|
||
return Array.from(deduped.values()).sort((a, b) => {
|
||
const sourceRankA = a.source === 'BOT' ? 0 : 1;
|
||
const sourceRankB = b.source === 'BOT' ? 0 : 1;
|
||
if (sourceRankA !== sourceRankB) return sourceRankA - sourceRankB;
|
||
const notionalA = Math.abs(Number(a.entryPrice || 0) * Number(a.size || 0));
|
||
const notionalB = Math.abs(Number(b.entryPrice || 0) * Number(b.size || 0));
|
||
return notionalB - notionalA;
|
||
});
|
||
}
|
||
return [...filteredManualPositions].sort((a, b) => {
|
||
const sourceRankA = a.source === 'BOT' ? 0 : 1;
|
||
const sourceRankB = b.source === 'BOT' ? 0 : 1;
|
||
if (sourceRankA !== sourceRankB) return sourceRankA - sourceRankB;
|
||
const notionalA = Math.abs(Number(a.entryPrice || 0) * Number(a.size || 0));
|
||
const notionalB = Math.abs(Number(b.entryPrice || 0) * Number(b.size || 0));
|
||
return notionalB - notionalA;
|
||
});
|
||
}, [
|
||
hasCanonicalLifecycle,
|
||
canonicalSnapshot,
|
||
selectedProfileId,
|
||
filteredBotPositions,
|
||
filteredManualPositions,
|
||
hasManagedSymbolScope,
|
||
managedSymbolTokens,
|
||
EPSILON
|
||
]);
|
||
|
||
const positionMismatches = useMemo(() => {
|
||
const runtimeMismatches = filteredBotPositions
|
||
.filter((pos) => {
|
||
const symbol = String(pos.symbol || '').trim();
|
||
if (!symbol) return false;
|
||
if (hasManagedSymbolScope && !managedSymbolTokens.has(normalizeLifecycleSymbolToken(symbol))) return false;
|
||
return true;
|
||
})
|
||
.map((pos) => {
|
||
if (!pos.tradeId) {
|
||
return {
|
||
id: `${pos.id}-missing-trade`,
|
||
severity: 'warning' as const,
|
||
profileName: pos.profileName || profiles.find((p) => p.id === pos.profileId)?.name || 'BOT',
|
||
symbol: pos.symbol,
|
||
tradeId: 'N/A',
|
||
reason: 'Position has no trade ID. Lifecycle tracing is degraded.'
|
||
};
|
||
}
|
||
|
||
const scopedKey = `${pos.profileId || 'global'}|${pos.tradeId}`;
|
||
const entryOrder = entryOrdersLookup.byScopedTrade.get(scopedKey)
|
||
|| entryOrdersLookup.byTrade.get(pos.tradeId);
|
||
if (!entryOrder) {
|
||
return {
|
||
id: `${pos.id}-missing-entry`,
|
||
severity: 'critical' as const,
|
||
profileName: pos.profileName || profiles.find((p) => p.id === pos.profileId)?.name || 'BOT',
|
||
symbol: pos.symbol,
|
||
tradeId: pos.tradeId,
|
||
reason: 'Position has trade ID but no matching ENTRY order.'
|
||
};
|
||
}
|
||
|
||
if (!symbolsMatchForLifecycle(entryOrder.symbol, pos.symbol)) {
|
||
return {
|
||
id: `${pos.id}-symbol-mismatch`,
|
||
severity: 'critical' as const,
|
||
profileName: pos.profileName || profiles.find((p) => p.id === pos.profileId)?.name || 'BOT',
|
||
symbol: pos.symbol,
|
||
tradeId: pos.tradeId,
|
||
reason: `Trade ID resolves to ${entryOrder.symbol} entry, but position symbol is ${pos.symbol}.`
|
||
};
|
||
}
|
||
|
||
if ((entryOrder.profileId || 'global') !== (pos.profileId || 'global')) {
|
||
return {
|
||
id: `${pos.id}-profile-mismatch`,
|
||
severity: 'critical' as const,
|
||
profileName: pos.profileName || profiles.find((p) => p.id === pos.profileId)?.name || 'BOT',
|
||
symbol: pos.symbol,
|
||
tradeId: pos.tradeId,
|
||
reason: 'Trade ID entry order belongs to a different profile scope.'
|
||
};
|
||
}
|
||
|
||
return null;
|
||
})
|
||
.filter((issue): issue is NonNullable<typeof issue> => !!issue);
|
||
|
||
const canonicalMismatches = (canonicalSnapshot && canonicalSnapshot.lifecycleRows.length > 0)
|
||
? canonicalSnapshot.lifecycleRows
|
||
.filter((row) => selectedProfileId === 'all' || row.profileId === selectedProfileId)
|
||
.filter((row) => row.state === 'ORPHAN_EXIT' || (Number(row.openQty || 0) > EPSILON && Number(row.entryQty || 0) <= EPSILON))
|
||
.map((row) => ({
|
||
id: `${row.id}-canonical-mismatch`,
|
||
severity: 'critical' as const,
|
||
profileName: row.profileName || profiles.find((p) => p.id === row.profileId)?.name || 'BOT',
|
||
symbol: row.symbol,
|
||
tradeId: row.tradeId,
|
||
reason: row.state === 'ORPHAN_EXIT'
|
||
? 'Lifecycle has EXIT fill without a matching ENTRY chain.'
|
||
: 'Open lifecycle is missing a valid ENTRY fill.'
|
||
}))
|
||
: [];
|
||
|
||
const deduped = new Map<string, (typeof runtimeMismatches)[number]>();
|
||
for (const issue of [...canonicalMismatches, ...runtimeMismatches]) {
|
||
const key = `${issue.profileName}|${issue.symbol}|${issue.tradeId}|${issue.reason}`;
|
||
if (!deduped.has(key)) {
|
||
deduped.set(key, issue);
|
||
}
|
||
}
|
||
|
||
return Array.from(deduped.values());
|
||
}, [
|
||
hasCanonicalLifecycle,
|
||
canonicalSnapshot,
|
||
selectedProfileId,
|
||
filteredBotPositions,
|
||
entryOrdersLookup,
|
||
profiles,
|
||
hasManagedSymbolScope,
|
||
managedSymbolTokens,
|
||
EPSILON
|
||
]);
|
||
|
||
return (
|
||
<div className="positions-tab space-y-6">
|
||
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||
<div className="tab-header">
|
||
<h2 className="text-2xl font-bold text-white tracking-tight">Positions & Orders</h2>
|
||
<p className="text-gray-400 text-sm">Real-time isolation by strategy profile.</p>
|
||
</div>
|
||
|
||
<div className="flex gap-2 border-b border-white/5 items-end">
|
||
<button
|
||
onClick={() => setSelectedProfileId('all')}
|
||
className={`px-4 py-3 text-sm font-semibold transition-all border-b-2 ${selectedProfileId === 'all'
|
||
? 'border-[#00ff88] text-[#00ff88]'
|
||
: 'border-transparent text-gray-500 hover:text-white'
|
||
}`}
|
||
>
|
||
Global
|
||
</button>
|
||
{profiles.map(p => (
|
||
<button
|
||
key={p.id}
|
||
onClick={() => setSelectedProfileId(p.id)}
|
||
className={`px-4 py-3 text-sm font-semibold transition-all border-b-2 ${selectedProfileId === p.id
|
||
? 'border-[#00ff88] text-[#00ff88]'
|
||
: 'border-transparent text-gray-500 hover:text-white'
|
||
}`}
|
||
>
|
||
{p.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</header>
|
||
|
||
{!hasCanonicalLifecycle && (
|
||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 text-amber-300 text-xs px-3 py-2">
|
||
Canonical lifecycle is unavailable{canonicalLoading ? ' (loading)' : ''}. Truth labels are in fallback mode (DB/runtime-derived).
|
||
{canonicalError ? ` ${canonicalError}` : ''}
|
||
</div>
|
||
)}
|
||
{canonicalSnapshot?.diagnostics?.truncated && (
|
||
<div className="rounded-xl border border-red-500/30 bg-red-500/10 text-red-300 text-xs px-3 py-2">
|
||
Canonical lifecycle snapshot is truncated ({canonicalSnapshot.diagnostics.orderRows} rows). Narrow profile scope before operational decisions.
|
||
</div>
|
||
)}
|
||
|
||
{/* 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 (
|
||
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4 flex items-start gap-3">
|
||
<span className="text-yellow-400 text-xl">⚠️</span>
|
||
<div className="flex-1">
|
||
<h4 className="text-yellow-400 font-bold text-sm mb-1">
|
||
{staleOrders.length} Stale Order{staleOrders.length > 1 ? 's' : ''} Detected
|
||
</h4>
|
||
<p className="text-gray-400 text-xs">
|
||
Some orders have been in <code className="bg-black/30 px-1 rounded">pending_new</code> status for more than 5 minutes.
|
||
The background sync service is checking their actual status with the exchange.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{positionMismatches.length > 0 && (
|
||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4">
|
||
<div className="flex items-start gap-3">
|
||
<AlertTriangle className="text-red-400 mt-0.5" size={18} />
|
||
<div className="flex-1 space-y-3">
|
||
<div>
|
||
<h4 className="text-red-400 font-bold text-sm">
|
||
Lifecycle Mismatch Diagnostics ({positionMismatches.length})
|
||
</h4>
|
||
<p className="text-gray-400 text-xs">
|
||
One or more open positions do not have a clean entry-order lineage by profile and trade ID.
|
||
</p>
|
||
</div>
|
||
<div className="space-y-2">
|
||
{positionMismatches.map((issue) => (
|
||
<div key={issue.id} className="flex flex-wrap items-center gap-2 text-[10px] border border-white/10 rounded px-2 py-1">
|
||
<span className={`px-1.5 py-0.5 rounded font-bold uppercase tracking-wider ${issue.severity === 'critical'
|
||
? 'bg-red-500/20 text-red-300 border border-red-500/20'
|
||
: 'bg-yellow-500/20 text-yellow-300 border border-yellow-500/20'
|
||
}`}>
|
||
{issue.severity}
|
||
</span>
|
||
<span className="text-gray-300 font-semibold">{issue.profileName}</span>
|
||
<span className="text-white font-mono">{issue.symbol}</span>
|
||
<span className="text-zinc-500 font-mono">{issue.tradeId}</span>
|
||
<span className="text-zinc-400">{issue.reason}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<section className="positions-section">
|
||
<div className="flex items-center gap-2 mb-4">
|
||
<Layers size={18} className="text-blue-400" />
|
||
<h3 className="text-lg font-bold text-gray-200">Open Positions</h3>
|
||
</div>
|
||
<div className="table-container bg-black/20 border border-white/5 rounded-2xl overflow-hidden">
|
||
<table className="pro-table w-full">
|
||
<thead>
|
||
<tr className="bg-white/[0.02] text-left">
|
||
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Source</th>
|
||
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Truth</th>
|
||
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Trade</th>
|
||
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Asset</th>
|
||
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Side</th>
|
||
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Size</th>
|
||
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Entry</th>
|
||
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Current</th>
|
||
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">SL</th>
|
||
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">TP</th>
|
||
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500 text-right">P/L (%)</th>
|
||
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500 text-right">Action</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-white/5">
|
||
{allPositions.length > 0 ? (
|
||
allPositions.map(pos => {
|
||
const entryRisk = resolveEntryOrder(pos.tradeId, pos.profileId);
|
||
const positionTruth = getTruthSourceForPosition(entryRisk, hasCanonicalLifecycle);
|
||
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 && (
|
||
(pos.side === 'BUY' && pos.currentPrice <= displayStopLoss)
|
||
|| (pos.side === 'SELL' && pos.currentPrice >= displayStopLoss)
|
||
);
|
||
const tpHit = displayTakeProfit > 0 && (
|
||
(pos.side === 'BUY' && pos.currentPrice >= displayTakeProfit)
|
||
|| (pos.side === 'SELL' && pos.currentPrice <= displayTakeProfit)
|
||
);
|
||
|
||
return (
|
||
<tr key={pos.id} className="hover:bg-white/[0.02] transition-colors">
|
||
<td className="px-6 py-4">
|
||
<span className={`px-2 py-0.5 rounded text-[10px] font-black tracking-tighter ${pos.source === 'BOT' ? 'bg-purple-500/20 text-purple-400 border border-purple-500/20' : 'bg-orange-500/20 text-orange-400 border border-orange-500/20'}`}>
|
||
{pos.source === 'BOT' ? (pos.profileName || profiles.find(pr => pr.id === pos.profileId)?.name || 'BOT') : 'MANUAL'}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<span className={positionTruth.className}>{positionTruth.label}</span>
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
{pos.tradeId ? (
|
||
<div className="flex items-center gap-1">
|
||
<Link2 size={9} className="text-blue-400" />
|
||
<span className="text-[10px] font-mono text-zinc-600 truncate max-w-[80px]" title={pos.tradeId}>
|
||
{pos.tradeId.split('-').pop()}
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<span className="text-[10px] text-zinc-700">—</span>
|
||
)}
|
||
</td>
|
||
<td className="px-6 py-4 font-mono font-bold text-white">{pos.symbol}</td>
|
||
<td className="px-6 py-4">
|
||
<span className={`text-[10px] font-black ${pos.side === 'BUY' ? 'text-green-400' : 'text-red-400'}`}>{pos.side}</span>
|
||
</td>
|
||
<td className="px-6 py-4 text-xs font-mono text-gray-300">
|
||
{formatDisplayQty(pos.size)}
|
||
</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()}
|
||
<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">
|
||
SL breached
|
||
</span>
|
||
)}
|
||
{!slBreached && tpHit && (
|
||
<span className="px-1.5 py-0.5 rounded text-[9px] font-black uppercase tracking-wider bg-green-500/20 text-green-300 border border-green-500/20">
|
||
TP hit
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 text-xs font-mono text-red-400/80">
|
||
{displayStopLoss ? `$${displayStopLoss.toLocaleString()}` : '-'}
|
||
</td>
|
||
<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">
|
||
{pos.source === 'BOT' && (
|
||
<button
|
||
onClick={async () => {
|
||
if (!confirm(`Are you sure you want to CLOSE ${pos.symbol}?`)) return;
|
||
try {
|
||
const accessToken = await getPlatformAccessToken();
|
||
const response = await fetch(`${tradingRuntime.tradingApiUrl}/api/close`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${accessToken}`,
|
||
'x-request-id': createRequestId('web-close')
|
||
},
|
||
body: JSON.stringify({
|
||
profile_id: pos.profileId,
|
||
symbol: pos.symbol
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
alert(`Successfully closed ${pos.symbol}`);
|
||
} else {
|
||
alert(`Failed to close: ${data.error}`);
|
||
}
|
||
} catch (e: any) {
|
||
alert(`Error: ${e.message}`);
|
||
}
|
||
}}
|
||
className="px-2 py-1 bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 rounded text-[10px] font-bold uppercase transition-colors"
|
||
>
|
||
Square Off
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
)})
|
||
) : (
|
||
<tr>
|
||
<td colSpan={12} className="px-6 py-12 text-center text-gray-500 italic text-sm">No active positions for this selection.</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="orders-section">
|
||
<div className="flex items-center gap-2 mb-4">
|
||
<ListFilter size={18} className="text-orange-400" />
|
||
<h3 className="text-lg font-bold text-gray-200">Order Activity</h3>
|
||
{finalOrders.some((o) => o.tradeId) && (
|
||
<span className="flex items-center gap-1 px-2 py-0.5 rounded text-[9px] font-bold bg-blue-500/10 text-blue-400 border border-blue-500/20 ml-2">
|
||
<Link2 size={9} />
|
||
Trade cycle tracing active
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="table-container bg-black/20 border border-white/5 rounded-2xl overflow-hidden">
|
||
<table className="pro-table w-full">
|
||
<thead>
|
||
<tr className="bg-white/[0.02] text-left">
|
||
<th className="px-4 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Trade ID</th>
|
||
<th className="px-4 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Sub-tag</th>
|
||
<th className="px-4 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Source</th>
|
||
<th className="px-4 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Truth</th>
|
||
<th className="px-4 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Time</th>
|
||
<th className="px-4 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Asset</th>
|
||
<th className="px-4 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Action</th>
|
||
<th className="px-4 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Side</th>
|
||
<th className="px-4 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Size</th>
|
||
<th className="px-4 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Price</th>
|
||
<th className="px-4 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500 text-right">Status</th>
|
||
</tr>
|
||
<tr className="bg-white/[0.015] text-left align-top">
|
||
<th className="px-4 py-2 text-[10px] text-gray-500 font-medium">
|
||
Profile + Date filters
|
||
</th>
|
||
<th className="px-4 py-2">
|
||
<select
|
||
value={selectedProfileId}
|
||
onChange={(e) => setSelectedProfileId(e.target.value)}
|
||
className="w-full bg-white/5 border border-white/10 rounded px-2 py-1 text-[10px] font-semibold text-gray-200 focus:outline-none focus:ring-2 focus:ring-orange-500/40"
|
||
>
|
||
<option value="all">All Profiles</option>
|
||
{profiles.map((profileOption) => (
|
||
<option key={profileOption.id} value={profileOption.id}>
|
||
{profileOption.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</th>
|
||
<th className="px-4 py-2">
|
||
<div className="flex flex-col gap-1">
|
||
<input
|
||
type="date"
|
||
value={ordersDateFrom}
|
||
onChange={(e) => setOrdersDateFrom(e.target.value)}
|
||
className="bg-white/5 border border-white/10 rounded px-2 py-1 text-[10px] text-gray-200 focus:outline-none focus:ring-2 focus:ring-orange-500/40"
|
||
/>
|
||
<input
|
||
type="date"
|
||
value={ordersDateTo}
|
||
onChange={(e) => setOrdersDateTo(e.target.value)}
|
||
className="bg-white/5 border border-white/10 rounded px-2 py-1 text-[10px] text-gray-200 focus:outline-none focus:ring-2 focus:ring-orange-500/40"
|
||
/>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => {
|
||
setOrdersDateFrom('');
|
||
setOrdersDateTo('');
|
||
}}
|
||
className="px-2 py-1 rounded border border-white/10 text-[9px] font-bold uppercase tracking-wider text-gray-300 hover:bg-white/5 transition-colors"
|
||
>
|
||
Clear
|
||
</button>
|
||
<button
|
||
onClick={() => setOrdersSortDirection((current) => current === 'desc' ? 'asc' : 'desc')}
|
||
className="px-2 py-1 rounded border border-white/10 text-[9px] font-bold uppercase tracking-wider text-gray-300 hover:bg-white/5 transition-colors"
|
||
>
|
||
{ordersSortDirection === 'desc' ? 'Newest' : 'Oldest'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</th>
|
||
<th colSpan={8} className="px-4 py-2 text-[10px] text-gray-500 font-medium">
|
||
Sorting is applied by time on this table.
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-white/5">
|
||
{finalOrders.length > 0 ? (
|
||
finalOrders.map((order) => {
|
||
const hasTradeId = !!order.tradeId;
|
||
const entryOrder = hasTradeId ? resolveEntryOrder(order.tradeId, order.profileId) : undefined;
|
||
const resolvedAction: OrderAction | undefined = order.action
|
||
|| (hasTradeId
|
||
? (entryOrder && order.side === entryOrder.side ? 'ENTRY' : 'EXIT')
|
||
: undefined);
|
||
const isEntry = resolvedAction === 'ENTRY';
|
||
const isExit = resolvedAction === 'EXIT';
|
||
const truthSource = getTruthSourceForOrder(order, historyTradeKeySet, hasCanonicalLifecycle);
|
||
const tradePnl = isExit && entryOrder
|
||
? (order.price - entryOrder.price)
|
||
* Math.min(order.qty || 0, entryOrder.qty || order.qty || 0)
|
||
* (entryOrder.side === 'BUY' ? 1 : -1)
|
||
: null;
|
||
|
||
return (
|
||
<tr key={`${order.id}-${order.timestamp}`} className={`hover:bg-white/[0.02] transition-colors ${hasTradeId && isExit ? 'border-t border-dashed border-blue-500/10' : ''}`}>
|
||
<td className="px-4 py-4">
|
||
{hasTradeId ? (
|
||
<div className="flex items-center gap-1">
|
||
<Link2 size={9} className={isEntry ? 'text-blue-400' : 'text-amber-400'} />
|
||
<span className="text-[9px] font-mono text-zinc-500 truncate max-w-[220px]" title={order.tradeId}>
|
||
{order.tradeId}
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<span className="text-[9px] text-zinc-700">-</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-4 text-[9px] font-mono text-zinc-500">
|
||
{order.subTag ? (
|
||
<span className="truncate max-w-[180px] inline-block align-middle" title={order.subTag}>
|
||
{order.subTag}
|
||
</span>
|
||
) : (
|
||
<span className="text-zinc-700">-</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-4">
|
||
<span className={`px-2 py-0.5 rounded text-[10px] font-black tracking-tighter ${order.source === 'BOT' ? 'bg-purple-500/20 text-purple-400 border border-purple-500/20' : 'bg-orange-500/20 text-orange-400 border border-orange-500/20'}`}>
|
||
{order.profileId ? (profiles.find(pr => pr.id === order.profileId)?.name || 'BOT') : 'MANUAL'}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-4">
|
||
<span className={truthSource.className}>{truthSource.label}</span>
|
||
</td>
|
||
<td className="px-4 py-4 text-[10px] text-gray-500 font-mono">
|
||
{order.timestamp ? new Date(order.timestamp).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-'}
|
||
</td>
|
||
<td className="px-4 py-4 font-mono font-bold text-white text-xs">{order.symbol}</td>
|
||
<td className="px-4 py-4">
|
||
{resolvedAction ? (
|
||
<span className={`flex items-center gap-1 px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider w-fit ${isEntry
|
||
? 'bg-blue-500/10 text-blue-400 border border-blue-500/20'
|
||
: 'bg-amber-500/10 text-amber-400 border border-amber-500/20'
|
||
}`}>
|
||
{isEntry ? 'ENTRY' : 'EXIT'}
|
||
</span>
|
||
) : (
|
||
<span className="text-[10px] text-gray-500 uppercase">{order.type}</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-4">
|
||
<span className={`text-[10px] font-black ${order.side === 'BUY' ? 'text-green-400' : 'text-red-400'}`}>{order.side}</span>
|
||
</td>
|
||
<td className="px-4 py-4 text-xs font-mono text-gray-300">{Number(order.qty || 0).toFixed(4)}</td>
|
||
<td className="px-4 py-4 text-xs font-mono text-gray-400">${Number(order.price).toLocaleString()}</td>
|
||
<td className="px-4 py-4 text-right">
|
||
<div className="flex items-center justify-end gap-2">
|
||
{tradePnl !== null && (
|
||
<span className={`text-[10px] font-mono font-bold ${tradePnl >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||
{tradePnl >= 0 ? '+' : ''}{tradePnl.toFixed(2)}
|
||
</span>
|
||
)}
|
||
{(() => {
|
||
const isPendingNew = isPendingLikeStatus(order.status);
|
||
const isExpired = order.status === 'expired';
|
||
const isUnknown = order.status === 'unknown';
|
||
const orderAge = order.timestamp ? Date.now() - order.timestamp : 0;
|
||
const isStale = isPendingNew && orderAge > 5 * 60 * 1000;
|
||
|
||
let badgeClass = 'bg-white/10 text-gray-400';
|
||
let tooltip = '';
|
||
|
||
if (order.status === 'filled') {
|
||
badgeClass = 'bg-green-500/20 text-green-400 border border-green-500/20';
|
||
} else if (isStale) {
|
||
badgeClass = 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/20';
|
||
tooltip = 'Order pending for >5 min - sync in progress';
|
||
} else if (isExpired) {
|
||
badgeClass = 'bg-orange-500/20 text-orange-400 border border-orange-500/20';
|
||
tooltip = 'Order not found on exchange - likely never executed';
|
||
} else if (isUnknown) {
|
||
badgeClass = 'bg-gray-500/20 text-gray-400 border border-gray-500/20';
|
||
tooltip = 'Order status could not be verified';
|
||
}
|
||
|
||
return (
|
||
<div className="flex items-center gap-1">
|
||
<span
|
||
className={`px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-tighter ${badgeClass}`}
|
||
title={tooltip}
|
||
>
|
||
{order.status}
|
||
</span>
|
||
{isStale && (
|
||
<span className="text-[8px] text-yellow-400" title="Order may be stale - sync in progress">!</span>
|
||
)}
|
||
{(isExpired || isUnknown) && (
|
||
<span className="text-[8px] text-gray-400" title={tooltip}>i</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})
|
||
) : (
|
||
<tr>
|
||
<td colSpan={11} className="px-6 py-12 text-center text-gray-500 italic text-sm">No recent orders for this cluster.</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr className="bg-white/[0.015]">
|
||
<td colSpan={11} className="px-4 py-3">
|
||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 text-xs text-gray-400">
|
||
<span>
|
||
Showing {sortedOrdersForActivity.length === 0 ? 0 : ((ordersPage - 1) * ORDER_ACTIVITY_PAGE_SIZE) + 1}
|
||
-{Math.min(ordersPage * ORDER_ACTIVITY_PAGE_SIZE, sortedOrdersForActivity.length)} of {sortedOrdersForActivity.length}
|
||
</span>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setOrdersPage((current) => Math.max(1, current - 1))}
|
||
disabled={ordersPage === 1}
|
||
className="px-3 py-1.5 rounded border border-white/10 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-white/5 transition-colors"
|
||
>
|
||
Prev
|
||
</button>
|
||
<span className="text-[10px] uppercase tracking-widest text-gray-500">
|
||
Page {ordersPage} / {ordersTotalPages}
|
||
</span>
|
||
<button
|
||
onClick={() => setOrdersPage((current) => Math.min(ordersTotalPages, current + 1))}
|
||
disabled={ordersPage >= ordersTotalPages}
|
||
className="px-3 py-1.5 rounded border border-white/10 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-white/5 transition-colors"
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="orders-section">
|
||
<div className="flex items-center gap-2 mb-4">
|
||
<GitBranch size={18} className="text-cyan-400" />
|
||
<h3 className="text-lg font-bold text-gray-200">Lifecycle Trace</h3>
|
||
<span className="px-2 py-0.5 rounded text-[9px] font-bold bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">
|
||
ENTRY -> EXIT chain by trade_id
|
||
</span>
|
||
</div>
|
||
<div className="mb-3 flex flex-wrap items-center gap-2 text-[10px]">
|
||
<span className="px-2 py-0.5 rounded bg-blue-500/10 text-blue-300 border border-blue-500/20">OPEN: {lifecycleSummary.OPEN}</span>
|
||
<span className="px-2 py-0.5 rounded bg-amber-500/10 text-amber-300 border border-amber-500/20">PARTIAL_EXIT: {lifecycleSummary.PARTIAL_EXIT}</span>
|
||
<span className="px-2 py-0.5 rounded bg-green-500/10 text-green-300 border border-green-500/20">CLOSED: {lifecycleSummary.CLOSED}</span>
|
||
<span className="px-2 py-0.5 rounded bg-yellow-500/10 text-yellow-300 border border-yellow-500/20">EXIT_PENDING: {lifecycleSummary.EXIT_PENDING}</span>
|
||
<span className="px-2 py-0.5 rounded bg-red-500/10 text-red-300 border border-red-500/20">ORPHAN_EXIT: {lifecycleSummary.ORPHAN_EXIT}</span>
|
||
</div>
|
||
<p className="mb-3 text-[10px] text-zinc-500">
|
||
State is computed from filled ENTRY/EXIT quantities and reconciled with `trade_history` for closed-lifecycle confirmation.
|
||
</p>
|
||
<div className="table-container bg-black/20 border border-white/5 rounded-2xl overflow-hidden">
|
||
<table className="pro-table w-full">
|
||
<thead>
|
||
<tr className="bg-white/[0.02] text-left">
|
||
<th className="px-4 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Trade ID</th>
|
||
<th className="px-4 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Profile</th>
|
||
<th className="px-4 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Asset</th>
|
||
<th className="px-4 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Lifecycle Chain</th>
|
||
<th className="px-4 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500 text-right">Entry Used ($)</th>
|
||
<th className="px-4 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500 text-right">Open Qty</th>
|
||
<th className="px-4 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500 text-right">State</th>
|
||
</tr>
|
||
<tr className="bg-white/[0.015] text-left align-top">
|
||
<th className="px-4 py-2 text-[10px] text-gray-500 font-medium">
|
||
Profile + Date filters
|
||
</th>
|
||
<th className="px-4 py-2">
|
||
<select
|
||
value={selectedProfileId}
|
||
onChange={(e) => setSelectedProfileId(e.target.value)}
|
||
className="w-full bg-white/5 border border-white/10 rounded px-2 py-1 text-[10px] font-semibold text-gray-200 focus:outline-none focus:ring-2 focus:ring-cyan-500/40"
|
||
>
|
||
<option value="all">All Profiles</option>
|
||
{profiles.map((profileOption) => (
|
||
<option key={profileOption.id} value={profileOption.id}>
|
||
{profileOption.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</th>
|
||
<th className="px-4 py-2 text-[10px] text-gray-500 font-medium">
|
||
Filter in headers
|
||
</th>
|
||
<th className="px-4 py-2">
|
||
<div className="flex flex-col gap-1">
|
||
<input
|
||
type="date"
|
||
value={lifecycleDateFrom}
|
||
onChange={(e) => setLifecycleDateFrom(e.target.value)}
|
||
className="bg-white/5 border border-white/10 rounded px-2 py-1 text-[10px] text-gray-200 focus:outline-none focus:ring-2 focus:ring-cyan-500/40"
|
||
/>
|
||
<input
|
||
type="date"
|
||
value={lifecycleDateTo}
|
||
onChange={(e) => setLifecycleDateTo(e.target.value)}
|
||
className="bg-white/5 border border-white/10 rounded px-2 py-1 text-[10px] text-gray-200 focus:outline-none focus:ring-2 focus:ring-cyan-500/40"
|
||
/>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => {
|
||
setLifecycleDateFrom('');
|
||
setLifecycleDateTo('');
|
||
}}
|
||
className="px-2 py-1 rounded border border-white/10 text-[9px] font-bold uppercase tracking-wider text-gray-300 hover:bg-white/5 transition-colors"
|
||
>
|
||
Clear
|
||
</button>
|
||
<button
|
||
onClick={() => setLifecycleSortDirection((current) => current === 'desc' ? 'asc' : 'desc')}
|
||
className="px-2 py-1 rounded border border-white/10 text-[9px] font-bold uppercase tracking-wider text-gray-300 hover:bg-white/5 transition-colors"
|
||
>
|
||
{lifecycleSortDirection === 'desc' ? 'Newest' : 'Oldest'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</th>
|
||
<th colSpan={3} className="px-4 py-2 text-[10px] text-gray-500 font-medium">
|
||
Sorting is applied by latest lifecycle event time.
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-white/5">
|
||
{filteredLifecycleTraces.length > 0 ? (
|
||
paginatedLifecycleTraces.map((trace) => (
|
||
<tr key={trace.traceKey} className="hover:bg-white/[0.02] transition-colors">
|
||
<td className="px-4 py-4">
|
||
<div className="flex items-center gap-1">
|
||
<Link2 size={9} className="text-cyan-400" />
|
||
<span className="text-[9px] font-mono text-zinc-400 truncate max-w-[120px]" title={trace.tradeId}>
|
||
{trace.tradeId}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-4">
|
||
<span className={`px-2 py-0.5 rounded text-[10px] font-black tracking-tighter ${trace.source === 'BOT'
|
||
? 'bg-purple-500/20 text-purple-400 border border-purple-500/20'
|
||
: 'bg-orange-500/20 text-orange-400 border border-orange-500/20'
|
||
}`}>
|
||
{trace.profileName}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-4 text-xs font-mono font-bold text-white">
|
||
{trace.symbol}
|
||
</td>
|
||
<td className="px-4 py-4">
|
||
<div className="space-y-1">
|
||
{trace.orderedEvents.map((event) => {
|
||
const actionLabel: OrderAction = event.action === 'EXIT' ? 'EXIT' : 'ENTRY';
|
||
const actionClass = actionLabel === 'ENTRY'
|
||
? 'bg-blue-500/10 text-blue-300 border border-blue-500/20'
|
||
: 'bg-amber-500/10 text-amber-300 border border-amber-500/20';
|
||
return (
|
||
<div key={`${trace.tradeId}-${event.id}-${event.timestamp}`} className="flex flex-wrap items-center gap-2 text-[9px]">
|
||
<span className={`px-1.5 py-0.5 rounded font-bold ${actionClass}`}>
|
||
{actionLabel}
|
||
</span>
|
||
<span className="text-zinc-500 font-mono">
|
||
{event.timestamp ? new Date(event.timestamp).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-'}
|
||
</span>
|
||
<span className="text-gray-300 font-mono">
|
||
{event.side} {Number(event.qty || 0).toFixed(4)} @ ${Number(event.price || 0).toLocaleString()}
|
||
</span>
|
||
<span className="text-zinc-500">{event.status}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-4 text-right text-xs font-mono text-cyan-200">
|
||
${trace.entryUsedUsd.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||
<div className="text-[9px] text-zinc-500">
|
||
@ ${trace.entryAvgPrice.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-4 text-right text-xs font-mono text-gray-300">
|
||
{trace.openQty.toFixed(4)}
|
||
</td>
|
||
<td className="px-4 py-4 text-right">
|
||
<div className="space-y-1">
|
||
<span className={`px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-wider ${trace.state === 'CLOSED'
|
||
? 'bg-green-500/20 text-green-300 border border-green-500/20'
|
||
: trace.state === 'PARTIAL_EXIT'
|
||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/20'
|
||
: trace.state === 'ORPHAN_EXIT'
|
||
? 'bg-red-500/20 text-red-300 border border-red-500/20'
|
||
: trace.state === 'EXIT_PENDING'
|
||
? 'bg-yellow-500/20 text-yellow-300 border border-yellow-500/20'
|
||
: 'bg-blue-500/20 text-blue-300 border border-blue-500/20'
|
||
}`}>
|
||
{trace.state}
|
||
</span>
|
||
<div className="text-[9px] text-zinc-500 leading-tight text-right max-w-[240px] ml-auto">
|
||
{trace.stateReason}
|
||
</div>
|
||
<div className="flex items-center justify-end gap-2 text-white/60">
|
||
{trace.entryOrder && <Lock size={14} aria-label="Lock acquired" className="text-[#38bdf8]" />}
|
||
{trace.hasHistoryMatch && <RefreshCw size={14} aria-label="Reconciliation applied" className="text-[#22c55e]" />}
|
||
{trace.state === 'PARTIAL_EXIT' && <AlertTriangle size={14} aria-label="Partial fill" className="text-[#facc15]" />}
|
||
{trace.hasCancel && <XCircle size={14} aria-label="Cancel detected" className="text-[#f87171]" />}
|
||
{trace.state === 'CLOSED' && <CheckCircle size={14} aria-label="Finalized" className="text-[#34d399]" />}
|
||
</div>
|
||
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))
|
||
) : (
|
||
<tr>
|
||
<td colSpan={7} className="px-6 py-12 text-center text-gray-500 italic text-sm">
|
||
No trade lifecycle traces available for this selection.
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr className="bg-white/[0.015]">
|
||
<td colSpan={7} className="px-4 py-3">
|
||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 text-xs text-gray-400">
|
||
<span>
|
||
Showing {sortedLifecycleTraces.length === 0 ? 0 : ((lifecyclePage - 1) * TRACE_PAGE_SIZE) + 1}
|
||
-{Math.min(lifecyclePage * TRACE_PAGE_SIZE, sortedLifecycleTraces.length)} of {sortedLifecycleTraces.length}
|
||
</span>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setLifecyclePage((current) => Math.max(1, current - 1))}
|
||
disabled={lifecyclePage === 1}
|
||
className="px-3 py-1.5 rounded border border-white/10 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-white/5 transition-colors"
|
||
>
|
||
Prev
|
||
</button>
|
||
<span className="text-[10px] uppercase tracking-widest text-gray-500">
|
||
Page {lifecyclePage} / {lifecycleTotalPages}
|
||
</span>
|
||
<button
|
||
onClick={() => setLifecyclePage((current) => Math.min(lifecycleTotalPages, current + 1))}
|
||
disabled={lifecyclePage >= lifecycleTotalPages}
|
||
className="px-3 py-1.5 rounded border border-white/10 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-white/5 transition-colors"
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
};
|
||
|