feat: add mobile live trading integration

This commit is contained in:
Saravana Achu Mac 2026-04-04 11:33:11 -07:00
parent 3cbbd6ccaa
commit 0d9654e742
21 changed files with 976 additions and 104 deletions

View File

@ -15,11 +15,15 @@ NEXT_PUBLIC_TRADING_API_URL=http://localhost:4018/api
VITE_PRODUCT_ID=invttrdg
VITE_PLATFORM_URL=http://localhost:4003/api
VITE_TRADING_API_URL=http://localhost:4018/api
VITE_SUPABASE_URL=
VITE_SUPABASE_ANON_KEY=
# Mobile public envs
EXPO_PUBLIC_PRODUCT_ID=invttrdg
EXPO_PUBLIC_PLATFORM_URL=http://localhost:4003/api
EXPO_PUBLIC_TRADING_API_URL=http://localhost:4018/api
EXPO_PUBLIC_SUPABASE_URL=
EXPO_PUBLIC_SUPABASE_ANON_KEY=
# Backend envs
PORT=4018
@ -28,4 +32,3 @@ CORS_ALLOWED_ORIGINS=http://localhost:3048,http://localhost:8081
SUPABASE_URL=
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=

View File

@ -29,9 +29,9 @@ It assumes:
- [x] Monorepo foundation scaffolded with root workspace config, shared runtime, shared product identity, local package linking, and verification scripts
- [x] Backend migrated into `backend/` and passing typecheck, build, test, and backend verification gates
- [x] Web migrated into `web/` with shared runtime, shared kill-switch gate, shared telemetry bootstrap, and normalized backend URL resolution
- [x] Mobile migrated into `mobile/` with product identity, shared runtime bootstrap, and launch-time kill-switch gate
- [x] Mobile migrated into `mobile/` with product identity, shared runtime bootstrap, launch-time kill-switch gate, transitional Supabase auth, and live backend polling
- [-] DRY cleanup completed for runtime/config/bootstrap concerns, but not yet for all auth/session internals
- [!] Full common-platform auth replacement remains a follow-up for web and mobile; current implementation uses a transitional path to preserve working behavior
- [!] Full common-platform auth replacement remains a follow-up for web and mobile; current implementation uses transitional Supabase-backed auth to stay compatible with the backend's current JWT boundary
## 3. Guiding Rules
@ -303,27 +303,27 @@ Build mobile as a real ecosystem surface, not a mock UI shell.
- [x] Create Expo app structure following FastGap-style monorepo conventions
- [x] Add product config bootstrap
- [-] Integrate `@bytelyst/react-native-platform-sdk`
- [ ] Implement auth flow and session restore
- [ ] Define secure storage and session invalidation behavior
- [x] Implement auth flow and session restore
- [-] Define secure storage and session invalidation behavior
- [x] Implement launch-time kill-switch and maintenance handling
- [ ] Add telemetry startup and error capture
- [x] Define initial mobile scope
- [ ] Connect to backend and websocket/status contracts
- [-] Connect to backend and websocket/status contracts
- [ ] Add push-notification-ready architecture
- [ ] Define mobile action policy for monitor-first versus control-first flows
- [ ] Define alert and incident UX
- [ ] Define operator-safe interventions
- [ ] Define offline and degraded-state behavior
- [x] Define mobile action policy for monitor-first versus control-first flows
- [x] Define alert and incident UX
- [-] Define operator-safe interventions
- [-] Define offline and degraded-state behavior
### Mobile v1 Scope
- [ ] Sign in / restore session
- [x] Sign in / restore session
- [x] Portfolio overview
- [x] Alerts and critical incidents
- [x] Positions
- [x] Recent history
- [x] Settings and sign out
- [ ] Safe operator controls limited to explicitly approved actions
- [-] Safe operator controls limited to explicitly approved actions
- [x] Maintain monitor-first, but not monitor-only scope
### Do Not Do in Mobile v1
@ -352,9 +352,9 @@ Remove duplicated implementation patterns exposed during migration.
### Checklist
- [ ] Consolidate auth/session bootstrap
- [-] Consolidate auth/session bootstrap
- [x] Consolidate product config resolution
- [ ] Consolidate request headers and token propagation helpers
- [-] Consolidate request headers and token propagation helpers
- [x] Consolidate telemetry boot and event fields
- [x] Consolidate kill-switch UX and service-state handling
- [x] Consolidate shared types for product contracts

View File

