learning_ai_invt_trdg/web/src/tabs/PositionsTab.tsx

1877 lines
105 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 -&gt; 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>
);
};