Simplify portfolio strategy UI and reconnect handling

This commit is contained in:
Thigazhezhilan J 2026-03-24 21:59:17 +05:30
parent 469f204452
commit aa93225ef7
4 changed files with 283 additions and 232 deletions

View File

@ -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 />;
}

View File

@ -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">

View File

@ -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" }}

View File

@ -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;
}