Show live broker positions in portfolio

This commit is contained in:
Thigazhezhilan J 2026-04-06 11:31:29 +05:30
parent c3686a6e4c
commit 4f17fb166d

View File

@ -47,6 +47,10 @@ type HoldingsResponse = {
holdings: any[];
};
type PositionsResponse = {
positions: any[];
};
type MarketStatusResponse = {
status?: "OPEN" | "CLOSED";
checked_at?: string;
@ -202,6 +206,49 @@ function getHoldingValue(item: any) {
return firstNumber(item?.holding_value, getEffectiveQuantity(item) * getLastPrice(item));
}
function getPortfolioItemKey(item: any, idx: number) {
return `${item.tradingsymbol || item.symbol || item.instrument_token || item.exchange || "item"}-${idx}`;
}
function renderPortfolioRows(items: any[]) {
return items.map((item, idx) => {
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={getPortfolioItemKey(item, idx)}>
<td className="px-4 py-3 sm:px-6">
<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>
{item.product ? <Badge variant="secondary">{item.product}</Badge> : null}
</div>
</td>
<td className="px-4 py-3 sm:px-6">
<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-4 py-3 sm:px-6">{formatCurrency(avg, { decimals: 2 })}</td>
<td className="px-4 py-3 sm:px-6">{ltp ? formatCurrency(ltp, { decimals: 2 }) : "-"}</td>
<td className="px-4 py-3 sm:px-6">
<span className={pnl >= 0 ? "text-emerald-500" : "text-red-500"}>
{formatCurrency(pnl, { decimals: 2 })}
</span>
</td>
</tr>
);
});
}
function getAvailableFundsValue(funds?: FundsResponse["funds"] | null) {
return firstNumber(
funds?.available?.live_balance,
@ -251,6 +298,7 @@ export default function PortfolioSection() {
const [sessionExpired, setSessionExpired] = useState(false);
const [reconnectAttempted, setReconnectAttempted] = useState(false);
const [cachedHoldings, setCachedHoldings] = useState<any[]>([]);
const [cachedPositions, setCachedPositions] = useState<any[]>([]);
const [cachedFunds, setCachedFunds] = useState<FundsResponse["funds"] | null>(null);
const [cachedEquityCurve, setCachedEquityCurve] = useState<EquityCurveResponse | null>(null);
const {
@ -341,9 +389,11 @@ export default function PortfolioSection() {
setSessionExpired(false);
setReconnectAttempted(false);
setCachedHoldings([]);
setCachedPositions([]);
setCachedFunds(null);
setCachedEquityCurve(null);
queryClient.removeQueries({ queryKey: ["/broker/holdings"] });
queryClient.removeQueries({ queryKey: ["/broker/positions"] });
queryClient.removeQueries({ queryKey: ["/broker/funds"] });
queryClient.removeQueries({ queryKey: ["/broker/equity-curve"] });
await refetchBrokerStatus();
@ -551,6 +601,27 @@ export default function PortfolioSection() {
},
});
const positionsQuery = useQuery<PositionsResponse>({
queryKey: ["/broker/positions"],
queryFn: async () => {
const res = await apiRequest("GET", "/broker/positions");
return res.json();
},
enabled: !!brokerStatus?.connected,
retry: 1,
retryDelay: 600,
onSuccess: (data) => {
setCachedPositions(data?.positions || []);
setSessionExpired(false);
setReconnectAttempted(false);
},
onError: () => {
if (brokerStatus?.connected) {
setSessionExpired(true);
}
},
});
const refreshBrokerData = useCallback(
async ({ includeEquityCurve = false }: { includeEquityCurve?: boolean } = {}) => {
if (!brokerStatus?.connected) {
@ -558,6 +629,7 @@ export default function PortfolioSection() {
}
const tasks = [
holdingsQuery.refetch(),
positionsQuery.refetch(),
fundsQuery.refetch(),
refetchBrokerStatus(),
];
@ -569,6 +641,7 @@ export default function PortfolioSection() {
[
brokerStatus?.connected,
holdingsQuery.refetch,
positionsQuery.refetch,
fundsQuery.refetch,
equityCurveQuery.refetch,
refetchBrokerStatus,
@ -578,8 +651,10 @@ export default function PortfolioSection() {
const isConnected = !!brokerStatus?.connected;
const isAuthed = brokerStatus !== null;
const holdings = holdingsQuery.data ? holdingsQuery.data.holdings : cachedHoldings;
const positions = positionsQuery.data ? positionsQuery.data.positions : cachedPositions;
const fundsSnapshot = fundsQuery.data?.funds ?? cachedFunds;
const noHoldings = holdings.length === 0;
const noPositions = positions.length === 0;
useEffect(() => {
if (!isConnected) {
setSessionExpired(false);
@ -635,7 +710,7 @@ export default function PortfolioSection() {
const availableFunds = getAvailableFundsValue(fundsSnapshot);
const { totalValue, totalPnl } = useMemo(() => {
return holdings.reduce(
return [...holdings, ...positions].reduce(
(acc, item) => {
return {
totalValue: acc.totalValue + getHoldingValue(item),
@ -644,12 +719,16 @@ export default function PortfolioSection() {
},
{ totalValue: 0, totalPnl: 0 },
);
}, [holdings]);
}, [holdings, positions]);
const activeHoldingCount = useMemo(
() => holdings.filter((item) => getEffectiveQuantity(item) > 0).length,
[holdings],
);
const activePositionCount = useMemo(
() => positions.filter((item) => getEffectiveQuantity(item) !== 0).length,
[positions],
);
const equityCurve = equityCurveQuery.data ?? cachedEquityCurve;
const equityCurvePoints = equityCurve?.points ?? [];
@ -1003,11 +1082,15 @@ export default function PortfolioSection() {
label: "Portfolio value",
value: formatCurrency(totalValue, { decimals: 2 }),
muted: !isConnected,
subText:
isConnected && (activeHoldingCount > 0 || activePositionCount > 0)
? `Holdings: ${activeHoldingCount} | Positions: ${activePositionCount}`
: undefined,
},
{
icon: <BarChart3 className="h-5 w-5" />,
label: "Positions",
value: activeHoldingCount.toString(),
value: activePositionCount.toString(),
muted: !isConnected,
},
{
@ -1127,11 +1210,11 @@ export default function PortfolioSection() {
<Button
variant="outline"
onClick={handleRefreshBrokerData}
disabled={holdingsQuery.isFetching || equityCurveQuery.isFetching}
disabled={holdingsQuery.isFetching || positionsQuery.isFetching || equityCurveQuery.isFetching}
className="w-full sm:w-auto"
>
<RefreshCcw className="h-4 w-4" />
{holdingsQuery.isFetching || equityCurveQuery.isFetching
{holdingsQuery.isFetching || positionsQuery.isFetching || equityCurveQuery.isFetching
? "Refreshing..."
: "Refresh data"}
</Button>
@ -1188,7 +1271,7 @@ export default function PortfolioSection() {
<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.
Settled holdings synced from your connected broker.
</p>
</div>
<Badge variant={isConnected ? "secondary" : "outline"}>
@ -1235,41 +1318,61 @@ export default function PortfolioSection() {
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{holdings.map((item, idx) => {
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-4 py-3 sm:px-6">
<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>
{renderPortfolioRows(holdings)}
</tbody>
</table>
</div>
</td>
<td className="px-4 py-3 sm:px-6">
<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-4 py-3 sm:px-6">{formatCurrency(avg, { decimals: 2 })}</td>
<td className="px-4 py-3 sm:px-6">{ltp ? formatCurrency(ltp, { decimals: 2 }) : "-"}</td>
<td className="px-4 py-3 sm:px-6">
<span className={pnl >= 0 ? "text-emerald-500" : "text-red-500"}>
{formatCurrency(pnl, { decimals: 2 })}
</span>
</td>
<div
className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl overflow-hidden ${hoverLift} ${revealTransition} ${cardRevealClass}`}
style={prefersReducedMotion ? undefined : { transitionDelay: "550ms" }}
>
<div className="flex flex-col gap-3 border-b border-border/50 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<div className="space-y-1">
<p className="text-sm font-semibold">Live positions</p>
<p className="text-xs text-muted-foreground">
Same-day broker positions appear here before they move into holdings.
</p>
</div>
<Badge variant={isConnected ? "secondary" : "outline"}>
{activePositionCount} open
</Badge>
</div>
{!isAuthed ? (
<ZeroState message="Log in and connect your broker to see live positions." />
) : brokerStatusLoading ? (
<ZeroState message="Checking broker status..." />
) : !isConnected ? (
<ZeroState message="Connect to broker to see live positions." />
) : positionsQuery.isLoading ? (
<ZeroState message="Fetching positions..." />
) : positionsQuery.isError && positions.length === 0 ? (
<ZeroState
message={
showSessionExpired
? "Session expired. Reconnect to refresh positions."
: "Could not fetch positions. Try refreshing."
}
/>
) : noPositions ? (
<ZeroState message="No live positions yet. Same-day buys and sells will appear here." />
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="px-4 py-3 text-left font-medium sm:px-6">Symbol</th>
<th className="px-4 py-3 text-left font-medium sm:px-6">Qty</th>
<th className="px-4 py-3 text-left font-medium sm:px-6">Avg price</th>
<th className="px-4 py-3 text-left font-medium sm:px-6">LTP</th>
<th className="px-4 py-3 text-left font-medium sm:px-6">P&L</th>
</tr>
);
})}
</thead>
<tbody className="divide-y divide-border/60">
{renderPortfolioRows(positions)}
</tbody>
</table>
</div>