2026-02-08 18:32:13 +00:00

1274 lines
44 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type React from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
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 { getStrategyStatus, startStrategy, stopStrategy } from "@/api/strategy";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import StrategyTimeline from "@/components/StrategyTimeline";
import { toast } from "@/hooks/use-toast";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import {
Area,
AreaChart,
CartesianGrid,
XAxis,
YAxis,
} from "recharts";
import type { User } from "@shared/schema";
type BrokerStatusResponse = {
connected: boolean;
broker?: string;
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;
};
};
type HoldingsResponse = {
holdings: any[];
};
type MarketStatusResponse = {
status?: "OPEN" | "CLOSED";
checked_at?: string;
};
type FundsResponse = {
funds?: {
net?: number;
cash?: number;
withdrawable?: number;
utilized?: number;
balance?: number;
};
};
type EquityCurveResponse = {
startDate: string;
endDate: string;
accountOpenDate?: string;
points: { date: string; value: number }[];
};
type EngineStatus = {
state?: string;
run_id?: string | null;
last_heartbeat_ts?: string | null;
last_execution_ts?: string | null;
next_eligible_ts?: string | null;
};
type SessionUser = Pick<User, "id" | "username">;
const MotionButton = motion(Button);
function formatCurrency(amount: number, options?: { decimals?: number }) {
const decimals = options?.decimals ?? 2;
return new Intl.NumberFormat("en-IN", {
style: "currency",
currency: "INR",
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(amount || 0);
}
function formatDateInput(date: Date) {
return date.toISOString().slice(0, 10);
}
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 usePrefersReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setPrefersReducedMotion(mediaQuery.matches);
update();
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener("change", update);
return () => mediaQuery.removeEventListener("change", update);
}
mediaQuery.addListener(update);
return () => mediaQuery.removeListener(update);
}, []);
return prefersReducedMotion;
}
export default function PortfolioSection() {
const sectionRef = useRef<HTMLElement | null>(null);
const [isVisible, setIsVisible] = useState(false);
const [scrollY, setScrollY] = useState(0);
const prefersReducedMotion = usePrefersReducedMotion();
const [loginPromptOpen, setLoginPromptOpen] = useState(false);
const [connectPromptOpen, setConnectPromptOpen] = useState(false);
const [brokerDialogOpen, setBrokerDialogOpen] = useState(false);
const [sessionExpired, setSessionExpired] = useState(false);
const [reconnectAttempted, setReconnectAttempted] = useState(false);
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,
refetch: refetchBrokerStatus,
} = useQuery<BrokerStatusResponse | null>({
queryKey: ["/broker/status"],
queryFn: getQueryFn<BrokerStatusResponse>({ on401: "returnNull" }),
staleTime: 0,
refetchOnMount: "always",
});
const { data: sessionUser } = useQuery<SessionUser | null>({
queryKey: ["/me"],
queryFn: getQueryFn<SessionUser | null>({ on401: "returnNull" }),
});
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 = {};
try {
payload = await res.json();
} catch {}
const redirect =
payload?.detail?.redirect_url ||
payload?.redirect_url ||
"/broker/login";
window.location.assign(redirect);
return null;
}
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);
toast({
title: "System armed",
description: data.next_execution
? `Next execution at ${new Date(data.next_execution).toLocaleString()}`
: "All strategies are armed.",
});
systemStatusQuery.refetch();
refetchBrokerStatus();
},
onError: (err: any) =>
toast({
title: "Arm failed",
description: err?.message || "Unable to arm system.",
}),
});
const holdingsQuery = useQuery<HoldingsResponse>({
queryKey: ["/zerodha/holdings"],
queryFn: async () => {
const res = await apiRequest("GET", "/zerodha/holdings");
return res.json();
},
enabled: !!brokerStatus?.connected,
retry: 1,
retryDelay: 600,
onSuccess: (data) => {
setCachedHoldings(data?.holdings || []);
setSessionExpired(false);
setReconnectAttempted(false);
},
onError: () => {
if (brokerStatus?.connected) {
setSessionExpired(true);
}
},
});
const fundsQuery = useQuery<FundsResponse>({
queryKey: ["/zerodha/funds"],
queryFn: async () => {
const res = await apiRequest("GET", "/zerodha/funds");
return res.json();
},
enabled: !!brokerStatus?.connected,
retry: 1,
retryDelay: 600,
onSuccess: (data) => {
setCachedFunds(data?.funds ?? null);
setSessionExpired(false);
setReconnectAttempted(false);
},
onError: () => {
if (brokerStatus?.connected) {
setSessionExpired(true);
}
},
});
const [startDate, setStartDate] = useState(() =>
formatDateInput(new Date(Date.now() - 90 * 24 * 60 * 60 * 1000)),
);
const [sipAmount, setSipAmount] = useState(5000);
const [frequencyDays, setFrequencyDays] = useState(30);
const [strategyStatus, setStrategyStatus] = useState("STOPPED");
const [isStarting, setIsStarting] = useState(false);
const [isStopping, setIsStopping] = useState(false);
const [engineStatus, setEngineStatus] = useState<EngineStatus | null>(null);
const [marketStatus, setMarketStatus] = useState<MarketStatusResponse | null>(null);
const linkedAtDate = brokerStatus?.connected_at
? new Date(brokerStatus.connected_at)
: null;
const linkedAtInput =
linkedAtDate && !isNaN(linkedAtDate.getTime())
? formatDateInput(linkedAtDate)
: undefined;
useEffect(() => {
if (linkedAtInput) {
setStartDate((prev) => (prev === linkedAtInput ? prev : linkedAtInput));
}
}, [linkedAtInput]);
const refreshStatus = useCallback(async () => {
try {
const status = await getStrategyStatus();
setStrategyStatus(status?.status ?? "STOPPED");
} catch (error) {
setStrategyStatus("STOPPED");
}
}, []);
useEffect(() => {
refreshStatus();
const interval = window.setInterval(refreshStatus, 15000);
return () => window.clearInterval(interval);
}, [refreshStatus]);
useEffect(() => {
const fetchStatus = async () => {
try {
const res = await fetch("/engine/status");
const data = await res.json();
setEngineStatus(data);
} catch {
setEngineStatus(null);
}
};
fetchStatus();
const id = window.setInterval(fetchStatus, 5000);
return () => window.clearInterval(id);
}, []);
useEffect(() => {
const fetchMarketStatus = async () => {
try {
const res = await fetch("/market/status");
const data = await res.json();
setMarketStatus(data);
} catch {
setMarketStatus(null);
}
};
fetchMarketStatus();
const id = window.setInterval(fetchMarketStatus, 5000);
return () => window.clearInterval(id);
}, []);
useEffect(() => {
if (prefersReducedMotion) {
setIsVisible(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
}
},
{ threshold: 0.2 },
);
if (sectionRef.current) {
observer.observe(sectionRef.current);
}
return () => observer.disconnect();
}, [prefersReducedMotion]);
useEffect(() => {
if (prefersReducedMotion) {
setScrollY(0);
return;
}
let rafId: number | null = null;
const handleScroll = () => {
if (rafId !== null) return;
rafId = window.requestAnimationFrame(() => {
setScrollY(window.scrollY);
rafId = null;
});
};
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll();
return () => {
window.removeEventListener("scroll", handleScroll);
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
};
}, [prefersReducedMotion]);
const equityCurveQuery = useQuery<EquityCurveResponse>({
queryKey: ["/zerodha/equity-curve", startDate],
queryFn: async () => {
const res = await apiRequest(
"GET",
`/zerodha/equity-curve${startDate ? `?from=${startDate}` : ""}`,
);
return res.json();
},
enabled: !!brokerStatus?.connected,
retry: 1,
retryDelay: 600,
onSuccess: (data) => {
setCachedEquityCurve(data ?? null);
setSessionExpired(false);
setReconnectAttempted(false);
},
onError: () => {
if (brokerStatus?.connected) {
setSessionExpired(true);
}
},
});
const isConnected = !!brokerStatus?.connected;
const isAuthed = brokerStatus !== null;
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);
setReconnectAttempted(false);
}
}, [isConnected]);
useEffect(() => {
if (!sessionExpired || reconnectAttempted || !isConnected) {
return;
}
setReconnectAttempted(true);
(async () => {
try {
await refetchBrokerStatus();
await Promise.all([
holdingsQuery.refetch(),
fundsQuery.refetch(),
equityCurveQuery.refetch(),
]);
} catch {
return;
}
})();
}, [
sessionExpired,
reconnectAttempted,
isConnected,
refetchBrokerStatus,
holdingsQuery,
fundsQuery,
equityCurveQuery,
]);
const availableFunds =
fundsSnapshot?.balance ??
fundsSnapshot?.net ??
fundsSnapshot?.withdrawable ??
fundsSnapshot?.cash ??
fundsSnapshot?.raw?.net ??
fundsSnapshot?.raw?.available?.live_balance ??
fundsSnapshot?.raw?.available?.opening_balance ??
0;
const { totalValue, totalPnl } = useMemo(() => {
return holdings.reduce(
(acc, item) => {
const qty = Number(item.quantity ?? item.qty ?? 0);
const last = Number(item.last_price ?? item.average_price ?? 0);
const pnl = Number(item.pnl ?? 0);
return {
totalValue: acc.totalValue + qty * last,
totalPnl: acc.totalPnl + pnl,
};
},
{ totalValue: 0, totalPnl: 0 },
);
}, [holdings]);
const equityCurve = equityCurveQuery.data ?? cachedEquityCurve;
const equityCurvePoints = equityCurve?.points ?? [];
const showSessionExpired = sessionExpired && isConnected;
const normalizedStrategyStatus =
strategyStatus === "RUNNING" ? "RUNNING" : "STOPPED";
const isStrategyRunning = normalizedStrategyStatus === "RUNNING";
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 marketState = marketStatus?.status ?? "UNKNOWN";
const executionAllowed =
marketState === "OPEN" && (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 requireLogin = useCallback(() => {
if (!sessionUser) {
setLoginPromptOpen(true);
return false;
}
return true;
}, [sessionUser]);
const handleReconnectClick = useCallback(() => {
if (!requireLogin()) {
return;
}
setBrokerDialogOpen(true);
}, [requireLogin]);
const handleStart = async () => {
if (!requireLogin()) {
return;
}
if (!isConnected) {
setConnectPromptOpen(true);
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",
});
if (result?.status === "already_running") {
toast({
title: "Strategy already running",
description: "The engine is already active.",
});
}
} finally {
setIsStarting(false);
await refreshStatus();
}
};
const handleStop = async () => {
setIsStopping(true);
try {
await stopStrategy();
} finally {
setIsStopping(false);
await refreshStatus();
}
};
const summaryCards = [
{
icon: <Wallet className="h-5 w-5" />,
label: "Available funds",
value: formatCurrency(isConnected ? availableFunds : 0, { decimals: 2 }),
muted: !isConnected,
subText:
isConnected && fundsQuery.data?.funds?.utilized !== undefined
? `Utilized: ${formatCurrency(fundsQuery.data?.funds?.utilized || 0, { decimals: 2 })}`
: undefined,
},
{
icon: <Wallet className="h-5 w-5" />,
label: "Portfolio value",
value: formatCurrency(totalValue, { decimals: 2 }),
muted: !isConnected,
},
{
icon: <BarChart3 className="h-5 w-5" />,
label: "Positions",
value: holdings.length.toString(),
muted: !isConnected,
},
{
icon: <AlertCircle className="h-5 w-5" />,
label: "Unrealized P&L",
value: formatCurrency(totalPnl, { decimals: 2 }),
muted: !isConnected,
},
];
const revealTransition = prefersReducedMotion ? "" : "transition-all duration-700";
const sectionRevealClass = prefersReducedMotion
? "opacity-100"
: isVisible
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-6";
const cardRevealClass = prefersReducedMotion
? "opacity-100"
: isVisible
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-4";
const hoverLift = prefersReducedMotion
? ""
: "transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl hover:shadow-primary/10";
const parallaxOffset = prefersReducedMotion ? 0 : Math.min(scrollY * 0.3, 220);
const cardContainer = {
hidden: {},
show: {
transition: {
staggerChildren: 0.12,
},
},
};
const cardItem = {
hidden: { opacity: 0, y: 14 },
show: { opacity: 1, y: 0 },
};
const summaryAnimate = prefersReducedMotion || isVisible ? "show" : "hidden";
const ctaMotionProps = prefersReducedMotion
? {}
: {
whileHover: { scale: 1.02 },
whileTap: { scale: 0.97 },
transition: { type: "spring", stiffness: 400, damping: 25 },
};
return (
<section
ref={sectionRef}
id="portfolio"
className={`relative overflow-hidden py-32 px-6 pb-32 bg-gradient-to-b from-background to-background/80 ${revealTransition} ${sectionRevealClass}`}
>
<LoginRequiredDialog
open={loginPromptOpen}
onOpenChange={setLoginPromptOpen}
context="start the strategy"
/>
<LoginRequiredDialog
open={connectPromptOpen}
onOpenChange={setConnectPromptOpen}
title="Connect broker"
message="Connect your broker to start the strategy."
/>
<div className="absolute inset-0 pointer-events-none">
<div
className="absolute -top-24 left-1/4 h-72 w-72 rounded-full bg-primary/10 blur-3xl"
style={{ transform: `translate3d(0, ${parallaxOffset}px, 0)` }}
/>
<div
className="absolute top-6 right-[8%] h-80 w-80 rounded-full bg-chart-2/10 blur-3xl"
style={{ transform: `translate3d(0, ${parallaxOffset}px, 0)` }}
/>
<div
className="absolute top-32 left-8 h-48 w-48 rounded-full bg-chart-3/10 blur-3xl"
style={{ transform: `translate3d(0, ${parallaxOffset}px, 0)` }}
/>
</div>
<div className="relative z-10 max-w-6xl mx-auto space-y-10">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="space-y-2">
<p className="text-sm uppercase tracking-[0.25em] text-muted-foreground">Portfolio</p>
<h3 className="text-3xl md:text-4xl font-bold">Your holdings & live positions</h3>
<p className="text-muted-foreground max-w-2xl">
Connect your broker to sync holdings. When disconnected, values stay at zero and you will see a prompt to connect.
</p>
{isConnected && (brokerStatus?.userName || brokerStatus?.broker) ? (
<div className="inline-flex items-center gap-2 rounded-full border border-primary/50 bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
<Wallet className="h-4 w-4" />
{brokerStatus?.userName ? (
<>
Connected as <span className="font-semibold">{brokerStatus.userName}</span>
</>
) : (
<>
Connected to <span className="font-semibold">{brokerStatus?.broker}</span>
</>
)}
</div>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<BrokerConnectDialog open={brokerDialogOpen} onOpenChange={setBrokerDialogOpen} />
<Button variant="secondary" asChild>
<a href="/portfolio/paper" target="_blank" rel="noreferrer">
Paper Trading Portfolio
</a>
</Button>
{isConnected ? (
<Button
variant="outline"
onClick={() => {
holdingsQuery.refetch();
fundsQuery.refetch();
equityCurveQuery.refetch();
refetchBrokerStatus();
}}
disabled={holdingsQuery.isFetching || equityCurveQuery.isFetching}
>
<RefreshCcw className="h-4 w-4" />
{holdingsQuery.isFetching || equityCurveQuery.isFetching
? "Refreshing..."
: "Refresh data"}
</Button>
) : null}
</div>
</div>
<motion.div
className="grid gap-4 md:grid-cols-3"
variants={cardContainer}
initial="hidden"
animate={summaryAnimate}
>
{summaryCards.map((card) => (
<motion.div
key={card.label}
variants={cardItem}
whileHover={{
y: -6,
boxShadow: "0 20px 40px rgba(0,0,0,0.18)",
}}
transition={{ type: "spring", stiffness: 300, damping: 24 }}
>
<SummaryCard {...card} prefersReducedMotion={prefersReducedMotion} />
</motion.div>
))}
</motion.div>
<div
className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl overflow-hidden ${hoverLift} ${revealTransition} ${cardRevealClass}`}
style={prefersReducedMotion ? undefined : { transitionDelay: "500ms" }}
>
<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">Holdings</p>
<p className="text-xs text-muted-foreground">
Current positions pulled from your connected broker.
</p>
</div>
<Badge variant={isConnected ? "secondary" : "outline"}>
{isConnected ? "Broker connected" : "Not connected"}
</Badge>
</div>
{!isAuthed ? (
<ZeroState message="Log in and connect your broker to see your portfolio." />
) : brokerStatusLoading ? (
<ZeroState message="Checking broker status..." />
) : !isConnected ? (
<ZeroState message="Connect to broker to see your portfolio." />
) : holdingsQuery.isLoading ? (
<ZeroState message="Fetching holdings..." />
) : holdingsQuery.isError && holdings.length === 0 ? (
<ZeroState
message={
showSessionExpired
? "Session expired. Reconnect to refresh holdings and funds."
: "Could not fetch holdings. Try refreshing."
}
/>
) : noHoldings ? (
<ZeroState message="No holdings yet. Refresh after connecting." />
) : (
<div className="overflow-x-auto">
{showSessionExpired ? (
<div className="flex flex-wrap items-center justify-between gap-2 px-6 py-3 text-xs text-amber-200 bg-amber-500/10 border-b border-amber-400/20">
<span>Session expired. Showing the last known holdings. Reconnect to refresh.</span>
<Button size="sm" variant="secondary" onClick={handleReconnectClick}>
Reconnect broker
</Button>
</div>
) : null}
<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">Symbol</th>
<th className="px-6 py-3 text-left font-medium">Qty</th>
<th className="px-6 py-3 text-left font-medium">Avg price</th>
<th className="px-6 py-3 text-left font-medium">LTP</th>
<th className="px-6 py-3 text-left font-medium">P&L</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{holdings.map((item, idx) => {
const qty = Number(item.quantity ?? item.qty ?? 0);
const avg = Number(item.average_price ?? item.avg_price ?? 0);
const ltp = Number(item.last_price ?? 0);
const pnl = Number(item.pnl ?? 0);
return (
<tr key={`${item.tradingsymbol || item.instrument_token || idx}`}>
<td className="px-6 py-3">
<div className="flex items-center gap-2">
<span className="font-semibold">
{item.tradingsymbol || item.symbol || "Instrument"}
</span>
<Badge variant="outline">{item.exchange || item.exchange_type || "N/A"}</Badge>
</div>
</td>
<td className="px-6 py-3">{qty}</td>
<td className="px-6 py-3">{formatCurrency(avg, { decimals: 2 })}</td>
<td className="px-6 py-3">{ltp ? formatCurrency(ltp, { decimals: 2 }) : "-"}</td>
<td className="px-6 py-3">
<span className={pnl >= 0 ? "text-emerald-500" : "text-red-500"}>
{formatCurrency(pnl, { decimals: 2 })}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</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}`}
style={prefersReducedMotion ? undefined : { transitionDelay: "600ms" }}
>
<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 control</p>
<p className="text-xs text-muted-foreground">
Start or stop the Golden Nifty SIP engine from the dashboard.
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className={livenessBadgeClass}>
{liveness}
</Badge>
<Badge
variant="outline"
className={
normalizedStrategyStatus === "RUNNING"
? "border-emerald-500/50 bg-emerald-500/10 text-emerald-400"
: "border-red-500/40 bg-red-500/10 text-red-400"
}
>
{normalizedStrategyStatus}
</Badge>
</div>
</div>
<div className="p-6 space-y-4">
<div className="rounded-lg border border-border/60 bg-background/40 px-4 py-3">
<div className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Next eligible SIP
</div>
<div className="text-sm font-semibold text-foreground">{nextEligibleLine}</div>
{eligibilityStatus ? (
<div className={`text-xs ${eligibilityClass}`}>{eligibilityStatus}</div>
) : null}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="strategy-sip">SIP Amount</Label>
<Input
id="strategy-sip"
type="number"
min={0}
step={100}
value={sipAmount}
onChange={(event) => {
const value = Number(event.target.value);
setSipAmount(Number.isNaN(value) ? 0 : value);
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="strategy-frequency">Frequency (days)</Label>
<Input
id="strategy-frequency"
type="number"
min={1}
step={1}
value={frequencyDays}
onChange={(event) => {
const value = Number(event.target.value);
setFrequencyDays(Number.isNaN(value) ? 1 : value);
}}
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<MotionButton
{...ctaMotionProps}
onClick={handleStart}
disabled={isStarting || !executionAllowed || isStrategyRunning}
className="shimmer"
>
{isStarting ? "Starting..." : "Start Strategy"}
</MotionButton>
<Button variant="outline" onClick={handleStop} disabled={isStopping}>
{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}
</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" }}
>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div>
<p className="text-sm font-semibold">Equity curve</p>
<p className="text-xs text-muted-foreground">
Track your account value over time from your demat open date.
</p>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="equity-start" className="text-xs text-muted-foreground">
From
</Label>
<Input
id="equity-start"
type="date"
value={startDate}
max={formatDateInput(new Date())}
min={linkedAtInput}
onChange={(e) => setStartDate(e.target.value)}
className="w-[180px]"
disabled={!isConnected}
/>
</div>
</div>
{!isConnected ? (
<ZeroState message="Connect to broker to see your equity curve." />
) : equityCurveQuery.isLoading && equityCurvePoints.length === 0 ? (
<ZeroState message="Loading equity curve..." />
) : equityCurveQuery.isError && equityCurvePoints.length === 0 ? (
<ZeroState message="Could not load equity curve." />
) : equityCurvePoints.length === 0 ? (
<ZeroState message="Could not load equity curve." />
) : (
<div className="h-80">
<ChartContainer
config={{
equity: { label: "Equity", color: "hsl(var(--chart-1))" },
}}
className="rounded-xl bg-background/60"
>
<AreaChart data={equityCurvePoints}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="date"
tickFormatter={(value) => new Date(value).toLocaleDateString("en-IN", { month: "short", day: "numeric" })}
tickLine={false}
axisLine={false}
minTickGap={24}
/>
<YAxis
tickFormatter={(v) =>
new Intl.NumberFormat("en-IN", { maximumFractionDigits: 0, notation: "compact" }).format(v as number)
}
width={80}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(value) =>
new Date(value as string).toLocaleDateString("en-IN", {
year: "numeric",
month: "short",
day: "numeric",
})
}
formatter={(val) => formatCurrency(Number(val))}
/>
}
/>
<Area
type="monotone"
dataKey="value"
stroke="var(--color-equity)"
fill="var(--color-equity)"
fillOpacity={0.15}
strokeWidth={2}
/>
</AreaChart>
</ChartContainer>
<p className="text-xs text-muted-foreground mt-2">
Account open date: {equityCurve?.accountOpenDate
? new Date(equityCurve.accountOpenDate).toLocaleDateString("en-IN")
: "unknown"}
</p>
</div>
)}
</div>
</div>
</section>
);
}
function SummaryCard({
icon,
label,
value,
muted,
subText,
prefersReducedMotion,
}: {
icon: React.ReactNode;
label: string;
value: string;
muted?: boolean;
subText?: string;
prefersReducedMotion: boolean;
}) {
const cardRef = useRef<HTMLDivElement | null>(null);
const [transform, setTransform] = useState({ rotateX: 0, rotateY: 0, scale: 1 });
const hoverClass = prefersReducedMotion
? ""
: "transition-all duration-300";
const handleMouseMove = (event: React.MouseEvent<HTMLDivElement>) => {
if (prefersReducedMotion || !cardRef.current) return;
const rect = cardRef.current.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width - 0.5;
const y = (event.clientY - rect.top) / rect.height - 0.5;
setTransform({
rotateX: y * -8,
rotateY: x * 8,
scale: 1.02,
});
};
const handleMouseLeave = () => {
if (prefersReducedMotion) return;
setTransform({ rotateX: 0, rotateY: 0, scale: 1 });
};
const tiltStyle = prefersReducedMotion
? undefined
: {
transform: `perspective(900px) rotateX(${transform.rotateX}deg) rotateY(${transform.rotateY}deg) scale(${transform.scale})`,
transition: "transform 0.12s ease-out",
};
return (
<div
ref={cardRef}
className={`rounded-xl border border-border/70 bg-card/80 p-4 shadow-sm ${hoverClass}`}
style={prefersReducedMotion ? undefined : { perspective: "900px" }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
<div className="will-change-transform" style={tiltStyle}>
<div className="flex items-center gap-3">
<div className="rounded-full bg-primary/10 p-2 text-primary">{icon}</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{label}</p>
<p className={`text-xl font-semibold ${muted ? "text-muted-foreground" : "text-foreground"}`}>
<motion.span
key={value}
initial={{ opacity: 0.6, y: 3 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
>
{value}
</motion.span>
</p>
{subText ? <p className="text-xs text-muted-foreground">{subText}</p> : null}
</div>
</div>
</div>
</div>
);
}
function ZeroState({ message }: { message: string }) {
return (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
<div className="rounded-full bg-muted/70 p-3 text-muted-foreground">
<Wallet className="h-6 w-6" />
</div>
<p className="text-sm font-medium">{message}</p>
<p className="text-xs text-muted-foreground max-w-md">
Once connected, we will pull your latest holdings and live positions securely from your broker.
</p>
</div>
);
}