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[]; holdings: any[];
}; };
type PositionsResponse = {
positions: any[];
};
type MarketStatusResponse = { type MarketStatusResponse = {
status?: "OPEN" | "CLOSED"; status?: "OPEN" | "CLOSED";
checked_at?: string; checked_at?: string;
@ -202,6 +206,49 @@ function getHoldingValue(item: any) {
return firstNumber(item?.holding_value, getEffectiveQuantity(item) * getLastPrice(item)); 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) { function getAvailableFundsValue(funds?: FundsResponse["funds"] | null) {
return firstNumber( return firstNumber(
funds?.available?.live_balance, funds?.available?.live_balance,
@ -251,6 +298,7 @@ export default function PortfolioSection() {
const [sessionExpired, setSessionExpired] = useState(false); const [sessionExpired, setSessionExpired] = useState(false);
const [reconnectAttempted, setReconnectAttempted] = useState(false); const [reconnectAttempted, setReconnectAttempted] = useState(false);
const [cachedHoldings, setCachedHoldings] = useState<any[]>([]); const [cachedHoldings, setCachedHoldings] = useState<any[]>([]);
const [cachedPositions, setCachedPositions] = 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 { const {
@ -341,9 +389,11 @@ export default function PortfolioSection() {
setSessionExpired(false); setSessionExpired(false);
setReconnectAttempted(false); setReconnectAttempted(false);
setCachedHoldings([]); setCachedHoldings([]);
setCachedPositions([]);
setCachedFunds(null); setCachedFunds(null);
setCachedEquityCurve(null); setCachedEquityCurve(null);
queryClient.removeQueries({ queryKey: ["/broker/holdings"] }); queryClient.removeQueries({ queryKey: ["/broker/holdings"] });
queryClient.removeQueries({ queryKey: ["/broker/positions"] });
queryClient.removeQueries({ queryKey: ["/broker/funds"] }); queryClient.removeQueries({ queryKey: ["/broker/funds"] });
queryClient.removeQueries({ queryKey: ["/broker/equity-curve"] }); queryClient.removeQueries({ queryKey: ["/broker/equity-curve"] });
await refetchBrokerStatus(); 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( const refreshBrokerData = useCallback(
async ({ includeEquityCurve = false }: { includeEquityCurve?: boolean } = {}) => { async ({ includeEquityCurve = false }: { includeEquityCurve?: boolean } = {}) => {
if (!brokerStatus?.connected) { if (!brokerStatus?.connected) {
@ -558,6 +629,7 @@ export default function PortfolioSection() {
} }
const tasks = [ const tasks = [
holdingsQuery.refetch(), holdingsQuery.refetch(),
positionsQuery.refetch(),
fundsQuery.refetch(), fundsQuery.refetch(),
refetchBrokerStatus(), refetchBrokerStatus(),
]; ];
@ -569,6 +641,7 @@ export default function PortfolioSection() {
[ [
brokerStatus?.connected, brokerStatus?.connected,
holdingsQuery.refetch, holdingsQuery.refetch,
positionsQuery.refetch,
fundsQuery.refetch, fundsQuery.refetch,
equityCurveQuery.refetch, equityCurveQuery.refetch,
refetchBrokerStatus, refetchBrokerStatus,
@ -578,8 +651,10 @@ export default function PortfolioSection() {
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;
const positions = positionsQuery.data ? positionsQuery.data.positions : cachedPositions;
const fundsSnapshot = fundsQuery.data?.funds ?? cachedFunds; const fundsSnapshot = fundsQuery.data?.funds ?? cachedFunds;
const noHoldings = holdings.length === 0; const noHoldings = holdings.length === 0;
const noPositions = positions.length === 0;
useEffect(() => { useEffect(() => {
if (!isConnected) { if (!isConnected) {
setSessionExpired(false); setSessionExpired(false);
@ -635,7 +710,7 @@ export default function PortfolioSection() {
const availableFunds = getAvailableFundsValue(fundsSnapshot); const availableFunds = getAvailableFundsValue(fundsSnapshot);
const { totalValue, totalPnl } = useMemo(() => { const { totalValue, totalPnl } = useMemo(() => {
return holdings.reduce( return [...holdings, ...positions].reduce(
(acc, item) => { (acc, item) => {
return { return {
totalValue: acc.totalValue + getHoldingValue(item), totalValue: acc.totalValue + getHoldingValue(item),
@ -644,12 +719,16 @@ export default function PortfolioSection() {
}, },
{ totalValue: 0, totalPnl: 0 }, { totalValue: 0, totalPnl: 0 },
); );
}, [holdings]); }, [holdings, positions]);
const activeHoldingCount = useMemo( const activeHoldingCount = useMemo(
() => holdings.filter((item) => getEffectiveQuantity(item) > 0).length, () => holdings.filter((item) => getEffectiveQuantity(item) > 0).length,
[holdings], [holdings],
); );
const activePositionCount = useMemo(
() => positions.filter((item) => getEffectiveQuantity(item) !== 0).length,
[positions],
);
const equityCurve = equityCurveQuery.data ?? cachedEquityCurve; const equityCurve = equityCurveQuery.data ?? cachedEquityCurve;
const equityCurvePoints = equityCurve?.points ?? []; const equityCurvePoints = equityCurve?.points ?? [];
@ -1003,11 +1082,15 @@ export default function PortfolioSection() {
label: "Portfolio value", label: "Portfolio value",
value: formatCurrency(totalValue, { decimals: 2 }), value: formatCurrency(totalValue, { decimals: 2 }),
muted: !isConnected, muted: !isConnected,
subText:
isConnected && (activeHoldingCount > 0 || activePositionCount > 0)
? `Holdings: ${activeHoldingCount} | Positions: ${activePositionCount}`
: undefined,
}, },
{ {
icon: <BarChart3 className="h-5 w-5" />, icon: <BarChart3 className="h-5 w-5" />,
label: "Positions", label: "Positions",
value: activeHoldingCount.toString(), value: activePositionCount.toString(),
muted: !isConnected, muted: !isConnected,
}, },
{ {
@ -1127,11 +1210,11 @@ export default function PortfolioSection() {
<Button <Button
variant="outline" variant="outline"
onClick={handleRefreshBrokerData} onClick={handleRefreshBrokerData}
disabled={holdingsQuery.isFetching || equityCurveQuery.isFetching} disabled={holdingsQuery.isFetching || positionsQuery.isFetching || equityCurveQuery.isFetching}
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
<RefreshCcw className="h-4 w-4" /> <RefreshCcw className="h-4 w-4" />
{holdingsQuery.isFetching || equityCurveQuery.isFetching {holdingsQuery.isFetching || positionsQuery.isFetching || equityCurveQuery.isFetching
? "Refreshing..." ? "Refreshing..."
: "Refresh data"} : "Refresh data"}
</Button> </Button>
@ -1188,7 +1271,7 @@ export default function PortfolioSection() {
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-semibold">Holdings</p> <p className="text-sm font-semibold">Holdings</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Current positions pulled from your connected broker. Settled holdings synced from your connected broker.
</p> </p>
</div> </div>
<Badge variant={isConnected ? "secondary" : "outline"}> <Badge variant={isConnected ? "secondary" : "outline"}>
@ -1235,41 +1318,61 @@ export default function PortfolioSection() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border/60"> <tbody className="divide-y divide-border/60">
{holdings.map((item, idx) => { {renderPortfolioRows(holdings)}
const qty = getEffectiveQuantity(item); </tbody>
const settledQty = getSettledQuantity(item); </table>
const t1Qty = getT1Quantity(item); </div>
const avg = getAveragePrice(item); )}
const ltp = getLastPrice(item); </div>
const pnl = getDisplayPnl(item);
return ( <div
<tr key={`${item.tradingsymbol || item.instrument_token || idx}`}> className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl overflow-hidden ${hoverLift} ${revealTransition} ${cardRevealClass}`}
<td className="px-4 py-3 sm:px-6"> style={prefersReducedMotion ? undefined : { transitionDelay: "550ms" }}
<div className="flex items-center gap-2"> >
<span className="font-semibold"> <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">
{item.tradingsymbol || item.symbol || "Instrument"} <div className="space-y-1">
</span> <p className="text-sm font-semibold">Live positions</p>
<Badge variant="outline">{item.exchange || item.exchange_type || "N/A"}</Badge> <p className="text-xs text-muted-foreground">
</div> Same-day broker positions appear here before they move into holdings.
</td> </p>
<td className="px-4 py-3 sm:px-6"> </div>
<div className="flex flex-col"> <Badge variant={isConnected ? "secondary" : "outline"}>
<span>{qty}</span> {activePositionCount} open
{t1Qty > 0 && settledQty <= 0 ? ( </Badge>
<span className="text-xs text-amber-300">T1 pending</span> </div>
) : null}
</div> {!isAuthed ? (
</td> <ZeroState message="Log in and connect your broker to see live positions." />
<td className="px-4 py-3 sm:px-6">{formatCurrency(avg, { decimals: 2 })}</td> ) : brokerStatusLoading ? (
<td className="px-4 py-3 sm:px-6">{ltp ? formatCurrency(ltp, { decimals: 2 }) : "-"}</td> <ZeroState message="Checking broker status..." />
<td className="px-4 py-3 sm:px-6"> ) : !isConnected ? (
<span className={pnl >= 0 ? "text-emerald-500" : "text-red-500"}> <ZeroState message="Connect to broker to see live positions." />
{formatCurrency(pnl, { decimals: 2 })} ) : positionsQuery.isLoading ? (
</span> <ZeroState message="Fetching positions..." />
</td> ) : positionsQuery.isError && positions.length === 0 ? (
</tr> <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> </tbody>
</table> </table>
</div> </div>