From e2008f70b9943a0cbd0d082c7a9a40da25b9d93a Mon Sep 17 00:00:00 2001 From: root Date: Tue, 14 Apr 2026 04:50:51 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20web=20+=20mobile=20pre-beta=20audit=20?= =?UTF-8?q?=E2=80=94=20real=20APIs,=20socket=20routing,=20empty=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web: - runtime.ts: use import.meta.env (process.env is undefined in Vite browser bundle) - tradingApiUrl local fallback: drop /api suffix (API libs already append /api/*) - useWebSocket: deriveSocketParams() โ€” correctly splits origin + socket path for Caddy handle_path /invttrdg/* proxy (io(origin, {path}), not io(url-with-path)) - App.tsx: pass socket prop to AdminTab; pass connected prop to SignalsTab - AdminTab: remove duplicate useWebSocket; accept socket as prop - SignalsTab: connection-aware empty state message - backtest/flags: default to disabled when VITE_BACKTEST_ENABLED unset - EntryForm: NaN guard before live trade execution - MarketplaceTab: null-safety on symbols.rules access - Tests: pass socket prop to AdminTab; update empty state assertion Mobile: - TradingDataProvider: same deriveSocketParams fix โ€” EXPO_PUBLIC_SOCKET_PATH overrides auto-derived path from tradingApiUrl - strategies: replace mock data with real GET /api/profiles + PATCH active toggle - chat: wire to real POST /api/chat; remove hardcoded mock reply - marketplace: fetch GET /api/marketplace-presets; USE STRATEGY calls POST /api/profiles - settings: sign-out confirmation dialog; execution mode read-only hint; version from expo-constants instead of hardcoded v2.3 - positions/history: empty state UI when no data - CustomTabBar: always show tab labels (not only when focused) Co-Authored-By: Claude Sonnet 4.6 --- mobile/app/(tabs)/history.tsx | 26 +- mobile/app/(tabs)/positions.tsx | 32 ++- mobile/app/(tabs)/settings.tsx | 17 +- mobile/app/(tabs)/strategies.tsx | 234 ++++++++++-------- mobile/app/chat.tsx | 103 ++++++-- mobile/app/marketplace.tsx | 223 +++++++++++++---- mobile/components/CustomTabBar.tsx | 8 +- mobile/providers/TradingDataProvider.tsx | 18 +- web/src/App.tsx | 6 +- web/src/backtest/flags.ts | 6 +- web/src/components/EntryForm.tsx | 4 + .../components/ProductAccessibilityGate.tsx | 5 +- web/src/hooks/useWebSocket.ts | 27 +- web/src/lib/runtime.ts | 9 +- web/src/tabs/AdminTab.dom.test.tsx | 4 +- web/src/tabs/AdminTab.tsx | 8 +- web/src/tabs/MarketplaceTab.tsx | 4 +- web/src/tabs/SignalsTab.tsx | 9 +- web/src/tabs/TabSuite.test.ts | 7 +- 19 files changed, 532 insertions(+), 218 deletions(-) diff --git a/mobile/app/(tabs)/history.tsx b/mobile/app/(tabs)/history.tsx index 8b0ee82..75f699e 100644 --- a/mobile/app/(tabs)/history.tsx +++ b/mobile/app/(tabs)/history.tsx @@ -67,7 +67,12 @@ export default function HistoryScreen() { ))} - {timeFilteredTrades.map((trade, index) => ( + {timeFilteredTrades.length === 0 ? ( + + No trades found + Closed trades will appear here once the bot exits a position. + + ) : timeFilteredTrades.map((trade, index) => ( ))} @@ -299,4 +304,23 @@ const styles = StyleSheet.create({ color: Colors.text.secondary, marginLeft: 'auto', }, + emptyState: { + alignItems: 'center' as const, + justifyContent: 'center' as const, + paddingVertical: 60, + gap: 10, + }, + emptyText: { + fontFamily: Fonts.inter.bold, + fontSize: FontSize.subheading, + color: Colors.text.secondary, + }, + emptyHint: { + fontFamily: Fonts.inter.medium, + fontSize: FontSize.body, + color: Colors.text.muted, + textAlign: 'center' as const, + maxWidth: 260, + lineHeight: 20, + }, }); diff --git a/mobile/app/(tabs)/positions.tsx b/mobile/app/(tabs)/positions.tsx index eab90b6..aa307f7 100644 --- a/mobile/app/(tabs)/positions.tsx +++ b/mobile/app/(tabs)/positions.tsx @@ -156,10 +156,15 @@ export default function PositionsScreen() { contentContainerStyle={styles.listContent} showsVerticalScrollIndicator={false} > - {activeTab === 0 - ? positions.map((pos, i) => ) - : orders.map((ord, i) => ) - } + {activeTab === 0 ? ( + positions.length > 0 + ? positions.map((pos, i) => ) + : No open positionsActive positions will appear here once the bot enters a trade. + ) : ( + orders.length > 0 + ? orders.map((ord, i) => ) + : No pending ordersOrders placed by the bot or manually will appear here. + )} ); @@ -295,4 +300,23 @@ const styles = StyleSheet.create({ fontSize: FontSize.micro, color: Colors.text.secondary, }, + emptyState: { + alignItems: 'center' as const, + justifyContent: 'center' as const, + paddingVertical: 60, + gap: 10, + }, + emptyText: { + fontFamily: Fonts.inter.bold, + fontSize: FontSize.subheading, + color: Colors.text.secondary, + }, + emptyHint: { + fontFamily: Fonts.inter.medium, + fontSize: FontSize.body, + color: Colors.text.muted, + textAlign: 'center' as const, + maxWidth: 260, + lineHeight: 20, + }, }); diff --git a/mobile/app/(tabs)/settings.tsx b/mobile/app/(tabs)/settings.tsx index 337be01..c24e9c8 100644 --- a/mobile/app/(tabs)/settings.tsx +++ b/mobile/app/(tabs)/settings.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { Alert, View, Text, ScrollView, Switch, StyleSheet } from 'react-native'; +import Constants from 'expo-constants'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { LinearGradient } from 'expo-linear-gradient'; import { ChevronRight, Lock, Check } from 'lucide-react-native'; @@ -84,6 +85,7 @@ export default function SettingsScreen() { activeColor={modeColors[executionModeIndex]} activeTextColor={executionModeIndex === 2 ? '#fff' : '#000'} /> + Execution mode is managed from the web dashboard. {executionModeIndex === 2 && ( @@ -248,9 +250,14 @@ export default function SettingsScreen() { ABOUT Version - v2.3 + v{Constants.expoConfig?.version ?? '1.0.0'} - void signOut()}> + { + Alert.alert('Sign Out', 'Are you sure you want to sign out?', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Sign Out', style: 'destructive', onPress: () => void signOut() }, + ]); + }}> Sign Out @@ -381,6 +388,12 @@ const styles = StyleSheet.create({ color: Colors.accent.green, letterSpacing: 1, }, + readOnlyHint: { + fontFamily: Fonts.mono.regular, + fontSize: FontSize.micro, + color: Colors.text.muted, + marginTop: 4, + }, warningBanner: { backgroundColor: 'rgba(230,126,34,0.1)', borderWidth: 1, diff --git a/mobile/app/(tabs)/strategies.tsx b/mobile/app/(tabs)/strategies.tsx index cb6ada8..8f836c6 100644 --- a/mobile/app/(tabs)/strategies.tsx +++ b/mobile/app/(tabs)/strategies.tsx @@ -1,14 +1,30 @@ -import React, { useState } from 'react'; -import { View, Text, ScrollView, Switch, StyleSheet } from 'react-native'; +import React, { useState, useEffect, useCallback } from 'react'; +import { View, Text, ScrollView, Switch, StyleSheet, ActivityIndicator } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; import { LinearGradient } from 'expo-linear-gradient'; import { Colors, Fonts, FontSize, BorderRadius, Shadows, Spacing } from '@/constants/theme'; -import { strategies } from '@/constants/mockData'; import { formatCurrency } from '@/utils/format'; import AnimatedCard from '@/components/AnimatedCard'; import PillBadge from '@/components/PillBadge'; import PressableScale from '@/components/PressableScale'; +import { useMobileAuth } from '@/providers/MobileAuthProvider'; +import { mobileRuntime } from '@/lib/runtime'; +import { createRequestId } from '../../../shared/request-id.js'; + +interface TradeProfile { + id: string; + name: string; + is_active: boolean; + allocated_capital: number; + risk_per_trade_percent: number; + symbols: string; + strategy_config?: { + riskStyle?: string; + execution?: { minRulePassRatio?: number }; + riskLimits?: { dailyProfitTargetUsd?: number }; + }; +} const RISK_COLORS: Record = { aggressive: { color: Colors.accent.orange, label: 'Aggressive', icon: '\u{1F525}' }, @@ -16,10 +32,29 @@ const RISK_COLORS: Record= 0; +function StrategyCard({ profile, index, onToggle }: { + profile: TradeProfile; + index: number; + onToggle: (id: string, isActive: boolean) => Promise; +}) { + const [isActive, setIsActive] = useState(profile.is_active); + const [toggling, setToggling] = useState(false); + const riskStyle = profile.strategy_config?.riskStyle || 'balanced'; + const risk = RISK_COLORS[riskStyle] || RISK_COLORS.balanced; + const dailyTarget = profile.strategy_config?.riskLimits?.dailyProfitTargetUsd ?? 0; + const symbolList = profile.symbols ? profile.symbols.split(',').map(s => s.trim()) : []; + + const handleToggle = async (newValue: boolean) => { + setIsActive(newValue); + setToggling(true); + try { + await onToggle(profile.id, newValue); + } catch { + setIsActive(!newValue); + } finally { + setToggling(false); + } + }; return ( @@ -32,10 +67,11 @@ function StrategyCard({ strategy, index }: { strategy: typeof strategies[0]; ind - {strategy.name} + {profile.name} @@ -47,15 +83,8 @@ function StrategyCard({ strategy, index }: { strategy: typeof strategies[0]; ind bgColor={`${risk.color}20`} /> - - - - - - - - {strategy.symbols.map(s => ( + {symbolList.map(s => ( {s} @@ -64,49 +93,62 @@ function StrategyCard({ strategy, index }: { strategy: typeof strategies[0]; ind - ${strategy.allocatedCapital.toLocaleString()} allocated + {formatCurrency(profile.allocated_capital)} allocated ยท {profile.risk_per_trade_percent}% risk/trade - - - - - Daily Target - - ${strategy.dailyProgress} / ${strategy.dailyTarget} - - - - - + {dailyTarget > 0 && ( + Daily target: {formatCurrency(dailyTarget)} + )} ); } -function StatCell({ label, value, color, mono }: { label: string; value: string; color?: string; mono?: boolean }) { - return ( - - {label} - - {value} - - - ); -} - export default function StrategiesScreen() { const insets = useSafeAreaInsets(); const router = useRouter(); + const { accessToken } = useMobileAuth(); + const [profiles, setProfiles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchProfiles = useCallback(async () => { + if (!accessToken) return; + try { + const res = await fetch(`${mobileRuntime.tradingApiUrl}/profiles`, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'x-request-id': createRequestId('mobile-strategies'), + }, + }); + if (!res.ok) throw new Error(`Failed to load profiles (${res.status})`); + const body = await res.json(); + setProfiles(Array.isArray(body.profiles) ? body.profiles : []); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load strategies'); + } finally { + setLoading(false); + } + }, [accessToken]); + + useEffect(() => { + void fetchProfiles(); + }, [fetchProfiles]); + + const handleToggle = async (id: string, isActive: boolean) => { + if (!accessToken) return; + const res = await fetch(`${mobileRuntime.tradingApiUrl}/profiles/${id}/active`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + 'x-request-id': createRequestId('mobile-toggle'), + }, + body: JSON.stringify({ is_active: isActive }), + }); + if (!res.ok) throw new Error(`Toggle failed (${res.status})`); + }; return ( @@ -120,9 +162,23 @@ export default function StrategiesScreen() { contentContainerStyle={styles.content} showsVerticalScrollIndicator={false} > - {strategies.map((s, i) => ( - - ))} + {loading ? ( + + ) : error ? ( + + Failed to load strategies + {error} + + ) : profiles.length === 0 ? ( + + No strategies yet + Create a strategy from the marketplace or web dashboard. + + ) : ( + profiles.map((p, i) => ( + + )) + )} (chatMessages); + const { accessToken } = useMobileAuth(); + const [messages, setMessages] = useState([]); const [inputText, setInputText] = useState(''); + const [sending, setSending] = useState(false); const scrollRef = useRef(null); - const sendMessage = (text: string) => { - if (!text.trim()) return; - const newMsg: ChatMessage = { - id: `user-${Date.now()}`, - role: 'user', - text: text.trim(), - }; - setMessages(prev => [...prev, newMsg]); + const sendMessage = async (text: string) => { + if (!text.trim() || sending) return; + const userMsg: ChatMessage = { id: `user-${Date.now()}`, role: 'user', text: text.trim() }; + setMessages(prev => [...prev, userMsg]); setInputText(''); + setSending(true); - setTimeout(() => { - const botReply: ChatMessage = { - id: `bot-${Date.now()}`, + try { + const res = await fetch(`${mobileRuntime.tradingApiUrl}/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + 'x-request-id': createRequestId('mobile-chat'), + }, + body: JSON.stringify({ message: text.trim(), context: [] }), + }); + + if (!res.ok) throw new Error(`Chat request failed (${res.status})`); + const body = await res.json(); + const reply = body.summary || body.message || body.response || 'No response from assistant.'; + + const botMsg: ChatMessage = { id: `bot-${Date.now()}`, role: 'bot', text: reply }; + setMessages(prev => [...prev, botMsg]); + } catch (e) { + const errMsg: ChatMessage = { + id: `bot-err-${Date.now()}`, role: 'bot', - text: "I'm analyzing your request. Based on current market conditions and your portfolio allocation, I'd recommend reviewing your SOL/USDT position which is showing strong momentum.", + text: `Error: ${e instanceof Error ? e.message : 'Failed to reach assistant'}`, }; - setMessages(prev => [...prev, botReply]); - }, 1000); + setMessages(prev => [...prev, errMsg]); + } finally { + setSending(false); + } }; return ( @@ -113,17 +140,25 @@ export default function ChatScreen() { showsVerticalScrollIndicator={false} onContentSizeChange={() => scrollRef.current?.scrollToEnd({ animated: true })} > + {messages.length === 0 && ( + + + Bytelyst AI + Ask about your strategies, market conditions, or get trade recommendations. + + )} + {messages.map((msg) => ( ))} - {messages.length <= chatMessages.length && ( + {messages.length === 0 && ( {chatSuggestions.map((s) => ( sendMessage(s)} + onPress={() => void sendMessage(s)} > {s} @@ -140,21 +175,22 @@ export default function ChatScreen() { placeholderTextColor={Colors.text.ultraDim} value={inputText} onChangeText={setInputText} - onSubmitEditing={() => sendMessage(inputText)} + onSubmitEditing={() => void sendMessage(inputText)} selectionColor={Colors.accent.green} + editable={!sending} /> sendMessage(inputText)} + style={[styles.sendBtnWrapper, sending && styles.sendBtnDisabled]} + onPress={() => void sendMessage(inputText)} > - + @@ -220,6 +256,24 @@ const styles = StyleSheet.create({ gap: 14, paddingBottom: 20, }, + welcomeMsg: { + alignItems: 'center', + gap: 12, + paddingVertical: 32, + paddingHorizontal: 24, + }, + welcomeTitle: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.subheading, + color: Colors.text.primary, + }, + welcomeText: { + fontFamily: Fonts.inter.medium, + fontSize: FontSize.body, + color: Colors.text.secondary, + textAlign: 'center', + lineHeight: 22, + }, msgRow: { flexDirection: 'row', gap: 10, @@ -313,6 +367,9 @@ const styles = StyleSheet.create({ sendBtnWrapper: { borderRadius: 10, }, + sendBtnDisabled: { + opacity: 0.5, + }, sendBtn: { width: 36, height: 36, diff --git a/mobile/app/marketplace.tsx b/mobile/app/marketplace.tsx index 3cd4d90..d09dbe7 100644 --- a/mobile/app/marketplace.tsx +++ b/mobile/app/marketplace.tsx @@ -1,24 +1,102 @@ -import React from 'react'; -import { View, Text, ScrollView, Pressable, StyleSheet } from 'react-native'; +import React, { useState, useEffect, useCallback } from 'react'; +import { View, Text, ScrollView, Pressable, StyleSheet, ActivityIndicator, Alert } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; import { LinearGradient } from 'expo-linear-gradient'; import { ArrowLeft } from 'lucide-react-native'; import { Colors, Fonts, FontSize, BorderRadius, Shadows, Spacing } from '@/constants/theme'; -import { marketplacePresets } from '@/constants/mockData'; import AnimatedCard from '@/components/AnimatedCard'; import PillBadge from '@/components/PillBadge'; import PressableScale from '@/components/PressableScale'; +import { useMobileAuth } from '@/providers/MobileAuthProvider'; +import { mobileRuntime } from '@/lib/runtime'; +import { createRequestId } from '../../../shared/request-id.js'; + +interface MarketplacePreset { + id: string; + name: string; + description: string; + risk_style_id: string; + recommended_assets: string[]; + typical_trades_per_day: string; + performance_tag: string; + is_popular: boolean; + strategy_config?: Record; +} const RISK_COLORS: Record = { aggressive: { color: Colors.accent.orange, label: 'Aggressive' }, balanced: { color: Colors.accent.green, label: 'Balanced' }, safe: { color: Colors.accent.blue, label: 'Conservative' }, + scalping: { color: Colors.accent.purple, label: 'Scalping' }, + swing: { color: Colors.accent.amber, label: 'Swing' }, }; export default function MarketplaceScreen() { const insets = useSafeAreaInsets(); const router = useRouter(); + const { accessToken, user } = useMobileAuth(); + const [presets, setPresets] = useState([]); + const [loading, setLoading] = useState(true); + const [applyingId, setApplyingId] = useState(null); + + const fetchPresets = useCallback(async () => { + if (!accessToken) return; + try { + const res = await fetch(`${mobileRuntime.tradingApiUrl}/marketplace-presets`, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'x-request-id': createRequestId('mobile-marketplace'), + }, + }); + if (!res.ok) throw new Error(`Failed to load presets (${res.status})`); + const body = await res.json(); + setPresets(Array.isArray(body.presets) ? body.presets : []); + } catch (e) { + Alert.alert('Error', e instanceof Error ? e.message : 'Failed to load marketplace'); + } finally { + setLoading(false); + } + }, [accessToken]); + + useEffect(() => { + void fetchPresets(); + }, [fetchPresets]); + + const handleUseStrategy = async (preset: MarketplacePreset) => { + if (!accessToken || !user?.id) return; + setApplyingId(preset.id); + try { + const res = await fetch(`${mobileRuntime.tradingApiUrl}/profiles`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + 'x-request-id': createRequestId('mobile-use-strategy'), + }, + body: JSON.stringify({ + name: `${preset.name} (Mobile)`, + user_id: user.id, + allocated_capital: 1000, + risk_per_trade_percent: 1, + symbols: (preset.recommended_assets || []).join(','), + is_active: false, + strategy_config: preset.strategy_config || {}, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error((body as { error?: string }).error || `Failed to create profile (${res.status})`); + } + Alert.alert('Strategy Added', `"${preset.name}" has been added to your strategies. You can activate it from the Strategies tab.`, [ + { text: 'OK', onPress: () => router.back() }, + ]); + } catch (e) { + Alert.alert('Error', e instanceof Error ? e.message : 'Failed to apply strategy'); + } finally { + setApplyingId(null); + } + }; return ( @@ -34,62 +112,75 @@ export default function MarketplaceScreen() { - - {marketplacePresets.map((preset, index) => { - const risk = RISK_COLORS[preset.risk]; - return ( - - - - {preset.isPopular && ( - - Popular - - )} - - {preset.name} - {preset.description} - - - - {preset.trades} - - - - {preset.assets.map(a => ( - - {a} + {loading ? ( + + ) : ( + + {presets.length === 0 ? ( + + No strategies available + Check back later or create a custom strategy from the web dashboard. + + ) : presets.map((preset, index) => { + const risk = RISK_COLORS[preset.risk_style_id] || RISK_COLORS.balanced; + const isApplying = applyingId === preset.id; + return ( + + + + {preset.is_popular && ( + + Popular - ))} - + )} - - {preset.tag} - + {preset.name} + {preset.description} - - + + {preset.typical_trades_per_day} + + + + {(preset.recommended_assets || []).map(a => ( + + {a} + + ))} + + + + {preset.performance_tag} + + + void handleUseStrategy(preset)} > - USE STRATEGY - - - - - - ); - })} - + + + {isApplying ? 'ADDING...' : 'USE STRATEGY'} + + + + + + + ); + })} + + )} ); } @@ -214,6 +305,9 @@ const styles = StyleSheet.create({ shadowRadius: 36, elevation: 8, }, + ctaDisabled: { + opacity: 0.6, + }, ctaButton: { height: 56, borderRadius: 18, @@ -226,4 +320,23 @@ const styles = StyleSheet.create({ color: '#000', letterSpacing: 2, }, + emptyState: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 60, + gap: 10, + }, + emptyText: { + fontFamily: Fonts.inter.bold, + fontSize: FontSize.subheading, + color: Colors.text.secondary, + }, + emptyHint: { + fontFamily: Fonts.inter.medium, + fontSize: FontSize.body, + color: Colors.text.muted, + textAlign: 'center', + maxWidth: 260, + lineHeight: 20, + }, }); diff --git a/mobile/components/CustomTabBar.tsx b/mobile/components/CustomTabBar.tsx index 50c6af1..7fbeeb6 100644 --- a/mobile/components/CustomTabBar.tsx +++ b/mobile/components/CustomTabBar.tsx @@ -51,9 +51,7 @@ function TabItem({ /> {isFocused && } - {isFocused && ( - {label} - )} + {label} ); } @@ -128,4 +126,8 @@ const styles = StyleSheet.create({ letterSpacing: 1, marginTop: 6, }, + inactiveLabel: { + color: Colors.text.secondary, + fontFamily: Fonts.inter.medium, + }, }); diff --git a/mobile/providers/TradingDataProvider.tsx b/mobile/providers/TradingDataProvider.tsx index 08d9931..415cdeb 100644 --- a/mobile/providers/TradingDataProvider.tsx +++ b/mobile/providers/TradingDataProvider.tsx @@ -127,7 +127,21 @@ interface TradingDataContextValue { } const TradingDataContext = createContext(null); -const tradingSocketUrl = mobileRuntime.tradingApiUrl.replace(/\/api$/, ''); + +function deriveSocketParams(tradingApiUrl: string): { socketOrigin: string; socketPath: string } { + const envPath = process.env.EXPO_PUBLIC_SOCKET_PATH?.trim(); + try { + const parsed = new URL(tradingApiUrl); + const prefix = parsed.pathname.replace(/\/api\/?$/, ''); + const socketPath = envPath || (prefix && prefix !== '/' ? `${prefix}/socket.io` : '/socket.io'); + return { socketOrigin: parsed.origin, socketPath }; + } catch { + return { socketOrigin: tradingApiUrl.replace(/\/api$/, ''), socketPath: envPath || '/socket.io' }; + } +} + +const { socketOrigin: tradingSocketOrigin, socketPath: tradingSocketPath } = + deriveSocketParams(mobileRuntime.tradingApiUrl); const EMPTY_STATE: TradingPortfolioSummary = { netPnl: 0, @@ -226,7 +240,7 @@ export function TradingDataProvider({ children }: { children: ReactNode }) { })); }; - socket = io(tradingSocketUrl, buildTradingSocketOptions(accessToken)); + socket = io(tradingSocketOrigin, buildTradingSocketOptions(accessToken, tradingSocketPath)); socket.on('connect', () => { setConnected(true); diff --git a/web/src/App.tsx b/web/src/App.tsx index 445edc2..3406e10 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -57,7 +57,7 @@ export const buildChatApplyPayload = ( function App() { const { user, profile, loading, signOut } = useAuth(); - const { botState, connected } = useWebSocket(tradingRuntime.tradingApiUrl); + const { socket, botState, connected } = useWebSocket(tradingRuntime.tradingApiUrl); const [activeTab, setActiveTab] = useState('overview'); const [wizardSeed, setWizardSeed] = useState(null); const [chatProfiles, setChatProfiles] = useState([]); @@ -177,7 +177,7 @@ function App() { case 'overview': return ; case 'signals': if (!isAdmin) return ; - return ; + return ; case 'entries': if (!isAdmin) return ; return ; @@ -207,7 +207,7 @@ function App() { case 'settings': return ; case 'admin': if (!isAdmin) return ; - return ; + return ; default: return ; } }; diff --git a/web/src/backtest/flags.ts b/web/src/backtest/flags.ts index 839fefd..b6f7421 100644 --- a/web/src/backtest/flags.ts +++ b/web/src/backtest/flags.ts @@ -29,10 +29,10 @@ const toBoolean = (value: unknown, fallback: boolean = false): boolean => { export const isBacktestBuildEnabled = (): boolean => { const raw = import.meta.env.VITE_BACKTEST_ENABLED; if (raw === undefined || String(raw).trim() === '') { - // Default to enabled unless explicitly disabled at build-time. - return true; + // Default to disabled โ€” backtest is not yet production-ready. + return false; } - return toBoolean(raw, true); + return toBoolean(raw, false); }; export const clearBacktestRuntimeFlagCache = (): void => { diff --git a/web/src/components/EntryForm.tsx b/web/src/components/EntryForm.tsx index 1a4a404..1f2e250 100644 --- a/web/src/components/EntryForm.tsx +++ b/web/src/components/EntryForm.tsx @@ -108,6 +108,10 @@ export function EntryForm({ onSuccess, initialData }: EntryFormProps) { // --- ๐Ÿš€ REAL TRADE EXECUTION --- if (formData.execute_order && !initialData) { + if (!Number.isFinite(payload.quantity) || !payload.symbol.trim()) { + alert('Symbol and a valid quantity are required to execute a trade.'); + return; + } // Determine side (Buy if buying, Sell if selling - simplistic for now assuming Entry = Buy) // If closing, we handle differently (via close button usually), but here handle Entry. diff --git a/web/src/components/ProductAccessibilityGate.tsx b/web/src/components/ProductAccessibilityGate.tsx index 099058f..8db63f7 100644 --- a/web/src/components/ProductAccessibilityGate.tsx +++ b/web/src/components/ProductAccessibilityGate.tsx @@ -34,7 +34,10 @@ export function ProductAccessibilityGate({ children }: { children: ReactNode }) setState({ status: 'available' }); } catch (error) { - console.warn('[ProductAccessibilityGate] Failed to evaluate kill switch.', error); + // Fail open โ€” kill switch service being down should not block users. + // The kill switch is a safety net, not an auth gate; degraded availability + // is preferable to a hard block when the check service is unreachable. + console.warn('[ProductAccessibilityGate] Kill switch check failed, defaulting to available.', error); if (active) { setState({ status: 'available' }); } diff --git a/web/src/hooks/useWebSocket.ts b/web/src/hooks/useWebSocket.ts index fa0ea19..7aa97cb 100644 --- a/web/src/hooks/useWebSocket.ts +++ b/web/src/hooks/useWebSocket.ts @@ -264,6 +264,26 @@ export const replaceSettingsUpdate = (prev: BotState, settings: BotState['settin settings }); +/** + * Derive the Socket.IO server origin and path from a trading API URL. + * - Production: https://api.bytelyst.com/invttrdg/api + * โ†’ origin: https://api.bytelyst.com, path: /invttrdg/socket.io + * - Local dev: http://localhost:4018/api + * โ†’ origin: http://localhost:4018, path: /socket.io (default) + * VITE_SOCKET_PATH overrides the derived path when explicitly set. + */ +function deriveSocketParams(tradingApiUrl: string): { socketOrigin: string; socketPath: string } { + const envPath = (import.meta.env.VITE_SOCKET_PATH as string | undefined)?.trim(); + try { + const parsed = new URL(tradingApiUrl); + const prefix = parsed.pathname.replace(/\/api\/?$/, ''); // e.g. '/invttrdg' + const socketPath = envPath || (prefix && prefix !== '/' ? `${prefix}/socket.io` : '/socket.io'); + return { socketOrigin: parsed.origin, socketPath }; + } catch { + return { socketOrigin: tradingApiUrl.replace(/\/api$/, ''), socketPath: envPath || '/socket.io' }; + } +} + export const useWebSocket = (url: string) => { const [socket, setSocket] = useState(null); const [botState, setBotState] = useState(DEFAULT_BOT_STATE); @@ -275,7 +295,8 @@ export const useWebSocket = (url: string) => { let newSocket: Socket | null = null; const connectSocket = async () => { - console.log('๐Ÿ”Œ Attempting to connect to:', url); + const { socketOrigin, socketPath } = deriveSocketParams(url); + console.log('๐Ÿ”Œ Attempting to connect to:', socketOrigin, 'path:', socketPath); const token = await getPlatformAccessToken().catch(() => null); if (!token) { @@ -284,9 +305,9 @@ export const useWebSocket = (url: string) => { return; } - const socketOptions = buildTradingSocketOptions(token, import.meta.env.VITE_SOCKET_PATH); + const socketOptions = buildTradingSocketOptions(token, socketPath); - newSocket = io(url, socketOptions); + newSocket = io(socketOrigin, socketOptions); newSocket.on('connect', () => { console.log('โœ… Connected to bot'); diff --git a/web/src/lib/runtime.ts b/web/src/lib/runtime.ts index 0ad4482..8171698 100644 --- a/web/src/lib/runtime.ts +++ b/web/src/lib/runtime.ts @@ -1,6 +1,11 @@ -import { getRuntimeEnvironment } from '../../../shared/runtime.js'; import { createTradingKillSwitchClient, createTradingWebTelemetry } from '../../../shared/platform-web.js'; -export const tradingRuntime = getRuntimeEnvironment('web'); +// Read Vite env vars via import.meta.env (not process.env โ€” Vite does not inject those in the browser bundle) +export const tradingRuntime = { + productId: (import.meta.env.VITE_PRODUCT_ID as string) || 'invttrdg', + platformApiUrl: (import.meta.env.VITE_PLATFORM_URL as string) || 'http://localhost:4003/api', + tradingApiUrl: (import.meta.env.VITE_TRADING_API_URL as string) || 'http://localhost:4018', +}; + export const tradingKillSwitchClient = createTradingKillSwitchClient('web'); export const tradingTelemetry = createTradingWebTelemetry(); diff --git a/web/src/tabs/AdminTab.dom.test.tsx b/web/src/tabs/AdminTab.dom.test.tsx index ce6f451..a53326f 100644 --- a/web/src/tabs/AdminTab.dom.test.tsx +++ b/web/src/tabs/AdminTab.dom.test.tsx @@ -45,7 +45,7 @@ describe('AdminTab coverage', () => { it('denies access to non-admins', () => { authState.profile.role = 'user'; - render(); + render(); expect(screen.getByText(/Access Denied/i)).toBeInTheDocument(); }); @@ -57,7 +57,7 @@ describe('AdminTab coverage', () => { if (event === 'debug_log') logHandler = rb; }); - render(); + render(); // Switch to Debug tab to see logs const debugTabBtn = screen.getByRole('button', { name: /Debug/i }); diff --git a/web/src/tabs/AdminTab.tsx b/web/src/tabs/AdminTab.tsx index f8c1a41..c5ab87c 100644 --- a/web/src/tabs/AdminTab.tsx +++ b/web/src/tabs/AdminTab.tsx @@ -11,15 +11,16 @@ import { Database, RefreshCcw, Heart, Info, XCircle } from 'lucide-react'; import type { BotState } from '../hooks/useWebSocket'; -import { useWebSocket } from '../hooks/useWebSocket'; +import type { Socket } from 'socket.io-client'; import { useAuth } from '../components/AuthContext'; -import { tradingRuntime } from '../lib/runtime'; import { fetchDynamicConfigItems, upsertDynamicConfigItems } from '../lib/dynamicConfigApi'; import { getPlatformAccessToken } from '../lib/authSession'; import { createRequestId } from '../../../shared/request-id.js'; +import { tradingRuntime } from '../lib/runtime'; interface AdminTabProps { botState: BotState; + socket: Socket | null; } const ruleDescriptions: { [key: string]: { desc: string; category: string; icon: typeof Hexagon } } = { @@ -41,9 +42,8 @@ const categoryColors: { [key: string]: string } = { 'AI': '#10b981', }; -export const AdminTab = ({ botState }: AdminTabProps) => { +export const AdminTab = ({ botState, socket }: AdminTabProps) => { const { profile } = useAuth(); - const { socket } = useWebSocket(tradingRuntime.tradingApiUrl); const [subTab, setSubTab] = React.useState<'rules' | 'config' | 'debug' | 'health' | 'reconciliation'>('rules'); const [debugLogs, setDebugLogs] = React.useState([]); const logEndRef = React.useRef(null); diff --git a/web/src/tabs/MarketplaceTab.tsx b/web/src/tabs/MarketplaceTab.tsx index 49cd36b..c5ec9bb 100644 --- a/web/src/tabs/MarketplaceTab.tsx +++ b/web/src/tabs/MarketplaceTab.tsx @@ -13,8 +13,8 @@ export const MarketplaceTab: React.FC = ({ onClone, botStat const symbols = Object.keys(botState.symbols); const aiSetups = symbols - .filter(s => botState.symbols[s].rules['AIAnalysisRule']?.metadata?.confidence !== undefined) - .sort((a, b) => (botState.symbols[b].rules['AIAnalysisRule']?.metadata?.confidence || 0) - (botState.symbols[a].rules['AIAnalysisRule']?.metadata?.confidence || 0)) + .filter(s => botState.symbols[s]?.rules?.['AIAnalysisRule']?.metadata?.confidence !== undefined) + .sort((a, b) => (botState.symbols[b]?.rules?.['AIAnalysisRule']?.metadata?.confidence || 0) - (botState.symbols[a]?.rules?.['AIAnalysisRule']?.metadata?.confidence || 0)) .slice(0, 5); const topVolatile = [...symbols] diff --git a/web/src/tabs/SignalsTab.tsx b/web/src/tabs/SignalsTab.tsx index 4fcf3f9..9b9ffd9 100644 --- a/web/src/tabs/SignalsTab.tsx +++ b/web/src/tabs/SignalsTab.tsx @@ -3,11 +3,16 @@ import type { BotState } from '../hooks/useWebSocket'; interface SignalsTabProps { botState: BotState; + connected?: boolean; } -export const SignalsTab = ({ botState }: SignalsTabProps) => { +export const SignalsTab = ({ botState, connected }: SignalsTabProps) => { const symbols = Object.keys(botState.symbols); + const emptyMessage = connected === false + ? 'Connecting to botโ€ฆ signals will appear once the real-time feed is established.' + : 'No symbols available. Add symbols to your trading profiles to see live signals here.'; + return (
@@ -21,7 +26,7 @@ export const SignalsTab = ({ botState }: SignalsTabProps) => { )) ) : ( -
No symbols configured. Check your .env file.
+
{emptyMessage}
)}
diff --git a/web/src/tabs/TabSuite.test.ts b/web/src/tabs/TabSuite.test.ts index 41e0cce..bdcbaed 100644 --- a/web/src/tabs/TabSuite.test.ts +++ b/web/src/tabs/TabSuite.test.ts @@ -96,7 +96,7 @@ describe('dashboard tabs smoke coverage', () => { const emptyHtml = renderToStaticMarkup( React.createElement(SignalsTab, { botState: emptyState }) ); - expect(emptyHtml).toContain('No symbols configured'); + expect(emptyHtml).toContain('No symbols available'); }); it('renders SettingsTab user config and bot status blocks', () => { @@ -129,7 +129,8 @@ describe('dashboard tabs smoke coverage', () => { const adminHtml = renderToStaticMarkup( React.createElement(AdminTab, { - botState: adminState + botState: adminState, + socket: null }) ); @@ -148,4 +149,4 @@ describe('dashboard tabs smoke coverage', () => { ); expect(html).toContain('animate-spin'); }); -}); +});