Simplify portfolio strategy UI and reconnect handling
This commit is contained in:
parent
469f204452
commit
aa93225ef7
@ -18,6 +18,19 @@ type StrategyRun = {
|
|||||||
events: StrategyEvent[];
|
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 {
|
function normalizeLog(log: unknown): StrategyEvent {
|
||||||
if (typeof log === "string") {
|
if (typeof log === "string") {
|
||||||
try {
|
try {
|
||||||
@ -45,6 +58,16 @@ function formatTimestamp(value?: string) {
|
|||||||
return parsed.toLocaleString();
|
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) {
|
function getRunId(entry: StrategyEvent) {
|
||||||
return entry.run_id ?? "unknown";
|
return entry.run_id ?? "unknown";
|
||||||
}
|
}
|
||||||
@ -104,7 +127,62 @@ function getStopReason(events: StrategyEvent[]) {
|
|||||||
return typeof reason === "string" ? reason : null;
|
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 [logs, setLogs] = useState<StrategyEvent[]>([]);
|
||||||
const latestSeqRef = useRef(0);
|
const latestSeqRef = useRef(0);
|
||||||
|
|
||||||
@ -118,10 +196,10 @@ export default function StrategyTimeline() {
|
|||||||
|
|
||||||
setLogs((prev) => {
|
setLogs((prev) => {
|
||||||
const seenSeq = new Set(
|
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(
|
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];
|
return [...prev, ...next];
|
||||||
});
|
});
|
||||||
@ -131,7 +209,7 @@ export default function StrategyTimeline() {
|
|||||||
} else {
|
} else {
|
||||||
const lastSeq = normalized.reduce(
|
const lastSeq = normalized.reduce(
|
||||||
(max, entry) => Math.max(max, entry.seq ?? 0),
|
(max, entry) => Math.max(max, entry.seq ?? 0),
|
||||||
latestSeqRef.current
|
latestSeqRef.current,
|
||||||
);
|
);
|
||||||
latestSeqRef.current = lastSeq;
|
latestSeqRef.current = lastSeq;
|
||||||
}
|
}
|
||||||
@ -190,7 +268,7 @@ export default function StrategyTimeline() {
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold text-white/90">
|
<div className="text-sm font-semibold text-white/90">
|
||||||
▶ Run started at {formatTimestamp(startTime)}
|
Run started at {formatTimestamp(startTime)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-green-300/70">Run ID: {run.runId}</div>
|
<div className="text-xs text-green-300/70">Run ID: {run.runId}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -264,3 +342,10 @@ export default function StrategyTimeline() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function StrategyTimeline({ compact = false }: StrategyTimelineProps) {
|
||||||
|
if (compact) {
|
||||||
|
return <CompactStrategySummary />;
|
||||||
|
}
|
||||||
|
return <VerboseStrategyTimeline />;
|
||||||
|
}
|
||||||
|
|||||||
@ -32,6 +32,7 @@ type BrokerStatusResponse = {
|
|||||||
connected_at?: string;
|
connected_at?: string;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
brokerUserId?: string;
|
brokerUserId?: string;
|
||||||
|
authState?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CALLBACK_STORAGE_KEY = "zerodha:callback";
|
const CALLBACK_STORAGE_KEY = "zerodha:callback";
|
||||||
@ -61,9 +62,11 @@ export default function BrokerConnectDialog({
|
|||||||
const [apiSecret, setApiSecret] = useState("");
|
const [apiSecret, setApiSecret] = useState("");
|
||||||
const [holdings, setHoldings] = useState<any[]>([]);
|
const [holdings, setHoldings] = useState<any[]>([]);
|
||||||
|
|
||||||
const { data: sessionUser } = useQuery<SessionUser | null>({
|
const { data: sessionUser, refetch: refetchSessionUser } = useQuery<SessionUser | null>({
|
||||||
queryKey: ["/me"],
|
queryKey: ["/me"],
|
||||||
queryFn: getQueryFn<SessionUser | null>({ on401: "returnNull" }),
|
queryFn: getQueryFn<SessionUser | null>({ on401: "returnNull" }),
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: "always",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: brokerStatus, refetch: refetchStatus } = useQuery<BrokerStatusResponse | null>({
|
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." }),
|
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({
|
const holdingsMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await apiRequest("GET", "/zerodha/holdings");
|
const res = await apiRequest("GET", "/zerodha/holdings");
|
||||||
@ -133,12 +164,15 @@ export default function BrokerConnectDialog({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const connected = !!brokerStatus?.connected;
|
const connected = !!brokerStatus?.connected;
|
||||||
|
const canReconnectWithSavedZerodha =
|
||||||
|
connected && (brokerStatus?.broker || "").trim().toUpperCase() === "ZERODHA";
|
||||||
const connectedAt = brokerStatus?.connected_at
|
const connectedAt = brokerStatus?.connected_at
|
||||||
? new Date(brokerStatus.connected_at)
|
? new Date(brokerStatus.connected_at)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const handleConnectClick = () => {
|
const handleConnectClick = async () => {
|
||||||
if (!sessionUser) {
|
const latest = await refetchSessionUser();
|
||||||
|
if (!latest.data) {
|
||||||
setLoginPromptOpen(true);
|
setLoginPromptOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -297,6 +331,16 @@ export default function BrokerConnectDialog({
|
|||||||
<ArrowUpRight className="h-4 w-4" />
|
<ArrowUpRight className="h-4 w-4" />
|
||||||
{loginUrlMutation.isPending ? "Opening Zerodha..." : "Open Zerodha login"}
|
{loginUrlMutation.isPending ? "Opening Zerodha..." : "Open Zerodha login"}
|
||||||
</Button>
|
</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 ? (
|
{connected ? (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
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
|
After you log in with your Zerodha account, we will connect automatically. Keep this tab open
|
||||||
until the login completes.
|
until the login completes.
|
||||||
</p>
|
</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>
|
</motion.div>
|
||||||
|
|
||||||
<div className="space-y-2 rounded-lg border border-dashed border-border/50 p-4 text-sm text-muted-foreground">
|
<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 { Wallet, BarChart3, AlertCircle, RefreshCcw, PlugZap } from "lucide-react";
|
||||||
import BrokerConnectDialog from "./BrokerConnectDialog";
|
import BrokerConnectDialog from "./BrokerConnectDialog";
|
||||||
import LoginRequiredDialog from "./LoginRequiredDialog";
|
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 { getStrategyStatus, 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";
|
||||||
@ -33,38 +33,7 @@ type BrokerStatusResponse = {
|
|||||||
connected_at?: string;
|
connected_at?: string;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
brokerUserId?: string;
|
brokerUserId?: string;
|
||||||
};
|
authState?: 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 = {
|
type HoldingsResponse = {
|
||||||
@ -177,7 +146,6 @@ export default function PortfolioSection() {
|
|||||||
const [cachedHoldings, setCachedHoldings] = useState<any[]>([]);
|
const [cachedHoldings, setCachedHoldings] = useState<any[]>([]);
|
||||||
const [cachedFunds, setCachedFunds] = useState<FundsResponse["funds"] | null>(null);
|
const [cachedFunds, setCachedFunds] = useState<FundsResponse["funds"] | null>(null);
|
||||||
const [cachedEquityCurve, setCachedEquityCurve] = useState<EquityCurveResponse | null>(null);
|
const [cachedEquityCurve, setCachedEquityCurve] = useState<EquityCurveResponse | null>(null);
|
||||||
const [armSummary, setArmSummary] = useState<SystemArmResponse | null>(null);
|
|
||||||
const {
|
const {
|
||||||
data: brokerStatus,
|
data: brokerStatus,
|
||||||
isFetching: brokerStatusLoading,
|
isFetching: brokerStatusLoading,
|
||||||
@ -188,55 +156,67 @@ export default function PortfolioSection() {
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
});
|
});
|
||||||
const { data: sessionUser } = useQuery<SessionUser | null>({
|
const { data: sessionUser, refetch: refetchSessionUser } = useQuery<SessionUser | null>({
|
||||||
queryKey: ["/me"],
|
queryKey: ["/me"],
|
||||||
queryFn: getQueryFn<SessionUser | null>({ on401: "returnNull" }),
|
queryFn: getQueryFn<SessionUser | null>({ on401: "returnNull" }),
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: "always",
|
||||||
});
|
});
|
||||||
const systemStatusQuery = useQuery<SystemStatusResponse>({
|
|
||||||
queryKey: ["/system/status"],
|
async function startSavedBrokerReconnect() {
|
||||||
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 {
|
try {
|
||||||
payload = await res.json();
|
const res = await apiRequest("GET", "/broker/login-url");
|
||||||
} catch {}
|
const data = (await res.json()) as { loginUrl?: string };
|
||||||
const redirect =
|
if (!data?.loginUrl) {
|
||||||
payload?.detail?.redirect_url ||
|
throw new Error("Reconnect link unavailable.");
|
||||||
payload?.redirect_url ||
|
|
||||||
"/broker/login";
|
|
||||||
window.location.assign(redirect);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
window.open(data.loginUrl, "_blank", "noopener,noreferrer");
|
||||||
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({
|
toast({
|
||||||
title: "System armed",
|
title: "Continue in Zerodha",
|
||||||
description: data.next_execution
|
description: "Log in and return here. We will reconnect your broker automatically.",
|
||||||
? `Next execution at ${new Date(data.next_execution).toLocaleString()}`
|
});
|
||||||
: "All strategies are armed.",
|
} 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) =>
|
onError: (err: any) =>
|
||||||
toast({
|
toast({
|
||||||
title: "Arm failed",
|
title: "Disconnect failed",
|
||||||
description: err?.message || "Unable to arm system.",
|
description: err?.message || "Unable to disconnect broker.",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -325,7 +305,7 @@ export default function PortfolioSection() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStatus = async () => {
|
const fetchStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/engine/status");
|
const res = await apiRequest("GET", "/engine/status");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setEngineStatus(data);
|
setEngineStatus(data);
|
||||||
} catch {
|
} catch {
|
||||||
@ -341,7 +321,7 @@ export default function PortfolioSection() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchMarketStatus = async () => {
|
const fetchMarketStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/market/status");
|
const res = await apiRequest("GET", "/market/status");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setMarketStatus(data);
|
setMarketStatus(data);
|
||||||
} catch {
|
} catch {
|
||||||
@ -431,18 +411,6 @@ export default function PortfolioSection() {
|
|||||||
const holdings = holdingsQuery.data ? holdingsQuery.data.holdings : cachedHoldings;
|
const holdings = holdingsQuery.data ? holdingsQuery.data.holdings : cachedHoldings;
|
||||||
const fundsSnapshot = fundsQuery.data?.funds ?? cachedFunds;
|
const fundsSnapshot = fundsQuery.data?.funds ?? cachedFunds;
|
||||||
const noHoldings = holdings.length === 0;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
setSessionExpired(false);
|
setSessionExpired(false);
|
||||||
@ -549,7 +517,7 @@ export default function PortfolioSection() {
|
|||||||
const isEligible = eligibleSeconds <= 0;
|
const isEligible = eligibleSeconds <= 0;
|
||||||
const relativeEligible = formatRelativeSeconds(eligibleSeconds);
|
const relativeEligible = formatRelativeSeconds(eligibleSeconds);
|
||||||
|
|
||||||
let nextEligibleLine = "—";
|
let nextEligibleLine = "-";
|
||||||
let eligibilityStatus: string | null = "First execution pending";
|
let eligibilityStatus: string | null = "First execution pending";
|
||||||
let eligibilityClass = "text-muted-foreground";
|
let eligibilityClass = "text-muted-foreground";
|
||||||
|
|
||||||
@ -557,11 +525,11 @@ export default function PortfolioSection() {
|
|||||||
eligibilityStatus = null;
|
eligibilityStatus = null;
|
||||||
if (isEligible && marketState === "OPEN") {
|
if (isEligible && marketState === "OPEN") {
|
||||||
nextEligibleLine = "Now";
|
nextEligibleLine = "Now";
|
||||||
eligibilityStatus = "Eligible — execution imminent";
|
eligibilityStatus = "Eligible. Execution imminent.";
|
||||||
eligibilityClass = "text-emerald-400";
|
eligibilityClass = "text-emerald-400";
|
||||||
} else if (isEligible && marketState === "CLOSED") {
|
} else if (isEligible && marketState === "CLOSED") {
|
||||||
nextEligibleLine = `${formatMinuteTimestamp(nextEligibleTs)} (eligible)`;
|
nextEligibleLine = `${formatMinuteTimestamp(nextEligibleTs)} (eligible)`;
|
||||||
eligibilityStatus = "Eligible — waiting for market open";
|
eligibilityStatus = "Eligible. Waiting for market open.";
|
||||||
eligibilityClass = "text-amber-300";
|
eligibilityClass = "text-amber-300";
|
||||||
} else {
|
} else {
|
||||||
nextEligibleLine = `${formatMinuteTimestamp(nextEligibleTs)} (${relativeEligible})`;
|
nextEligibleLine = `${formatMinuteTimestamp(nextEligibleTs)} (${relativeEligible})`;
|
||||||
@ -570,46 +538,97 @@ export default function PortfolioSection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const requireLogin = useCallback(() => {
|
const requireLogin = useCallback(async () => {
|
||||||
if (!sessionUser) {
|
const latest = await refetchSessionUser();
|
||||||
|
if (!latest.data) {
|
||||||
setLoginPromptOpen(true);
|
setLoginPromptOpen(true);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}, [sessionUser]);
|
}, [refetchSessionUser]);
|
||||||
|
|
||||||
const handleReconnectClick = useCallback(() => {
|
const handleReconnectClick = useCallback(() => {
|
||||||
if (!requireLogin()) {
|
requireLogin()
|
||||||
|
.then((isLoggedIn) => {
|
||||||
|
if (!isLoggedIn) {
|
||||||
return;
|
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);
|
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 () => {
|
const handleStart = async () => {
|
||||||
if (!requireLogin()) {
|
if (!(await requireLogin())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
setConnectPromptOpen(true);
|
setConnectPromptOpen(true);
|
||||||
return;
|
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);
|
setIsStarting(true);
|
||||||
try {
|
try {
|
||||||
const result = await startStrategy({
|
const result = await startStrategy({
|
||||||
strategy_name: "Golden Nifty",
|
strategy_name: "Golden Nifty",
|
||||||
initial_cash: availableFunds,
|
|
||||||
sip_amount: sipAmount,
|
sip_amount: sipAmount,
|
||||||
sip_frequency: {
|
sip_frequency: {
|
||||||
value: frequencyDays,
|
value: frequencyDays,
|
||||||
unit: "days",
|
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") {
|
if (result?.status === "already_running") {
|
||||||
toast({
|
toast({
|
||||||
title: "Strategy already running",
|
title: "Strategy already running",
|
||||||
description: "The engine is already active.",
|
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 {
|
} finally {
|
||||||
setIsStarting(false);
|
setIsStarting(false);
|
||||||
@ -762,12 +781,7 @@ export default function PortfolioSection() {
|
|||||||
{isConnected ? (
|
{isConnected ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={handleRefreshBrokerData}
|
||||||
holdingsQuery.refetch();
|
|
||||||
fundsQuery.refetch();
|
|
||||||
equityCurveQuery.refetch();
|
|
||||||
refetchBrokerStatus();
|
|
||||||
}}
|
|
||||||
disabled={holdingsQuery.isFetching || equityCurveQuery.isFetching}
|
disabled={holdingsQuery.isFetching || equityCurveQuery.isFetching}
|
||||||
>
|
>
|
||||||
<RefreshCcw className="h-4 w-4" />
|
<RefreshCcw className="h-4 w-4" />
|
||||||
@ -776,6 +790,24 @@ export default function PortfolioSection() {
|
|||||||
: "Refresh data"}
|
: "Refresh data"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -887,109 +919,6 @@ export default function PortfolioSection() {
|
|||||||
)}
|
)}
|
||||||
</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
|
<div
|
||||||
className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl overflow-hidden ${hoverLift} ${revealTransition} ${cardRevealClass}`}
|
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"}
|
{isStopping ? "Stopping..." : "Stop Strategy"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{marketState === "CLOSED" ? (
|
<StrategyTimeline compact />
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StrategyTimeline />
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl p-6 space-y-4 ${hoverLift} ${revealTransition} ${cardRevealClass}`}
|
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" }}
|
style={prefersReducedMotion ? undefined : { transitionDelay: "700ms" }}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ const NORMALIZED_API_BASE_URL = API_BASE_URL
|
|||||||
: "";
|
: "";
|
||||||
const REQUEST_TIMEOUT_MS = 12000;
|
const REQUEST_TIMEOUT_MS = 12000;
|
||||||
|
|
||||||
function resolveApiUrl(url: string) {
|
export function resolveApiUrl(url: string) {
|
||||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user