mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-17 01:14:40 +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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user