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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import StrategyTimeline from "@/components/StrategyTimeline";
import { toast } from "@/hooks/use-toast";
import {
@ -52,13 +59,19 @@ type FundsResponse = {
withdrawable?: number;
utilized?: number;
balance?: number;
available?: {
live_balance?: number;
cash?: number;
opening_balance?: number;
};
raw?: any;
};
};
type EquityCurveResponse = {
startDate: string;
endDate: string;
accountOpenDate?: string;
exactFrom?: string | null;
points: { date: string; value: number }[];
};
@ -73,6 +86,7 @@ type EngineStatus = {
type SessionUser = Pick<User, "id" | "username">;
const MotionButton = motion(Button);
const BROKER_DATA_REFRESH_MS = 30_000;
function formatCurrency(amount: number, options?: { decimals?: number }) {
const decimals = options?.decimals ?? 2;
@ -113,6 +127,65 @@ function formatRelativeSeconds(seconds: number) {
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() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
@ -266,27 +339,14 @@ export default function PortfolioSection() {
formatDateInput(new Date(Date.now() - 90 * 24 * 60 * 60 * 1000)),
);
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 [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();
@ -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 isAuthed = brokerStatus !== null;
const holdings = holdingsQuery.data ? holdingsQuery.data.holdings : cachedHoldings;
@ -425,12 +509,7 @@ export default function PortfolioSection() {
setReconnectAttempted(true);
(async () => {
try {
await refetchBrokerStatus();
await Promise.all([
holdingsQuery.refetch(),
fundsQuery.refetch(),
equityCurveQuery.refetch(),
]);
await refreshBrokerData({ includeEquityCurve: true });
} catch {
return;
}
@ -439,36 +518,54 @@ export default function PortfolioSection() {
sessionExpired,
reconnectAttempted,
isConnected,
refetchBrokerStatus,
holdingsQuery,
fundsQuery,
equityCurveQuery,
refreshBrokerData,
]);
const availableFunds =
fundsSnapshot?.balance ??
fundsSnapshot?.net ??
fundsSnapshot?.withdrawable ??
fundsSnapshot?.cash ??
fundsSnapshot?.raw?.net ??
fundsSnapshot?.raw?.available?.live_balance ??
fundsSnapshot?.raw?.available?.opening_balance ??
0;
useEffect(() => {
if (!isConnected) {
return;
}
const refreshIfVisible = () => {
if (document.visibilityState !== "visible") {
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(() => {
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: acc.totalValue + getHoldingValue(item),
totalPnl: acc.totalPnl + getDisplayPnl(item),
};
},
{ totalValue: 0, totalPnl: 0 },
);
}, [holdings]);
const activeHoldingCount = useMemo(
() => holdings.filter((item) => getEffectiveQuantity(item) > 0).length,
[holdings],
);
const equityCurve = equityCurveQuery.data ?? cachedEquityCurve;
const equityCurvePoints = equityCurve?.points ?? [];
const showSessionExpired = sessionExpired && isConnected;
@ -569,11 +666,8 @@ export default function PortfolioSection() {
}, [brokerStatus, requireLogin]);
const handleRefreshBrokerData = useCallback(() => {
holdingsQuery.refetch();
fundsQuery.refetch();
equityCurveQuery.refetch();
refetchBrokerStatus();
}, [equityCurveQuery, fundsQuery, holdingsQuery, refetchBrokerStatus]);
void refreshBrokerData({ includeEquityCurve: true });
}, [refreshBrokerData]);
const handleDisconnectBroker = useCallback(() => {
disconnectBrokerMutation.mutate();
@ -601,8 +695,8 @@ export default function PortfolioSection() {
strategy_name: "Golden Nifty",
sip_amount: sipAmount,
sip_frequency: {
value: frequencyDays,
unit: "days",
value: frequencyValue,
unit: frequencyUnit,
},
mode: "LIVE",
});
@ -632,7 +726,10 @@ export default function PortfolioSection() {
}
} finally {
setIsStarting(false);
await refreshStatus();
await Promise.allSettled([
refreshStatus(),
refreshBrokerData({ includeEquityCurve: true }),
]);
}
};
@ -642,7 +739,10 @@ export default function PortfolioSection() {
await stopStrategy();
} finally {
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" />,
label: "Positions",
value: holdings.length.toString(),
value: activeHoldingCount.toString(),
muted: !isConnected,
},
{
@ -888,10 +988,12 @@ export default function PortfolioSection() {
</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);
const qty = getEffectiveQuantity(item);
const settledQty = getSettledQuantity(item);
const t1Qty = getT1Quantity(item);
const avg = getAveragePrice(item);
const ltp = getLastPrice(item);
const pnl = getDisplayPnl(item);
return (
<tr key={`${item.tradingsymbol || item.instrument_token || idx}`}>
<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>
</div>
</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">{ltp ? formatCurrency(ltp, { decimals: 2 }) : "-"}</td>
<td className="px-6 py-3">
@ -972,21 +1081,43 @@ export default function PortfolioSection() {
}}
/>
</div>
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-2">
<Label htmlFor="strategy-frequency">Frequency (days)</Label>
<Label htmlFor="strategy-frequency">Frequency</Label>
<Input
id="strategy-frequency"
type="number"
min={1}
step={1}
value={frequencyDays}
value={frequencyValue}
onChange={(event) => {
const value = Number(event.target.value);
setFrequencyDays(Number.isNaN(value) ? 1 : value);
setFrequencyValue(Number.isNaN(value) ? 1 : value);
}}
/>
</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>
{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">
<MotionButton
{...ctaMotionProps}
@ -1012,7 +1143,7 @@ export default function PortfolioSection() {
<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.
Track exact stored daily broker snapshots from the day recording began.
</p>
</div>
<div className="flex items-center gap-2">
@ -1024,7 +1155,6 @@ export default function PortfolioSection() {
type="date"
value={startDate}
max={formatDateInput(new Date())}
min={linkedAtInput}
onChange={(e) => setStartDate(e.target.value)}
className="w-[180px]"
disabled={!isConnected}
@ -1039,7 +1169,7 @@ export default function PortfolioSection() {
) : equityCurveQuery.isError && equityCurvePoints.length === 0 ? (
<ZeroState message="Could not load equity curve." />
) : 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">
<ChartContainer
@ -1088,9 +1218,15 @@ export default function PortfolioSection() {
</AreaChart>
</ChartContainer>
<p className="text-xs text-muted-foreground mt-2">
Account open date: {equityCurve?.accountOpenDate
? new Date(equityCurve.accountOpenDate).toLocaleDateString("en-IN")
: "unknown"}
Exact daily values from{" "}
{equityCurve?.exactFrom
? new Date(equityCurve.exactFrom).toLocaleDateString("en-IN", {
year: "numeric",
month: "short",
day: "numeric",
})
: "the first recorded snapshot"}
.
</p>
</div>
)}