learning_ai_invt_trdg/mobile/app/(tabs)/strategies.tsx
root e2008f70b9 fix: web + mobile pre-beta audit — real APIs, socket routing, empty states
Web:
- runtime.ts: use import.meta.env (process.env is undefined in Vite browser bundle)
- tradingApiUrl local fallback: drop /api suffix (API libs already append /api/*)
- useWebSocket: deriveSocketParams() — correctly splits origin + socket path for
  Caddy handle_path /invttrdg/* proxy (io(origin, {path}), not io(url-with-path))
- App.tsx: pass socket prop to AdminTab; pass connected prop to SignalsTab
- AdminTab: remove duplicate useWebSocket; accept socket as prop
- SignalsTab: connection-aware empty state message
- backtest/flags: default to disabled when VITE_BACKTEST_ENABLED unset
- EntryForm: NaN guard before live trade execution
- MarketplaceTab: null-safety on symbols.rules access
- Tests: pass socket prop to AdminTab; update empty state assertion

Mobile:
- TradingDataProvider: same deriveSocketParams fix — EXPO_PUBLIC_SOCKET_PATH
  overrides auto-derived path from tradingApiUrl
- strategies: replace mock data with real GET /api/profiles + PATCH active toggle
- chat: wire to real POST /api/chat; remove hardcoded mock reply
- marketplace: fetch GET /api/marketplace-presets; USE STRATEGY calls POST /api/profiles
- settings: sign-out confirmation dialog; execution mode read-only hint;
  version from expo-constants instead of hardcoded v2.3
- positions/history: empty state UI when no data
- CustomTabBar: always show tab labels (not only when focused)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 04:50:51 +00:00

326 lines
9.5 KiB
TypeScript

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 { 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<string, { color: string; label: string; icon: string }> = {
aggressive: { color: Colors.accent.orange, label: 'Aggressive', icon: '\u{1F525}' },
balanced: { color: Colors.accent.green, label: 'Balanced', icon: '\u{2696}\u{FE0F}' },
safe: { color: Colors.accent.blue, label: 'Conservative', icon: '\u{1F6E1}\u{FE0F}' },
};
function StrategyCard({ profile, index, onToggle }: {
profile: TradeProfile;
index: number;
onToggle: (id: string, isActive: boolean) => Promise<void>;
}) {
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 (
<AnimatedCard index={index} style={[Shadows.card, { borderRadius: BorderRadius.large }]}>
<LinearGradient
colors={['rgba(20,21,26,0.9)', 'rgba(14,15,18,0.95)']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.card}
>
<View style={[styles.accentLine, { backgroundColor: risk.color }]} />
<View style={styles.cardHeader}>
<Text style={styles.stratName}>{profile.name}</Text>
<Switch
value={isActive}
onValueChange={handleToggle}
disabled={toggling}
trackColor={{ false: Colors.background.elevated, true: 'rgba(0,255,136,0.3)' }}
thumbColor={isActive ? Colors.accent.green : '#666'}
/>
</View>
<PillBadge
label={`${risk.icon} ${risk.label}`}
color={risk.color}
bgColor={`${risk.color}20`}
/>
<View style={styles.assetPills}>
{symbolList.map(s => (
<View key={s} style={styles.assetPill}>
<Text style={styles.assetText}>{s}</Text>
</View>
))}
</View>
<View style={styles.capitalRow}>
<Text style={styles.capitalLabel}>
{formatCurrency(profile.allocated_capital)} allocated · {profile.risk_per_trade_percent}% risk/trade
</Text>
{dailyTarget > 0 && (
<Text style={styles.capitalLabel}>Daily target: {formatCurrency(dailyTarget)}</Text>
)}
</View>
</LinearGradient>
</AnimatedCard>
);
}
export default function StrategiesScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const { accessToken } = useMobileAuth();
const [profiles, setProfiles] = useState<TradeProfile[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<View style={[styles.container, { paddingTop: insets.top }]}>
<View style={styles.headerSection}>
<Text style={styles.sectionLabel}>STRATEGIES</Text>
<Text style={styles.pageTitle}>My Strategies</Text>
</View>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{loading ? (
<ActivityIndicator color={Colors.accent.green} style={{ marginTop: 40 }} />
) : error ? (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>Failed to load strategies</Text>
<Text style={styles.emptyHint}>{error}</Text>
</View>
) : profiles.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>No strategies yet</Text>
<Text style={styles.emptyHint}>Create a strategy from the marketplace or web dashboard.</Text>
</View>
) : (
profiles.map((p, i) => (
<StrategyCard key={p.id} profile={p} index={i} onToggle={handleToggle} />
))
)}
<PressableScale
haptic="medium"
onPress={() => router.push('/marketplace')}
style={styles.ctaWrapper}
>
<LinearGradient
colors={['#00ff88', '#00cc6a']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.ctaButton}
>
<Text style={styles.ctaText}>EXPLORE MARKETPLACE</Text>
</LinearGradient>
</PressableScale>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.background.primary,
},
headerSection: {
padding: Spacing.screenPadding,
paddingBottom: 0,
},
sectionLabel: {
fontFamily: Fonts.inter.black,
fontSize: FontSize.micro,
color: Colors.accent.green,
letterSpacing: 4,
marginBottom: 8,
},
pageTitle: {
fontFamily: Fonts.inter.black,
fontSize: FontSize.hero,
color: Colors.text.primary,
letterSpacing: -0.5,
marginBottom: 16,
},
scroll: {
flex: 1,
},
content: {
padding: Spacing.screenPadding,
gap: 16,
paddingBottom: 120,
},
card: {
borderRadius: BorderRadius.large,
padding: Spacing.cardPaddingLarge,
borderWidth: 1,
borderColor: Colors.border.default,
overflow: 'hidden',
gap: 12,
},
accentLine: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 2,
opacity: 0.6,
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
stratName: {
fontFamily: Fonts.inter.black,
fontSize: 18,
color: Colors.text.primary,
flex: 1,
marginRight: 12,
},
assetPills: {
flexDirection: 'row',
gap: 6,
flexWrap: 'wrap',
},
assetPill: {
backgroundColor: 'rgba(255,255,255,0.05)',
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: BorderRadius.xs,
},
assetText: {
fontFamily: Fonts.mono.medium,
fontSize: FontSize.micro,
color: Colors.text.secondary,
},
capitalRow: {
gap: 2,
},
capitalLabel: {
fontFamily: Fonts.mono.medium,
fontSize: FontSize.bodySmall,
color: Colors.text.secondary,
},
ctaWrapper: {
marginTop: 8,
borderRadius: 18,
shadowColor: 'rgba(0,255,136,0.4)',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 1,
shadowRadius: 36,
elevation: 8,
},
ctaButton: {
height: 56,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
ctaText: {
fontFamily: Fonts.inter.black,
fontSize: FontSize.bodySmall,
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,
},
});