Add live testing controls and portfolio refresh fixes

This commit is contained in:
Thigazhezhilan J 2026-03-25 23:30:09 +05:30
parent aa93225ef7
commit 2097489053

View File

@ -11,6 +11,13 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import StrategyTimeline from "@/components/StrategyTimeline"; import StrategyTimeline from "@/components/StrategyTimeline";
import { toast } from "@/hooks/use-toast"; import { toast } from "@/hooks/use-toast";
import { import {
@ -52,13 +59,19 @@ type FundsResponse = {
withdrawable?: number; withdrawable?: number;
utilized?: number; utilized?: number;
balance?: number; balance?: number;
available?: {
live_balance?: number;
cash?: number;
opening_balance?: number;
};
raw?: any;
}; };
}; };
type EquityCurveResponse = { type EquityCurveResponse = {
startDate: string; startDate: string;
endDate: string; endDate: string;
accountOpenDate?: string; exactFrom?: string | null;
points: { date: string; value: number }[]; points: { date: string; value: number }[];
}; };
@ -73,6 +86,7 @@ type EngineStatus = {
type SessionUser = Pick<User, "id" | "username">; type SessionUser = Pick<User, "id" | "username">;
const MotionButton = motion(Button); const MotionButton = motion(Button);
const BROKER_DATA_REFRESH_MS = 30_000;
function formatCurrency(amount: number, options?: { decimals?: number }) { function formatCurrency(amount: number, options?: { decimals?: number }) {
const decimals = options?.decimals ?? 2; const decimals = options?.decimals ?? 2;
@ -113,6 +127,65 @@ function formatRelativeSeconds(seconds: number) {
return `in ${parts.join(" ")}`; return `in ${parts.join(" ")}`;
} }
function firstNumber(...values: unknown[]) {
for (const value of values) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return 0;
}
function getSettledQuantity(item: any) {
return firstNumber(item?.settled_quantity, item?.quantity, item?.qty);
}
function getT1Quantity(item: any) {
return firstNumber(item?.t1_quantity);
}
function getEffectiveQuantity(item: any) {
return firstNumber(item?.effective_quantity, getSettledQuantity(item) + getT1Quantity(item));
}
function getAveragePrice(item: any) {
return firstNumber(item?.average_price, item?.avg_price);
}
function getLastPrice(item: any) {
return firstNumber(item?.last_price, item?.close_price, getAveragePrice(item));
}
function getDisplayPnl(item: any) {
return firstNumber(
item?.display_pnl,
getEffectiveQuantity(item) * (getLastPrice(item) - getAveragePrice(item)),
);
}
function getHoldingValue(item: any) {
return firstNumber(item?.holding_value, getEffectiveQuantity(item) * getLastPrice(item));
}
function getAvailableFundsValue(funds?: FundsResponse["funds"] | null) {
return firstNumber(
funds?.available?.live_balance,
funds?.raw?.equity?.available?.live_balance,
funds?.raw?.equity?.available?.cash,
funds?.raw?.available?.live_balance,
funds?.balance,
funds?.cash,
funds?.withdrawable,
funds?.net,
funds?.raw?.equity?.net,
funds?.raw?.net,
funds?.available?.opening_balance,
funds?.raw?.equity?.available?.opening_balance,
funds?.raw?.available?.opening_balance,
);
}
function usePrefersReducedMotion() { function usePrefersReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
@ -266,27 +339,14 @@ export default function PortfolioSection() {
formatDateInput(new Date(Date.now() - 90 * 24 * 60 * 60 * 1000)), formatDateInput(new Date(Date.now() - 90 * 24 * 60 * 60 * 1000)),
); );
const [sipAmount, setSipAmount] = useState(5000); const [sipAmount, setSipAmount] = useState(5000);
const [frequencyDays, setFrequencyDays] = useState(30); const [frequencyValue, setFrequencyValue] = useState(30);
const [frequencyUnit, setFrequencyUnit] = useState<"days" | "minutes">("days");
const [strategyStatus, setStrategyStatus] = useState("STOPPED"); const [strategyStatus, setStrategyStatus] = useState("STOPPED");
const [isStarting, setIsStarting] = useState(false); const [isStarting, setIsStarting] = useState(false);
const [isStopping, setIsStopping] = useState(false); const [isStopping, setIsStopping] = useState(false);
const [engineStatus, setEngineStatus] = useState<EngineStatus | null>(null); const [engineStatus, setEngineStatus] = useState<EngineStatus | null>(null);
const [marketStatus, setMarketStatus] = useState<MarketStatusResponse | null>(null); const [marketStatus, setMarketStatus] = useState<MarketStatusResponse | null>(null);
const 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 () => { const refreshStatus = useCallback(async () => {
try { try {
const status = await getStrategyStatus(); const status = await getStrategyStatus();
@ -406,6 +466,30 @@ export default function PortfolioSection() {
}, },
}); });
const refreshBrokerData = useCallback(
async ({ includeEquityCurve = false }: { includeEquityCurve?: boolean } = {}) => {
if (!brokerStatus?.connected) {
return;
}
const tasks = [
holdingsQuery.refetch(),
fundsQuery.refetch(),
refetchBrokerStatus(),
];
if (includeEquityCurve) {
tasks.push(equityCurveQuery.refetch());
}
await Promise.allSettled(tasks);
},
[
brokerStatus?.connected,
holdingsQuery.refetch,
fundsQuery.refetch,
equityCurveQuery.refetch,
refetchBrokerStatus,
],
);
const isConnected = !!brokerStatus?.connected; const isConnected = !!brokerStatus?.connected;
const isAuthed = brokerStatus !== null; const isAuthed = brokerStatus !== null;
const holdings = holdingsQuery.data ? holdingsQuery.data.holdings : cachedHoldings; const holdings = holdingsQuery.data ? holdingsQuery.data.holdings : cachedHoldings;
@ -425,12 +509,7 @@ export default function PortfolioSection() {
setReconnectAttempted(true); setReconnectAttempted(true);
(async () => { (async () => {
try { try {
await refetchBrokerStatus(); await refreshBrokerData({ includeEquityCurve: true });
await Promise.all([
holdingsQuery.refetch(),
fundsQuery.refetch(),
equityCurveQuery.refetch(),
]);
} catch { } catch {
return; return;
} }
@ -439,36 +518,54 @@ export default function PortfolioSection() {
sessionExpired, sessionExpired,
reconnectAttempted, reconnectAttempted,
isConnected, isConnected,
refetchBrokerStatus, refreshBrokerData,
holdingsQuery,
fundsQuery,
equityCurveQuery,
]); ]);
const availableFunds =
fundsSnapshot?.balance ?? useEffect(() => {
fundsSnapshot?.net ?? if (!isConnected) {
fundsSnapshot?.withdrawable ?? return;
fundsSnapshot?.cash ?? }
fundsSnapshot?.raw?.net ??
fundsSnapshot?.raw?.available?.live_balance ?? const refreshIfVisible = () => {
fundsSnapshot?.raw?.available?.opening_balance ?? if (document.visibilityState !== "visible") {
0; return;
}
void refreshBrokerData();
};
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
void refreshBrokerData();
}
};
const intervalId = window.setInterval(refreshIfVisible, BROKER_DATA_REFRESH_MS);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.clearInterval(intervalId);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [isConnected, refreshBrokerData]);
const availableFunds = getAvailableFundsValue(fundsSnapshot);
const { totalValue, totalPnl } = useMemo(() => { const { totalValue, totalPnl } = useMemo(() => {
return holdings.reduce( return holdings.reduce(
(acc, item) => { (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 { return {
totalValue: acc.totalValue + qty * last, totalValue: acc.totalValue + getHoldingValue(item),
totalPnl: acc.totalPnl + pnl, totalPnl: acc.totalPnl + getDisplayPnl(item),
}; };
}, },
{ totalValue: 0, totalPnl: 0 }, { totalValue: 0, totalPnl: 0 },
); );
}, [holdings]); }, [holdings]);
const activeHoldingCount = useMemo(
() => holdings.filter((item) => getEffectiveQuantity(item) > 0).length,
[holdings],
);
const equityCurve = equityCurveQuery.data ?? cachedEquityCurve; const equityCurve = equityCurveQuery.data ?? cachedEquityCurve;
const equityCurvePoints = equityCurve?.points ?? []; const equityCurvePoints = equityCurve?.points ?? [];
const showSessionExpired = sessionExpired && isConnected; const showSessionExpired = sessionExpired && isConnected;
@ -569,11 +666,8 @@ export default function PortfolioSection() {
}, [brokerStatus, requireLogin]); }, [brokerStatus, requireLogin]);
const handleRefreshBrokerData = useCallback(() => { const handleRefreshBrokerData = useCallback(() => {
holdingsQuery.refetch(); void refreshBrokerData({ includeEquityCurve: true });
fundsQuery.refetch(); }, [refreshBrokerData]);
equityCurveQuery.refetch();
refetchBrokerStatus();
}, [equityCurveQuery, fundsQuery, holdingsQuery, refetchBrokerStatus]);
const handleDisconnectBroker = useCallback(() => { const handleDisconnectBroker = useCallback(() => {
disconnectBrokerMutation.mutate(); disconnectBrokerMutation.mutate();
@ -601,8 +695,8 @@ export default function PortfolioSection() {
strategy_name: "Golden Nifty", strategy_name: "Golden Nifty",
sip_amount: sipAmount, sip_amount: sipAmount,
sip_frequency: { sip_frequency: {
value: frequencyDays, value: frequencyValue,
unit: "days", unit: frequencyUnit,
}, },
mode: "LIVE", mode: "LIVE",
}); });
@ -632,7 +726,10 @@ export default function PortfolioSection() {
} }
} finally { } finally {
setIsStarting(false); setIsStarting(false);
await refreshStatus(); await Promise.allSettled([
refreshStatus(),
refreshBrokerData({ includeEquityCurve: true }),
]);
} }
}; };
@ -642,7 +739,10 @@ export default function PortfolioSection() {
await stopStrategy(); await stopStrategy();
} finally { } finally {
setIsStopping(false); setIsStopping(false);
await refreshStatus(); await Promise.allSettled([
refreshStatus(),
refreshBrokerData({ includeEquityCurve: true }),
]);
} }
}; };
@ -666,7 +766,7 @@ export default function PortfolioSection() {
{ {
icon: <BarChart3 className="h-5 w-5" />, icon: <BarChart3 className="h-5 w-5" />,
label: "Positions", label: "Positions",
value: holdings.length.toString(), value: activeHoldingCount.toString(),
muted: !isConnected, muted: !isConnected,
}, },
{ {
@ -888,10 +988,12 @@ export default function PortfolioSection() {
</thead> </thead>
<tbody className="divide-y divide-border/60"> <tbody className="divide-y divide-border/60">
{holdings.map((item, idx) => { {holdings.map((item, idx) => {
const qty = Number(item.quantity ?? item.qty ?? 0); const qty = getEffectiveQuantity(item);
const avg = Number(item.average_price ?? item.avg_price ?? 0); const settledQty = getSettledQuantity(item);
const ltp = Number(item.last_price ?? 0); const t1Qty = getT1Quantity(item);
const pnl = Number(item.pnl ?? 0); const avg = getAveragePrice(item);
const ltp = getLastPrice(item);
const pnl = getDisplayPnl(item);
return ( return (
<tr key={`${item.tradingsymbol || item.instrument_token || idx}`}> <tr key={`${item.tradingsymbol || item.instrument_token || idx}`}>
<td className="px-6 py-3"> <td className="px-6 py-3">
@ -902,7 +1004,14 @@ export default function PortfolioSection() {
<Badge variant="outline">{item.exchange || item.exchange_type || "N/A"}</Badge> <Badge variant="outline">{item.exchange || item.exchange_type || "N/A"}</Badge>
</div> </div>
</td> </td>
<td className="px-6 py-3">{qty}</td> <td className="px-6 py-3">
<div className="flex flex-col">
<span>{qty}</span>
{t1Qty > 0 && settledQty <= 0 ? (
<span className="text-xs text-amber-300">T1 pending</span>
) : null}
</div>
</td>
<td className="px-6 py-3">{formatCurrency(avg, { decimals: 2 })}</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">{ltp ? formatCurrency(ltp, { decimals: 2 }) : "-"}</td>
<td className="px-6 py-3"> <td className="px-6 py-3">
@ -972,21 +1081,43 @@ export default function PortfolioSection() {
}} }}
/> />
</div> </div>
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="strategy-frequency">Frequency (days)</Label> <Label htmlFor="strategy-frequency">Frequency</Label>
<Input <Input
id="strategy-frequency" id="strategy-frequency"
type="number" type="number"
min={1} min={1}
step={1} step={1}
value={frequencyDays} value={frequencyValue}
onChange={(event) => { onChange={(event) => {
const value = Number(event.target.value); const value = Number(event.target.value);
setFrequencyDays(Number.isNaN(value) ? 1 : value); setFrequencyValue(Number.isNaN(value) ? 1 : value);
}} }}
/> />
</div> </div>
<div className="space-y-2">
<Label>Unit</Label>
<Select
value={frequencyUnit}
onValueChange={(value) => setFrequencyUnit(value as "days" | "minutes")}
>
<SelectTrigger>
<SelectValue placeholder="Select unit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="days">Days</SelectItem>
<SelectItem value="minutes">Minutes (testing)</SelectItem>
</SelectContent>
</Select>
</div> </div>
</div>
</div>
{frequencyUnit === "minutes" ? (
<div className="text-xs text-amber-300">
Minutes mode is for live testing only. The engine checks every few seconds in this mode.
</div>
) : null}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<MotionButton <MotionButton
{...ctaMotionProps} {...ctaMotionProps}
@ -1012,7 +1143,7 @@ export default function PortfolioSection() {
<div> <div>
<p className="text-sm font-semibold">Equity curve</p> <p className="text-sm font-semibold">Equity curve</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Track your account value over time from your demat open date. Track exact stored daily broker snapshots from the day recording began.
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -1024,7 +1155,6 @@ export default function PortfolioSection() {
type="date" type="date"
value={startDate} value={startDate}
max={formatDateInput(new Date())} max={formatDateInput(new Date())}
min={linkedAtInput}
onChange={(e) => setStartDate(e.target.value)} onChange={(e) => setStartDate(e.target.value)}
className="w-[180px]" className="w-[180px]"
disabled={!isConnected} disabled={!isConnected}
@ -1039,7 +1169,7 @@ export default function PortfolioSection() {
) : equityCurveQuery.isError && equityCurvePoints.length === 0 ? ( ) : equityCurveQuery.isError && equityCurvePoints.length === 0 ? (
<ZeroState message="Could not load equity curve." /> <ZeroState message="Could not load equity curve." />
) : equityCurvePoints.length === 0 ? ( ) : equityCurvePoints.length === 0 ? (
<ZeroState message="Could not load equity curve." /> <ZeroState message="No exact equity snapshots yet. The curve starts from the first recorded daily snapshot." />
) : ( ) : (
<div className="h-80"> <div className="h-80">
<ChartContainer <ChartContainer
@ -1088,9 +1218,15 @@ export default function PortfolioSection() {
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">
Account open date: {equityCurve?.accountOpenDate Exact daily values from{" "}
? new Date(equityCurve.accountOpenDate).toLocaleDateString("en-IN") {equityCurve?.exactFrom
: "unknown"} ? new Date(equityCurve.exactFrom).toLocaleDateString("en-IN", {
year: "numeric",
month: "short",
day: "numeric",
})
: "the first recorded snapshot"}
.
</p> </p>
</div> </div>
)} )}