From 137580c9fa0fc985e0df21f6436891b9fb2c25b2 Mon Sep 17 00:00:00 2001 From: Thibaud Moustier Date: Mon, 23 Feb 2026 17:20:44 +0100 Subject: [PATCH] Mobile: dashboard modularisation + setting + socket client integration (client only) --- mobile-ui/package-lock.json | 89 +++++- mobile-ui/package.json | 3 +- mobile-ui/src/components/ActionsCard.tsx | 65 ++-- mobile-ui/src/components/WalletCard.tsx | 68 ++-- mobile-ui/src/config/env.ts | 1 + mobile-ui/src/models/UserSettings.ts | 1 + mobile-ui/src/screens/DashboardScreen.tsx | 370 ++++++++-------------- mobile-ui/src/services/socketService.ts | 68 ++++ mobile-ui/src/types/Alert.ts | 9 + 9 files changed, 345 insertions(+), 329 deletions(-) create mode 100644 mobile-ui/src/config/env.ts create mode 100644 mobile-ui/src/services/socketService.ts create mode 100644 mobile-ui/src/types/Alert.ts diff --git a/mobile-ui/package-lock.json b/mobile-ui/package-lock.json index 29d5991..c917a21 100644 --- a/mobile-ui/package-lock.json +++ b/mobile-ui/package-lock.json @@ -16,7 +16,8 @@ "react": "19.1.0", "react-native": "0.81.5", "react-native-safe-area-context": "~5.6.0", - "react-native-screens": "~4.16.0" + "react-native-screens": "~4.16.0", + "socket.io-client": "^4.8.3" }, "devDependencies": { "@types/react": "~19.1.0", @@ -3096,6 +3097,12 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4367,6 +4374,49 @@ "node": ">= 0.8" } }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/env-editor": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", @@ -7471,6 +7521,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8166,6 +8217,34 @@ "node": ">=8.0.0" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -9057,6 +9136,14 @@ "node": ">=8.0" } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/mobile-ui/package.json b/mobile-ui/package.json index e7af7ea..b229db2 100644 --- a/mobile-ui/package.json +++ b/mobile-ui/package.json @@ -17,7 +17,8 @@ "react": "19.1.0", "react-native": "0.81.5", "react-native-safe-area-context": "~5.6.0", - "react-native-screens": "~4.16.0" + "react-native-screens": "~4.16.0", + "socket.io-client": "^4.8.3" }, "devDependencies": { "@types/react": "~19.1.0", diff --git a/mobile-ui/src/components/ActionsCard.tsx b/mobile-ui/src/components/ActionsCard.tsx index f8fe47a..eadcfee 100644 --- a/mobile-ui/src/components/ActionsCard.tsx +++ b/mobile-ui/src/components/ActionsCard.tsx @@ -1,60 +1,37 @@ -import { View, Text, StyleSheet, TouchableOpacity } from "react-native"; +import { View, Text, TouchableOpacity } from "react-native"; +import { ui } from "./ui/uiStyles"; type Props = { onGoSettings: () => void; }; +/** + * ActionsCard + * ----------- + * Regroupe les actions rapides de l'utilisateur. + * Pour l'instant : + * - Voir stratégie (placeholder) + * - Historique (placeholder) + * - Paramètres (navigation réelle via callback) + */ export default function ActionsCard({ onGoSettings }: Props) { return ( - - Actions + + Actions - - - Voir stratégie + + {}}> + Voir stratégie - - Historique + {}}> + Historique - - Paramètres + + Paramètres ); -} - -const styles = StyleSheet.create({ - card: { - borderWidth: 1, - borderColor: "#aaa", - padding: 12, - marginBottom: 12, - borderRadius: 6, - }, - sectionTitle: { - fontWeight: "bold", - marginBottom: 6, - fontSize: 16, - }, - row: { - flexDirection: "row", - flexWrap: "wrap", - gap: 8, - }, - button: { - backgroundColor: "#555", - paddingHorizontal: 10, - paddingVertical: 10, - borderRadius: 4, - flexGrow: 1, - flexBasis: "48%", - alignItems: "center", - }, - buttonText: { - color: "#fff", - fontWeight: "bold", - }, -}); \ No newline at end of file +} \ No newline at end of file diff --git a/mobile-ui/src/components/WalletCard.tsx b/mobile-ui/src/components/WalletCard.tsx index 4fbcb88..52e5162 100644 --- a/mobile-ui/src/components/WalletCard.tsx +++ b/mobile-ui/src/components/WalletCard.tsx @@ -1,55 +1,39 @@ -import { View, Text, StyleSheet } from "react-native"; +import { View, Text } from "react-native"; import type { UserSettings } from "../models/UserSettings"; +import { ui } from "./ui/uiStyles"; type Props = { settings: UserSettings; }; +/** + * WalletCard + * ---------- + * Step 1 : placeholder portefeuille. + * Pour l'instant on affiche une valeur totale fixe (mock), + * mais on utilise déjà la devise des settings. + * + * Plus tard (Step 3) : + * - portefeuille multi-cryptos + * - valeur globale calculée + */ export default function WalletCard({ settings }: Props) { + const totalValue = 10_000; // mock (à remplacer plus tard) + return ( - - Portefeuille + + Portefeuille - - Valeur totale - 10 000 {settings.currency} + + Valeur totale + + {totalValue.toLocaleString("fr-BE")} {settings.currency} + - Step 1 : mono-utilisateur / mono-crypto + + Step 1 : mono-utilisateur / mono-crypto + ); -} - -const styles = StyleSheet.create({ - card: { - borderWidth: 1, - borderColor: "#aaa", - padding: 12, - marginBottom: 12, - borderRadius: 6, - }, - sectionTitle: { - fontWeight: "bold", - marginBottom: 6, - fontSize: 16, - }, - priceRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - marginVertical: 4, - }, - value: { - fontSize: 16, - }, - valueBold: { - fontSize: 16, - fontWeight: "bold", - }, - updated: { - fontSize: 12, - opacity: 0.6, - marginTop: 6, - textAlign: "center", - }, -}); \ No newline at end of file +} \ No newline at end of file diff --git a/mobile-ui/src/config/env.ts b/mobile-ui/src/config/env.ts new file mode 100644 index 0000000..799b6a2 --- /dev/null +++ b/mobile-ui/src/config/env.ts @@ -0,0 +1 @@ +export const SERVER_URL = "http://192.168.129.121:3000"; \ No newline at end of file diff --git a/mobile-ui/src/models/UserSettings.ts b/mobile-ui/src/models/UserSettings.ts index c8e552e..fd54d3d 100644 --- a/mobile-ui/src/models/UserSettings.ts +++ b/mobile-ui/src/models/UserSettings.ts @@ -10,6 +10,7 @@ export type RefreshMode = | "auto"; export interface UserSettings { + userId?: string; // ✅ pour Socket.IO (optionnel, Step 4 sera l'auth) currency: Currency; favoriteSymbol: string; alertPreference: AlertPreference; diff --git a/mobile-ui/src/screens/DashboardScreen.tsx b/mobile-ui/src/screens/DashboardScreen.tsx index 5fe177b..168536f 100644 --- a/mobile-ui/src/screens/DashboardScreen.tsx +++ b/mobile-ui/src/screens/DashboardScreen.tsx @@ -1,53 +1,58 @@ -import { - View, - Text, - StyleSheet, - ScrollView, - TouchableOpacity, -} from "react-native"; +import { View, Text, StyleSheet, ScrollView } from "react-native"; import { useState, useCallback, useEffect } from "react"; import { SafeAreaView } from "react-native-safe-area-context"; import { useFocusEffect, useNavigation } from "@react-navigation/native"; -import type { - DashboardSummary, - TradeDecision, - AlertLevel, -} from "../types/DashboardSummary"; +import type { DashboardSummary } from "../types/DashboardSummary"; import { fetchDashboardSummary } from "../services/dashboardService"; import { loadSettings } from "../utils/settingsStorage"; import type { UserSettings } from "../models/UserSettings"; +import MarketCard from "../components/MarketCard"; +import StrategyCard from "../components/StrategyCard"; +import WalletCard from "../components/WalletCard"; +import ActionsCard from "../components/ActionsCard"; +import { ui } from "../components/ui/uiStyles"; + +// ✅ Socket.IO +import { socketService } from "../services/socketService"; +import { SERVER_URL } from "../config/env"; +import type { Alert } from "../types/Alert"; + /** * DashboardScreen - * -------------- - * Écran principal mobile (Step 1). - * Rôle : afficher des données (consommateur API) : - * - Marché (prix) - * - Signal (BUY/SELL/HOLD/STOP_LOSS) + niveau d'alerte (CRITICAL/WARNING/INFO) - * - Portefeuille (placeholder Step 1) - * - Actions rapides (dont accès aux Paramètres) + * ---------------- + * Écran principal mobile. + * + * Responsabilités : + * - Charger les données (mock/API REST) + * - Charger les paramètres utilisateur + * - Gérer le refresh automatique (settings.refreshMode) + * - Gérer la navigation + * - Recevoir les alertes temps réel via Socket.IO (non bloquant) * - * Spécificité : - * - On recharge les données quand l'écran reprend le focus (retour depuis Settings). - * - Si refreshMode === "auto", on rafraîchit le dashboard périodiquement. + * L'affichage "business" est délégué aux composants (Cards). */ export default function DashboardScreen() { - // Données du dashboard (mock pour l'instant) const [summary, setSummary] = useState(null); - - // Paramètres utilisateur (AsyncStorage) const [settings, setSettings] = useState(null); - // États UI (loading / erreur) const [loading, setLoading] = useState(true); + + // erreurs REST/refresh const [error, setError] = useState(null); - // Navigation (Stack) + // état Socket.IO (non bloquant) + const [socketConnected, setSocketConnected] = useState(false); + const [socketError, setSocketError] = useState(null); + + // alertes reçues (optionnel mais utile) + const [liveAlerts, setLiveAlerts] = useState([]); + const navigation = useNavigation(); /** - * Charge dashboard + settings à chaque focus sur l'écran. + * Chargement initial + rechargement à chaque focus * (utile quand on revient des Paramètres) */ useFocusEffect( @@ -68,7 +73,7 @@ export default function DashboardScreen() { setSummary(dashboardData); setSettings(userSettings); } - } catch (e) { + } catch { if (isActive) { setError("Impossible de charger le dashboard."); } @@ -88,15 +93,10 @@ export default function DashboardScreen() { ); /** - * Rafraîchissement automatique si l'utilisateur l'a activé. - * On ne relance pas le loading global (pour éviter que l'écran "clignote"), - * on met juste à jour le summary. + * Refresh automatique si activé */ useEffect(() => { - // Pas de settings => impossible de savoir le mode if (!settings) return; - - // Mode manuel => pas d'interval if (settings.refreshMode !== "auto") return; let cancelled = false; @@ -106,11 +106,9 @@ export default function DashboardScreen() { const data = await fetchDashboardSummary(); if (!cancelled) setSummary(data); } catch { - // On évite de spammer l'utilisateur : on peut juste logguer - // ou afficher une erreur discrète. if (!cancelled) setError("Erreur lors du rafraîchissement automatique."); } - }, 5000); // toutes les 5 secondes + }, 5000); return () => { cancelled = true; @@ -118,157 +116,107 @@ export default function DashboardScreen() { }; }, [settings]); - // UI : Chargement + /** + * Socket.IO : connexion / écoute des alertes + * - Non bloquant : si socket KO, l'app continue via REST + * - Se déclenche quand on a settings (souvent nécessaire pour userId) + * + * IMPORTANT : + * 👉 Version propre : on n'utilise PAS @ts-expect-error. + * 👉 On lit un userId optionnel (si présent), sinon on désactive le socket + * et on affiche un message clair. + */ + useEffect(() => { + if (!settings) return; + + setSocketError(null); + + // ✅ Proposition propre : un champ optionnel dans UserSettings : userId?: string + // Si pas dispo => on ne connecte pas (pas de fake "user-123" en prod) + const userId = settings.userId; + + if (!userId) { + setSocketConnected(false); + setSocketError("Socket désactivé : userId absent (pas connecté)."); + return; + } + + try { + socketService.connect(SERVER_URL, userId); + setSocketConnected(true); + } catch { + setSocketConnected(false); + setSocketError("Socket: impossible de se connecter (URL/userId)."); + return; + } + + const unsubscribeAlert = socketService.onAlert((alert) => { + setLiveAlerts((prev) => [alert, ...prev].slice(0, 50)); + }); + + // ping optionnel pour debug + socketService.ping(); + + return () => { + unsubscribeAlert(); + socketService.disconnect(); + setSocketConnected(false); + }; + }, [settings]); + + // Chargement if (loading) { return ( - + Chargement du dashboard… ); } - // UI : Erreur (erreur "bloquante") + // Erreur bloquante if (error && (!summary || !settings)) { return ( - + {error} ); } - // Fallback sécurité + // Sécurité if (!summary || !settings) { return ( - + Initialisation… ); } - /** - * Helpers d'affichage (couleurs) pour respecter le contrat mobile - */ - const getDecisionColor = (decision: TradeDecision) => { - switch (decision) { - case "BUY": - return "#16a34a"; - case "SELL": - return "#dc2626"; - case "STOP_LOSS": - return "#991b1b"; - case "HOLD": - default: - return "#ca8a04"; - } - }; - - const getAlertColor = (level: AlertLevel) => { - switch (level) { - case "CRITICAL": - return "#b91c1c"; - case "WARNING": - return "#ca8a04"; - case "INFO": - default: - return "#2563eb"; - } - }; - return ( - {/* Petit warning non bloquant si l'auto-refresh a eu un souci */} + {/* Warning REST/refresh (non bloquant) */} {error && ( - + {error} )} - {/* Carte Marché */} - - Marché - - {summary.pair} - - - Prix actuel - - {summary.price.toFixed(2)} {settings.currency} - - - - - Dernière mise à jour : {new Date(summary.timestamp).toLocaleString()} - - - - {/* Carte Stratégie + Signal */} - - Stratégie - - {summary.strategy} - - - {summary.decision} + {/* Socket status (non bloquant) */} + + + Socket.IO : {socketConnected ? "connecté ✅" : "déconnecté ⚠️"} - - {summary.alertLevel} - + {!!socketError && {socketError}} - - Confiance - - {(summary.confidence * 100).toFixed(1)} % - - - - {summary.reason} + Alertes reçues : {liveAlerts.length} - {/* Carte Portefeuille (placeholder Step 1) */} - - Portefeuille + + + - - Valeur totale - 10 000 {settings.currency} - - - Step 1 : mono-utilisateur / mono-crypto - - - {/* Carte Actions */} - - Actions - - - - Voir stratégie - - - - Historique - - - navigation.navigate("Settings" as never)} - > - Paramètres - - - + navigation.navigate("Settings" as never)} /> ); @@ -279,111 +227,51 @@ export default function DashboardScreen() { const styles = StyleSheet.create({ safeArea: { flex: 1, - backgroundColor: "#fff", + backgroundColor: ui.screen.backgroundColor, }, container: { padding: 16, }, - card: { - borderWidth: 1, - borderColor: "#aaa", - padding: 12, - marginBottom: 12, - borderRadius: 6, - }, - - sectionTitle: { - fontWeight: "bold", - marginBottom: 6, - fontSize: 16, - }, - - price: { - fontSize: 26, - fontWeight: "bold", - textAlign: "center", - marginVertical: 6, - }, - - decision: { - fontSize: 32, - fontWeight: "bold", - textAlign: "center", - marginVertical: 10, - }, - - alertLevel: { - textAlign: "center", - fontWeight: "bold", - marginBottom: 6, - }, - - row: { - flexDirection: "row", - flexWrap: "wrap", - gap: 8, - }, - - priceRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - marginVertical: 4, - }, - - value: { - fontSize: 16, - }, - - valueBold: { - fontSize: 16, - fontWeight: "bold", - }, - - reason: { - marginTop: 6, - fontStyle: "italic", - }, - - button: { - backgroundColor: "#555", - paddingHorizontal: 10, - paddingVertical: 10, - borderRadius: 4, - flexGrow: 1, - flexBasis: "48%", + centered: { + flex: 1, + justifyContent: "center", alignItems: "center", - }, - - buttonText: { - color: "#fff", - fontWeight: "bold", - }, - - updated: { - fontSize: 12, - opacity: 0.6, - marginTop: 6, - textAlign: "center", + backgroundColor: ui.screen.backgroundColor, }, errorText: { color: "#dc2626", - fontWeight: "bold", + fontWeight: "800", }, - warningBanner: { + // Bannières en style "card" comme les mockups + warningCard: { + ...ui.card, borderWidth: 1, borderColor: "#ca8a04", - padding: 10, - borderRadius: 6, - marginBottom: 12, }, warningText: { color: "#ca8a04", - fontWeight: "bold", + fontWeight: "800", + }, + + socketCard: { + ...ui.card, + borderWidth: 1, + borderColor: "#cbd5e1", + }, + + socketTitle: { + fontWeight: "800", + color: "#0f172a", + marginBottom: 6, + }, + + socketMuted: { + ...ui.muted, + marginTop: 2, }, }); \ No newline at end of file diff --git a/mobile-ui/src/services/socketService.ts b/mobile-ui/src/services/socketService.ts new file mode 100644 index 0000000..27bf531 --- /dev/null +++ b/mobile-ui/src/services/socketService.ts @@ -0,0 +1,68 @@ +import { io, Socket } from "socket.io-client"; +import type { Alert } from "../types/Alert"; + +class SocketService { + private socket: Socket | null = null; + private listeners = new Set<(alert: Alert) => void>(); + + connect(serverUrl: string, userId: string) { + if (!serverUrl) throw new Error("serverUrl is required"); + if (!userId) throw new Error("userId is required"); + + // évite d'ouvrir plusieurs connexions si connect() est appelé plusieurs fois + if (this.socket?.connected) return; + + this.socket = io(serverUrl, { + transports: ["websocket", "polling"], // plus fiable sur mobile + reconnection: true, + reconnectionAttempts: 5, + timeout: 10000, + }); + + this.socket.on("connect", () => { + console.log("✅ Socket connecté:", this.socket?.id); + // tuto de ton camarade : auth via userId + this.socket?.emit("auth", userId); + }); + + this.socket.on("auth_success", (data: any) => { + console.log("✅ Auth success:", data?.message ?? data); + }); + + this.socket.on("alert", (alert: Alert) => { + for (const cb of this.listeners) cb(alert); + }); + + this.socket.on("connect_error", (err) => { + console.log("❌ Socket connect_error:", err?.message ?? err); + }); + + this.socket.on("disconnect", (reason) => { + console.log("⚠️ Socket disconnect:", reason); + }); + } + + onAlert(callback: (alert: Alert) => void) { + this.listeners.add(callback); + return () => this.listeners.delete(callback); + } + + ping() { + this.socket?.emit("ping_alerts"); + } + + onPong(callback: (data: any) => void) { + this.socket?.on("pong_alerts", callback); + return () => this.socket?.off("pong_alerts", callback); + } + + disconnect() { + if (!this.socket) return; + this.socket.removeAllListeners(); + this.socket.disconnect(); + this.socket = null; + this.listeners.clear(); + } +} + +export const socketService = new SocketService(); \ No newline at end of file diff --git a/mobile-ui/src/types/Alert.ts b/mobile-ui/src/types/Alert.ts new file mode 100644 index 0000000..a59d4b4 --- /dev/null +++ b/mobile-ui/src/types/Alert.ts @@ -0,0 +1,9 @@ +export interface Alert { + action: "BUY" | "SELL" | "HOLD"; + pair: string; // ex: "BTC/EUR" + confidence: number; // 0..1 + reason: string; + alertLevel: "INFO" | "WARNING" | "CRITICAL"; + timestamp: number; // Unix ms + price?: number; +} \ No newline at end of file -- 2.50.1