Add resume strategy functionality and update UI for resuming strategies
This commit is contained in:
parent
67b633ae9b
commit
5720cdb63c
@ -10,6 +10,11 @@ export async function stopStrategy() {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resumeStrategy() {
|
||||||
|
const res = await apiRequest("POST", "/strategy/resume");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
export async function getStrategyStatus() {
|
export async function getStrategyStatus() {
|
||||||
const res = await apiRequest("GET", "/strategy/status");
|
const res = await apiRequest("GET", "/strategy/status");
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|||||||
@ -88,6 +88,10 @@ function getBadge(entry: StrategyEvent) {
|
|||||||
return { label: "STOP", className: "border-slate-400/40 bg-slate-400/20 text-slate-200" };
|
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") {
|
if (eventName === "ORDER_PLACED") {
|
||||||
const side = typeof meta.side === "string" ? meta.side.toUpperCase() : "";
|
const side = typeof meta.side === "string" ? meta.side.toUpperCase() : "";
|
||||||
if (side === "BUY") {
|
if (side === "BUY") {
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { Wallet, BarChart3, AlertCircle, RefreshCcw, PlugZap } from "lucide-reac
|
|||||||
import BrokerConnectDialog from "./BrokerConnectDialog";
|
import BrokerConnectDialog from "./BrokerConnectDialog";
|
||||||
import LoginRequiredDialog from "./LoginRequiredDialog";
|
import LoginRequiredDialog from "./LoginRequiredDialog";
|
||||||
import { getQueryFn, apiRequest, queryClient } from "@/lib/queryClient";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@ -83,6 +83,27 @@ type EngineStatus = {
|
|||||||
next_eligible_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;
|
||||||
|
};
|
||||||
|
|
||||||
type SessionUser = Pick<User, "id" | "username">;
|
type SessionUser = Pick<User, "id" | "username">;
|
||||||
|
|
||||||
const MotionButton = motion(Button);
|
const MotionButton = motion(Button);
|
||||||
@ -342,19 +363,38 @@ export default function PortfolioSection() {
|
|||||||
const [frequencyValue, setFrequencyValue] = useState(30);
|
const [frequencyValue, setFrequencyValue] = useState(30);
|
||||||
const [frequencyUnit, setFrequencyUnit] = useState<"days" | "minutes">("days");
|
const [frequencyUnit, setFrequencyUnit] = useState<"days" | "minutes">("days");
|
||||||
const [strategyStatus, setStrategyStatus] = useState("STOPPED");
|
const [strategyStatus, setStrategyStatus] = useState("STOPPED");
|
||||||
|
const [strategyDetails, setStrategyDetails] = useState<StrategyStatusResponse | null>(null);
|
||||||
const [isStarting, setIsStarting] = useState(false);
|
const [isStarting, setIsStarting] = useState(false);
|
||||||
|
const [isResuming, setIsResuming] = useState(false);
|
||||||
const [isStopping, setIsStopping] = useState(false);
|
const [isStopping, setIsStopping] = useState(false);
|
||||||
|
const [freshStartRequested, setFreshStartRequested] = useState(false);
|
||||||
const [engineStatus, setEngineStatus] = useState<EngineStatus | null>(null);
|
const [engineStatus, setEngineStatus] = useState<EngineStatus | null>(null);
|
||||||
const [marketStatus, setMarketStatus] = useState<MarketStatusResponse | null>(null);
|
const [marketStatus, setMarketStatus] = useState<MarketStatusResponse | null>(null);
|
||||||
|
|
||||||
const refreshStatus = useCallback(async () => {
|
const refreshStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const status = await getStrategyStatus();
|
const status = (await getStrategyStatus()) as StrategyStatusResponse;
|
||||||
|
setStrategyDetails(status ?? null);
|
||||||
setStrategyStatus(status?.status ?? "STOPPED");
|
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) {
|
} catch (error) {
|
||||||
|
setStrategyDetails(null);
|
||||||
setStrategyStatus("STOPPED");
|
setStrategyStatus("STOPPED");
|
||||||
}
|
}
|
||||||
}, []);
|
}, [freshStartRequested]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
@ -581,6 +621,14 @@ export default function PortfolioSection() {
|
|||||||
const isStrategyActive =
|
const isStrategyActive =
|
||||||
normalizedStrategyStatus === "RUNNING" ||
|
normalizedStrategyStatus === "RUNNING" ||
|
||||||
normalizedStrategyStatus === "WAITING";
|
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
|
const heartbeatAgeSec = engineStatus?.last_heartbeat_ts
|
||||||
? (Date.now() - new Date(engineStatus.last_heartbeat_ts).getTime()) / 1000
|
? (Date.now() - new Date(engineStatus.last_heartbeat_ts).getTime()) / 1000
|
||||||
@ -741,6 +789,7 @@ export default function PortfolioSection() {
|
|||||||
description: "The engine is already active.",
|
description: "The engine is already active.",
|
||||||
});
|
});
|
||||||
} else if (result?.status === "started" || result?.status === "restarted") {
|
} else if (result?.status === "started" || result?.status === "restarted") {
|
||||||
|
setFreshStartRequested(false);
|
||||||
toast({
|
toast({
|
||||||
title: "Live strategy started",
|
title: "Live strategy started",
|
||||||
description: "Golden Nifty is now armed for live Zerodha execution.",
|
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 () => {
|
const handleStop = async () => {
|
||||||
setIsStopping(true);
|
setIsStopping(true);
|
||||||
try {
|
try {
|
||||||
await stopStrategy();
|
await stopStrategy();
|
||||||
|
setFreshStartRequested(false);
|
||||||
} finally {
|
} finally {
|
||||||
setIsStopping(false);
|
setIsStopping(false);
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
@ -1095,6 +1204,7 @@ export default function PortfolioSection() {
|
|||||||
min={0}
|
min={0}
|
||||||
step={100}
|
step={100}
|
||||||
value={sipAmount}
|
value={sipAmount}
|
||||||
|
disabled={!canEditStrategyConfig}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const value = Number(event.target.value);
|
const value = Number(event.target.value);
|
||||||
setSipAmount(Number.isNaN(value) ? 0 : value);
|
setSipAmount(Number.isNaN(value) ? 0 : value);
|
||||||
@ -1110,6 +1220,7 @@ export default function PortfolioSection() {
|
|||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
value={frequencyValue}
|
value={frequencyValue}
|
||||||
|
disabled={!canEditStrategyConfig}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const value = Number(event.target.value);
|
const value = Number(event.target.value);
|
||||||
setFrequencyValue(Number.isNaN(value) ? 1 : value);
|
setFrequencyValue(Number.isNaN(value) ? 1 : value);
|
||||||
@ -1120,6 +1231,7 @@ export default function PortfolioSection() {
|
|||||||
<Label>Unit</Label>
|
<Label>Unit</Label>
|
||||||
<Select
|
<Select
|
||||||
value={frequencyUnit}
|
value={frequencyUnit}
|
||||||
|
disabled={!canEditStrategyConfig}
|
||||||
onValueChange={(value) => setFrequencyUnit(value as "days" | "minutes")}
|
onValueChange={(value) => setFrequencyUnit(value as "days" | "minutes")}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@ -1138,7 +1250,23 @@ export default function PortfolioSection() {
|
|||||||
Minutes mode is for live testing only. The engine checks every few seconds in this mode.
|
Minutes mode is for live testing only. The engine checks every few seconds in this mode.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{showResumeStrategy ? (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Resume uses the previously saved SIP configuration. Choose restart to begin a fresh cycle.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{showResumeStrategy ? (
|
||||||
|
<MotionButton
|
||||||
|
{...ctaMotionProps}
|
||||||
|
onClick={handleResume}
|
||||||
|
disabled={isResuming || !canArmStrategy}
|
||||||
|
className="shimmer"
|
||||||
|
>
|
||||||
|
{isResuming ? "Resuming..." : "Resume Strategy"}
|
||||||
|
</MotionButton>
|
||||||
|
) : null}
|
||||||
|
{showFreshStartStrategy ? (
|
||||||
<MotionButton
|
<MotionButton
|
||||||
{...ctaMotionProps}
|
{...ctaMotionProps}
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
@ -1147,9 +1275,17 @@ export default function PortfolioSection() {
|
|||||||
>
|
>
|
||||||
{isStarting ? "Starting..." : "Start Strategy"}
|
{isStarting ? "Starting..." : "Start Strategy"}
|
||||||
</MotionButton>
|
</MotionButton>
|
||||||
|
) : null}
|
||||||
|
{showRestartStrategy ? (
|
||||||
|
<Button variant="outline" onClick={() => setFreshStartRequested(true)}>
|
||||||
|
Restart Strategy
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{isStrategyActive ? (
|
||||||
<Button variant="outline" onClick={handleStop} disabled={isStopping}>
|
<Button variant="outline" onClick={handleStop} disabled={isStopping}>
|
||||||
{isStopping ? "Stopping..." : "Stop Strategy"}
|
{isStopping ? "Stopping..." : "Stop Strategy"}
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<StrategyTimeline compact />
|
<StrategyTimeline compact />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user