Simplify portfolio strategy UI and reconnect handling
This commit is contained in:
parent
469f204452
commit
aa93225ef7
@ -18,6 +18,19 @@ type StrategyRun = {
|
||||
events: StrategyEvent[];
|
||||
};
|
||||
|
||||
type StrategySummary = {
|
||||
run_id?: string | null;
|
||||
status?: string | null;
|
||||
tone?: "neutral" | "warning" | "error" | "success";
|
||||
message?: string | null;
|
||||
event?: string | null;
|
||||
ts?: string | null;
|
||||
};
|
||||
|
||||
type StrategyTimelineProps = {
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
function normalizeLog(log: unknown): StrategyEvent {
|
||||
if (typeof log === "string") {
|
||||
try {
|
||||
@ -45,6 +58,16 @@ function formatTimestamp(value?: string) {
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function formatCompactTimestamp(value?: string | null) {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
return parsed.toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function getRunId(entry: StrategyEvent) {
|
||||
return entry.run_id ?? "unknown";
|
||||
}
|
||||
@ -104,7 +127,62 @@ function getStopReason(events: StrategyEvent[]) {
|
||||
return typeof reason === "string" ? reason : null;
|
||||
}
|
||||
|
||||
export default function StrategyTimeline() {
|
||||
function CompactStrategySummary() {
|
||||
const [summary, setSummary] = useState<StrategySummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const fetchSummary = async () => {
|
||||
try {
|
||||
const res = await apiRequest("GET", "/strategy/summary");
|
||||
const data = (await res.json()) as StrategySummary;
|
||||
if (!cancelled) {
|
||||
setSummary(data);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setSummary({
|
||||
tone: "error",
|
||||
message: "Could not load strategy status.",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchSummary();
|
||||
const id = window.setInterval(fetchSummary, 5000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tone = summary?.tone ?? "neutral";
|
||||
const message = summary?.message || "No active strategy.";
|
||||
const updatedAt = formatCompactTimestamp(summary?.ts);
|
||||
const className =
|
||||
tone === "error"
|
||||
? "border-red-500/40 bg-red-500/10 text-red-300"
|
||||
: tone === "warning"
|
||||
? "border-amber-400/40 bg-amber-400/10 text-amber-200"
|
||||
: tone === "success"
|
||||
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-300"
|
||||
: "border-border/60 bg-background/40 text-muted-foreground";
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border px-4 py-3 text-sm ${className}`}>
|
||||
<div className="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
||||
<span>{message}</span>
|
||||
{updatedAt ? (
|
||||
<span className="text-xs opacity-80">Updated {updatedAt}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VerboseStrategyTimeline() {
|
||||
const [logs, setLogs] = useState<StrategyEvent[]>([]);
|
||||
const latestSeqRef = useRef(0);
|
||||
|
||||
@ -118,10 +196,10 @@ export default function StrategyTimeline() {
|
||||
|
||||
setLogs((prev) => {
|
||||
const seenSeq = new Set(
|
||||
prev.map((entry) => entry.seq).filter((seq) => typeof seq === "number")
|
||||
prev.map((entry) => entry.seq).filter((seq) => typeof seq === "number"),
|
||||
);
|
||||
const next = normalized.filter(
|
||||
(entry) => typeof entry.seq !== "number" || !seenSeq.has(entry.seq)
|
||||
(entry) => typeof entry.seq !== "number" || !seenSeq.has(entry.seq),
|
||||
);
|
||||
return [...prev, ...next];
|
||||
});
|
||||
@ -131,7 +209,7 @@ export default function StrategyTimeline() {
|
||||
} else {
|
||||
const lastSeq = normalized.reduce(
|
||||
(max, entry) => Math.max(max, entry.seq ?? 0),
|
||||
latestSeqRef.current
|
||||
latestSeqRef.current,
|
||||
);
|
||||
latestSeqRef.current = lastSeq;
|
||||
}
|
||||
@ -190,7 +268,7 @@ export default function StrategyTimeline() {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white/90">
|
||||
▶ Run started at {formatTimestamp(startTime)}
|
||||
Run started at {formatTimestamp(startTime)}
|
||||
</div>
|
||||
<div className="text-xs text-green-300/70">Run ID: {run.runId}</div>
|
||||
</div>
|
||||
@ -264,3 +342,10 @@ export default function StrategyTimeline() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StrategyTimeline({ compact = false }: StrategyTimelineProps) {
|
||||
if (compact) {
|
||||
return <CompactStrategySummary />;
|
||||
}
|
||||
return <VerboseStrategyTimeline />;
|
||||
}
|
||||
|
||||
@ -32,6 +32,7 @@ type BrokerStatusResponse = {
|
||||
connected_at?: string;
|
||||
userName?: string;
|
||||
brokerUserId?: string;
|
||||
authState?: string;
|
||||
};
|
||||
|
||||
const CALLBACK_STORAGE_KEY = "zerodha:callback";
|
||||
@ -61,9 +62,11 @@ export default function BrokerConnectDialog({
|
||||
const [apiSecret, setApiSecret] = useState("");
|
||||
const [holdings, setHoldings] = useState<any[]>([]);
|
||||
|
||||
const { data: sessionUser } = useQuery<SessionUser | null>({
|
||||
const { data: sessionUser, refetch: refetchSessionUser } = useQuery<SessionUser | null>({
|
||||
queryKey: ["/me"],
|
||||
queryFn: getQueryFn<SessionUser | null>({ on401: "returnNull" }),
|
||||
staleTime: 0,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
const { data: brokerStatus, refetch: refetchStatus } = useQuery<BrokerStatusResponse | null>({
|
||||
@ -100,6 +103,34 @@ export default function BrokerConnectDialog({
|
||||
toast({ title: "Could not start Zerodha login", description: err?.message || "Try again." }),
|
||||
});
|
||||
|
||||
const reconnectSavedMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiRequest("GET", "/broker/login-url");
|
||||
return res.json() as Promise<{ loginUrl: string }>;
|
||||
},
|
||||
onSuccess: ({ loginUrl }) => {
|
||||
window.open(loginUrl, "_blank", "noopener,noreferrer");
|
||||
toast({
|
||||
title: "Continue in Zerodha",
|
||||
description: "Log in and return here. We will reconnect your broker automatically.",
|
||||
});
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const message = String(err?.message || "");
|
||||
if (message.includes("400:") && message.includes("Broker credentials not configured")) {
|
||||
toast({
|
||||
title: "Enter Zerodha API credentials",
|
||||
description: "Saved credentials are missing. Enter the API key and secret once to reconnect.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Could not reconnect Zerodha",
|
||||
description: err?.message || "Try again.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const holdingsMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiRequest("GET", "/zerodha/holdings");
|
||||
@ -133,12 +164,15 @@ export default function BrokerConnectDialog({
|
||||
});
|
||||
|
||||
const connected = !!brokerStatus?.connected;
|
||||
const canReconnectWithSavedZerodha =
|
||||
connected && (brokerStatus?.broker || "").trim().toUpperCase() === "ZERODHA";
|
||||
const connectedAt = brokerStatus?.connected_at
|
||||
? new Date(brokerStatus.connected_at)
|
||||
: null;
|
||||
|
||||
const handleConnectClick = () => {
|
||||
if (!sessionUser) {
|
||||
const handleConnectClick = async () => {
|
||||
const latest = await refetchSessionUser();
|
||||
if (!latest.data) {
|
||||
setLoginPromptOpen(true);
|
||||
return;
|
||||
}
|
||||
@ -297,6 +331,16 @@ export default function BrokerConnectDialog({
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
{loginUrlMutation.isPending ? "Opening Zerodha..." : "Open Zerodha login"}
|
||||
</Button>
|
||||
{canReconnectWithSavedZerodha ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => reconnectSavedMutation.mutate()}
|
||||
disabled={reconnectSavedMutation.isPending}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{reconnectSavedMutation.isPending ? "Opening Zerodha..." : "Reconnect saved Zerodha"}
|
||||
</Button>
|
||||
) : null}
|
||||
{connected ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
@ -322,6 +366,10 @@ export default function BrokerConnectDialog({
|
||||
After you log in with your Zerodha account, we will connect automatically. Keep this tab open
|
||||
until the login completes.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Zerodha access tokens expire daily. After the first setup, reconnect can use your saved API key
|
||||
and secret without re-entering them.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-dashed border-border/50 p-4 text-sm text-muted-foreground">
|
||||
|
||||
@ -5,7 +5,7 @@ import { motion } from "framer-motion";
|
||||
import { Wallet, BarChart3, AlertCircle, RefreshCcw, PlugZap } from "lucide-react";
|
||||
import BrokerConnectDialog from "./BrokerConnectDialog";
|
||||
import LoginRequiredDialog from "./LoginRequiredDialog";
|
||||
import { getQueryFn, apiRequest } from "@/lib/queryClient";
|
||||
import { getQueryFn, apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { getStrategyStatus, startStrategy, stopStrategy } from "@/api/strategy";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -33,38 +33,7 @@ type BrokerStatusResponse = {
|
||||
connected_at?: string;
|
||||
userName?: string;
|
||||
brokerUserId?: string;
|
||||
};
|
||||
|
||||
type SystemArmResponse = {
|
||||
armed_runs: Array<{ run_id: string; status: string; next_run?: string; already_running?: boolean }>;
|
||||
failed_runs: Array<{ run_id: string; status: string; reason: string }>;
|
||||
next_execution?: string | null;
|
||||
broker_state?: {
|
||||
connected?: boolean;
|
||||
auth_state?: string | null;
|
||||
broker?: string | null;
|
||||
user_name?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
type SystemStatusRun = {
|
||||
run_id: string;
|
||||
status: string;
|
||||
strategy?: string | null;
|
||||
mode?: string | null;
|
||||
broker?: string | null;
|
||||
next_run?: string | null;
|
||||
lifecycle?: string | null;
|
||||
};
|
||||
|
||||
type SystemStatusResponse = {
|
||||
runs: SystemStatusRun[];
|
||||
broker_state?: {
|
||||
connected?: boolean;
|
||||
auth_state?: string | null;
|
||||
broker?: string | null;
|
||||
user_name?: string | null;
|
||||
};
|
||||
authState?: string;
|
||||
};
|
||||
|
||||
type HoldingsResponse = {
|
||||
@ -177,7 +146,6 @@ export default function PortfolioSection() {
|
||||
const [cachedHoldings, setCachedHoldings] = useState<any[]>([]);
|
||||
const [cachedFunds, setCachedFunds] = useState<FundsResponse["funds"] | null>(null);
|
||||
const [cachedEquityCurve, setCachedEquityCurve] = useState<EquityCurveResponse | null>(null);
|
||||
const [armSummary, setArmSummary] = useState<SystemArmResponse | null>(null);
|
||||
const {
|
||||
data: brokerStatus,
|
||||
isFetching: brokerStatusLoading,
|
||||
@ -188,55 +156,67 @@ export default function PortfolioSection() {
|
||||
staleTime: 0,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
const { data: sessionUser } = useQuery<SessionUser | null>({
|
||||
const { data: sessionUser, refetch: refetchSessionUser } = useQuery<SessionUser | null>({
|
||||
queryKey: ["/me"],
|
||||
queryFn: getQueryFn<SessionUser | null>({ on401: "returnNull" }),
|
||||
staleTime: 0,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
const systemStatusQuery = useQuery<SystemStatusResponse>({
|
||||
queryKey: ["/system/status"],
|
||||
queryFn: getQueryFn<SystemStatusResponse>({ on401: "throw" }),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
const armMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/system/arm", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (res.status === 401) {
|
||||
let payload: any = {};
|
||||
|
||||
async function startSavedBrokerReconnect() {
|
||||
try {
|
||||
payload = await res.json();
|
||||
} catch {}
|
||||
const redirect =
|
||||
payload?.detail?.redirect_url ||
|
||||
payload?.redirect_url ||
|
||||
"/broker/login";
|
||||
window.location.assign(redirect);
|
||||
return null;
|
||||
const res = await apiRequest("GET", "/broker/login-url");
|
||||
const data = (await res.json()) as { loginUrl?: string };
|
||||
if (!data?.loginUrl) {
|
||||
throw new Error("Reconnect link unavailable.");
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || res.statusText);
|
||||
}
|
||||
return (await res.json()) as SystemArmResponse;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (!data) return;
|
||||
setArmSummary(data);
|
||||
window.open(data.loginUrl, "_blank", "noopener,noreferrer");
|
||||
toast({
|
||||
title: "System armed",
|
||||
description: data.next_execution
|
||||
? `Next execution at ${new Date(data.next_execution).toLocaleString()}`
|
||||
: "All strategies are armed.",
|
||||
title: "Continue in Zerodha",
|
||||
description: "Log in and return here. We will reconnect your broker automatically.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
const message = String(error?.message || "");
|
||||
if (message.includes("400:") && message.includes("Broker credentials not configured")) {
|
||||
setBrokerDialogOpen(true);
|
||||
toast({
|
||||
title: "Enter Zerodha API credentials",
|
||||
description: "Saved broker credentials are missing. Enter the API key and secret once to reconnect.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.includes("401:")) {
|
||||
setLoginPromptOpen(true);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const disconnectBrokerMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiRequest("POST", "/broker/disconnect");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setSessionExpired(false);
|
||||
setReconnectAttempted(false);
|
||||
setCachedHoldings([]);
|
||||
setCachedFunds(null);
|
||||
setCachedEquityCurve(null);
|
||||
queryClient.removeQueries({ queryKey: ["/zerodha/holdings"] });
|
||||
queryClient.removeQueries({ queryKey: ["/zerodha/funds"] });
|
||||
queryClient.removeQueries({ queryKey: ["/zerodha/equity-curve"] });
|
||||
await refetchBrokerStatus();
|
||||
toast({
|
||||
title: "Broker disconnected",
|
||||
description: "Your broker account has been disconnected.",
|
||||
});
|
||||
systemStatusQuery.refetch();
|
||||
refetchBrokerStatus();
|
||||
},
|
||||
onError: (err: any) =>
|
||||
toast({
|
||||
title: "Arm failed",
|
||||
description: err?.message || "Unable to arm system.",
|
||||
title: "Disconnect failed",
|
||||
description: err?.message || "Unable to disconnect broker.",
|
||||
}),
|
||||
});
|
||||
|
||||
@ -325,7 +305,7 @@ export default function PortfolioSection() {
|
||||
useEffect(() => {
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await fetch("/engine/status");
|
||||
const res = await apiRequest("GET", "/engine/status");
|
||||
const data = await res.json();
|
||||
setEngineStatus(data);
|
||||
} catch {
|
||||
@ -341,7 +321,7 @@ export default function PortfolioSection() {
|
||||
useEffect(() => {
|
||||
const fetchMarketStatus = async () => {
|
||||
try {
|
||||
const res = await fetch("/market/status");
|
||||
const res = await apiRequest("GET", "/market/status");
|
||||
const data = await res.json();
|
||||
setMarketStatus(data);
|
||||
} catch {
|
||||
@ -431,18 +411,6 @@ export default function PortfolioSection() {
|
||||
const holdings = holdingsQuery.data ? holdingsQuery.data.holdings : cachedHoldings;
|
||||
const fundsSnapshot = fundsQuery.data?.funds ?? cachedFunds;
|
||||
const noHoldings = holdings.length === 0;
|
||||
const systemRuns = systemStatusQuery.data?.runs ?? [];
|
||||
const armedCount = systemRuns.filter(
|
||||
(run) => (run.status || "").toUpperCase() === "RUNNING",
|
||||
).length;
|
||||
const nextExecution = useMemo(() => {
|
||||
const nextDates = systemRuns
|
||||
.map((run) => (run.next_run ? new Date(run.next_run) : null))
|
||||
.filter((value): value is Date => !!value && !Number.isNaN(value.getTime()));
|
||||
if (!nextDates.length) return null;
|
||||
nextDates.sort((a, b) => a.getTime() - b.getTime());
|
||||
return nextDates[0];
|
||||
}, [systemRuns]);
|
||||
useEffect(() => {
|
||||
if (!isConnected) {
|
||||
setSessionExpired(false);
|
||||
@ -549,7 +517,7 @@ export default function PortfolioSection() {
|
||||
const isEligible = eligibleSeconds <= 0;
|
||||
const relativeEligible = formatRelativeSeconds(eligibleSeconds);
|
||||
|
||||
let nextEligibleLine = "—";
|
||||
let nextEligibleLine = "-";
|
||||
let eligibilityStatus: string | null = "First execution pending";
|
||||
let eligibilityClass = "text-muted-foreground";
|
||||
|
||||
@ -557,11 +525,11 @@ export default function PortfolioSection() {
|
||||
eligibilityStatus = null;
|
||||
if (isEligible && marketState === "OPEN") {
|
||||
nextEligibleLine = "Now";
|
||||
eligibilityStatus = "Eligible — execution imminent";
|
||||
eligibilityStatus = "Eligible. Execution imminent.";
|
||||
eligibilityClass = "text-emerald-400";
|
||||
} else if (isEligible && marketState === "CLOSED") {
|
||||
nextEligibleLine = `${formatMinuteTimestamp(nextEligibleTs)} (eligible)`;
|
||||
eligibilityStatus = "Eligible — waiting for market open";
|
||||
eligibilityStatus = "Eligible. Waiting for market open.";
|
||||
eligibilityClass = "text-amber-300";
|
||||
} else {
|
||||
nextEligibleLine = `${formatMinuteTimestamp(nextEligibleTs)} (${relativeEligible})`;
|
||||
@ -570,46 +538,97 @@ export default function PortfolioSection() {
|
||||
}
|
||||
}
|
||||
|
||||
const requireLogin = useCallback(() => {
|
||||
if (!sessionUser) {
|
||||
const requireLogin = useCallback(async () => {
|
||||
const latest = await refetchSessionUser();
|
||||
if (!latest.data) {
|
||||
setLoginPromptOpen(true);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [sessionUser]);
|
||||
}, [refetchSessionUser]);
|
||||
|
||||
const handleReconnectClick = useCallback(() => {
|
||||
if (!requireLogin()) {
|
||||
requireLogin()
|
||||
.then((isLoggedIn) => {
|
||||
if (!isLoggedIn) {
|
||||
return;
|
||||
}
|
||||
if (brokerStatus?.connected && (brokerStatus?.broker || "").trim().toUpperCase() === "ZERODHA") {
|
||||
return startSavedBrokerReconnect().catch((error: any) =>
|
||||
toast({
|
||||
title: "Reconnect failed",
|
||||
description: error?.message || "Unable to start Zerodha reconnect.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
setBrokerDialogOpen(true);
|
||||
}, [requireLogin]);
|
||||
})
|
||||
.catch(() => {
|
||||
setLoginPromptOpen(true);
|
||||
});
|
||||
}, [brokerStatus, requireLogin]);
|
||||
|
||||
const handleRefreshBrokerData = useCallback(() => {
|
||||
holdingsQuery.refetch();
|
||||
fundsQuery.refetch();
|
||||
equityCurveQuery.refetch();
|
||||
refetchBrokerStatus();
|
||||
}, [equityCurveQuery, fundsQuery, holdingsQuery, refetchBrokerStatus]);
|
||||
|
||||
const handleDisconnectBroker = useCallback(() => {
|
||||
disconnectBrokerMutation.mutate();
|
||||
}, [disconnectBrokerMutation]);
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!requireLogin()) {
|
||||
if (!(await requireLogin())) {
|
||||
return;
|
||||
}
|
||||
if (!isConnected) {
|
||||
setConnectPromptOpen(true);
|
||||
return;
|
||||
}
|
||||
if (showSessionExpired || (brokerStatus?.authState || "").toUpperCase() === "EXPIRED") {
|
||||
toast({
|
||||
title: "Reconnect broker",
|
||||
description: "Your Zerodha session has expired. Reconnect before starting the live strategy.",
|
||||
});
|
||||
handleReconnectClick();
|
||||
return;
|
||||
}
|
||||
setIsStarting(true);
|
||||
try {
|
||||
const result = await startStrategy({
|
||||
strategy_name: "Golden Nifty",
|
||||
initial_cash: availableFunds,
|
||||
sip_amount: sipAmount,
|
||||
sip_frequency: {
|
||||
value: frequencyDays,
|
||||
unit: "days",
|
||||
},
|
||||
mode: "PAPER",
|
||||
mode: "LIVE",
|
||||
});
|
||||
if (result?.status === "broker_auth_required") {
|
||||
toast({
|
||||
title: "Reconnect broker",
|
||||
description: "Your Zerodha session has expired. Reconnect and try again.",
|
||||
});
|
||||
await startSavedBrokerReconnect();
|
||||
return;
|
||||
}
|
||||
if (result?.status === "already_running") {
|
||||
toast({
|
||||
title: "Strategy already running",
|
||||
description: "The engine is already active.",
|
||||
});
|
||||
} else if (result?.status === "started" || result?.status === "restarted") {
|
||||
toast({
|
||||
title: "Live strategy started",
|
||||
description: "Golden Nifty is now armed for live Zerodha execution.",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Start failed",
|
||||
description: result?.message || result?.status || "Unable to start strategy.",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
@ -762,12 +781,7 @@ export default function PortfolioSection() {
|
||||
{isConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
holdingsQuery.refetch();
|
||||
fundsQuery.refetch();
|
||||
equityCurveQuery.refetch();
|
||||
refetchBrokerStatus();
|
||||
}}
|
||||
onClick={handleRefreshBrokerData}
|
||||
disabled={holdingsQuery.isFetching || equityCurveQuery.isFetching}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
@ -776,6 +790,24 @@ export default function PortfolioSection() {
|
||||
: "Refresh data"}
|
||||
</Button>
|
||||
) : null}
|
||||
{isConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReconnectClick}
|
||||
>
|
||||
<PlugZap className="h-4 w-4" />
|
||||
Reconnect broker
|
||||
</Button>
|
||||
) : null}
|
||||
{isConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDisconnectBroker}
|
||||
disabled={disconnectBrokerMutation.isPending}
|
||||
>
|
||||
{disconnectBrokerMutation.isPending ? "Disconnecting..." : "Disconnect broker"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -887,109 +919,6 @@ export default function PortfolioSection() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl overflow-hidden ${hoverLift} ${revealTransition} ${cardRevealClass}`}
|
||||
style={prefersReducedMotion ? undefined : { transitionDelay: "560ms" }}
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 px-6 py-4 border-b border-border/50">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold">System arm</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Re-arm all active strategies after broker login.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="shimmer"
|
||||
onClick={() => armMutation.mutate()}
|
||||
disabled={armMutation.isPending || !isConnected}
|
||||
>
|
||||
<PlugZap className="h-4 w-4" />
|
||||
{armMutation.isPending ? "Arming..." : "Arm All Strategies"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-5 px-6 py-4 text-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Broker</p>
|
||||
<p className="font-semibold">
|
||||
{isConnected ? "Connected" : "Not connected"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Armed</p>
|
||||
<p className="font-semibold">{armedCount}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Market</p>
|
||||
<p className="font-semibold">{marketState}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Next execution</p>
|
||||
<p className="font-semibold">
|
||||
{nextExecution ? formatMinuteTimestamp(nextExecution) : "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Engine</p>
|
||||
<p className="font-semibold">{liveness}</p>
|
||||
</div>
|
||||
</div>
|
||||
{armSummary ? (
|
||||
<div className="px-6 pb-4 text-xs text-muted-foreground">
|
||||
System armed. {armSummary.failed_runs?.length ? "Some runs failed to arm." : "All runs armed."}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl overflow-hidden ${hoverLift} ${revealTransition} ${cardRevealClass}`}
|
||||
style={prefersReducedMotion ? undefined : { transitionDelay: "580ms" }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold">Strategy status</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Live status for every configured strategy run.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{systemRuns.length} total</Badge>
|
||||
</div>
|
||||
{systemStatusQuery.isLoading ? (
|
||||
<ZeroState message="Loading strategy status..." />
|
||||
) : systemRuns.length === 0 ? (
|
||||
<ZeroState message="No strategies configured yet." />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-muted/40 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left font-medium">Strategy</th>
|
||||
<th className="px-6 py-3 text-left font-medium">Mode</th>
|
||||
<th className="px-6 py-3 text-left font-medium">Status</th>
|
||||
<th className="px-6 py-3 text-left font-medium">Next run</th>
|
||||
<th className="px-6 py-3 text-left font-medium">Broker</th>
|
||||
<th className="px-6 py-3 text-left font-medium">Lifecycle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{systemRuns.map((run) => (
|
||||
<tr key={run.run_id}>
|
||||
<td className="px-6 py-3">
|
||||
{run.strategy || "Strategy"}
|
||||
</td>
|
||||
<td className="px-6 py-3">{run.mode || "-"}</td>
|
||||
<td className="px-6 py-3">{run.status}</td>
|
||||
<td className="px-6 py-3">
|
||||
{run.next_run ? new Date(run.next_run).toLocaleString() : "-"}
|
||||
</td>
|
||||
<td className="px-6 py-3">{run.broker || "-"}</td>
|
||||
<td className="px-6 py-3">{run.lifecycle || run.status}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl overflow-hidden ${hoverLift} ${revealTransition} ${cardRevealClass}`}
|
||||
@ -1071,21 +1000,10 @@ export default function PortfolioSection() {
|
||||
{isStopping ? "Stopping..." : "Stop Strategy"}
|
||||
</Button>
|
||||
</div>
|
||||
{marketState === "CLOSED" ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Market closed — execution will resume at next session
|
||||
</p>
|
||||
) : null}
|
||||
{isStrategyRunning ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Strategy running — next SIP will execute when eligible
|
||||
</p>
|
||||
) : null}
|
||||
<StrategyTimeline compact />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StrategyTimeline />
|
||||
|
||||
<div
|
||||
className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl p-6 space-y-4 ${hoverLift} ${revealTransition} ${cardRevealClass}`}
|
||||
style={prefersReducedMotion ? undefined : { transitionDelay: "700ms" }}
|
||||
|
||||
@ -12,7 +12,7 @@ const NORMALIZED_API_BASE_URL = API_BASE_URL
|
||||
: "";
|
||||
const REQUEST_TIMEOUT_MS = 12000;
|
||||
|
||||
function resolveApiUrl(url: string) {
|
||||
export function resolveApiUrl(url: string) {
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
return url;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user