diff --git a/.env.example b/.env.example index 8370369..f9611e0 100644 --- a/.env.example +++ b/.env.example @@ -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= - diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 43ca4d8..b43a0a0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/mobile/app/(tabs)/history.tsx b/mobile/app/(tabs)/history.tsx index cdf3f7b..8b0ee82 100644 --- a/mobile/app/(tabs)/history.tsx +++ b/mobile/app/(tabs)/history.tsx @@ -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 ( @@ -33,15 +45,15 @@ export default function HistoryScreen() { showsVerticalScrollIndicator={false} > - - + + = 0 ? '+' : '-'}$${Math.abs(historyMetrics.netPnl).toFixed(2)}`} color={historyMetrics.netPnl >= 0 ? Colors.accent.green : Colors.accent.red} mono /> @@ -55,8 +67,8 @@ export default function HistoryScreen() { ))} - {filteredTrades.map((trade, index) => ( - + {timeFilteredTrades.map((trade, index) => ( + ))} @@ -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 }) {formatPrice(trade.entryPrice)} → {formatPrice(trade.exitPrice)} - {trade.size} {trade.sizeUnit} + {trade.size} {trade.symbol.split('/')[0]} - {trade.timestamp} + {new Date(trade.timestamp).toLocaleString()} diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index ade1681..0cc6a53 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -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 ( @@ -27,7 +29,7 @@ export default function DashboardScreen() { showsVerticalScrollIndicator={false} refreshControl={ [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 ( {order.symbol} - + @@ -94,9 +110,9 @@ function OrderCard({ order, index }: { order: typeof orders[0]; index: number }) {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 ( diff --git a/mobile/app/(tabs)/settings.tsx b/mobile/app/(tabs)/settings.tsx index 206254d..b89a836 100644 --- a/mobile/app/(tabs)/settings.tsx +++ b/mobile/app/(tabs)/settings.tsx @@ -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} > - SK + + {`${profile?.first_name?.[0] || profile?.email?.[0] || 'T'}${profile?.last_name?.[0] || ''}`.slice(0, 2).toUpperCase()} + - Saravana Kumar - saravana@bytelyst.ai + {[profile?.first_name, profile?.last_name].filter(Boolean).join(' ') || 'Trading User'} + {profile?.email || 'No email loaded'} - ELITE + {(profile?.role || 'member').toUpperCase()} @@ -61,12 +68,12 @@ export default function SettingsScreen() { EXECUTION MODE undefined} + activeColor={modeColors[executionModeIndex]} + activeTextColor={executionModeIndex === 2 ? '#fff' : '#000'} /> - {executionMode === 2 && ( + {executionModeIndex === 2 && ( Real money trading enabled. Use caution. @@ -82,9 +89,9 @@ export default function SettingsScreen() { - + - {riskPercent.toFixed(1)}% + {(Number(botState?.settings.riskPerTrade || 0) * 100).toFixed(2)}% @@ -105,7 +112,7 @@ export default function SettingsScreen() { - $25,000 + ${portfolio.totalCapital.toLocaleString()} @@ -120,11 +127,46 @@ export default function SettingsScreen() { Connected - API Key: ••••••••k3xR + {tradingMode === 'PAUSED' ? 'Trading is currently paused.' : 'Broker state is sourced from the live backend.'} - + {isAdmin ? ( + + + TRADING CONTROL + + Mobile is monitor-first. Only limited safety controls are exposed here. + + + { + const result = await pauseTrading('Paused from mobile control surface'); + if (result.error) { + Alert.alert('Pause failed', result.error); + } + }} + > + Pause Trading + + { + const result = await resumeTrading('Resumed from mobile control surface'); + if (result.error) { + Alert.alert('Resume failed', result.error); + } + }} + > + Resume Trading + + + + + ) : null} + + NOTIFICATIONS - + APPEARANCE @@ -173,13 +215,17 @@ export default function SettingsScreen() { - + ABOUT Version v2.3 + void signOut()}> + Sign Out + + Terms of Service @@ -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, + }, }); diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 07f1f9b..4578262 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -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 ( - - - - - - - + + + + + + + + + + + + + ); } diff --git a/mobile/components/auth/AuthGate.tsx b/mobile/components/auth/AuthGate.tsx new file mode 100644 index 0000000..7d639cc --- /dev/null +++ b/mobile/components/auth/AuthGate.tsx @@ -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 ( + + + Restoring trading session... + + ); + } + + if (user) { + return <>{children}; + } + + return ( + + + BYTElyst TRADING + Sign in to your trading workspace + + Mobile uses the same Supabase-backed identity boundary as the current trading backend. + + + + {errorMessage ? {errorMessage} : null} + { + setSubmitting(true); + await signIn(email.trim(), password); + setSubmitting(false); + }} + > + {submitting ? 'Signing in...' : 'Sign In'} + + + + ); +} + +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, + }, +}); diff --git a/mobile/components/dashboard/ActiveAlerts.tsx b/mobile/components/dashboard/ActiveAlerts.tsx index 3833682..ed83137 100644 --- a/mobile/components/dashboard/ActiveAlerts.tsx +++ b/mobile/components/dashboard/ActiveAlerts.tsx @@ -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 = { signal: Colors.accent.green, @@ -19,6 +21,13 @@ const ALERT_ICONS: Record = { }; 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 ( @@ -35,7 +44,7 @@ export default function ActiveAlerts() { {ALERT_ICONS[alert.type]} {alert.symbol} - {alert.timestamp} + {alert.timestampLabel} {alert.message} @@ -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, diff --git a/mobile/components/dashboard/MarketTicker.tsx b/mobile/components/dashboard/MarketTicker.tsx index 1689e34..ceb04dd 100644 --- a/mobile/components/dashboard/MarketTicker.tsx +++ b/mobile/components/dashboard/MarketTicker.tsx @@ -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(() => { diff --git a/mobile/components/dashboard/PortfolioHeroCard.tsx b/mobile/components/dashboard/PortfolioHeroCard.tsx index 57127f3..4388771 100644 --- a/mobile/components/dashboard/PortfolioHeroCard.tsx +++ b/mobile/components/dashboard/PortfolioHeroCard.tsx @@ -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 ( PORTFOLIO OVERVIEW @@ -74,9 +70,9 @@ export default function PortfolioHeroCard() { - - - + + + @@ -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 }]} /> - {portfolioData.utilization}% utilized + {portfolio.utilization.toFixed(1)}% utilized Realized P&L - +${portfolioData.realizedPnl.toLocaleString('en-US', { minimumFractionDigits: 2 })} + {portfolio.realizedPnl >= 0 ? '+' : '-'}${Math.abs(portfolio.realizedPnl).toLocaleString('en-US', { minimumFractionDigits: 2 })} Unrealized P&L - +${portfolioData.unrealizedPnl.toLocaleString('en-US', { minimumFractionDigits: 2 })} + {portfolio.unrealizedPnl >= 0 ? '+' : '-'}${Math.abs(portfolio.unrealizedPnl).toLocaleString('en-US', { minimumFractionDigits: 2 })} diff --git a/mobile/components/dashboard/QuickPositions.tsx b/mobile/components/dashboard/QuickPositions.tsx index 90098fd..251361d 100644 --- a/mobile/components/dashboard/QuickPositions.tsx +++ b/mobile/components/dashboard/QuickPositions.tsx @@ -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 ( diff --git a/mobile/components/dashboard/StatusBanner.tsx b/mobile/components/dashboard/StatusBanner.tsx index 88b8289..56f36d8 100644 --- a/mobile/components/dashboard/StatusBanner.tsx +++ b/mobile/components/dashboard/StatusBanner.tsx @@ -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 ( - - - RUNNING + + {!isPaused ? : null} + + {connected ? controlMode : 'OFFLINE'} + - PAPER + {executionMode.toUpperCase()} - 4d 12h 33m + {uptimeHours}h {uptimeMinutes}m ); } @@ -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, diff --git a/mobile/components/dashboard/WinRateStrip.tsx b/mobile/components/dashboard/WinRateStrip.tsx index 1062bde..05e35a2 100644 --- a/mobile/components/dashboard/WinRateStrip.tsx +++ b/mobile/components/dashboard/WinRateStrip.tsx @@ -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 ( | 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 }> = {} +): 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 }; +} diff --git a/mobile/package.json b/mobile/package.json index 417fdc9..935327e 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -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", diff --git a/mobile/providers/MobileAuthProvider.tsx b/mobile/providers/MobileAuthProvider.tsx new file mode 100644 index 0000000..35e0b12 --- /dev/null +++ b/mobile/providers/MobileAuthProvider.tsx @@ -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; + refreshProfile: () => Promise; + accessToken: string | null; +} + +const MobileAuthContext = createContext(null); + +export function MobileAuthProvider({ children }: { children: ReactNode }) { + const [session, setSession] = useState(null); + const [user, setUser] = useState(null); + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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( + () => ({ + session, + user, + profile, + loading, + error, + signIn, + signOut, + refreshProfile, + accessToken: session?.access_token ?? null, + }), + [session, user, profile, loading, error] + ); + + return {children}; +} + +export function useMobileAuth() { + const context = useContext(MobileAuthContext); + if (!context) { + throw new Error('useMobileAuth must be used within a MobileAuthProvider'); + } + return context; +} diff --git a/mobile/providers/TradingDataProvider.tsx b/mobile/providers/TradingDataProvider.tsx new file mode 100644 index 0000000..0ba28e7 --- /dev/null +++ b/mobile/providers/TradingDataProvider.tsx @@ -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; + profileSignals?: Record; + }>; + 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; + 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(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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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(() => { + 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( + () => ({ + 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 {children}; +} + +export function useTradingData() { + const context = useContext(TradingDataContext); + if (!context) { + throw new Error('useTradingData must be used within a TradingDataProvider'); + } + return context; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b9ae61..c4144f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: