Web:
- runtime.ts: use import.meta.env (process.env is undefined in Vite browser bundle)
- tradingApiUrl local fallback: drop /api suffix (API libs already append /api/*)
- useWebSocket: deriveSocketParams() — correctly splits origin + socket path for
Caddy handle_path /invttrdg/* proxy (io(origin, {path}), not io(url-with-path))
- App.tsx: pass socket prop to AdminTab; pass connected prop to SignalsTab
- AdminTab: remove duplicate useWebSocket; accept socket as prop
- SignalsTab: connection-aware empty state message
- backtest/flags: default to disabled when VITE_BACKTEST_ENABLED unset
- EntryForm: NaN guard before live trade execution
- MarketplaceTab: null-safety on symbols.rules access
- Tests: pass socket prop to AdminTab; update empty state assertion
Mobile:
- TradingDataProvider: same deriveSocketParams fix — EXPO_PUBLIC_SOCKET_PATH
overrides auto-derived path from tradingApiUrl
- strategies: replace mock data with real GET /api/profiles + PATCH active toggle
- chat: wire to real POST /api/chat; remove hardcoded mock reply
- marketplace: fetch GET /api/marketplace-presets; USE STRATEGY calls POST /api/profiles
- settings: sign-out confirmation dialog; execution mode read-only hint;
version from expo-constants instead of hardcoded v2.3
- positions/history: empty state UI when no data
- CustomTabBar: always show tab labels (not only when focused)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
327 lines
10 KiB
TypeScript
327 lines
10 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { View, Text, ScrollView, StyleSheet } from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { Colors, Fonts, FontSize, BorderRadius, Spacing } from '@/constants/theme';
|
|
import { formatPrice, formatPercent, formatCurrency } from '@/utils/format';
|
|
import SegmentedControl from '@/components/SegmentedControl';
|
|
import AnimatedCard from '@/components/AnimatedCard';
|
|
import PillBadge from '@/components/PillBadge';
|
|
import { useTradingData } from '@/providers/TradingDataProvider';
|
|
import { buildHistoryMetrics } from '@/lib/tradingViewModels';
|
|
|
|
const FILTERS = ['All', 'Today', 'This Week', 'This Month'];
|
|
|
|
export default function HistoryScreen() {
|
|
const insets = useSafeAreaInsets();
|
|
const { botState } = useTradingData();
|
|
const tradeHistory = botState?.history || [];
|
|
const profileOptions = ['All', ...Array.from(new Set(tradeHistory.map((trade) => trade.profileName || 'Trading Profile')))];
|
|
const [profileIndex, setProfileIndex] = useState(0);
|
|
const [filterIndex, setFilterIndex] = useState(0);
|
|
const historyMetrics = buildHistoryMetrics(tradeHistory);
|
|
|
|
const filteredTrades = profileIndex === 0
|
|
? tradeHistory
|
|
: tradeHistory.filter((trade) => (trade.profileName || 'Trading Profile') === profileOptions[profileIndex]);
|
|
|
|
const timeFilteredTrades = filteredTrades.filter((trade) => {
|
|
if (filterIndex === 0) return true;
|
|
const ageMs = Date.now() - Number(trade.timestamp || 0);
|
|
if (filterIndex === 1) return ageMs <= 24 * 60 * 60 * 1000;
|
|
if (filterIndex === 2) return ageMs <= 7 * 24 * 60 * 60 * 1000;
|
|
return ageMs <= 30 * 24 * 60 * 60 * 1000;
|
|
});
|
|
|
|
return (
|
|
<View style={[styles.container, { paddingTop: insets.top }]}>
|
|
<View style={styles.headerSection}>
|
|
<Text style={styles.subtitle}>COMPLETE TRADE LEDGER</Text>
|
|
<Text style={styles.pageTitle}>Audit Logs</Text>
|
|
</View>
|
|
|
|
<ScrollView
|
|
style={styles.scroll}
|
|
contentContainerStyle={styles.content}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<SegmentedControl
|
|
segments={profileOptions}
|
|
activeIndex={profileIndex}
|
|
onPress={setProfileIndex}
|
|
/>
|
|
|
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.metricsRow}>
|
|
<MetricCard label="TOTAL TRADES" value={historyMetrics.totalTrades.toString()} />
|
|
<MetricCard label="WIN RATE" value={`${historyMetrics.winRate.toFixed(1)}%`} color={Colors.accent.green} />
|
|
<MetricCard label="NET P&L" value={`${historyMetrics.netPnl >= 0 ? '+' : '-'}$${Math.abs(historyMetrics.netPnl).toFixed(2)}`} color={historyMetrics.netPnl >= 0 ? Colors.accent.green : Colors.accent.red} mono />
|
|
</ScrollView>
|
|
|
|
<View style={styles.filterRow}>
|
|
{FILTERS.map((f, i) => (
|
|
<PressableFilter
|
|
key={f}
|
|
label={f}
|
|
active={filterIndex === i}
|
|
onPress={() => setFilterIndex(i)}
|
|
/>
|
|
))}
|
|
</View>
|
|
|
|
{timeFilteredTrades.length === 0 ? (
|
|
<View style={styles.emptyState}>
|
|
<Text style={styles.emptyText}>No trades found</Text>
|
|
<Text style={styles.emptyHint}>Closed trades will appear here once the bot exits a position.</Text>
|
|
</View>
|
|
) : timeFilteredTrades.map((trade, index) => (
|
|
<TradeRow key={`${trade.symbol}-${trade.timestamp}-${index}`} trade={trade} index={index} />
|
|
))}
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function MetricCard({ label, value, color, mono }: { label: string; value: string; color?: string; mono?: boolean }) {
|
|
return (
|
|
<View style={styles.metricCard}>
|
|
<Text style={styles.metricLabel}>{label}</Text>
|
|
<Text style={[styles.metricValue, color ? { color } : null, mono ? { fontFamily: Fonts.mono.extraBold } : null]}>
|
|
{value}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function PressableFilter({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
|
|
return (
|
|
<View style={[styles.filterPill, active && styles.filterActive]}>
|
|
<Text
|
|
style={[styles.filterText, active && styles.filterTextActive]}
|
|
onPress={onPress}
|
|
>
|
|
{label}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function TradeRow({ trade, index }: { trade: {
|
|
symbol: string;
|
|
side: string;
|
|
entryPrice: number;
|
|
exitPrice: number;
|
|
size: number;
|
|
pnl: number;
|
|
pnlPercent: number;
|
|
reason: string;
|
|
source?: 'BOT' | 'MANUAL';
|
|
timestamp: number;
|
|
}; index: number }) {
|
|
const isLoss = trade.pnl < 0;
|
|
const pnlColor = isLoss ? Colors.accent.red : Colors.accent.green;
|
|
|
|
const reasonColors: Record<string, { bg: string; color: string }> = {
|
|
'Take Profit': { bg: 'rgba(0,255,136,0.1)', color: Colors.accent.green },
|
|
'Stop Loss': { bg: 'rgba(255,51,102,0.1)', color: Colors.accent.red },
|
|
'Signal Exit': { bg: 'rgba(52,152,219,0.1)', color: Colors.accent.blue },
|
|
'Manual': { bg: 'rgba(255,255,255,0.05)', color: Colors.text.secondary },
|
|
};
|
|
const rc = reasonColors[trade.reason] || reasonColors['Manual'];
|
|
|
|
return (
|
|
<AnimatedCard index={index} style={[styles.tradeCard, isLoss ? styles.tradeCardLoss : undefined]}>
|
|
{isLoss && <View style={styles.lossLeftBorder} />}
|
|
<View style={styles.tradeContent}>
|
|
<View style={styles.tradeHeader}>
|
|
<Text style={styles.tradeSymbol}>{trade.symbol}</Text>
|
|
<PillBadge
|
|
label={trade.side}
|
|
color={trade.side === 'BUY' ? Colors.accent.green : Colors.accent.red}
|
|
bgColor={trade.side === 'BUY' ? 'rgba(0,255,136,0.15)' : 'rgba(255,51,102,0.15)'}
|
|
/>
|
|
<Text style={[styles.tradePnl, { color: pnlColor }]}>
|
|
{formatCurrency(trade.pnl)} ({formatPercent(trade.pnlPercent)})
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={styles.tradeDetails}>
|
|
<Text style={styles.tradeArrow}>
|
|
{formatPrice(trade.entryPrice)} → {formatPrice(trade.exitPrice)}
|
|
</Text>
|
|
<Text style={styles.tradeSize}>{trade.size} {trade.symbol.split('/')[0]}</Text>
|
|
</View>
|
|
|
|
<View style={styles.tradeFooter}>
|
|
<PillBadge label={trade.reason} color={rc.color} bgColor={rc.bg} />
|
|
<PillBadge
|
|
label={trade.source || 'BOT'}
|
|
color={(trade.source || 'BOT') === 'BOT' ? Colors.accent.purple : Colors.accent.orange}
|
|
bgColor={(trade.source || 'BOT') === 'BOT' ? 'rgba(168,85,247,0.15)' : 'rgba(230,126,34,0.15)'}
|
|
/>
|
|
<Text style={styles.tradeTime}>{new Date(trade.timestamp).toLocaleString()}</Text>
|
|
</View>
|
|
</View>
|
|
</AnimatedCard>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: Colors.background.primary,
|
|
},
|
|
headerSection: {
|
|
padding: Spacing.screenPadding,
|
|
paddingBottom: 0,
|
|
},
|
|
subtitle: {
|
|
fontFamily: Fonts.inter.black,
|
|
fontSize: FontSize.micro,
|
|
color: Colors.accent.green,
|
|
letterSpacing: 4,
|
|
marginBottom: 8,
|
|
},
|
|
pageTitle: {
|
|
fontFamily: Fonts.inter.black,
|
|
fontSize: FontSize.hero,
|
|
color: Colors.text.primary,
|
|
letterSpacing: -0.5,
|
|
marginBottom: 16,
|
|
},
|
|
scroll: {
|
|
flex: 1,
|
|
},
|
|
content: {
|
|
padding: Spacing.screenPadding,
|
|
gap: 16,
|
|
paddingBottom: 120,
|
|
},
|
|
metricsRow: {
|
|
gap: 12,
|
|
},
|
|
metricCard: {
|
|
backgroundColor: Colors.background.subtle,
|
|
borderRadius: BorderRadius.large,
|
|
borderWidth: 1,
|
|
borderColor: Colors.border.default,
|
|
padding: 20,
|
|
minWidth: 140,
|
|
},
|
|
metricLabel: {
|
|
fontFamily: Fonts.inter.black,
|
|
fontSize: FontSize.micro,
|
|
color: Colors.text.secondary,
|
|
letterSpacing: 3,
|
|
marginBottom: 8,
|
|
},
|
|
metricValue: {
|
|
fontFamily: Fonts.inter.black,
|
|
fontSize: FontSize.hero,
|
|
color: Colors.text.primary,
|
|
},
|
|
filterRow: {
|
|
flexDirection: 'row',
|
|
gap: 8,
|
|
},
|
|
filterPill: {
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 7,
|
|
borderRadius: BorderRadius.xs,
|
|
borderWidth: 1,
|
|
borderColor: Colors.border.medium,
|
|
backgroundColor: 'rgba(255,255,255,0.03)',
|
|
},
|
|
filterActive: {
|
|
borderColor: 'rgba(0,255,136,0.45)',
|
|
backgroundColor: 'rgba(0,255,136,0.08)',
|
|
},
|
|
filterText: {
|
|
fontFamily: Fonts.inter.semiBold,
|
|
fontSize: FontSize.badge,
|
|
color: '#a1a1aa',
|
|
},
|
|
filterTextActive: {
|
|
color: Colors.accent.green,
|
|
},
|
|
tradeCard: {
|
|
backgroundColor: Colors.background.card,
|
|
borderRadius: BorderRadius.medium,
|
|
borderWidth: 1,
|
|
borderColor: Colors.border.default,
|
|
overflow: 'hidden',
|
|
flexDirection: 'row',
|
|
},
|
|
tradeCardLoss: {
|
|
backgroundColor: 'rgba(255,51,102,0.04)',
|
|
},
|
|
lossLeftBorder: {
|
|
width: 2,
|
|
backgroundColor: Colors.accent.red,
|
|
},
|
|
tradeContent: {
|
|
flex: 1,
|
|
padding: 16,
|
|
gap: 8,
|
|
},
|
|
tradeHeader: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
},
|
|
tradeSymbol: {
|
|
fontFamily: Fonts.inter.extraBold,
|
|
fontSize: FontSize.bodyLarge,
|
|
color: Colors.text.primary,
|
|
},
|
|
tradePnl: {
|
|
fontFamily: Fonts.mono.extraBold,
|
|
fontSize: FontSize.body,
|
|
marginLeft: 'auto',
|
|
},
|
|
tradeDetails: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
},
|
|
tradeArrow: {
|
|
fontFamily: Fonts.mono.medium,
|
|
fontSize: FontSize.body,
|
|
color: Colors.text.primary,
|
|
},
|
|
tradeSize: {
|
|
fontFamily: Fonts.mono.regular,
|
|
fontSize: FontSize.bodySmall,
|
|
color: Colors.text.secondary,
|
|
},
|
|
tradeFooter: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
marginTop: 4,
|
|
},
|
|
tradeTime: {
|
|
fontFamily: Fonts.inter.medium,
|
|
fontSize: FontSize.micro,
|
|
color: Colors.text.secondary,
|
|
marginLeft: 'auto',
|
|
},
|
|
emptyState: {
|
|
alignItems: 'center' as const,
|
|
justifyContent: 'center' as const,
|
|
paddingVertical: 60,
|
|
gap: 10,
|
|
},
|
|
emptyText: {
|
|
fontFamily: Fonts.inter.bold,
|
|
fontSize: FontSize.subheading,
|
|
color: Colors.text.secondary,
|
|
},
|
|
emptyHint: {
|
|
fontFamily: Fonts.inter.medium,
|
|
fontSize: FontSize.body,
|
|
color: Colors.text.muted,
|
|
textAlign: 'center' as const,
|
|
maxWidth: 260,
|
|
lineHeight: 20,
|
|
},
|
|
});
|