diff --git a/src/pages/PaperPortfolio.tsx b/src/pages/PaperPortfolio.tsx index c7df8f84..c48611d7 100644 --- a/src/pages/PaperPortfolio.tsx +++ b/src/pages/PaperPortfolio.tsx @@ -10,13 +10,25 @@ import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { ChartContainer, ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; import { apiRequest } from "@/lib/queryClient"; -import { getStrategyStatus, startStrategy, stopStrategy } from "@/api/strategy"; +import { + getStrategyStatus, + resumeStrategy, + startStrategy, + stopStrategy, +} from "@/api/strategy"; import StrategyTimeline from "@/components/StrategyTimeline"; import { toast } from "@/hooks/use-toast"; @@ -80,8 +92,62 @@ type MarketStatusResponse = { checked_at?: string; }; +type EngineStatus = { + state?: string; + run_id?: string | null; + last_heartbeat_ts?: string | null; + last_execution_ts?: string | null; + 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; +}; + const MotionButton = motion(Button); +function formatMinuteTimestamp(date: Date) { + return date.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function formatRelativeSeconds(seconds: number) { + if (!Number.isFinite(seconds)) return ""; + if (seconds <= 0) return "now"; + const total = Math.round(seconds); + const days = Math.floor(total / 86400); + const hours = Math.floor((total % 86400) / 3600); + const minutes = Math.floor((total % 3600) / 60); + const parts = []; + if (days) parts.push(`${days}d`); + if (hours) parts.push(`${hours}h`); + if (!days && minutes) parts.push(`${minutes}m`); + if (parts.length === 0) parts.push("less than 1m"); + return `in ${parts.join(" ")}`; +} + function formatCurrency(value: number, decimals = 2) { return new Intl.NumberFormat("en-IN", { style: "currency", @@ -115,11 +181,12 @@ function PaperTradingPortfolio() { const [frequencyValue, setFrequencyValue] = useState(10); const [frequencyUnit, setFrequencyUnit] = useState<"minutes" | "days">("minutes"); const [strategyStatus, setStrategyStatus] = useState("STOPPED"); - const [nextSipTs, setNextSipTs] = useState(null); - const [nextSipCountdown, setNextSipCountdown] = useState(null); + const [strategyDetails, setStrategyDetails] = useState(null); const [isStarting, setIsStarting] = useState(false); + const [isResuming, setIsResuming] = useState(false); const [isStopping, setIsStopping] = useState(false); const [isResetting, setIsResetting] = useState(false); + const [freshStartRequested, setFreshStartRequested] = useState(false); const [addCashEnabled, setAddCashEnabled] = useState(false); const [addCashAmount, setAddCashAmount] = useState(""); const [isAddingCash, setIsAddingCash] = useState(false); @@ -132,9 +199,9 @@ function PaperTradingPortfolio() { const [mtmPnlPoints, setMtmPnlPoints] = useState< { date: string; pnl: number }[] >([]); + const [engineStatus, setEngineStatus] = useState(null); const [priceStale, setPriceStale] = useState(false); const [marketStatus, setMarketStatus] = useState(null); - const [timelineKey, setTimelineKey] = useState(0); const skipFirstPnlPointRef = useRef(true); const fundsQuery = useQuery({ @@ -186,14 +253,32 @@ function PaperTradingPortfolio() { const refreshStatus = useCallback(async () => { try { - const status = await getStrategyStatus(); + const status = (await getStrategyStatus()) as StrategyStatusResponse; + setStrategyDetails(status ?? null); setStrategyStatus(status?.status ?? "STOPPED"); - setNextSipTs(status?.next_eligible_ts ?? null); + 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 { + setStrategyDetails(null); setStrategyStatus("STOPPED"); - setNextSipTs(null); } - }, []); + }, [freshStartRequested]); useEffect(() => { refreshStatus(); @@ -212,35 +297,21 @@ function PaperTradingPortfolio() { } }, []); - useEffect(() => { - if (!nextSipTs) { - setNextSipCountdown(null); - return; + const refreshEngineStatus = useCallback(async () => { + try { + const res = await apiRequest("GET", "/engine/status"); + const data: EngineStatus = await res.json(); + setEngineStatus(data); + } catch { + setEngineStatus(null); } - const updateCountdown = () => { - const target = new Date(nextSipTs).getTime(); - if (Number.isNaN(target)) { - setNextSipCountdown(null); - return; - } - const diff = target - Date.now(); - if (diff <= 0) { - setNextSipCountdown("now"); - return; - } - const totalSeconds = Math.floor(diff / 1000); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - const hh = hours > 0 ? `${hours}h ` : ""; - const mm = `${minutes}`.padStart(2, "0"); - const ss = `${seconds}`.padStart(2, "0"); - setNextSipCountdown(`${hh}${mm}:${ss}`); - }; - updateCountdown(); - const id = window.setInterval(updateCountdown, 1000); + }, []); + + useEffect(() => { + void refreshEngineStatus(); + const id = window.setInterval(refreshEngineStatus, 5000); return () => window.clearInterval(id); - }, [nextSipTs]); + }, [refreshEngineStatus]); useEffect(() => { let timer: number; @@ -299,22 +370,28 @@ function PaperTradingPortfolio() { return () => window.clearInterval(timer); }, []); - useEffect(() => { - const fetchMarketStatus = async () => { - try { - const res = await apiRequest("GET", "/market/status"); - const data: MarketStatusResponse = await res.json(); - setMarketStatus(data); - } catch { - setMarketStatus(null); - } - }; - - fetchMarketStatus(); - const id = window.setInterval(fetchMarketStatus, 5000); - return () => window.clearInterval(id); + const refreshMarketStatus = useCallback(async () => { + try { + const res = await apiRequest("GET", "/market/status"); + const data: MarketStatusResponse = await res.json(); + setMarketStatus(data); + } catch { + setMarketStatus(null); + } }, []); + useEffect(() => { + void refreshMarketStatus(); + const id = window.setInterval(refreshMarketStatus, 5000); + return () => window.clearInterval(id); + }, [refreshMarketStatus]); + + useEffect(() => { + if (!freshStartRequested && typeof mtmInitialCash === "number" && mtmInitialCash > 0) { + setInitialCash(mtmInitialCash); + } + }, [freshStartRequested, mtmInitialCash]); + const handleReset = async () => { const ok = window.confirm( "This will RESET the entire paper account.\n\n" + @@ -345,8 +422,10 @@ function PaperTradingPortfolio() { positionsQuery.refetch(), ordersQuery.refetch(), refreshStatus(), + refreshEngineStatus(), + refreshMarketStatus(), ]); - setTimelineKey((value) => value + 1); + setFreshStartRequested(false); } catch (error) { toast({ title: "Reset failed", @@ -388,6 +467,7 @@ function PaperTradingPortfolio() { }); } if (result?.status === "started" || result?.status === "restarted") { + setFreshStartRequested(false); toast({ title: "Paper strategy started", description: "The simulator is now running.", @@ -401,8 +481,56 @@ function PaperTradingPortfolio() { console.error(error); } finally { setIsStarting(false); - await Promise.all([ + await Promise.allSettled([ refreshStatus(), + refreshEngineStatus(), + refreshMarketStatus(), + fundsQuery.refetch(), + positionsQuery.refetch(), + ordersQuery.refetch(), + ]); + } + }; + + const handleResume = async () => { + setIsResuming(true); + try { + const result = await resumeStrategy(); + if (result?.status === "already_running") { + toast({ + title: "Strategy already running", + description: "The paper strategy is already active.", + }); + } else if (result?.status === "resumed") { + setFreshStartRequested(false); + toast({ + title: "Paper strategy resumed", + description: "The previous paper 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 paper cycle instead.", + }); + } else { + toast({ + title: "Resume failed", + description: result?.message || result?.status || "Unable to resume strategy.", + }); + } + } catch (error) { + toast({ + title: "Resume failed", + description: error instanceof Error ? error.message : "Please try again.", + }); + console.error(error); + } finally { + setIsResuming(false); + await Promise.allSettled([ + refreshStatus(), + refreshEngineStatus(), + refreshMarketStatus(), fundsQuery.refetch(), positionsQuery.refetch(), ordersQuery.refetch(), @@ -414,6 +542,7 @@ function PaperTradingPortfolio() { setIsStopping(true); try { await stopStrategy(); + setFreshStartRequested(false); toast({ title: "Paper strategy stopped", description: "The simulator has been stopped.", @@ -426,8 +555,10 @@ function PaperTradingPortfolio() { console.error(error); } finally { setIsStopping(false); - await Promise.all([ + await Promise.allSettled([ refreshStatus(), + refreshEngineStatus(), + refreshMarketStatus(), fundsQuery.refetch(), positionsQuery.refetch(), ordersQuery.refetch(), @@ -441,29 +572,100 @@ function PaperTradingPortfolio() { : strategyStatus === "WAITING" ? "WAITING" : "STOPPED"; - const canStart = typeof initialCash === "number" && initialCash >= 10000; - const canStop = + const isStrategyActive = normalizedStrategyStatus === "RUNNING" || normalizedStrategyStatus === "WAITING"; - const strategyLocked = canStop || isStarting || isStopping || isResetting; + 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 canStartFresh = typeof initialCash === "number" && initialCash >= 10000; + const canStop = isStrategyActive; + const strategyLocked = + !canEditStrategyConfig || isStarting || isResuming || isStopping || isResetting; const canAddCash = canStop && addCashEnabled && typeof addCashAmount === "number" && addCashAmount > 0; + const heartbeatAgeSec = engineStatus?.last_heartbeat_ts + ? (Date.now() - new Date(engineStatus.last_heartbeat_ts).getTime()) / 1000 + : Infinity; + + let liveness: "ACTIVE" | "STALLED" | "DEAD" | "STOPPED"; + if (!engineStatus) { + liveness = "DEAD"; + } else if (engineStatus.state !== "RUNNING") { + liveness = "STOPPED"; + } else if (heartbeatAgeSec < 10) { + liveness = "ACTIVE"; + } else if (heartbeatAgeSec < 30) { + liveness = "STALLED"; + } else { + liveness = "DEAD"; + } + + const livenessBadgeClass = + liveness === "ACTIVE" + ? "border-emerald-500/50 bg-emerald-500/15 text-emerald-300" + : liveness === "STALLED" + ? "border-amber-400/50 bg-amber-400/15 text-amber-200" + : liveness === "DEAD" + ? "border-red-500/50 bg-red-500/15 text-red-300" + : "border-slate-400/40 bg-slate-400/15 text-slate-200"; + const livenessBadgeLabel = + liveness === "ACTIVE" + ? "Engine active" + : liveness === "STALLED" + ? "Engine stalled" + : liveness === "DEAD" + ? "Engine dead" + : "Engine stopped"; const strategyBadgeClass = normalizedStrategyStatus === "RUNNING" - ? "bg-green-500 text-white" + ? "border-emerald-500/50 bg-emerald-500/10 text-emerald-400" : normalizedStrategyStatus === "WAITING" - ? "bg-yellow-500 text-white" - : "bg-gray-400 text-white"; + ? "border-amber-400/50 bg-amber-400/10 text-amber-300" + : "border-red-500/40 bg-red-500/10 text-red-400"; + const strategyBadgeLabel = + normalizedStrategyStatus === "RUNNING" + ? "Strategy running" + : normalizedStrategyStatus === "WAITING" + ? "Strategy waiting" + : "Strategy stopped"; const marketState = marketStatus?.status ?? "UNKNOWN"; - const marketBadgeClass = - marketState === "OPEN" - ? "border-emerald-500/50 bg-emerald-500/15 text-emerald-300" - : marketState === "CLOSED" - ? "border-amber-400/50 bg-amber-400/15 text-amber-200" - : "border-slate-400/40 bg-slate-400/15 text-slate-200"; - const priceBadgeClass = priceStale - ? "border-amber-400/50 bg-amber-400/15 text-amber-200" - : "border-emerald-500/50 bg-emerald-500/15 text-emerald-300"; + const canArmStrategy = liveness === "ACTIVE" || liveness === "STOPPED"; + const nextEligibleTs = engineStatus?.next_eligible_ts + ? new Date(engineStatus.next_eligible_ts) + : null; + const nextEligibleValid = nextEligibleTs && !Number.isNaN(nextEligibleTs.getTime()); + const eligibleSeconds = nextEligibleValid + ? (nextEligibleTs.getTime() - Date.now()) / 1000 + : Infinity; + const isEligible = eligibleSeconds <= 0; + const relativeEligible = formatRelativeSeconds(eligibleSeconds); + + let nextEligibleLine = "-"; + let eligibilityStatus: string | null = "First execution pending"; + let eligibilityClass = "text-muted-foreground"; + + if (nextEligibleValid) { + eligibilityStatus = null; + if (isEligible && marketState === "OPEN") { + nextEligibleLine = "Now"; + eligibilityStatus = "Eligible. Execution imminent."; + eligibilityClass = "text-emerald-400"; + } else if (isEligible && marketState === "CLOSED") { + nextEligibleLine = `${formatMinuteTimestamp(nextEligibleTs)} (eligible)`; + eligibilityStatus = "Eligible. Waiting for market open."; + eligibilityClass = "text-amber-300"; + } else { + nextEligibleLine = `${formatMinuteTimestamp(nextEligibleTs)} (${relativeEligible})`; + eligibilityStatus = "Not eligible yet"; + eligibilityClass = "text-muted-foreground"; + } + } const summary = [ { label: "Initial Cash", value: mtmInitialCash ?? 0 }, @@ -547,11 +749,19 @@ function PaperTradingPortfolio() {
Paper trading (simulated) -
@@ -560,35 +770,35 @@ function PaperTradingPortfolio() {
-

Paper Strategy Control

+

Strategy control

- Strategy: Golden Nifty (Paper) ยท Assets: NIFTYBEES + GOLDBEES + Start or stop the Golden Nifty SIP engine from the dashboard.

- {nextSipTs && ( -

- Next SIP {nextSipCountdown ? `in ${nextSipCountdown}` : ""} ({new Date(nextSipTs).toLocaleTimeString()}) -

- )}
- - {normalizedStrategyStatus} - - - {marketState === "OPEN" - ? "Market OPEN" - : marketState === "CLOSED" - ? "Market CLOSED" - : "Market UNKNOWN"} + + {livenessBadgeLabel} - {priceStale && ( - - Price STALE + + {strategyBadgeLabel} + + {priceStale ? ( + + Price stale - )} + ) : null}
+
+
+ Next eligible SIP +
+
{nextEligibleLine}
+ {eligibilityStatus ? ( +
{eligibilityStatus}
+ ) : null} +
setAddCashEnabled(value === true)} /> @@ -630,7 +840,7 @@ function PaperTradingPortfolio() { min={1} step={100} value={addCashAmount} - disabled={!canStop || !addCashEnabled || isAddingCash || isStarting || isStopping || isResetting} + disabled={!canStop || !addCashEnabled || isAddingCash || isStarting || isResuming || isStopping || isResetting} onChange={(event) => { const raw = event.target.value; if (raw === "") { @@ -671,7 +881,7 @@ function PaperTradingPortfolio() { />
-
+
- + + + + + Days + Minutes (testing) + +

@@ -708,40 +921,62 @@ function PaperTradingPortfolio() {

+ {showResumeStrategy ? ( +
+ Resume uses the previously saved paper SIP configuration. Choose restart to begin a fresh cycle. +
+ ) : null}
- - {isStarting ? "Starting..." : "Start Paper Strategy"} - - + {showResumeStrategy ? ( + + {isResuming ? "Resuming..." : "Resume Strategy"} + + ) : null} + {showFreshStartStrategy ? ( + + {isStarting ? "Starting..." : "Start Strategy"} + + ) : null} + {showRestartStrategy ? ( + + ) : null} + {isStrategyActive ? ( + + ) : null}
- {marketState === "CLOSED" ? ( -

- Market CLOSED - orders will execute when the next session opens. -

- ) : null} +
- -