Show live broker positions in portfolio
This commit is contained in:
parent
c3686a6e4c
commit
4f17fb166d
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user