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>
323 lines
10 KiB
TypeScript
323 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 { LinearGradient } from 'expo-linear-gradient';
|
|
import { Colors, Fonts, FontSize, BorderRadius, Shadows, Spacing } from '@/constants/theme';
|
|
import { formatPrice, formatPercent, formatCurrency } from '@/utils/format';
|
|
import SegmentedControl from '@/components/SegmentedControl';
|
|
import AnimatedCard from '@/components/AnimatedCard';
|
|
import Sparkline from '@/components/Sparkline';
|
|
import PillBadge from '@/components/PillBadge';
|
|
import { useTradingData } from '@/providers/TradingDataProvider';
|
|
import { toPositionCards } from '@/lib/tradingViewModels';
|
|
|
|
type MobileOrder = {
|
|
id: string;
|
|
symbol: string;
|
|
type: string;
|
|
side: string;
|
|
qty: number;
|
|
price: number;
|
|
status: string;
|
|
action?: 'ENTRY' | 'EXIT';
|
|
source?: 'BOT' | 'MANUAL';
|
|
timestamp: number;
|
|
};
|
|
|
|
function PositionCard({ pos, index }: { pos: ReturnType<typeof toPositionCards>[number]; index: number }) {
|
|
const isPositive = pos.unrealizedPnl >= 0;
|
|
const sideColor = pos.side === 'BUY' ? Colors.accent.green : Colors.accent.red;
|
|
|
|
return (
|
|
<AnimatedCard index={index} style={[Shadows.card, { borderRadius: BorderRadius.large }]}>
|
|
<LinearGradient
|
|
colors={['rgba(20,21,26,0.9)', 'rgba(14,15,18,0.95)']}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
style={styles.posCard}
|
|
>
|
|
<View style={[styles.accentLine, { backgroundColor: sideColor }]} />
|
|
|
|
<View style={styles.row}>
|
|
<Text style={styles.symbol}>{pos.symbol}</Text>
|
|
<PillBadge
|
|
label={pos.side === 'BUY' ? 'LONG' : 'SHORT'}
|
|
color={sideColor}
|
|
bgColor={pos.side === 'BUY' ? 'rgba(0,255,136,0.15)' : 'rgba(255,51,102,0.15)'}
|
|
/>
|
|
<PillBadge
|
|
label={pos.source}
|
|
color={pos.source === 'BOT' ? Colors.accent.purple : Colors.accent.orange}
|
|
bgColor={pos.source === 'BOT' ? 'rgba(168,85,247,0.15)' : 'rgba(230,126,34,0.15)'}
|
|
/>
|
|
</View>
|
|
|
|
<Text style={styles.profileText}>{pos.profileName}</Text>
|
|
|
|
<View style={styles.metricsGrid}>
|
|
<MetricCell label="ENTRY" value={formatPrice(pos.entryPrice)} />
|
|
<MetricCell label="CURRENT" value={formatPrice(pos.currentPrice)} />
|
|
<MetricCell label="SIZE" value={`${pos.size} ${pos.symbol.split('/')[0]}`} />
|
|
<MetricCell label="MKT VALUE" value={formatPrice(pos.marketValue)} />
|
|
</View>
|
|
|
|
<View style={styles.slTpRow}>
|
|
<Text style={[styles.slTp, { color: Colors.accent.red }]}>SL {formatPrice(pos.stopLoss)}</Text>
|
|
<Text style={[styles.slTp, { color: Colors.accent.green }]}>TP {formatPrice(pos.takeProfit)}</Text>
|
|
</View>
|
|
|
|
<Text style={[styles.pnlHero, { color: isPositive ? Colors.accent.green : Colors.accent.red }]}>
|
|
{formatCurrency(pos.unrealizedPnl)} ({formatPercent(pos.unrealizedPnlPercent)})
|
|
</Text>
|
|
|
|
<Sparkline
|
|
data={pos.sparkData}
|
|
width={300}
|
|
height={50}
|
|
color={isPositive ? Colors.accent.green : Colors.accent.red}
|
|
/>
|
|
</LinearGradient>
|
|
</AnimatedCard>
|
|
);
|
|
}
|
|
|
|
function OrderCard({ order, index }: { order: MobileOrder; index: number }) {
|
|
const actionColors = {
|
|
ENTRY: { bg: 'rgba(59,130,246,0.1)', color: '#3b82f6', border: 'rgba(59,130,246,0.2)' },
|
|
EXIT: { bg: 'rgba(245,158,11,0.1)', color: '#f59e0b', border: 'rgba(245,158,11,0.2)' },
|
|
};
|
|
const statusColors: Record<string, { bg: string; color: string }> = {
|
|
filled: { bg: 'rgba(0,255,136,0.15)', color: Colors.accent.green },
|
|
pending_new: { bg: 'rgba(250,204,21,0.15)', color: Colors.accent.amber },
|
|
cancelled: { bg: 'rgba(255,255,255,0.05)', color: Colors.text.secondary },
|
|
};
|
|
const actionKey = order.action || 'ENTRY';
|
|
const ac = actionColors[actionKey];
|
|
const sc = statusColors[order.status] || statusColors.cancelled;
|
|
const source = order.source || 'BOT';
|
|
|
|
return (
|
|
<AnimatedCard index={index} style={[Shadows.card, { borderRadius: BorderRadius.medium }]}>
|
|
<View style={styles.orderCard}>
|
|
<View style={styles.row}>
|
|
<Text style={styles.symbol}>{order.symbol}</Text>
|
|
<PillBadge label={actionKey} color={ac.color} bgColor={ac.bg} borderColor={ac.border} />
|
|
<PillBadge label={order.status.replace('_', ' ')} color={sc.color} bgColor={sc.bg} />
|
|
</View>
|
|
<View style={styles.orderDetails}>
|
|
<Text style={styles.orderType}>{order.type} {order.side}</Text>
|
|
<Text style={styles.orderQty}>{order.qty} @ {formatPrice(order.price)}</Text>
|
|
</View>
|
|
<View style={styles.orderFooter}>
|
|
<PillBadge
|
|
label={source}
|
|
color={source === 'BOT' ? Colors.accent.purple : Colors.accent.orange}
|
|
bgColor={source === 'BOT' ? 'rgba(168,85,247,0.15)' : 'rgba(230,126,34,0.15)'}
|
|
/>
|
|
<Text style={styles.orderTime}>
|
|
{new Date(order.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</AnimatedCard>
|
|
);
|
|
}
|
|
|
|
function MetricCell({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<View style={styles.metricCell}>
|
|
<Text style={styles.metricLabel}>{label}</Text>
|
|
<Text style={styles.metricValue}>{value}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export default function PositionsScreen() {
|
|
const insets = useSafeAreaInsets();
|
|
const [activeTab, setActiveTab] = useState(0);
|
|
const { botState } = useTradingData();
|
|
const positions = toPositionCards(botState?.positions || [], botState?.symbols);
|
|
const orders: MobileOrder[] = botState?.orders || [];
|
|
|
|
return (
|
|
<View style={[styles.container, { paddingTop: insets.top }]}>
|
|
<View style={styles.headerSection}>
|
|
<Text style={styles.sectionLabel}>PORTFOLIO</Text>
|
|
<Text style={styles.pageTitle}>Positions</Text>
|
|
<SegmentedControl
|
|
segments={['Positions', 'Orders']}
|
|
activeIndex={activeTab}
|
|
onPress={setActiveTab}
|
|
/>
|
|
</View>
|
|
|
|
<ScrollView
|
|
style={styles.scroll}
|
|
contentContainerStyle={styles.listContent}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{activeTab === 0 ? (
|
|
positions.length > 0
|
|
? positions.map((pos, i) => <PositionCard key={pos.id} pos={pos} index={i} />)
|
|
: <View style={styles.emptyState}><Text style={styles.emptyText}>No open positions</Text><Text style={styles.emptyHint}>Active positions will appear here once the bot enters a trade.</Text></View>
|
|
) : (
|
|
orders.length > 0
|
|
? orders.map((ord, i) => <OrderCard key={ord.id} order={ord} index={i} />)
|
|
: <View style={styles.emptyState}><Text style={styles.emptyText}>No pending orders</Text><Text style={styles.emptyHint}>Orders placed by the bot or manually will appear here.</Text></View>
|
|
)}
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: Colors.background.primary,
|
|
},
|
|
headerSection: {
|
|
padding: Spacing.screenPadding,
|
|
gap: 12,
|
|
},
|
|
sectionLabel: {
|
|
fontFamily: Fonts.inter.black,
|
|
fontSize: FontSize.micro,
|
|
color: Colors.accent.green,
|
|
letterSpacing: 4,
|
|
},
|
|
pageTitle: {
|
|
fontFamily: Fonts.inter.black,
|
|
fontSize: FontSize.hero,
|
|
color: Colors.text.primary,
|
|
letterSpacing: -0.5,
|
|
},
|
|
scroll: {
|
|
flex: 1,
|
|
},
|
|
listContent: {
|
|
padding: Spacing.screenPadding,
|
|
paddingTop: 0,
|
|
gap: 16,
|
|
paddingBottom: 120,
|
|
},
|
|
posCard: {
|
|
borderRadius: BorderRadius.large,
|
|
padding: Spacing.cardPadding,
|
|
borderWidth: 1,
|
|
borderColor: Colors.border.default,
|
|
overflow: 'hidden',
|
|
gap: 10,
|
|
},
|
|
accentLine: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: 2,
|
|
opacity: 0.6,
|
|
},
|
|
row: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
},
|
|
symbol: {
|
|
fontFamily: Fonts.inter.black,
|
|
fontSize: FontSize.subheading,
|
|
color: Colors.text.primary,
|
|
letterSpacing: -0.3,
|
|
},
|
|
profileText: {
|
|
fontFamily: Fonts.inter.medium,
|
|
fontSize: FontSize.badge,
|
|
color: Colors.text.secondary,
|
|
},
|
|
metricsGrid: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
gap: 12,
|
|
marginTop: 4,
|
|
},
|
|
metricCell: {
|
|
width: '46%',
|
|
},
|
|
metricLabel: {
|
|
fontFamily: Fonts.inter.black,
|
|
fontSize: FontSize.micro,
|
|
color: Colors.text.secondary,
|
|
letterSpacing: 0.8,
|
|
marginBottom: 3,
|
|
},
|
|
metricValue: {
|
|
fontFamily: Fonts.mono.extraBold,
|
|
fontSize: FontSize.bodyLarge,
|
|
color: Colors.text.primary,
|
|
},
|
|
slTpRow: {
|
|
flexDirection: 'row',
|
|
gap: 16,
|
|
marginTop: 4,
|
|
},
|
|
slTp: {
|
|
fontFamily: Fonts.mono.medium,
|
|
fontSize: FontSize.badge,
|
|
},
|
|
pnlHero: {
|
|
fontFamily: Fonts.mono.extraBold,
|
|
fontSize: FontSize.heading,
|
|
marginTop: 4,
|
|
},
|
|
orderCard: {
|
|
backgroundColor: Colors.background.card,
|
|
borderRadius: BorderRadius.medium,
|
|
padding: Spacing.cardPadding,
|
|
borderWidth: 1,
|
|
borderColor: Colors.border.default,
|
|
gap: 10,
|
|
},
|
|
orderDetails: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
},
|
|
orderType: {
|
|
fontFamily: Fonts.inter.bold,
|
|
fontSize: FontSize.body,
|
|
color: Colors.text.primary,
|
|
},
|
|
orderQty: {
|
|
fontFamily: Fonts.mono.bold,
|
|
fontSize: FontSize.body,
|
|
color: Colors.text.primary,
|
|
},
|
|
orderFooter: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
},
|
|
orderTime: {
|
|
fontFamily: Fonts.mono.regular,
|
|
fontSize: FontSize.micro,
|
|
color: Colors.text.secondary,
|
|
},
|
|
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,
|
|
},
|
|
});
|