mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-09 22:10:57 +08:00
feat: unify autopilot launch on server preflight with live balance polling
- New shared launch module (web/src/lib/launch): preflight client, model/ exchange resolution, and a single launchAutopilot orchestrator used by both Strategy Studio and the guided launch panel — the two previously duplicated and divergent launch implementations are gone. - Preflight runs BEFORE any strategy mutation, so a failed launch no longer rewrites/activates the strategy as a side effect; readiness and minimums come from the server (live balances) instead of stale client caches. - Guided panel polls preflight every 20s so deposits are detected without manual refresh; step metas show live balances and required minimums; the beginner wallet page polls its balance while the deposit screen is open. - Every failure now routes to a guided fix: hyperliquid-funds gets its own setup anchor (scrolls to the launch panel), start rejections surface the server's preflight reason (including manual restarts from the trader list), and structured error bodies flow through ApiError.errorData. - Terminal dashboard shows a persistent runtime banner when the AI fee wallet runs low/empty or the trader enters safe mode. - Vitest coverage for setup-target routing, readiness helpers, and the launch orchestration order (no side effects before preflight).
This commit is contained in:
@@ -303,6 +303,24 @@ export function TerminalDashboard({
|
||||
navSlot,
|
||||
)}
|
||||
<div className="tm-box" style={{ maxWidth: 1280, margin: '0 auto', border: 'none' }}>
|
||||
{/* runtime health banner — AI fee wallet dry / safe mode would otherwise
|
||||
only be visible in server logs while the bot silently idles */}
|
||||
{!on && status && (status.safe_mode || status.ai_wallet_status === 'empty' || status.ai_wallet_status === 'low') && (
|
||||
<div className="tm-mono" style={{ display: 'flex', gap: 10, alignItems: 'center', margin: '8px 14px 0', padding: '8px 12px', fontSize: 11, border: '1px solid var(--tm-down)', color: 'var(--tm-down)', background: 'rgba(200,60,40,0.06)', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
{status.ai_wallet_status === 'empty'
|
||||
? 'AI fee wallet is out of USDC — decisions are failing.'
|
||||
: status.ai_wallet_status === 'low'
|
||||
? `AI fee wallet is low (${(status.ai_wallet_balance_usdc ?? 0).toFixed(2)} USDC) — top up soon.`
|
||||
: 'Safe mode: AI failed repeatedly, no new positions are being opened.'}
|
||||
</span>
|
||||
<span style={{ color: 'var(--tm-ink-2)' }}>
|
||||
{status.ai_wallet_status === 'empty' || status.ai_wallet_status === 'low'
|
||||
? 'Deposit Base USDC to the Claw402 wallet, the trader recovers automatically.'
|
||||
: status.safe_mode_reason || ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* config / identity strip — first row, flows directly under the global nav */}
|
||||
<div className="tm-mono" style={{ display: 'flex', gap: 16, padding: '6px 14px', fontSize: 11, color: 'var(--tm-ink-2)', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontWeight: 500 }}>{selectedTrader?.trader_name ?? 'NOFX'}</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import useSWR from 'swr'
|
||||
import { api } from '../../lib/api'
|
||||
import { ApiError } from '../../lib/httpClient'
|
||||
import type {
|
||||
TraderInfo,
|
||||
CreateTraderRequest,
|
||||
@@ -305,7 +306,15 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
|
||||
await mutateTraders()
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle trader:', error)
|
||||
// Launch preflight rejections carry an actionable reason (e.g. "AI fee
|
||||
// wallet is empty") — show it instead of a generic failure toast.
|
||||
if (
|
||||
error instanceof ApiError &&
|
||||
error.errorKey === 'trader.start.preflight_failed'
|
||||
) {
|
||||
toast.error(error.message)
|
||||
return
|
||||
}
|
||||
toast.error(t('operationFailed', language))
|
||||
}
|
||||
}
|
||||
@@ -662,6 +671,15 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
handleOpenClaw402Config()
|
||||
} else if (setupTarget === 'hyperliquid') {
|
||||
handleOpenHyperliquidConfig()
|
||||
} else if (setupTarget === 'hyperliquid-funds') {
|
||||
// Funding shortfall: bring the guided launch panel (with the trading
|
||||
// balance step and live preflight polling) into view.
|
||||
document
|
||||
.getElementById('autopilot-launch-panel')
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
toast.info(
|
||||
'Deposit USDC to your Hyperliquid account, the balance check updates automatically.'
|
||||
)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ import {
|
||||
import { toast } from 'sonner'
|
||||
import { api } from '../../lib/api'
|
||||
import { buildDashboardPath, ROUTES } from '../../router/paths'
|
||||
import {
|
||||
ensureClaw402Strategy,
|
||||
launchAutopilot,
|
||||
} from '../../lib/launch/launchAutopilot'
|
||||
import { runLaunchPreflight } from '../../lib/launch/preflight'
|
||||
import type { LaunchPreflightResult } from '../../lib/launch/types'
|
||||
import type {
|
||||
AIModel,
|
||||
CurrentBeginnerWalletResponse,
|
||||
@@ -219,13 +225,6 @@ export function AutopilotLaunchPanel({
|
||||
[models]
|
||||
)
|
||||
|
||||
const feeWalletAddress = claw402Model?.walletAddress || wallet?.address || ''
|
||||
const feeWalletBalance = parseNumber(
|
||||
claw402Model?.balanceUsdc || wallet?.balance_usdc
|
||||
)
|
||||
const feeReady =
|
||||
Boolean(feeWalletAddress) && feeWalletBalance >= MIN_AI_FEE_USDC
|
||||
|
||||
const hyperliquidExchange = useMemo(
|
||||
() =>
|
||||
exchanges.find(
|
||||
@@ -238,17 +237,80 @@ export function AutopilotLaunchPanel({
|
||||
[exchanges]
|
||||
)
|
||||
|
||||
// Any hyperliquid account (even partially configured) is enough for the
|
||||
// server preflight — it reports exactly which prerequisite is missing.
|
||||
const preflightExchange = useMemo(
|
||||
() =>
|
||||
hyperliquidExchange ||
|
||||
exchanges.find((exchange) => exchange.exchange_type === 'hyperliquid') ||
|
||||
null,
|
||||
[exchanges, hyperliquidExchange]
|
||||
)
|
||||
|
||||
// Server-side preflight is the source of truth for balances: it queries the
|
||||
// chain / exchange live (30s server cache) instead of trusting the balance
|
||||
// snapshot cached in the model object. Poll while the panel is visible so
|
||||
// deposits show up without a manual refresh.
|
||||
const [preflight, setPreflight] = useState<LaunchPreflightResult | null>(null)
|
||||
const claw402ModelId = claw402Model?.id
|
||||
const preflightExchangeId = preflightExchange?.id
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn || !claw402ModelId || !preflightExchangeId) {
|
||||
setPreflight(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const check = async () => {
|
||||
try {
|
||||
const result = await runLaunchPreflight({
|
||||
ai_model_id: claw402ModelId,
|
||||
exchange_id: preflightExchangeId,
|
||||
})
|
||||
if (!cancelled) setPreflight(result)
|
||||
} catch {
|
||||
// keep the last known result; client-derived fallbacks still render
|
||||
}
|
||||
}
|
||||
void check()
|
||||
const timer = setInterval(() => void check(), 20000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [isLoggedIn, claw402ModelId, preflightExchangeId])
|
||||
|
||||
const preflightCheck = (id: string) =>
|
||||
preflight?.checks.find((check) => check.id === id)
|
||||
|
||||
const feeWalletAddress =
|
||||
claw402Model?.walletAddress ||
|
||||
wallet?.address ||
|
||||
preflightCheck('ai_wallet')?.address ||
|
||||
''
|
||||
const feeFundsCheck = preflightCheck('ai_wallet_funds')
|
||||
const feeWalletBalance =
|
||||
feeFundsCheck?.actual ??
|
||||
parseNumber(claw402Model?.balanceUsdc || wallet?.balance_usdc)
|
||||
const minAIFeeUSDC = preflight?.min_ai_fee_usdc ?? MIN_AI_FEE_USDC
|
||||
const feeReady = feeFundsCheck
|
||||
? feeFundsCheck.status !== 'failed' && Boolean(feeWalletAddress)
|
||||
: Boolean(feeWalletAddress) && feeWalletBalance >= minAIFeeUSDC
|
||||
|
||||
const hyperliquidConnected = Boolean(hyperliquidExchange)
|
||||
const exchangeState = hyperliquidExchange
|
||||
? exchangeAccountStates[hyperliquidExchange.id]
|
||||
: undefined
|
||||
const tradingBalance = parseNumber(
|
||||
exchangeState?.available_balance ?? exchangeState?.total_equity
|
||||
)
|
||||
const accountCheck = preflightCheck('exchange_account')
|
||||
const tradingFundsCheck = preflightCheck('exchange_funds')
|
||||
const tradingBalance =
|
||||
tradingFundsCheck?.actual ??
|
||||
parseNumber(exchangeState?.available_balance ?? exchangeState?.total_equity)
|
||||
const minTradingUSDC = preflight?.min_trading_usdc ?? MIN_TRADING_USDC
|
||||
const tradingBalanceReady =
|
||||
hyperliquidConnected &&
|
||||
exchangeState?.status === 'ok' &&
|
||||
tradingBalance >= MIN_TRADING_USDC
|
||||
(accountCheck && tradingFundsCheck
|
||||
? accountCheck.status === 'ok' && tradingFundsCheck.status !== 'failed'
|
||||
: exchangeState?.status === 'ok' && tradingBalance >= minTradingUSDC)
|
||||
|
||||
const autopilotTrader = useMemo(
|
||||
() =>
|
||||
@@ -286,90 +348,39 @@ export function AutopilotLaunchPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const ensureClaw402Strategy = async () => {
|
||||
const strategies = await api.getStrategies()
|
||||
const existing =
|
||||
strategies.find(
|
||||
(strategy) =>
|
||||
strategy.is_active &&
|
||||
strategy.config?.ai_config?.coin_source?.source_type ===
|
||||
'vergex_signal'
|
||||
) ||
|
||||
strategies.find((strategy) =>
|
||||
strategy.name.toLowerCase().includes('claw402')
|
||||
)
|
||||
|
||||
if (existing) {
|
||||
if (!existing.is_active) {
|
||||
await api.activateStrategy(existing.id)
|
||||
}
|
||||
return existing.id
|
||||
}
|
||||
|
||||
const config = await api.getDefaultStrategyConfig()
|
||||
const created = await api.createStrategy({
|
||||
name: 'NOFX Claw402 Auto Strategy',
|
||||
description:
|
||||
'Single built-in strategy: Claw402 board, per-symbol details, raw candles, then execution.',
|
||||
config,
|
||||
})
|
||||
if (created?.id) {
|
||||
await api.activateStrategy(created.id)
|
||||
return created.id
|
||||
}
|
||||
|
||||
const refreshed = await api.getStrategies()
|
||||
const fallback = refreshed.find((strategy) =>
|
||||
strategy.name.toLowerCase().includes('claw402')
|
||||
)
|
||||
if (!fallback) throw new Error('Failed to create Claw402 strategy')
|
||||
await api.activateStrategy(fallback.id)
|
||||
return fallback.id
|
||||
}
|
||||
|
||||
const launchAutopilot = async () => {
|
||||
const handleLaunch = async () => {
|
||||
if (!claw402Model || !hyperliquidExchange) return
|
||||
setLaunching(true)
|
||||
try {
|
||||
// Re-fetch the live trader list before deciding. The `traders` prop can be
|
||||
// stale (open tab serving an old snapshot), which would make us create a
|
||||
// duplicate "NOFX Autopilot" — paying the ~35s first-create cost again and
|
||||
// orphaning the dashboard onto a deleted id (the "API Not Found" 404). Match
|
||||
// the same way the autopilotTrader memo does, falling back to it on failure.
|
||||
let trader = autopilotTrader
|
||||
try {
|
||||
const fresh = await api.getTraders(true)
|
||||
trader =
|
||||
fresh.find((t) => t.trader_name === 'NOFX Autopilot') ||
|
||||
fresh.find((t) =>
|
||||
(t.strategy_name || '').toLowerCase().includes('claw402')
|
||||
) ||
|
||||
trader
|
||||
} catch {
|
||||
// network hiccup — keep the prop-derived trader rather than risk a dup
|
||||
// Shared launch path (same as Strategy Studio): server preflight with
|
||||
// fresh balances first, then strategy provisioning, then create/start.
|
||||
const outcome = await launchAutopilot({
|
||||
ensureStrategy: ensureClaw402Strategy,
|
||||
scanIntervalMinutes: 5,
|
||||
})
|
||||
|
||||
if (!outcome.ok) {
|
||||
toast.error(outcome.message)
|
||||
if (outcome.kind === 'preflight') {
|
||||
setPreflight(outcome.preflight)
|
||||
}
|
||||
if (outcome.kind !== 'error') {
|
||||
if (outcome.setupTarget === 'claw402') {
|
||||
onOpenClaw402Config?.()
|
||||
} else if (outcome.setupTarget === 'hyperliquid') {
|
||||
onOpenHyperliquidConfig?.()
|
||||
}
|
||||
}
|
||||
await refreshEverything()
|
||||
return
|
||||
}
|
||||
if (!trader) {
|
||||
const strategyId = await ensureClaw402Strategy()
|
||||
trader = await api.createTrader({
|
||||
name: 'NOFX Autopilot',
|
||||
ai_model_id: claw402Model.id,
|
||||
exchange_id: hyperliquidExchange.id,
|
||||
strategy_id: strategyId,
|
||||
scan_interval_minutes: 5,
|
||||
is_cross_margin: true,
|
||||
show_in_competition: true,
|
||||
btc_eth_leverage: 10,
|
||||
altcoin_leverage: 10,
|
||||
})
|
||||
}
|
||||
if (!trader.is_running) {
|
||||
await api.startTrader(trader.trader_id)
|
||||
|
||||
if (outcome.warning) {
|
||||
toast.warning(outcome.warning)
|
||||
}
|
||||
await onRefresh()
|
||||
toast.success('NOFX Autopilot is running')
|
||||
navigate(buildDashboardPath(trader.trader_id))
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Launch failed')
|
||||
navigate(buildDashboardPath(outcome.traderId))
|
||||
} finally {
|
||||
setLaunching(false)
|
||||
}
|
||||
@@ -388,7 +399,9 @@ export function AutopilotLaunchPanel({
|
||||
'Pays Claw402.ai data and model calls with Base USDC. This is separate from trading collateral.',
|
||||
status: feeReady ? 'ready' : 'action',
|
||||
meta: feeWalletAddress
|
||||
? `${shortAddress(feeWalletAddress)} · ${formatUSDC(feeWalletBalance)} USDC`
|
||||
? `${shortAddress(feeWalletAddress)} · ${formatUSDC(feeWalletBalance)} USDC${
|
||||
feeReady ? '' : ` · needs ≥ ${minAIFeeUSDC} USDC`
|
||||
}`
|
||||
: 'Base USDC wallet required',
|
||||
action: feeWalletAddress ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -449,7 +462,9 @@ export function AutopilotLaunchPanel({
|
||||
? 'action'
|
||||
: 'blocked',
|
||||
meta: hyperliquidConnected
|
||||
? `${formatUSDC(tradingBalance)} USDC available`
|
||||
? `${formatUSDC(tradingBalance)} USDC available${
|
||||
tradingBalanceReady ? '' : ` · needs ≥ ${minTradingUSDC} USDC`
|
||||
}`
|
||||
: 'Connect Hyperliquid first',
|
||||
},
|
||||
{
|
||||
@@ -538,7 +553,7 @@ export function AutopilotLaunchPanel({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={launchAutopilot}
|
||||
onClick={() => void handleLaunch()}
|
||||
disabled={launching || !allReady}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-nofx-gold px-4 py-3 text-sm font-bold text-white hover:bg-nofx-accent disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
@@ -553,7 +568,10 @@ export function AutopilotLaunchPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-xl border border-nofx-gold/20 bg-nofx-bg-lighter">
|
||||
<section
|
||||
id="autopilot-launch-panel"
|
||||
className="overflow-hidden rounded-xl border border-nofx-gold/20 bg-nofx-bg-lighter"
|
||||
>
|
||||
<div className="grid gap-0 xl:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="p-5 md:p-6">
|
||||
<div className="mb-5 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
|
||||
@@ -10,9 +10,10 @@ function throwApiError(
|
||||
message: string,
|
||||
errorKey?: string,
|
||||
errorParams?: Record<string, string>,
|
||||
statusCode?: number
|
||||
statusCode?: number,
|
||||
errorData?: Record<string, any>
|
||||
): never {
|
||||
throw new ApiError(message, errorKey, errorParams, statusCode)
|
||||
throw new ApiError(message, errorKey, errorParams, statusCode, errorData)
|
||||
}
|
||||
|
||||
export const traderApi = {
|
||||
@@ -61,7 +62,8 @@ export const traderApi = {
|
||||
result.message || 'Failed to start trader',
|
||||
result.errorKey,
|
||||
result.errorParams,
|
||||
result.statusCode
|
||||
result.statusCode,
|
||||
result.errorData
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -22,24 +22,29 @@ export interface ApiResponse<T = any> {
|
||||
errorKey?: string
|
||||
errorParams?: Record<string, string>
|
||||
statusCode?: number
|
||||
/** Full error response body for endpoints that return structured details. */
|
||||
errorData?: Record<string, any>
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
errorKey?: string
|
||||
errorParams?: Record<string, string>
|
||||
statusCode?: number
|
||||
errorData?: Record<string, any>
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
errorKey?: string,
|
||||
errorParams?: Record<string, string>,
|
||||
statusCode?: number
|
||||
statusCode?: number,
|
||||
errorData?: Record<string, any>
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.errorKey = errorKey
|
||||
this.errorParams = errorParams
|
||||
this.statusCode = statusCode
|
||||
this.errorData = errorData
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +259,8 @@ export class HttpClient {
|
||||
errorKey: errorData?.error_key,
|
||||
errorParams: errorData?.error_params,
|
||||
statusCode: error.response.status,
|
||||
errorData:
|
||||
errorData && typeof errorData === 'object' ? errorData : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
146
web/src/lib/launch/launch.test.ts
Normal file
146
web/src/lib/launch/launch.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
describeLaunchFailures,
|
||||
failedLaunchChecks,
|
||||
primarySetupTarget,
|
||||
setupTargetForCheck,
|
||||
} from './preflight'
|
||||
import { pickTradingExchange, pickTradingModel } from './resolve'
|
||||
import type { LaunchCheck, LaunchPreflightResult } from './types'
|
||||
import type { AIModel, Exchange } from '../../types'
|
||||
|
||||
vi.mock('../api', () => ({ api: {} }))
|
||||
vi.mock('../api/helpers', () => ({
|
||||
API_BASE: '',
|
||||
httpClient: { get: vi.fn(), post: vi.fn() },
|
||||
}))
|
||||
|
||||
function preflightResult(checks: LaunchCheck[]): LaunchPreflightResult {
|
||||
return {
|
||||
ready: checks.every((check) => check.status !== 'failed'),
|
||||
checks,
|
||||
min_ai_fee_usdc: 1,
|
||||
min_trading_usdc: 12,
|
||||
checked_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('setupTargetForCheck', () => {
|
||||
it('routes AI wallet problems to claw402 setup', () => {
|
||||
expect(setupTargetForCheck({ id: 'ai_model', status: 'failed' })).toBe(
|
||||
'claw402'
|
||||
)
|
||||
expect(setupTargetForCheck({ id: 'ai_wallet', status: 'failed' })).toBe(
|
||||
'claw402'
|
||||
)
|
||||
expect(
|
||||
setupTargetForCheck({ id: 'ai_wallet_funds', status: 'failed' })
|
||||
).toBe('claw402')
|
||||
})
|
||||
|
||||
it('routes exchange config/account problems to hyperliquid setup', () => {
|
||||
expect(
|
||||
setupTargetForCheck({ id: 'exchange_config', status: 'failed' })
|
||||
).toBe('hyperliquid')
|
||||
expect(
|
||||
setupTargetForCheck({ id: 'exchange_account', status: 'failed' })
|
||||
).toBe('hyperliquid')
|
||||
})
|
||||
|
||||
it('routes funding shortfalls to the funds anchor', () => {
|
||||
expect(
|
||||
setupTargetForCheck({ id: 'exchange_funds', status: 'failed' })
|
||||
).toBe('hyperliquid-funds')
|
||||
})
|
||||
|
||||
it('has no anchor for strategy problems', () => {
|
||||
expect(setupTargetForCheck({ id: 'strategy', status: 'failed' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('primarySetupTarget', () => {
|
||||
it('returns the anchor for the first failing check', () => {
|
||||
const result = preflightResult([
|
||||
{ id: 'ai_model', status: 'ok' },
|
||||
{ id: 'ai_wallet_funds', status: 'failed', message: 'AI wallet empty.' },
|
||||
{ id: 'exchange_funds', status: 'failed', message: 'Low margin.' },
|
||||
])
|
||||
expect(primarySetupTarget(result)).toBe('claw402')
|
||||
})
|
||||
|
||||
it('returns null when everything passes', () => {
|
||||
const result = preflightResult([
|
||||
{ id: 'ai_model', status: 'ok' },
|
||||
{ id: 'exchange_funds', status: 'warning' },
|
||||
])
|
||||
expect(primarySetupTarget(result)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeLaunchFailures / failedLaunchChecks', () => {
|
||||
it('collects only failed checks and joins their messages', () => {
|
||||
const result = preflightResult([
|
||||
{ id: 'ai_wallet_funds', status: 'failed', message: 'AI wallet empty.' },
|
||||
{ id: 'exchange_funds', status: 'warning', message: 'Testnet funds.' },
|
||||
{ id: 'exchange_account', status: 'failed', message: 'Bad key.' },
|
||||
{ id: 'strategy', status: 'skipped' },
|
||||
])
|
||||
expect(failedLaunchChecks(result)).toHaveLength(2)
|
||||
expect(describeLaunchFailures(result)).toBe('AI wallet empty. Bad key.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickTradingModel', () => {
|
||||
const base: Partial<AIModel> = { enabled: true }
|
||||
|
||||
it('prefers claw402 over other enabled models', () => {
|
||||
const models = [
|
||||
{ ...base, id: 'openai', provider: 'openai', has_api_key: true },
|
||||
{ ...base, id: 'c402', provider: 'claw402', has_api_key: true },
|
||||
] as AIModel[]
|
||||
expect(pickTradingModel(models)?.id).toBe('c402')
|
||||
})
|
||||
|
||||
it('accepts a claw402 model with only a wallet address', () => {
|
||||
const models = [
|
||||
{ ...base, id: 'c402', provider: 'claw402', walletAddress: '0xabc' },
|
||||
] as AIModel[]
|
||||
expect(pickTradingModel(models)?.id).toBe('c402')
|
||||
})
|
||||
|
||||
it('returns null when nothing usable exists', () => {
|
||||
const models = [
|
||||
{ id: 'x', provider: 'openai', enabled: false, has_api_key: true },
|
||||
{ id: 'y', provider: 'deepseek', enabled: true },
|
||||
] as AIModel[]
|
||||
expect(pickTradingModel(models)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickTradingExchange', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('requires enabled + key + builder approval + wallet address', () => {
|
||||
const ready = {
|
||||
id: 'hl',
|
||||
exchange_type: 'hyperliquid',
|
||||
enabled: true,
|
||||
has_api_key: true,
|
||||
hyperliquidBuilderApproved: true,
|
||||
hyperliquidWalletAddr: '0x1',
|
||||
} as unknown as Exchange
|
||||
expect(pickTradingExchange([ready])?.id).toBe('hl')
|
||||
|
||||
const unapproved = {
|
||||
...ready,
|
||||
hyperliquidBuilderApproved: false,
|
||||
} as unknown as Exchange
|
||||
expect(pickTradingExchange([unapproved])).toBeNull()
|
||||
|
||||
const noAddr = {
|
||||
...ready,
|
||||
hyperliquidWalletAddr: ' ',
|
||||
} as unknown as Exchange
|
||||
expect(pickTradingExchange([noAddr])).toBeNull()
|
||||
})
|
||||
})
|
||||
172
web/src/lib/launch/launchAutopilot.test.ts
Normal file
172
web/src/lib/launch/launchAutopilot.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { launchAutopilot } from './launchAutopilot'
|
||||
import { ApiError } from '../httpClient'
|
||||
import type { LaunchPreflightResult } from './types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
api: {
|
||||
getTraders: vi.fn(),
|
||||
createTrader: vi.fn(),
|
||||
updateTrader: vi.fn(),
|
||||
startTrader: vi.fn(),
|
||||
},
|
||||
runLaunchPreflight: vi.fn(),
|
||||
resolveLaunchModel: vi.fn(),
|
||||
resolveLaunchExchange: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../api', () => ({ api: mocks.api }))
|
||||
vi.mock('./preflight', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./preflight')>()),
|
||||
runLaunchPreflight: mocks.runLaunchPreflight,
|
||||
}))
|
||||
vi.mock('./resolve', () => ({
|
||||
resolveLaunchModel: mocks.resolveLaunchModel,
|
||||
resolveLaunchExchange: mocks.resolveLaunchExchange,
|
||||
}))
|
||||
|
||||
function readyPreflight(): LaunchPreflightResult {
|
||||
return {
|
||||
ready: true,
|
||||
checks: [],
|
||||
min_ai_fee_usdc: 1,
|
||||
min_trading_usdc: 12,
|
||||
checked_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function failedPreflight(): LaunchPreflightResult {
|
||||
return {
|
||||
ready: false,
|
||||
checks: [
|
||||
{
|
||||
id: 'ai_wallet_funds',
|
||||
status: 'failed',
|
||||
code: 'AI_WALLET_INSUFFICIENT_FUNDS',
|
||||
message: 'AI wallet needs 1 USDC.',
|
||||
},
|
||||
],
|
||||
min_ai_fee_usdc: 1,
|
||||
min_trading_usdc: 12,
|
||||
checked_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('launchAutopilot', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.resolveLaunchModel.mockResolvedValue({ id: 'model-1' })
|
||||
mocks.resolveLaunchExchange.mockResolvedValue({
|
||||
exchange: { id: 'ex-1' },
|
||||
})
|
||||
mocks.api.getTraders.mockResolvedValue([])
|
||||
mocks.api.createTrader.mockResolvedValue({
|
||||
trader_id: 't-1',
|
||||
is_running: false,
|
||||
})
|
||||
mocks.api.startTrader.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('never touches the strategy when preflight fails', async () => {
|
||||
mocks.runLaunchPreflight.mockResolvedValue(failedPreflight())
|
||||
const ensureStrategy = vi.fn()
|
||||
|
||||
const outcome = await launchAutopilot({ ensureStrategy })
|
||||
|
||||
expect(ensureStrategy).not.toHaveBeenCalled()
|
||||
expect(mocks.api.createTrader).not.toHaveBeenCalled()
|
||||
expect(outcome.ok).toBe(false)
|
||||
if (outcome.ok || outcome.kind !== 'preflight') {
|
||||
throw new Error('expected a preflight failure outcome')
|
||||
}
|
||||
expect(outcome.setupTarget).toBe('claw402')
|
||||
expect(outcome.message).toContain('AI wallet needs 1 USDC.')
|
||||
})
|
||||
|
||||
it('creates and starts the trader after preflight passes', async () => {
|
||||
mocks.runLaunchPreflight.mockResolvedValue(readyPreflight())
|
||||
const ensureStrategy = vi.fn().mockResolvedValue('strat-1')
|
||||
|
||||
const outcome = await launchAutopilot({
|
||||
ensureStrategy,
|
||||
scanIntervalMinutes: 15,
|
||||
})
|
||||
|
||||
expect(ensureStrategy).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.api.createTrader).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
strategy_id: 'strat-1',
|
||||
scan_interval_minutes: 15,
|
||||
ai_model_id: 'model-1',
|
||||
exchange_id: 'ex-1',
|
||||
})
|
||||
)
|
||||
expect(mocks.api.startTrader).toHaveBeenCalledWith('t-1')
|
||||
expect(outcome).toEqual(
|
||||
expect.objectContaining({ ok: true, traderId: 't-1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('updates the existing autopilot instead of creating a duplicate', async () => {
|
||||
mocks.runLaunchPreflight.mockResolvedValue(readyPreflight())
|
||||
mocks.api.getTraders.mockResolvedValue([
|
||||
{ trader_id: 't-old', trader_name: 'NOFX Autopilot', is_running: false },
|
||||
])
|
||||
mocks.api.updateTrader.mockResolvedValue({
|
||||
trader_id: 't-old',
|
||||
is_running: true,
|
||||
})
|
||||
|
||||
const outcome = await launchAutopilot({
|
||||
ensureStrategy: vi.fn().mockResolvedValue('strat-1'),
|
||||
})
|
||||
|
||||
expect(mocks.api.updateTrader).toHaveBeenCalled()
|
||||
expect(mocks.api.createTrader).not.toHaveBeenCalled()
|
||||
expect(mocks.api.startTrader).not.toHaveBeenCalled()
|
||||
expect(outcome).toEqual(
|
||||
expect.objectContaining({ ok: true, traderId: 't-old' })
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces the server-side preflight result when start is rejected', async () => {
|
||||
mocks.runLaunchPreflight.mockResolvedValue(readyPreflight())
|
||||
mocks.api.startTrader.mockRejectedValue(
|
||||
new ApiError(
|
||||
'preflight failed',
|
||||
'trader.start.preflight_failed',
|
||||
undefined,
|
||||
400,
|
||||
{ preflight: failedPreflight() }
|
||||
)
|
||||
)
|
||||
|
||||
const outcome = await launchAutopilot({
|
||||
ensureStrategy: vi.fn().mockResolvedValue('strat-1'),
|
||||
})
|
||||
|
||||
expect(outcome.ok).toBe(false)
|
||||
if (outcome.ok || outcome.kind !== 'preflight') {
|
||||
throw new Error('expected a preflight failure outcome')
|
||||
}
|
||||
expect(outcome.setupTarget).toBe('claw402')
|
||||
})
|
||||
|
||||
it('routes missing exchange setup to the hyperliquid anchor', async () => {
|
||||
mocks.resolveLaunchExchange.mockResolvedValue({
|
||||
exchange: null,
|
||||
reason: 'No Hyperliquid account is connected.',
|
||||
})
|
||||
|
||||
const outcome = await launchAutopilot({ ensureStrategy: vi.fn() })
|
||||
|
||||
expect(outcome).toEqual(
|
||||
expect.objectContaining({
|
||||
ok: false,
|
||||
kind: 'setup',
|
||||
setupTarget: 'hyperliquid',
|
||||
})
|
||||
)
|
||||
expect(mocks.runLaunchPreflight).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
178
web/src/lib/launch/launchAutopilot.ts
Normal file
178
web/src/lib/launch/launchAutopilot.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { api } from '../api'
|
||||
import { ApiError } from '../httpClient'
|
||||
import {
|
||||
describeLaunchFailures,
|
||||
primarySetupTarget,
|
||||
runLaunchPreflight,
|
||||
} from './preflight'
|
||||
import { resolveLaunchExchange, resolveLaunchModel } from './resolve'
|
||||
import type { LaunchOutcome } from './types'
|
||||
|
||||
export const AUTOPILOT_TRADER_NAME = 'NOFX Autopilot'
|
||||
|
||||
export interface LaunchAutopilotOptions {
|
||||
/**
|
||||
* Provides the strategy id to trade. Called only AFTER preflight passes so
|
||||
* a failed launch never mutates or activates a strategy as a side effect.
|
||||
*/
|
||||
ensureStrategy: () => Promise<string>
|
||||
scanIntervalMinutes?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The single Autopilot launch path shared by Strategy Studio and the guided
|
||||
* launch panel. Order matters: resolve → preflight (server-side, fresh
|
||||
* balances) → strategy → create/update trader → start. No side effects happen
|
||||
* before preflight passes.
|
||||
*/
|
||||
export async function launchAutopilot(
|
||||
options: LaunchAutopilotOptions
|
||||
): Promise<LaunchOutcome> {
|
||||
const { ensureStrategy, scanIntervalMinutes = 5 } = options
|
||||
|
||||
try {
|
||||
const model = await resolveLaunchModel()
|
||||
if (!model) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'setup',
|
||||
message:
|
||||
'No enabled AI model is ready. Create or fund the Claw402 wallet first.',
|
||||
setupTarget: 'claw402',
|
||||
}
|
||||
}
|
||||
|
||||
const exchangeResult = await resolveLaunchExchange()
|
||||
if (!exchangeResult.exchange) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'setup',
|
||||
message: exchangeResult.reason,
|
||||
setupTarget: 'hyperliquid',
|
||||
}
|
||||
}
|
||||
const exchange = exchangeResult.exchange
|
||||
|
||||
const preflight = await runLaunchPreflight({
|
||||
ai_model_id: model.id,
|
||||
exchange_id: exchange.id,
|
||||
})
|
||||
if (!preflight.ready) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'preflight',
|
||||
message:
|
||||
describeLaunchFailures(preflight) ||
|
||||
'Launch prerequisites are not ready yet.',
|
||||
preflight,
|
||||
setupTarget: primarySetupTarget(preflight),
|
||||
}
|
||||
}
|
||||
|
||||
const strategyId = await ensureStrategy()
|
||||
|
||||
const traderRequest = {
|
||||
name: AUTOPILOT_TRADER_NAME,
|
||||
ai_model_id: model.id,
|
||||
exchange_id: exchange.id,
|
||||
strategy_id: strategyId,
|
||||
scan_interval_minutes: scanIntervalMinutes,
|
||||
is_cross_margin: true,
|
||||
show_in_competition: true,
|
||||
btc_eth_leverage: 10,
|
||||
altcoin_leverage: 10,
|
||||
}
|
||||
|
||||
// Re-fetch the live trader list before deciding create vs update. Stale
|
||||
// props/snapshots would create a duplicate "NOFX Autopilot" — paying the
|
||||
// slow first-create cost again and orphaning dashboards onto a deleted id.
|
||||
const existingTraders = await api.getTraders(true)
|
||||
const existing =
|
||||
existingTraders.find(
|
||||
(trader) => trader.trader_name === AUTOPILOT_TRADER_NAME
|
||||
) ||
|
||||
existingTraders.find((trader) =>
|
||||
(trader.strategy_name || '').toLowerCase().includes('claw402')
|
||||
) ||
|
||||
null
|
||||
|
||||
const autopilot = existing
|
||||
? await api.updateTrader(existing.trader_id, traderRequest)
|
||||
: await api.createTrader(traderRequest)
|
||||
|
||||
if (!autopilot.is_running) {
|
||||
await api.startTrader(autopilot.trader_id)
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
traderId: autopilot.trader_id,
|
||||
warning: autopilot.startup_warning,
|
||||
}
|
||||
} catch (err) {
|
||||
// The server re-runs preflight on start; surface its structured result if
|
||||
// readiness changed between our check and the start call.
|
||||
if (err instanceof ApiError && err.errorKey === 'trader.start.preflight_failed') {
|
||||
const preflight = err.errorData?.preflight
|
||||
if (preflight) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'preflight',
|
||||
message: describeLaunchFailures(preflight) || err.message,
|
||||
preflight,
|
||||
setupTarget: primarySetupTarget(preflight),
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'error',
|
||||
message:
|
||||
err instanceof Error ? err.message : 'Failed to launch NOFX Autopilot',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default strategy provisioning for the guided panel: reuse the active
|
||||
* Claw402 strategy, otherwise create and activate it.
|
||||
*/
|
||||
export async function ensureClaw402Strategy(): Promise<string> {
|
||||
const strategies = await api.getStrategies()
|
||||
const existing =
|
||||
strategies.find(
|
||||
(strategy) =>
|
||||
strategy.is_active &&
|
||||
strategy.config?.ai_config?.coin_source?.source_type === 'vergex_signal'
|
||||
) ||
|
||||
strategies.find((strategy) =>
|
||||
strategy.name.toLowerCase().includes('claw402')
|
||||
)
|
||||
|
||||
if (existing) {
|
||||
if (!existing.is_active) {
|
||||
await api.activateStrategy(existing.id)
|
||||
}
|
||||
return existing.id
|
||||
}
|
||||
|
||||
const config = await api.getDefaultStrategyConfig()
|
||||
const created = await api.createStrategy({
|
||||
name: 'NOFX Claw402 Auto Strategy',
|
||||
description:
|
||||
'Single built-in strategy: Claw402 board, per-symbol details, raw candles, then execution.',
|
||||
config,
|
||||
})
|
||||
if (created?.id) {
|
||||
await api.activateStrategy(created.id)
|
||||
return created.id
|
||||
}
|
||||
|
||||
const refreshed = await api.getStrategies()
|
||||
const fallback = refreshed.find((strategy) =>
|
||||
strategy.name.toLowerCase().includes('claw402')
|
||||
)
|
||||
if (!fallback) throw new Error('Failed to create Claw402 strategy')
|
||||
await api.activateStrategy(fallback.id)
|
||||
return fallback.id
|
||||
}
|
||||
90
web/src/lib/launch/preflight.ts
Normal file
90
web/src/lib/launch/preflight.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { API_BASE, httpClient } from '../api/helpers'
|
||||
import type {
|
||||
LaunchCheck,
|
||||
LaunchPreflightResult,
|
||||
SetupTarget,
|
||||
} from './types'
|
||||
|
||||
export interface LaunchPreflightRequest {
|
||||
ai_model_id: string
|
||||
exchange_id: string
|
||||
strategy_id?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side launch readiness checks. This is the single source of truth for
|
||||
* balances and minimums — never gate the UI on cached client-side values.
|
||||
*/
|
||||
export async function runLaunchPreflight(
|
||||
request: LaunchPreflightRequest
|
||||
): Promise<LaunchPreflightResult> {
|
||||
const result = await httpClient.post<LaunchPreflightResult>(
|
||||
`${API_BASE}/launch/preflight`,
|
||||
request
|
||||
)
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.message || 'Failed to run launch preflight')
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
export async function getTraderPreflight(
|
||||
traderId: string
|
||||
): Promise<LaunchPreflightResult> {
|
||||
const result = await httpClient.get<LaunchPreflightResult>(
|
||||
`${API_BASE}/traders/${traderId}/preflight`
|
||||
)
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.message || 'Failed to run launch preflight')
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
export function failedLaunchChecks(
|
||||
result: LaunchPreflightResult
|
||||
): LaunchCheck[] {
|
||||
return result.checks.filter((check) => check.status === 'failed')
|
||||
}
|
||||
|
||||
export function launchWarnings(result: LaunchPreflightResult): LaunchCheck[] {
|
||||
return result.checks.filter((check) => check.status === 'warning')
|
||||
}
|
||||
|
||||
/** Human-readable one-liner combining every failing check. */
|
||||
export function describeLaunchFailures(result: LaunchPreflightResult): string {
|
||||
return failedLaunchChecks(result)
|
||||
.map((check) => check.message)
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a failing check to the guided-setup anchor that fixes it. The traders
|
||||
* page opens the matching modal/section from the `?setup=` query param.
|
||||
*/
|
||||
export function setupTargetForCheck(check: LaunchCheck): SetupTarget | null {
|
||||
switch (check.id) {
|
||||
case 'ai_model':
|
||||
case 'ai_wallet':
|
||||
case 'ai_wallet_funds':
|
||||
return 'claw402'
|
||||
case 'exchange_config':
|
||||
case 'exchange_account':
|
||||
return 'hyperliquid'
|
||||
case 'exchange_funds':
|
||||
return 'hyperliquid-funds'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** The setup anchor for the first (highest-priority) failing check. */
|
||||
export function primarySetupTarget(
|
||||
result: LaunchPreflightResult
|
||||
): SetupTarget | null {
|
||||
for (const check of failedLaunchChecks(result)) {
|
||||
const target = setupTargetForCheck(check)
|
||||
if (target) return target
|
||||
}
|
||||
return null
|
||||
}
|
||||
136
web/src/lib/launch/resolve.ts
Normal file
136
web/src/lib/launch/resolve.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { api } from '../api'
|
||||
import type { AIModel, Exchange } from '../../types'
|
||||
|
||||
export function modelHasCredential(model: AIModel) {
|
||||
return Boolean(
|
||||
model.has_api_key ||
|
||||
model.apiKey ||
|
||||
(model.provider === 'claw402' && model.walletAddress)
|
||||
)
|
||||
}
|
||||
|
||||
export function exchangeHasKey(exchange: Exchange) {
|
||||
return Boolean(exchange.has_api_key || exchange.apiKey)
|
||||
}
|
||||
|
||||
export function isHyperliquidExchange(exchange: Exchange) {
|
||||
return exchange.exchange_type === 'hyperliquid'
|
||||
}
|
||||
|
||||
/** Prefers the claw402 model, falls back to any enabled model with a credential. */
|
||||
export function pickTradingModel(models: AIModel[]) {
|
||||
return (
|
||||
models.find(
|
||||
(model) =>
|
||||
model.provider === 'claw402' &&
|
||||
model.enabled &&
|
||||
modelHasCredential(model)
|
||||
) ||
|
||||
models.find((model) => model.enabled && modelHasCredential(model)) ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
export function pickTradingExchange(exchanges: Exchange[]) {
|
||||
return (
|
||||
exchanges.find(
|
||||
(exchange) =>
|
||||
isHyperliquidExchange(exchange) &&
|
||||
exchange.enabled &&
|
||||
exchangeHasKey(exchange) &&
|
||||
Boolean(exchange.hyperliquidBuilderApproved) &&
|
||||
(exchange.hyperliquidWalletAddr || '').trim() !== ''
|
||||
) || null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a launch-capable AI model, auto-provisioning the beginner claw402
|
||||
* wallet when none is configured yet. Returns null when nothing could be
|
||||
* resolved — the caller routes the user into claw402 setup.
|
||||
*/
|
||||
export async function resolveLaunchModel(): Promise<AIModel | null> {
|
||||
let models = await api.getModelConfigs()
|
||||
let model = pickTradingModel(models)
|
||||
if (model) return model
|
||||
|
||||
const onboarding = await api.prepareBeginnerOnboarding()
|
||||
models = await api.getModelConfigs()
|
||||
model =
|
||||
models.find(
|
||||
(item) =>
|
||||
item.id === onboarding.configured_model_id &&
|
||||
item.enabled &&
|
||||
modelHasCredential(item)
|
||||
) || pickTradingModel(models)
|
||||
if (model) return model
|
||||
|
||||
if (onboarding.configured_model_id && onboarding.private_key) {
|
||||
await api.updateModelConfigs({
|
||||
models: {
|
||||
[onboarding.configured_model_id]: {
|
||||
enabled: true,
|
||||
api_key: onboarding.private_key,
|
||||
custom_api_url: '',
|
||||
custom_model_name: onboarding.default_model,
|
||||
},
|
||||
},
|
||||
})
|
||||
models = await api.getModelConfigs()
|
||||
model =
|
||||
models.find(
|
||||
(item) =>
|
||||
item.id === onboarding.configured_model_id &&
|
||||
item.enabled &&
|
||||
modelHasCredential(item)
|
||||
) || pickTradingModel(models)
|
||||
}
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a launch-capable exchange. Returns the exchange or a message
|
||||
* explaining the most specific missing prerequisite.
|
||||
*/
|
||||
export async function resolveLaunchExchange(): Promise<
|
||||
{ exchange: Exchange } | { exchange: null; reason: string }
|
||||
> {
|
||||
const exchanges = await api.getExchangeConfigs()
|
||||
const ready = pickTradingExchange(exchanges)
|
||||
if (ready) return { exchange: ready }
|
||||
|
||||
const hyperliquid = exchanges.find(isHyperliquidExchange)
|
||||
if (!hyperliquid) {
|
||||
return {
|
||||
exchange: null,
|
||||
reason:
|
||||
'No Hyperliquid account is connected. Connect Hyperliquid and authorize the NOFX agent first.',
|
||||
}
|
||||
}
|
||||
if (!hyperliquid.enabled) {
|
||||
return {
|
||||
exchange: null,
|
||||
reason: 'The Hyperliquid account is disabled. Enable it first.',
|
||||
}
|
||||
}
|
||||
if (!exchangeHasKey(hyperliquid)) {
|
||||
return {
|
||||
exchange: null,
|
||||
reason:
|
||||
'The Hyperliquid agent key is missing. Reconnect Hyperliquid and save the agent wallet.',
|
||||
}
|
||||
}
|
||||
if (!hyperliquid.hyperliquidBuilderApproved) {
|
||||
return {
|
||||
exchange: null,
|
||||
reason:
|
||||
'Hyperliquid builder authorization is not complete. Finish wallet authorization first.',
|
||||
}
|
||||
}
|
||||
return {
|
||||
exchange: null,
|
||||
reason:
|
||||
'The Hyperliquid wallet address is missing. Reconnect Hyperliquid first.',
|
||||
}
|
||||
}
|
||||
44
web/src/lib/launch/types.ts
Normal file
44
web/src/lib/launch/types.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export type LaunchCheckStatus = 'ok' | 'failed' | 'warning' | 'skipped'
|
||||
|
||||
export type LaunchCheckId =
|
||||
| 'ai_model'
|
||||
| 'ai_wallet'
|
||||
| 'ai_wallet_funds'
|
||||
| 'strategy'
|
||||
| 'exchange_config'
|
||||
| 'exchange_account'
|
||||
| 'exchange_funds'
|
||||
|
||||
export interface LaunchCheck {
|
||||
id: LaunchCheckId | string
|
||||
status: LaunchCheckStatus
|
||||
code?: string
|
||||
message?: string
|
||||
required?: number
|
||||
actual?: number
|
||||
asset?: string
|
||||
address?: string
|
||||
}
|
||||
|
||||
export interface LaunchPreflightResult {
|
||||
ready: boolean
|
||||
checks: LaunchCheck[]
|
||||
min_ai_fee_usdc: number
|
||||
min_trading_usdc: number
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
/** Guided-setup anchor consumed by the traders page (`?setup=`). */
|
||||
export type SetupTarget = 'claw402' | 'hyperliquid' | 'hyperliquid-funds'
|
||||
|
||||
export type LaunchOutcome =
|
||||
| { ok: true; traderId: string; warning?: string }
|
||||
| {
|
||||
ok: false
|
||||
kind: 'preflight'
|
||||
message: string
|
||||
preflight: LaunchPreflightResult
|
||||
setupTarget: SetupTarget | null
|
||||
}
|
||||
| { ok: false; kind: 'setup'; message: string; setupTarget: SetupTarget }
|
||||
| { ok: false; kind: 'error'; message: string }
|
||||
@@ -58,6 +58,34 @@ export function BeginnerOnboardingPage() {
|
||||
void loadOnboarding(true)
|
||||
}, [])
|
||||
|
||||
// Poll the balance while the user is depositing so the page updates on its
|
||||
// own. Uses the lightweight current-wallet endpoint (server caches 30s), not
|
||||
// the heavier prepare call that re-writes .env.
|
||||
const walletAddress = data?.address
|
||||
useEffect(() => {
|
||||
if (!walletAddress) return
|
||||
let cancelled = false
|
||||
const timer = setInterval(() => {
|
||||
void api
|
||||
.getCurrentBeginnerWallet()
|
||||
.then((wallet) => {
|
||||
if (cancelled || !wallet.found || !wallet.balance_usdc) return
|
||||
setData((prev) =>
|
||||
prev && prev.address === wallet.address
|
||||
? { ...prev, balance_usdc: wallet.balance_usdc! }
|
||||
: prev
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
// transient — the manual refresh button still works
|
||||
})
|
||||
}, 15000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [walletAddress])
|
||||
|
||||
const noticeText = useMemo(
|
||||
() =>
|
||||
isZh
|
||||
|
||||
@@ -27,12 +27,7 @@ import type {
|
||||
Strategy,
|
||||
StrategyConfig,
|
||||
} from '../types'
|
||||
import type {
|
||||
AIModel,
|
||||
CurrentBeginnerWalletResponse,
|
||||
Exchange,
|
||||
ExchangeAccountState,
|
||||
} from '../types'
|
||||
import { launchAutopilot } from '../lib/launch/launchAutopilot'
|
||||
import type {
|
||||
MarketSymbol,
|
||||
VergexHeatmapBin,
|
||||
@@ -80,85 +75,10 @@ const topNOptions = [5, 6, 7, 8, 9, 10]
|
||||
const detailBandOptions = ['5', '10', '15', '20']
|
||||
const claw402BoardLimit = 30
|
||||
const confidenceOptions = [65, 75, 82]
|
||||
const MIN_AI_FEE_USDC = 1
|
||||
const MIN_TRADING_USDC = 12
|
||||
|
||||
type LaunchSetupTarget = 'claw402' | 'hyperliquid' | 'hyperliquid-funds'
|
||||
type FeeWalletSnapshot = Pick<
|
||||
CurrentBeginnerWalletResponse,
|
||||
'address' | 'balance_usdc'
|
||||
>
|
||||
|
||||
class LaunchSetupError extends Error {
|
||||
target: LaunchSetupTarget
|
||||
|
||||
constructor(message: string, target: LaunchSetupTarget) {
|
||||
super(message)
|
||||
this.name = 'LaunchSetupError'
|
||||
this.target = target
|
||||
}
|
||||
}
|
||||
|
||||
const text = (language: string, zh: string, en: string) =>
|
||||
language === 'zh' ? zh : en
|
||||
|
||||
function modelHasCredential(model: AIModel) {
|
||||
return Boolean(
|
||||
model.has_api_key ||
|
||||
model.apiKey ||
|
||||
(model.provider === 'claw402' && model.walletAddress)
|
||||
)
|
||||
}
|
||||
|
||||
function parseUsdc(value?: string | number) {
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : 0
|
||||
if (!value) return 0
|
||||
const parsed = Number(value.replace(/[,$\s]/g, ''))
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
function pickTradingModel(models: AIModel[]) {
|
||||
return (
|
||||
models.find(
|
||||
(model) =>
|
||||
model.provider === 'claw402' &&
|
||||
model.enabled &&
|
||||
modelHasCredential(model)
|
||||
) ||
|
||||
models.find((model) => model.enabled && modelHasCredential(model)) ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
function isHyperliquidExchange(exchange: Exchange) {
|
||||
return exchange.exchange_type === 'hyperliquid'
|
||||
}
|
||||
|
||||
function hyperliquidWalletAddress(exchange: Exchange) {
|
||||
return exchange.hyperliquidWalletAddr || ''
|
||||
}
|
||||
|
||||
function pickTradingExchange(exchanges: Exchange[]) {
|
||||
return (
|
||||
exchanges.find(
|
||||
(exchange) =>
|
||||
isHyperliquidExchange(exchange) &&
|
||||
exchange.enabled &&
|
||||
modelHasExchangeKey(exchange) &&
|
||||
Boolean(exchange.hyperliquidBuilderApproved) &&
|
||||
hyperliquidWalletAddress(exchange).trim() !== ''
|
||||
) || null
|
||||
)
|
||||
}
|
||||
|
||||
function modelHasExchangeKey(exchange: Exchange) {
|
||||
return Boolean(exchange.has_api_key || exchange.apiKey)
|
||||
}
|
||||
|
||||
function exchangeAvailableUsdc(state?: ExchangeAccountState) {
|
||||
return parseUsdc(state?.available_balance ?? state?.total_equity)
|
||||
}
|
||||
|
||||
type Profile = 'careful' | 'balanced' | 'active'
|
||||
|
||||
const profileOptions: Array<{
|
||||
@@ -1484,236 +1404,48 @@ export function StrategyStudioPage() {
|
||||
return base
|
||||
}
|
||||
|
||||
const requireClaw402FeeWallet = async (
|
||||
model: AIModel,
|
||||
wallet?: FeeWalletSnapshot | null
|
||||
) => {
|
||||
if (model.provider !== 'claw402') return
|
||||
|
||||
const address = model.walletAddress || wallet?.address || ''
|
||||
const balance = parseUsdc(model.balanceUsdc ?? wallet?.balance_usdc)
|
||||
|
||||
if (!address.trim()) {
|
||||
throw new LaunchSetupError(
|
||||
'Claw402 wallet is not ready. Open the guided setup and create the Base USDC payment wallet first.',
|
||||
'claw402'
|
||||
)
|
||||
}
|
||||
|
||||
if (balance < MIN_AI_FEE_USDC) {
|
||||
throw new LaunchSetupError(
|
||||
`Claw402 wallet needs at least ${MIN_AI_FEE_USDC} USDC on Base before Autopilot can pay for data and model calls.`,
|
||||
'claw402'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const requireHyperliquidBalance = async (exchange: Exchange) => {
|
||||
let accountState: ExchangeAccountState | undefined
|
||||
try {
|
||||
const response = await api.getExchangeAccountState()
|
||||
accountState = response.states[exchange.id]
|
||||
} catch {
|
||||
throw new LaunchSetupError(
|
||||
'Could not verify the Hyperliquid trading balance. Open the guided setup and refresh the account state first.',
|
||||
'hyperliquid-funds'
|
||||
)
|
||||
}
|
||||
|
||||
if (!accountState) {
|
||||
throw new LaunchSetupError(
|
||||
'Could not verify the Hyperliquid trading balance. Open the guided setup and refresh the account state first.',
|
||||
'hyperliquid-funds'
|
||||
)
|
||||
}
|
||||
|
||||
if (accountState.status !== 'ok') {
|
||||
throw new LaunchSetupError(
|
||||
accountState.error_message ||
|
||||
'Hyperliquid account is not ready. Reconnect the wallet and approve the NOFX Agent.',
|
||||
'hyperliquid'
|
||||
)
|
||||
}
|
||||
|
||||
const balance = exchangeAvailableUsdc(accountState)
|
||||
if (balance < MIN_TRADING_USDC) {
|
||||
throw new LaunchSetupError(
|
||||
`Hyperliquid needs at least ${MIN_TRADING_USDC} USDC available before Autopilot can place its first trade.`,
|
||||
'hyperliquid-funds'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const resolveOneClickModel = async () => {
|
||||
let models = await api.getModelConfigs()
|
||||
let model = pickTradingModel(models)
|
||||
if (model) {
|
||||
let wallet: CurrentBeginnerWalletResponse | null = null
|
||||
try {
|
||||
wallet = await api.getCurrentBeginnerWallet()
|
||||
} catch {
|
||||
wallet = null
|
||||
}
|
||||
await requireClaw402FeeWallet(model, wallet)
|
||||
return model
|
||||
}
|
||||
|
||||
const onboarding = await api.prepareBeginnerOnboarding()
|
||||
models = await api.getModelConfigs()
|
||||
model =
|
||||
models.find(
|
||||
(item) =>
|
||||
item.id === onboarding.configured_model_id &&
|
||||
item.enabled &&
|
||||
modelHasCredential(item)
|
||||
) || pickTradingModel(models)
|
||||
|
||||
if (!model && onboarding.configured_model_id && onboarding.private_key) {
|
||||
await api.updateModelConfigs({
|
||||
models: {
|
||||
[onboarding.configured_model_id]: {
|
||||
enabled: true,
|
||||
api_key: onboarding.private_key,
|
||||
custom_api_url: '',
|
||||
custom_model_name: onboarding.default_model,
|
||||
},
|
||||
},
|
||||
})
|
||||
models = await api.getModelConfigs()
|
||||
model =
|
||||
models.find(
|
||||
(item) =>
|
||||
item.id === onboarding.configured_model_id &&
|
||||
item.enabled &&
|
||||
modelHasCredential(item)
|
||||
) || pickTradingModel(models)
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
throw new LaunchSetupError(
|
||||
'No enabled AI model is ready. Create or fund the Claw402 wallet first.',
|
||||
'claw402'
|
||||
)
|
||||
}
|
||||
|
||||
await requireClaw402FeeWallet(model, onboarding)
|
||||
return model
|
||||
}
|
||||
|
||||
const resolveOneClickExchange = async () => {
|
||||
const exchanges = await api.getExchangeConfigs()
|
||||
const exchange = pickTradingExchange(exchanges)
|
||||
if (exchange) {
|
||||
await requireHyperliquidBalance(exchange)
|
||||
return exchange
|
||||
}
|
||||
|
||||
const hyperliquid = exchanges.find(isHyperliquidExchange)
|
||||
if (!hyperliquid) {
|
||||
throw new LaunchSetupError(
|
||||
'No Hyperliquid account is ready. Connect Hyperliquid and authorize the NOFX agent first.',
|
||||
'hyperliquid'
|
||||
)
|
||||
}
|
||||
if (!hyperliquid.enabled) {
|
||||
throw new LaunchSetupError(
|
||||
'The Hyperliquid account is disabled. Enable it first.',
|
||||
'hyperliquid'
|
||||
)
|
||||
}
|
||||
if (!modelHasExchangeKey(hyperliquid)) {
|
||||
throw new LaunchSetupError(
|
||||
'The Hyperliquid agent key is missing. Reconnect Hyperliquid and save the agent wallet.',
|
||||
'hyperliquid'
|
||||
)
|
||||
}
|
||||
if (!hyperliquid.hyperliquidBuilderApproved) {
|
||||
throw new LaunchSetupError(
|
||||
'Hyperliquid builder authorization is not complete. Finish wallet authorization first.',
|
||||
'hyperliquid'
|
||||
)
|
||||
}
|
||||
if (!hyperliquidWalletAddress(hyperliquid).trim()) {
|
||||
throw new LaunchSetupError(
|
||||
'The Hyperliquid wallet address is missing. Reconnect Hyperliquid first.',
|
||||
'hyperliquid'
|
||||
)
|
||||
}
|
||||
|
||||
throw new LaunchSetupError(
|
||||
'No ready Hyperliquid account found. Check the wallet authorization.',
|
||||
'hyperliquid'
|
||||
)
|
||||
}
|
||||
|
||||
const startUnifiedClaw402Agent = async () => {
|
||||
if (!selectedStrategy) return
|
||||
const config = buildUnifiedClaw402Config()
|
||||
const traderName = 'NOFX Autopilot'
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
setEditingConfig(config)
|
||||
setHasChanges(true)
|
||||
|
||||
await api.updateStrategy(selectedStrategy.id, {
|
||||
name: selectedStrategy.name,
|
||||
description:
|
||||
selectedStrategy.description ||
|
||||
'Autonomous market selection powered by Claw402.ai Signal Lab, liquidation structure, and raw candles.',
|
||||
config,
|
||||
// The shared launcher runs the server-side preflight (fresh wallet and
|
||||
// exchange balances) BEFORE ensureStrategy, so a failed launch never
|
||||
// mutates or activates the strategy as a side effect.
|
||||
const outcome = await launchAutopilot({
|
||||
scanIntervalMinutes: 15,
|
||||
ensureStrategy: async () => {
|
||||
const config = buildUnifiedClaw402Config()
|
||||
setEditingConfig(config)
|
||||
await api.updateStrategy(selectedStrategy.id, {
|
||||
name: selectedStrategy.name,
|
||||
description:
|
||||
selectedStrategy.description ||
|
||||
'Autonomous market selection powered by Claw402.ai Signal Lab, liquidation structure, and raw candles.',
|
||||
config,
|
||||
})
|
||||
await api.activateStrategy(selectedStrategy.id)
|
||||
return selectedStrategy.id
|
||||
},
|
||||
})
|
||||
await api.activateStrategy(selectedStrategy.id)
|
||||
|
||||
const [model, exchange] = await Promise.all([
|
||||
resolveOneClickModel(),
|
||||
resolveOneClickExchange(),
|
||||
])
|
||||
|
||||
const traderRequest = {
|
||||
name: traderName,
|
||||
ai_model_id: model.id,
|
||||
exchange_id: exchange.id,
|
||||
strategy_id: selectedStrategy.id,
|
||||
scan_interval_minutes: 15,
|
||||
is_cross_margin: true,
|
||||
show_in_competition: true,
|
||||
}
|
||||
|
||||
const existingTraders = await api.getTraders(true)
|
||||
const existingAutopilot = existingTraders.find(
|
||||
(trader) => trader.trader_name === traderName
|
||||
)
|
||||
const autopilot = existingAutopilot
|
||||
? await api.updateTrader(existingAutopilot.trader_id, traderRequest)
|
||||
: await api.createTrader(traderRequest)
|
||||
|
||||
if (autopilot.startup_warning) {
|
||||
notify.warning(autopilot.startup_warning)
|
||||
}
|
||||
|
||||
if (!autopilot.is_running) {
|
||||
await api.startTrader(autopilot.trader_id)
|
||||
}
|
||||
notify.success(`${traderName} started`)
|
||||
setHasChanges(false)
|
||||
await loadStrategies(selectedStrategy.id)
|
||||
navigate(buildDashboardPath(autopilot.trader_id))
|
||||
} catch (err) {
|
||||
if (err instanceof LaunchSetupError) {
|
||||
notify.error(err.message)
|
||||
navigate(
|
||||
err.target === 'claw402'
|
||||
? `${ROUTES.traders}?setup=claw402`
|
||||
: err.target === 'hyperliquid'
|
||||
? `${ROUTES.traders}?setup=hyperliquid`
|
||||
: ROUTES.traders
|
||||
)
|
||||
if (!outcome.ok) {
|
||||
notify.error(outcome.message)
|
||||
const setupTarget =
|
||||
outcome.kind === 'error' ? null : outcome.setupTarget
|
||||
if (setupTarget) {
|
||||
navigate(`${ROUTES.traders}?setup=${setupTarget}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
notify.error(
|
||||
err instanceof Error ? err.message : 'Failed to launch NOFX Autopilot'
|
||||
)
|
||||
|
||||
if (outcome.warning) {
|
||||
notify.warning(outcome.warning)
|
||||
}
|
||||
notify.success('NOFX Autopilot started')
|
||||
setHasChanges(false)
|
||||
await loadStrategies(selectedStrategy.id)
|
||||
navigate(buildDashboardPath(outcome.traderId))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,13 @@ export interface SystemStatus {
|
||||
ai_provider: string
|
||||
strategy_type?: 'ai_trading' | 'grid_trading'
|
||||
grid_symbol?: string
|
||||
/** Runtime health: true when AI failed repeatedly and no new positions open. */
|
||||
safe_mode?: boolean
|
||||
safe_mode_reason?: string
|
||||
/** Claw402 AI fee wallet health, observed by the run loop. */
|
||||
ai_wallet_status?: 'ok' | 'low' | 'empty' | 'unknown'
|
||||
ai_wallet_balance_usdc?: number
|
||||
ai_wallet_checked_at?: string
|
||||
}
|
||||
|
||||
export interface AccountInfo {
|
||||
|
||||
Reference in New Issue
Block a user