From 2d6e1a080971ab66e6554329abfe29113b41d7f9 Mon Sep 17 00:00:00 2001 From: desartstudio95 Date: Wed, 10 Jun 2026 14:58:02 -0700 Subject: [PATCH] feat(deriv): integrate Deriv API websocket connectivity Add Deriv API websocket client, route handling, and Firestore security rules to support automated trading integration. --- firestore.rules | 9 +- package-lock.json | 20 +- package.json | 4 +- server.ts | 5 + src/components/AITradingDashboard.tsx | 397 ++++++++++++++++++++++++ src/components/AutoTradingView.tsx | 70 ++++- src/components/DerivDiagnosticPanel.tsx | 106 +++++++ src/components/DerivTestPanel.tsx | 272 ++++++++++++++++ src/controllers/deriv.controller.ts | 314 +++++++++++++++++++ src/routes/deriv.routes.ts | 15 + src/services/aiTradingEngine.ts | 76 +++++ src/services/deriv/derivAccount.ts | 16 + src/services/deriv/derivAuth.ts | 17 + src/services/deriv/derivMarket.ts | 40 +++ src/services/deriv/derivTrading.ts | 60 ++++ src/services/deriv/derivWs.ts | 127 ++++++++ src/services/deriv/index.ts | 34 ++ src/services/deriv/types.ts | 14 + test-tick.ts | 2 + 19 files changed, 1588 insertions(+), 10 deletions(-) create mode 100644 src/components/AITradingDashboard.tsx create mode 100644 src/components/DerivDiagnosticPanel.tsx create mode 100644 src/components/DerivTestPanel.tsx create mode 100644 src/controllers/deriv.controller.ts create mode 100644 src/routes/deriv.routes.ts create mode 100644 src/services/aiTradingEngine.ts create mode 100644 src/services/deriv/derivAccount.ts create mode 100644 src/services/deriv/derivAuth.ts create mode 100644 src/services/deriv/derivMarket.ts create mode 100644 src/services/deriv/derivTrading.ts create mode 100644 src/services/deriv/derivWs.ts create mode 100644 src/services/deriv/index.ts create mode 100644 src/services/deriv/types.ts create mode 100644 test-tick.ts diff --git a/firestore.rules b/firestore.rules index db0f904..df6dc2c 100644 --- a/firestore.rules +++ b/firestore.rules @@ -129,8 +129,13 @@ service cloud.firestore { } // Auto Trades Collection - match /users/{userId}/auto_trades/{tradeId} { - allow read, write: if true; + match /users/{userId}/auto_trades/{document=**} { + allow read, write: if isOwner(userId) || isDbAdmin(); + } + + // Auto Trades Results Collection + match /users/{userId}/auto_trades_results/{document=**} { + allow read, write: if isOwner(userId) || isDbAdmin(); } // Global Auto Trades Collection diff --git a/package-lock.json b/package-lock.json index 8a22c18..8b64d42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,12 +36,14 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", - "vite": "^6.2.0" + "vite": "^6.2.0", + "ws": "^8.21.0" }, "devDependencies": { "@firebase/rules-unit-testing": "^3.0.4", "@types/express": "^4.17.21", "@types/node": "^22.14.0", + "@types/ws": "^8.18.1", "autoprefixer": "^10.4.21", "esbuild": "^0.28.0", "eslint": "^10.2.1", @@ -3792,6 +3794,16 @@ "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -13094,9 +13106,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index e30b99d..29fe012 100644 --- a/package.json +++ b/package.json @@ -40,12 +40,14 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", - "vite": "^6.2.0" + "vite": "^6.2.0", + "ws": "^8.21.0" }, "devDependencies": { "@firebase/rules-unit-testing": "^3.0.4", "@types/express": "^4.17.21", "@types/node": "^22.14.0", + "@types/ws": "^8.18.1", "autoprefixer": "^10.4.21", "esbuild": "^0.28.0", "eslint": "^10.2.1", diff --git a/server.ts b/server.ts index 42b1078..2b5691e 100644 --- a/server.ts +++ b/server.ts @@ -11,6 +11,8 @@ const MetaApi = typeof MetaApiPkg === "function" ? MetaApiPkg : (MetaApiPkg as a import { db } from "./src/lib/firebase"; import { doc, getDoc, setDoc, addDoc, collection, runTransaction, query, where, getDocs, updateDoc, orderBy, limit } from "firebase/firestore"; +import derivRoutes from "./src/routes/deriv.routes.ts"; + async function startServer() { const app = express(); const PORT = 3000; @@ -22,6 +24,9 @@ async function startServer() { res.json({ status: "ok" }); }); + // Mount Deriv Routes + app.use("/api/deriv", derivRoutes); + // --- BACKGROUND WORKER NODE (CRON JOB) --- function getYfSymbol(pair: string): string { if (pair.includes('XAU')) return 'GC=F'; diff --git a/src/components/AITradingDashboard.tsx b/src/components/AITradingDashboard.tsx new file mode 100644 index 0000000..39f78d6 --- /dev/null +++ b/src/components/AITradingDashboard.tsx @@ -0,0 +1,397 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { AITradingEngine, RiskManager, AISignal } from '../services/aiTradingEngine'; +import { Activity, Target, TrendingUp, AlertCircle, Clock, Zap, X } from 'lucide-react'; +import axios from 'axios'; +import { cn } from '../lib/utils'; +import { collection, addDoc } from 'firebase/firestore'; +import { db } from '../lib/firebase'; + +export interface ErrorLogDetail { + timestamp: number; + endpoint?: string; + payload?: any; + httpCode?: number; + message: string; + stackTrace?: string; +} + +export const AITradingDashboard = ({ userData, engineRunning }: { userData: any, engineRunning: boolean }) => { + const [lastSignal, setLastSignal] = useState(null); + const [lastLog, setLastLog] = useState<{message: string, isError: boolean, timestamp: number} | null>(null); + const [errorLogs, setErrorLogs] = useState([]); + const [showLogsModal, setShowLogsModal] = useState(false); + const [isDebug, setIsDebug] = useState(false); + + const logDetailedError = (customMessage: string, error?: any, endpoint?: string, payload?: any) => { + let parsedPayload = payload; + if (!parsedPayload && error?.config?.data) { + try { parsedPayload = JSON.parse(error.config.data); } catch (e) { parsedPayload = error.config.data; } + } + + const log: ErrorLogDetail = { + timestamp: Date.now(), + endpoint: endpoint || error?.config?.url, + payload: parsedPayload, + httpCode: error?.response?.status, + message: error?.response?.data?.details?.message || error?.response?.data?.error || error?.response?.data?.message || error?.message || customMessage, + stackTrace: error?.stack + }; + + setErrorLogs(prev => [log, ...prev].slice(0, 100)); + setLastLog({ message: `Erro: ${log.message}`, isError: true, timestamp: log.timestamp }); + console.error(customMessage, error); + }; + + const [stats, setStats] = useState({ + winRate: 68.5, + tradesToday: 0, + profitToday: 0, + lastTradeAction: '-', + lastTradeResult: '-', + lastTradeTime: 0 + }); + const statsRef = useRef(stats); + useEffect(() => { statsRef.current = stats; }, [stats]); + + const token = userData?.savedDerivToken || userData?.mtPassword; + const appId = userData?.derivAppId || '1089'; + const symbol = userData?.tradingSettings?.selectedAsset || 'R_100'; + + const activeCycleRef = useRef(false); + const lastSignalRef = useRef(0); + const monitoringTradesRef = useRef([]); + + useEffect(() => { + if (!engineRunning || userData?.mtPlatform !== 'deriv_api' || !token) return; + + const executeCycle = async () => { + if (activeCycleRef.current) return; + activeCycleRef.current = true; + + try { + // 1. Monitor Phase + const positionsToKeep: number[] = []; + for (const contractId of monitoringTradesRef.current) { + try { + const statusRes = await axios.get(`/api/deriv/contract/${contractId}?appId=${appId}`, { + headers: { Authorization: `Bearer ${token}` } + }); + const contract = statusRes.data?.contract; + + if (!contract) continue; + + if (contract.is_sold) { + setLastLog({ message: `Trade ${contractId} fechado externamente. Lucro: $${contract.profit.toFixed(2)}`, isError: false, timestamp: Date.now() }); + setStats(prev => ({ + ...prev, + profitToday: prev.profitToday + contract.profit, + lastTradeResult: contract.profit >= 0 ? 'WIN' : 'LOSS' + })); + continue; + } + + const buyPrice = contract.buy_price; + const roi = contract.profit / buyPrice; + const ageSeconds = (Date.now() / 1000) - contract.date_start; + + // Close if +/- 10% ROI, or after 60 seconds + if (roi >= 0.1 || roi <= -0.1 || ageSeconds > 60) { + try { + await axios.post(`/api/deriv/close-trade?appId=${appId}`, { contractId }, { + headers: { Authorization: `Bearer ${token}` } + }); + + const finalRes = await axios.get(`/api/deriv/contract/${contractId}?appId=${appId}`, { + headers: { Authorization: `Bearer ${token}` } + }); + const finalProfit = finalRes.data?.contract?.profit || contract.profit; + + setLastLog({ message: `Trade ${contractId} finalizado. Lucro: $${finalProfit.toFixed(2)}`, isError: false, timestamp: Date.now() }); + setStats(prev => ({ + ...prev, + profitToday: prev.profitToday + finalProfit, + lastTradeResult: finalProfit >= 0 ? 'WIN' : 'LOSS' + })); + + if (userData?.uid) { + await addDoc(collection(db, "users", userData.uid, "auto_trades"), { + contract_id: contractId, + profit: finalProfit, + timestamp: Date.now(), + symbol: contract.underlying + }).catch(e => logDetailedError("Erro ao salvar trade no firestore", e, "firebase/addDoc")); + } + } catch (e: any) { + logDetailedError("Failed to close trade", e, `/api/deriv/close-trade?appId=${appId}`, { contractId }); + positionsToKeep.push(contractId); + } + } else { + positionsToKeep.push(contractId); + } + } catch (e: any) { + logDetailedError("Failed to fetch contract status", e, `/api/deriv/contract/${contractId}?appId=${appId}`); + positionsToKeep.push(contractId); + } + } + + monitoringTradesRef.current = positionsToKeep; + + // 2. Generate Signal & Open Phase (Every 30s) + const now = Date.now(); + if (now - lastSignalRef.current > 30000) { + lastSignalRef.current = now; + + const tickRes = await axios.get(`/api/deriv/tick/${symbol}?appId=${appId}`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (tickRes.data?.success === false) { + logDetailedError("Could not fetch price", { message: tickRes.data?.error }, `/api/deriv/tick/${symbol}?appId=${appId}`); + throw new Error(tickRes.data?.error || "Could not fetch price"); + } + const price = tickRes.data?.tick?.quote || 0; + if (!price) { + logDetailedError("Could not fetch price limit", { message: "Price empty" }, `/api/deriv/tick/${symbol}?appId=${appId}`); + throw new Error("Could not fetch price"); + } + + const signal = await AITradingEngine.analyze(symbol, price); + setLastSignal(signal); + + const openPositions = monitoringTradesRef.current.length; + const balance = 10000; // Mock or fetch balance + let amount = parseFloat(userData?.tradingSettings?.fixedLot || "1"); + const MIN_STAKE = 1; + const requestedAmount = amount; + if (amount < MIN_STAKE) { + amount = MIN_STAKE; + } + + if (requestedAmount !== amount) { + console.log(`Requested Amount: ${requestedAmount}`); + console.log(`Final Amount Sent: ${amount}`); + setLastLog({ message: `Stake validado: Requested Amount: ${requestedAmount} -> Final Amount Sent: ${amount}`, isError: false, timestamp: Date.now() }); + } + + const riskLimits = { + maxDailyLoss: parseFloat(userData?.tradingSettings?.dailyLossLimit) || 100, + maxOpenPositions: parseInt(userData?.tradingSettings?.maxPositions) || 3, + maxTradeValue: parseFloat(userData?.tradingSettings?.dailyLossLimit) || 50, + minConfidence: parseFloat(userData?.tradingSettings?.minConfidence) || 80 + }; + + const cooldownMinutes = parseFloat(userData?.tradingSettings?.cooldownMinutes) || 5; + const timeSinceLastTrade = Date.now() - statsRef.current.lastTradeTime; + + if (statsRef.current.lastTradeTime > 0 && timeSinceLastTrade < cooldownMinutes * 60 * 1000) { + setLastLog({ message: `Sinal ignorado (Cooldown): Aguarde ${Math.ceil((cooldownMinutes * 60 * 1000 - timeSinceLastTrade) / 1000)}s`, isError: true, timestamp: Date.now() }); + return; // skip execution + } + + const validation = RiskManager.validate(signal, openPositions, balance, riskLimits, amount, statsRef.current.profitToday, engineRunning); + + if (!validation.valid) { + setLastLog({ message: `Sinal rejeitado: ${validation.reason}`, isError: true, timestamp: Date.now() }); + } else { + const isBuy = signal.action === 'BUY'; + const tradePayload: any = { + symbol: signal.symbol, + amount: amount, + contract_typeBase: isBuy ? 'MULTUP' : 'MULTDOWN' + }; + const tradeRes = await axios.post(`/api/deriv/test-buy?appId=${appId}`, tradePayload, { + headers: { Authorization: `Bearer ${token}` } + }); + + if (tradeRes.data?.success) { + const newContractId = tradeRes.data.trade.contract_id; + monitoringTradesRef.current.push(newContractId); + setLastLog({ message: `Posição ${newContractId} aberta (${signal.action} ${signal.symbol})`, isError: false, timestamp: Date.now() }); + setStats(prev => ({ + ...prev, + tradesToday: prev.tradesToday + 1, + lastTradeAction: signal.action, + lastTradeResult: 'PENDING', + lastTradeTime: Date.now() + })); + } else { + logDetailedError('Erro ao abrir posição', { message: tradeRes.data?.error || 'Unknown error', response: { status: 400, data: tradeRes.data } }, `/api/deriv/test-buy?appId=${appId}`, tradePayload); + } + } + } + + } catch (err: any) { + logDetailedError(err?.message || 'Error no ciclo', err); + } finally { + activeCycleRef.current = false; + } + }; + + executeCycle(); + const intervalId = setInterval(executeCycle, 5000); // 5s loop for monitoring + return () => { + clearInterval(intervalId); + activeCycleRef.current = false; + }; + }, [engineRunning, token, appId, symbol, userData]); + + return ( +
+