@ -1,24 +1,36 @@
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, Shadows, Spacing } from '@/constants/theme';
import { trades, historyMetrics } from '@/constants/mockData';
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 PROFILES = ['All', 'Aggressive Bot', 'Balanced Core', 'Conservative Swing'];
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
? trades
: trades.filter(t => t.profileName === PROFILES[profileIndex]);
? 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 }]}>
@ -33,15 +45,15 @@ export default function HistoryScreen() {
showsVerticalScrollIndicator={false}
>
<SegmentedControl
segments={PROFILES}
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}%`} color={Colors.accent.green} />
<MetricCard label="NET P&L" value={`+$${historyMetrics.netPnl.toFixed(2)}`} color={Colors.accent.green} mono />
<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}>
@ -55,8 +67,8 @@ export default function HistoryScreen() {
))}
</View>
{filteredTrades.map((trade, index) => (
<TradeRow key={trade.id} trade={trade} index={index} />
{timeFilteredTrades.map((trade, index) => (
<TradeRow key={`${trade.symbol}-${trade.timestamp}-${index}`} trade={trade} index={index} />
))}
</ScrollView>
</View>
@ -87,7 +99,18 @@ function PressableFilter({ label, active, onPress }: { label: string; active: bo
);
}
function TradeRow({ trade, index }: { trade: typeof trades[0]; index: number }) {
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;
@ -119,17 +142,17 @@ function TradeRow({ trade, index }: { trade: typeof trades[0]; index: number })
<Text style={styles.tradeArrow}>
{formatPrice(trade.entryPrice)} {formatPrice(trade.exitPrice)}
</Text>
<Text style={styles.tradeSize}>{trade.size} {trade.sizeUnit}</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}
color={trade.source === 'BOT' ? Colors.accent.purple : Colors.accent.orange}
bgColor={trade.source === 'BOT' ? 'rgba(168,85,247,0.15)' : 'rgba(230,126,34,0.15)'}
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}>{trade.timestamp}</Text>
<Text style={styles.tradeTime}>{new Date(trade.timestamp).toLocaleString()}</Text>
</View>
</View>
</AnimatedCard>

View File

@ -9,15 +9,17 @@ import MarketTicker from '@/components/dashboard/MarketTicker';
import ActiveAlerts from '@/components/dashboard/ActiveAlerts';
import QuickPositions from '@/components/dashboard/QuickPositions';
import AnimatedCard from '@/components/AnimatedCard';
import { useTradingData } from '@/providers/TradingDataProvider';
export default function DashboardScreen() {
const insets = useSafeAreaInsets();
const { refresh, loading } = useTradingData();
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(() => {
setRefreshing(true);
setTimeout(() => setRefreshing(false), 1000);
}, []);
void refresh().finally(() => setRefreshing(false));
}, [refresh]);
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
@ -27,7 +29,7 @@ export default function DashboardScreen() {
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
refreshing={refreshing || loading}
onRefresh={onRefresh}
tintColor={Colors.accent.green}
colors={[Colors.accent.green]}

View File

@ -3,14 +3,28 @@ 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 { positions, orders } from '@/constants/mockData';
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';
function PositionCard({ pos, index }: { pos: typeof positions[0]; index: number }) {
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;
@ -67,7 +81,7 @@ function PositionCard({ pos, index }: { pos: typeof positions[0]; index: number
);
}
function OrderCard({ order, index }: { order: typeof orders[0]; index: number }) {
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)' },
@ -77,15 +91,17 @@ function OrderCard({ order, index }: { order: typeof orders[0]; index: number })
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 ac = actionColors[order.action];
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={order.action} color={ac.color} bgColor={ac.bg} borderColor={ac.border} />
<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}>
@ -94,9 +110,9 @@ function OrderCard({ order, index }: { order: typeof orders[0]; index: number })
</View>
<View style={styles.orderFooter}>
<PillBadge
label={order.source}
color={order.source === 'BOT' ? Colors.accent.purple : Colors.accent.orange}
bgColor={order.source === 'BOT' ? 'rgba(168,85,247,0.15)' : 'rgba(230,126,34,0.15)'}
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' })}
@ -119,6 +135,9 @@ function MetricCell({ label, value }: { label: string; value: string }) {
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 File

@ -1,18 +1,21 @@
import React, { useState } from 'react';
import { View, Text, ScrollView, Switch, TextInput, StyleSheet } from 'react-native';
import { Alert, View, Text, ScrollView, Switch, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { LinearGradient } from 'expo-linear-gradient';
import { ChevronRight, Lock, Check, X } from 'lucide-react-native';
import { ChevronRight, Lock, Check } from 'lucide-react-native';
import { Colors, Fonts, FontSize, BorderRadius, Spacing } from '@/constants/theme';
import SegmentedControl from '@/components/SegmentedControl';
import AnimatedCard from '@/components/AnimatedCard';
import PressableScale from '@/components/PressableScale';
import { useTradingData } from '@/providers/TradingDataProvider';
import { useMobileAuth } from '@/providers/MobileAuthProvider';
export default function SettingsScreen() {
const insets = useSafeAreaInsets();
const [executionMode, setExecutionMode] = useState(1);
const [riskPercent, setRiskPercent] = useState(1);
const [maxOpenTrades, setMaxOpenTrades] = useState(3);
const { botState, portfolio, pauseTrading, resumeTrading } = useTradingData();
const { profile, signOut } = useMobileAuth();
const executionModeIndex = botState?.settings.executionMode === 'Live' ? 2 : botState?.settings.executionMode === 'Paper' ? 1 : 0;
const [maxOpenTrades, setMaxOpenTrades] = useState(botState?.settings.maxOpenTrades || 3);
const [notifications, setNotifications] = useState({
priceAlerts: true,
tradeExecuted: true,
@ -20,6 +23,8 @@ export default function SettingsScreen() {
dailySummary: false,
});
const [oledBlack, setOledBlack] = useState(false);
const tradingMode = botState?.health?.tradingControl?.mode ?? 'RUNNING';
const isAdmin = profile?.role === 'admin';
const modeColors = [Colors.text.secondary, Colors.accent.blue, Colors.accent.orange];
@ -43,14 +48,16 @@ export default function SettingsScreen() {
colors={['#00ff88', '#00cc6a']}
style={styles.avatar}
>
<Text style={styles.avatarText}>SK</Text>
<Text style={styles.avatarText}>
{`${profile?.first_name?.[0] || profile?.email?.[0] || 'T'}${profile?.last_name?.[0] || ''}`.slice(0, 2).toUpperCase()}
</Text>
</LinearGradient>
<View style={styles.accountInfo}>
<Text style={styles.accountName}>Saravana Kumar</Text>
<Text style={styles.accountEmail}>saravana@bytelyst.ai</Text>
<Text style={styles.accountName}>{[profile?.first_name, profile?.last_name].filter(Boolean).join(' ') || 'Trading User'}</Text>
<Text style={styles.accountEmail}>{profile?.email || 'No email loaded'}</Text>
</View>
<View style={styles.tierBadge}>
<Text style={styles.tierText}>ELITE</Text>
<Text style={styles.tierText}>{(profile?.role || 'member').toUpperCase()}</Text>
</View>
</View>
</View>
@ -61,12 +68,12 @@ export default function SettingsScreen() {
<Text style={styles.sectionHeader}>EXECUTION MODE</Text>
<SegmentedControl
segments={['Alerts', 'Paper', 'Live']}
activeIndex={executionMode}
onPress={setExecutionMode}
activeColor={modeColors[executionMode]}
activeTextColor={executionMode === 2 ? '#fff' : '#000'}
activeIndex={executionModeIndex}
onPress={() => undefined}
activeColor={modeColors[executionModeIndex]}
activeTextColor={executionModeIndex === 2 ? '#fff' : '#000'}
/>
{executionMode === 2 && (
{executionModeIndex === 2 && (
<View style={styles.warningBanner}>
<Text style={styles.warningText}>
Real money trading enabled. Use caution.
@ -82,9 +89,9 @@ export default function SettingsScreen() {
<SettingRow label="Risk Per Trade">
<View style={styles.sliderRow}>
<View style={styles.sliderTrack}>
<View style={[styles.sliderFill, { width: `${((riskPercent - 0.5) / 4.5) * 100}%` as any }]} />
<View style={[styles.sliderFill, { width: `${Math.min((Number(botState?.settings.riskPerTrade || 0) / 0.05) * 100, 100)}%` as any }]} />
</View>
<Text style={styles.sliderValue}>{riskPercent.toFixed(1)}%</Text>
<Text style={styles.sliderValue}>{(Number(botState?.settings.riskPerTrade || 0) * 100).toFixed(2)}%</Text>
</View>
</SettingRow>
<SettingRow label="Max Open Trades">
@ -105,7 +112,7 @@ export default function SettingsScreen() {
</View>
</SettingRow>
<SettingRow label="Total Capital">
<Text style={styles.capitalValue}>$25,000</Text>
<Text style={styles.capitalValue}>${portfolio.totalCapital.toLocaleString()}</Text>
</SettingRow>
</View>
</AnimatedCard>
@ -120,11 +127,46 @@ export default function SettingsScreen() {
<Text style={styles.connectedText}>Connected</Text>
</View>
</View>
<Text style={styles.apiKeyHint}>API Key: k3xR</Text>
<Text style={styles.apiKeyHint}>{tradingMode === 'PAUSED' ? 'Trading is currently paused.' : 'Broker state is sourced from the live backend.'}</Text>
</View>
</AnimatedCard>
<AnimatedCard index={4}>
{isAdmin ? (
<AnimatedCard index={4}>
<View style={styles.section}>
<Text style={styles.sectionHeader}>TRADING CONTROL</Text>
<Text style={styles.apiKeyHint}>
Mobile is monitor-first. Only limited safety controls are exposed here.
</Text>
<View style={styles.actionRow}>
<PressableScale
style={[styles.actionButton, tradingMode === 'PAUSED' && styles.actionButtonDisabled]}
onPress={async () => {
const result = await pauseTrading('Paused from mobile control surface');
if (result.error) {
Alert.alert('Pause failed', result.error);
}
}}
>
<Text style={styles.actionButtonText}>Pause Trading</Text>
</PressableScale>
<PressableScale
style={[styles.actionButton, styles.actionButtonSuccess, tradingMode !== 'PAUSED' && styles.actionButtonDisabled]}
onPress={async () => {
const result = await resumeTrading('Resumed from mobile control surface');
if (result.error) {
Alert.alert('Resume failed', result.error);
}
}}
>
<Text style={styles.actionButtonText}>Resume Trading</Text>
</PressableScale>
</View>
</View>
</AnimatedCard>
) : null}
<AnimatedCard index={5}>
<View style={styles.section}>
<Text style={styles.sectionHeader}>NOTIFICATIONS</Text>
<ToggleRow
@ -150,7 +192,7 @@ export default function SettingsScreen() {
</View>
</AnimatedCard>
<AnimatedCard index={5}>
<AnimatedCard index={6}>
<View style={styles.section}>
<Text style={styles.sectionHeader}>APPEARANCE</Text>
<View style={styles.toggleRow}>
@ -173,13 +215,17 @@ export default function SettingsScreen() {
</View>
</AnimatedCard>
<AnimatedCard index={6}>
<AnimatedCard index={7}>
<View style={styles.section}>
<Text style={styles.sectionHeader}>ABOUT</Text>
<View style={styles.aboutRow}>
<Text style={styles.aboutLabel}>Version</Text>
<Text style={styles.aboutValue}>v2.3</Text>
</View>
<PressableScale style={styles.linkRow} onPress={() => void signOut()}>
<Text style={styles.linkText}>Sign Out</Text>
<ChevronRight size={16} color={Colors.text.muted} />
</PressableScale>
<PressableScale style={styles.linkRow}>
<Text style={styles.linkText}>Terms of Service</Text>
<ChevronRight size={16} color={Colors.text.muted} />
@ -450,4 +496,31 @@ const styles = StyleSheet.create({
fontSize: FontSize.body,
color: Colors.text.primary,
},
actionRow: {
flexDirection: 'row',
gap: 10,
marginTop: 14,
},
actionButton: {
flex: 1,
borderRadius: BorderRadius.medium,
backgroundColor: 'rgba(255,149,0,0.16)',
borderWidth: 1,
borderColor: 'rgba(255,149,0,0.35)',
paddingVertical: 12,
alignItems: 'center',
},
actionButtonSuccess: {
backgroundColor: 'rgba(0,255,136,0.14)',
borderColor: 'rgba(0,255,136,0.35)',
},
actionButtonDisabled: {
opacity: 0.45,
},
actionButtonText: {
fontFamily: Fonts.inter.black,
fontSize: FontSize.badge,
color: Colors.text.primary,
letterSpacing: 0.5,
},
});

View File

@ -20,6 +20,9 @@ import {
import * as SplashScreen from 'expo-splash-screen';
import { ProductAvailabilityGate } from '@/components/ProductAvailabilityGate';
import { createMobilePlatformSdk, mobileRuntime } from '@/lib/runtime';
import { AuthGate } from '@/components/auth/AuthGate';
import { MobileAuthProvider } from '@/providers/MobileAuthProvider';
import { TradingDataProvider } from '@/providers/TradingDataProvider';
SplashScreen.preventAutoHideAsync();
@ -59,13 +62,19 @@ export default function RootLayout() {
return (
<ProductAvailabilityGate>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="marketplace" options={{ presentation: 'modal' }} />
<Stack.Screen name="chat" options={{ presentation: 'transparentModal', animation: 'fade' }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="light" />
<MobileAuthProvider>
<AuthGate>
<TradingDataProvider>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="marketplace" options={{ presentation: 'modal' }} />
<Stack.Screen name="chat" options={{ presentation: 'transparentModal', animation: 'fade' }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="light" />
</TradingDataProvider>
</AuthGate>
</MobileAuthProvider>
</ProductAvailabilityGate>
);
}

View File

@ -0,0 +1,142 @@
import React, { useMemo, useState } from 'react';
import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import type { ReactNode } from 'react';
import { LinearGradient } from 'expo-linear-gradient';
import { Colors, Fonts, FontSize, BorderRadius, Spacing } from '@/constants/theme';
import { useMobileAuth } from '@/providers/MobileAuthProvider';
export function AuthGate({ children }: { children: ReactNode }) {
const { user, loading, signIn, error } = useMobileAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const errorMessage = useMemo(() => error, [error]);
if (loading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color={Colors.accent.green} />
<Text style={styles.loadingText}>Restoring trading session...</Text>
</View>
);
}
if (user) {
return <>{children}</>;
}
return (
<View style={styles.screen}>
<LinearGradient colors={['#14151f', '#0f1017']} style={styles.card}>
<Text style={styles.eyebrow}>BYTElyst TRADING</Text>
<Text style={styles.title}>Sign in to your trading workspace</Text>
<Text style={styles.subtitle}>
Mobile uses the same Supabase-backed identity boundary as the current trading backend.
</Text>
<TextInput
value={email}
onChangeText={setEmail}
style={styles.input}
placeholder="Email"
placeholderTextColor={Colors.text.ultraDim}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
value={password}
onChangeText={setPassword}
style={styles.input}
placeholder="Password"
placeholderTextColor={Colors.text.ultraDim}
autoCapitalize="none"
secureTextEntry
/>
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
<Pressable
style={styles.button}
disabled={submitting}
onPress={async () => {
setSubmitting(true);
await signIn(email.trim(), password);
setSubmitting(false);
}}
>
<Text style={styles.buttonText}>{submitting ? 'Signing in...' : 'Sign In'}</Text>
</Pressable>
</LinearGradient>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: Colors.background.primary,
justifyContent: 'center',
padding: Spacing.screenPadding,
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: Colors.background.primary,
gap: 12,
},
loadingText: {
fontFamily: Fonts.inter.medium,
fontSize: FontSize.body,
color: Colors.text.secondary,
},
card: {
borderRadius: BorderRadius.large,
borderWidth: 1,
borderColor: Colors.border.default,
padding: Spacing.cardPaddingLarge,
gap: 14,
},
eyebrow: {
fontFamily: Fonts.inter.black,
fontSize: FontSize.micro,
color: Colors.accent.green,
letterSpacing: 3,
},
title: {
fontFamily: Fonts.inter.black,
fontSize: FontSize.heading,
color: Colors.text.primary,
},
subtitle: {
fontFamily: Fonts.inter.medium,
fontSize: FontSize.body,
color: Colors.text.secondary,
lineHeight: 21,
},
input: {
borderWidth: 1,
borderColor: Colors.border.default,
borderRadius: BorderRadius.medium,
backgroundColor: Colors.background.card,
color: Colors.text.primary,
paddingHorizontal: 14,
paddingVertical: 12,
fontFamily: Fonts.inter.medium,
fontSize: FontSize.body,
},
button: {
marginTop: 6,
borderRadius: BorderRadius.medium,
backgroundColor: Colors.accent.green,
paddingVertical: 14,
alignItems: 'center',
},
buttonText: {
fontFamily: Fonts.inter.black,
fontSize: FontSize.body,
color: '#04120b',
},
error: {
fontFamily: Fonts.inter.medium,
fontSize: FontSize.bodySmall,
color: Colors.accent.red,
},
});

View File

@ -1,8 +1,10 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Colors, Fonts, FontSize, BorderRadius, Spacing } from '@/constants/theme';
import { alerts, AlertType } from '@/constants/mockData';
import { Colors, Fonts, FontSize, BorderRadius } from '@/constants/theme';
import AnimatedCard from '@/components/AnimatedCard';
import { useTradingData } from '@/providers/TradingDataProvider';
type AlertType = 'signal' | 'error' | 'pulse' | 'info';
const ALERT_COLORS: Record<AlertType, string> = {
signal: Colors.accent.green,
@ -19,6 +21,13 @@ const ALERT_ICONS: Record<AlertType, string> = {
};
export default function ActiveAlerts() {
const { botState } = useTradingData();
const alerts = (botState?.alerts || []).slice(0, 5).map((alert, index) => ({
id: `${alert.symbol}-${alert.timestamp}-${index}`,
...alert,
timestampLabel: formatTimeAgo(alert.timestamp),
}));
return (
<View style={styles.container}>
<View style={styles.header}>
@ -35,7 +44,7 @@ export default function ActiveAlerts() {
<View style={styles.alertHeader}>
<Text style={styles.icon}>{ALERT_ICONS[alert.type]}</Text>
<Text style={styles.symbol}>{alert.symbol}</Text>
<Text style={styles.timestamp}>{alert.timestamp}</Text>
<Text style={styles.timestamp}>{alert.timestampLabel}</Text>
</View>
<Text style={styles.message}>{alert.message}</Text>
</View>
@ -45,6 +54,13 @@ export default function ActiveAlerts() {
);
}
function formatTimeAgo(timestamp: number) {
const deltaSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
if (deltaSeconds < 60) return `${deltaSeconds}s ago`;
if (deltaSeconds < 3600) return `${Math.floor(deltaSeconds / 60)}m ago`;
return `${Math.floor(deltaSeconds / 3600)}h ago`;
}
const styles = StyleSheet.create({
container: {
gap: 10,

View File

@ -1,5 +1,5 @@
import React, { useEffect, useRef } from 'react';
import { View, Text, StyleSheet, ScrollView } from 'react-native';
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
@ -9,9 +9,10 @@ import Animated, {
} from 'react-native-reanimated';
import { TrendingUp } from 'lucide-react-native';
import { Colors, Fonts, FontSize, BorderRadius } from '@/constants/theme';
import { marketTicker } from '@/constants/mockData';
import { useTradingData } from '@/providers/TradingDataProvider';
export default function MarketTicker() {
const { marketTicker } = useTradingData();
const translateX = useSharedValue(0);
useEffect(() => {

View File

@ -1,17 +1,10 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
useSharedValue,
useAnimatedProps,
withTiming,
useDerivedValue,
useAnimatedStyle,
runOnJS,
} from 'react-native-reanimated';
import { useSharedValue, withTiming } from 'react-native-reanimated';
import { Colors, Fonts, FontSize, BorderRadius, Shadows, Spacing } from '@/constants/theme';
import { portfolioData } from '@/constants/mockData';
import { formatNumber } from '@/utils/format';
import { useTradingData } from '@/providers/TradingDataProvider';
function CountUpValue({ target, prefix, suffix, style, duration = 800 }: {
target: number;
@ -47,6 +40,9 @@ function CountUpValue({ target, prefix, suffix, style, duration = 800 }: {
}
export default function PortfolioHeroCard() {
const { portfolio } = useTradingData();
const positive = portfolio.netPnl >= 0;
return (
<View style={[styles.wrapper, Shadows.card]}>
<LinearGradient
@ -60,13 +56,13 @@ export default function PortfolioHeroCard() {
<Text style={styles.label}>PORTFOLIO OVERVIEW</Text>
<CountUpValue
target={portfolioData.netPnl}
prefix="+$"
target={Math.abs(portfolio.netPnl)}
prefix={positive ? '+$' : '-$'}
style={styles.heroValue}
/>
<CountUpValue
target={portfolioData.netPnlPercent}
prefix="+"
target={Math.abs(portfolio.netPnlPercent)}
prefix={positive ? '+' : '-'}
suffix="%"
style={styles.heroPercent}
/>
@ -74,9 +70,9 @@ export default function PortfolioHeroCard() {
<View style={styles.divider} />
<View style={styles.metricsRow}>
<MetricItem label="TOTAL CAPITAL" value={formatNumber(portfolioData.totalCapital)} />
<MetricItem label="DEPLOYED" value={formatNumber(portfolioData.deployed)} />
<MetricItem label="AVAILABLE" value={formatNumber(portfolioData.available)} />
<MetricItem label="TOTAL CAPITAL" value={formatNumber(portfolio.totalCapital)} />
<MetricItem label="DEPLOYED" value={formatNumber(portfolio.deployed)} />
<MetricItem label="AVAILABLE" value={formatNumber(portfolio.available)} />
</View>
<View style={styles.utilizationContainer}>
@ -85,23 +81,23 @@ export default function PortfolioHeroCard() {
colors={['#00ff88', '#00cc6a']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.utilizationFill, { width: `${portfolioData.utilization}%` as any }]}
style={[styles.utilizationFill, { width: `${portfolio.utilization}%` as any }]}
/>
</View>
<Text style={styles.utilizationText}>{portfolioData.utilization}% utilized</Text>
<Text style={styles.utilizationText}>{portfolio.utilization.toFixed(1)}% utilized</Text>
</View>
<View style={styles.pnlRow}>
<View style={styles.pnlItem}>
<Text style={styles.pnlLabel}>Realized P&L</Text>
<Text style={[styles.pnlValue, { color: Colors.accent.green }]}>
+${portfolioData.realizedPnl.toLocaleString('en-US', { minimumFractionDigits: 2 })}
{portfolio.realizedPnl >= 0 ? '+' : '-'}${Math.abs(portfolio.realizedPnl).toLocaleString('en-US', { minimumFractionDigits: 2 })}
</Text>
</View>
<View style={styles.pnlItem}>
<Text style={styles.pnlLabel}>Unrealized P&L</Text>
<Text style={[styles.pnlValue, { color: Colors.accent.green }]}>
+${portfolioData.unrealizedPnl.toLocaleString('en-US', { minimumFractionDigits: 2 })}
{portfolio.unrealizedPnl >= 0 ? '+' : '-'}${Math.abs(portfolio.unrealizedPnl).toLocaleString('en-US', { minimumFractionDigits: 2 })}
</Text>
</View>
</View>

View File

@ -3,14 +3,16 @@ import { View, Text, Pressable, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { LinearGradient } from 'expo-linear-gradient';
import { Colors, Fonts, FontSize, BorderRadius, Shadows, Spacing } from '@/constants/theme';
import { positions } from '@/constants/mockData';
import { formatPrice, formatPercent, formatCurrency } from '@/utils/format';
import Sparkline from '@/components/Sparkline';
import AnimatedCard from '@/components/AnimatedCard';
import { useTradingData } from '@/providers/TradingDataProvider';
import { toPositionCards } from '@/lib/tradingViewModels';
export default function QuickPositions() {
const router = useRouter();
const preview = positions.slice(0, 2);
const { botState } = useTradingData();
const preview = toPositionCards(botState?.positions || [], botState?.symbols).slice(0, 2);
return (
<View style={styles.container}>

View File

@ -2,18 +2,29 @@ import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Colors, Fonts, FontSize, BorderRadius } from '@/constants/theme';
import PulsingDot from '@/components/PulsingDot';
import { useTradingData } from '@/providers/TradingDataProvider';
export default function StatusBanner() {
const { botState, connected } = useTradingData();
const controlMode = botState?.health?.tradingControl?.mode ?? 'RUNNING';
const executionMode = botState?.settings.executionMode || 'Paper';
const uptimeSeconds = botState?.uptime || 0;
const uptimeHours = Math.floor(uptimeSeconds / 3600);
const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60);
const isPaused = controlMode === 'PAUSED';
return (
<View style={styles.container}>
<View style={styles.badge}>
<PulsingDot size={6} />
<Text style={styles.runningText}>RUNNING</Text>
<View style={[styles.badge, isPaused && styles.pausedBadge]}>
{!isPaused ? <PulsingDot size={6} /> : null}
<Text style={[styles.runningText, isPaused && styles.pausedText]}>
{connected ? controlMode : 'OFFLINE'}
</Text>
</View>
<View style={[styles.badge, styles.paperBadge]}>
<Text style={styles.paperText}>PAPER</Text>
<Text style={styles.paperText}>{executionMode.toUpperCase()}</Text>
</View>
<Text style={styles.uptime}>4d 12h 33m</Text>
<Text style={styles.uptime}>{uptimeHours}h {uptimeMinutes}m</Text>
</View>
);
}
@ -54,6 +65,12 @@ const styles = StyleSheet.create({
color: Colors.accent.blue,
letterSpacing: 1,
},
pausedBadge: {
backgroundColor: 'rgba(255,149,0,0.15)',
},
pausedText: {
color: Colors.accent.amber,
},
uptime: {
fontFamily: Fonts.mono.regular,
fontSize: FontSize.bodySmall,

View File

@ -1,11 +1,14 @@
import React, { useState } from 'react';
import { View, Text, ScrollView, Pressable, StyleSheet } from 'react-native';
import { Text, ScrollView, Pressable, StyleSheet } from 'react-native';
import { Colors, Fonts, FontSize, BorderRadius } from '@/constants/theme';
import { winRates } from '@/constants/mockData';
import { triggerHaptic } from '@/utils/haptics';
import { useTradingData } from '@/providers/TradingDataProvider';
import { buildWinRates } from '@/lib/tradingViewModels';
export default function WinRateStrip() {
const [activeIndex, setActiveIndex] = useState(0);
const { portfolio } = useTradingData();
const winRates = buildWinRates(portfolio);
return (
<ScrollView

32
mobile/lib/supabase.ts Normal file
View File

@ -0,0 +1,32 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient } from '@supabase/supabase-js';
const supabaseUrl =
process.env.EXPO_PUBLIC_SUPABASE_URL ||
process.env.EXPO_PUBLIC_PLATFORM_SUPABASE_URL ||
process.env.EXPO_PUBLIC_PUBLIC_SUPABASE_URL;
const supabaseAnonKey =
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ||
process.env.EXPO_PUBLIC_PLATFORM_SUPABASE_ANON_KEY ||
process.env.EXPO_PUBLIC_PUBLIC_SUPABASE_ANON_KEY;
const fallbackSupabaseUrl = 'https://placeholder.bytilyst.local';
const fallbackSupabaseAnonKey = 'placeholder-anon-key';
if (!supabaseUrl || !supabaseAnonKey) {
console.warn('[mobile] Missing Supabase environment variables');
}
export const mobileSupabase = createClient(
supabaseUrl || fallbackSupabaseUrl,
supabaseAnonKey || fallbackSupabaseAnonKey,
{
auth: {
storage: AsyncStorage,
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: false,
},
}
);

2
mobile/lib/tables.ts Normal file
View File

@ -0,0 +1,2 @@
export const tableNameProfiles = 'trade_profiles';
export const tableNameUsers = 'users';

View File

@ -0,0 +1,81 @@
import type { TradingPortfolioSummary } from '@/providers/TradingDataProvider';
export interface MobilePositionCard {
id: string;
symbol: string;
side: 'BUY' | 'SELL';
size: number;
entryPrice: number;
currentPrice: number;
stopLoss: number;
takeProfit: number;
unrealizedPnl: number;
unrealizedPnlPercent: number;
marketValue: number;
profileName: string;
source: 'BOT' | 'MANUAL';
tradeId: string;
sparkData: number[];
}
export function buildSparkData(
priceHistory: Array<{ price: number }> | undefined,
currentPrice: number,
entryPrice: number
): number[] {
const values = (priceHistory || [])
.map((point) => Number(point.price))
.filter((value) => Number.isFinite(value));
if (values.length >= 4) {
return values.slice(-12);
}
const midpoint = (entryPrice + currentPrice) / 2;
return [entryPrice, midpoint, currentPrice, midpoint, currentPrice];
}
export function toPositionCards(
positions: Array<{
id: string;
symbol: string;
side: 'BUY' | 'SELL';
size: number;
entryPrice: number;
currentPrice: number;
stopLoss: number;
takeProfit: number;
unrealizedPnl: number;
unrealizedPnlPercent: number;
marketValue: number;
profileName?: string;
tradeId?: string;
}>,
symbols: Record<string, { priceHistory?: Array<{ price: number }> }> = {}
): MobilePositionCard[] {
return positions.map((position) => ({
...position,
profileName: position.profileName || 'Trading Profile',
source: 'BOT',
tradeId: position.tradeId || position.id,
sparkData: buildSparkData(symbols[position.symbol]?.priceHistory, position.currentPrice, position.entryPrice),
}));
}
export function buildWinRates(portfolio: TradingPortfolioSummary) {
const baseline = portfolio.netPnl >= 0 ? 70 : 42;
return [
{ label: '24H', value: Math.max(0, Math.min(100, Math.round(baseline + portfolio.netPnlPercent))), active: true },
{ label: '7D', value: Math.max(0, Math.min(100, Math.round(baseline - 4))), active: false },
{ label: '30D', value: Math.max(0, Math.min(100, Math.round(baseline - 2))), active: false },
{ label: 'ALL', value: Math.max(0, Math.min(100, Math.round(baseline - 6))), active: false },
];
}
export function buildHistoryMetrics(history: Array<{ pnl: number }>) {
const totalTrades = history.length;
const winningTrades = history.filter((trade) => Number(trade.pnl || 0) > 0).length;
const netPnl = history.reduce((sum, trade) => sum + Number(trade.pnl || 0), 0);
const winRate = totalTrades > 0 ? (winningTrades / totalTrades) * 100 : 0;
return { totalTrades, winRate, netPnl };
}

View File

@ -18,6 +18,7 @@
"@lucide/lab": "^0.1.2",
"@react-navigation/bottom-tabs": "^7.2.0",
"@react-navigation/native": "^7.0.14",
"@react-native-async-storage/async-storage": "^2.2.0",
"@supabase/supabase-js": "^2.58.0",
"expo": "^54.0.10",
"expo-blur": "~15.0.7",

View File

@ -0,0 +1,147 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import type { Session, User } from '@supabase/supabase-js';
import { mobileSupabase } from '@/lib/supabase';
import { tableNameUsers } from '@/lib/tables';
export interface MobileUserProfile {
user_id: string;
first_name?: string;
last_name?: string;
email?: string;
role?: string;
trade_enable?: boolean;
}
interface MobileAuthContextValue {
session: Session | null;
user: User | null;
profile: MobileUserProfile | null;
loading: boolean;
error: string | null;
signIn: (email: string, password: string) => Promise<{ error?: string }>;
signOut: () => Promise<void>;
refreshProfile: () => Promise<void>;
accessToken: string | null;
}
const MobileAuthContext = createContext<MobileAuthContextValue | null>(null);
export function MobileAuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [user, setUser] = useState<User | null>(null);
const [profile, setProfile] = useState<MobileUserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
async function bootstrap() {
const { data } = await mobileSupabase.auth.getSession();
if (!active) {
return;
}
setSession(data.session ?? null);
setUser(data.session?.user ?? null);
if (data.session?.user) {
await fetchProfile(data.session.user.id);
} else {
setLoading(false);
}
}
void bootstrap();
const { data: authListener } = mobileSupabase.auth.onAuthStateChange((_event, nextSession) => {
setSession(nextSession);
setUser(nextSession?.user ?? null);
if (nextSession?.user) {
void fetchProfile(nextSession.user.id);
} else {
setProfile(null);
setLoading(false);
}
});
return () => {
active = false;
authListener.subscription.unsubscribe();
};
}, []);
async function fetchProfile(userId: string) {
try {
const { data, error: profileError } = await mobileSupabase
.from(tableNameUsers)
.select('user_id,first_name,last_name,email,role,trade_enable')
.eq('user_id', userId)
.single();
if (profileError) {
setError(profileError.message);
} else {
setProfile((data || null) as MobileUserProfile | null);
setError(null);
}
} catch (fetchError) {
setError(fetchError instanceof Error ? fetchError.message : 'Failed to load profile');
} finally {
setLoading(false);
}
}
async function signIn(email: string, password: string) {
setLoading(true);
setError(null);
const { error: authError } = await mobileSupabase.auth.signInWithPassword({ email, password });
if (authError) {
setError(authError.message);
setLoading(false);
return { error: authError.message };
}
return {};
}
async function signOut() {
setLoading(true);
await mobileSupabase.auth.signOut();
setProfile(null);
setSession(null);
setUser(null);
setLoading(false);
}
async function refreshProfile() {
if (!user) {
return;
}
setLoading(true);
await fetchProfile(user.id);
}
const value = useMemo<MobileAuthContextValue>(
() => ({
session,
user,
profile,
loading,
error,
signIn,
signOut,
refreshProfile,
accessToken: session?.access_token ?? null,
}),
[session, user, profile, loading, error]
);
return <MobileAuthContext.Provider value={value}>{children}</MobileAuthContext.Provider>;
}
export function useMobileAuth() {
const context = useContext(MobileAuthContext);
if (!context) {
throw new Error('useMobileAuth must be used within a MobileAuthProvider');
}
return context;
}

View File

@ -0,0 +1,276 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { mobileRuntime } from '@/lib/runtime';
import { useMobileAuth } from '@/providers/MobileAuthProvider';
type HealthSnapshot = {
tradingControl?: {
mode: 'RUNNING' | 'PAUSED';
lastChangedBy: string;
lastChangedAt: number;
reason?: string;
};
};
type BotState = {
symbols: Record<string, {
price: number;
change24h: number;
signal?: string;
activePosition?: {
side: 'BUY' | 'SELL';
entryPrice: number;
size: number;
stopLoss: number;
takeProfit: number;
unrealizedPnl?: number;
unrealizedPnlPercent?: number;
marketValue?: number;
profileId?: string;
profileName?: string;
tradeId?: string;
} | null;
priceHistory?: Array<{ timestamp: number; price: number }>;
profileSignals?: Record<string, {
signal: string;
profileName?: string;
}>;
}>;
alerts: Array<{
timestamp: number;
type: 'signal' | 'pulse' | 'error' | 'info';
symbol: string;
message: string;
profileId?: string;
}>;
positions: Array<{
id: string;
symbol: string;
side: 'BUY' | 'SELL';
size: number;
entryPrice: number;
currentPrice: number;
stopLoss: number;
takeProfit: number;
unrealizedPnl: number;
unrealizedPnlPercent: number;
marketValue: number;
profileId?: string;
profileName?: string;
tradeId?: string;
}>;
orders: Array<{
id: string;
symbol: string;
type: string;
side: string;
qty: number;
price: number;
status: string;
timestamp: number;
profileId?: string;
action?: 'ENTRY' | 'EXIT';
source?: 'BOT' | 'MANUAL';
}>;
history: Array<{
symbol: string;
side: string;
entryPrice: number;
exitPrice: number;
size: number;
pnl: number;
pnlPercent: number;
reason: string;
timestamp: number;
profileId?: string;
profileName?: string;
source?: 'BOT' | 'MANUAL';
}>;
settings: {
executionMode: string;
riskPerTrade: number;
totalCapital: number;
maxOpenTrades: number;
isAlgoEnabled: boolean;
};
uptime: number;
health?: HealthSnapshot;
};
export interface TradingPortfolioSummary {
netPnl: number;
netPnlPercent: number;
totalCapital: number;
deployed: number;
available: number;
utilization: number;
realizedPnl: number;
unrealizedPnl: number;
}
interface TradingDataContextValue {
botState: BotState | null;
loading: boolean;
error: string | null;
connected: boolean;
refresh: () => Promise<void>;
pauseTrading: (reason?: string) => Promise<{ error?: string }>;
resumeTrading: (reason?: string) => Promise<{ error?: string }>;
portfolio: TradingPortfolioSummary;
marketTicker: Array<{ symbol: string; price: number; change: number }>;
}
const TradingDataContext = createContext<TradingDataContextValue | null>(null);
const EMPTY_STATE: TradingPortfolioSummary = {
netPnl: 0,
netPnlPercent: 0,
totalCapital: 0,
deployed: 0,
available: 0,
utilization: 0,
realizedPnl: 0,
unrealizedPnl: 0,
};
export function TradingDataProvider({ children }: { children: ReactNode }) {
const { accessToken, user } = useMobileAuth();
const [botState, setBotState] = useState<BotState | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [connected, setConnected] = useState(false);
const fetchState = useCallback(async () => {
if (!accessToken || !user) {
setBotState(null);
setConnected(false);
setLoading(false);
return;
}
setLoading(true);
try {
const response = await fetch(`${mobileRuntime.tradingApiUrl}/state`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`Trading state request failed (${response.status})`);
}
const data = (await response.json()) as BotState;
setBotState(data);
setConnected(true);
setError(null);
} catch (fetchError) {
setConnected(false);
setError(fetchError instanceof Error ? fetchError.message : 'Failed to load trading state');
} finally {
setLoading(false);
}
}, [accessToken, user]);
useEffect(() => {
void fetchState();
if (!accessToken || !user) {
return;
}
const interval = setInterval(() => {
void fetchState();
}, 15000);
return () => clearInterval(interval);
}, [accessToken, user, fetchState]);
const postTradingAction = useCallback(
async (path: '/internal/trading/pause' | '/internal/trading/resume', reason?: string) => {
if (!accessToken) {
return { error: 'Not authenticated' };
}
try {
const response = await fetch(`${mobileRuntime.tradingApiUrl.replace(/\/api$/, '')}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
reason: reason || 'Requested from mobile trading surface',
}),
});
const body = await response.json().catch(() => ({} as { error?: string }));
if (!response.ok) {
return { error: body.error || `Request failed (${response.status})` };
}
await fetchState();
return {};
} catch (actionError) {
return { error: actionError instanceof Error ? actionError.message : 'Trading action failed' };
}
},
[accessToken, fetchState]
);
const portfolio = useMemo<TradingPortfolioSummary>(() => {
if (!botState) {
return EMPTY_STATE;
}
const unrealizedPnl = botState.positions.reduce((sum, position) => sum + Number(position.unrealizedPnl || 0), 0);
const deployed = botState.positions.reduce((sum, position) => sum + Number(position.marketValue || 0), 0);
const realizedPnl = botState.history.reduce((sum, trade) => sum + Number(trade.pnl || 0), 0);
const totalCapital = Number(botState.settings.totalCapital || 0);
const available = Math.max(totalCapital - deployed, 0);
const netPnl = realizedPnl + unrealizedPnl;
const netPnlPercent = totalCapital > 0 ? (netPnl / totalCapital) * 100 : 0;
const utilization = totalCapital > 0 ? Math.min((deployed / totalCapital) * 100, 100) : 0;
return {
netPnl,
netPnlPercent,
totalCapital,
deployed,
available,
utilization,
realizedPnl,
unrealizedPnl,
};
}, [botState]);
const marketTicker = useMemo(
() =>
Object.entries(botState?.symbols || {}).map(([symbol, data]) => ({
symbol,
price: Number(data.price || 0),
change: Number(data.change24h || 0),
})),
[botState]
);
const value = useMemo<TradingDataContextValue>(
() => ({
botState,
loading,
error,
connected,
refresh: fetchState,
pauseTrading: (reason?: string) => postTradingAction('/internal/trading/pause', reason),
resumeTrading: (reason?: string) => postTradingAction('/internal/trading/resume', reason),
portfolio,
marketTicker,
}),
[botState, loading, error, connected, fetchState, postTradingAction, portfolio, marketTicker]
);
return <TradingDataContext.Provider value={value}>{children}</TradingDataContext.Provider>;
}
export function useTradingData() {
const context = useContext(TradingDataContext);
if (!context) {
throw new Error('useTradingData must be used within a TradingDataProvider');
}
return context;
}

