diff --git a/frontend/src/api/strategy.ts b/frontend/src/api/strategy.ts index 74cce1314..ecd9b5c8b 100644 --- a/frontend/src/api/strategy.ts +++ b/frontend/src/api/strategy.ts @@ -97,6 +97,13 @@ export const useCreateStrategy = () => { }); }; +export const useTestConnection = () => { + return useMutation({ + mutationFn: (data: CreateStrategyRequest["exchange_config"]) => + apiClient.post>("/strategies/test-connection", data), + }); +}; + export const useStopStrategy = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/app/agent/components/strategy-items/forms/exchange-form.tsx b/frontend/src/app/agent/components/strategy-items/forms/exchange-form.tsx index fbd488ba1..e9fcfa1cf 100644 --- a/frontend/src/app/agent/components/strategy-items/forms/exchange-form.tsx +++ b/frontend/src/app/agent/components/strategy-items/forms/exchange-form.tsx @@ -1,7 +1,12 @@ +import { Wallet } from "lucide-react"; +import { useState } from "react"; +import { useTestConnection } from "@/api/strategy"; +import { Button } from "@/components/ui/button"; import { FieldGroup } from "@/components/ui/field"; import { Label } from "@/components/ui/label"; import { RadioGroupItem } from "@/components/ui/radio-group"; import { SelectItem } from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; import PngIcon from "@/components/valuecell/icon/png-icon"; import { EXCHANGE_ICONS } from "@/constants/icons"; import { withForm } from "@/hooks/use-form"; @@ -48,8 +53,27 @@ export const ExchangeForm = withForm({ private_key: "", }, render({ form }) { + const { mutateAsync: testConnection, isPending } = useTestConnection(); + const [testStatus, setTestStatus] = useState<{ + success: boolean; + message: string; + } | null>(null); + + const handleTestConnection = async () => { + setTestStatus(null); + try { + await testConnection(form.state.values); + setTestStatus({ success: true, message: "Success!" }); + } catch (_error) { + setTestStatus({ + success: false, + message: "Failed, please check your API key", + }); + } + }; + return ( - + { @@ -167,6 +191,32 @@ export const ExchangeForm = withForm({ ); }} + +
+ {testStatus && ( +

+ {testStatus.message} +

+ )} + +
) ); diff --git a/python/valuecell/agents/common/trading/execution/ccxt_trading.py b/python/valuecell/agents/common/trading/execution/ccxt_trading.py index 0e904f80b..c18bbabfe 100644 --- a/python/valuecell/agents/common/trading/execution/ccxt_trading.py +++ b/python/valuecell/agents/common/trading/execution/ccxt_trading.py @@ -1280,6 +1280,16 @@ async def _exec_noop(self, inst: TradeInstruction) -> TxResult: meta=inst.meta, ) + async def test_connection(self) -> bool: + """Test connectivity and authentication with the exchange.""" + try: + # Attempt to fetch balance which requires authentication + await self.fetch_balance() + return True + except Exception as e: + logger.warning(f"⚠️ Connection test failed for {self.exchange_id}: {e}") + return False + async def close(self) -> None: """Close the exchange connection and cleanup resources.""" if self._exchange is not None: diff --git a/python/valuecell/agents/common/trading/execution/interfaces.py b/python/valuecell/agents/common/trading/execution/interfaces.py index 72021c3b7..e269f58c3 100644 --- a/python/valuecell/agents/common/trading/execution/interfaces.py +++ b/python/valuecell/agents/common/trading/execution/interfaces.py @@ -32,6 +32,15 @@ async def execute( raise NotImplementedError + @abstractmethod + async def test_connection(self) -> bool: + """Test the connection to the exchange/broker. + + Returns: + True if connection is successful and credentials are valid, False otherwise. + """ + raise NotImplementedError + @abstractmethod async def close(self) -> None: """Close the gateway and release any held resources. diff --git a/python/valuecell/server/api/routers/strategy_agent.py b/python/valuecell/server/api/routers/strategy_agent.py index 48142b8c4..17f622c34 100644 --- a/python/valuecell/server/api/routers/strategy_agent.py +++ b/python/valuecell/server/api/routers/strategy_agent.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Session from valuecell.agents.common.trading.models import ( + ExchangeConfig, StrategyStatus, StrategyStatusContent, StrategyType, @@ -315,6 +316,53 @@ async def create_strategy_agent( strategy_id=fallback_strategy_id, status=StrategyStatus.ERROR ) + @router.post("/test-connection") + async def test_exchange_connection(request: ExchangeConfig): + """Test connection to the exchange with provided credentials.""" + try: + # If virtual trading, just return success immediately + if getattr(request, "trading_mode", None) == "virtual": + return SuccessResponse.create(msg="Success!") + + from valuecell.agents.common.trading.execution.ccxt_trading import ( + create_ccxt_gateway, + ) + + # Map ExchangeConfig fields to gateway args + # Note: ExchangeConfig might differ slightly from create_ccxt_gateway args + gateway = await create_ccxt_gateway( + exchange_id=request.exchange_id, + api_key=request.api_key or "", + secret_key=request.secret_key or "", + passphrase=request.passphrase, + wallet_address=request.wallet_address, + private_key=request.private_key, + # Ensure we pass a safe default for required args if missing in config + market_type="swap", # Default to swap/perpetual for testing + ) + + try: + is_connected = await gateway.test_connection() + if is_connected: + return SuccessResponse.create(msg="Success!") + else: + # Return 200 with error message or 400? User asked for "Failed..." return + # We'll throw 400 for UI to catch, or return success=False in body + # But SuccessResponse implies 200. + # If I raise HTTPException it shows as error. + raise HTTPException( + status_code=400, detail="Failed, please check your API key" + ) + finally: + await gateway.close() + + except Exception as e: + # If create_ccxt_gateway fails or other error + logger.warning(f"Connection test failed: {e}") + raise HTTPException( + status_code=400, detail=f"Failed, please check your API key: {str(e)}" + ) + @router.delete("/delete") async def delete_strategy_agent( id: str = Query(..., description="Strategy ID"),