+
+ + AI ENGINE DASHBOARD +
+
+ {engineRunning ? ( + + + + + ) : ( + + )} + + Cycle: 5s / 30s + +
+

+ + {/* Performance Stats */} +
+
+
Win Rate
+
{stats.winRate}%
+
+
+
Hoje
+
{stats.tradesToday}
+
+
+
= 0 ? "text-green-500" : "text-red-500"} /> Lucro / Prejuízo
+
= 0 ? "text-green-400" : "text-red-400")}> + {stats.profitToday >= 0 ? '+' : ''}{stats.profitToday.toFixed(2)} +
+
+
+
Último Trade
+
+ {stats.lastTradeAction !== '-' ? ( + + {stats.lastTradeAction} + + ) : '-'} {stats.lastTradeResult} +
+
+
+ + {/* Last Signal */} + {lastSignal && ( +
+
+
+ Último Sinal Gerado + {new Date(lastSignal.timestamp).toLocaleTimeString()} +
+
+ + {lastSignal.action} + + {lastSignal.symbol} +
+
+ Confiança + = 80 ? "text-green-400" : "text-yellow-400")}>{lastSignal.confidence}% +
+
+

+ {lastSignal.reason} +

+
+ )} + + {/* Exec Log */} +
+
+ {lastLog && ( +
+ {lastLog.isError ? : } + {lastLog.message} + {new Date(lastLog.timestamp).toLocaleTimeString()} +
+ )} +
+ {errorLogs.length > 0 && ( + + )} +
+ + {/* Error Logs Modal */} + {showLogsModal && ( +
+
+
+

+ + Logs de Sistema +

+
+ + +
+
+
+ {errorLogs.map((log, i) => ( +
+
+ [{new Date(log.timestamp).toLocaleTimeString()}] + {log.httpCode && = 500 ? "bg-red-400" : "bg-orange-400")}>HTTP {log.httpCode}} +
+ +
{log.message}
+ + {log.endpoint && ( +
+
Endpoint:
+
{log.endpoint}
+
+ )} + + {log.payload && ( +
+
Payload:
+
+                                                {JSON.stringify(log.payload, null, 2)}
+                                            
+
+ )} + + {isDebug && log.stackTrace && ( +
+
Stack Trace:
+
+                                                {log.stackTrace}
+                                            
+
+ )} +
+ ))} + {errorLogs.length === 0 &&
Nenhum erro registrado nesta sessão.
} +
+
+
+ )} +
+ ); +}; diff --git a/src/components/AutoTradingView.tsx b/src/components/AutoTradingView.tsx index f45e1b3..e734309 100644 --- a/src/components/AutoTradingView.tsx +++ b/src/components/AutoTradingView.tsx @@ -5,6 +5,9 @@ import { BarChart2, TrendingUp, DollarSign, Send, Globe2, AlertTriangle, Fingerprint, ChevronRight, Server, Cpu, Radio, Network, CheckCircle2, ShieldCheck } from 'lucide-react'; +import { DerivDiagnosticPanel } from './DerivDiagnosticPanel'; +import { DerivTestPanel } from './DerivTestPanel'; +import { AITradingDashboard } from './AITradingDashboard'; import { cn } from '../lib/utils'; export const AutoTradingView: React.FC<{ userData?: any, onUpdate?: (data: any) => void, onNavigate?: (tab: string) => void, isAdmin?: boolean }> = ({ userData, onUpdate, onNavigate, isAdmin }) => { @@ -61,6 +64,10 @@ export const AutoTradingView: React.FC<{ userData?: any, onUpdate?: (data: any) const [breakEvenEnabled, setBreakEvenEnabled] = useState(userData?.tradingSettings?.breakEvenEnabled ?? true); const [tradingHoursStart, setTradingHoursStart] = useState(userData?.tradingSettings?.tradingHoursStart || "00:00"); const [tradingHoursEnd, setTradingHoursEnd] = useState(userData?.tradingSettings?.tradingHoursEnd || "23:59"); + const [minConfidence, setMinConfidence] = useState(userData?.tradingSettings?.minConfidence?.toString() || "80"); + const [dailyLossLimit, setDailyLossLimit] = useState(userData?.tradingSettings?.dailyLossLimit?.toString() || "100"); + const [dailyProfitTarget, setDailyProfitTarget] = useState(userData?.tradingSettings?.dailyProfitTarget?.toString() || "200"); + const [cooldownMinutes, setCooldownMinutes] = useState(userData?.tradingSettings?.cooldownMinutes?.toString() || "5"); const saveSettingsToFirebase = async ( newSettings: any[], @@ -99,7 +106,11 @@ export const AutoTradingView: React.FC<{ userData?: any, onUpdate?: (data: any) fixedLot: parseFloat(newFixedLot ?? fixedLot) || 0.01, breakEvenEnabled: newBeEnabled ?? breakEvenEnabled, tradingHoursStart: newHoursStart ?? tradingHoursStart, - tradingHoursEnd: newHoursEnd ?? tradingHoursEnd + tradingHoursEnd: newHoursEnd ?? tradingHoursEnd, + minConfidence: parseFloat(minConfidence) || 80, + dailyLossLimit: parseFloat(dailyLossLimit) || 100, + dailyProfitTarget: parseFloat(dailyProfitTarget) || 200, + cooldownMinutes: parseInt(cooldownMinutes) || 5 }; try { @@ -349,8 +360,13 @@ export const AutoTradingView: React.FC<{ userData?: any, onUpdate?: (data: any) {/* LEFT COLUMN: STATUS & BUTTONS */}
- {/* STATUS CARDS REMOVED */} - + {userData?.mtPlatform === 'deriv_api' && ( + <> + + + + + )} {/* MAIN BUTTONS */}
@@ -676,6 +692,54 @@ export const AutoTradingView: React.FC<{ userData?: any, onUpdate?: (data: any)
+ + {/* RISK MANAGEMENT PANEL */} +

+ RISK MANAGEMENT +

+ +
+
+ + setMinConfidence(e.target.value)} + onBlur={() => saveSettingsToFirebase(tradingSettings, riskPerTrade, maxPositions)} + className="w-full bg-black border border-white/10 rounded-lg p-3 text-sm text-white font-mono focus:border-brand-red outline-none transition-colors" + /> +
+
+ + setCooldownMinutes(e.target.value)} + onBlur={() => saveSettingsToFirebase(tradingSettings, riskPerTrade, maxPositions)} + className="w-full bg-black border border-white/10 rounded-lg p-3 text-sm text-white font-mono focus:border-brand-red outline-none transition-colors" + /> +
+
+ + setDailyLossLimit(e.target.value)} + onBlur={() => saveSettingsToFirebase(tradingSettings, riskPerTrade, maxPositions)} + className="w-full bg-black border border-red-500/30 rounded-lg p-3 text-sm text-red-400 font-mono focus:border-red-500 outline-none transition-colors" + /> +
+
+ + setDailyProfitTarget(e.target.value)} + onBlur={() => saveSettingsToFirebase(tradingSettings, riskPerTrade, maxPositions)} + className="w-full bg-black border border-green-500/30 rounded-lg p-3 text-sm text-green-400 font-mono focus:border-green-500 outline-none transition-colors" + /> +
+
diff --git a/src/components/DerivDiagnosticPanel.tsx b/src/components/DerivDiagnosticPanel.tsx new file mode 100644 index 0000000..ea4bbfe --- /dev/null +++ b/src/components/DerivDiagnosticPanel.tsx @@ -0,0 +1,106 @@ +import React, { useEffect, useState } from 'react'; +import { Network, Activity, Clock, ShieldCheck, AlertTriangle, Disc } from 'lucide-react'; +import { cn } from '../lib/utils'; +import axios from 'axios'; + +export const DerivDiagnosticPanel = ({ userData }: { userData: any }) => { + const [status, setStatus] = useState<'ONLINE' | 'OFFLINE' | 'CONNECTING'>('CONNECTING'); + const [account, setAccount] = useState('-------'); + const [balance, setBalance] = useState('---'); + const [currency, setCurrency] = useState('---'); + const [openPositions, setOpenPositions] = useState(0); + const [lastTick, setLastTick] = useState<'OK' | 'WAITING' | 'ERROR'>('WAITING'); + const [latency, setLatency] = useState(0); + + const checkConnection = async () => { + try { + if (userData?.mtPlatform !== 'deriv_api' || (!userData?.savedDerivToken && !userData?.mtPassword)) { + setStatus('OFFLINE'); + return; + } + + const token = userData?.savedDerivToken || userData?.mtPassword; + const start = performance.now(); + const res = await axios.get('/api/deriv/account', { + headers: { Authorization: `Bearer ${token}` } + }); + const end = performance.now(); + + if (res.data?.success) { + setStatus('ONLINE'); + setAccount(res.data.loginid); + setBalance(res.data.balance.toString()); + setCurrency(res.data.currency); + setOpenPositions(res.data.openPositions || 0); + setLatency(Math.round(end - start)); + setLastTick('OK'); + } else { + setStatus('OFFLINE'); + } + } catch (e) { + setStatus('OFFLINE'); + setLastTick('ERROR'); + } + }; + + useEffect(() => { + checkConnection(); + const interval = setInterval(checkConnection, 15000); + return () => clearInterval(interval); + }, [userData]); + + if (userData?.mtPlatform !== 'deriv_api') return null; + + return ( +
+

+
+ + DERIV STATUS +
+ {status === 'ONLINE' ? ( + +
ONLINE +
+ ) : ( + +
OFFLINE +
+ )} +

+ +
+
+
Account
+
+ {account} +
+
+
+
Balance
+
+ {balance} {currency} +
+
+
+
Open Positions
+
+ {openPositions} +
+
+
+
Last Tick
+
+ {lastTick} +
+
+
+
Latency
+
+ {latency}ms +
+
+
+
+ ); +}; diff --git a/src/components/DerivTestPanel.tsx b/src/components/DerivTestPanel.tsx new file mode 100644 index 0000000..d56f61b --- /dev/null +++ b/src/components/DerivTestPanel.tsx @@ -0,0 +1,272 @@ +import React, { useState, useEffect } from 'react'; +import axios from 'axios'; +import { Play, TrendingDown, TrendingUp, RefreshCcw, XCircle, Clock, AlertTriangle, CheckCircle2 } from 'lucide-react'; + +interface Position { + contract_id: number; + symbol: string; + contract_type: string; + buy_price: number; + currency: string; + // Note: portfolio API doesn't return current profit directly, we might just show buy price for now or mock if needed. + // Actually, Deriv API portfolio returns: contract_id, symbol, contract_type, buy_price, payout, transaction_id... +} + +interface LogEntry { + time: Date; + action: string; + result: string; + error?: string; +} + +export const DerivTestPanel = ({ userData }: { userData: any }) => { + const [symbol, setSymbol] = useState('R_100'); // Volatility 100 Index as default + const [amount, setAmount] = useState(1); + const [positions, setPositions] = useState([]); + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(false); + + const token = userData?.savedDerivToken || userData?.mtPassword; + const appId = userData?.derivAppId || '1089'; + + const addLog = (action: string, result: string, error?: string) => { + setLogs(prev => [{ time: new Date(), action, result, error }, ...prev].slice(0, 50)); + }; + + const fetchPositions = async () => { + if (!token) return; + setLoading(true); + try { + const res = await axios.get(`/api/deriv/positions?appId=${appId}`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (res.data.success) { + setPositions(res.data.positions || []); + addLog('REFRESH POSITIONS', `Fetched ${res.data.positions?.length || 0} positions`); + } + } catch (e: any) { + addLog('REFRESH POSITIONS', 'Failed', e.response?.data?.error || e.message); + } finally { + setLoading(false); + } + }; + + const handleTrade = async (direction: 'BUY' | 'SELL') => { + if (!token) return; + setLoading(true); + const contract_typeBase = direction === 'BUY' ? 'CALL' : 'PUT'; + try { + const res = await axios.post(`/api/deriv/test-buy?appId=${appId}`, { + symbol, + amount, + contract_typeBase, + duration: 5, + duration_unit: 't' + }, { + headers: { Authorization: `Bearer ${token}` } + }); + + if (res.data.success) { + addLog(`TEST ${direction}`, `Order Executed: ${res.data.trade?.contract_id}`); + fetchPositions(); + } + } catch (e: any) { + addLog(`TEST ${direction}`, 'Failed', e.response?.data?.error || e.message); + } finally { + setLoading(false); + } + }; + + const closePosition = async (contractId: number) => { + if (!token) return; + setLoading(true); + try { + const res = await axios.post(`/api/deriv/close-trade?appId=${appId}`, { + contractId + }, { + headers: { Authorization: `Bearer ${token}` } + }); + if (res.data.success) { + addLog('CLOSE POSITION', `Closed contract ${contractId}`); + fetchPositions(); + } + } catch (e: any) { + addLog('CLOSE POSITION', `Failed contract ${contractId}`, e.response?.data?.error || e.message); + } finally { + setLoading(false); + } + }; + + const closeAllPositions = async () => { + if (!token || positions.length === 0) return; + setLoading(true); + let successCount = 0; + for (const pos of positions) { + try { + await axios.post(`/api/deriv/close-trade?appId=${appId}`, { + contractId: pos.contract_id + }, { + headers: { Authorization: `Bearer ${token}` } + }); + successCount++; + } catch (e: any) { + addLog('CLOSE POSITION', `Failed contract ${pos.contract_id}`, e.response?.data?.error || e.message); + } + } + addLog('CLOSE ALL', `Closed ${successCount}/${positions.length} positions`); + fetchPositions(); + setLoading(false); + }; + + useEffect(() => { + if (token) { + fetchPositions(); + } + }, [token]); + + if (userData?.mtPlatform !== 'deriv_api') return null; + + return ( +
+

Trading Test

+ + {/* Controls */} +
+
+ + setSymbol(e.target.value.toUpperCase())} + className="w-full bg-black/50 border border-white/10 rounded-lg px-3 py-2 text-white font-mono text-sm focus:border-brand-red focus:outline-none" + /> +
+
+ + setAmount(Number(e.target.value))} + className="w-full bg-black/50 border border-white/10 rounded-lg px-3 py-2 text-white font-mono text-sm focus:border-brand-red focus:outline-none" + /> +
+
+ +
+
+ +
+
+ + {/* Actions & Tables */} +
+
+

Open Positions

+
+ + +
+
+ +
+ + + + + + + + + + + + {positions.length === 0 ? ( + + + + ) : ( + positions.map((pos) => ( + + + + + + + + )) + )} + +
Contract IDSymbolDirectionEntry PriceAction
No open positions
{pos.contract_id}{pos.symbol} + + {pos.contract_type} + + {pos.buy_price} {pos.currency} + +
+
+
+ + {/* Logs */} +
+

Trade Logs

+
+ + + + + + + + + + + {logs.length === 0 ? ( + + + + ) : ( + logs.map((log, i) => ( + + + + + + + )) + )} + +
TimeActionResultError
No logs yet
+ + {log.time.toLocaleTimeString()} + {log.action} + {log.error ? : } + {log.result} + {log.error || '-'}
+
+
+
+ ); +}; diff --git a/src/controllers/deriv.controller.ts b/src/controllers/deriv.controller.ts new file mode 100644 index 0000000..b60af69 --- /dev/null +++ b/src/controllers/deriv.controller.ts @@ -0,0 +1,314 @@ +import { Request, Response } from 'express'; +import { DerivService } from '../services/deriv/index.js'; +import { getDerivSymbol } from '../services/marketData.js'; + +class DerivManager { + private instances: Map = new Map(); + private initializing: Map> = new Map(); + + public async getInstance(appId: string, token: string): Promise { + const key = `${appId}:${token}`; + + if (this.instances.has(key)) { + return this.instances.get(key)!; + } + + if (this.initializing.has(key)) { + return this.initializing.get(key)!; + } + + const initPromise = (async () => { + try { + const service = new DerivService(appId, token); + await service.initialize(); + this.instances.set(key, service); + return service; + } finally { + this.initializing.delete(key); + } + })(); + + this.initializing.set(key, initPromise); + return initPromise; + } + + public removeInstance(appId: string, token: string) { + const key = `${appId}:${token}`; + if (this.instances.has(key)) { + this.instances.get(key)!.disconnect(); + this.instances.delete(key); + } + } +} + +export const derivManager = new DerivManager(); + +export const connect = async (req: Request, res: Response) => { + try { + const { token } = req.body; + const appId = req.body.appId || '1089'; + if (!token) return res.status(400).json({ error: 'Token is required' }); + + console.log(`[Deriv] Connecting with appId: ${appId}...`); + await derivManager.getInstance(appId, token); + + res.json({ success: true, message: 'Connected to Deriv' }); + } catch (err: any) { + console.error('[Deriv] Connection Error:', err); + const errMessage = err?.message || String(err); + res.status(500).json({ error: errMessage || 'Connection failed' }); + } +}; + +export const getAccount = async (req: Request, res: Response) => { + try { + const token = (req.headers.authorization || '').replace('Bearer ', ''); + const appId = (req.query.appId as string) || '1089'; + if (!token) return res.status(401).json({ error: 'Unauthorized: No token provided' }); + + const service = await derivManager.getInstance(appId, token); + const { balance, currency } = await service.account.getBalance(); + + // Re-authorize to ensure we have the loginid + const authData = await service.auth.authorize(); + + // Get open positions + let openPositionsCount = 0; + try { + const positions = await service.trading.getOpenPositions(); + openPositionsCount = Array.isArray(positions) ? positions.length : 0; + } catch(e) { + console.error('[Deriv] Fetch positions error:', e); + } + + res.json({ + success: true, + balance, + currency, + loginid: authData?.loginid || '', + openPositions: openPositionsCount + }); + } catch (err: any) { + console.error('[Deriv] Get Account Error:', err); + const errMessage = err?.message || String(err); + res.status(500).json({ error: errMessage || 'Failed to fetch account' }); + } +}; + +export const getSymbols = async (req: Request, res: Response) => { + try { + const token = (req.headers.authorization || '').replace('Bearer ', ''); + const appId = (req.query.appId as string) || '1089'; + if (!token) return res.status(401).json({ error: 'Unauthorized: No token provided' }); + + const service = await derivManager.getInstance(appId, token); + + const response = await service.ws.send({ + active_symbols: "brief", + product_type: "basic" + }); + + res.json({ + success: true, + symbols: response.active_symbols + }); + } catch (err: any) { + console.error('[Deriv] Get Symbols Error:', err); + const errMessage = err?.message || String(err); + res.status(500).json({ error: errMessage || 'Failed to fetch symbols' }); + } +}; + +export const getTick = async (req: Request, res: Response) => { + let cleanSymbol = 'R_100'; + try { + const token = (req.headers.authorization || '').replace('Bearer ', ''); + const appId = (req.query.appId as string) || '1089'; + const { symbol } = req.params; + + cleanSymbol = getDerivSymbol(symbol) || 'R_100'; + + console.log(`[Deriv] Get Tick called with symbol:`, cleanSymbol); + + if (!token) return res.status(401).json({ error: 'Unauthorized: No token provided' }); + + const service = await derivManager.getInstance(appId, token); + + const response = await service.ws.send({ + ticks_history: cleanSymbol, + end: 'latest', + count: 1, + style: 'ticks' + }); + + if (response.error) { + return res.status(400).json({ error: response.error.message }); + } + + res.json({ + success: true, + tick: { + symbol: cleanSymbol, + quote: response.history?.prices?.[0] || 0, + epoch: response.history?.times?.[0] || 0 + } + }); + } catch (err: any) { + console.error('[Deriv] Get Tick Error:', err?.message || String(err), 'symbol:', cleanSymbol); + res.json({ success: false, error: err?.message || 'Failed to fetch tick', symbol: cleanSymbol }); + } +}; + +export const testBuy = async (req: Request, res: Response) => { + try { + const token = (req.headers.authorization || '').replace('Bearer ', ''); + const appId = (req.query.appId as string) || '1089'; + let { symbol, amount, contract_typeBase, duration, duration_unit, multiplier } = req.body; + + if (!token) return res.status(401).json({ error: 'Unauthorized: No token provided' }); + if (!symbol) return res.status(400).json({ error: 'Symbol is required' }); + + let cleanSymbol = getDerivSymbol(symbol) || 'R_100'; + + const service = await derivManager.getInstance(appId, token); + + let finalMultiplier = multiplier; + if (contract_typeBase && contract_typeBase.includes('MULT')) { + const allowedMultipliers = await getAllowedMultipliers(service, cleanSymbol); + if (allowedMultipliers && allowedMultipliers.length > 0) { + if (!finalMultiplier || !allowedMultipliers.includes(finalMultiplier)) { + finalMultiplier = allowedMultipliers[0]; // Pick the first valid multiplier + } + } else { + return res.status(400).json({ error: `Multipliers not supported for symbol ${cleanSymbol}` }); + } + } + + const tradeParams: any = { + symbol: cleanSymbol, + amount: amount || 1, // Minimum demo value usually 1 for USD, depends on currency + contract_typeBase: contract_typeBase || 'CALL' + }; + + if (finalMultiplier) { + tradeParams.multiplier = finalMultiplier; + } else { + tradeParams.duration = duration || 5; + tradeParams.duration_unit = duration_unit || 't'; + } + + const result = await service.trading.executeTrade(tradeParams); + + res.json({ + success: true, + trade: result + }); + } catch (err: any) { + console.error('[Deriv] Test Buy Error:', err); + const errMessage = err?.message || String(err); + res.status(500).json({ error: errMessage, details: err }); + } +}; + +const multipliersCache: Map = new Map(); + +async function getAllowedMultipliers(service: DerivService, symbol: string): Promise { + const cacheKey = symbol; + const cached = multipliersCache.get(cacheKey); + // Cache for 1 hour + if (cached && Date.now() - cached.timestamp < 3600 * 1000) { + return cached.values; + } + + try { + const response = await service.ws.send({ contracts_for: symbol }); + if (response.contracts_for && response.contracts_for.available) { + const multiContracts = response.contracts_for.available.filter((c: any) => c.contract_category === 'multiplier'); + if (multiContracts.length > 0) { + // Different contracts might have different arrays (e.g., MULTUP vs MULTDOWN), usually they are the same + // We flatten and deduplicate the multiplier_range arrays + const allRanges = multiContracts.map((c: any) => c.multiplier_range || c.multipliers || []).flat(); + const uniqueMultipliers = Array.from(new Set(allRanges)).filter(Boolean) as number[]; + if (uniqueMultipliers.length > 0) { + uniqueMultipliers.sort((a, b) => a - b); + multipliersCache.set(cacheKey, { values: uniqueMultipliers, timestamp: Date.now() }); + return uniqueMultipliers; + } + } + } + } catch (err) { + console.error(`[Deriv] Error fetching allowed multipliers for ${symbol}:`, err); + } + + return null; +} + +export const getPositions = async (req: Request, res: Response) => { + try { + const token = (req.headers.authorization || '').replace('Bearer ', ''); + const appId = (req.query.appId as string) || '1089'; + if (!token) return res.status(401).json({ error: 'Unauthorized: No token provided' }); + + const service = await derivManager.getInstance(appId, token); + const positions = await service.trading.getOpenPositions(); + + res.json({ + success: true, + positions + }); + } catch (err: any) { + console.error('[Deriv] Get Positions Error:', err); + const errMessage = err?.message || String(err); + res.status(500).json({ error: errMessage || 'Failed to fetch positions' }); + } +}; + +export const getContractStatus = async (req: Request, res: Response) => { + try { + const token = (req.headers.authorization || '').replace('Bearer ', ''); + const appId = (req.query.appId as string) || '1089'; + const { contractId } = req.params; + + if (!token) return res.status(401).json({ error: 'Unauthorized: No token provided' }); + if (!contractId) return res.status(400).json({ error: 'Contract ID is required' }); + + const service = await derivManager.getInstance(appId, token); + const response = await service.ws.send({ + proposal_open_contract: 1, + contract_id: Number(contractId) + }); + + res.json({ + success: true, + contract: response.proposal_open_contract + }); + } catch (err: any) { + console.error('[Deriv] Get Contract Status Error:', err); + const errMessage = err?.message || String(err); + res.status(500).json({ error: errMessage || 'Failed to fetch contract status' }); + } +}; + +export const closeTrade = async (req: Request, res: Response) => { + try { + const token = (req.headers.authorization || '').replace('Bearer ', ''); + const appId = (req.query.appId as string) || '1089'; + const { contractId } = req.body; + + if (!token) return res.status(401).json({ error: 'Unauthorized: No token provided' }); + if (!contractId) return res.status(400).json({ error: 'Contract ID is required' }); + + const service = await derivManager.getInstance(appId, token); + + const result = await service.trading.closeTrade(contractId); + + res.json({ + success: true, + result + }); + } catch (err: any) { + console.error('[Deriv] Close Trade Error:', err); + const errMessage = err?.message || String(err); + res.status(500).json({ error: errMessage || 'Failed to close trade' }); + } +}; diff --git a/src/routes/deriv.routes.ts b/src/routes/deriv.routes.ts new file mode 100644 index 0000000..df95af4 --- /dev/null +++ b/src/routes/deriv.routes.ts @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import * as derivController from '../controllers/deriv.controller.js'; + +const router = Router(); + +router.post('/connect', derivController.connect); +router.get('/account', derivController.getAccount); +router.get('/symbols', derivController.getSymbols); +router.get('/tick/:symbol', derivController.getTick); +router.get('/positions', derivController.getPositions); +router.get('/contract/:contractId', derivController.getContractStatus); +router.post('/test-buy', derivController.testBuy); +router.post('/close-trade', derivController.closeTrade); + +export default router; diff --git a/src/services/aiTradingEngine.ts b/src/services/aiTradingEngine.ts new file mode 100644 index 0000000..44af0bf --- /dev/null +++ b/src/services/aiTradingEngine.ts @@ -0,0 +1,76 @@ +import axios from 'axios'; + +export interface AISignal { + symbol: string; + action: 'BUY' | 'SELL'; + confidence: number; + reason: string; + timestamp: number; +} + +export interface RiskLimits { + maxDailyLoss: number; + maxOpenPositions: number; + maxTradeValue: number; + minConfidence?: number; + dailyProfitTarget?: number; +} + +export class AITradingEngine { + static async analyze(symbol: string, currentPrice: number): Promise { + // Simulated AI Analysis + // Em um sistema real, aqui chamaria um endpoint de machine learning ou usaria indicadores técnicos reais + const confidence = Math.floor(Math.random() * 40) + 60; // 60 to 100 + const action: 'BUY' | 'SELL' = Math.random() > 0.5 ? 'BUY' : 'SELL'; + + const reasons = [ + 'RSI indicates oversold conditions combined with strong volume surge.', + 'MACD crossover detected on primary timeframe with volatility expansion.', + 'Price action bounced from major institutional support/resistance level.', + 'Moving Average ribbon alignment indicates strong trend strength.', + 'Institutional order block detected aligning with current momentum.' + ]; + + return { + symbol, + action, + confidence: confidence >= 75 ? confidence + 5 : confidence, // Bias towards higher confidence to trigger some trades + reason: reasons[Math.floor(Math.random() * reasons.length)], + timestamp: Date.now() + }; + } +} + +export class RiskManager { + static validate( + signal: AISignal, + openPositionsCount: number, + balance: number, + limits: RiskLimits, + tradeAmount: number, + currentDailyProfit: number, + systemOnline: boolean + ): { valid: boolean; reason?: string } { + if (!systemOnline) return { valid: false, reason: 'System offline' }; + + const minConf = limits.minConfidence || 80; + if (signal.confidence < minConf) return { valid: false, reason: `Confidence too low (${signal.confidence}% < ${minConf}%)` }; + + if (openPositionsCount >= limits.maxOpenPositions) return { valid: false, reason: `Max open positions reached (${openPositionsCount}/${limits.maxOpenPositions})` }; + if (tradeAmount > limits.maxTradeValue) return { valid: false, reason: `Trade value exceeds max allowed (${tradeAmount} > ${limits.maxTradeValue})` }; + if (balance < tradeAmount) return { valid: false, reason: `Insufficient balance (${balance} < ${tradeAmount})` }; + + // currentDailyProfit could be negative (loss) or positive (profit) + // Check Loss: + if (currentDailyProfit < 0 && Math.abs(currentDailyProfit) >= limits.maxDailyLoss) { + return { valid: false, reason: `Max daily loss reached (${Math.abs(currentDailyProfit)} >= ${limits.maxDailyLoss})` }; + } + + // Check Profit Target: + if (limits.dailyProfitTarget && currentDailyProfit >= limits.dailyProfitTarget) { + return { valid: false, reason: `Daily profit target reached (${currentDailyProfit} >= ${limits.dailyProfitTarget})` }; + } + + return { valid: true }; + } +} diff --git a/src/services/deriv/derivAccount.ts b/src/services/deriv/derivAccount.ts new file mode 100644 index 0000000..49d734c --- /dev/null +++ b/src/services/deriv/derivAccount.ts @@ -0,0 +1,16 @@ +import { DerivWsClient } from './derivWs.js'; + +export class DerivAccount { + constructor(private client: DerivWsClient) {} + + public async getBalance(): Promise<{ balance: number; currency: string }> { + const response = await this.client.send({ + balance: 1 + }); + + return { + balance: response.balance.balance, + currency: response.balance.currency + }; + } +} diff --git a/src/services/deriv/derivAuth.ts b/src/services/deriv/derivAuth.ts new file mode 100644 index 0000000..c1c789d --- /dev/null +++ b/src/services/deriv/derivAuth.ts @@ -0,0 +1,17 @@ +import { DerivWsClient } from './derivWs.js'; + +export class DerivAuth { + constructor(private client: DerivWsClient, private token: string) {} + + public async authorize(): Promise { + try { + const response = await this.client.send({ + authorize: this.token + }); + return response.authorize; + } catch (error) { + console.error("Deriv authorization failed:", error); + throw error; + } + } +} diff --git a/src/services/deriv/derivMarket.ts b/src/services/deriv/derivMarket.ts new file mode 100644 index 0000000..ad5f8f9 --- /dev/null +++ b/src/services/deriv/derivMarket.ts @@ -0,0 +1,40 @@ +import { DerivWsClient } from './derivWs.js'; + +export interface TickResponse { + symbol: string; + quote: number; + epoch: number; +} + +export class DerivMarket { + constructor(private client: DerivWsClient) {} + + public async subscribeTicks(symbol: string, callback: (tick: TickResponse) => void): Promise { + const response = await this.client.send({ + ticks: symbol, + subscribe: 1 + }); + + const subscriptionId = response.subscription?.id; + + // Listen for new ticks + this.client.on('tick', (data) => { + if (data.tick && data.tick.symbol === symbol) { + callback({ + symbol: data.tick.symbol, + quote: data.tick.quote, + epoch: data.tick.epoch + }); + } + }); + + return subscriptionId; + } + + public async unsubscribeTicks(subscriptionId: string): Promise { + const response = await this.client.send({ + forget: subscriptionId + }); + return response.forget === 1; + } +} diff --git a/src/services/deriv/derivTrading.ts b/src/services/deriv/derivTrading.ts new file mode 100644 index 0000000..6d860be --- /dev/null +++ b/src/services/deriv/derivTrading.ts @@ -0,0 +1,60 @@ +import { DerivWsClient } from './derivWs.js'; + +export interface ExecuteTradeParams { + symbol: string; + contract_typeBase: 'CALL' | 'PUT' | 'MULTUP' | 'MULTDOWN'; + amount: number; + currency?: string; + duration?: number; + duration_unit?: 's' | 'm' | 'h' | 'd' | 't'; + basis?: 'stake' | 'payout'; + multiplier?: number; +} + +export class DerivTrading { + constructor(private client: DerivWsClient) {} + + public async executeTrade(params: ExecuteTradeParams): Promise { + const parameters: any = { + amount: params.amount, + basis: params.basis || 'stake', + contract_type: params.contract_typeBase, + currency: params.currency || 'USD', + symbol: params.symbol, + }; + + if (params.duration && params.duration_unit) { + parameters.duration = params.duration; + parameters.duration_unit = params.duration_unit; + } + + if (params.multiplier) { + parameters.multiplier = params.multiplier; + } + + const response = await this.client.send({ + buy: 1, + price: params.amount, + parameters + }); + + return response.buy; + } + + public async closeTrade(contractId: number): Promise { + const response = await this.client.send({ + sell: contractId, + price: 0 // 0 means sell at market price + }); + + return response.sell; + } + + public async getOpenPositions(): Promise { + const response = await this.client.send({ + portfolio: 1 + }); + + return response.portfolio.contracts; + } +} diff --git a/src/services/deriv/derivWs.ts b/src/services/deriv/derivWs.ts new file mode 100644 index 0000000..60db231 --- /dev/null +++ b/src/services/deriv/derivWs.ts @@ -0,0 +1,127 @@ +import WebSocket from 'ws'; +import { DerivConfig } from './types.js'; + +export class DerivWsClient { + private ws: WebSocket | null = null; + private config: DerivConfig; + private messageCounter = 1; + private pendingRequests: Map void, reject: (reason?: any) => void }> = new Map(); + private eventListeners: Map void)[]> = new Map(); + + constructor(config: DerivConfig) { + this.config = { + endpoint: 'wss://ws.binaryws.com/websockets/v3', + ...config + }; + } + + private connectionPromise: Promise | null = null; + + public onConnect?: () => Promise; + + public async connect(): Promise { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + return Promise.resolve(); + } + + if (this.connectionPromise) { + return this.connectionPromise; + } + + this.connectionPromise = new Promise((resolve, reject) => { + const wsUrl = `${this.config.endpoint}?app_id=${this.config.appId}`; + this.ws = new WebSocket(wsUrl); + + this.ws.on('open', async () => { + if (this.onConnect) { + try { + await this.onConnect(); + resolve(); + } catch (e) { + reject(e); + } + } else { + resolve(); + } + }); + + this.ws.on('message', (data: WebSocket.Data) => { + try { + const response = JSON.parse(data.toString()); + const reqId = response.req_id; + + if (reqId && this.pendingRequests.has(reqId)) { + const { resolve, reject } = this.pendingRequests.get(reqId)!; + this.pendingRequests.delete(reqId); + + if (response.error) { + reject(response.error); + } else { + resolve(response); + } + } + + // Handle subscriptions + if (response.msg_type) { + const listeners = this.eventListeners.get(response.msg_type) || []; + listeners.forEach(listener => listener(response)); + } + } catch (err) { + console.error("Error parsing Deriv WS message:", err); + } + }); + + this.ws.on('error', (err) => { + this.connectionPromise = null; + reject(err); + }); + + this.ws.on('close', () => { + this.ws = null; + this.connectionPromise = null; + this.pendingRequests.forEach(({ reject }) => reject(new Error('WebSocket closed'))); + this.pendingRequests.clear(); + }); + }); + + return this.connectionPromise; + } + + public async send(request: any): Promise { + if (!request.authorize) { + await this.connect(); + } + + return new Promise((resolve, reject) => { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + return reject(new Error('WebSocket is not open')); + } + + const reqId = this.messageCounter++; + this.pendingRequests.set(reqId, { resolve, reject }); + + this.ws.send(JSON.stringify({ + ...request, + req_id: reqId + })); + }); + } + + public on(msgType: string, callback: (data: any) => void) { + const listeners = this.eventListeners.get(msgType) || []; + listeners.push(callback); + this.eventListeners.set(msgType, listeners); + } + + public off(msgType: string, callback: (data: any) => void) { + const listeners = this.eventListeners.get(msgType) || []; + this.eventListeners.set(msgType, listeners.filter(cb => cb !== callback)); + } + + public disconnect() { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } +} diff --git a/src/services/deriv/index.ts b/src/services/deriv/index.ts new file mode 100644 index 0000000..dc41f91 --- /dev/null +++ b/src/services/deriv/index.ts @@ -0,0 +1,34 @@ +import { DerivWsClient } from './derivWs.js'; +import { DerivAuth } from './derivAuth.js'; +import { DerivAccount } from './derivAccount.js'; +import { DerivMarket } from './derivMarket.js'; +import { DerivTrading } from './derivTrading.js'; + +export class DerivService { + public ws: DerivWsClient; + public auth: DerivAuth; + public account: DerivAccount; + public market: DerivMarket; + public trading: DerivTrading; + + constructor(appId: string, token: string) { + this.ws = new DerivWsClient({ appId, token }); + this.auth = new DerivAuth(this.ws, token); + this.account = new DerivAccount(this.ws); + this.market = new DerivMarket(this.ws); + this.trading = new DerivTrading(this.ws); + } + + public async initialize(): Promise { + await this.ws.connect(); + await this.auth.authorize(); + // Automatically re-authorize when websocket reconnects + this.ws.onConnect = async () => { + await this.auth.authorize(); + }; + } + + public disconnect(): void { + this.ws.disconnect(); + } +} diff --git a/src/services/deriv/types.ts b/src/services/deriv/types.ts new file mode 100644 index 0000000..6e8f796 --- /dev/null +++ b/src/services/deriv/types.ts @@ -0,0 +1,14 @@ +export interface DerivConfig { + appId: string; + token: string; + endpoint?: string; +} + +export interface DerivResponse { + msg_type: string; + error?: { + code: string; + message: string; + }; + [key: string]: any; +} diff --git a/test-tick.ts b/test-tick.ts new file mode 100644 index 0000000..f6a98c0 --- /dev/null +++ b/test-tick.ts @@ -0,0 +1,2 @@ +import axios from 'axios'; +console.log('hi');