676 lines
35 KiB
TypeScript
676 lines
35 KiB
TypeScript
import { useEffect, useState, useMemo } from 'react';
|
|
import { useAuth } from '../components/AuthContext';
|
|
import { aggregateHistoryLedger, buildHistoryLedger } from '../lib/tradeHistoryLedger';
|
|
import {
|
|
type LifecycleOrderRow
|
|
} from '../lib/orderLifecycleLedger';
|
|
import { useCanonicalLifecycle } from '../hooks/useCanonicalLifecycle';
|
|
import { fetchTradeHistory } from '../lib/tradeHistoryApi';
|
|
import { fetchPositionsBootstrap } from '../lib/positionsApi';
|
|
|
|
|
|
interface TradeRecord {
|
|
id: string;
|
|
timestamp: number | string;
|
|
symbol: string;
|
|
side: string;
|
|
size?: number;
|
|
entry_price: number;
|
|
exit_price: number;
|
|
pnl: number;
|
|
pnl_percent: number;
|
|
reason: string;
|
|
profile_id?: string;
|
|
stop_loss?: number;
|
|
take_profit?: number;
|
|
rules_metadata?: any;
|
|
created_at?: string;
|
|
trade_id?: string;
|
|
source?: 'BOT' | 'MANUAL';
|
|
sub_tag?: string;
|
|
subTag?: string;
|
|
}
|
|
|
|
interface Profile {
|
|
id: string;
|
|
name: string;
|
|
allocated_capital: number;
|
|
}
|
|
|
|
interface OrderLifecycleRecord extends LifecycleOrderRow {
|
|
trade_id?: string;
|
|
profile_id?: string;
|
|
sub_tag?: string;
|
|
timestamp?: number | string;
|
|
filled_at?: string;
|
|
created_at?: string;
|
|
}
|
|
|
|
// ─── NEW HISTORY LEDGER SIMPLIFIED ───────────────────────────────────────────────
|
|
|
|
interface HistoryTabProps {
|
|
botState?: any;
|
|
}
|
|
|
|
export interface HistoryDateBounds {
|
|
from: number | null;
|
|
to: number | null;
|
|
}
|
|
|
|
export const toTimestampMs = (value?: number | string, fallbackCreatedAt?: string): number => {
|
|
if (typeof value === 'number') return value;
|
|
if (typeof value === 'string') {
|
|
const parsedValue = new Date(value).getTime();
|
|
if (Number.isFinite(parsedValue)) return parsedValue;
|
|
}
|
|
if (fallbackCreatedAt) {
|
|
const parsedCreatedAt = new Date(fallbackCreatedAt).getTime();
|
|
if (Number.isFinite(parsedCreatedAt)) return parsedCreatedAt;
|
|
}
|
|
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 filterHistoryByProfileAndDate = (
|
|
history: TradeRecord[],
|
|
selectedProfileId: string,
|
|
bounds: HistoryDateBounds
|
|
) =>
|
|
history.filter((record) => {
|
|
if (selectedProfileId !== 'all' && record.profile_id !== selectedProfileId) return false;
|
|
const recordTimestamp = toTimestampMs(record.timestamp, record.created_at);
|
|
if (bounds.from !== null && recordTimestamp < bounds.from) return false;
|
|
if (bounds.to !== null && recordTimestamp > bounds.to) return false;
|
|
return true;
|
|
});
|
|
|
|
export const sortHistoryByTimestamp = (
|
|
records: TradeRecord[],
|
|
historySortDirection: 'desc' | 'asc'
|
|
) => {
|
|
const direction = historySortDirection === 'asc' ? 1 : -1;
|
|
return [...records].sort((a, b) => {
|
|
const aTimestamp = toTimestampMs(a.timestamp, a.created_at);
|
|
const bTimestamp = toTimestampMs(b.timestamp, b.created_at);
|
|
if (aTimestamp !== bTimestamp) {
|
|
return direction * (aTimestamp - bTimestamp);
|
|
}
|
|
return direction * String(a.id).localeCompare(String(b.id));
|
|
});
|
|
};
|
|
|
|
export const paginateHistory = (records: TradeRecord[], page: number, pageSize: number) => {
|
|
const startIndex = (page - 1) * pageSize;
|
|
return records.slice(startIndex, startIndex + pageSize);
|
|
};
|
|
|
|
interface ExpectancyMetrics {
|
|
tradeCount: number;
|
|
avgWin: number;
|
|
avgLossAbs: number;
|
|
profitFactor: number | null;
|
|
expectancyPerTrade: number;
|
|
}
|
|
|
|
const computeExpectancyMetrics = (records: TradeRecord[]): ExpectancyMetrics => {
|
|
const byLifecycle = new Map<string, number>();
|
|
|
|
for (const record of records) {
|
|
const tradeId = String(record.trade_id || '').trim();
|
|
const profileId = String(record.profile_id || '').trim() || 'global';
|
|
const lifecycleKey = tradeId
|
|
? `trade:${profileId}:${tradeId}`
|
|
: `row:${record.id}`;
|
|
const pnl = Number(record.pnl || 0);
|
|
const next = (byLifecycle.get(lifecycleKey) || 0) + (Number.isFinite(pnl) ? pnl : 0);
|
|
byLifecycle.set(lifecycleKey, next);
|
|
}
|
|
|
|
const tradePnls = Array.from(byLifecycle.values());
|
|
const wins = tradePnls.filter((value) => value > 0);
|
|
const losses = tradePnls.filter((value) => value < 0);
|
|
|
|
const grossWin = wins.reduce((sum, value) => sum + value, 0);
|
|
const grossLossAbs = Math.abs(losses.reduce((sum, value) => sum + value, 0));
|
|
const tradeCount = tradePnls.length;
|
|
|
|
const avgWin = wins.length > 0 ? grossWin / wins.length : 0;
|
|
const avgLossAbs = losses.length > 0 ? grossLossAbs / losses.length : 0;
|
|
const profitFactor = grossLossAbs > 0
|
|
? (grossWin / grossLossAbs)
|
|
: (grossWin > 0 ? Number.POSITIVE_INFINITY : null);
|
|
const expectancyPerTrade = tradeCount > 0
|
|
? (tradePnls.reduce((sum, value) => sum + value, 0) / tradeCount)
|
|
: 0;
|
|
|
|
return {
|
|
tradeCount,
|
|
avgWin,
|
|
avgLossAbs,
|
|
profitFactor,
|
|
expectancyPerTrade
|
|
};
|
|
};
|
|
|
|
const compactTag = (value?: string): string => {
|
|
const token = String(value || '').trim();
|
|
if (!token) return '-';
|
|
return token.length > 24 ? `${token.slice(0, 12)}...${token.slice(-8)}` : token;
|
|
};
|
|
|
|
export const HistoryTab = ({ botState }: HistoryTabProps) => {
|
|
const { user, profile } = useAuth();
|
|
const [selectedProfileId, setSelectedProfileId] = useState<string>('all');
|
|
const [historyDateFrom, setHistoryDateFrom] = useState<string>('');
|
|
const [historyDateTo, setHistoryDateTo] = useState<string>('');
|
|
const [historySortDirection, setHistorySortDirection] = useState<'desc' | 'asc'>('desc');
|
|
const [historyPage, setHistoryPage] = useState(1);
|
|
const {
|
|
snapshot: canonicalSnapshot,
|
|
loading: canonicalLoading,
|
|
error: canonicalError
|
|
} = useCanonicalLifecycle(selectedProfileId === 'all' ? undefined : selectedProfileId);
|
|
const [dbHistory, setDbHistory] = useState<TradeRecord[]>([]);
|
|
const [dbLifecycleOrders, setDbLifecycleOrders] = useState<OrderLifecycleRecord[]>([]);
|
|
const [profiles, setProfiles] = useState<Profile[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const HISTORY_PAGE_SIZE = 20;
|
|
|
|
const orderSubTagByTrade = useMemo(() => {
|
|
const byTrade = new Map<string, { tag: string; ts: number }>();
|
|
const setIfLatest = (tradeIdRaw: unknown, profileIdRaw: unknown, tagRaw: unknown, tsRaw: unknown) => {
|
|
const tradeId = String(tradeIdRaw || '').trim();
|
|
const profileId = String(profileIdRaw || '').trim();
|
|
const tag = String(tagRaw || '').trim();
|
|
if (!tradeId || !tag) return;
|
|
|
|
const timestampCandidate = typeof tsRaw === 'number'
|
|
? tsRaw
|
|
: new Date(String(tsRaw || '')).getTime();
|
|
const timestamp = Number.isFinite(timestampCandidate) ? timestampCandidate : 0;
|
|
const scopedKey = `${profileId || 'global'}|${tradeId}`;
|
|
const globalKey = `global|${tradeId}`;
|
|
|
|
const scopedExisting = byTrade.get(scopedKey);
|
|
if (!scopedExisting || timestamp >= scopedExisting.ts) {
|
|
byTrade.set(scopedKey, { tag, ts: timestamp });
|
|
}
|
|
|
|
const globalExisting = byTrade.get(globalKey);
|
|
if (!globalExisting || timestamp >= globalExisting.ts) {
|
|
byTrade.set(globalKey, { tag, ts: timestamp });
|
|
}
|
|
};
|
|
|
|
for (const row of dbLifecycleOrders) {
|
|
setIfLatest(
|
|
row.trade_id,
|
|
row.profile_id,
|
|
row.sub_tag,
|
|
row.filled_at ?? row.timestamp ?? row.created_at
|
|
);
|
|
}
|
|
|
|
for (const row of (canonicalSnapshot?.lifecycleRows || [])) {
|
|
setIfLatest(
|
|
row.tradeId,
|
|
row.profileId,
|
|
row.subTag,
|
|
row.lastEventAt
|
|
);
|
|
}
|
|
|
|
for (const order of (botState?.orders || [])) {
|
|
setIfLatest(
|
|
(order as any).trade_id || (order as any).tradeId,
|
|
(order as any).profileId || (order as any).profile_id,
|
|
(order as any).subTag || (order as any).sub_tag || (order as any).subtag,
|
|
(order as any).timestamp
|
|
);
|
|
}
|
|
|
|
return byTrade;
|
|
}, [canonicalSnapshot?.lifecycleRows, dbLifecycleOrders, botState?.orders]);
|
|
|
|
const canonicalLifecycleTrades = useMemo(() => {
|
|
if (canonicalSnapshot && !canonicalSnapshot.diagnostics?.truncated) {
|
|
return canonicalSnapshot.realizedTrades.map((trade) => ({
|
|
id: trade.id,
|
|
tradeId: trade.tradeId,
|
|
profileId: trade.profileId,
|
|
symbol: trade.symbol,
|
|
side: trade.side,
|
|
size: Number(trade.size || 0),
|
|
entryPrice: Number(trade.entryPrice || 0),
|
|
exitPrice: Number(trade.exitPrice || 0),
|
|
pnl: Number(trade.pnl || 0),
|
|
pnlPercent: Number(trade.pnlPercent || 0),
|
|
closedAtMs: Number(trade.closedAt || 0)
|
|
}));
|
|
}
|
|
return [];
|
|
}, [canonicalSnapshot]);
|
|
|
|
const canonicalHistory = useMemo<TradeRecord[]>(() => (
|
|
canonicalLifecycleTrades.map((trade) => ({
|
|
id: trade.id,
|
|
timestamp: trade.closedAtMs,
|
|
symbol: trade.symbol,
|
|
side: trade.side,
|
|
size: trade.size,
|
|
entry_price: trade.entryPrice,
|
|
exit_price: trade.exitPrice,
|
|
pnl: trade.pnl,
|
|
pnl_percent: trade.pnlPercent,
|
|
reason: 'Exchange Filled Lifecycle',
|
|
profile_id: trade.profileId,
|
|
created_at: trade.closedAtMs > 0 ? new Date(trade.closedAtMs).toISOString() : undefined,
|
|
trade_id: trade.tradeId,
|
|
source: 'BOT'
|
|
}))
|
|
), [canonicalLifecycleTrades]);
|
|
const hasCanonicalLifecycle = Boolean(canonicalSnapshot && canonicalSnapshot.lifecycleRows.length > 0);
|
|
|
|
// Merge DB history with real-time bot history
|
|
const history = useMemo(() => {
|
|
if (canonicalHistory.length > 0) {
|
|
return canonicalHistory.map((row) => {
|
|
const scopedTradeKey = `${row.profile_id || 'global'}|${row.trade_id || ''}`;
|
|
const globalTradeKey = `global|${row.trade_id || ''}`;
|
|
const derivedSubTag = row.trade_id
|
|
? (orderSubTagByTrade.get(scopedTradeKey)?.tag || orderSubTagByTrade.get(globalTradeKey)?.tag)
|
|
: undefined;
|
|
return {
|
|
...row,
|
|
sub_tag: derivedSubTag
|
|
};
|
|
});
|
|
}
|
|
|
|
return dbHistory.map((row) => {
|
|
const tradeId = String(row.trade_id || '').trim();
|
|
const profileId = String(row.profile_id || '').trim();
|
|
const scopedTradeKey = `${profileId || 'global'}|${tradeId}`;
|
|
const globalTradeKey = `global|${tradeId}`;
|
|
const derivedSubTag = tradeId
|
|
? (orderSubTagByTrade.get(scopedTradeKey)?.tag || orderSubTagByTrade.get(globalTradeKey)?.tag)
|
|
: undefined;
|
|
|
|
return ({
|
|
...row,
|
|
sub_tag: row.sub_tag || derivedSubTag
|
|
});
|
|
});
|
|
}, [canonicalHistory, dbHistory, orderSubTagByTrade]);
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const scope = profile?.role === 'admin' ? 'all' : 'user';
|
|
const [historyRows, positionsBootstrap] = await Promise.all([
|
|
fetchTradeHistory({ scope, limit: 5000 }),
|
|
fetchPositionsBootstrap({ scope, limit: 5000 }),
|
|
]);
|
|
|
|
const profData = (positionsBootstrap.profiles || []).map((item: any) => ({
|
|
id: String(item.id),
|
|
name: String(item.name || item.id || 'Unnamed Profile'),
|
|
allocated_capital: Number(item.allocated_capital || 0)
|
|
}));
|
|
|
|
setDbHistory((historyRows as TradeRecord[]) || []);
|
|
setDbLifecycleOrders((positionsBootstrap.orders as OrderLifecycleRecord[]) || []);
|
|
setProfiles((profData as Profile[]) || []);
|
|
} catch (err) {
|
|
console.error("Unexpected error:", err);
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
fetchData();
|
|
}, [user, profile?.role]);
|
|
|
|
const historyDateBounds = useMemo(() => ({
|
|
from: parseDateStart(historyDateFrom),
|
|
to: parseDateEnd(historyDateTo)
|
|
}), [historyDateFrom, historyDateTo]);
|
|
|
|
const filteredHistory = useMemo(() => {
|
|
return filterHistoryByProfileAndDate(history, selectedProfileId, historyDateBounds);
|
|
}, [history, historyDateBounds, selectedProfileId]);
|
|
|
|
const sortedHistory = useMemo(() => {
|
|
return sortHistoryByTimestamp(filteredHistory, historySortDirection);
|
|
}, [filteredHistory, historySortDirection]);
|
|
|
|
useEffect(() => {
|
|
setHistoryPage(1);
|
|
}, [selectedProfileId, historyDateFrom, historyDateTo]);
|
|
|
|
const historyTotalPages = useMemo(
|
|
() => Math.max(1, Math.ceil(sortedHistory.length / HISTORY_PAGE_SIZE)),
|
|
[sortedHistory.length, HISTORY_PAGE_SIZE]
|
|
);
|
|
|
|
useEffect(() => {
|
|
setHistoryPage((current) => Math.min(current, historyTotalPages));
|
|
}, [historyTotalPages]);
|
|
|
|
const paginatedHistory = useMemo(() => {
|
|
return paginateHistory(sortedHistory, historyPage, HISTORY_PAGE_SIZE);
|
|
}, [sortedHistory, historyPage, HISTORY_PAGE_SIZE]);
|
|
|
|
// Aggregate Metrics for the metrics bar
|
|
const metrics = useMemo(() => {
|
|
const aggregate = aggregateHistoryLedger(
|
|
buildHistoryLedger({
|
|
dbRows: filteredHistory,
|
|
includeRealtime: false
|
|
})
|
|
);
|
|
const expectancy = computeExpectancyMetrics(filteredHistory);
|
|
|
|
return {
|
|
total: aggregate.tradeCount,
|
|
winRate: aggregate.winRate,
|
|
netPnl: aggregate.totalPnl,
|
|
expectancy
|
|
};
|
|
}, [filteredHistory]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-40">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-[#00ff88]"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="history-tab max-w-[1400px] mx-auto px-6 space-y-8 pb-20 animate-in fade-in duration-500">
|
|
{/* Header Section */}
|
|
<header className="pt-8 space-y-4">
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
|
<div className="space-y-1">
|
|
<h1 className="text-4xl font-black text-white tracking-tight uppercase">Audit Logs</h1>
|
|
<p className="text-gray-400 font-medium text-[10px] uppercase tracking-widest opacity-60">Verified Execution & Performance History</p>
|
|
</div>
|
|
|
|
<div className="flex gap-2 bg-white/5 p-1 rounded-xl shadow-inner border border-white/5">
|
|
<button
|
|
onClick={() => setSelectedProfileId('all')}
|
|
className={`px-6 py-2 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${selectedProfileId === 'all'
|
|
? 'bg-[#00ff88] text-black shadow-[0_0_20px_rgba(0,255,136,0.2)]'
|
|
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
|
}`}
|
|
>
|
|
Global
|
|
</button>
|
|
{profiles.map(p => (
|
|
<button
|
|
key={p.id}
|
|
onClick={() => setSelectedProfileId(p.id)}
|
|
className={`px-6 py-2 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${selectedProfileId === p.id
|
|
? 'bg-[#00ff88] text-black shadow-[0_0_20px_rgba(0,255,136,0.2)]'
|
|
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
|
}`}
|
|
>
|
|
{p.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{!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)' : ''}. History is using fallback ledger sources.
|
|
{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). Review a narrower scope for exact audit totals.
|
|
</div>
|
|
)}
|
|
|
|
{/* Performance Metrics Bar */}
|
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-6 pt-4">
|
|
<div className="bg-white/[0.02] border border-white/5 p-6 rounded-2xl flex flex-col gap-1 backdrop-blur-sm">
|
|
<span className="text-gray-500 text-[10px] font-black uppercase tracking-widest">Total Trades</span>
|
|
<span className="text-3xl font-black text-white">{metrics.total}</span>
|
|
</div>
|
|
<div className="bg-white/[0.02] border border-white/5 p-6 rounded-2xl flex flex-col gap-1 backdrop-blur-sm">
|
|
<span className="text-gray-500 text-[10px] font-black uppercase tracking-widest">Win Rate</span>
|
|
<span className="text-3xl font-black text-[#00ff88]">{metrics.winRate.toFixed(1)}%</span>
|
|
</div>
|
|
<div className="bg-white/[0.02] border border-white/5 p-6 rounded-2xl flex flex-col gap-1 backdrop-blur-sm">
|
|
<span className="text-gray-500 text-[10px] font-black uppercase tracking-widest">Realized P&L</span>
|
|
<span className={`text-3xl font-black ${metrics.netPnl >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
|
{metrics.netPnl >= 0 ? '+' : '-'}${Math.abs(metrics.netPnl).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
</span>
|
|
</div>
|
|
<div className="bg-white/[0.02] border border-white/5 p-6 rounded-2xl flex flex-col gap-1 backdrop-blur-sm">
|
|
<span className="text-gray-500 text-[10px] font-black uppercase tracking-widest">Profit Factor</span>
|
|
<span className={`text-3xl font-black ${metrics.expectancy.profitFactor === null ? 'text-gray-400' : metrics.expectancy.profitFactor >= 1 ? 'text-green-400' : 'text-red-400'}`}>
|
|
{metrics.expectancy.profitFactor === null
|
|
? '-'
|
|
: Number.isFinite(metrics.expectancy.profitFactor)
|
|
? metrics.expectancy.profitFactor.toFixed(2)
|
|
: '∞'}
|
|
</span>
|
|
</div>
|
|
<div className="bg-white/[0.02] border border-white/5 p-6 rounded-2xl flex flex-col gap-1 backdrop-blur-sm">
|
|
<span className="text-gray-500 text-[10px] font-black uppercase tracking-widest">Expectancy / Trade</span>
|
|
<span className={`text-3xl font-black ${metrics.expectancy.expectancyPerTrade >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
|
{metrics.expectancy.expectancyPerTrade >= 0 ? '+' : '-'}${Math.abs(metrics.expectancy.expectancyPerTrade).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
</span>
|
|
<span className="text-[10px] text-gray-500">
|
|
Avg win ${metrics.expectancy.avgWin.toFixed(2)} / Avg loss ${metrics.expectancy.avgLossAbs.toFixed(2)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Trade Ledger Table - Matched exactly with Active Orders Style */}
|
|
<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">Time</th>
|
|
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Trade ID</th>
|
|
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Sub-tag</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 text-right">Capital Used ($)</th>
|
|
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500 text-right">Entry</th>
|
|
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500 text-right">Exit</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">Reason</th>
|
|
</tr>
|
|
<tr className="bg-white/[0.015] text-left align-top">
|
|
<th className="px-6 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-[#00ff88]/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-6 py-2">
|
|
<div className="flex flex-col gap-1">
|
|
<input
|
|
type="date"
|
|
value={historyDateFrom}
|
|
onChange={(e) => setHistoryDateFrom(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-[#00ff88]/40"
|
|
/>
|
|
<input
|
|
type="date"
|
|
value={historyDateTo}
|
|
onChange={(e) => setHistoryDateTo(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-[#00ff88]/40"
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => {
|
|
setHistoryDateFrom('');
|
|
setHistoryDateTo('');
|
|
}}
|
|
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={() => setHistorySortDirection((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"
|
|
>
|
|
{historySortDirection === 'desc' ? 'Newest' : 'Oldest'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</th>
|
|
<th colSpan={9} className="px-6 py-2 text-[10px] text-gray-500 font-medium">
|
|
Filters and sorting are applied directly from table headers.
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-white/5">
|
|
{sortedHistory.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={11} className="px-6 py-12 text-center text-gray-500 italic text-sm">
|
|
No transaction journal found.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
paginatedHistory.map((t) => {
|
|
const pnlValue = Number(t.pnl ?? 0);
|
|
const pnlPercentValue = Number.isFinite(Number(t.pnl_percent)) ? Number(t.pnl_percent) : 0;
|
|
const isLoss = pnlValue < 0;
|
|
const normalizedSize = Number(t.size || 0);
|
|
const normalizedEntry = Number(t.entry_price || 0);
|
|
const capitalUsed = (normalizedSize > 0 && normalizedEntry > 0)
|
|
? Math.abs(normalizedSize * normalizedEntry)
|
|
: null;
|
|
|
|
return (
|
|
<tr
|
|
key={`${t.id}-${t.trade_id || ''}`}
|
|
className={`${isLoss ? 'bg-red-500/[0.06] border-l-2 border-l-red-500/50' : ''} 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 ${t.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'}`}>
|
|
{t.source === 'BOT'
|
|
? (t.profile_id ? (profiles.find(p => p.id === t.profile_id)?.name || 'BOT') : 'BOT')
|
|
: 'MANUAL'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-[10px] text-gray-500 font-mono">
|
|
{(t.timestamp || t.created_at) ? new Date(t.timestamp || t.created_at!).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-'}
|
|
</td>
|
|
<td className="px-6 py-4 text-[10px] text-gray-500 font-mono">
|
|
{t.trade_id || '-'}
|
|
</td>
|
|
<td className="px-6 py-4 text-[9px] text-gray-500 font-mono" title={t.sub_tag || t.subTag || ''}>
|
|
{compactTag(t.sub_tag || t.subTag)}
|
|
</td>
|
|
<td className="px-6 py-4 font-mono font-bold text-white">{t.symbol}</td>
|
|
<td className="px-6 py-4">
|
|
<span className={`text-[10px] font-black ${t.side === 'BUY' ? 'text-green-400' : t.side === 'SELL' ? 'text-red-400' : 'text-gray-400'}`}>
|
|
{t.side}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-right text-xs font-mono text-cyan-300">
|
|
{capitalUsed !== null
|
|
? `$${capitalUsed.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
|
: '-'}
|
|
</td>
|
|
<td className="px-6 py-4 text-right text-xs font-mono text-gray-400">
|
|
{Number.isFinite(Number(t.entry_price)) && Number(t.entry_price) > 0
|
|
? `$${Number(t.entry_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
|
: '-'}
|
|
</td>
|
|
<td className="px-6 py-4 text-right text-xs font-mono text-white font-bold">
|
|
{Number.isFinite(Number(t.exit_price)) && Number(t.exit_price) > 0
|
|
? `$${Number(t.exit_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
|
: '-'}
|
|
</td>
|
|
<td className="px-6 py-4 text-right">
|
|
<div className={`text-xs font-mono font-black ${isLoss ? 'text-red-300' : 'text-green-400'}`}>
|
|
{pnlValue >= 0 ? '+' : ''}{pnlPercentValue.toFixed(2)}%
|
|
<div className="text-[10px] opacity-60">
|
|
${pnlValue.toFixed(2)}
|
|
</div>
|
|
{isLoss && (
|
|
<div className="mt-1 inline-flex items-center px-1.5 py-0.5 rounded border border-red-500/40 bg-red-500/20 text-[8px] font-black uppercase tracking-wider text-red-200">
|
|
Loss Alert
|
|
</div>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<span className="text-[10px] font-bold text-gray-500 uppercase tracking-tighter truncate max-w-[150px] inline-block">
|
|
{t.reason}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
)})
|
|
)}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr className="bg-white/[0.015]">
|
|
<td colSpan={11} className="px-6 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 {sortedHistory.length === 0 ? 0 : ((historyPage - 1) * HISTORY_PAGE_SIZE) + 1}
|
|
-{Math.min(historyPage * HISTORY_PAGE_SIZE, sortedHistory.length)} of {sortedHistory.length}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setHistoryPage((current) => Math.max(1, current - 1))}
|
|
disabled={historyPage === 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 {historyPage} / {historyTotalPages}
|
|
</span>
|
|
<button
|
|
onClick={() => setHistoryPage((current) => Math.min(historyTotalPages, current + 1))}
|
|
disabled={historyPage >= historyTotalPages}
|
|
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>
|
|
</div>
|
|
);
|
|
};
|