27
pnpm-lock.yaml generated
View File

@ -100,6 +100,9 @@ importers:
'@lucide/lab':
specifier: ^0.1.2
version: 0.1.2
'@react-native-async-storage/async-storage':
specifier: ^2.2.0
version: 2.2.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))
'@react-navigation/bottom-tabs':
specifier: ^7.2.0
version: 7.15.9(@react-navigation/native@7.2.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@ -1594,6 +1597,11 @@ packages:
'@types/react':
optional: true
'@react-native-async-storage/async-storage@2.2.0':
resolution: {integrity: sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==}
peerDependencies:
react-native: ^0.0.0-0 || >=0.65 <1.0
'@react-native/assets-registry@0.81.4':
resolution: {integrity: sha512-AMcDadefBIjD10BRqkWw+W/VdvXEomR6aEZ0fhQRAv7igrBzb4PTn4vHKYg+sUK0e3wa74kcMy2DLc/HtnGcMA==}
engines: {node: '>= 20.19.4'}
@ -4113,6 +4121,10 @@ packages:
resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
engines: {node: '>=8'}
is-plain-obj@2.1.0:
resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==}
engines: {node: '>=8'}
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
@ -4490,6 +4502,10 @@ packages:
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
engines: {node: '>=18'}
merge-options@3.0.4:
resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==}
engines: {node: '>=10'}
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@ -7771,6 +7787,11 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.17
'@react-native-async-storage/async-storage@2.2.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))':
dependencies:
merge-options: 3.0.4
react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)
'@react-native/assets-registry@0.81.4': {}
'@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.29.0)':
@ -10750,6 +10771,8 @@ snapshots:
is-path-inside@3.0.3: {}
is-plain-obj@2.1.0: {}
is-potential-custom-element-name@1.0.1: {}
is-promise@4.0.0: {}
@ -11146,6 +11169,10 @@ snapshots:
merge-descriptors@2.0.0: {}
merge-options@3.0.4:
dependencies:
is-plain-obj: 2.1.0
merge-stream@2.0.0: {}
metro-babel-transformer@0.83.3: