diff --git a/src/api/strategy.js b/src/api/strategy.js index afd2236e..f9d45257 100644 --- a/src/api/strategy.js +++ b/src/api/strategy.js @@ -10,6 +10,11 @@ export async function stopStrategy() { return res.json(); } +export async function resumeStrategy() { + const res = await apiRequest("POST", "/strategy/resume"); + return res.json(); +} + export async function getStrategyStatus() { const res = await apiRequest("GET", "/strategy/status"); return res.json(); diff --git a/src/components/StrategyTimeline.tsx b/src/components/StrategyTimeline.tsx index 56b1d2c4..25ad963d 100644 --- a/src/components/StrategyTimeline.tsx +++ b/src/components/StrategyTimeline.tsx @@ -88,6 +88,10 @@ function getBadge(entry: StrategyEvent) { return { label: "STOP", className: "border-slate-400/40 bg-slate-400/20 text-slate-200" }; } + if (eventName === "STRATEGY_RESUMED") { + return { label: "RESUME", className: "border-cyan-400/40 bg-cyan-400/20 text-cyan-200" }; + } + if (eventName === "ORDER_PLACED") { const side = typeof meta.side === "string" ? meta.side.toUpperCase() : ""; if (side === "BUY") { diff --git a/src/components/landing/PortfolioSection.tsx b/src/components/landing/PortfolioSection.tsx index 6f4374be..410ce184 100644 --- a/src/components/landing/PortfolioSection.tsx +++ b/src/components/landing/PortfolioSection.tsx @@ -6,7 +6,7 @@ import { Wallet, BarChart3, AlertCircle, RefreshCcw, PlugZap } from "lucide-reac import BrokerConnectDialog from "./BrokerConnectDialog"; import LoginRequiredDialog from "./LoginRequiredDialog"; import { getQueryFn, apiRequest, queryClient } from "@/lib/queryClient"; -import { getStrategyStatus, startStrategy, stopStrategy } from "@/api/strategy"; +import { getStrategyStatus, resumeStrategy, startStrategy, stopStrategy } from "@/api/strategy"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -83,6 +83,27 @@ type EngineStatus = { next_eligible_ts?: string | null; }; +type StrategyStatusResponse = { + status?: string; + last_updated?: string | null; + last_execution_ts?: string | null; + next_eligible_ts?: string | null; + run_id?: string | null; + can_resume?: boolean; + can_restart?: boolean; + config?: { + strategy?: string | null; + sip_amount?: number | null; + sip_frequency?: { + value?: number | null; + unit?: string | null; + } | null; + mode?: string | null; + broker?: string | null; + active?: boolean | null; + } | null; +}; + type SessionUser = Pick; const MotionButton = motion(Button); @@ -342,19 +363,38 @@ export default function PortfolioSection() { const [frequencyValue, setFrequencyValue] = useState(30); const [frequencyUnit, setFrequencyUnit] = useState<"days" | "minutes">("days"); const [strategyStatus, setStrategyStatus] = useState("STOPPED"); + const [strategyDetails, setStrategyDetails] = useState(null); const [isStarting, setIsStarting] = useState(false); + const [isResuming, setIsResuming] = useState(false); const [isStopping, setIsStopping] = useState(false); + const [freshStartRequested, setFreshStartRequested] = useState(false); const [engineStatus, setEngineStatus] = useState(null); const [marketStatus, setMarketStatus] = useState(null); const refreshStatus = useCallback(async () => { try { - const status = await getStrategyStatus(); + const status = (await getStrategyStatus()) as StrategyStatusResponse; + setStrategyDetails(status ?? null); setStrategyStatus(status?.status ?? "STOPPED"); + const savedAmount = Number(status?.config?.sip_amount); + if (!freshStartRequested && Number.isFinite(savedAmount) && savedAmount > 0) { + setSipAmount(savedAmount); + } + const savedFrequency = status?.config?.sip_frequency; + const savedFrequencyValue = Number(savedFrequency?.value); + const savedFrequencyUnit = + savedFrequency?.unit === "minutes" ? "minutes" : savedFrequency?.unit === "days" ? "days" : null; + if (!freshStartRequested && Number.isFinite(savedFrequencyValue) && savedFrequencyValue >= 1) { + setFrequencyValue(savedFrequencyValue); + } + if (!freshStartRequested && savedFrequencyUnit) { + setFrequencyUnit(savedFrequencyUnit); + } } catch (error) { + setStrategyDetails(null); setStrategyStatus("STOPPED"); } - }, []); + }, [freshStartRequested]); useEffect(() => { refreshStatus(); @@ -581,6 +621,14 @@ export default function PortfolioSection() { const isStrategyActive = normalizedStrategyStatus === "RUNNING" || normalizedStrategyStatus === "WAITING"; + const canResumeSavedStrategy = !!strategyDetails?.can_resume; + const showResumeStrategy = + !isStrategyActive && canResumeSavedStrategy && !freshStartRequested; + const showRestartStrategy = + !isStrategyActive && !!strategyDetails?.can_restart && !freshStartRequested; + const showFreshStartStrategy = + !isStrategyActive && (!canResumeSavedStrategy || freshStartRequested); + const canEditStrategyConfig = showFreshStartStrategy; const heartbeatAgeSec = engineStatus?.last_heartbeat_ts ? (Date.now() - new Date(engineStatus.last_heartbeat_ts).getTime()) / 1000 @@ -741,6 +789,7 @@ export default function PortfolioSection() { description: "The engine is already active.", }); } else if (result?.status === "started" || result?.status === "restarted") { + setFreshStartRequested(false); toast({ title: "Live strategy started", description: "Golden Nifty is now armed for live Zerodha execution.", @@ -760,10 +809,70 @@ export default function PortfolioSection() { } }; + const handleResume = async () => { + if (!(await requireLogin())) { + return; + } + if (!isConnected) { + setConnectPromptOpen(true); + return; + } + if (showSessionExpired || brokerAuthExpired) { + toast({ + title: "Reconnect broker", + description: "Your Zerodha session has expired. Reconnect before resuming the live strategy.", + }); + handleReconnectClick(); + return; + } + setIsResuming(true); + try { + const result = await resumeStrategy(); + if (result?.status === "broker_auth_required") { + toast({ + title: "Reconnect broker", + description: "Your Zerodha session has expired. Reconnect and resume again.", + }); + await startSavedBrokerReconnect(); + return; + } + if (result?.status === "already_running") { + toast({ + title: "Strategy already running", + description: "The engine is already active.", + }); + } else if (result?.status === "resumed") { + setFreshStartRequested(false); + toast({ + title: "Live strategy resumed", + description: "The previous live run has been resumed with its saved schedule.", + }); + } else if (result?.status === "no_resumable_run") { + setFreshStartRequested(true); + toast({ + title: "No resumable strategy", + description: "Start a fresh cycle instead.", + }); + } else { + toast({ + title: "Resume failed", + description: result?.message || result?.status || "Unable to resume strategy.", + }); + } + } finally { + setIsResuming(false); + await Promise.allSettled([ + refreshStatus(), + refreshBrokerData({ includeEquityCurve: true }), + ]); + } + }; + const handleStop = async () => { setIsStopping(true); try { await stopStrategy(); + setFreshStartRequested(false); } finally { setIsStopping(false); await Promise.allSettled([ @@ -1095,6 +1204,7 @@ export default function PortfolioSection() { min={0} step={100} value={sipAmount} + disabled={!canEditStrategyConfig} onChange={(event) => { const value = Number(event.target.value); setSipAmount(Number.isNaN(value) ? 0 : value); @@ -1110,6 +1220,7 @@ export default function PortfolioSection() { min={1} step={1} value={frequencyValue} + disabled={!canEditStrategyConfig} onChange={(event) => { const value = Number(event.target.value); setFrequencyValue(Number.isNaN(value) ? 1 : value); @@ -1120,6 +1231,7 @@ export default function PortfolioSection() {